Files
TREK/client/CLAUDE.md
T
Maurice c0516dd9bf fix(client): check each literal branch of a template key
A template key was read as a pattern in which every interpolation is a
wildcard, so t(`roadtrip.window.${a ? 'onRoute' : 'onRoutTypo'}`) passed
as long as some other roadtrip.window key existed, and the typo rendered
as raw text.

An interpolation that can only produce string literals (a ternary chain
of them, in parentheses or not) is now expanded, and each resulting key is
checked like a literal one. Thirteen call sites become literal keys that
way. Where data is involved, a literal fallback (`${role ?? 'editor'}`)
is still checked as a key, with the template kept as a pattern under
its own allow-list site. Literals in a condition (`phase === 'start'`)
are never taken for results.

Narrow templates over data still pass without an allow-list entry. The
script header now says plainly what that leaves unchecked: a runtime
value with no key under a narrow prefix, which needs the value's type.
2026-10-08 13:04:42 +02:00

13 KiB

CLAUDE.md

Scope: the @trek/client workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root CLAUDE.md for the monorepo picture and the philosophy, server/CLAUDE.md for the API. This file holds the client's commands, invariants and pitfalls; ls the directories for the inventory.

Commands (run from client/)

npm run dev               # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
npm run build             # prebuild generates PWA icons, then vite build
npm run typecheck         # tsc --noEmit (CI)
npm run lint              # eslint .   (lint:check in CI)
npm run lint:warnings     # eslint, failing on any error and on any warning count per rule above scripts/eslint-baseline.json (app code, tests and eslint-disable'd messages counted apart; a `/* eslint <rule>: ... */` config comment fails outright; --update lowers it)
npm run lint:pages        # enforce the Page pattern (CI gate)
npm run lint:rtl          # physical left/right sides and rtl-lint-disable comments may only shrink per file (CI gate; --list shows them, --update lowers both baselines)
npm run lint:size         # no source file or stylesheet past 1000 lines, no test (Playwright specs and fixtures under e2e/ included) past 2000, counted wrapped at 120 columns; longer ones may only shrink (CI gate; --update lowers the baseline)
npm run lint:format       # Prettier: every .ts/.tsx/.mjs/.css file under src/, tests/, e2e/ and scripts/ outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
npm run lint:layers       # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
npm run lint:offline      # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
npm run lint:skips        # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips)
npm run lint:i18n-keys    # every translation key src/ names exists in shared en (CI gate; --unused lists en keys nothing reaches)
npm run test              # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
npm run e2e               # Playwright (CI runs --project=public --project=app; e2e:report opens the last report)
npm run shots             # Playwright screenshot project (shots:promote to accept)
npm run theme:lint        # theme conformance audit (theme:lint:strict exits 1; local only)
npm run check:gl-split    # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (CI gate)

Single test: npx vitest run src/store/slices/budgetSlice.test.ts, or npx vitest run -t "optimistically adds the place".

The dev server needs the API on :3001 — run npm run dev at the repo root to start both.

Page pattern (enforced — lint:pages fails otherwise)

Every src/pages/*Page.tsx is a thin wiring container; all state/effects/handlers live in a co-located use<Page>() hook under src/pages/<page>/. The page body must not call React state/effect/memo/ref hooks — only context hooks like useTranslation(). An optional <page>Model.ts holds pure, React-free types and helpers. Full spec in src/pages/PATTERN.md. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.

Data flow — offline-first

The layering is component → feature hook → store/slice → repo/ → api/ | Dexie, and writes go through a mutation queue:

  • src/store/ — Zustand. tripStore.ts is composed from slices in store/slices/; slices/remoteEventHandler.ts applies inbound WebSocket events to local state.
  • src/repo/ — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
  • src/api/client.ts — the single Axios instance, typed from @trek/shared. New API surface goes in a per-domain api/<domain>.ts, not the god module.
  • src/db/offlineDb.ts — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
  • src/sync/ — mutationQueue.ts: offline writes do an optimistic Dexie write (temporary negative id for creates), then flush() replays REST on reconnect with the mutation's UUID as the X-Idempotency-Key header (matching the server interceptor) and reconciles the row. Writes to one entity keep their order: a write parked as failed or conflict holds back the later writes to the same resource + entity id until the user retries, resolves or discards it, and only that action deletes a parked row (trip eviction never does). Repos whose writes carry no version token (visits, day clears, tours, driving settings) ask mutationQueue.mustQueue() before an online write and queue it while an older write to the entity is still queued, so a retried write cannot land over a newer one made here online; places and packing items send X-Base-Updated-At, so the server refuses the stale replay with a 409 instead. flush() takes the Web Lock trek-mutation-flush:<db name> so only one tab replays at a time; a tab that finds it taken waits for it, so flush() never resolves while another tab is still replaying and a re-seed chained onto it cannot lay older server rows over those writes (the in-tab flag alone where the API is missing). The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).

Rules for new code

The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:

  • One data path, no layer skips. Never import api/client (or raw fetch/axios) from a component or modal, and especially never write from one. lint:offline enforces it: a file under components/, mobile/, pages/ or hooks/ may not import anything from src/api/ except types. The long tail that already does is listed in scripts/offline-baseline.json; the list only shrinks (a file that stops leaves it via --update) and is debt, not precedent. A domain that is online-only on purpose (admin, auth) still puts its calls in a store or repo, not in the view.
  • Offline-first is the product promise. New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
  • Imports go downwards (lint:layers, rules in scripts/lib/layers.mjs). A component never imports pages/ or mobile/, a shared hook no view, and stores, repos, sync, api, db and the helpers no view at all; a file directly in src/ is the layer of its name, so src/types.ts follows the types/ rules. A model or helper both sides need moves down (utils/, hooks/, types/); the counts in scripts/layers-baseline.json are debt, not precedent.
  • No god components or god hooks. The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
  • Prefer React 19 idioms: useOptimistic, useActionState/useFormStatus, use() + Suspense, useSyncExternalStore for external subscriptions (hooks/useNetworkMode is the reference).
  • Optimistic writes must reconcile — rollback + user-visible toast on failure. No .catch(() => {}), no bare catch {}.
  • WS remote-event handling must match local slice reducers field-for-field. Divergence is silent, cross-user data loss.
  • Subscribe with selectors (useShallow for multi-field reads); never whole-store subscriptions; getState() for imperative access in effects.
  • Connectivity: isEffectivelyOffline() is the single source of truth — never read navigator.onLine in feature code.
  • Rendering security: never interpolate user content into HTML strings (map popups included — build via DOM + textContent, or escapeHtml from @trek/shared); untrusted markdown gets rehype-sanitize; never rehype-raw near it.
  • No window event buses or global mutable window state. No any at boundaries: WS payloads and map renderer props get real types.
  • Hygiene: error boundaries around new shells/routes (components/shared/ErrorBoundary.tsx); every async .then(setState) needs a cancelled flag or AbortController; no routine eslint-disable exhaustive-deps (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
  • Theming: use the semantic Tailwind tokens defined in tailwind.config.js (bg-surface*, text-content*, border-edge*, bg-accent*, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only applyAppearance() in src/theme/ mutates <html> styling (see src/theme/README.md). theme:lint catches literals and bg-[#...]-style classes but not named palette classes — review those by hand.

Big-picture pieces

  • Maps (src/components/Map/): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by MapViewAuto.tsx. Keep both in sync when changing map features, and keep mapbox-gl and maplibre-gl in separate chunks (check:gl-split). The raster prefetcher fetches tiles no-cors so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses cors because it has to read the responses — don't "align" one with the other.
  • i18n (src/i18n/TranslationContext.tsx): en is bundled; every other locale is a dynamic import('@trek/shared/i18n/<locale>') so Vite emits one chunk per locale. Strings live in shared/, never here. t() returns an unknown key unchanged, so lint:i18n-keys (scripts/i18n-keys.mjs) resolves every literal key in t(), tHtml(), tr(), <TransHtml html>, translateApiError and *Key props against shared/src/i18n/en; a template whose interpolations only choose between string literals is expanded and each branch checked as a key; any other template key is read with each interpolation as one key segment and passes by itself only when it reaches between 1 and MAX_IMPLICIT_MATCHES (30) en keys; a wider one, or one with no fixed dotted prefix, needs a DYNAMIC_ALLOWED entry keyed by file and template that says what bounds the value, and an entry the check no longer needs fails. Pass counts as numbers, and let a plural group pick the wording instead of switching keys on the count.
  • Mobile shell (src/mobile/): below the phone breakpoint (useIsPhone) App.tsx wraps routes in MobileShell and the M* screens under mobile/screens/ take over. A UI change to a domain with an M* twin usually needs both — and the shared-hook rule below.
  • Plugins UI (src/components/Plugins/): third-party surfaces render only inside the sandboxed PluginFrame iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the trek-plugin-dev skill.
  • Managed installs (src/managed/index.tsx): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.

Desktop ↔ mobile shells and the SonarQube duplication gate

Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows ≤ 3% duplicated lines on the PR's new code, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (useInstanceSettings, useRangeBypass under components/Admin/ are the pattern) and keep only markup in each shell. Full gate rules in the root CLAUDE.md.

Tests

vitest with @vitejs/plugin-react, a custom jsdom environment (tests/environment/), forks pool. Tests live in tests/{unit,integration}/ and co-located as src/**/*.test.{ts,tsx}. msw mocks HTTP, fake-indexeddb backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see store/slices/budgetSlice.test.ts).