Application map / References / Frontend contract
Frontend Platform Contract
The rules that keep one frontend serving five personas from degrading into many stitched-together apps — and the machinery that enforces them.
Why this exists
Left to grow organically, a data-dense multi-persona frontend degrades along two axes: architectural entropy (route files accrete domain logic, features import each other's internals, primitives get forked, data fetching scatters) and visual entropy (every page reinvents type, spacing, and color). The contract answers both with fixed layers, tokens, a canonical component vocabulary, named page anatomies, and — critically — mechanical enforcement: a rule that matters gets a lint rule, a ratchet entry, or a test, never review vigilance alone.
Four layers, one-way imports
| Layer | Owns | Must not contain |
|---|---|---|
Routes (src/app/) | URLs, layouts, metadata, framework plumbing. A migrated route is thin: it delegates to exactly one feature entrypoint. | Domain UI, business logic, deep component trees. |
Feature slices (src/features/, one per domain) | The domain's UI, hooks, data actions, types, formatters, tests. | Imports of another feature's internals. |
Shared product layer (src/shared/) | Product wrappers with operational defaults baked in, plus cross-feature utilities. | Anything belonging to a single feature. |
Primitives (src/components/ui/) | Vendored, registry-faithful shadcn/ui files — regenerable from the registry. | Domain behavior, adapters, wrappers. |
Imports flow routes → features → shared → primitives; never upward, never sideways between features (shared code moves down). Data fetching belongs to a feature's data edge — server actions, route handlers, named client helpers — never arbitrary components. The one sanctioned cross-cutting exception is the app shell and its selected-property context, which features read only through its defined interface (a server resolver and a client hook).
Known delta: cross-feature hooks/lib/types live at top-level src/hooks|lib|types rather than under src/shared/; harmless, but src/shared/* is the target home if touched.
The design contract
- Tokens are the only source of visual values. Every color goes through semantic tokens (background, card, foreground, muted, primary, destructive, success/warning/error aliases, chart-1…5); no hardcoded hex in product UI, ever — dark mode is then a free re-mapping, and impossible otherwise. Radii derive from a single base token via multipliers (4px badges/bars · 8px buttons/nav · ~12px cards and the content frame · full for avatars).
- Status tones are a closed vocabulary —
danger / warning / success / brand / neutral / info— with a fixed mapping from domain labels (e.g. Critical, Over Budget → danger; Watchlist, Reserve Study Overdue → warning; Healthy, Well Funded → success). A new label is assigned to an existing tone; new tones are not minted per page. Rendering is always a light-tinted background with readable colored text, never solid-with-white-text. - Typography: one sans face (Inter) in a small role scale — display numbers 20–28px semibold; headings/titles 14px medium; body and table cells 14px regular; column headers 13px uppercase muted; badges 10–11px. Tight line heights (1.0 labels, 1.25 body).
- Spacing: card padding 16–20px, 20px gaps between cards, ~33px table rows, 8px icon-to-text.
- Icons come from one library (lucide); inline SVG is reserved for chart-like shared components on an explicit allowlist.
The component vocabulary
Card is the primary building block — every distinct section of content is a card, with a table-card variant that wraps the data table flush. One data table implementation (shadcn table + TanStack engine behind a typed column API): sortable headers, text left / numbers right / badges centered, currency always with cents and separators, negatives in destructive color and parentheses, horizontal scroll inside the card on narrow viewports — per-column overrides, never forks. Status badges in the two light-tinted forms. Loading skeletons (pulse cards at final-layout spacing, never spinners) and error states (destructive-tinted card with a plain-language message and a retry action).
New pages compose one of four named archetypes instead of inventing a layout: A summary cards + detail table · B dashboard overview (two 2-column rows + full-width card) · C summary donuts + accordion lists · D stacked tables under a standalone heading. Everything renders inside one shell: 256px sidebar, breadcrumbs + assistance link, notifications bell and user button in the top bar.
The maintained wrapper inventory (metric tiles, page headers, charts, filter toolbar, StatusBadge, the data table and its hooks) is exported from src/shared/ui/index.ts — the code is the authoritative list.
Mechanical enforcement — what a migration must update
- Lint restricted-import rules block retired paths and wrong-direction imports at edit time.
- The ratchet script (
pnpm check:frontend-architecture, run in CI) enumerates what has been cleaned and forbids regression: deleted legacy directories that may never reappear, routes proven thin that must stay thin, slices scanned for hardcoded colors / raw form-table-svg elements / strayfetch(— each with a small explicit allowlist. The philosophy: lists enumerate what is migrated rather than globbing the world, and every migration finishes by adding itself to the ratchet (its route to the thin list, its prefix to the primitive checks, its deleted paths to the legacy list). - Architecture tests assert the product-source guards not worth encoding in the script.
Migration discipline: one vertical slice at a time — keep URLs, role behavior, and server contracts stable; delegate the route to a feature entrypoint; replace raw values with tokens and one-offs with canonical components; add tests; delete the legacy implementation only when zero imports remain; ratchet in the same change. Non-goals: big-bang rewrites, per-feature token/table forks, custom primitives where mature libraries exist, and fabricated placeholder data styled to look real.
Cleanup residue (verified 2026-07-08, still open unless closed since)
- fabricated data
/management/teamserves a hardcoded sample-team array behind a live route and nav item — a direct violation of the zero-fabricated-data rule. Fix: back it with real roster data or retire the route. - The architecture spec still asserts on a doc the docs reorganization moved, so those two file reads fail — drop or repoint the doc-content assertions.
- A misnamed E2E spec survives from a retired page but now tests
/tasks— rename or fold it in. - A dead minimum-font-size token is referenced nowhere and enforces no floor (smallest shipped sizes: 10px widely, one 9px outlier) — cleanup candidate.