Skip to main content

0004 — Portal dashboard uses inner-scroll

Status: Accepted · Date: 2026-06-15

Context

The two apps had diverged on scroll model. The tracking app is a Capacitor mobile shell: its <body> is pinned to the viewport (h-full) and a single content pane scrolls (inner-scroll). The portal was built browser-first with document-scroll (<body class="min-h-screen">, the window scrolls).

This divergence taxed the shared @enode/ui flow-components. A component that fills height or pins to the scroll container resolves differently under each model — e.g. the shared Sidebar's persistent column, a stretching flex child, stretched to the content height under document-scroll and overflowed the viewport, but was correctly viewport-bounded under inner-scroll. We were absorbing the difference with consumer-side escape hatches (a sticky top-0 h-screen override on the portal sidebar), which pushed each component's real contract out into its consumers.

We also want the frosted overlay header (@enode/ui/header-height) — sticky title, content scrolling under it — on every portal page. That pattern wants a bounded scroll container, which inner-scroll provides natively.

Decision

The authenticated portal dashboard uses inner-scroll, matching the tracking app. The window never scrolls; the persistent chrome (sidebar, page header) stays put and a single content pane scrolls.

  • Scoped to the dashboard, not the whole site. The conversion lives in AppShell (apps/portal/src/components/app-shell.tsx), which locks itself to the viewport with h-dvh overflow-hidden (dvh handles the mobile URL-bar resize). The root layout's <body> is left at min-h-screen, so the public / login/marketing route keeps document-scroll — the right default for a content page.
  • <main> is a bounded, non-scrolling flex column (relative flex min-h-0 flex-1 flex-col overflow-hidden). Pages own their scroll region.
  • PageShell (apps/portal/src/components/page-shell.tsx) owns the per-page scroll. Regular pages wrap their content in it and never hand-roll the min-h-0 / overflow-y-auto chain. It renders the title bar via the shared header kit and the scroll body beneath. Pages with their own full-height layout (planner, performance, the dev-only debug sandboxes) skip PageShell and fill <main> directly with their own internal scroll.

Consequences

  • The shared Sidebar escape hatch is gone — both apps now give it a viewport-bounded parent, so it works with plain h-full stretch in both.
  • Shared flow-components may now assume a viewport-bounded flex parent. See the contract note in docs/frontend.md.
  • Browser scroll restoration is forfeited on back/forward — Next.js restores window scroll only. Acceptable for an authed console; PageShell remounts per route, so pages open at the top (no stale-offset bug). Revisit with manual restoration only if a specific page needs it.
  • Every flex ancestor between <main> and a scroll pane needs min-h-0, or the overflow lands back on the window. This is centralized in AppShell + PageShell so page authors don't re-derive it.
  • Self-contained, viewport-fixed surfaces (drawers, dialogs, toasts) are unaffected — they establish their own scroll context regardless of model.

Alternatives considered

  • Keep document-scroll, make page headers position: sticky. Works, but leaves the two apps on different models — the shared-library tax persists and every shareable flow-component needs per-app adaptation. Rejected in favour of unifying on one model.