CLAUDE.md@apps/web · diff
git:20260901.07ffa8d to git:20260905.5309323
1 added, 1 removed. Audit A to A.
# CLAUDE.md — apps/web (frontend)
Scoped guidance for the Next.js frontend. The root `CLAUDE.md` (including its Systemic Rules) still applies; this file adds the frontend-specific invariants.
## Stack & validation
- Next.js 16 (App Router), React 19, TypeScript strict, Tailwind, react-i18next, vitest.
- Tests: `task test:frontend` (or `pnpm test` from `apps/web/`). Lint/format: `task lint:frontend` / `task format:frontend`.
- **Never validate runtime behavior with a local `pnpm build`/`pnpm dev` outside the workflow** — runtime validation goes through the Docker dev container (`lia-web-dev`). Static validation uses `task lint:frontend`; when types, tests, `tsconfig`, or generated declarations change, also run the clean host check `pnpm exec tsc --noEmit --incremental false` so a stale incremental cache cannot mask diagnostics. The pre-commit hook runs `eslint`, `tsc` and the i18n parity check from the host.
- After any `pnpm add`/`pnpm remove` inside the container, re-sync the host lockfile (root `CLAUDE.md` → Dev Container Pitfalls #1) or the prod build breaks on `--frozen-lockfile`.
## Audit quality gates (security excluded)
The root audit-derived gates apply in full. This section adds the frontend-specific contract; it introduces no security criterion.
- **Accessible interaction is correctness**: prefer native `button`, `a`, `label`, and form controls; give every control a stable translated programmatic name. Test keyboard activation, focus order/visibility/return, disabled/error states, dialogs, and announcements by role/name. Never replace native semantics with a generic key handler or suppression.
- **Shared primitives localize every user-facing string**: never hardcode `sr-only` content, `aria-label`, `title`, placeholders, empty-state text, or fallbacks. Resolve each string from the active locale. A shared primitive with a programmatic name requires a direct accessible-name test in at least English and French, plus six-locale key parity.
- **Tests stay type-safe and behavioral**: builders take `Partial<Props>` and return `Props` without `as any`, double assertions, `as never`, or ignored diagnostics; mocks preserve public signatures. Assert visible state, data transitions, requests, cancellation, and recovery. CSS selectors, snapshots, and fully mocked hooks cannot be the sole oracle for business behavior.
- **Ratchets are shrink-only**: coverage, `a11y`, React-hooks, and complexity baselines may only improve. Never exclude business code, disable a rule, relocate branches, or raise a baseline to pass. A touched violation should decrease before its baseline is updated.
- **Keep React logic explicit**: reducers are pure; derive state during render; effects synchronize external systems; clean up timers/listeners/streams and abort requests. Extract hotspots into typed state machines, hooks, decision tables, and pure functions — never a god-hook.
- **A refresh is not a first load, and a busy control is not a removed one.** Two ways a section silently destroys the user's work, both found in the same component (`PeerConnectionsSettings`, 2026-07-31): (1) `loading ? <Spinner/> : content` unmounts the subtree on every post-mutation refetch, wiping typed input and results — gate the spinner on a *first-load* flag that is monotone (derived from `data === undefined`, never from `error`, which a refetch resets), and announce refreshes with `aria-busy`; (2) putting `disabled` on a control **while it holds focus** makes the browser blur it and drop it from the tab order, so a keyboard user lands back on `<body>` — use `aria-disabled` plus a guard in the handler (the guard, not the attribute, is what prevents the double submit). Both cost a real user their place; both are invisible to a snapshot test and need a focus/value oracle.
- **Coverage follows risk**: prioritize localized App Router pages, chat/SSE reconnect/cancel, settings, connectors, Journals, spaces/uploads, voice/audio, retries, partial failures, cache invalidation, i18n, and timezone boundaries over trivial wrappers.
- **Browser assurance stays hermetic**: intercept controlled API/SSE traffic; never contact production, a real backend, or a paid provider. Changed critical journeys cover success, their highest-risk failure/retry, and keyboard/focus. Keep PR Chromium smoke fast; extend periodic evidence to Firefox/WebKit, zoom/reflow, contrast, and NVDA/VoiceOver.
From the repository root, run the complete local frontend gate after any behavioral change:
```bash
task lint:frontend
cd apps/web
pnpm exec tsc --noEmit --incremental false
pnpm test:coverage
pnpm a11y:ratchet && pnpm react-hooks:ratchet && pnpm cc:ratchet
```
Run the affected Playwright scenarios when a user journey changes; run the full hermetic E2E package when shared routing, API interception, accessibility infrastructure, or global layout changes.
## Security invariants (do not weaken)
- **BFF auth**: authentication is a HTTP-only session cookie sent via `credentials: 'include'` in `src/lib/api-client.ts`. **Never** store tokens/secrets in `localStorage`/`sessionStorage`; never add an `Authorization` header; never expose session material to JS.
- **XSS boundary**: any dynamic content (LLM output, API data, user input) is rendered either as React children (auto-escaped) or through the ReactMarkdown pipeline with the exact plugin order `[rehypeRaw, [rehypeSanitize, sanitizeSchema], rehypeMathInText, rehypeKatex]` (`src/lib/markdown-sanitize-schema.ts` — everything after sanitize is the math-rendering stage, sanitize-exempt on purpose). `rehypeMathInText` (`src/lib/rehype-math-in-text.ts`) converts `$…$`/`$$…$$` found in the raw HTML the assistant emits into KaTeX markers — needed because `remark-math` only sees markdown, not the HTML blocks the assistant wraps every answer in; it reads only already-sanitized text and emits fixed-class `<span>`s, so the XSS posture is unchanged.
- `dangerouslySetInnerHTML` is reserved for **app-controlled static content compiled from the repo** (blog/FAQ/guides markdown, JsonLd SEO). It is **never** used for LLM output, API payloads, or anything user-derived — when in doubt, render as children.
- MCP App / Skill App HTML renders **only** inside the sandboxed widget iframe (sentinel → widget), never through markdown.
- **CSP is per-document and test-pinned** (ADR-098): both policies live in `src/lib/csp.ts` and every feature-bearing directive is pinned by `src/lib/__tests__/csp.test.ts` — change policy and test together, never the header strings inline in `next.config.ts`. MCP App widgets render through the airlock shell (`public/widget-frame.html`, permissive CSP, sandbox = the real isolation); the shell's sandbox/lock logic must never be weakened (an unsandboxed shell executing a payload = XSS under the app origin). Skill `frame.html` widgets stay on `srcDoc` on purpose.
## Conventions
- **API access**: components never call `fetch` directly — use the typed hooks `useApiQuery`/`useApiMutation` (which wrap `api-client`). New feature → new hook in `src/hooks/use{Feature}.ts`.
- **Routing**: all pages live under `app/[lng]/` — every route is localized.
- **i18n**: keys in `locales/{lng}/translation.json`, 6 languages (en, fr, de, es, it, zh), **strict key parity enforced by the pre-commit hook** (`en` is the reference). zh has no CLDR plural form: duplicate the value to `_one` so parity passes. Backend contracts should ship structured data + `label_key`s resolved client-side — never pre-translated strings baked into API payloads.
- **Chat state**: `src/reducers/chat-reducer.ts` is a pure, immutable FSM (idle → sending → streaming → idle) with documented transitions and anti-race guards — no side effects in reducers, clear stale sub-state on transitions, add a reducer test for every new action.
- **Bundle discipline**: heavy components are lazy-loaded via `next/dynamic` (follow `McpAppWidget`, `SkillAppWidget`, `CodeBlock`/Prism, `MermaidDiagram`). Don't import them statically from shared paths.
- **TypeScript discipline**: `@ts-ignore`/`@ts-expect-error` are effectively forbidden (2 occurrences across 438 files — keep it that way); every `eslint-disable` carries a justification comment.
- **Nothing generated lives in `apps/web/`, and Tailwind never auto-detects its sources.** `globals.css` opens with `@import 'tailwindcss' source(none)` + an explicit `@source` bounded to `src/`, because automatic detection walks the whole working directory and declares EVERY file it finds as a webpack dependency — over a bind mount where one `stat()` costs ~1.5-2.8 ms, that is the single most expensive thing in the dev loop. Measured 2026-08-23 with three leftover `.next-e2e*` proof dists in place: 35 331 files scanned (96 % of them build artefacts), 449 s to compile `globals.css`, 10 min 32 s for the first page against ~20 s once scoped, and a stylesheet carrying 333 phantom selectors extracted from minified bundles. Guarded by `src/styles/__tests__/tailwind-source-scope.test.ts` (shrink-only file cap, and a lower bound so a collapsed scan is caught too). Same reason `tsconfig.json` enumerates its `include` instead of globbing `**/*.ts`: Next appends one entry per `NEXT_DIST_DIR` it has ever seen, and those entries outlive the directories.
- **A host edit only reaches the container in polling mode, and the interval is load-bearing.** The bind mount forwards no filesystem events on Windows/macOS — measured, zero `fs.watch` events for an edit whose content was already visible on disk — so Fast Refresh needs `WATCHPACK_POLLING` (`WEB_WATCH_POLL_MS`, **5000 ms**). Before this, every frontend change required `docker restart lia-web-dev`. A shorter interval is NOT more responsive: polling stats the tree over a transport costing ~3 ms per stat, and at 1000 ms webpack's graph never settles, so every page view pays a rebuild. Three consecutive hits on an ALREADY COMPILED `/dashboard`, nothing edited in between: **44.5 s / 3406 ms / 1958 ms at 1000 ms, against 5.6 s / 313 ms / 200 ms at 5000 ms** — the short interval reproduced the very "compiling on every page" symptom it was meant to fix. Reload latency is 12.4 s at 5000 ms (4.8-14 s at 1000 ms). Change this only from a fresh measurement of BOTH navigation and reload, never from reload latency alone. If an edit still seems invisible, check the variable reached the container before doubting the component.
- **This app also runs inside a native WebView** (Android + iOS shells — `docs/guides/GUIDE_MOBILE_{ANDROID,IOS}.md`), which loads the **remote origin**, so the BFF cookie contract above is what makes the shells possible at all. Consequences for frontend code: never assume a full browser — `Notification`, `PushManager` and `SharedArrayBuffer` are **absent in both WebViews** (measured), `navigator.serviceWorker` is absent on iOS, and `crossOriginIsolated` is false, so anything gated on them must degrade like `isSherpaKwsSupported()` already does rather than throw. Where a shell CAN do what the browser cannot, the fix is to make the existing hook delegate — `useFCMToken` asks `enrolNativePush()` for a token and keeps one implementation of everything after it (ADR-246) — never a parallel hook the settings screen would have to choose between. Note the trap that pattern hides: `refreshTokens` re-read `Notification.permission` after enrolment and overwrote the state it had just established, which in a shell reads `unsupported`. A refresh must not undo the thing it follows. The native layer never reimplements UI: it opens existing routes (`/{lng}/share?title=…`, `?intent=` under ADR-210), so **those two contracts are load-bearing** — changing their query parameters breaks the share extension and notification taps. A new native capability gets ONE module in `src/lib/native/` (`shell.ts`, `push.ts`) with a web and a native implementation resolved at runtime, never a second component tree. **And when a behaviour differs by platform, decide it at the chokepoint the flows already share, not per flow**: eight OAuth departures go through `navigateToAuthorizationUrl`, so that is where "leave for the system browser" lives (ADR-246) — sign-in briefly had its own copy, and removing it deleted code. `api-client` adds `X-LIA-Native` on every request in a shell for the same reason: scoping it to a list of OAuth paths would be an allowlist to keep in step with every new connector. Those modules import **nothing** from `@capacitor/core` — they read `window.Capacitor`, which the bridge injects at document start, because a native dependency in this bundle would be paid for on every page load in every browser.
- **Any change to `src/lib/csp.ts` must be re-measured on both engines**: `task mobile:probe:{android,ios}` imports `buildAppCsp`/`resolveCoepMode` from that very module, so the probe follows policy automatically — but only if someone runs it. The Capacitor bridge survives the strict CSP today because it is injected out of band and its bundled JS contains no `eval`; a directive change is exactly what could break that silently.
- - **The expressive eyes are a rig, and the boundary is one rule** (ADR-252). `components/eyes/rig/` computes the motion and publishes it as `--rig-*` custom properties on the eyes root, every frame; `styles/eyes.css` owns what is DRAWN (silhouette, skin, matter, the identity of the six styles). **A stylesheet READS `--rig-*`, never DECLARES one, and never puts a `transition` on a property the rig writes** — a transition chasing a value that changes sixty times a second lags behind it and fights the springs. `rig/__tests__/css-boundary.test.ts` enforces both halves, plus two things a comment cannot: every `--rig-*` the sheet reads is a real channel, and every `var(--rig-x, fallback)` fallback still equals that channel's rest value (those fallbacks render the neutral pose before the first frame, so drift there is invisible until someone loads the page with JS off). The DOM carries two vocabularies on purpose: `data-*` is the STATE the host declares, `--rig-*` is the MOTION the rig computes — the widget's behavioural tests read the former, never the latter. Three traps the architecture already paid for: an expression recipe that declares a **radius** applies it to all six styles (that is the 2026-08 "everything became Cozmo" bug, now guarded on both the CSS and the pose-table side); a **sustained lid clip** destroys a stroke and fragments a ring, so `traits` and `anneaux` fold their lids into a squash (`STYLE_LID_MODE`, and a guard checks the sheet agrees); and **exaggeration by mood must not touch lids, blink or radii** — those state a fact, not an intensity, and a drowsy `sleep` scaled down sleeps with its eyes ajar.
+ - **The expressive eyes are a rig, and the boundary is one rule** (ADR-252). `components/eyes/rig/` computes the motion and publishes it as `--rig-*` custom properties on the eyes root, every frame; `styles/eyes.css` owns what is DRAWN (silhouette, skin, matter, the identity of the six styles). **A stylesheet READS `--rig-*`, never DECLARES one, and never puts a `transition` on a property the rig writes** — a transition chasing a value that changes sixty times a second lags behind it and fights the springs. `rig/__tests__/css-boundary.test.ts` enforces both halves, plus two things a comment cannot: every `--rig-*` the sheet reads is a real channel, and every `var(--rig-x, fallback)` fallback still equals that channel's rest value (those fallbacks render the neutral pose before the first frame, so drift there is invisible until someone loads the page with JS off). The DOM carries two vocabularies on purpose: `data-*` is the STATE the host declares, `--rig-*` is the MOTION the rig computes — the widget's behavioural tests read the former, never the latter. Three traps the architecture already paid for: an expression recipe that declares a **radius** applies it to all six styles (that is the 2026-08 "everything became Cozmo" bug, now guarded on both the CSS and the pose-table side); a **sustained lid clip** destroys a stroke and fragments a ring, so `traits` and `anneaux` fold their lids into a squash (`STYLE_LID_MODE`, and a guard checks the sheet agrees); and **exaggeration by mood must not touch lids, blink or radii** — those state a fact, not an intensity, and a drowsy `sleep` scaled down sleeps with its eyes ajar. ADR-264 added the brow's arch, a faint resting brow and the secondary couplings, and settled two rules on the way: **a coupling is motion, so it lives in the rig** (`writeDerived`, `BROW_GAZE_LIFT_EM` / `BROW_BLINK_DIP_EM`), never as a `calc()` term nothing can read; and **every derived contribution is written as an absolute value from the spring plus the loops' own offset, never `output[key] += …`** — the idle fast path only rewrites the channels a loop rides, so an increment there is added again on every quiet frame and drifts for the whole session (pinned by a test comparing 20 000 small steps against one). The moving hold is a budget in pixels (`rig/__tests__/life.test.ts`): visible at rest, under two pixels, exactly zero on `focused`.
- **Timers/aborts**: prefer `AbortSignal.timeout()` over manual `setTimeout` + `AbortController`; always clean up timers and subscriptions in effects.
- **Diagrams in guides**: fenced ```mermaid blocks rendered by `MermaidDiagram` (dark-mode aware) — no ASCII art, no static images for flows.
- **Shared primitives own their contract** (ADR-206). A labelled control goes through `useFieldA11y`/`FieldFrame` (`ui/field.tsx`): `useId` for identity — never the label text, which collides between homonymous fields and changes with the locale — plus `aria-invalid` and an **additive** `aria-describedby`, so a hint the caller attached survives the error. A primitive never invents a user-facing string: it resolves it from the locale (`LoadingSpinner`, `alert`, `pagination`, `search-input`) or takes it as a prop when a hook is impossible — `Skeleton` is rendered by App Router **server** components, so it is decorative (`aria-hidden`) and only speaks when given a `label`. Accessible names of design-system controls are guarded on the rendered DOM (`ui/__tests__/form-control-names.guard.test.tsx`), never through `jsx-a11y` component mapping: measured 2026-08-05, that mapping cannot follow `htmlFor` → `id` across sibling components and produced 96 findings on correct code.
- **The label-to-control gap is `space-y-3` (12px), and the `Label` primitive is `block`** — one value, everywhere (owner arbitration 2026-08-05, decided on real screenshots of the three candidates). The `block` half is load-bearing: a `<label>` is inline by browser default, and **vertical margins are computed but never rendered on inline elements** — measured in-browser 2026-08-05, every `space-y-*` under a `Label` produced a ~3px gap whatever its value, so three successive recalibrations were invisible and the owner's "nothing changed" reports were literally accurate. Guarded by `ui/__tests__/label.test.tsx`; when a gap change has no visible effect, check the element's `display` before doubting the delivery chain. `FieldFrame` carries the gap for the `label=` path; hand-written `<Label>` + control stacks wrap in `space-y-3` (or `grid gap-3`), and a bare `<label>` above a control takes `mb-3`. Lowercase `<label>` counts — the first sweep missed 19 of them in the admin pricing modals by matching `<Label` only. One deliberate exception, same logic as ADR-207 altitudes: the in-row micro-editor (`CommitmentEditor`, 11px captions on compact inputs) keeps its dense `gap-1` — a row is not a form. Do not reintroduce per-screen variants.
- **A title always carries an icon, and a title icon is never grey** (owner rule 2026-08-05): section titles, zone titles and sub-block headings pair their text with a lucide icon in the THEME colour (`text-primary`), never `text-muted-foreground`. Metadata glyphs inside muted text lines are not titles and keep their line's colour.
- **Grey badges are reserved for INACTIVE elements** (owner rule 2026-08-05): `secondary` says disabled/dormant/idle — nothing else. A live state takes its semantic tone from `lib/status-tone.ts`; a live trait with no semantic family takes the theme colour (`default`). An app-wide sweep of legacy `secondary` badges is pending — do not add new ones on active elements.
- **A list row exposes its actions ONE way** (ADR-208). Row actions go through `RowActions` (`ui/row-actions.tsx`): always-visible ghost icons from `sm` up (delete red at rest), a named "⋮" `DropdownMenu` below — never `opacity-0 group-hover` (keyboard focus lands on invisible controls; measured 2026-08-05), never a tap-anywhere card handler, never a per-screen mobile action Dialog. A list section's header bar goes through `SectionToolbar`: labelled primary CTA at every size, secondary actions folded into a "⋯" menu on phones (never `hidden lg:flex` — that amputates the feature) unless `pinned: true` keeps them inline at every size (owner arbitration 2026-08-05: Export is pinned on the memory, interests and journals bars — folded it read as absent; the "⋯" only renders when something foldable remains), destructive visible everywhere at the same geometry. Metadata the reader consults (not scans) folds behind `SettingsDisclosure`; min/max-per-day and hour-window controls come from `FrequencyControls`.
- **An action has an altitude, and the altitude picks the shape** (ADR-207). A section-level CTA (create, export, import, a hub shortcut) is SOLID and themed (`variant="default"`); bulk destruction is solid red at the SAME size as its toolbar neighbours; a row-level action stays `ghost size="icon"` with delete carrying `text-destructive` at rest (the passkeys pattern — a colour the pointer must reveal is not a code); `outline` means one thing only: a true secondary (cancel, close, filter preset, error retry). Status and trait badges take their variant from `lib/status-tone.ts` (`lifecycleTone`, `skillTraitTone`…), never from per-screen class maps.
- **Loading, emptiness, selection**: first load of a known shape → `<Skeleton>` matching the real geometry, with one `<LoadingAnnouncement/>` for the whole route; refresh of populated content → `aria-busy`, never an unmount; a pending action → `<Button isLoading>`. Emptiness goes through `<EmptyState>`; `variant="page"` **requires** an action (enforced by the types) and `reason` separates "nothing exists yet" from "the filter matched nothing". A selected option is stated with `aria-current` and a guard inside the handler — **never** by putting `disabled` on the control the click just landed on, which blurs it and drops it from the tab order.