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
| Path | Role |
|---|---|
app/(app)/layout.tsx | Server session guard → renders <AppShell> |
app/(app)/<module>/page.tsx | Route-level module pages (placeholders for now) |
app/page.tsx | Public 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.ts | APP_NAME, APP_VERSION — single source for branding |
shared/nav.ts | Nav 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.
Navigation
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:
| Group | Items | Permission |
|---|---|---|
| Overview | Dashboard | — |
| Platform | Schools · Subscriptions · Platform Settings | schools.read · subscriptions.read · platform_configuration.manage |
School context — gated by the active school's activeContext permissions:
| Group | Items | Permission |
|---|---|---|
| Overview | Dashboard | — |
| People | Students · Admissions · Staff · Members | students.read · admissions.read · staff.read · school_memberships.read |
| Academics | Attendance · Assessments | attendance.read · assessments.read |
| Finance | Fees | fees.read |
| Insights | Reports | — |
| Setup | Academic Years · Academic Structure | academic_years.read · classes.read |
| (pinned) | Settings | school_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.
| Store | Owns | Here |
|---|---|---|
| TanStack Query | Server state | useMe() (identity, memberships, permissions) |
| React Context | Application state | school-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
tenantCodeis sent asx-tenant-code; the API resolves it toactiveContext(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.
| Tab | Content |
|---|---|
| Quick actions | Grouped shortcuts (permission-trimmed; disabled until modules land) |
| Notifications | Empty state for now |
| Preferences | Personal 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 intoshared/ui. - Tokens are CSS variables in
app/global.css: slate neutrals, a blue primary, and a.darkblock — 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
- Add the item (and its
.readpermission) toshared/nav.ts. - Create
app/(app)/<module>/page.tsx. - Build screens with
shared/uiprimitives; fetch via a TanStack Query hook inapi/that callsbffFetch.