Skip to content

Plan 010 — Frontend conventions ​

Status: approved Written in plan mode from spec.md and research.md. Approved by the human before any code is written. Status is set by the human, never by the agent: proposed → approved.

Produced alone — tasks.md stays untouched until this plan is approved in turn.

Context ​

apps/api has nestjs.md; apps/web has nothing equivalent, three hand-written files that already disagree on naming, a stock Panda config with no tokens, and a facade whose pending/error/success union every consuming component must branch on. The legendary planner is the next feature and the largest UI in the product — every convention left unstated now gets decided implicitly by whoever writes it first. This plan sets those conventions while the codebase they bind is three files, and makes the health round-trip the reference implementation that proves each one.

Goal ​

apps/web gains a documented, enforced shape: a developer (or agent) adding a feature knows where each file goes, what it is called, how it reaches server data, and where its state lives — and learns it from react.md and design-system.md rather than by reading the existing code and guessing. Every rule that a machine can check fails the build or the suite when broken, so the documents describe the repository's actual state rather than an intention it has drifted from.

Approach ​

Six changes, sequenced so that each lands on a green tree and none is left half-applied. The ordering is load-bearing: three of the six make the build or the linter stricter, and applying a rule before the code obeys it turns the repo red for the length of the change.

1 · Compiler and its build gate. Wire react(), babel({ presets: [reactCompilerPreset({ panicThreshold: 'all_errors' })] }) into vite.config.ts, adding the four devDependencies from research F1. This lands first because it is self-contained, touches no source file, and — with panicThreshold — turns every later change into one the compiler has vetted. Removing hand-memoization is vacuous today (there is none), so this task's deliverable is the wiring and the guard, not a code sweep.

2 · Structure and naming. Move routes/health-page.tsx → features/health/HealthPage.tsx, create features/health/routes.tsx and shared/{ui,lib}/, relocate all four test files into __tests__/ folders, rename the two kebab-case files Biome's rule will reject (test-setup.ts → testSetup.ts, vite-proxy.test.ts → viteProxy.test.ts, with vitest.config.ts's setupFiles updated in step), and reduce main.tsx to a manifest that mounts features/*/routes.tsx. The Biome override is enabled in the same task, immediately after the renames — enabling it before them would fail pnpm lint on files the task is about to move anyway.

3 · Facade and data flow. Add suspenseOptions (research F2) as a named, tested unit of src/api; rewrite useHealth to return Health rather than a union; add the ErrorBoundary class to shared/ui; move <Suspense> and the boundary into the shell and the feature's route. HealthPage loses its three branches. This is the task where MSW arrives, allowBuilds: msw: false included.

4 · Design tokens. Extend panda.config.ts with the rarity scale and the semantic surface/text tokens, and convert the existing css() calls to reference them.

5 · Guards. Extend api/boundary.test.ts's file-walking pattern into a conventions suite covering cross-feature imports, @tanstack/react-query outside src/api, hand-memoization, and literal colours. The naming rule is not here — Biome owns it (research V4).

6 · Documentation. react.md and design-system.md written last, describing what the previous five tasks made true, plus the CLAUDE.md Reference entries. Writing them first would produce a document that describes intentions; writing them last makes SC8 checkable by reading.

Architecture ​

apps/web/src/
  api/                    contract boundary — the ONLY place that imports
    generated/            @tanstack/react-query or api/generated
    suspenseOptions.ts    narrows Orval's queryFn (skipToken -> QueryFunction)
    useHealth.ts          useSuspenseQuery + Zod parse -> Health
    index.ts              the public surface
    __tests__/            boundary.test.ts, useHealth.test.ts (MSW)
  features/
    health/               leaf-only: may import api/ and shared/, never a sibling
      HealthPage.tsx      route component -> Page suffix, no loading/error branch
      routes.tsx          this feature's route table
      __tests__/
  shared/                 shared-only: imported by features, imports no feature
    ui/ErrorBoundary.tsx
    lib/
  App.tsx                 shell: QueryClientProvider, ErrorBoundary, Suspense
  main.tsx                manifest: mounts feature route tables, declares none
  __tests__/App.test.tsx

Dependency rule, enforced by guard test: features/* → shared/, api/ ✓ · features/* → features/* ✗ · shared/ → features/* ✗.

Three enforcement layers, deliberately distinct — each convention is assigned to exactly one, and react.md states which:

LayerOwnsFails on
Biomefilename == exported symbol (filenameCases: ["export", "camelCase"], scoped apps/web/**)pnpm lint
Guard testsimport boundaries, hand-memoization, literal colourspnpm test
React CompilerRules of React (panicThreshold: 'all_errors')pnpm build only

The third row is why CI must run the build and not the suite alone (spec R17a): the compiler is not wired into vitest.config.ts, and it only sees files reachable from the entry.

Tech stack ​

React 19.2.8, React Router 8.3.0, TanStack Query 5.101.4, Base UI 1.6.0, Panda 1.11.5, Vite 8.1.5 with @vitejs/plugin-react 6.0.4, Biome 2.5.5, Vitest 4.1.10 + Testing Library, Orval 8.23.0, TypeScript 7. Node ≥ 22.18, pnpm 11.15.1.

Five new devDependencies, no runtime dependencies:

  • babel-plugin-react-compiler 1.0.0, @rolldown/plugin-babel, @babel/core, @types/babel__core — the compiler needs all four (spec R16a, research F1). @vitejs/plugin-react 6 has no babel option; Vite 8 is Rolldown/Oxc and otherwise Babel-free. Accepted cost: +110 ms on the Vite build, measured.
  • msw 2.15.0 — the facade test tier (spec R23). Requires allowBuilds: msw: false in pnpm-workspace.yaml; without an entry pnpm writes a placeholder that blocks every pnpm command (research F3).

Deliberately not added: react-error-boundary (research V3 — a class component plus TanStack's QueryErrorResetBoundary covers it), ESLint and react-compiler-healthcheck (research V2 — panicThreshold covers it), any global state library (spec R12).

Global Constraints ​

Copied verbatim from the architecture docs. Every task inherits these; do not summarise or reword them — a test asserts they appear here unchanged.

From docs/architecture/typescript.md:

  • No any. Not in app code, not in tests. Use unknown plus narrowing, or model the type properly. If a third-party type forces it, isolate it behind one typed adapter and comment why.
  • No non-null assertions (!) to silence the compiler.
  • No @ts-expect-error without a comment explaining what is expected and when it can be removed.
  • Validate everything crossing a boundary (GW2 API responses, HTTP input) at runtime, not just at the type level.
  • Prefer pure functions for domain logic. The optimizer must be testable without a network or a database.
  • Match the style of surrounding code. No new dependency without justification in the spec or plan.
  • moduleResolution: "node" and baseUrl are removed in TS 7. Use "nodenext" (or "bundler" for Vite/bundler-resolved code) plus paths: { "*": ["./*"] } in place of baseUrl.
  • TypeScript's default lib includes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), or document/window resolve everywhere, silently defeating profile isolation. A profile that needs DOM adds "DOM", "DOM.Iterable" on top of that floor.
  • experimentalDecorators and emitDecoratorMetadata are supported for typecheck only — tsc --noEmit accepts decorator syntax and resolves metadata types, but --noEmit never emits design:paramtypes at runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; see stack.md's api build model.

From docs/architecture/stack.md:

  • Monorepo, pnpm workspaces.
  • apps/api — NestJS (TypeScript).
  • apps/web — React (TypeScript).
  • packages/* — shared code (domain types, the curated Mystic Forge dataset) when sharing is real, not speculative.
  • Postgres for persistence. In-memory cache for the MVP — no Redis until the caching story earns it.
  • Vitest everywhere, both apps.
  • Deploy: managed PaaS (Fly.io / Railway). CI: GitHub Actions — lint + typecheck + test + build.
  • Static data (items, station recipes) is immutable: cache hard.
  • Prices are volatile: short TTL, recomputed live.
  • GW2 API rate limit: per-IP token bucket, 300 burst, refill 5/sec, 429 on overflow. Batch up to 200 ids per ?ids= call.
  • All GW2 API access goes through the client that budgets this. Never call the GW2 API directly from a service.
  • API keys are user secrets: encrypted at rest, never logged, never returned to the client.

From CLAUDE.md: typecheck clean, tests pass, every acceptance scenario and success criterion covered by a test whose name traces to it, no unexplained escape hatches, the human reviews the diff.

File Structure ​

PathChangeResponsibility
apps/web/vite.config.tsmodifiedCompiler wiring: babel({ presets: [reactCompilerPreset({ panicThreshold: 'all_errors' })] }), beside the existing dev proxy
apps/web/package.jsonmodifiedFive devDependencies
pnpm-workspace.yamlmodifiedallowBuilds: msw: false
biome.jsonmodifiedoverrides entry scoping useFilenamingConvention (error, ["export", "camelCase"]) to apps/web/**
apps/web/src/api/suspenseOptions.tsnewNarrows Orval's queryFn off skipToken; no cast, no any
apps/web/src/api/useHealth.tsnewuseSuspenseQuery + Zod parse → Health
apps/web/src/api/index.tsmodifiedPublic surface; the union type and its three branches are deleted
apps/web/src/api/__tests__/boundary.test.tsmovedFrom src/api/boundary.test.ts; gains the react-query import rule
apps/web/src/api/__tests__/useHealth.test.tsnewMSW tier: real hook + real validator + malformed payload
apps/web/src/features/health/HealthPage.tsxmovedFrom src/routes/health-page.tsx; loses all three status branches
apps/web/src/features/health/routes.tsxnewThis feature's route table
apps/web/src/features/health/__tests__/HealthPage.test.tsxmovedFrom src/routes/health-page.test.tsx; mocks the facade
apps/web/src/shared/ui/ErrorBoundary.tsxnewClass boundary; resets via QueryErrorResetBoundary
apps/web/src/App.tsxmodifiedShell: providers, <Suspense>, boundary
apps/web/src/main.tsxmodifiedManifest — mounts feature route tables, declares none
apps/web/src/__tests__/App.test.tsxmovedFrom src/App.test.tsx
apps/web/src/__tests__/viteProxy.test.tsmovedFrom src/vite-proxy.test.ts — kebab name fails the Biome rule
apps/web/src/testSetup.tsmovedFrom src/test-setup.ts — same reason
apps/web/vitest.config.tsmodifiedsetupFiles follows the rename; globs need no change (research F5)
apps/web/panda.config.tsmodifiedRarity tokens + semantic surface/text tokens
apps/web/src/__tests__/conventions.test.tsnewGuard suite: cross-feature imports, react-query boundary, hand-memoization, literal colours
docs/architecture/react.mdnewCode shape, the three enforcement layers, the divergences section
docs/architecture/design-system.mdnewTokens, rarity vocabulary, cva-vs-recipe promotion rule
CLAUDE.mdmodifiedReference entries for both documents

src/routes/ is deleted once empty. src/__tests__/ is the shell's test home — a small extension of spec R2, which named the feature and api cases but not the shell's.

Data & contracts ​

No HTTP contract change: no endpoint added, removed or altered, apps/api/openapi.json untouched, and src/api/generated regenerated-not-edited throughout. pnpm verify:contract must stay green (SC7) — it is a gate on this plan, not an output of it.

The only new type surface is internal to src/api:

  • suspenseOptions<T>(options: UseQueryOptions<T>) → { queryKey, queryFn }, throwing if queryFn is skipToken (unreachable for generated options; the throw is what lets narrowing replace a cast).
  • useHealth(): Health — replacing today's UseHealthResult union, which is deleted.

Test strategy ​

Spec criterionHow it becomes a test
P1 #2, #5 · SC3conventions.test.ts walks src/, asserts no cross-feature import and no route declared outside a feature; each failure names the file
P1 #3Biome, not a test: pnpm lint fails. Traceability cites the lint rule
P1 #4conventions.test.ts: every component a feature's routes.tsx renders has a name ending in Page. Biome cannot express this — it checks filename against export, not the suffix
P2 #1, #2, #3 · SC2HealthPage.test.tsx renders against a mocked facade and asserts the component contains no status branch — the pending and error paths are asserted at the boundary, not in the component
P2 #4 · SC3boundary.test.ts — extended api/generated rule plus the new @tanstack/react-query rule
P3 #1–#4Each guard is proven by a deliberately-violating fixture string fed to the same walker, so the guard is shown to fail before it is trusted
P4 #1, #2 · SC4conventions.test.ts rejects literal colours; token presence asserted against the generated types, not styles.css (research F6)
P5 #1, #2pnpm build with panicThreshold: 'all_errors' — a build step, not a test
SC6useHealth.test.ts (MSW): a malformed payload throws from the validator
SC7The full command set run at step 5

Prose-only conventions — the honest gap SC8 has to admit. Three requirements have no enforcement layer and are stated as prose in react.md under R19, because the code they govern does not exist yet: R7 (algorithmic code in React-free modules), R12 (the three state homes — no client state exists in the app today), and R14 (colocated cva promoted to a config recipe on the move to shared/ui — nothing is shared yet). Each becomes enforceable when the planner gives it something to check; the plan does not pretend otherwise by writing a guard that passes vacuously.

Not directly testable, and what stands in:

  • SC1 ("a new feature touches no file outside its folder") — no test can prove a counterfactual feature. The reference implementation stands in: features/health/ is self-contained, and the guard suite makes the violation that would break SC1 fail. Verified by human review of the diff.
  • SC8 ("every convention stated is true of the repo") — proxied by the enforcement table: each convention in react.md cites its layer, and the ones citing "prose" are the honest gap.
  • P5 #2 (bail-out detectable) — proven once, in the plan's own verification, by building with a deliberately violating component and observing the failure; not kept as a permanent test, since a committed violating file would break every subsequent build.

Alternatives considered ​

  • Orval's useSuspenseQuery output option — rejected: the spec puts Orval configuration out of scope, and research F2 showed a six-line adapter achieves the same with no regeneration.
  • react-error-boundary — rejected on research V3: no dependency needed.
  • ESLint + eslint-plugin-react-hooks for compiler rules — rejected: a second linter beside Biome to duplicate what panicThreshold already fails the build on.
  • A guard test for filename == export — rejected on research V4: Biome expresses it directly.
  • Documenting first, implementing after — rejected: it produces a document describing intentions, and SC8 asks the opposite.
  • MSW for every test — rejected during brainstorming: every component test would pay provider and network-stub setup, and component failures could originate in the facade.

Risks ​

  • Babel re-enters a Babel-free build. +110 ms measured on three components, and it is a per-file transform, so it grows. Mitigation: the number is recorded in react.md; if it becomes material, reactCompilerPreset's rolldown.filter can exclude non-React directories (research F1).
  • panicThreshold: 'all_errors' can block a legitimate pattern the compiler cannot compile. Mitigation: react.md documents the "use no memo" escape and requires a comment citing why — making it a visible, reviewable exception rather than a silent bail-out.
  • The Biome override, if mis-scoped, fails every file in apps/api. Mitigation: the override is added and pnpm lint run in the same task; the scope is apps/web/** (research V4 caveat 2).
  • msw blocks all pnpm commands if its allowBuilds entry is left unresolved — including the commands used to diagnose it. Mitigation: the entry lands in the same commit as the dependency.
  • CLAUDE.md's Reference section may conflict — nestjs.md's entry exists in the shared tree but not on origin/main, so this branch edits the same list. Mitigation: append below the existing entries; expect a trivial conflict at merge if nestjs.md lands first. See Open questions.
  • StrictMode double-rendering with Suspense may surface in tests as duplicated fetches. Mitigation: the MSW tier asserts on resolved data rather than call counts.

Open questions ​

  • nestjs.md's status — it is uncommitted in the shared tree, is not on origin/main, and is already listed in the shared tree's CLAUDE.md. This plan writes react.md as its sibling and edits the same Reference list. Whether nestjs.md merges before, after, or alongside this branch is the human's call; nothing here depends on it, but the merge order determines who resolves the conflict. (Human.)
  • Everything research.md raised is closed: status complete, four verdicts, no blocking items.

Amendments during implementation ​

  • T7 — the shell/route split (2026-08-06). The Architecture section above shows features/health/routes.tsx rendering <App /> and App.tsx importing QueryErrorResetBoundary. T7's own guards rejected both: the Page-suffix rule (P1 #4) forbids a route rendering a non-Page component, and the react-query boundary rule (R11) forbids importing @tanstack/react-query outside src/api. The guards were correct and the diagram was wrong. As built: App.tsx is now a pathless layout route that renders <Outlet /> in place of a hardcoded child; main.tsx builds the router with App as that layout route, wrapping the health feature's route table as its children, so features/health/routes.tsx renders <HealthPage /> directly and satisfies P1 #4. The two react-query usages (QueryErrorResetBoundary, QueryClient/QueryClientProvider) moved into two new files, src/api/QueryBoundary.tsx and src/api/ApiProvider.tsx, both re-exported through src/api/index.ts. They live inside src/api rather than shared/ui because the R11 guard checks the file's own path for /api/, not where a re-export points — a file at shared/ui/QueryBoundary.tsx importing @tanstack/react-query would still fail the guard no matter what re-exported it. App.tsx and main.tsx now import only from src/api, react, react-router and the styling/UI libraries; neither imports @tanstack/react-query directly.
  • Final review — the Architecture section's dependency rule overstates its guard (2026-08-08). The section above states, prefixed "enforced by guard test": features/* → shared/, api/ ✓ · features/* → features/* ✗ · shared/ → features/* ✗. The third arrow is not enforced. conventions.test.ts's crossFeatureImports walker skips every file outside features/ — via featureOf, which returns null for any path not under features/, and the walker filters those out before checking imports — so a file at shared/ui/X.tsx importing a feature would pass the guard silently, and the same is true of any file under src/api. What the guard actually checks is only the first two arrows: a file under features/<name>/ may import shared/ or api/, and may not import a sibling feature. The third arrow remains correct as a stated convention but currently holds only by discipline, not by the test. Not corrected in place — this plan is an approved, committed artifact; the Architecture line above is left as written, and this amendment is the record of the gap.