Skip to main content

App Shell

How the authenticated web UI is structured: the application shell, navigation, state ownership, and the design system. This is the implementation reference for building screens; see frontend architecture for the conceptual model.

Everything below lives under web/src.

Where it lives

PathRole
app/(app)/layout.tsxServer session guard → renders <AppShell>
app/(app)/<module>/page.tsxRoute-level module pages (placeholders for now)
app/page.tsxPublic sign-in gate; redirects to /dashboard when authenticated
shared/components/Shell: sidebar, top-bar, context-switcher, breadcrumbs, user-menu, Utility Panel, app-footer, app-shell
shared/ui/shadcn/ui primitives (button, card, dropdown-menu, sheet, …)
shared/config.tsAPP_NAME, APP_VERSION — single source for branding
shared/nav.tsNav groups, module list, permission gating helpers
providers/query-provider, school-context, layout-context
api/BFF client (client.ts), /me hook + types

Layout regions

┌───────────┬─────────────────────────────────────────────┐
│ Sidebar │ Top bar (switcher · breadcrumb · actions) │
│ (brand, ├─────────────────────────────────────────────┤
│ grouped │ │ ← Right flyout
│ nav, │ Content (page) │ (Quick actions)
│ settings)│ │
│ ├─────────────────────────────────────────────┤
│ │ Footer (version · context · API status) │
└───────────┴─────────────────────────────────────────────┘

The sidebar collapses to icons via the top-bar toggle. The right flyout is a slide-over (sheet) driven by layout-context.

The app has two contexts, chosen with the top-bar context switcher. The sidebar renders the nav for the active context only. Both are defined in shared/nav.ts.

Platform context — gated by the caller's platformPermissions:

GroupItemsPermission
OverviewDashboard
PlatformSchools · Subscriptions · Platform Settingsschools.read · subscriptions.read · platform_configuration.manage

School context — gated by the active school's activeContext permissions:

GroupItemsPermission
OverviewDashboard
PeopleStudents · Admissions · Staff · Membersstudents.read · admissions.read · staff.read · school_memberships.read
AcademicsAttendance · Assessmentsattendance.read · assessments.read
FinanceFeesfees.read
InsightsReports
SetupAcademic Years · Academic Structureacademic_years.read · classes.read
(pinned)Settingsschool_settings.read

Gating rule (canSee, strict): an item shows only when it needs no permission or the active permission set grants it. Dashboard and Reports carry no permission, so the shell is never empty.

Personas fall out of gating — e.g. a teacher sees Students, Attendance, Assessments, Academic Structure; a principal adds Admissions, Staff, Fees, Academic Years; a school admin adds Members + manage rights. Scope (school vs assigned-sections vs own-children) is a data-layer concern on each page, not a nav difference — principal and teacher see the same items.

State ownership

Follows the two-store rule from the architecture doc — never mix them.

StoreOwnsHere
TanStack QueryServer stateuseMe() (identity, memberships, permissions)
React ContextApplication stateschool-context (active context + school), layout-context (sidebar + panel)

Business data must not be copied from Query into Context.

Context & switching

SchoolProvider exposes a contextKind of platform or school and calls useMe(tenantCode):

  • The context switcher lists Platform administration (when the caller has platformPermissions) plus each school membership.
  • In school context the selected tenantCode is sent as x-tenant-code; the API resolves it to activeContext (roles + permissions).
  • In platform context no tenant is sent; the nav is gated by platformPermissions.
  • Selection persists in localStorage; a hybrid user (platform admin and school member) toggles cleanly between the two worlds.
const { contextKind, permissions, platformPermissions, selectSchool, selectPlatform } =
useSchool();

Utility Panel

The right flyout is a tabbed slide-over. See user preferences for its interaction model, preference persistence, and theme behavior.

TabContent
Quick actionsGrouped shortcuts (permission-trimmed; disabled until modules land)
NotificationsEmpty state for now
PreferencesPersonal UI preferences; appearance theme first

State (open, tab) lives in layout-context. Top-bar actions open their designated tabs; User menu → Preferences opens the Preferences tab. School and platform settings remain dedicated routes in their respective contexts.

Design system

  • Tailwind CSS v4 (CSS-first, no tailwind.config) + shadcn/ui primitives copied into shared/ui.
  • Tokens are CSS variables in app/global.css: slate neutrals, a blue primary, and a .dark block — dark mode is token-ready (no toggle yet).
  • Use semantic classes (bg-background, text-muted-foreground, bg-sidebar), never raw palette values, so re-theming is one file.

Branding

APP_NAME / APP_VERSION in shared/config.ts are the only place the product name appears. Change there to rebrand everywhere.

Data wiring

UI never calls the API or reads tokens directly. All calls go through the same-origin BFF via bffFetch, which the proxy forwards with the access token attached server-side. Paths are relative to the API base (which already includes /api), so pass /me, not /api/me.

await bffFetch<MeResponse>('/me', { tenantCode });

Adding a module

  1. Add the item (and its .read permission) to shared/nav.ts.
  2. Create app/(app)/<module>/page.tsx.
  3. Build screens with shared/ui primitives; fetch via a TanStack Query hook in api/ that calls bffFetch.