0017 — Layout architecture: route groups, not runtime flags

Context

The PropFlow frontend has accumulated structural debt around layout. Symptoms:

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:

  1. 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)
  2. AppShell is dissolved. Each route group has its own layout.tsx that composes layout primitives (SideNav, TopBar, ContentArea, DockProvider, DockSlot). Primitives live at src/components/primitives/ and don't know about each other.

  3. The chart-as-page feature works without any escape hatch. The standalone insight URL (/admin/dev/insight/[section]/[chart]) lives at src/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 at src/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).

  4. No LayoutIntentProvider. No useLayoutIntent hook. No fullBleed flag. No usePathname() checks inside layout primitives. A layout primitive's behavior is driven by props, context, or framework URL APIs — never by ad-hoc runtime overrides.

  5. The single allowed runtime context that resembles "layout intent" is DockProvider, which manages dock open/closed state. That state is provided in WorkspaceLayout (not OperationsLayout) because SideNav reads useDockContext().reservedWidth for its own sizing, and SideNav renders inside WorkspaceLayout. The visible dock UI still only appears on operational pages — DockProvider is 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

What becomes easier

What becomes harder

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:

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:

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:

C. Slots-based shell with named children

AppShell exposes named slots (sidenav, dock, body) and pages render into them.

Why rejected:

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):

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