Tasks 010 — Frontend conventions
For agentic workers: REQUIRED SUB-SKILL —
superpowers:subagent-driven-development(one implementer per task, then a two-stage review: spec compliance, then code quality).superpowers:test-driven-developmentapplies inside every task: no production code before a failing test that demands it. Reach forsuperpowers:systematic-debuggingon any surprise rather than guessing. Steps use- [ ]for tracking.
Execution skill: superpowers:subagent-driven-development, with superpowers:test-driven-development inside every task and superpowers:systematic-debugging on any surprise.
Derived from plan.md (approved, 2026-08-05). Each task is small, independently verifiable, and reviewed as its own diff. A task is done only when it satisfies the Definition of Done in CLAUDE.md: typecheck clean, tests pass, the named test traces to its criterion, no any / no unexplained escape hatches, the human has reviewed the diff.
Goal: give apps/web a documented, enforced shape — feature-owned folders, one path to server data, a token layer, and three enforcement layers with one owner each — proven by rebuilding the health round-trip as the reference implementation.
Global Constraints live in plan.md (copied verbatim from the architecture docs) and apply to every task below — they are not repeated per task. The load-bearing ones for 010: no any, no non-null !, no @ts-expect-error without a comment, validate everything crossing a boundary at runtime, match surrounding style, no file written under docs/superpowers/.
Build order follows plan.md §Approach. Tasks are drawn so a reviewer can accept one without the next.
T1 — The React Compiler runs, and a bail-out fails the build
Satisfies: R16, R16a, R17, R17a, P5 #1, P5 #2. (no dependency) → every later task is vetted by it.
Files:
- Modify:
apps/web/vite.config.ts - Modify:
apps/web/package.json(four devDependencies) - Test:
apps/web/src/vite-proxy.test.ts(extended; T2 renames it)
Interfaces produced: none in code. The build now rejects Rules-of-React violations.
- [ ] RED: add to
apps/web/src/vite-proxy.test.ts. It reads the config source rather than the resolved plugin objects, because plugin identity is an implementation detail of Rolldown while the declared wiring is the thing under test:
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('T1 — React Compiler wiring', () => {
const source = readFileSync(
join(import.meta.dirname, '..', 'vite.config.ts'),
'utf8',
);
it('R16a: the compiler is wired through @rolldown/plugin-babel, not a `babel` option', () => {
expect(source).toContain("from '@rolldown/plugin-babel'");
expect(source).toContain('reactCompilerPreset');
});
it('R17: a bail-out fails the build rather than passing silently', () => {
expect(source).toMatch(/panicThreshold:\s*'all_errors'/);
});
});- [ ] Run it, watch it fail.
pnpm --filter @gw2priory/web exec vitest run src/vite-proxy.test.tsExpected: FAIL — the config imports neither symbol yet. - [ ] Install the four devDependencies. From
apps/web:pnpm add -D babel-plugin-react-compiler@1.0.0 @rolldown/plugin-babel @babel/core @types/babel__coreAll four are required —@vitejs/plugin-react6 has nobabeloption (research F1). - [ ] GREEN: rewrite
apps/web/vite.config.ts, leaving the proxy block as it is:
import babel from '@rolldown/plugin-babel';
import react, { reactCompilerPreset } from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
// Dev-only proxy: `vite dev` forwards `/api/*` to the Nest api so the browser never needs to know
// the api's port. Production networking (CORS, real origins) is out of scope (spec 004 R12).
//
// The React Compiler runs through @rolldown/plugin-babel: Vite 8 is Rolldown/Oxc and
// @vitejs/plugin-react 6 exposes no `babel` option — passing one is ignored in silence
// (research.md F1). `panicThreshold: 'all_errors'` makes a bail-out a build failure rather than a
// component the compiler quietly skipped (research.md V2).
export default defineConfig({
plugins: [
react(),
babel({
presets: [reactCompilerPreset({ panicThreshold: 'all_errors' })],
}),
],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
});- [ ] Run the test, watch it pass, then
pnpm --filter @gw2priory/web build— expected: succeeds, a ~370 ms Vite build against a ~260 ms baseline (research V1). - [ ] Confirm the gate has teeth. Create
apps/web/src/__probe.tsxand import it fromApp.tsx— the compiler only sees files in the module graph, so an unimported file builds clean (research V2):
import { useState } from 'react';
export function Probe({ on }: { on: boolean }) {
if (on) {
const [n] = useState(0);
return <p>{n}</p>;
}
return <p>off</p>;
} Run `pnpm --filter @gw2priory/web build`. Expected: FAILS with
`ReactCompilerError: Hooks must always be called in a consistent order`.
**Then delete the probe and revert the `App.tsx` import** — a committed violation breaks every
later build.
- [ ] Check the tree:
git statusshows only the config and manifest changes. - [ ] Commit.
Verified by: T1 — React Compiler wiring › R16a and › R17 in src/vite-proxy.test.ts, plus the observed build failure from the probe step (proven once, not committed).
T2 — apps/web/src takes the documented shape, and Biome enforces the names
Satisfies: R1, R2, R3, R4 (the Page suffix is applied here; T7 enforces it), R5, R19a, R23a, P1 #1, P1 #3. (depends on T1)
Files:
- Move:
src/routes/health-page.tsx→src/features/health/HealthPage.tsx - Move:
src/routes/health-page.test.tsx→src/features/health/__tests__/HealthPage.test.tsx - Move:
src/api/boundary.test.ts→src/api/__tests__/boundary.test.ts - Move:
src/App.test.tsx→src/__tests__/App.test.tsx - Move:
src/vite-proxy.test.ts→src/__tests__/viteProxy.test.ts - Move:
src/test-setup.ts→src/testSetup.ts - Create:
src/features/health/routes.tsx,src/shared/ui/.gitkeep,src/shared/lib/.gitkeep - Modify:
src/main.tsx,src/App.tsx(import path only),apps/web/vitest.config.ts,biome.json - Delete:
src/routes/once empty
Interfaces produced: features/health/routes.tsx exports routes: RouteObject[], consumed by main.tsx. HealthPage keeps its current behaviour — T5 changes that.
- [ ] RED: write
src/features/health/__tests__/routes.test.tsxbefore the route table exists:
import { describe, expect, it } from 'vitest';
import { routes } from '../routes';
describe('T2 — health feature routes', () => {
it('R5/P1 #2: the feature owns its own route table', () => {
expect(routes).toHaveLength(1);
expect(routes[0]?.path).toBe('/');
});
});- [ ] Run it, watch it fail —
routes.tsxdoes not exist. - [ ] GREEN: create
src/features/health/routes.tsx:
import type { RouteObject } from 'react-router';
import { App } from '../../App';
// The feature owns its route table; main.tsx only assembles the tables it is given (R5).
export const routes: RouteObject[] = [{ path: '/', element: <App /> }];- [ ] Move the files with
git mvso history follows them, and fix the imports each move breaks:HealthPage.tsxreaches../../styled-system/cssand../../api; each relocated test gains one../;App.tsximports./features/health/HealthPage. - [ ] Update
apps/web/vitest.config.ts:setupFiles: ['./src/testSetup.ts']. Theincludeglobs need no change — they are depth-agnostic undersrc/(research F5). - [ ] Reduce
main.tsxto a manifest:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router';
import { routes as healthRoutes } from './features/health/routes';
import './index.css';
// A manifest, not a home for logic: routes are declared by the features that own them (R5).
const router = createBrowserRouter([...healthRoutes]);
const queryClient = new QueryClient();
const rootElement = document.getElementById('root');
if (rootElement === null) {
throw new Error('#root element not found in index.html');
}
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);- [ ] Run
pnpm test— expected: green, every test found at its new path. - [ ] Enable the naming rule in
biome.json, scoped soapps/api's kebab-case is untouched (research V4):
"overrides": [
{
"includes": ["apps/web/**"],
"linter": {
"rules": {
"style": {
"useFilenamingConvention": {
"level": "error",
"options": { "filenameCases": ["export", "camelCase"] }
}
}
}
}
}
]- [ ] Run
pnpm lint. Expected: clean. A file still flagged is one the moves missed — fix the name rather than wideningfilenameCases. - [ ] Confirm the rule has teeth: rename
src/testSetup.tsback totest-setup.ts, runpnpm lint, watchuseFilenamingConventionfire, restore. - [ ] Commit.
Verified by: T2 — health feature routes › R5/P1 #2; pnpm lint clean with the override active; the full suite green at the new paths.
T3 — suspenseOptions narrows Orval's query options
Satisfies: R25. (depends on T2) → unblocks T4.
Files:
- Create:
src/api/suspenseOptions.ts - Test:
src/api/__tests__/suspenseOptions.test.ts
Interfaces produced: suspenseOptions<T>(options: UseQueryOptions<T>) → { queryKey, queryFn }, accepted by useSuspenseQuery. Consumed by every facade hook from T4 onward.
- [ ] RED: write
src/api/__tests__/suspenseOptions.test.ts:
import { skipToken } from '@tanstack/react-query';
import { describe, expect, it } from 'vitest';
import { suspenseOptions } from '../suspenseOptions';
describe('T3 — suspenseOptions', () => {
it('R25: passes the query key and function through unchanged', () => {
const queryFn = () => Promise.resolve('payload');
const options = suspenseOptions({ queryKey: ['health'], queryFn });
expect(options.queryKey).toEqual(['health']);
expect(options.queryFn).toBe(queryFn);
});
it('R25: rejects skipToken, which useSuspenseQuery cannot accept', () => {
expect(() =>
suspenseOptions({ queryKey: ['health'], queryFn: skipToken }),
).toThrow(/queryFn/);
});
});- [ ] Run it, watch it fail — the module does not exist.
- [ ] GREEN: create
src/api/suspenseOptions.ts:
import type { UseQueryOptions } from '@tanstack/react-query';
// Orval types the generated `queryFn` as `QueryFunction | typeof skipToken`; useSuspenseQuery forbids
// skipToken, and `exactOptionalPropertyTypes: true` removes the usual slack (research.md F2). Narrowing
// here — once, in a plain function — is what lets every facade hook stay cast-free. The guard must not
// live inline in a hook: an `if` before useSuspenseQuery would be a conditional-hook violation and
// would fail the compiler's build check (R17).
export function suspenseOptions<T>(options: UseQueryOptions<T>) {
const { queryKey, queryFn } = options;
if (typeof queryFn !== 'function') {
throw new Error(
'generated query options always carry a queryFn; skipToken is not reachable here',
);
}
return { queryKey, queryFn };
}- [ ] Run the test, watch it pass. Then
pnpm typecheck— the step that matters: the narrowing must satisfyexactOptionalPropertyTypeswith no cast and noany. - [ ] Confirm the test has teeth: replace the
throwwithreturn options, watch the skipToken case fail, restore. - [ ] Commit.
Verified by: T3 — suspenseOptions › R25 (both cases) in src/api/__tests__/suspenseOptions.test.ts.
T4 — useHealth returns validated data, and MSW proves the path
Satisfies: R8, R11, R22, R22a, R23, P2 #1, P2 #4, SC6. (depends on T3) → unblocks T5.
Files:
- Create:
src/api/useHealth.ts - Modify:
src/api/index.ts(the union and its three branches are deleted) - Modify:
pnpm-workspace.yaml(allowBuilds) - Test:
src/api/__tests__/useHealth.test.tsx
Interfaces produced: useHealth(): Health and type Health. UseHealthResult is deleted; T5 updates its only consumer.
- [ ] Add
mswand its build-script entry in one step.pnpm add -D msw@2.15.0fromapps/web, then immediately setmsw: falseunderallowBuildsinpnpm-workspace.yaml— pnpm writes an unresolved placeholder that blocks every subsequent pnpm command, including the ones used to diagnose it (research F3). The postinstall only copies a browser service worker the jsdom tier never uses:
allowBuilds:
esbuild: true
"@swc/core": true
"@scarf/scarf": false
# msw's postinstall copies a browser service worker; the Node/jsdom test tier does not use it.
msw: false- [ ] RED: write
src/api/__tests__/useHealth.test.tsx. The first case drives the hook through a real Suspense boundary; the second asserts validation at the layer that owns it — the form verified in discovery (research F4):
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { Suspense, type ReactNode } from 'react';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { healthControllerGetHealth } from '../generated/endpoints/health/health';
import { HealthControllerGetHealthResponse } from '../generated/endpoints/health/health.zod';
import { useHealth } from '../useHealth';
const server = setupServer(
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
function wrapper({ children }: { children: ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return (
<QueryClientProvider client={queryClient}>
<Suspense fallback={null}>{children}</Suspense>
</QueryClientProvider>
);
}
describe('T4 — useHealth', () => {
it('R8/P2 #1: returns validated data, not a status union', async () => {
const { result } = renderHook(() => useHealth(), { wrapper });
await waitFor(() => expect(result.current).toEqual({ status: 'ok' }));
});
it('SC6: a malformed payload fails validation rather than reaching a component', async () => {
server.use(
http.get('/api/health', () => HttpResponse.json({ status: 'nope' })),
);
const response = await healthControllerGetHealth();
expect(() =>
HealthControllerGetHealthResponse.parse(response.data),
).toThrow();
});
});- [ ] Run it, watch it fail —
useHealth.tsdoes not exist. - [ ] GREEN: create
src/api/useHealth.ts:
import { useSuspenseQuery } from '@tanstack/react-query';
import type { z } from 'zod';
import { getHealthControllerGetHealthQueryOptions } from './generated/endpoints/health/health';
import { HealthControllerGetHealthResponse } from './generated/endpoints/health/health.zod';
import { suspenseOptions } from './suspenseOptions';
export type Health = z.infer<typeof HealthControllerGetHealthResponse>;
// Orval does not wire its generated validators into its generated hooks (spec 004, research F10), so
// this is where response integrity is enforced. A parse failure throws into the same boundary as a
// network failure (R9) — a component receives data or nothing.
export function useHealth(): Health {
const query = useSuspenseQuery(
suspenseOptions(getHealthControllerGetHealthQueryOptions()),
);
return HealthControllerGetHealthResponse.parse(query.data.data);
}- [ ] Reduce
src/api/index.tsto the public surface, deletingUseHealthResultand its branches:
// The facade (spec 004 R9): the single place a generated hook is composed with its generated Zod
// validator. Application code imports only from `src/api`, never from `src/api/generated` (SC6);
// `__tests__/boundary.test.ts` asserts that statically.
export { type Health, useHealth } from './useHealth';- [ ] Run the suite.
HealthPage.test.tsxnow fails — it mocks a union that no longer exists. Fold T5's page change into this commit rather than committing a red tree: the two are one reviewable change, and the plan's task boundary is drawn for review, not for keeping the suite red. - [ ] Commit (with T5's page and shell changes, per the previous step).
Verified by: T4 — useHealth › R8/P2 #1 and › SC6 in src/api/__tests__/useHealth.test.tsx.
T5 — One <Suspense> and one boundary per route; the page loses its branches
Satisfies: R9, R10, R22b, P2 #2, P2 #3, SC2. (depends on T4; commits with it)
Files:
- Create:
src/shared/ui/ErrorBoundary.tsx - Modify:
src/App.tsx,src/features/health/HealthPage.tsx - Test:
src/shared/ui/__tests__/ErrorBoundary.test.tsx,src/features/health/__tests__/HealthPage.test.tsx
Interfaces produced: ErrorBoundary, props { children, onReset, fallback: (retry: () => void) => ReactNode }.
- [ ] RED: write
src/shared/ui/__tests__/ErrorBoundary.test.tsx:
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ErrorBoundary } from '../ErrorBoundary';
function Boom(): never {
throw new Error('boom');
}
describe('T5 — ErrorBoundary', () => {
it('R22b/P2 #3: renders the fallback when a child throws', () => {
// React logs caught render errors; silence it so the failure output stays readable.
vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary onReset={() => {}} fallback={() => <p role="alert">failed</p>}>
<Boom />
</ErrorBoundary>,
);
expect(screen.getByRole('alert')).toBeInTheDocument();
});
it('R22b: renders children when nothing throws', () => {
render(
<ErrorBoundary onReset={() => {}} fallback={() => <p role="alert">failed</p>}>
<p>fine</p>
</ErrorBoundary>,
);
expect(screen.getByText('fine')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});- [ ] Run it, watch it fail.
- [ ] GREEN: create
src/shared/ui/ErrorBoundary.tsx. A class, not a dependency — React exposes no hook API for boundaries, and TanStack'sQueryErrorResetBoundarysupplies the reset (research V3):
import { Component, type ReactNode } from 'react';
type Props = {
children: ReactNode;
onReset: () => void;
fallback: (retry: () => void) => ReactNode;
};
type State = { error: Error | null };
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
retry = (): void => {
this.props.onReset();
this.setState({ error: null });
};
render(): ReactNode {
return this.state.error === null
? this.props.children
: this.props.fallback(this.retry);
}
}- [ ] Simplify
HealthPage.tsx— all three branches go:
import { css } from '../../../styled-system/css';
import { useHealth } from '../../api';
// No loading or error branch: the route's <Suspense> and error boundary own both (R10).
const statusStyles = css({ fontSize: 'md', color: 'text.muted' });
export function HealthPage() {
const health = useHealth();
return <p className={statusStyles}>API status: {health.status}</p>;
}- [ ] Rewrite
src/features/health/__tests__/HealthPage.test.tsx— one case now, not three:
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as api from '../../../api';
import { HealthPage } from '../HealthPage';
afterEach(() => vi.restoreAllMocks());
describe('T5 — health page', () => {
it('SC2/P2 #1: renders the status with no branch of its own', () => {
vi.spyOn(api, 'useHealth').mockReturnValue({ status: 'ok' });
render(<HealthPage />);
expect(screen.getByText(/ok/i)).toBeInTheDocument();
});
});- [ ] Move the fallback and boundary into the shell.
src/App.tsx:
import { Separator } from '@base-ui/react/separator';
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { Suspense } from 'react';
import { css } from '../styled-system/css';
import { HealthPage } from './features/health/HealthPage';
import { ErrorBoundary } from './shared/ui/ErrorBoundary';
const headingStyles = css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'text.strong',
});
// Loading and error UI are declared once, here — never inside the component that needs the data (R10).
export function App() {
return (
<main>
<h1 className={headingStyles}>GW2 Priory</h1>
<Separator />
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallback={(retry) => (
<p role="alert">
Health check failed.{' '}
<button type="button" onClick={retry}>
Retry
</button>
</p>
)}
>
<Suspense fallback={<p>Loading…</p>}>
<HealthPage />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</main>
);
}- [ ] Update
src/__tests__/App.test.tsx: keep its three assertions (heading, separator, Panda class) and mockuseHealthasHealthPage's test does, so the shell test stays about the shell. - [ ] Run
pnpm testandpnpm typecheck— expected: green, T4's page test included. - [ ] Note the
QueryErrorResetBoundaryimport comes from@tanstack/react-queryinsideApp.tsx, which T7's guard forbids outsidesrc/api. Resolve it there, not here — T7 moves the providers. - [ ] Commit (together with T4).
Verified by: T5 — ErrorBoundary › R22b/P2 #3, T5 — health page › SC2/P2 #1, and the existing shell assertions in src/__tests__/App.test.tsx.
T6 — The token layer, and no literal colours left behind
Satisfies: R13, P4 #1, P4 #2. (depends on T5)
Files:
- Modify:
apps/web/panda.config.ts - Test:
src/__tests__/tokens.test.ts
Interfaces produced: tokens colors.rarity.{basic,fine,masterwork,rare,exotic,ascended,legendary} and semantic tokens colors.surface, colors.text.strong, colors.text.muted — the names T5's components already reference.
- [ ] RED: write
src/__tests__/tokens.test.ts. Assert against the generated types, notstyles.css— a semantic token emits no CSS variable until something references it (research F6):
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
const tokenTypes = readFileSync(
join(import.meta.dirname, '..', '..', 'styled-system', 'tokens', 'tokens.d.ts'),
'utf8',
);
describe('T6 — design tokens', () => {
it('P4 #1: every GW2 item rarity has a colour token', () => {
for (const rarity of [
'basic',
'fine',
'masterwork',
'rare',
'exotic',
'ascended',
'legendary',
]) {
expect(tokenTypes).toContain(`rarity.${rarity}`);
}
});
it('R13: surface and text are semantic tokens, not raw greys', () => {
expect(tokenTypes).toContain('surface');
expect(tokenTypes).toContain('text.strong');
expect(tokenTypes).toContain('text.muted');
});
});- [ ] Run it, watch it fail — the stock config defines none of them.
- [ ] GREEN: extend
apps/web/panda.config.ts:
theme: {
extend: {
tokens: {
colors: {
// GW2 item rarity colours — domain vocabulary, not decoration.
rarity: {
basic: { value: '#ffffff' },
fine: { value: '#62a4da' },
masterwork: { value: '#1a9306' },
rare: { value: '#fcd00b' },
exotic: { value: '#ffa405' },
ascended: { value: '#fb3e8d' },
legendary: { value: '#4c139d' },
},
},
},
semanticTokens: {
colors: {
surface: {
value: { base: '{colors.gray.50}', _dark: '{colors.gray.900}' },
},
text: {
strong: {
value: { base: '{colors.gray.900}', _dark: '{colors.gray.50}' },
},
muted: {
value: { base: '{colors.gray.700}', _dark: '{colors.gray.300}' },
},
},
},
},
},
},- [ ] Run
pnpm --filter @gw2priory/web exec panda codegen, then the test — expected: pass. - [ ] Confirm nothing literal survives in components:
grep -rEn "#[0-9a-fA-F]{3,8}" apps/web/src --include=*.tsxreturns nothing outsidegenerated/. T7 makes this permanent. - [ ] Source the rarity values. Check
docs/architecture/domain.md; if it does not record them, they come from the GW2 Wiki anddesign-system.mdmust say so at T8. Do not invent a citation. - [ ] Commit.
Verified by: T6 — design tokens › P4 #1 and › R13 in src/__tests__/tokens.test.ts.
T7 — The conventions guard suite
Satisfies: R4 (enforced here), R6, R11, R16, R18, P1 #2, P1 #4, P1 #5, P3 #1, P3 #2, P3 #3, P3 #4, SC3, SC4, SC5. (depends on T6)
Files:
- Create:
src/__tests__/conventions.test.ts - Modify:
src/api/__tests__/boundary.test.ts(adds the react-query rule) - Modify:
src/App.tsx,src/main.tsx,src/features/health/routes.tsx(the split the guards force)
Interfaces produced: four predicates — crossFeatureImports, queryImportsOutsideApi, handMemoization, literalColours — each (files: SourceFile[]) => string[] returning offending paths, so each is provable against a fixture as well as against the repo.
- [ ] RED: write
src/__tests__/conventions.test.ts. Every rule runs twice — against the repo (must be empty) and against a violating fixture (must not be). A guard that has never failed is not known to work:
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative, resolve, sep } from 'node:path';
import { describe, expect, it } from 'vitest';
type SourceFile = { path: string; source: string };
const webSrc = join(import.meta.dirname, '..');
const featuresDir = join(webSrc, 'features');
// This file names every pattern it forbids, so scanning it would report itself.
const SELF = join(webSrc, '__tests__', 'conventions.test.ts');
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
if (statSync(path).isDirectory()) {
return path.includes(`${sep}generated`) ? [] : walk(path);
}
return /\.tsx?$/.test(path) ? [path] : [];
});
}
const repoFiles: SourceFile[] = walk(webSrc)
.filter((path) => path !== SELF)
.map((path) => ({ path, source: readFileSync(path, 'utf8') }));
function importSpecifiers(source: string): string[] {
return [...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map(
(match) => match[1] ?? '',
);
}
function featureOf(path: string): string | null {
const rel = relative(featuresDir, path);
if (rel.startsWith('..')) return null;
return rel.split(sep)[0] ?? null;
}
function crossFeatureImports(files: SourceFile[]): string[] {
return files
.filter(({ path, source }) => {
const own = featureOf(path);
if (own === null) return false;
return importSpecifiers(source).some((specifier) => {
if (!specifier.startsWith('.')) return false;
const target = featureOf(resolve(dirname(path), specifier));
return target !== null && target !== own;
});
})
.map(({ path }) => path);
}
function queryImportsOutsideApi(files: SourceFile[]): string[] {
return files
.filter(({ path }) => !path.includes(`${sep}api${sep}`))
.filter(({ source }) =>
importSpecifiers(source).some(
(specifier) =>
specifier === '@tanstack/react-query' ||
specifier.includes('api/generated'),
),
)
.map(({ path }) => path);
}
function handMemoization(files: SourceFile[]): string[] {
return files
.filter(({ source }) =>
/\buseMemo\s*\(|\buseCallback\s*\(|\bmemo\s*\(/.test(source),
)
.map(({ path }) => path);
}
function literalColours(files: SourceFile[]): string[] {
return files
.filter(({ source }) =>
/#[0-9a-fA-F]{3,8}\b|\brgba?\(|\bhsla?\(/.test(source),
)
.map(({ path }) => path);
}
describe('T7 — conventions', () => {
it('R6/P1 #5/SC3: no feature imports another feature', () => {
expect(crossFeatureImports(repoFiles)).toEqual([]);
});
it('R6/P3 #1: the rule catches a violation', () => {
expect(
crossFeatureImports([
{
path: join(featuresDir, 'planner', 'PlannerPage.tsx'),
source: "import { thing } from '../health/HealthPage';",
},
]),
).toHaveLength(1);
});
it('R11/P2 #4/SC3: react-query and generated code stay inside src/api', () => {
expect(queryImportsOutsideApi(repoFiles)).toEqual([]);
});
it('R11/P3 #2: the rule catches a violation', () => {
expect(
queryImportsOutsideApi([
{
path: join(featuresDir, 'planner', 'usePlanner.ts'),
source: "import { useQuery } from '@tanstack/react-query';",
},
]),
).toHaveLength(1);
});
it('R16/SC5: nothing is memoized by hand', () => {
expect(handMemoization(repoFiles)).toEqual([]);
});
it('R16/P3 #3: the rule catches a violation', () => {
expect(
handMemoization([
{
path: join(webSrc, 'Thing.tsx'),
source: 'const x = useMemo(() => 1, []);',
},
]),
).toHaveLength(1);
});
it('P4 #2/SC4: no literal colour values', () => {
expect(literalColours(repoFiles)).toEqual([]);
});
it('P3 #4: the rule catches a violation', () => {
expect(
literalColours([
{ path: join(webSrc, 'Thing.tsx'), source: "css({ color: '#a335ee' })" },
]),
).toHaveLength(1);
});
it('P1 #4: every component a route renders is named <Name>Page', () => {
const offenders = readdirSync(featuresDir).flatMap((feature) => {
const source = readFileSync(
join(featuresDir, feature, 'routes.tsx'),
'utf8',
);
return [...source.matchAll(/element:\s*<([A-Z]\w+)/g)]
.map((match) => match[1] ?? '')
.filter((component) => !component.endsWith('Page'));
});
expect(offenders).toEqual([]);
});
});- [ ] Run it. Two repo-facing cases are expected to FAIL, and both are real:
health/routes.tsxrenders<App />, which has noPagesuffix; andApp.tsxandmain.tsximport@tanstack/react-queryfrom outsidesrc/api. - [ ] GREEN, by fixing the code rather than loosening the rule. Split shell from route:
features/health/routes.tsxrenders<HealthPage />directly;App.tsxkeeps the layout, heading, Suspense and boundary but takes its children from the router;main.tsxkeepsQueryClientProvider, andQueryErrorResetBoundarymoves into a smallsrc/shared/ui/QueryBoundary.tsxthatsrc/apire-exports — so the one react-query import outside the facade disappears. Record the shape you land on; it amendsplan.md's diagram. - [ ] Extend
src/api/__tests__/boundary.test.tswith the react-query rule, keeping its existingapi/generatedassertion and its "facade exports a health hook" case. - [ ] Run
pnpm test,pnpm lint,pnpm typecheck,pnpm build— all green. - [ ] Commit.
Verified by: the nine named cases in src/__tests__/conventions.test.ts, each repo-facing rule paired with its fixture counterpart.
T8 — react.md and design-system.md
Satisfies: R7 (prose), R12 (prose), R14 (prose), R15, R19, R20, R21, P1 #1, P4 #3, SC8. (depends on T7)
Files:
- Create:
docs/architecture/react.md,docs/architecture/design-system.md - Modify:
CLAUDE.md(Reference section)
Interfaces produced: none.
- [ ] Write
docs/architecture/react.md, mirroringnestjs.md: an opening saying the build model lives instack.mdand this file is about code shape; then feature layout, naming, the boundary rule, data flow, state homes, the enforcement table, testing, and a closing "Where this diverges" section naming PascalCase filenames against the api's kebab-case,__tests__/folders against the api's colocated.test.ts, no hand-memoization, no global state library, and Suspense-only data access. - [ ] Graduate the research findings listed in
research.md§Graduation: thereactCompilerPreset+@rolldown/plugin-babelwiring with its four devDependencies and the +110 ms measurement;panicThreshold: 'all_errors'with both limits — module-graph-only coverage, and tests not running the compiler, so CI must run the build; and whysuspenseOptionsexists. - [ ] State the two honest limits rather than leaving them to be discovered: errors reach the boundary only when there is no data to show, so a failed background refetch keeps rendering stale data (research V3); and R7, R12 and R14 are prose with no enforcement layer until the planner gives them something to check.
- [ ] Document the
"use no memo"escape: a component the compiler cannot compile may opt out, but only with a comment saying why — a visible exception, not a silent bail-out. - [ ] Write
docs/architecture/design-system.md: the rarity vocabulary and where its values come from, the semantic tokens, tokens-never-literals, the colocated-cva-until-promoted rule, and the note that a semantic token emits no CSS variable until referenced — so guards assert against generated types. - [ ] Add both to
CLAUDE.md's Reference section, appended below the existing entries: anestjs.mdentry may arrive on another branch, so append rather than reorder. - [ ] Run
pnpm docs:build— expected: succeeds. - [ ] Re-read both documents against the repo. Every claim must be true now; a sentence describing an intention is a bug in this task (SC8).
- [ ] Commit.
Verified by: human review against SC8 — each convention in react.md names its enforcement layer and that layer exists. pnpm docs:build green.
T9 — Verification and traceability
Satisfies: R24, SC1, SC7, SC8. (depends on T8)
Files:
Modify:
specs/010-frontend-conventions/spec.md(traceability table only)[ ] Run the full set and paste the actual output into the PR rather than asserting success:
pnpm typecheck,pnpm test,pnpm lint,pnpm build,pnpm verify:contract.[ ] Run the app.
pnpm dev, load it, confirm the health round-trip renders — and that the Suspense fallback appears before it, which no test asserts (P2 #2 in a real browser).[ ] Confirm
verify:contractis untouched-green: no endpoint changed,openapi.jsonandsrc/api/generatedbyte-identical (spec §HTTP contract).[ ] Fill the traceability table in
spec.md: one row per acceptance scenario and success criterion, naming the test that covers it. Rows met by Biome or by the build cite that instead of a test name; SC1 and SC8 cite human review, asplan.md§Test strategy states.[ ] Apply
superpowers:verification-before-completion: every completeness claim in the PR carries the command output that backs it.[ ] Commit.
Verified by: the five commands' output recorded in the PR, and the completed traceability table.
Notes
Staging area for decisions and surprises found during implementation — including anything that turned out differently from what plan.md assumed. Move each one into spec.md, research.md, or docs/ before closing the feature; this section is not a home.
- T7 forces a shell/route split
plan.mddid not fully draw. The plan hasApp.tsxas the shell andhealth/routes.tsxrendering<App />, which thePage-suffix guard (P1 #4) rejects; the react-query guard likewise rejectsQueryErrorResetBoundarysitting inApp.tsx. The resolution is in T7's GREEN step, and it amends the plan's architecture diagram — carry it intoreact.mdat T8. - T4 and T5 commit together. The task boundary is drawn for review, but splitting the commit would leave the suite red between them.
- Rarity colour provenance (T6) — if
domain.mddoes not record the values,design-system.mdcites the GW2 Wiki and the values become graduation candidates fordomain.md.