diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
new file mode 100644
index 0000000..0944703
--- /dev/null
+++ b/.planning/ROADMAP.md
@@ -0,0 +1,89 @@
+# spa β Roadmap
+
+A React + TypeScript single-page application for end-user operators of the TRM platform: race directors, marshals, timekeepers, and (post-Phase 4 of [[directus]]) public-facing participants. Talks to [[directus]] for REST/GraphQL + business-plane WebSocket and to [[processor]] for the live-position WebSocket firehose.
+
+This file is the single navigation hub for all SPA implementation planning. Each phase has its own folder with a README and granular task files. Update statuses here as work lands.
+
+## Status legend
+
+| Symbol | Meaning |
+| ------ | ----------------------------- |
+| β¬ | Not started |
+| π¦ | Planned (designed, not coded) |
+| π¨ | In progress |
+| π© | Done |
+| βΈ | Paused / blocked |
+| βοΈ | Frozen / future / optional |
+
+## Architectural anchors
+
+The SPA is specified by the wiki at `../docs/wiki/`. Implementing agents should read these pages before starting any task:
+
+- **This service** β `docs/wiki/entities/react-spa.md`
+- **Map subsystem** β `docs/wiki/concepts/maps-architecture.md`, `docs/wiki/sources/traccar-maps-architecture.md`
+- **Live channel** β `docs/wiki/concepts/live-channel-architecture.md`, `docs/wiki/synthesis/processor-ws-contract.md`
+- **Auth + business plane** β `docs/wiki/entities/directus.md`, `docs/wiki/synthesis/directus-schema-draft.md`
+- **System architecture** β `docs/wiki/sources/gps-tracking-architecture.md`, `docs/wiki/concepts/plane-separation.md`
+
+## Non-negotiable design rules
+
+These rules govern every task. Any deviation must be discussed and documented as a decision before code lands.
+
+1. **Singleton MapLibre.** A single `maplibregl.Map` instance lives in a module-level variable, attached to a detached `
`. React mounts/unmounts the div via refs across page navigation. WebGL context never recreates. Pattern is documented in `docs/wiki/concepts/maps-architecture.md`.
+2. **Side-effect-only `Map*` components.** Every map-participating component returns `null` and uses `useEffect` for setup + cleanup, with a separate effect for `setData` updates. No DOM marker components, no `react-map-gl`.
+3. **rAF coalescer at the WS boundary.** Position messages buffer per-device; one `requestAnimationFrame` tick flushes the latest snapshot to the Zustand store. Per-message dispatch is the failure mode `traccar-web` exhibits β we don't replicate it.
+4. **Same-origin everything.** SPA bundle, [[directus]] REST/WS, and [[processor]] WS all served under one origin via reverse proxy. Vite dev proxy in dev; Traefik in stage/prod. Cross-origin breaks the cookie auth flow.
+5. **In-memory access token, httpOnly refresh cookie.** Access token never touches `localStorage`. Refresh cookie is `httpOnly + Secure + SameSite=Lax`. Refresh on 401 via Directus's `/auth/refresh`.
+6. **Role-aware UI shape from day one.** Even though everyone's admin until [[directus]] Phase 4, the route guards and conditional rendering check `user.role` rather than hard-coding "everyone sees everything." Retrofitting is painful; baking it in costs nothing.
+7. **Runtime config, not build-time.** Per-environment values (Directus URL, processor WS URL, optional Google Maps key) come from `/config.json` served by the proxy. One image, multiple environments. No rebuild to switch envs.
+8. **Native PostGIS GeoJSON.** Geometry round-trips through `ST_AsGeoJSON(geometry)` on the server, not WKT. The SPA never imports `wellknown` or runs WKT parsing in the client.
+
+## Phases
+
+### Phase 1 β Foundation
+
+**Status:** π¨ In progress (1.1 done)
+**Outcome:** A deployable empty-shell SPA: scaffold + stack rounded out + Directus cookie auth flow + protected routes + Gitea CI + compose deploy block. End state: an operator can browse to `https://stage.trmtracking.org`, log in with a Directus credential, see a placeholder home page, log out. No live map yet β that's Phase 2.
+
+[**See `phase-1-foundation/README.md`**](./phase-1-foundation/README.md)
+
+| # | Task | Status | Landed in |
+| ---- | ------------------------------------------------------------------------------------------------------------ | ------ | --------- |
+| 1.1 | Project scaffold (Vite + React + TS) | π© | (manual) |
+| 1.2 | [Stack rounding-out (Tailwind + shadcn/ui + deps + Prettier)](./phase-1-foundation/02-stack-rounding-out.md) | π© | local (uncommitted) |
+| 1.3 | [Vite dev proxy + path aliases + tsconfig hardening](./phase-1-foundation/03-vite-dev-proxy.md) | β¬ | β |
+| 1.4 | [Runtime config endpoint](./phase-1-foundation/04-runtime-config.md) | β¬ | β |
+| 1.5 | [Directus auth client (cookie mode + refresh)](./phase-1-foundation/05-directus-auth-client.md) | β¬ | β |
+| 1.6 | [Login page](./phase-1-foundation/06-login-page.md) | β¬ | β |
+| 1.7 | [Routing skeleton (TanStack Router + role-aware guards)](./phase-1-foundation/07-routing-skeleton.md) | β¬ | β |
+| 1.8 | [Logout flow](./phase-1-foundation/08-logout-flow.md) | β¬ | β |
+| 1.9 | [Gitea CI + Dockerfile + nginx static serve](./phase-1-foundation/09-gitea-ci-and-dockerfile.md) | β¬ | β |
+| 1.10 | [Compose service block in trm/deploy](./phase-1-foundation/10-deploy-compose-block.md) | β¬ | β |
+
+### Phase 2 β Live monitoring map
+
+**Status:** β¬ Not started β depends on [[processor]] Phase 1.5 landing
+**Outcome:** The dogfood-day deliverable. MapLibre singleton + tile-source switcher + sprite preload + WS client with rAF coalescer + Zustand position store + `MapPositions` (clustered + selected) + `MapTrails` (bounded ring buffer) + event picker + camera control trio + connection-status indicator. Operators on race day open one page, pick the active event, and watch their field move in real time.
+
+[**See `phase-2-live-map/README.md`**](./phase-2-live-map/README.md)
+
+### Phase 3 β Dogfood readiness
+
+**Status:** β¬ Not started
+**Outcome:** Operational polish a closed pilot needs but a demo doesn't: error boundaries that don't blank-screen the map, connection-state UI that tells the operator "WS reconnecting", mobile-responsive baseline (doesn't crash on a phone in the field), per-device detail panel, operator-friendly empty/loading states.
+
+[**See `phase-3-dogfood-readiness/README.md`**](./phase-3-dogfood-readiness/README.md)
+
+### Phase 4 β Future / optional
+
+**Status:** βοΈ Not committed
+[**See `phase-4-future/README.md`**](./phase-4-future/README.md)
+
+Ideas on radar: geometry editor (depends on [[directus]] Phase 2 collections), replay mode, heatmaps / deck.gl, i18n (Albanian), dark mode, E2E tests.
+
+## Operating model
+
+- **Implementation agent contract.** Each task file is self-sufficient: goal, deliverables, specification, acceptance criteria. An agent should be able to complete one task without reading the whole wiki β but should skim the wiki references at the top of the task before starting.
+- **Sequence within a phase.** Task numbering reflects intended order. Soft dependencies are explicit in each task's "Depends on" field. Tasks with no dependencies on each other can be done in parallel.
+- **Status updates.** When a task is started, change its row in this ROADMAP to π¨ and the task file's status badge accordingly. When done, π© + a one-line note in the task file's "Done" section pointing at the merging commit/PR.
+- **Drift control.** If implementation diverges from a task's spec, update the task file _before_ the diverging code lands, with a note explaining why. Do not let plans rot.
diff --git a/.planning/phase-1-foundation/02-stack-rounding-out.md b/.planning/phase-1-foundation/02-stack-rounding-out.md
new file mode 100644
index 0000000..4177021
--- /dev/null
+++ b/.planning/phase-1-foundation/02-stack-rounding-out.md
@@ -0,0 +1,110 @@
+# Task 1.2 β Stack rounding-out (Tailwind + shadcn/ui + deps + Prettier)
+
+**Phase:** 1 β Foundation
+**Status:** β¬ Not started
+**Depends on:** 1.1 (scaffold)
+**Wiki refs:** `docs/wiki/entities/react-spa.md` Β§Stack
+
+## Goal
+
+Take the bare Vite + React + TS scaffold from 1.1 and round it out into a real app stack: Tailwind CSS, shadcn/ui primitives, TanStack Router + Query, Zustand, `@directus/sdk`, zod, react-hook-form, Prettier. After this task, every dependency the rest of Phase 1 needs is installed and configured; subsequent tasks build features on top.
+
+This is mechanical setup, not feature work. Don't introduce features here β keep the placeholder `App.tsx` returning "SPA" until 1.7 wires the router.
+
+## Deliverables
+
+- **Tailwind CSS 4 via the Vite plugin:**
+ - `pnpm add -D tailwindcss @tailwindcss/vite`
+ - `vite.config.ts` updated to include the Tailwind plugin (additive; don't disturb the React plugin).
+ - `src/styles/globals.css` with `@import "tailwindcss";` (Tailwind 4 syntax β single import, no `@tailwind base/components/utilities` triplet).
+ - `src/main.tsx` imports `./styles/globals.css`.
+ - Sanity check: `
test
` in `App.tsx` renders red.
+- **shadcn/ui initialised:**
+ - `npx shadcn@latest init` β accept the defaults that fit our setup (Tailwind 4, TS, `src/ui/primitives` as the `components` path, `src/lib/utils.ts` for `cn`).
+ - `components.json` committed.
+ - Add the bare-minimum primitives we'll need in 1.6 (login page) and 1.7 (routing): `button`, `input`, `label`, `form`, `card`, `alert`. Run `npx shadcn@latest add button input label form card alert` β these populate `src/ui/primitives/` as plain TS files we own and edit.
+- **TanStack Router + Query:**
+ - `pnpm add @tanstack/react-router @tanstack/react-query`
+ - `pnpm add -D @tanstack/router-plugin @tanstack/router-devtools @tanstack/react-query-devtools`
+ - Don't wire them yet β that's 1.7. Just install.
+- **State + data libs:**
+ - `pnpm add zustand @directus/sdk zod react-hook-form @hookform/resolvers`
+- **Prettier + ESLint integration:**
+ - `pnpm add -D prettier eslint-config-prettier eslint-plugin-prettier`
+ - `.prettierrc` with sensible defaults (single quotes, semi: true, printWidth: 100, trailingComma: 'all').
+ - `.prettierignore` matching `.gitignore` plus `pnpm-lock.yaml`, `dist/`, `public/`.
+ - `eslint.config.js` extended to include `eslint-config-prettier` last (turns off conflicting rules) and the prettier plugin's recommended config.
+ - `package.json` `scripts`:
+ - `"format": "prettier --write ."`
+ - `"format:check": "prettier --check ."`
+ - `"typecheck": "tsc -b --noEmit"` (replaces the implicit typecheck inside the existing `build` script for a faster CI gate).
+- `README.md` updated with: stack list, common scripts, "how to add a shadcn component."
+
+## Specification
+
+### Why Tailwind 4 (the Vite-plugin path)
+
+Tailwind 4 ships a Vite plugin that does the JIT generation in-process β no PostCSS dance, no `@tailwind` directives, single `@import "tailwindcss";` in the entry CSS. Faster, fewer config files, the modern path. The shadcn/ui CLI auto-detects Tailwind 4 and configures correctly.
+
+### Why a separate `src/lib/` for `cn` and `query-client.ts`
+
+shadcn/ui expects `src/lib/utils.ts` to exist (it imports `cn` from there in every primitive). The `query-client.ts` (in 1.5) lives alongside it β both are framework-glue, not feature code.
+
+### What we don't install yet
+
+- **MapLibre GL JS** β Phase 2.
+- **`@mapbox/mapbox-gl-draw`** β deferred until geometry editing in Phase 4.
+- **`maplibre-google-maps`** β Phase 2, optional / runtime-config-gated.
+- **Vitest + React Testing Library** β Phase 3.
+- **i18n libraries** β deferred (English-only for dogfood).
+
+### Prettier β ESLint coexistence
+
+ESLint 10 with the `typescript-eslint` flat config has different opinions from Prettier on quote style and trailing commas. The standard fix is: `eslint-config-prettier` last in the chain (disables the conflicting style rules), `eslint-plugin-prettier` in `recommended` mode (so ESLint reports Prettier diffs as ESLint errors, single source of truth in CI).
+
+Don't try to make ESLint also do formatting β Prettier owns formatting, ESLint owns lint. The two-tool split is the boring-correct setup.
+
+### `package.json` final shape (illustrative)
+
+```json
+{
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "lint": "eslint .",
+ "typecheck": "tsc -b --noEmit",
+ "format": "prettier --write .",
+ "format:check": "prettier --check ."
+ }
+}
+```
+
+CI (task 1.9) will run `typecheck`, `lint`, `format:check`, `build` in that order.
+
+## Acceptance criteria
+
+- [ ] `pnpm install` completes without warnings about peer-dep conflicts.
+- [ ] `pnpm dev` starts and the page shows "SPA" still rendering, plus a Tailwind-styled colour test (e.g. red background) to prove Tailwind works.
+- [ ] `pnpm typecheck` clean.
+- [ ] `pnpm lint` clean.
+- [ ] `pnpm format:check` clean.
+- [ ] `pnpm build` produces `dist/` with no warnings.
+- [ ] `npx shadcn@latest add card` (or any primitive) works β proves shadcn/ui is configured correctly.
+- [ ] `package.json` `dependencies` section includes (at minimum): `@directus/sdk`, `@hookform/resolvers`, `@tanstack/react-query`, `@tanstack/react-router`, `react`, `react-dom`, `react-hook-form`, `zod`, `zustand`. `devDependencies` includes the rest.
+
+## Risks / open questions
+
+- **Tailwind 4 + shadcn/ui compatibility.** shadcn supports Tailwind 4 as of late 2024. If the CLI throws an error during init, fall back to Tailwind 3 + the PostCSS plugin (older but stable). Document the choice in the task's Done section.
+- **TanStack Router type inference.** The router type generation is done by the Vite plugin; without it, route-tree types don't propagate. 1.3 pulls in the Vite plugin alongside the proxy config; for now leave the router uninstalled-as-feature.
+- **React 19 + libs.** Most ecosystem libs support React 19 by now (early 2026). If any of these has a peer-dep complaint, document and decide between forcing the install or downgrading the lib in question.
+
+## Done
+
+Stack rounded out: Tailwind 4 via `@tailwindcss/vite`, shadcn/ui (slate base / new-york style) with primitives `button`, `input`, `label`, `form`, `card`, `alert` in `src/ui/primitives/`, TanStack Router 1.169 + Query 5.100 (devtools + router-plugin in devDeps, not yet wired β that's 1.7), Zustand 5, `@directus/sdk` 21, zod 4, react-hook-form 7 + `@hookform/resolvers`, `class-variance-authority` + `clsx` + `tailwind-merge` + `lucide-react` (shadcn runtime), Prettier 3 + `eslint-config-prettier` + `eslint-plugin-prettier` integrated last in `eslint.config.js`. Path alias `@/*` β `./src/*` set up in both `tsconfig.json` and `tsconfig.app.json` (TS 6 deprecates `baseUrl`; paths now resolve relative to the config file). Scripts: `dev`, `build`, `preview`, `lint`, `typecheck`, `format`, `format:check`, `test` (placeholder). README rewritten with stack/scripts/shadcn-add instructions.
+
+**Deviation from spec:** the path alias was originally scoped to task 1.3, but shadcn's `add` CLI needs the alias resolvable to populate primitives at the right path β without it shadcn created a literal `@\` directory. Set up minimally here; 1.3 still owns the rest of the tsconfig hardening and the dev proxy.
+
+**Smoke check:** `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, `pnpm build` all green. `App.tsx` renders a Tailwind-styled centered card with a shadcn `Button` to prove the toolchain works end-to-end. Bundle: 222KB raw / 70KB gzipped.
+
+(Commit SHA pending β work landed locally, not yet committed.)
diff --git a/.planning/phase-1-foundation/03-vite-dev-proxy.md b/.planning/phase-1-foundation/03-vite-dev-proxy.md
new file mode 100644
index 0000000..4aec658
--- /dev/null
+++ b/.planning/phase-1-foundation/03-vite-dev-proxy.md
@@ -0,0 +1,135 @@
+# Task 1.3 β Vite dev proxy + path aliases + tsconfig hardening
+
+**Phase:** 1 β Foundation
+**Status:** β¬ Not started
+**Depends on:** 1.2
+**Wiki refs:** `docs/wiki/entities/react-spa.md` Β§Auth pattern
+
+## Goal
+
+Configure Vite's dev server to proxy `/api`, `/ws-business`, `/ws-live`, and `/config.json` to the appropriate local services so the SPA dev experience matches the stage/prod reverse-proxy topology. Same-origin in dev means cookies flow correctly, identical to stage/prod where Traefik does the routing. Also: add path aliases (`@/`) so imports stay short, and tighten `tsconfig.json` for stricter type safety.
+
+After this task, `pnpm dev` against a locally-running Directus + (eventually) Processor lets the SPA exercise the real auth flow without CORS workarounds.
+
+## Deliverables
+
+- `vite.config.ts` updated:
+ - `server.proxy` configured for:
+ - `/api` β `http://localhost:8055` (Directus REST/GraphQL).
+ - `/ws-business` β `ws://localhost:8055/websocket` (Directus business-plane WS), with `ws: true` and `rewrite` to strip `/ws-business`.
+ - `/ws-live` β `ws://localhost:8081` (Processor live WS, once Phase 1.5 lands), with `ws: true` and `rewrite` to strip `/ws-live`.
+ - Don't proxy `/config.json` β it's served from `public/` by Vite directly in dev.
+ - `resolve.alias` for `@/` β `src/`.
+ - `server.port` pinned to `5173` (Vite default; mention in README so anyone running other Vite apps avoids the conflict).
+- `tsconfig.app.json` updated:
+ - `compilerOptions.baseUrl` to `.`, `paths` to `{ "@/*": ["src/*"] }`.
+ - Add `noUncheckedIndexedAccess: true` (matches `tcp-ingestion` / `processor` strictness).
+ - Add `noImplicitOverride: true`.
+ - `verbatimModuleSyntax: true` is fine if already on; otherwise leave for now.
+- `README.md` updated: a "local dev" section listing the assumed local services + how to override the proxy targets via env vars (next bullet).
+- Optional: env-var override for proxy targets:
+ - `VITE_DEV_DIRECTUS_URL` (default `http://localhost:8055`).
+ - `VITE_DEV_PROCESSOR_WS_URL` (default `ws://localhost:8081`).
+ - Read in `vite.config.ts` via `loadEnv`.
+ - Document in `.env.dev.example` (committed) and `.env.dev.local` (gitignored).
+
+## Specification
+
+### Why proxy in dev instead of CORS
+
+CORS-with-credentials requires:
+
+1. The browser sending `withCredentials: true` on every request.
+2. The server responding with `Access-Control-Allow-Origin:
` (not `*`) and `Access-Control-Allow-Credentials: true`.
+3. Cookies being scoped correctly (`SameSite=None; Secure` for cross-site β which means HTTPS even in dev).
+
+This works but it's three places that must agree, and `SameSite=None; Secure` requires localhost-with-TLS in dev (extra setup). The dev proxy collapses all of it into "everything is one origin," matching stage/prod's reverse-proxy topology. **Pick the dev proxy.**
+
+### Proxy config shape
+
+```ts
+// vite.config.ts
+import { defineConfig, loadEnv } from 'vite';
+import react from '@vitejs/plugin-react';
+import tailwindcss from '@tailwindcss/vite';
+import path from 'node:path';
+
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), 'VITE_');
+ const directusUrl = env.VITE_DEV_DIRECTUS_URL ?? 'http://localhost:8055';
+ const processorWsUrl = env.VITE_DEV_PROCESSOR_WS_URL ?? 'ws://localhost:8081';
+
+ return {
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: { '@': path.resolve(__dirname, './src') },
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: directusUrl,
+ changeOrigin: true,
+ rewrite: (p) => p.replace(/^\/api/, ''),
+ },
+ '/ws-business': {
+ target: directusUrl,
+ ws: true,
+ changeOrigin: true,
+ rewrite: (p) => p.replace(/^\/ws-business/, '/websocket'),
+ },
+ '/ws-live': {
+ target: processorWsUrl,
+ ws: true,
+ changeOrigin: true,
+ rewrite: (p) => p.replace(/^\/ws-live/, ''),
+ },
+ },
+ },
+ };
+});
+```
+
+The path namespacing (`/api`, `/ws-business`, `/ws-live`) is what the SPA code calls. The proxy strips the namespace and forwards. Stage/prod's Traefik does the same routing under the public domain β same paths, same code, no env branching in the SPA.
+
+### Path alias
+
+`@/` is the convention shadcn/ui assumes (its `components.json` config wires `@/components/...`, `@/lib/utils`). Aligning Vite + tsconfig with it means imports look like:
+
+```ts
+import { Button } from '@/ui/primitives/button';
+import { useAuthStore } from '@/auth/store';
+```
+
+instead of `../../../ui/primitives/button`. Don't add other top-level aliases β `@/` for `src/` is enough.
+
+### Why `noUncheckedIndexedAccess`
+
+Without it, `arr[0]` is typed as `T` even when the array might be empty. With it, `arr[0]` is typed `T | undefined`, forcing a check. Catches a real class of bugs in map-position code (where "first device" assumptions are easy to make and easy to get wrong when the array is empty during reconnect).
+
+The processor and tcp-ingestion both have this on. Match.
+
+### Vite proxy and WebSocket behaviour
+
+The `ws: true` flag on the proxy entry tells Vite (which uses `http-proxy-middleware`) to forward the upgrade request. Cookies attached to the upgrade carry through transparently β that's the whole point of running same-origin in dev.
+
+One gotcha: the proxy's `target` for a WS route must use a `ws://` or `wss://` scheme, _not_ `http://`. If you accidentally use `http://`, the upgrade fails silently and the SPA gets a connection error. Document in the README.
+
+## Acceptance criteria
+
+- [ ] `pnpm dev` starts at `http://localhost:5173`; the SPA loads.
+- [ ] With Directus running locally on `:8055`, `fetch('/api/server/info')` from the browser console returns Directus's server info β proxy is forwarding.
+- [ ] WebSocket smoke test: `new WebSocket('ws://localhost:5173/ws-business')` connects (returns code 1006 if Directus's WS endpoint is locked down without auth, but the upgrade itself succeeds β that's the test).
+- [ ] `import { foo } from '@/auth/foo'` resolves both at type-check time and at runtime.
+- [ ] `pnpm typecheck` is stricter β adding `arr[0].whatever` without a check should now fail.
+- [ ] No CORS errors in the browser console when calling `/api/...` from `http://localhost:5173`.
+
+## Risks / open questions
+
+- **Directus's WS path is `/websocket`, not `/ws`.** Confirmed by reading Directus 11.x source. The proxy `rewrite` rule reflects this.
+- **Processor WS path is unsettled.** [[processor-ws-contract]] uses `/processor/ws` as illustrative; the actual path will be set by the proxy config in stage/prod (`trm/deploy`). For dev we assume the Processor binds to `:8081` with no path prefix, and the proxy adds the `/ws-live` prefix on the SPA side. Reconcile if Phase 1.5 lands with a different path convention.
+- **Multiple Vite apps on one machine.** Pinning to `:5173` collides if another Vite app is running. Document the override (`VITE_PORT=5174 pnpm dev` works as a one-off; permanent change needs the config edit).
+
+## Done
+
+(Filled in when the task lands.)
diff --git a/.planning/phase-1-foundation/04-runtime-config.md b/.planning/phase-1-foundation/04-runtime-config.md
new file mode 100644
index 0000000..34e8b1d
--- /dev/null
+++ b/.planning/phase-1-foundation/04-runtime-config.md
@@ -0,0 +1,105 @@
+# Task 1.4 β Runtime config endpoint
+
+**Phase:** 1 β Foundation
+**Status:** β¬ Not started
+**Depends on:** 1.2
+**Wiki refs:** `docs/wiki/entities/react-spa.md` Β§Auth pattern; `.planning/ROADMAP.md` design rule 7
+
+## Goal
+
+Establish the runtime-config pattern: a `/config.json` file fetched at app boot, validated with zod, made available via a typed React context. Per-environment values (Directus URL, processor WS URL, Google Maps key, feature flags) live there β not in build-time `import.meta.env`. One built image, many deploy environments.
+
+## Deliverables
+
+- `src/config/schema.ts` β zod schema:
+ ```ts
+ export const RuntimeConfigSchema = z.object({
+ directusUrl: z.string().url(), // public-facing Directus URL (e.g. https://stage.trmtracking.org/api)
+ liveWsUrl: z.string().url(), // public-facing Processor WS URL (wss://stage.trmtracking.org/ws-live)
+ businessWsUrl: z.string().url(), // public-facing Directus WS URL (wss://stage.trmtracking.org/ws-business)
+ googleMapsKey: z.string().optional(), // BYOK; if absent, Google tile sources are hidden in the UI
+ env: z.enum(['dev', 'stage', 'prod']), // for log labels and feature flag conditionals
+ });
+ export type RuntimeConfig = z.infer;
+ ```
+- `src/config/load.ts`:
+ - `async function loadConfig(): Promise` β fetches `/config.json`, parses with zod, throws on validation failure.
+ - On dev, the file is served directly from `public/config.json` by Vite. On stage/prod, the proxy serves it (Traefik file-middleware or a tiny nginx rule).
+- `src/config/context.tsx`:
+ - `RuntimeConfigProvider` β fetches config in a `useEffect` on mount, holds it in state, passes via context.
+ - `useRuntimeConfig()` hook β throws if used outside the provider, returns the typed config.
+ - While loading, render a minimal "Loadingβ¦" placeholder (one centred div, no shadcn dependency).
+ - On error, render an error state with the validation issues β clear enough that a misconfigured `config.json` doesn't masquerade as a generic crash.
+- `public/config.json` β checked-in file with dev defaults:
+ ```json
+ {
+ "directusUrl": "/api",
+ "liveWsUrl": "/ws-live",
+ "businessWsUrl": "/ws-business",
+ "env": "dev"
+ }
+ ```
+ Note: relative URLs work because the SPA always runs same-origin (Vite dev proxy or Traefik in stage/prod). Document this in the file's accompanying README.
+- `src/main.tsx` updated to wrap `` in ``.
+- `README.md` updated with a "Runtime config" section explaining: where the file lives in dev vs stage/prod, the schema, how to add a new field, the dev defaults.
+
+## Specification
+
+### Why runtime config not build-time
+
+Build-time means one bundle per environment. That breaks reproducibility (dev image β stage image β prod image), prevents promoting a known-good build to prod ("ship the stage build to prod after stage soak"), and forces a rebuild for any env-only change (add a feature flag β rebuild β reupload). Runtime config lets one image serve all environments β the proxy or stack overrides the file.
+
+The Directus-side runtime config (`DIRECTUS_PUBLIC_URL`, etc.) and Processor-side (`DIRECTUS_BASE_URL`, etc.) follow the same pattern. SPA consistency.
+
+### How the file is overridden in stage/prod
+
+Two reasonable options:
+
+**Option A: Static file in the SPA's nginx serve config.** The Dockerfile (1.9) bakes a default `config.json` into the image. Override happens at deploy time: a Traefik file-middleware (or a sibling nginx rule) intercepts `/config.json` and serves a different file from a host volume. Requires zero rebuilds.
+
+**Option B: Server-rendered config.** Directus could serve `/config.json` via a custom endpoint that reads env vars. Rebuildable through Directus.
+
+**Pick Option A.** Simpler, zero new code, deploy-time override is clean. Document the override pattern in `trm/deploy/README.md` (1.10).
+
+### What goes in config vs what doesn't
+
+In config:
+
+- URLs the SPA calls (Directus, Processor WS, business WS).
+- Optional API keys the operator brings (Google Maps).
+- Environment label for log/feature use.
+
+NOT in config:
+
+- Anything that varies per-user (the user's role, their subscriptions).
+- Anything operational secret (no JWT signing keys, no DB passwords).
+- Anything that changes during the session (that's state, not config).
+
+### Failure modes
+
+- **`config.json` missing (404):** error state with "Runtime config not found at /config.json. Did the deployment skip its config volume?" Don't fall back to defaults silently β that hides misconfiguration.
+- **`config.json` invalid (zod fails):** error state listing the validation issues. Useful for catching typos in stage configs.
+- **Network failure on first load:** retry once after 500ms, then show error. Don't infinite-retry β the user can reload the page if the network heals.
+
+### Why a context provider rather than a Zustand store
+
+Auth state needs Zustand (read in many places, granular subscribers). Runtime config is read once and never changes during the session β context is exactly right for that. Don't over-engineer.
+
+## Acceptance criteria
+
+- [ ] `pnpm dev` shows a brief "Loadingβ¦" then renders the placeholder content.
+- [ ] Manually editing `public/config.json` to invalid shape and reloading shows the error state with the validation issues, not a blank screen.
+- [ ] Manually editing `public/config.json` to invalidate URL format (e.g. `directusUrl: "not-a-url"`) shows the same error state.
+- [ ] `useRuntimeConfig()` is callable from any descendant of `` and returns typed config.
+- [ ] Calling `useRuntimeConfig()` outside the provider throws a clear error (not "cannot read property X of undefined").
+- [ ] The default `public/config.json` validates against the schema.
+
+## Risks / open questions
+
+- **Same-origin assumption.** The relative URLs (`/api`, `/ws-live`) only work if everything is same-origin. If a deploy environment ever wants to point the SPA at a different-origin Directus (e.g. for a partner integration), the config schema accepts absolute URLs β but cookie auth breaks at that point. Document the constraint.
+- **Field projection in zod.** If we ever add a sensitive field (a per-tenant analytics key), it would be visible to anyone hitting `/config.json` directly. Don't put anything sensitive there. The schema's `RuntimeConfigSchema.optional()` discipline helps but doesn't enforce.
+- **Hot reload of config in dev.** Editing `public/config.json` requires a hard reload (Vite watches the file but the React Provider only fetches on mount). Acceptable for dev.
+
+## Done
+
+(Filled in when the task lands.)
diff --git a/.planning/phase-1-foundation/05-directus-auth-client.md b/.planning/phase-1-foundation/05-directus-auth-client.md
new file mode 100644
index 0000000..576aa21
--- /dev/null
+++ b/.planning/phase-1-foundation/05-directus-auth-client.md
@@ -0,0 +1,208 @@
+# Task 1.5 β Directus auth client (cookie mode + refresh)
+
+**Phase:** 1 β Foundation
+**Status:** β¬ Not started
+**Depends on:** 1.4
+**Wiki refs:** `docs/wiki/entities/directus.md`; `docs/wiki/entities/react-spa.md` Β§Auth pattern; `docs/wiki/synthesis/processor-ws-contract.md` Β§Auth handshake
+
+## Goal
+
+Wire the Directus SDK with cookie-mode authentication: `/auth/login` issues an httpOnly refresh cookie + returns an access token (held in memory only); `/auth/refresh` rotates both; the SDK auto-refreshes on 401. Build a small Zustand auth store (`user` + `status`) so any component can react to login/logout. After this task, the auth machinery is functional but headless β the login page (1.6) and route guard (1.7) build on it.
+
+## Deliverables
+
+- `src/auth/client.ts` β typed Directus SDK client:
+
+ ```ts
+ import { authentication, createDirectus, rest } from '@directus/sdk';
+ import { config } from '@/config/context'; // see "Bootstrapping" below
+
+ export type DirectusUser = {
+ id: string;
+ email: string | null;
+ role: string | null;
+ first_name: string | null;
+ last_name: string | null;
+ };
+
+ export type Schema = {
+ // grow as collections become typed; for now an empty schema
+ };
+
+ export const directus = createDirectus(config.directusUrl)
+ .with(authentication('cookie', { credentials: 'include', autoRefresh: true }))
+ .with(rest({ credentials: 'include' }));
+ ```
+
+ - The `'cookie'` mode + `credentials: 'include'` is the SDK's contract for httpOnly-refresh-cookie + in-memory-access-token. `autoRefresh: true` enables the SDK's built-in 401 interceptor.
+
+- `src/auth/store.ts` β Zustand auth store:
+
+ ```ts
+ type AuthState =
+ | { status: 'unknown' } // initial; before /users/me check
+ | { status: 'anonymous' }
+ | { status: 'authenticated'; user: DirectusUser }
+ | { status: 'authenticating' }; // login in progress
+
+ type AuthActions = {
+ initialize(): Promise; // call /users/me; set status accordingly
+ login(email: string, password: string): Promise<{ ok: true } | { ok: false; error: string }>;
+ logout(): Promise;
+ setUser(user: DirectusUser): void; // for after refresh sees a user-update
+ };
+ ```
+
+ - `initialize` is called once at app boot from ``'s post-load step. It tries `/users/me`; on 200, sets `status: 'authenticated'`; on 401/error, sets `status: 'anonymous'`.
+
+- `src/auth/guard.ts` β `useRequireAuth()` hook used by Phase 1.7's protected routes:
+ ```ts
+ export function useRequireAuth() {
+ const status = useAuthStore((s) => s.status);
+ const navigate = useNavigate(); // TanStack Router; lands in 1.7
+ useEffect(() => {
+ if (status === 'anonymous') navigate({ to: '/login' });
+ }, [status, navigate]);
+ return status;
+ }
+ ```
+- `src/auth/index.ts` β barrel re-exports.
+- `src/main.tsx` β call `useAuthStore.getState().initialize()` after the runtime config loads. (More precisely: the `RuntimeConfigProvider`'s success path triggers `initialize()` exactly once.)
+
+## Specification
+
+### Why a Zustand auth store
+
+The auth state is read in many places: route guards, the user-menu component, audit logs, the WS client (when 1.5 wires the WS). React Context cascades re-renders to all consumers on every change; Zustand's selector-based subscription means only components that read the changed slice re-render. For auth (small, infrequent changes) the difference is small in practice, but the pattern is the same one we'll use for live-position state in Phase 2 β staying consistent is the win.
+
+### Why cookie mode (not bearer-token mode)
+
+Three reasons documented in `docs/wiki/entities/react-spa.md` Β§Auth pattern:
+
+1. **No XSS exfiltration of long-lived credentials.** The refresh cookie is `httpOnly`; JS can never read it.
+2. **WS upgrade carries the cookie automatically.** Same-origin β browser attaches it. No "send the JWT in the upgrade message" dance.
+3. **Same pattern Directus already supports.** No custom server work; just the right login mode.
+
+The access token is held in memory by the SDK (in `directus._authentication.tokens` internally; not directly accessed by app code). It expires (default 15 min); the SDK's `autoRefresh: true` calls `/auth/refresh` on the next request that hits a 401, transparently.
+
+### Bootstrapping order
+
+```
+1. main.tsx renders shell.
+2. RuntimeConfigProvider fetches /config.json.
+3. On success: imports the config singleton, runtime-config-driven createDirectus(...) is realisable.
+4. RuntimeConfigProvider calls useAuthStore.getState().initialize().
+5. initialize() calls directus.request(readMe()) (the SDK's /users/me wrapper).
+6. On 200 β setUser, status: 'authenticated'.
+7. On 401 β status: 'anonymous'.
+8. Provider's children render β they can now read the auth store.
+```
+
+The order matters: createDirectus needs the runtime config to know what URL to hit. So the SDK client can't be a top-level module-level `const`; it has to be created after config loads. Two ways to handle this:
+
+**Option A: Lazy creation.** `getDirectusClient()` returns the singleton, creating it on first call. The first call lands inside `initialize()`, after the config has resolved.
+
+**Option B: Module-level after config import.** The config context exposes a `directus` instance that's only valid after the provider has loaded. Requires the import chain to defer.
+
+**Pick Option A.** Cleaner β the singleton creation is explicitly tied to a function call rather than module-load order.
+
+```ts
+// src/auth/client.ts
+let _client: ReturnType | null = null;
+
+export function getDirectus() {
+ if (!_client) {
+ const cfg = useRuntimeConfig.getState(); // or pass via param; see below
+ _client = createDirectus(cfg.directusUrl)
+ .with(authentication('cookie', { credentials: 'include', autoRefresh: true }))
+ .with(rest({ credentials: 'include' }));
+ }
+ return _client;
+}
+```
+
+If the runtime config hook isn't easily accessible from outside React, expose the config as a Zustand store too (or read directly from the loaded JSON via a non-React module). The simplest pragmatic answer: the config provider sets a module-level global on success, and `getDirectus()` reads it.
+
+### `initialize()` β the one-time login check
+
+```ts
+async initialize() {
+ const directus = getDirectus();
+ set({ status: 'authenticating' }); // tiny window; UX-invisible
+ try {
+ const user = await directus.request(readMe({
+ fields: ['id', 'email', 'role', 'first_name', 'last_name'],
+ }));
+ set({ status: 'authenticated', user: user as DirectusUser });
+ } catch (err) {
+ // 401 from /users/me means no session. Anything else is unusual but treat the same.
+ set({ status: 'anonymous' });
+ }
+}
+```
+
+The `try/catch` swallowing all errors is deliberate: at boot, a failure to read `/users/me` _is_ the "anonymous" state. We don't surface boot-time errors here.
+
+### `login(email, password)`
+
+```ts
+async login(email, password) {
+ const directus = getDirectus();
+ set({ status: 'authenticating' });
+ try {
+ await directus.login(email, password, { mode: 'cookie' });
+ // After login the SDK has the access token; re-fetch user.
+ const user = await directus.request(readMe({ fields: [...] }));
+ set({ status: 'authenticated', user: user as DirectusUser });
+ return { ok: true };
+ } catch (err) {
+ set({ status: 'anonymous' });
+ return { ok: false, error: humanizeAuthError(err) };
+ }
+}
+```
+
+`humanizeAuthError` maps Directus's error codes (`INVALID_CREDENTIALS`, `INVALID_OTP`, etc.) to user-friendly strings. Default for unknown: "Login failed. Please try again."
+
+### `logout()`
+
+```ts
+async logout() {
+ const directus = getDirectus();
+ try {
+ await directus.logout();
+ } catch {
+ // Ignore β even if logout fails server-side, clear local state.
+ }
+ set({ status: 'anonymous' });
+ // 1.8 builds on this: also invalidate the TanStack Query cache.
+}
+```
+
+### What this task does NOT do
+
+- **No login page.** That's 1.6.
+- **No route guards wired into routes.** That's 1.7.
+- **No automatic redirect on logout.** The component calling `logout()` decides where to navigate. 1.8 wires the actual logout button into the app shell.
+- **No "remember me."** Directus's refresh cookie is the persistence; the SPA doesn't add another layer.
+
+## Acceptance criteria
+
+- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check` clean.
+- [ ] In a browser console (with Directus running locally + a known user seeded): `useAuthStore.getState().login('admin@example.com', 'password')` resolves to `{ ok: true }` and `useAuthStore.getState().status === 'authenticated'`.
+- [ ] After login, `document.cookie` shows nothing (refresh cookie is httpOnly β invisible to JS, as intended).
+- [ ] After login, `useAuthStore.getState().user` is the expected user record.
+- [ ] `useAuthStore.getState().logout()` resolves; status returns to `'anonymous'`.
+- [ ] Wrong credentials produce `{ ok: false, error: 'Invalid email or password.' }` (or similar humanised string).
+- [ ] Network error during login resolves to `{ ok: false, error: ... }` β no uncaught exception bubbling to the React error boundary.
+
+## Risks / open questions
+
+- **`@directus/sdk` cookie mode quirks.** The SDK changed cookie-mode handling between 11.0 and 11.x. Verify the exact API against the version installed in 1.2 (`pnpm list @directus/sdk`). Adjust the `authentication('cookie', ...)` call signature if needed.
+- **CSRF.** Directus's cookie mode includes CSRF protection (the access token in the response body is the anti-CSRF measure β readable by JS, not by a third-party form post). The SDK handles this; we don't add custom CSRF headers.
+- **Session persistence across reloads.** The httpOnly refresh cookie persists across page reloads; on each load `initialize()` calls `/users/me`, which does a transparent refresh if needed, and the user is "still logged in." Verify in the integration smoke (1.6 task acceptance).
+- **Lazy `getDirectus()` and HMR.** During Vite dev HMR, the module reloads but the singleton survives (it's in a closure). If the config changes mid-session this becomes stale; acceptable trade-off β config rarely changes in dev.
+
+## Done
+
+(Filled in when the task lands.)
diff --git a/.planning/phase-1-foundation/06-login-page.md b/.planning/phase-1-foundation/06-login-page.md
new file mode 100644
index 0000000..3b8b388
--- /dev/null
+++ b/.planning/phase-1-foundation/06-login-page.md
@@ -0,0 +1,163 @@
+# Task 1.6 β Login page
+
+**Phase:** 1 β Foundation
+**Status:** β¬ Not started
+**Depends on:** 1.5
+**Wiki refs:** `docs/wiki/entities/react-spa.md` Β§Auth pattern
+
+## Goal
+
+A clean, single-screen login page. Email + password fields, validation via zod + react-hook-form, submit calls `useAuthStore.login(...)`, error state surfaces a humanised message under the form, success redirects to the intended destination (or `/` if none).
+
+The look-and-feel target is "professional, not flashy" β race operators logging in on stage day shouldn't be wondering whether the page is broken. Use shadcn/ui's `Card` + `Input` + `Label` + `Button` + `Alert` primitives installed in 1.2.
+
+## Deliverables
+
+- `src/ui/pages/login.tsx`:
+ - A centred card on a plain background.
+ - "TRM" or "Time Racing Management" header (whatever brand text is agreed; can be a placeholder for now).
+ - Email field (type `email`, autocomplete `username`).
+ - Password field (type `password`, autocomplete `current-password`).
+ - "Sign in" button.
+ - Validation: email must be valid format; password must be β₯ 1 character (Directus enforces real password rules; we don't duplicate them client-side).
+ - Submit handler calls `useAuthStore.getState().login(email, password)`. While the call is in flight, the button shows "Signing inβ¦" and is disabled.
+ - On `{ ok: false, error }`, render an `` above the form with the error message.
+ - On `{ ok: true }`, navigate to `searchParams.get('redirect') ?? '/'`.
+- `src/routes/login.tsx` (TanStack Router file-based route β final wiring lands in 1.7, but the page component itself is built here):
+ - Exports the page component.
+ - Accepts a `redirect` search param.
+ - When the auth store transitions to `'authenticated'` (e.g. via concurrent tab login), auto-navigate to `redirect` to avoid a stale login page.
+- `src/ui/pages/__tests__/login.test.tsx` (optional for Phase 1 β Vitest infra lands in Phase 3; for now a manual smoke is acceptance enough).
+
+## Specification
+
+### Form library + validation
+
+```tsx
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { z } from 'zod';
+
+const LoginFormSchema = z.object({
+ email: z.string().email({ message: 'Enter a valid email address.' }),
+ password: z.string().min(1, { message: 'Password is required.' }),
+});
+type LoginForm = z.infer;
+
+const form = useForm({
+ resolver: zodResolver(LoginFormSchema),
+ defaultValues: { email: '', password: '' },
+});
+```
+
+Field-level errors render under each input. Submit-level errors (the "wrong credentials" message) render in the alert above the form.
+
+### Layout
+
+```tsx
+
+
+
+ Sign in to TRM
+ Use your operator credentials.
+
+
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+
+
+
+```
+
+### Submit handler
+
+```tsx
+const [submitError, setSubmitError] = useState(null);
+const [isSubmitting, setIsSubmitting] = useState(false);
+const navigate = useNavigate();
+const { redirect } = useSearch({ from: '/login' });
+
+async function onSubmit(values: LoginForm) {
+ setSubmitError(null);
+ setIsSubmitting(true);
+ const result = await useAuthStore.getState().login(values.email, values.password);
+ setIsSubmitting(false);
+ if (result.ok) {
+ navigate({ to: redirect ?? '/', replace: true });
+ } else {
+ setSubmitError(result.error);
+ }
+}
+```
+
+`replace: true` on success β don't pile login pages in the back-button history.
+
+### Auto-navigate if already authenticated
+
+If the user is already authenticated (e.g. they bookmarked `/login` and came back), redirect them away. Watch the auth store:
+
+```tsx
+const status = useAuthStore((s) => s.status);
+useEffect(() => {
+ if (status === 'authenticated') {
+ navigate({ to: redirect ?? '/', replace: true });
+ }
+}, [status, navigate, redirect]);
+```
+
+### What this task does NOT include
+
+- **Forgot-password / password-reset link.** Directus has `/auth/password/request` but the email-template + the page that handles the reset link are out of Phase 1 scope. Add a placeholder "Forgot password?" link that's `disabled` or routes to a "Not yet available" page; document as deferred.
+- **OAuth / SSO buttons.** Directus supports OAuth, but Phase 1's dogfood doesn't need it. Skip entirely.
+- **2FA / OTP.** Same β not needed for the dogfood. If a Directus user has 2FA enabled, login will fail with a specific error code; add a TODO comment, document, and ignore for now.
+- **"Remember me" checkbox.** The refresh cookie is the persistence layer; no separate "remember me" toggle.
+
+## Acceptance criteria
+
+- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format:check` clean.
+- [ ] `pnpm dev` β navigate to `/login` (after 1.7 wires routing) β renders the form.
+- [ ] Submitting with empty fields shows field-level validation errors; no network call made.
+- [ ] Submitting with valid format but wrong credentials shows the alert with a humanised error.
+- [ ] Submitting with correct credentials redirects to `/` (or to the `?redirect=...` param if present).
+- [ ] Already-authenticated user landing on `/login` is redirected away, not stuck on the page.
+- [ ] Form is keyboard-navigable (tab order: email β password β submit); pressing Enter in either field submits.
+- [ ] Browser autofill works (the autocomplete attributes let password managers populate correctly).
+
+## Risks / open questions
+
+- **Form a11y.** The shadcn `Form` primitive (different from raw `