CLAUDE.md — frontend#
Frontend-specific guidance for the React + TypeScript app under /frontend.
Read together with the repository root CLAUDE.md (ask-don't-guess, TDD,
DDD, and project context all still apply here).
Testing strategy#
- Unit tests are welcome and expected — TDD applies to the frontend too: write a failing test first, then the minimal code to make it pass, then refactor.
- No test framework is installed yet. Use Vitest + React Testing Library: it fits the existing Vite setup with no extra bundler config, and is the natural choice for a Vite-based React project.
- Use
@vitest/coverage-v8to produce coverage output, feeding SonarCloud for code quality/coverage analysis (mirrors the backend's JaCoCo → SonarCloud setup). - Coverage is a gate.
coverage.thresholdsinvite.config.tsfailsnpm run test:coveragebelow the minimum, andfrontend-ci.ymlruns it. Theinclude/excludethere are deliberate: without them the report covers whatever the tests happened to import, which letApp.cssin with empty counters and leftmain.tsxout entirely. - Prefer driving the UI over calling the module functions directly. The interaction tests tick checkboxes and use the add row, so what they pin down is what a user does, not the shape of the API layer.
dates.tsis the exception, and is tested directly. It is the only pure logic here and the only place an off-by-one hides. Every function takes today as an ISO string rather than reading the clock, so a render is a pure function of its inputs and no test fakes a clock. Keep it that way, and keep the arithmetic anchored at UTC midnight:new Date('2026-10-26')is the 25th west of UTC, and day arithmetic across a daylight-saving change is off by one.- Test fixtures compute dates from the real today, never hardcode them. The board groups rows by how far away they are, so a fixed date changes section as time passes.
- Do not assert a transient state against an immediately-resolving stub. The loading test holds the board's fetch open on purpose; the version that did not was a race that passed on timing.
- Optimistic updates need the board gated on its first load. The list response replaces the
whole array, so anything added or ticked while it was still in flight was silently discarded.
Nothing is interactive until
loadingis false. Mock-driven tests answer instantly and can never show this; the browser suite against a slow backend did.
Module layout#
api.tsholds the wire: the types the backend speaks, the URLs, and everyfetch. It is the only place that knows a request shape.dates.tsholds due-date arithmetic and the words the board puts on screen.App.tsxholds state and composition, and nothing else.components/holds the pieces. They take callbacks and data; none of them fetches.- This split replaced a single 413-line
App.tsx. Its own header comment had named the API functions as the seam to cut first, which is where the cut was made.
Accessibility#
- An icon that carries meaning keeps its word, in a
.visually-hiddenspan. The importance dot is the example: sighted users get the dot, screen readers get "High". - Never encode meaning in colour alone. The importance levels differ in fill — hollow, solid, ringed — so they survive greyscale, colour blindness and high-contrast modes.
- Prefer a real control to a styled one. The row checkbox is an
input type="checkbox"and the row menu a<details>, so keyboard handling and roles come for free. - A keyboard shortcut must not swallow typing. The
nshortcut ignores events whose target is inside aninput,textarea,selector[contenteditable], and any event with a modifier held.
TypeScript#
- Use strict typing wherever possible. Enable
"strict": trueintsconfig.app.json(currently only a handful of individual flags are set, not full strict mode) and avoidany/ uncheckedascasts as escape hatches. - The API boundary is generated, not hand-maintained.
src/generated/holds the wire types and the ahead-of-time compiled Ajv validators, written bynpm run generate:apifrom the JSON Schemas the backend publishes underdoc/api/schema/. Never hand-edit anything insrc/generated/—frontend-ci.ymlregenerates it and fails on any difference. Change the backend's record, regenerate the contract, regenerate these. - A component's props are
Readonly<…Props>. Writefunction Row({ … }: Readonly<RowProps>). It is what Sonar's S6759 asks for, and the seven components it once flagged were changed to this form (#121). oxlint has no rule for it, so SonarCloud is what catches a new component that forgets — after the merge, as the quality-gate issuesonarcloud.ymlopens onmain. ajvis a devDependency and must stay one. The validators are compiled to plain JavaScript, so nothing new reaches the browser. The generator asserts this: if the compiled output ever needs a runtimeimport, it fails rather than quietly adding a dependency.
Linting#
oxlintis part of the project (npm run lint) and is wired intofrontend-ci.ymlas a failing check, alongside tests/coverage. Anything automatable — lint, type-check, tests, coverage — should be enforced by the standard CI run, not left as a manual convention (same principle as the backend).- A
TODO:comment fails the lint. To-dos are GitHub issues, not comments (#121), so.oxlintrc.jsonsetsno-warning-commentsto the markertodo:at the start of a comment. It is narrowed on purpose: the domain word — theTODOtask state — is everywhere and is fine. This replaces Sonar's S1135, which cannot be narrowed and is switched off insonar-project.properties.
Code quality tooling#
- Frontend code is analyzed with SonarCloud (alongside the backend), using the Vitest coverage report as input.
Documentation#
- TSDoc blocks on types, module-level functions and components, following the same rule as the backend: the first sentence says what the thing is for, and a second paragraph carries the why when there is one.
- This is convention only, not enforced — oxlint ships no
require-jsdocrule, so nothing will fail the build for a missing block. It depends on being remembered. - What is linted is doc-comment hygiene:
jsdoc/check-tag-names,jsdoc/empty-tagsandjsdoc/no-blank-blocksare errors in.oxlintrc.json. Deliberately not enabled arejsdoc/require-param-typeandjsdoc/require-returns-type, which would ask for types in comments that TypeScript already carries. - A type the backend owns is not redeclared here, it is aliased from
src/generated/with a block saying what the application calls it and why. Types used to be mirrored by hand with a comment saying the two had to change together; generating them is what replaced that. - Files whose reason for existing is a configuration subtlety — the Vite proxy, the Vitest setup, the Playwright config — get a file-level block explaining it, rather than a comment that can drift away from the line it explains.
- Anything larger than a single module belongs in
/doc, and is updated in the same change as the behaviour it describes.