0017 — Layout architecture: route groups, not runtime flags
- Status: Accepted (Implemented in PR #600)
- Date: 2026-05-03
- Deciders: Gera (Jose), pending Fede review
Context
The PropFlow frontend has accumulated structural debt around layout. Symptoms:
AppShellis a god component that hardcodes apadding-right: var(--dock-reserved-width)on<main>, which means every route inherits dock-reservation behavior whether it wants it or not.- The chart-as-page feature (PR #592 shipped the basic version, PR #595 abandoned the cross-slide polish) ran into a wall: a full-bleed insight route can't fill the body without escaping the AppShell padding.
- Six separate workarounds were attempted on the abandoned PR #595:
position: fixed,position: absolute; inset: 0,transform: translateZ(0), closing the dock on overlay open, negative-margin escape tricks, and pathname checks in AppShell. Each one fixed one symptom and broke another. The user (Gera) explicitly said "no more patches." - A previous proposal (
docs/architecture/full-page-routes-plan.md) suggested aLayoutIntentProviderruntime context that pages would push into viauseLayoutIntent({ fullBleed: true }). AppShell would read the context and conditionally drop the padding. - That proposal converges to the right behavior, but it leaves AppShell as a "smart shell with a runtime escape hatch." Every new layout shape adds another flag. The shell becomes a switch statement of intents over time.
The deeper question: does layout intent belong in runtime context or in route structure?
Next.js App Router was designed specifically so that different sections of an app can have different layouts, expressed via route groups + nested layouts. The framework computes layout statically from the URL — no runtime decision, no flicker, no provider stacking. Using runtime context to override layout is fighting the framework.
Decision
Layout is decided by route group, not by runtime flag.
Concretely:
The frontend has three top-level route groups + one sub-group:
(public)— unauthenticated, marketing chrome(workspace)— signed-in app (side nav + top bar)(workspace)/(operations)— signed-in routes that participate in the dock workspace
(standalone)— minimal-or-no chrome (onboarding, simulator, auth callbacks)
AppShellis dissolved. Each route group has its ownlayout.tsxthat composes layout primitives (SideNav,TopBar,ContentArea,DockProvider,DockSlot). Primitives live atsrc/components/primitives/and don't know about each other.The chart-as-page feature works without any escape hatch. The standalone insight URL (
/admin/dev/insight/[section]/[chart]) lives atsrc/app/(workspace)/admin/dev/insight/...— outside(operations), so it has no dock in its parent layout. The intercepted version for the cross-slide animation lives atsrc/app/(workspace)/(operations)/admin/dev/status/@insight/(..)insight/[section]/[chart]/page.tsx. Both share the same URL; soft navigation fires the intercept (dock layout, animation), hard navigation hits the standalone (no dock, fills body).No
LayoutIntentProvider. NouseLayoutIntenthook. NofullBleedflag. NousePathname()checks inside layout primitives. A layout primitive's behavior is driven by props, context, or framework URL APIs — never by ad-hoc runtime overrides.The single allowed runtime context that resembles "layout intent" is
DockProvider, which manages dock open/closed state. That state is provided inWorkspaceLayout(notOperationsLayout) becauseSideNavreadsuseDockContext().reservedWidthfor its own sizing, andSideNavrenders insideWorkspaceLayout. The visible dock UI still only appears on operational pages —DockProvideris context infrastructure, not layout intent. Pages outside(operations)get the context their primitives need without rendering any dock chrome.
The full architecture (route structure, layout primitives, token system, component organization, lib organization, naming conventions, state scope rules) is documented in docs/architecture/site-design.md. This ADR captures only the decision — that doc captures the design.
Consequences
What this commits us to
- Adding a new layout shape (e.g., a "tenant portal" surface) means adding a new route group. The decision is visible in the file tree.
- The
(app)route group is renamed to(workspace). This is a one-shot cost (file-path rename across the tree) but doesn't affect URLs since route groups don't appear in URLs. - Routes move between groups during migration. A page that's currently
(app)/dashboard/becomes(workspace)/(operations)/dashboard/. URLs unchanged. AppShellceases to exist as a single component. Its responsibilities split acrossWorkspaceLayout,OperationsLayout,PublicLayout,StandaloneLayout, each composed of primitives.- Visual regression coverage is mandatory for the route migration phase, since the failure mode of a botched migration is "every page looks slightly off."
docs/architecture/site-design.mdbecomes a same-PR-update requirement for any future architectural change to layout, route structure, or the conventions it codifies.
What becomes easier
- Adding a new full-bleed page (no dock, no chrome variant): drop the file in the right route group. No code edits to a shell.
- Reasoning about what chrome a route has: read its file path. No need to check what context providers are mounted.
- Server-side rendering: layout is known at build time. No FOUC from a context provider populating after mount.
- Cross-slide and other layout-shared animations between sibling routes: parallel slots and intercepting routes work cleanly because the shared parent layout is statically determined.
What becomes harder
- Layout intent that depends on runtime conditions (e.g., "show the chrome only if user has property X"). This decision rejects that pattern for layout. Such conditions should be expressed by routing the user to a different URL (and therefore a different layout), not by mutating the layout in place.
- Ad-hoc full-bleed experiments. Adding a new route under
(workspace)/foo/automatically inherits the workspace layout. To go full-bleed, the route lives under(workspace)/foo/outside(operations), OR a new layout shape is justified via this doc and an ADR. - Mixing dock-bearing and non-dock-bearing siblings under the same URL parent. The route group machinery handles this cleanly, but the file tree will have two branches converging on the same URL path (e.g.,
(workspace)/(operations)/admin/dev/status/and(workspace)/admin/dev/insight/). Both are valid; reviewers need to be aware that the same URL space is split across route groups. - Context providers for layout-adjacent state may need to live higher in the tree than the route group boundary where their visible UI renders. The
DockProvider/AskClaraProviderlesson (commit92b15558): we initially scoped both toOperationsLayoutbecause the dock UI is operations-only. ButSideNav(rendered inWorkspaceLayout, one level higher) callsuseDockContext().reservedWidthfor its sizing. The provider had to wrap SideNav, so it hoisted up toWorkspaceLayout. The visible dock UI is still operations-only — the provider is context infrastructure, not rendered chrome. Read the §7 state-scope table BEFORE placing a new provider; before placing one in a new layout,grep -r "useX()"for all consumers and ensure the provider wraps the topmost one. This is the single subtlest footgun in the design.
Follow-up work
All Track A–D work landed in PR #600 — see the phase log table at the top of site-design.md. The remaining items are:
- Phosphor migration on the SideNav + CauseEffectPair files that have unmigrated hand-rolled
<svg>. Tracked in §12 of site-design.md. oauth-popup-response.tscleanup,settings-resolver.tsrelocation,feature/integrations/rename. Also tracked in §12.- A future ESLint rule for "no top-level files under
src/lib/" (would be a custom check; not yet implemented). - Eventual removal of the dual-path
@/lib/*alias onceagents/clarais fully merged intosrc/. The alias is now permanent infrastructure rather than transitional — seesite-design.md§6 "The@/lib/*dual-path alias" for current behavior.
Lesson for future architecture refactors
PR #600 shipped 27 commits across 5 tracks (A0, A1–A7, B1–B5, C1–C6, D2–D5) in a single PR. The §11 spec said "each phase is its own PR" and we deviated. Several reviews caught surface-level polish issues but a mid-PR architectural change (this document's 92b15558 provider-hoist fix) left 4 documentation locations inconsistent — the kind of drift that's hard to spot at scale.
Recommendation for the next architecture-scale refactor: Track A (the load-bearing change — the bug fix and core architectural move) ships and merges first as its own PR. Tracks B–D (mechanical reorganizations against the new foundation) ship as follow-up PRs. The cleanup PRs are boring and safe — review fast. The interesting architectural work gets the review attention it deserves.
Splitting respects the spec we wrote and gives CI / reviewers per-track signal. We didn't pay for it on this PR (4998 tests passed throughout), but the next 30-commit one might.
Alternatives considered
A. Runtime layout intent context (LayoutIntentProvider)
The proposal in docs/architecture/full-page-routes-plan.md. Pages call useLayoutIntent({ fullBleed: true }) on mount; AppShell reads the context and conditionally drops the dock-reservation padding.
Why rejected:
- Leaves AppShell as a smart shell with a runtime escape hatch. The hatch will multiply.
- Possible flicker: the intent pushes after mount, so SSR renders with the wrong layout briefly.
- Doesn't compose with parallel/intercepting routes well: the cross-slide pattern needs both routes to share a parent layout that the framework chose, not one that runtime code shifted.
- Fights Next.js App Router instead of using its native layout system.
- Even after implementing the hook, the framework still wouldn't know about the intent — it's purely a CSS-padding override. The "layout" never actually changed at the framework level.
B. Single AppShell with route-aware logic
Keep AppShell as one component. Have it inspect usePathname() and decide which chrome to render. This is what some early PR #595 patches attempted.
Why rejected:
- Hardcodes route names inside a layout component. Renaming a route requires editing AppShell.
- Doesn't scale: every new route variant is another
ifbranch in AppShell. - Fails for parallel/intercepting routes — the pathname during an intercept is the intercepted route's pathname, but the layout context is the intercepting parent's.
C. Slots-based shell with named children
AppShell exposes named slots (sidenav, dock, body) and pages render into them.
Why rejected:
- Doesn't compose with App Router's native parallel slots.
- Pages become aware of slot names, which is implementation detail.
- Re-implements what the framework already provides via parallel routes.
D. View Transitions API
Use the Web Platform's View Transitions API (or Next.js's wrapper) for cross-page animation, sidestepping the parallel/intercept route complexity.
Why rejected (for now):
- Cross-browser support is incomplete — Chromium-only as of 2026-05. Safari and Firefox don't ship it.
- Doesn't solve the underlying layout-intent problem; it's only an animation primitive.
- Worth revisiting in a future ADR once browser support catches up.
E. Status-quo with patches
Keep the existing AppShell, layer more workarounds (position: fixed, transform: translateZ(0), etc.) until something works.
Why rejected: explicitly forbidden by the user. Six prior patches each broke something else. The architecture is the problem.
References
docs/architecture/site-design.md— the full design that this ADR formalizes.docs/architecture/full-page-routes-plan.md— the superseded proposal (LayoutIntent flag). Marked Superseded by this ADR.docs/architecture/cross-slide-pattern.md— pre-existing cross-slide doc; will be updated in Phase A7 of the migration.- PR #592 — original chart-as-page feature (merged).
- PR #595 — abandoned LayoutIntent / patch attempts. Should be closed without merging.