Decisions

ADR-158: gyrum SaaS shell — shell-vs-page boundary, sub-package shape, dogfood adoption order

Phase 0 of EPIC warp#1646 (fleet SaaS chrome). The EPIC's structural lift only works if the shell-vs-page boundary is locked BEFORE Phase 1 extraction; without this ADR Phase 1 either over-extracts (warp-specific…

#158

ADR-158: gyrum SaaS shell — shell-vs-page boundary, sub-package shape, dogfood adoption order

Status: Proposed Date: 2026-05-06 Related: warp#1646 (parent EPIC, fleet SaaS chrome), warp#1592 (sister EPIC, backend SaaS templates), warp#1551 (sister EPIC, sitemap-as-source-of-truth — ADR-155 / ADR-156), warp#1443 / warp#1444 / warp#1451 / warp#1452 / warp#1453 (auth cluster — warp public identity, mobile auth UX, gyrum-go shared auth library, fleet RBAC, agent API-key shape), warp#1645 (runner-monitor SaaS, Phase 3 consumer), warp#338 / ADR-112 (factory-as-project, Phase 3 consumer)

Context

Phase 0 of EPIC warp#1646 (fleet SaaS chrome). The EPIC's structural lift only works if the shell-vs-page boundary is locked BEFORE Phase 1 extraction; without this ADR Phase 1 either over-extracts (warp-specific identity / drawer / toast logic leaks into shell) or under-extracts (visual parity not achievable across fleet products). Same shape as warp#1551 Phase 0 ADRs (ADR-155, ADR-156) preceded the Phase 1 substrate, and as warp#1592 backend templates rides a Phase 0 contract before Phase 1 helm chart.

Today every fleet SaaS reimplements top-nav, footer, login, /sitemap, /account/admin/keys independently. warp's shell is hand-rolled in web/src/routes/+layout.svelte (314 lines: chrome + auth boot + version-footer + mobile-nav + preload-recovery + runners-status pill). ai-frontend has its own. New SaaS (warp#1645 runner-monitor, warp#338 factory-as-project) would copy-paste warp's shell and drift in 2 weeks — same drift class as warp#1551 (route-vs-nav drift) and warp#1486 (docs drift). The fix is the same shape: extract the GENERIC chrome into gyrum-ui/packages/shell, gate via visual-parity check, dogfood from warp first.

gyrum-ui already ships tokens + components (Lit Web Components) + svelte (Svelte 5 adapters); this EPIC adds a fourth peer package, shell. That package is the FRONTEND chrome equivalent of warp#1592's BACKEND templates: opinionated, shared, dogfooded, structurally enforced. Without locking shell-vs-page now, every Phase 1 PR re-debates "is this generic enough" and the API churns.

Decision

A new sibling sub-package gyrum-ui/packages/shell ships seven canonical components, with a fixed shell-vs-page boundary, a single prop-API for identity injection, a one-token theme override (--brand-accent), and a four-phase dogfood adoption order: extract-from-warp → warp-as-anchor → new-SaaS-from-day-1 → ai-frontend-backfill. The contract has six parts.

1. Shell-vs-page boundary. IN shell (canonical, identical fleet-wide): top-nav structure + sitemap-driven items, login state indicator + account dropdown, version footer (sha + age, warp#1533 pattern), /sitemap page (per ADR-156), /account/admin/keys token-mint page, /login page, theme tokens + brand-accent override. IN per-app body (varies per product): app routes + per-route content, app-specific stores (warp's drawer, toasts, ApiError handler), app-specific data fetching (warp's getRunnersPool, whoami via warp's identity store), per-route chrome variants (composed via slots in shell). The litmus test: a piece of UI is IN shell iff every fleet SaaS would render it identically given the same identity + sitemap props. The runners-status pill in warp's +layout.svelte is OUT (warp-specific data); the version footer is IN (every SaaS has one).

2. Canonical v1 component list. gyrum-ui/packages/shell exports exactly seven components in v1; everything else (notifications inbox, search quickmenu, breadcrumbs) is deferred to v2 follow-up ADRs. The seven:

  • <GyAppShell> — top-level layout (header + main + footer); composes the next four
  • <GyTopNav> — sitemap-driven; takes sitemap.json data via prop (ADR-155 schema)
  • <GyAccountMenu> — login state indicator + dropdown; takes identity state via prop
  • <GyVersionFooter> — version + sha + deploy-age (warp#1533 dual-image pattern)
  • <GySitemapPage> — canonical /sitemap page (operator-facing variant per ADR-156)
  • <GyAdminKeysPage> — token mint UI (POSTs to /api/v1/keys per ADR-153 / warp#1453)
  • <GyLoginPage> — login flow (delegates to warp public identity per warp#1443)

3. Prop API for identity injection. The shell takes identity state via prop, never via a hard-coded warp-store dependency. Caller's own store injects:

interface GyAppShellProps {
  identity: { whoami: User | null; loginUrl: string; logoutUrl: string };
  sitemap: SitemapJson;        // ADR-155 v1 schema
  version: VersionInfo | null; // sha + builtAt + buildAge — warp#1533 shape
  brandAccent?: string;         // CSS color override; sets --brand-accent only
  apiBase?: string;             // for /api/v1/keys etc.; defaults to same-origin
  children: Snippet;            // SvelteKit body slot
}

interface User { label: string; scope: 'user' | 'admin'; key_id: string; }

The contract: shell reads from props, never from $lib/stores; each consumer wires its own store(s) into the prop at the layout call-site. ai-frontend's identity shape will need a thin adapter to project onto User, but that mapping is the consumer's responsibility, not the shell's.

4. Theme override mechanism. Each app overrides exactly ONE token: --brand-accent. Everything else (spacing, type scale, neutral palette, radii) stays canonical from @gyrum-labs/tokens. The override is applied via the optional brandAccent prop on <GyAppShell>, which sets the CSS custom property at the shell's root. Multiple-token overrides are explicitly OUT — each new override is a per-app fork and the visual-parity gate (Phase 4) cannot reason about them. Apps that need broader theming bring it back as a Phase 5 ADR with a regression-corpus.

5. Dogfood adoption order. Four-phase rollout, order load-bearing: (a) extract warp's shell into gyrum-ui/packages/shell (Phase 1, three sub-tickets); (b) warp adopts as anchor consumer in the SAME PR sequence — extraction-without-anchor is documentation, not code (Phase 2, one sub-ticket); (c) new SaaS (warp#1645 runner-monitor, warp#338 factory-as-project) ship using shell from day 1 (Phase 3, two sub-tickets, parallel with backfill); (d) ai-frontend backfills last because it has the most existing surface to migrate (Phase 4, one sub-ticket). The forcing function is anchor-consumer-in-same-extraction-PR — extracting AND adopting at the same time is the only way the API gets right; extract-only ships a shell that drifts before any consumer notices.

6. Deprecation path for app-specific shells. Apps that have hand-rolled shell as of Phase 1 ship: shell exists alongside per-app chrome for 1 week (soft-warn rollover per warp#1406 title-format-gate precedent). After 1 week, the visual-parity gate in gyrum-review-pr fires and refuses PRs that touch +layout.svelte (or equivalent) WITHOUT using <GyAppShell>, modulo an audited --skip-shell "<reason>" override. The 1-week window aligns with the existing rollover precedent; the audited skip lets a genuinely-different surface (marketing landing, admin-only operator tool) opt out with a documented reason.

Consequences

What becomes easier. Phase 1 extraction has a fixed contract — shell exports seven components with one prop API, and contributors stop re-debating the boundary at every commit. New SaaS bootstrap from frontend-sveltekit template (warp#1592) drops <GyAppShell> at the root, fills the body, and inherits login + admin-keys + sitemap + version-footer verbatim. Visual-parity gate is one check (does the layout use <GyAppShell>?), not a per-component visual-regression suite. Cross-fleet UI changes (theming, accessibility, mobile responsiveness) ship as one PR to gyrum-ui/shell and propagate on the next consumer release.

What becomes harder. Every shell consumer takes a build-time dependency on @gyrum-labs/shell, with the auth + cadence implications already-established for @gyrum-labs/tokens / @gyrum-labs/components. ai-frontend backfill (Phase 4) is real work — its existing chrome differs from warp's; the migration cost is the visible price of consistency. The seven-component v1 list is locked; adding (e.g.) breadcrumbs requires a follow-up ADR + corpus; consumers cannot grow shell ad-hoc. Identity-store adapters are per-consumer concerns — apps with non-warp identity shapes (ai-frontend's may differ) write a thin projection at the call-site; this is intentional (shell stays generic) but adds a per-consumer surface.

What we sign up to operate. The visual-parity gate is a gyrum-review-pr extension shipped under devtools' release cadence. The 1-week soft-warn window is mechanically the same shape as warp#1406's title-format-gate rollover; the gate template is propagated by gyrum-setup. Audited skip overrides land in ~/.gyrum/admin-overrides.log (same channel as the existing --skip-smoke / --skip-regression-fixture). The seven-component v1 surface is a versioned contract; v1 → v2 is a breaking change requiring an ADR and dual-export migration window.

Alternatives considered

  • Ship shell as a Lit Web Component (framework-agnostic) instead of a Svelte adapter. Lost: the Phase 1 anchor consumer is warp, which is SvelteKit-shaped; a Lit shell forces every Svelte consumer to wrap routing slots in custom elements, losing SvelteKit's +layout.svelte ergonomics (snippets, $page store integration, server-side rendering of layout). Shell ships as Svelte 5 first; non-Svelte consumers can revisit via a Lit adapter ADR if/when one materialises.

  • Hard-code warp's identity store inside the shell. Lost: ai-frontend (Phase 4 consumer) has a different identity shape, and a hard-coded warp-store import couples every consumer to warp's $lib/stores symbol — exactly the drift this EPIC retires. Prop-injection costs each consumer a thin store-to-prop projection; the per-consumer cost is the price of shell genericity.

  • Extract shell without an anchor consumer (warp adopts in a follow-up). Lost: the "extract-then-adopt-later" pattern is what shipped pain on similar EPICs — the API drifts in the gap before the first real consumer hits it; by the time warp adopts, the shell needs a v2. Anchor-in-same-extraction-PR forces the API right while the extractor's context is fresh. The cost (Phase 1 + Phase 2 are sequential not parallel) is the structural gain.

  • Allow per-app multi-token theme override (palette, type scale, radii). Lost: every additional override token is a per-app fork the visual-parity gate cannot reason about. One token (--brand-accent) gives every SaaS a per-product identity without dissolving the canon. Broader theming returns as Phase 5 with its own regression corpus if a real consumer demand surfaces.

  • Soft-rollover at 4 weeks instead of 1. Lost: warp#1406 precedent runs on a 1-week window; aligning with the existing fleet rollover cadence keeps the operator's mental model consistent. A 4-week window also lets the consistency bug class keep re-shipping for an extra three weeks before the gate fires.

  • Treat toasts / drawer / ApiError handling as IN shell. Lost: warp's toast contract differs in subtle ways from ai-frontend's (warp's has action callbacks; others may not), and the drawer is warp-specific UX furniture (kanban add-form). Folding these into shell forces every consumer to take a behavioural opinion that does not generalise. They stay in per-app body; if a future ADR finds a generalisation, it ships as a Phase 5 follow-up.


Supersedes: none — extends EPIC warp#1646; sister to EPIC warp#1592 (backend SaaS templates) and EPIC warp#1551 (ADR-155, ADR-156). Superseded by: