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 withh-dvh overflow-hidden(dvhhandles the mobile URL-bar resize). The root layout's<body>is left atmin-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 themin-h-0/overflow-y-autochain. 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) skipPageShelland fill<main>directly with their own internal scroll.
Consequences
- The shared
Sidebarescape hatch is gone — both apps now give it a viewport-bounded parent, so it works with plainh-fullstretch in both. - Shared flow-components may now assume a viewport-bounded
flexparent. See the contract note indocs/frontend.md. - Browser scroll restoration is forfeited on back/forward — Next.js
restores window scroll only. Acceptable for an authed console;
PageShellremounts 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 needsmin-h-0, or the overflow lands back on the window. This is centralized inAppShell+PageShellso page authors don't re-derive it. - Self-contained, viewport-
fixedsurfaces (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.