Frontend structure
App Router
Each app (apps/tracking, apps/portal) is a Next.js App Router project under
src/app/:
page.tsx— login gate.layout.tsx— root layout. Importsglobals.css, mounts the prefetch<XLoader/>components from@enode/core, and runs a boot script that addsclass="ios"to<html>on the Capacitor iOS build.- Feature routes — composed from co-located
use-*hooks and presentational files.
Styling & design tokens
Tailwind v4 with the Enode design system in
packages/ui/src/styles/design-system.css. Each app's globals.css composes it:
@import "tailwindcss";
@import "@enode/ui/styles/design-system.css";
- Use semantic colour utilities (
bg-surface-base,text-foreground-muted,border-surface-stroke,bg-foreground-red-orange, …) and type-style classes (heading-hero,body-normal,stat-large-digits, …). - Never raw
gray/orange/emeraldutilities. Tokens carry light + dark values and flip viaprefers-color-scheme, so don't adddark:variants. - Destructive states still use raw
red-*— the design system has no danger token (a known gap). ios:is a custom variant scoped to the Capacitor iOS build.
Scroll model & page layout
Both apps use inner-scroll: the window does not scroll; persistent chrome
(sidebar, page header) stays put and a single content pane scrolls. The body is
locked to the viewport — the tracking app at the root (<body class="h-full">),
the portal inside AppShell (h-dvh) so its public / route can stay
document-scroll. See ADR 0004.
- The shell's
<main>is a bounded, non-scrolling flex column (relative flex min-h-0 flex-1 flex-col overflow-hidden). Pages own their scroll region. - Portal pages use
PageShell(apps/portal/src/components/page-shell.tsx) — it renders the pinned title bar (via the shared header kit) and the page's singleoverflow-y-autobody, so pages never hand-roll themin-h-0/ overflow chain. Pages with their own full-height layout (planner, performance) skip it and fill<main>directly with their own internal scroll. - Every flex ancestor between
<main>and a scroll pane needsmin-h-0, or the overflow lands back on the window.
Shared-component contract: components in @enode/ui that fill height or pin
to a scroll container (e.g. Sidebar, the header-height kit) may assume a
viewport-bounded flex parent — both apps now provide one. Components that
must work regardless of the host's scroll model should establish their own
scroll context (the fixed drawer/dialog/sheet pattern), not depend on the
page's.
Conventions
- State resets adjust state during render (the
prev-value pattern), not viasetStateinuseEffect— ESLint enforces this. - Local entity ids are
crypto.randomUUID(). - Reusable presentational components belong in
@enode/ui; shared non-UI logic in@enode/core. App-specific views stay in the app.
See Components for the reusable component layer and Storybook.