+ Two independent paths, both gated by the same allowlist. Server binds 127.0.0.1 only —
+ a reverse proxy on phoebe terminates TLS and forwards. Editing requires rewriting config.yaml and restarting.
+
+ issuer{a.oidc.issuer}
+ audience{a.oidc.audience}
+ claim{a.oidc.usernameClaim} → fallback sub
+ jwkscached · last fetch {a.oidc.jwksLastFetch}
+ used bycollector, scripted clients
+
+
+
resolution order
+
+ 1. Authorization: Bearer … validated → user from JWT
+ 2. else {a.forwardAuth.userHeader} taken from proxy
+ 3. else 401 · invalid bearer never falls back (fail-closed)
+
+ owner is server-derived from the authenticated user on every ingest write.
+ The wire format in internal/shared/wire/ has no owner field — collectors cannot impersonate.
+ Read endpoints filter to owner = current_user; admins ({a.admins.join(', ')}) may pass ?owner=<user> or ?owner=* to override. Non-admins passing ?owner= at all → 403.
+ A session belonging to another owner returns 404 (existence is never leaked).
+
+ );
+}
+
+function TweakButton({ label, onClick, secondary = false }) {
+ return (
+
+ );
+}
+
+Object.assign(window, {
+ useTweaks, TweaksPanel, TweakSection, TweakRow,
+ TweakSlider, TweakToggle, TweakRadio, TweakSelect,
+ TweakText, TweakNumber, TweakColor, TweakButton,
+});
diff --git a/docs/tasks/lethe-web-ui-foundation.md b/docs/tasks/lethe-web-ui-foundation.md
new file mode 100644
index 0000000000000000000000000000000000000000..c75e9118b740ee2ff2c0f6fe2ede52f2229da7b4
--- /dev/null
+++ b/docs/tasks/lethe-web-ui-foundation.md
@@ -0,0 +1,297 @@
+# lethe-web-ui-foundation
+
+**Status:** Design
+**Module:** `sourcecraft.dev/bigbes/lethe`
+**Branch:** master
+**Worktree:** none
+**Parent RFC:** Personal AI Assistant Log Aggregator (2026-04-25)
+**Design source:** `docs/design_handoff_assistant_log/` (Direction 4 — Dense Data UI)
+**Sibling tasks:**
+- `lethe-server.md` (#1, ✓ verified)
+- `lethe-collector-claude-code.md` (#2, deferred)
+- `lethe-search-and-opencode.md` (#3, deferred)
+- `lethe-web-ui-aggregates.md` (#5, deferred — Projects/Stats endpoints + screens)
+- `lethe-web-ui-palette-savedsearch.md` (#6, deferred — full ⌘K + saved searches)
+- `lethe-web-ui-settings-display.md` (#7, deferred — Settings → Display)
+- `lethe-web-ui-search.md` (#8, blocked on #3)
+- `lethe-web-ui-health-sources.md` (#9, blocked on #2)
+
+## Design
+
+### Purpose
+
+Stand up the Lethe web UI: a single-page React app served by the lethe binary, providing a usable browse-and-read experience over the existing `/api/v1/sessions` surface. This is the foundation that follow-on tasks build on (aggregates, palette, settings, search, health). **In scope**: project skeleton, build pipeline, embed/serve, theming primitives, Home, Session view. **Out of scope**: Projects, Stats, Search, Health, Settings, ⌘K palette beyond a stub, saved searches.
+
+### Chosen approach
+
+**Stack**: React 18 + TypeScript + Vite + TanStack Router + TanStack Query, vanilla CSS using the tokens shipped in the handoff's `prototype.css`. (React over alternatives because the handoff is a React prototype — porting JSX directly is the cheapest route to fidelity.)
+
+**Layout**: source at `web/` (sibling to `cmd/`, `internal/`); `web/dist/` is git-ignored; production embeds via `//go:embed all:dist/*` from `internal/server/web/embed.go` and serves the SPA as a fallback under the **unauthed** mux. `/api/v1` stays auth-gated; the SPA shell loads even if unauthenticated, and a 401 from any data fetch flips the UI into an auth-error state.
+
+**Dev**: `just web-dev` starts vite at `:5173` with a proxy to `127.0.0.1:18888` for `/api/v1`, `/healthz`, `/readyz`, `/metrics`. The proxy injects `Remote-User: bigbes` on every outbound request so forward-auth passes without a real reverse proxy. Go code is **not** modified for dev auth.
+
+### Routes shipped
+
+| Path | Screen |
+|---|---|
+| `/` | Home (recent sessions, real data) |
+| `/session/:tool/:host/:id` | Session view (turn list aside + transcript, real data) |
+| `/projects`, `/stats`, `/health`, `/settings` | Stub: TopBar tab is functional, screen renders a small "coming in a later task" `EmptyState` card |
+| `/search` | Stub (palette's synthetic `SEARCH for ""` row routes here) |
+| anything else | 404 (the prototype's `EmptyState`) |
+
+Stubs ship to keep the shell coherent (TopBar tabs and `g h/p/s/i` keybindings all "work" — they navigate). They're cheap and avoid TopBar dead-clicks.
+
+### Backend changes (additive only)
+
+`GET /api/v1/sessions` response gains five fields per row, all derivable from the current schema:
+
+| Field | SQL |
+|---|---|
+| `summary` | first user turn's `content`, truncated to 200 chars |
+| `turn_count` | `COUNT(*)` over turns scoped to the session |
+| `tokens_in_total`, `tokens_out_total` | `SUM(tokens_in)`, `SUM(tokens_out)` over the same scope |
+| `model` | latest turn's `model` (`ORDER BY seq DESC LIMIT 1`) |
+
+Implemented with a single SQL using correlated subqueries or a derived join, in `internal/domain/session/repository.go`. Repository tests cover each aggregate. **No wire-type changes**, **no schema changes**, **no removed fields** — the response stays a strict superset.
+
+`branch` and `has_error` (in the handoff's `Session` TS type) are **deferred** — neither is in the schema; will land in a future task that introduces them.
+
+### Frontend layout (under `web/`)
+
+```
+web/
+├── package.json
+├── vite.config.ts # proxy + Remote-User injection
+├── tsconfig.json
+├── index.html # entry;
+├── public/
+│ └── (fonts: Inter, JetBrains Mono — vendored)
+└── src/
+ ├── main.tsx # router + query client init
+ ├── routes/ # TanStack Router route tree
+ │ ├── _app.tsx # shell: TopBar, SubBar slot, Body slot
+ │ ├── index.tsx # Home
+ │ ├── session.$tool.$host.$id.tsx
+ │ └── ...stub routes
+ ├── shell/ # TopBar, SubBar, hint-chip, palette skeleton
+ ├── primitives/ # Tag, ToolDot, Spark, StatusDot, EmptyState, Sub
+ ├── features/
+ │ ├── home/ # SessionsTable, FilterChips, useSessions
+ │ └── session/ # TurnList, Transcript, useSession
+ ├── api/
+ │ ├── client.ts # fetch wrapper + RFC 7807 error normalization
+ │ └── adapters.ts # Go DTO → prototype TS shape
+ ├── styles/
+ │ ├── tokens.css # :root + [data-theme="dark"] from prototype.css
+ │ ├── shell.css
+ │ ├── primitives.css
+ │ ├── home.css
+ │ └── session.css
+ └── lib/
+ ├── theme.ts # prefers-color-scheme + localStorage
+ └── keyboard.ts # j/k/↵, g-leader (800ms), Esc, ⌘K
+```
+
+### Build pipeline
+
+- New Justfile targets: `web-install`, `web-dev`, `web-build`, `web-test`, `web-lint`.
+- `just build` becomes: `web-build` then `go build`. The Go embed directive fails the build if `web/dist/` is missing — so the binary cannot be built without the SPA bundle.
+- CI: add a `web` job that runs `npm ci && npm run build && npm test && npm run lint`. Existing Go job stays as-is.
+- Dockerfile: builder stage gains a `node:20-alpine` step that produces `web/dist/` before the Go build.
+
+### Dev experience
+
+- `just web-dev` → vite at `:5173`, proxies API to `127.0.0.1:18888`, injects `Remote-User: bigbes`.
+- `just run` → Go server at `:18888` (today's behavior).
+- Workflow: two terminals, vite for the SPA, Go for the API. Hot reload on the frontend; Air handles the backend.
+
+### Theme / density
+
+- **Theme**: `prefers-color-scheme` on first load; no user toggle in foundation. (Toggle ships with Settings.) `data-theme="light|dark"` attribute on ``.
+- **Density**: hard-coded `compact` in foundation (README's recommended default). Toggle ships with Settings.
+- Inline-on-`` accent variable writes from `Prototype.html` are dropped — the CSS rules suffice; the runtime "accent off" toggle is exploration-only and not shipped.
+
+### Keyboard map (foundation)
+
+| Key | Action |
+|---|---|
+| `j` / `k` | move row cursor in Home |
+| `↵` | open cursored session (Home only) |
+| `g h` | go to Home |
+| `g p` / `g s` / `g i` | go to stub screens (so the chord is "real") |
+| `⌘K` / `Ctrl+K` | open palette skeleton (single input, no items; SEARCH synthetic row routes to `/search` stub) |
+| `Esc` | close palette |
+
+Full palette items (JUMP/PROJECT/SESSION) ship in `lethe-web-ui-palette-savedsearch`.
+
+### Verification at end of execute
+
+- `go test ./... -race -count=1` green; new aggregate tests covered.
+- `just web-build` produces `web/dist/`; `just build` produces a binary that, when run, serves the SPA at `/`.
+- Manual smoke: hit `/` in a browser via `just web-dev` → see Home with real sessions; click a row → Session view loads with turns; press `g` then `p` → land on Projects stub; press `⌘K` → palette overlay opens and closes on `Esc`. Light + dark mode both render correctly (toggle OS theme).
+- `internal/shared/wire/` untouched (grep verification).
+
+### TDD: yes (scoped)
+
+Per the `up:test-driven-development` applicability rule — "deterministic, reusable code where regressions would warrant a CI red light":
+
+- **Yes** for: the SQL aggregate query (Go repository test), the Go→TS adapter (`api/adapters.ts`), the keyboard-state state machine (`lib/keyboard.ts` — g-leader timeout, palette cursor, Esc handling), the theme bootstrap (`lib/theme.ts` — prefers-color-scheme + localStorage interaction).
+- **No** for: visual primitives (`Tag`, `ToolDot`, `Spark`), shell layout components, routing wiring — verified by eye against the prototype.
+
+### Unknowns (resolve during execute)
+
+- **Aggregates query plan at scale**: the correlated-subquery shape is cheap on the existing indexes, but if it regresses materially we may add a covering index. Bench during execute against the existing fixtures and on a synthetic 10k-session DB.
+- **SPA-static auth posture**: shell loads unauthenticated; first `/api/v1` call shows the auth-error state. Decide what that state looks like during execute (probably: a centered error card mirroring `EmptyState`'s tone, copy "not authenticated"). Not novel work, just deferred styling.
+
+### Invariants
+
+- `web/dist/` is git-ignored. Production embeds via `//go:embed all:dist/*` only — no committed build output.
+- The forward-auth middleware code is unchanged. Dev-time auth is solved entirely in `web/vite.config.ts` (header injection).
+- `internal/shared/wire/` is untouched. New aggregate fields live only in the HTTP response DTO (`internal/domain/session/repository.go::Session`), not the wire contract.
+- All API calls go through TanStack Query. No raw `fetch` in components.
+- All sortable/data values render in `var(--mono)`; prose renders in `var(--sans)`. No mixing.
+- `GET /api/v1/sessions` response is a strict superset of today's — no removed or renamed fields.
+- `tweaks-panel.jsx` is **not** ported. Neither is the runtime accent-off toggle.
+- Routes that are shipped match the prototype pixel-for-pixel (tokens, type scale, row heights, spacing); stub routes render a single `EmptyState` and do not reserve layout for unimplemented content.
+
+### Principles
+
+- Pixel-match the prototype for shipped routes. The README's tokens, type scale, and spacing are authoritative; resist "polishing" them.
+- Reconcile Go DTO ↔ prototype TS shape in a **single** adapter layer at the query boundary (`api/adapters.ts`). Components consume the prototype shapes; nothing else touches snake_case.
+- Foundation ships shell + 2 routes; resist pre-building for routes that aren't shipped here. Stubs are placeholders, not skeletons.
+- No CSS-in-JS, no Tailwind, no UI kit. Plain CSS files with the token names from `prototype.css`.
+- Prefer the prototype's component boundaries (e.g. `proto-atoms.jsx` → `primitives/`, `proto-home.jsx` → `features/home/`) when porting, but rename to match repo conventions (PascalCase TS components, camelCase hooks).
+
+## Plan
+
+Approach: six commits — Phase 1 ships the backend aggregates Home depends on (Go-only, independently shippable); Phases 2–3 lay the frontend toolchain and Go-side embed; Phases 4–6 build shell + Home + Session in dependency order. TDD applies to the SQL aggregate, the API adapter, theme, and keyboard machine; visual port work is verified by eye against the handoff.
+
+### Phase 1 — Session List aggregates (Go)
+
+- **1.1** `internal/domain/session/repository.go:97-107` (modify) — `Session` struct gains five fields, all with matching `db` and `json` tags:
+ - `Summary string` (json `summary`)
+ - `TurnCount int64` (json `turn_count`)
+ - `TokensInTotal int64` (json `tokens_in_total`)
+ - `TokensOutTotal int64` (json `tokens_out_total`)
+ - `Model *string` (json `model,omitempty`)
+- **1.2** `internal/domain/session/repository.go:176-189` (modify) — `sessionSelectColumns` stays as-is for `Get` path (which uses `SessionWithTurns` and computes nothing). Add a new `sessionListSelectColumns` const that wraps the base columns plus four correlated subqueries and a join to the `turns` table for `summary`/`model`. Centralize the SQL fragment so repository tests can assert column order.
+ - Invariant: response is a strict superset; `Get` path unchanged.
+- **1.3** `internal/domain/session/repository.go:194-242` (modify) — `Repository.List` swaps to `sessionListSelectColumns`. ORDER/LIMIT/WHERE clauses unchanged.
+- **1.4** `internal/domain/session/repository_test.go` (modify) — add `TestList_Aggregates` covering: zero turns (turn_count=0, tokens=0, summary="", model=nil); one user turn (summary truncated at 200 chars when content > 200); multiple turns with mixed roles (model = newest turn's model regardless of role); NULL token columns (sums treat NULL as 0).
+- **1.5** `internal/domain/session/handler_test.go` (modify if any test asserts exact JSON shape) — extend fixtures so list-response assertions cover the new fields.
+- Commit: `session: extend List response with summary, turn_count, token totals, model`
+
+### Phase 2 — Frontend scaffold + tokens + primitives
+
+- **2.1** `web/package.json` (create) — deps: `react`, `react-dom`, `@tanstack/react-router`, `@tanstack/react-query`, `@tanstack/router-vite-plugin`. devDeps: `vite`, `@vitejs/plugin-react`, `typescript`, `@types/react`, `@types/react-dom`, `vitest`, `@testing-library/react`, `eslint`, `@typescript-eslint/*`, `prettier`. Scripts: `dev`, `build`, `test`, `lint`, `typecheck`.
+- **2.2** `web/vite.config.ts` (create) — react plugin + router plugin; `server.proxy` for `/api/v1`, `/healthz`, `/readyz`, `/metrics` to `http://127.0.0.1:18888`; proxy `configure` hook injects `req.headers['Remote-User'] = 'bigbes'` on every forwarded request.
+- **2.3** `web/tsconfig.json`, `web/tsconfig.node.json`, `web/.eslintrc.cjs`, `web/.prettierrc` (create) — strict mode, react-jsx, ES2022 target.
+- **2.4** `web/index.html` (create) — ``, body class `density-compact`, font preconnects.
+- **2.5** `web/src/main.tsx` (create) — React root, `QueryClientProvider`, `RouterProvider`, theme bootstrap call.
+- **2.6** `web/src/styles/tokens.css` (create) — port `prototype.css` `:root` and `[data-theme="dark"]` blocks verbatim. **Drop** the `[data-theme="dark"] { --accent: ...; ... }` inline-on-`` accent writes from `Prototype.html` (the CSS rules suffice).
+- **2.7** `web/src/styles/primitives.css` (create) — port `.tag`, `.tool-dot`, `.spark`, `.status-dot`, `.empty-state`, `.sub` rules from `prototype.css`.
+- **2.8** `web/src/primitives/{Tag,ToolDot,Spark,StatusDot,EmptyState,Sub}.tsx` (create) — direct ports from `proto-atoms.jsx`, retyped. Signatures:
+ - `Tag(props: { kind?: 'host'|'tool'|'accent'|'neutral'; onClick?: () => void; children: React.ReactNode }): JSX.Element`
+ - `ToolDot(props: { tool: 'claude-code'|'opencode'|'crush'|'pi'|'kimi' }): JSX.Element`
+ - `Spark(props: { points: number[]; w?: number; h?: number; accent?: boolean }): JSX.Element`
+ - `StatusDot(props: { status: 'ok'|'warn'|'err' }): JSX.Element`
+ - `EmptyState(props: { glyph: string; copy: string }): JSX.Element`
+- **2.9** `web/.gitignore` (create) — `node_modules/`, `dist/`, `coverage/`.
+- **2.10** Root `.gitignore` (modify) — append `web/dist/`, `web/node_modules/`.
+- Commit: `web: scaffold vite/react/ts project, port design tokens and primitives`
+
+### Phase 3 — Go embed + build pipeline
+
+- **3.1** `internal/server/web/embed.go` (create) — `package web`. `//go:embed all:dist/*` on a `var distFS embed.FS`. Export `Handler() http.Handler` returning a `http.FileServer(http.FS(...))` wrapped with a SPA-fallback shim: any 404 from the file server rewrites to `index.html` and serves that with 200; paths starting with `/api/`, `/healthz`, `/readyz`, `/metrics` short-circuit to a `next` handler so the mount point doesn't swallow API routes.
+ - Signature: `func Handler() http.Handler`
+ - Invariant: `web/dist/` ignored; embed fails the build if absent.
+- **3.2** `internal/server/web/dist/.gitkeep` (create) + `internal/server/web/dist/index.html` (create, placeholder) — required so `go build` succeeds in dev without first running `just web-build`. Real assets overwrite at build time.
+- **3.3** `internal/server/server.go:91-99` (modify) — mount the embed handler. Order: API routes first (still under `/api/v1` with auth middleware), `/healthz`/`/readyz`/`/metrics` unchanged, then a catch-all `r.Handle("/*", web.Handler())`. The web handler returns 404 → SPA fallback → `index.html`. Auth middleware does not run on `/`.
+- **3.4** `internal/server/server_test.go` (modify) — add `TestRouter_ServesSPAAtRoot` (GET `/` → 200 with HTML body), `TestRouter_SPAFallbackForNonAPIPath` (GET `/session/foo/bar/baz` → 200 HTML, not 404), `TestRouter_APIPathsBypassSPA` (GET `/api/v1/sessions` without auth → 401 problem+json, not HTML).
+- **3.5** `Justfile` (modify, line 1-) — add targets:
+ - `web-install: cd web && npm ci`
+ - `web-dev: cd web && npm run dev`
+ - `web-build: cd web && npm run build`
+ - `web-test: cd web && npm test`
+ - `web-lint: cd web && npm run lint && npm run typecheck`
+ - Modify existing `build` to depend on `web-build` (or document `just web-build && just build`).
+- **3.6** `Dockerfile` (modify) — add `node:20-alpine` builder stage that runs `npm ci && npm run build` against `web/`, copies `web/dist/` into the Go builder context before `go build`.
+- **3.7** `.github/workflows/*` or `.sourcecraft/ci.yml` (modify if exists; create otherwise) — add a `web` job: `npm ci`, `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`. Existing Go job stays.
+- Commit: `server: embed web SPA at /, wire build pipeline`
+
+### Phase 4 — Shell + theme + keyboard + stubs + palette skeleton
+
+- **4.1** `web/src/lib/theme.ts` (create) — TDD.
+ - `bootstrapTheme(): void` — runs once at startup. Reads `localStorage.theme`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')`. Sets `` accordingly. Adds a `change` listener on the media query that updates only when no `localStorage.theme` is set.
+ - `setTheme(theme: 'light' | 'dark' | null): void` — `null` clears the override (re-syncs with OS).
+- **4.2** `web/src/lib/theme.test.ts` (create) — TDD targets: bootstrap with no localStorage and OS=dark → `data-theme=dark`; with `localStorage.theme=light` → `light` even when OS=dark; OS change while no override flips `data-theme`; `setTheme(null)` resyncs.
+- **4.3** `web/src/lib/keyboard.ts` (create) — TDD.
+ - `createKeyboardController(opts: { go: (route: 'home'|'projects'|'stats'|'health') => void; openPalette: () => void; closePalette: () => void; cursor: { move(d: 1|-1): void; activate(): void } }): { onKeyDown(e: KeyboardEvent): void; teardown(): void }` — internal `gLeader` ref + 800 ms `setTimeout`. Cmd/Ctrl-K opens palette; Esc closes; `j`/`k` move cursor (only when not in ``); `↵` activates; `g` then `h|p|s|i` navigates.
+- **4.4** `web/src/lib/keyboard.test.ts` (create) — TDD targets: `g` then `h` within 800 ms calls `go('home')`; `g` then nothing for 1 s → next `h` does nothing; `j`/`k` ignored when target is an input; ⌘K opens; Esc closes when palette open.
+- **4.5** `web/src/shell/TopBar.tsx` (create) — port `proto-shell.jsx`. Brand crumb (`assistant-log / lethe`), search trigger button (`⌘K`), tab nav. Active tab via current router match. Dark always (`--topbar-bg`).
+ - Signature: `TopBar(props: { onPaletteOpen: () => void }): JSX.Element`
+- **4.6** `web/src/shell/SubBar.tsx` (create) — outlet/slot used by routes; renders nothing if no children.
+ - Signature: `SubBar(props: { children?: React.ReactNode }): JSX.Element`
+- **4.7** `web/src/shell/Palette.tsx` (create) — modal scrim; single ``; cursor list rendered from a single `JUMP` items array (`home`, `projects`, `stats`, `health`, `settings`); SEARCH synthetic row when input non-empty and no JUMP match. `↵` activates; `Esc` closes. Footer hint chip.
+ - Signature: `Palette(props: { open: boolean; onClose: () => void }): JSX.Element`
+- **4.8** `web/src/styles/{shell,palette}.css` (create) — port `.topbar`, `.subbar`, `.palette` rules from `prototype.css`.
+- **4.9** `web/src/routes/_app.tsx` (create) — TanStack root route; renders ``, ``, ``. Wires `keyboardController` to `document` on mount, `teardown()` on unmount.
+- **4.10** `web/src/routes/{projects,stats,health,settings,search}.tsx` (create) — five files, each renders `` and a `` strip.
+- **4.11** `web/src/routes/__root.tsx`, `web/src/routeTree.gen.ts` (create — generated by router plugin on first build). Route tree wiring follows TanStack Router file-based convention.
+- Commit: `web: shell, theme, keyboard, stub routes, palette skeleton`
+
+### Phase 5 — Home route (real data)
+
+- **5.1** `web/src/api/client.ts` (create) — `apiFetch(path: string, init?: RequestInit): Promise` — 401 throws `AuthError`, 4xx/5xx with `application/problem+json` body throws `APIError(detail, code, status)`. JSON decode otherwise.
+- **5.2** `web/src/api/adapters.ts` (create) — TDD.
+ - `type SessionDTO = { owner; tool; host; session_id; started_at; ended_at; working_dir?; summary; turn_count; tokens_in_total; tokens_out_total; model? }` (Go shape)
+ - `type Session = { id; tool; host; cwd; model?; started; ended; summary; turns; tokensIn; tokensOut; hasError }` (prototype shape)
+ - `adaptSession(d: SessionDTO): Session` — `id = ${tool}/${host}/${session_id}`; `cwd = working_dir ?? ''`; `started/ended` = ISO from unix; `hasError = false` (deferred).
+- **5.3** `web/src/api/adapters.test.ts` (create) — TDD targets: ISO conversion for unix=0; missing working_dir → cwd=''; missing model → `model` undefined; round-trip composite ID.
+- **5.4** `web/src/features/home/useSessions.ts` (create) — TanStack Query hook keyed `['sessions', filters]`; calls `apiFetch('/api/v1/sessions?…')`; pipes through `adaptSession`.
+ - Signature: `function useSessions(filters: HomeFilters): UseQueryResult`
+ - `type HomeFilters = { since?: '1d'|'7d'|'30d'|'90d'|'all'; tool?: Tool; host?: Host }` — `since` translates to a unix `since` query param.
+- **5.5** `web/src/features/home/FilterChips.tsx` (create) — chip bar: `since`, `tool`, `host`, `+ filter`. Each chip a ``; click opens an absolute-positioned popover. State held in URL search params (TanStack Router-managed).
+- **5.6** `web/src/features/home/SessionsTable.tsx` (create) — port `proto-home.jsx`. Grid columns: `STARTED · TOOL · HOST · SUMMARY · TURNS · TOK · CWD`. Cursor row `--accent-soft` bg + 2 px `--accent` left border. Click row → router push `/session/$tool/$host/$session_id`. EmptyState when result is empty after filters.
+ - Signature: `SessionsTable(props: { sessions: Session[]; cursor: number; onCursor: (i: number) => void; onOpen: (s: Session) => void }): JSX.Element`
+- **5.7** `web/src/features/home/useHomeCursor.ts` (create) — small hook holding the cursor index, exposing `move(d)` and `activate()` for the keyboard controller.
+- **5.8** `web/src/routes/index.tsx` (create) — Home route. Reads filters from URL, calls `useSessions`, wires `useHomeCursor`, registers cursor actions with the shell's keyboard controller via context, renders ``.
+- **5.9** `web/src/styles/home.css` (create) — port `.home-table`, `.home-row`, etc. from `prototype.css`.
+- Commit: `web: home route with real session list, filters, keyboard cursor`
+
+### Phase 6 — Session route (real data)
+
+- **6.1** `web/src/features/session/useSession.ts` (create) — TanStack Query hook keyed `['session', tool, host, sessionId]`; returns `{ session, turns }`.
+ - `adaptTurn(d: TurnDTO): Turn` — `i = seq`, role passthrough, body = content, optional model/tokens, `toolName/toolKind` parsed from `tool_calls` raw JSON when role=tool (best-effort; fall back to displaying raw content).
+- **6.2** `web/src/features/session/TurnList.tsx` (create) — 240 px aside; rows of `# · role-glyph · preview · tok`. Selected row `--paper-2` bg + 2 px accent left border. Scrolls independently.
+- **6.3** `web/src/features/session/Transcript.tsx` (create) — linear list of turn cards. USER (`--ink-4` left bar), ASSISTANT (`--accent` left bar), TOOL (no bar, `--turn-tool` bg, mono content). Markdown for user/assistant via `react-markdown` (or a 30-line minimal renderer if the dep is undesirable; flag in execute).
+- **6.4** `web/src/routes/session.$tool.$host.$id.tsx` (create) — Session route. SubBar = breadcrumb + tags. Body = `` two-column. Click in TurnList scrolls to corresponding turn in Transcript and updates the cursor.
+- **6.5** `web/src/styles/session.css` (create) — port `.session-aside`, `.turn`, `.turn.user`, `.turn.assistant`, `.turn.tool` rules.
+- Commit: `web: session view with turn list and transcript`
+
+### Test strategy
+
+- **Phase 1** (TDD): the four behaviors enumerated under 1.4. SQL is exercised end-to-end through `Repository.List` against the in-memory test SQLite.
+- **Phase 3**: SPA fallback + API bypass coverage in `server_test.go` (3.4).
+- **Phase 4** (TDD): `theme.test.ts`, `keyboard.test.ts` per 4.2 and 4.4.
+- **Phase 5** (TDD): `adapters.test.ts` per 5.3.
+- **Phase 6**: not TDD'd; visual primitives + integration verified manually against the prototype.
+
+### Order & dependencies
+
+- Phase 1 ⊥ Phases 2–6 (Go-only; can land first or in parallel).
+- Phase 2 → Phase 3 (embed needs `dist/` to exist; placeholder `.gitkeep` + stub `index.html` makes Go build green before npm build runs).
+- Phase 3 → Phase 4 (router needs index.html to be served).
+- Phase 4 → Phase 5 → Phase 6 (Home opens Session; Home needs aggregates from Phase 1).
+- A phase 5 PR ships unblocked even if Phase 6 hasn't started — Home is the user-visible value gate.
+
+### Open questions / risks / rollback
+
+- **Markdown renderer (Phase 6.3)**: prototype uses none (it ships raw markdown text). Adding `react-markdown` is ~50 KB gzip; a 30-line custom renderer covers the 90% case. Decide during execute; default to the custom renderer unless the prototype's session screenshots show full markdown rendering (which they do — code blocks, lists, headings). Likely outcome: ship `react-markdown`.
+- **Aggregate query plan (Phase 1)**: correlated subqueries are O(n) in the result page (40 by default). On `LIMIT 40` this is fine; on `LIMIT 200` it could matter. If `EXPLAIN QUERY PLAN` shows table scans on `turns`, add a covering index `(owner, tool, host, session_id, role, seq)` in a sibling commit. Not blocking the phase.
+- **Rollback**: Phase 1 alone is safe to revert (additive JSON fields). Phases 2–6 are SPA-only; reverting any phase leaves the Go server fully functional with the existing `/api/v1`. The embed in Phase 3 means a revert there leaves the binary serving 404 on `/` — the current behavior — so it's a no-op for API users.
+
+### Backwards-compat check
+
+Restating from Design: only `GET /api/v1/sessions` changes shape, additively. The five new fields (`summary`, `turn_count`, `tokens_in_total`, `tokens_out_total`, `model`) are added; nothing is removed or renamed. No client today depends on the response shape (only consumer is the integration test, which we update). No schema migrations; no wire-type changes; no config-key changes.