Persistent read-only chrome via an (app) route-group layout — stop the header re-animating on intra-app nav
0045-persistent-chrome-route-group-layout
- Reversibility
- two-way door
DEC-0045 — Persistent read-only chrome via an (app) route-group layout
Decided by the user (option (b), 2026-06-17 — recorded in
plans/sveltekit-migration.md) and built + verified this turn by the Principal SvelteKit Engineer executing DEC-0043 Phase 4, sub-step A. Recorded automatically in the same turn (the standing rule).confidence: verified— built, and the no-replay behavior was measured in a real browser.
Context
DEC-0043's whole reason to exist is the no-flash mandate: SvelteKit's client router does plain navigation with no forced view transitions. Phases 2/3 proved it — startViewTransition is called 0 times on every client nav, and there are 0 ::view-transition crossfade pseudos (the Astro <ClientRouter/> bug class is gone).
But porting the surfaces left the shared chrome composed per-page: board/+page.svelte and graph/+page.svelte each rendered their own <SiteHeader badge="read-only" …/> inside a .board-page/.graph-page wrapper. On a board↔graph client navigation SvelteKit re-mounts the destination route component and its SiteHeader, so the header's entrance choreography (site-header-drop, site-item-rise) replayed every navigation. Astro had masked this with a .navigated class on the astro:after-swap document swap; that machinery was deleted in the migration (there is no document swap to survive), so the replay surfaced. The flash was gone, but the calm-nav feel (header sits still, content moves) was not.
Options considered
- Accept the gentle re-entrance. Lowest effort; rejected — the user wanted the header to NOT re-animate.
- Gate the entrance to first-load only (e.g. a one-time flag). Rejected — reintroduces state-tracking machinery analogous to
.navigated, the thing DEC-0043 deleted. - Persistent-chrome route-group layout (CHOSEN, the user's call). Move
/board+/graphundersrc/routes/(app)/and lift the sharedSiteHeaderinto(app)/+layout.svelte. SvelteKit keeps a layout mounted across navigations within its subtree, so the header mounts once and never re-animates; only{@render children()}swaps and animates. Closest to the Astro calm-nav feel, with no transition/flag machinery — purely structural.
Decision
Create the route group src/routes/(app)/ with (app)/+layout.svelte that renders the shared SiteHeader (wordmark = the SSOT serif mark, homeAriaLabel, badge="read-only", the identical Board/Graph/Docs/GitHub nav) above {@render children()}, wrapped in a .app-shell that carries the full-height flex-column page shell. Move board/ and graph/ to (app)/board/ and (app)/graph/; remove the now-duplicated SiteHeader and the per-page .board-page/.graph-page wrapper from each page (those two wrappers were byte-identical, so they collapse into the one .app-shell rule — one place, not two). Keep SiteFooter per-page (the board's git-board note and the graph's d3-force credit differ; a footer re-mount on nav is acceptable — the header is what must persist).
The landing (/) and the dev-only /preview + /preview/[client] surfaces stay outside (app) and carry their own header treatment: the same shared SiteHeader component, composed by the page with a CTA (landing) or a dev preview badge (preview index), NOT the read-only group chrome. The CSS-import depth in the moved pages gains one ../ (routes/(app)/board/ is one level deeper); the (app) group is path-transparent, so the /board and /graph URLs and the svelte.config.js APP_OWNED_PREFIXES prerender allow-list are unchanged.
Rationale
- Suppress the replay structurally, not with a flag. A SvelteKit layout component persists across navigations within its subtree; lifting the header into the group layout means it mounts once and the entrance plays once — the calm-nav feel without re-introducing any
.navigated-style state machine (the net-simplification DEC-0043 bought). - No new flash risk. The change is purely where a component lives in the tree; no view transitions, no
startViewTransition, noastro:after-swapanalog. The no-flash mandate is preserved by construction. - Atomic, no fork. The header was only ever duplicated (identical props on both pages), never divergent; hoisting it removes the duplication and keeps the shared
SiteHeader(DEC-0038) the single source of the chrome. The two identical page-shell wrappers likewise collapse to one. - Landing keeps its identity. The landing's header is a different composition of the same component (CTA, no read-only badge); keeping it outside the group preserves that without forking the component.
Consequences
- Intra-app (
/board↔/graph) navigations no longer replay the header entrance; only page content animates (proven below). - The per-page footer still re-mounts on nav (accepted — it differs per page and a footer re-entrance is unobtrusive).
- A future in-app surface that wants the read-only chrome drops into
(app)/and inherits the persistent header for free. APP_OWNED_PREFIXESstays['/graph', '/board']— the route group does not change the URLs.
Reversibility
Two-way door: the group is a directory wrapper. Moving board//graph/ back out and re-inlining the header reverts it; nothing else depends on the structure. The durable commitment is the principle — the persistent chrome must not re-animate on intra-app nav, achieved structurally (a mounted layout), never by re-adding transition/.navigated machinery.
Review
confidence: verified: built and measured. pnpm --filter @dossier/app check is 0/0; the dev server serves /board + /graph at their unchanged URLs (HTTP 200) with exactly one <header class="site-header"> each. An animationstart capture on a real headless-Chrome board→graph client nav (the same method that proved the original flash) recorded: on first load the header entrance fires once (site-header-drop, site-item-rise) alongside page content; after the client nav, the animations that fired were only page content (graph-rise, graph-panel-in-sheet) — the header animations did NOT re-fire — and startViewTransition was called 0 times. See Migrate chrome-light app surfaces to SvelteKit; docs stay on Astro/Starlight (two apps, one origin) and Execute the SvelteKit app migration (DEC-0043) — phased, no big-bang, apex domain moved only at final cutover.