Skip to main content

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. Imports globals.css, mounts the prefetch <XLoader/> components from @enode/core, and runs a boot script that adds class="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/emerald utilities. Tokens carry light + dark values and flip via prefers-color-scheme, so don't add dark: 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 single overflow-y-auto body, so pages never hand-roll the min-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 needs min-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 via setState in useEffect — 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.