Skip to content

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-development applies inside every task: no production code before a failing test that demands it. Reach for superpowers:systematic-debugging on 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:
ts
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.ts Expected: 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__core All four are required — @vitejs/plugin-react 6 has no babel option (research F1).
  • [ ] GREEN: rewrite apps/web/vite.config.ts, leaving the proxy block as it is:
ts
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.tsx and import it from App.tsx — the compiler only sees files in the module graph, so an unimported file builds clean (research V2):
tsx
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 status shows 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.tsx before the route table exists:
tsx
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.tsx does not exist.
  • [ ] GREEN: create src/features/health/routes.tsx:
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 mv so history follows them, and fix the imports each move breaks: HealthPage.tsx reaches ../../styled-system/css and ../../api; each relocated test gains one ../; App.tsx imports ./features/health/HealthPage.
  • [ ] Update apps/web/vitest.config.ts: setupFiles: ['./src/testSetup.ts']. The include globs need no change — they are depth-agnostic under src/ (research F5).
  • [ ] Reduce main.tsx to a manifest:
tsx
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 so apps/api's kebab-case is untouched (research V4):
jsonc
  "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 widening filenameCases.
  • [ ] Confirm the rule has teeth: rename src/testSetup.ts back to test-setup.ts, run pnpm lint, watch useFilenamingConvention fire, 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:
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:
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 satisfy exactOptionalPropertyTypes with no cast and no any.
  • [ ] Confirm the test has teeth: replace the throw with return 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 msw and its build-script entry in one step. pnpm add -D msw@2.15.0 from apps/web, then immediately set msw: false under allowBuilds in pnpm-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:
yaml
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):
tsx
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.ts does not exist.
  • [ ] GREEN: create src/api/useHealth.ts:
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.ts to the public surface, deleting UseHealthResult and its branches:
ts
// 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.tsx now 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:
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's QueryErrorResetBoundary supplies the reset (research V3):
tsx
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:
tsx
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:
tsx
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:
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 mock useHealth as HealthPage's test does, so the shell test stays about the shell.
  • [ ] Run pnpm test and pnpm typecheck — expected: green, T4's page test included.
  • [ ] Note the QueryErrorResetBoundary import comes from @tanstack/react-query inside App.tsx, which T7's guard forbids outside src/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, not styles.css — a semantic token emits no CSS variable until something references it (research F6):
ts
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:
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=*.tsx returns nothing outside generated/. 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 and design-system.md must 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:
ts
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.tsx renders <App />, which has no Page suffix; and App.tsx and main.tsx import @tanstack/react-query from outside src/api.
  • [ ] GREEN, by fixing the code rather than loosening the rule. Split shell from route: features/health/routes.tsx renders <HealthPage /> directly; App.tsx keeps the layout, heading, Suspense and boundary but takes its children from the router; main.tsx keeps QueryClientProvider, and QueryErrorResetBoundary moves into a small src/shared/ui/QueryBoundary.tsx that src/api re-exports — so the one react-query import outside the facade disappears. Record the shape you land on; it amends plan.md's diagram.
  • [ ] Extend src/api/__tests__/boundary.test.ts with the react-query rule, keeping its existing api/generated assertion 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, mirroring nestjs.md: an opening saying the build model lives in stack.md and 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: the reactCompilerPreset + @rolldown/plugin-babel wiring 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 why suspenseOptions exists.
  • [ ] 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: a nestjs.md entry 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:contract is untouched-green: no endpoint changed, openapi.json and src/api/generated byte-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, as plan.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.md did not fully draw. The plan has App.tsx as the shell and health/routes.tsx rendering <App />, which the Page-suffix guard (P1 #4) rejects; the react-query guard likewise rejects QueryErrorResetBoundary sitting in App.tsx. The resolution is in T7's GREEN step, and it amends the plan's architecture diagram — carry it into react.md at 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.md does not record the values, design-system.md cites the GW2 Wiki and the values become graduation candidates for domain.md.