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.tsxDependency 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:
| Layer | Owns | Fails on |
|---|---|---|
| Biome | filename == exported symbol (filenameCases: ["export", "camelCase"], scoped apps/web/**) | pnpm lint |
| Guard tests | import boundaries, hand-memoization, literal colours | pnpm test |
| React Compiler | Rules 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-compiler1.0.0,@rolldown/plugin-babel,@babel/core,@types/babel__core— the compiler needs all four (spec R16a, research F1).@vitejs/plugin-react6 has nobabeloption; Vite 8 is Rolldown/Oxc and otherwise Babel-free. Accepted cost: +110 ms on the Vite build, measured.msw2.15.0 — the facade test tier (spec R23). RequiresallowBuilds: msw: falseinpnpm-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. Useunknownplus 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-errorwithout 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"andbaseUrlare removed in TS 7. Use"nodenext"(or"bundler"for Vite/bundler-resolved code) pluspaths: { "*": ["./*"] }in place ofbaseUrl.- TypeScript's default
libincludes DOM. A base tsconfig shared by non-DOM and DOM profiles must pin a non-DOM floor explicitly ("lib": ["ES2023"]), ordocument/windowresolve everywhere, silently defeating profile isolation. A profile that needs DOM adds"DOM","DOM.Iterable"on top of that floor. experimentalDecoratorsandemitDecoratorMetadataare supported for typecheck only —tsc --noEmitaccepts decorator syntax and resolves metadata types, but--noEmitnever emitsdesign:paramtypesat runtime regardless of these options. A decorator-consuming runtime (e.g. NestJS DI) needs a separate emitting compiler for that metadata; seestack.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,
429on 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
| Path | Change | Responsibility |
|---|---|---|
apps/web/vite.config.ts | modified | Compiler wiring: babel({ presets: [reactCompilerPreset({ panicThreshold: 'all_errors' })] }), beside the existing dev proxy |
apps/web/package.json | modified | Five devDependencies |
pnpm-workspace.yaml | modified | allowBuilds: msw: false |
biome.json | modified | overrides entry scoping useFilenamingConvention (error, ["export", "camelCase"]) to apps/web/** |
apps/web/src/api/suspenseOptions.ts | new | Narrows Orval's queryFn off skipToken; no cast, no any |
apps/web/src/api/useHealth.ts | new | useSuspenseQuery + Zod parse → Health |
apps/web/src/api/index.ts | modified | Public surface; the union type and its three branches are deleted |
apps/web/src/api/__tests__/boundary.test.ts | moved | From src/api/boundary.test.ts; gains the react-query import rule |
apps/web/src/api/__tests__/useHealth.test.ts | new | MSW tier: real hook + real validator + malformed payload |
apps/web/src/features/health/HealthPage.tsx | moved | From src/routes/health-page.tsx; loses all three status branches |
apps/web/src/features/health/routes.tsx | new | This feature's route table |
apps/web/src/features/health/__tests__/HealthPage.test.tsx | moved | From src/routes/health-page.test.tsx; mocks the facade |
apps/web/src/shared/ui/ErrorBoundary.tsx | new | Class boundary; resets via QueryErrorResetBoundary |
apps/web/src/App.tsx | modified | Shell: providers, <Suspense>, boundary |
apps/web/src/main.tsx | modified | Manifest — mounts feature route tables, declares none |
apps/web/src/__tests__/App.test.tsx | moved | From src/App.test.tsx |
apps/web/src/__tests__/viteProxy.test.ts | moved | From src/vite-proxy.test.ts — kebab name fails the Biome rule |
apps/web/src/testSetup.ts | moved | From src/test-setup.ts — same reason |
apps/web/vitest.config.ts | modified | setupFiles follows the rename; globs need no change (research F5) |
apps/web/panda.config.ts | modified | Rarity tokens + semantic surface/text tokens |
apps/web/src/__tests__/conventions.test.ts | new | Guard suite: cross-feature imports, react-query boundary, hand-memoization, literal colours |
docs/architecture/react.md | new | Code shape, the three enforcement layers, the divergences section |
docs/architecture/design-system.md | new | Tokens, rarity vocabulary, cva-vs-recipe promotion rule |
CLAUDE.md | modified | Reference 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 ifqueryFnisskipToken(unreachable for generated options; the throw is what lets narrowing replace a cast).useHealth(): Health— replacing today'sUseHealthResultunion, which is deleted.
Test strategy
| Spec criterion | How it becomes a test |
|---|---|
| P1 #2, #5 · SC3 | conventions.test.ts walks src/, asserts no cross-feature import and no route declared outside a feature; each failure names the file |
| P1 #3 | Biome, not a test: pnpm lint fails. Traceability cites the lint rule |
| P1 #4 | conventions.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 · SC2 | HealthPage.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 · SC3 | boundary.test.ts — extended api/generated rule plus the new @tanstack/react-query rule |
| P3 #1–#4 | Each 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 · SC4 | conventions.test.ts rejects literal colours; token presence asserted against the generated types, not styles.css (research F6) |
| P5 #1, #2 | pnpm build with panicThreshold: 'all_errors' — a build step, not a test |
| SC6 | useHealth.test.ts (MSW): a malformed payload throws from the validator |
| SC7 | The 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.mdcites 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
useSuspenseQueryoutput 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-hooksfor compiler rules — rejected: a second linter beside Biome to duplicate whatpanicThresholdalready 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'srolldown.filtercan exclude non-React directories (research F1). panicThreshold: 'all_errors'can block a legitimate pattern the compiler cannot compile. Mitigation:react.mddocuments 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 andpnpm lintrun in the same task; the scope isapps/web/**(research V4 caveat 2). mswblocks all pnpm commands if itsallowBuildsentry 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 onorigin/main, so this branch edits the same list. Mitigation: append below the existing entries; expect a trivial conflict at merge ifnestjs.mdlands 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 onorigin/main, and is already listed in the shared tree'sCLAUDE.md. This plan writesreact.mdas its sibling and edits the same Reference list. Whethernestjs.mdmerges 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.mdraised is closed: statuscomplete, four verdicts, no blocking items.
Amendments during implementation
- T7 — the shell/route split (2026-08-06). The Architecture section above shows
features/health/routes.tsxrendering<App />andApp.tsximportingQueryErrorResetBoundary. T7's own guards rejected both: thePage-suffix rule (P1 #4) forbids a route rendering a non-Pagecomponent, and the react-query boundary rule (R11) forbids importing@tanstack/react-queryoutsidesrc/api. The guards were correct and the diagram was wrong. As built:App.tsxis now a pathless layout route that renders<Outlet />in place of a hardcoded child;main.tsxbuilds the router withAppas that layout route, wrapping the health feature's route table as itschildren, sofeatures/health/routes.tsxrenders<HealthPage />directly and satisfies P1 #4. The two react-query usages (QueryErrorResetBoundary,QueryClient/QueryClientProvider) moved into two new files,src/api/QueryBoundary.tsxandsrc/api/ApiProvider.tsx, both re-exported throughsrc/api/index.ts. They live insidesrc/apirather thanshared/uibecause the R11 guard checks the file's own path for/api/, not where a re-export points — a file atshared/ui/QueryBoundary.tsximporting@tanstack/react-querywould still fail the guard no matter what re-exported it.App.tsxandmain.tsxnow import only fromsrc/api,react,react-routerand the styling/UI libraries; neither imports@tanstack/react-querydirectly. - 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'scrossFeatureImportswalker skips every file outsidefeatures/— viafeatureOf, which returnsnullfor any path not underfeatures/, and the walker filters those out before checking imports — so a file atshared/ui/X.tsximporting a feature would pass the guard silently, and the same is true of any file undersrc/api. What the guard actually checks is only the first two arrows: a file underfeatures/<name>/may importshared/orapi/, 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.