CLAUDE.md@apps/web · git:20260826.c4ec201 · 2026-08-26 · sha256 90484f7c750fe4aa
CLAUDE.md@apps/web git:20260826.c4ec201A
Immutable. This exact content is served forever at /api/v1/blob/90484f7c750fe4aa.
# 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.
- **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.