From 1be25bac49ceac3bb2923ce40facdf9ba7a9811f Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sun, 3 May 2026 13:45:25 +0300 Subject: [PATCH] docs: record shipped web tasks and dev tooling updates --- .air.toml | 2 +- Justfile | 17 +- docs/TODO.md | 12 +- docs/tasks/lethe-oidc-stub.md | 2 +- docs/tasks/lethe-web-ui-settings-display.md | 240 ++++++++++++++++++++ 5 files changed, 263 insertions(+), 10 deletions(-) create mode 100644 docs/tasks/lethe-web-ui-settings-display.md diff --git a/.air.toml b/.air.toml index a57beb7314ce900599db7ea5e7336332272ffb80..759da4ca041cc7f8196113d075027f40f13934d0 100644 --- a/.air.toml +++ b/.air.toml @@ -6,7 +6,7 @@ tmp_dir = "tmp" bin = "./tmp/lethe" args_bin = ["-config", "config.yaml"] include_ext = ["go", "yaml", "sql"] - exclude_dir = ["tmp", "vendor", "data", "docs"] + exclude_dir = ["tmp", "vendor", "data", "docs", "web", "internal/server/web/dist"] delay = 1000 [log] diff --git a/Justfile b/Justfile index d617288c242b9551a0cc077f7a62a880c9271f92..61cd33437f4940ed40a4a4cb58b215872952e23c 100644 --- a/Justfile +++ b/Justfile @@ -33,6 +33,15 @@ run: dev: go tool air +# Run Go (air) and Vite (web-dev) concurrently. Ctrl-C kills both. +dev-all: + #!/usr/bin/env bash + set -euo pipefail + trap 'kill 0' EXIT INT TERM + (cd web && npm run dev) & + go tool air & + wait + test: go test -race ./... @@ -47,9 +56,11 @@ fmt: tidy: go mod tidy -# Migration helpers. `go tool migrate` resolves through the `tool` directive in -# go.mod (Go 1.24+) — no separate install needed. Bootstrap once with: -# go get -tool github.com/golang-migrate/migrate/v4/cmd/migrate@latest +# Migration helpers for dev/ops use only — authoring new migrations or manually +# driving up/down against a local DB. The running daemon auto-migrates on +# startup via Database.Init -> Migrate (internal/platform/database/database.go), +# so production deployments do not need to invoke these. +# `go tool migrate` resolves through the `tool` directive in go.mod (Go 1.24+). migrate-up: go tool migrate -path internal/platform/database/migrations -database "sqlite://./lethe.db" up diff --git a/docs/TODO.md b/docs/TODO.md index 58ffc9055fac1ab94dfd0259ae2ea1691ac402fd..251a2549f11a51839428567cec3255758ebd8871 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -11,17 +11,18 @@ Index of task specs and their state. Each row points at a `docs/tasks/.md` | 3 | [`lethe-search-and-opencode`](tasks/lethe-search-and-opencode.md) | Designed (deferred) | Adds `GET /api/v1/search` (FTS5) and an `opencode` collector. Blocks #7. | | 4 | [`lethe-web-ui-foundation`](tasks/lethe-web-ui-foundation.md) | **Reviewed** | Vite/React/TS SPA, embed pipeline, shell + Home + Session views, palette skeleton, 5 stub routes. Plus `/sessions` aggregate fields. | | 5 | [`lethe-web-ui-aggregates`](tasks/lethe-web-ui-aggregates.md) | **Reviewed** | Backend `/projects` + `/stats` endpoints, Projects index + Project detail + Stats screen. Replaces 3 of #4's stubs. | -| 6 | `lethe-web-ui-palette-savedsearch` | Not started | Full ⌘K palette (PROJECT/SESSION items), saved-searches table + UI, palette pulls from it. Small backend (1 table) + Settings → Saved searches. | +| 6 | [`lethe-web-ui-palette-savedsearch`](tasks/lethe-web-ui-palette-savedsearch.md) | **Reviewed** | Full ⌘K palette (PROJECT/SESSION items), saved-searches table + UI, palette pulls from it. Small backend (1 table) + Settings → Saved searches. | | 7 | `lethe-web-ui-search` | Blocked on #3 | Search route: turn-level results, FTS `` highlighting, save-search action. | -| 8 | `lethe-web-ui-settings-display` | Not started | Settings → Display: theme toggle (light/dark/system), density toggle, "show tool calls" toggle, persisted to localStorage. | +| 8 | [`lethe-web-ui-settings-display`](tasks/lethe-web-ui-settings-display.md) | **Reviewed** | Settings → Display: theme toggle (light/dark/system), density toggle, "show tool calls" toggle, persisted to localStorage. | | 9 | `lethe-web-ui-health-sources` | Blocked on #2 (and ideally #3) | Settings → Sources (per-host-per-tool config table) + `/health` route (collector ingestion table, status pills, footer strip with backfill progress and last-error). | -| 10 | `lethe-oidc-stub` | Not started | Promote the in-test `oidcTestServer` (`internal/server/auth/oidctestserver_test.go`) to an exported `internal/testutil/oidcstub/` package, optionally wrap as `cmd/oidc-stub`. Unblocks real-browser smoke for #5 (the deferred verify item) and any future SPA route work without standing up Authelia locally. JWT-based by design — see `~/data/home/second-brain/wiki/jwt-for-sessions.md`: lethe is the OIDC verifier, JWT is the only legal wire format here. | +| 10 | [`lethe-oidc-stub`](tasks/lethe-oidc-stub.md) | **Reviewed** | Promote the in-test `oidcTestServer` (`internal/server/auth/oidctestserver_test.go`) to an exported `internal/testutil/oidcstub/` package, optionally wrap as `cmd/oidc-stub`. Unblocks real-browser smoke for #5 (the deferred verify item) and any future SPA route work without standing up Authelia locally. JWT-based by design — see `~/data/home/second-brain/wiki/jwt-for-sessions.md`: lethe is the OIDC verifier, JWT is the only legal wire format here. | +| 11 | [`lethe-web-ui-login`](tasks/lethe-web-ui-login.md) | **Reviewed** | SPA OIDC auth-code+PKCE flow: `/login` + `/auth/callback`, in-memory token store, `apiFetch` Bearer attachment, shared `AuthGate` replacing the three "not authenticated" placeholder cards. Dev-stub upgraded to a real auth-code OP. Closes the browser-smoke gate for #5 and #6. | ## Carry-overs from `lethe-web-ui-foundation` These were captured in `lethe-web-ui-foundation.md` Conclusion → Future work / Deferred. Pulled here so they're visible across tasks. -- **Composite-id-in-URL** (cosmetic, 1-line fix): `SessionsTable` Link passes the full composite as `params.id`, producing URLs like `/session/T/H/T%2FH%2FID`. Fix in `web/src/features/home/SessionsTable.tsx` to pass the bare `session_id`. +- ~~**Composite-id-in-URL**~~ — closed by #6 PH2 (`Session.sessionId` adapter field + call-site swaps in `routes/index.tsx:48` and `routes/project.$.tsx:33`). - **Aggregates absent on `GET /sessions/{tool}/{host}/{id}`**: the new `summary`/`turn_count`/`tokens_*_total` fields appear as zero values on the Get path because the Get SQL is unchanged (Plan 1.2 said so). UI doesn't read them, but a future API consumer would. Fix is either extending the Get SQL or adding `,omitempty` to the four numeric tags. - **Turn meta-line lacks timestamp**: the `Turn` TS interface from foundation Plan 6.1 omitted `timestamp`; meta line shows `# seq · model · tokens-in→tokens-out` only. Add `timestamp` to the interface + render. - **`go test ./...` walks `web/node_modules/flatted/golang/pkg/flatted`**: stray Go package shipped inside an npm dep. Switch CI to `go test ./internal/...` or gitignore the path. @@ -36,13 +37,14 @@ Cross-cutting tooling polish that doesn't warrant a full task file each. Track h - [ ] **Add `go fix ./...` to `just fmt`** — runs after `goimports`; mostly a no-op today but standardizes future API-rewrite migrations into the existing `just fmt` flow. - [ ] **`migrate` → `go tool migrate`** — `go get -tool github.com/golang-migrate/migrate/v4/cmd/migrate@latest`; rewrite the three `migrate-*` recipes; drop the now-stale `brew install golang-migrate` comment block. - [ ] **OIDC stub** — see task #10 (separate task file). +- [ ] **Full web lint unblock** — `npm run lint` currently fails on pre-existing `web/src/routes/auth.callback.tsx:137:16` (`e` unused); changed files from #8 pass targeted ESLint. ## Deferred operational follow-ups Tracked outside the task workflow because they're user-driven, not engineering work. - **Sourcecraft.dev push** — `~/data/home/lethe` has no remote configured; per global preference all `~/data/home/*` projects mirror to sourcecraft.dev. `sc init / sc push` when you want it visible / backed up. -- **Browser smoke for task #5** — `/projects`, `/project/`, `/stats?range=…` were verified by tsc + vitest + Go handler tests, but never walked in a real browser. Blocked on having an OIDC bearer + populated DB locally; task #10 (OIDC stub) closes both halves of that. +- **Browser smoke for tasks #5, #6, and #8** — `/projects`, `/project/`, `/stats?range=…`, `/settings` saved-search CRUD, `/settings` Display toggles, and the widened ⌘K palette were verified by tsc + vitest + Go handler tests, but never walked in a real browser. Unblocked: #10 (OIDC stub) + #11 (login flow) ship the dev OP and SPA flow needed. Recommended walk per the affected task Verify sections. ## Notes diff --git a/docs/tasks/lethe-oidc-stub.md b/docs/tasks/lethe-oidc-stub.md index 211fb7d8757f270a195d5969b4c8712ccce46c80..03e0edd45b027205088d5210424efdd1496d354c 100644 --- a/docs/tasks/lethe-oidc-stub.md +++ b/docs/tasks/lethe-oidc-stub.md @@ -1,6 +1,6 @@ # lethe-oidc-stub -**Status:** Designed +**Status:** Reviewed **Module:** `sourcecraft.dev/bigbes/lethe` **Branch:** master **Worktree:** none diff --git a/docs/tasks/lethe-web-ui-settings-display.md b/docs/tasks/lethe-web-ui-settings-display.md new file mode 100644 index 0000000000000000000000000000000000000000..c27384274848219ca5103a50639753bdb5291367 --- /dev/null +++ b/docs/tasks/lethe-web-ui-settings-display.md @@ -0,0 +1,240 @@ +# lethe-web-ui-settings-display + +**Status:** Reviewed +**Module:** `sourcecraft.dev/bigbes/lethe` +**Branch:** master +**Worktree:** none +**Parent RFC:** Personal AI Assistant Log Aggregator (2026-04-25) +**Sibling tasks:** +- `lethe-web-ui-foundation.md` (#4, ✓ Reviewed) — established `/settings` route, route tree, primitives, embed pipeline; `lib/theme.ts` shipped here +- `lethe-web-ui-palette-savedsearch.md` (#6, ✓ Reviewed) — sectioned `/settings` layout, `SectionRail`, reserved the `display` slot with the `'in #8'` muted tag — this task fills that slot +- `lethe-web-ui-login.md` (#11, ✓ Reviewed) — `AuthGate` + OIDC flow that lets users actually reach `/settings`; smoke for this task piggybacks on the same dev-stub OP + +## Design + +### Purpose + +Fill the `Display` section #6 reserved at `/settings` (rendered today as a disabled row with the `"in #8"` muted tag, see `routes/settings.tsx:13`) with three persisted, app-wide visual preferences: + +1. **Theme** — light / dark / system (machinery shipped in `lib/theme.ts`; this task adds the UI surface and a small getter). +2. **Density** — cozy / compact (greenfield; one CSS variable, mirrored module). +3. **Show tool calls** — on / off (greenfield; CSS-only hide of tool-tagged turns in `Transcript`). + +All three persist to localStorage, drive a `data-*` attribute on ``, and let CSS do the visual work. Personal-scale, single-user — no backend, no schema, no API. + +**In scope**: `DisplaySection.tsx` UI; two new preference modules (`lib/density.ts`, `lib/toolCalls.ts`) mirroring `lib/theme.ts`; an additive `getThemePreference()` on `lib/theme.ts`; bootstrap calls in `main.tsx`; a `--row-pad` token in `styles/tokens.css` driven by `[data-density]` for Home sessions, Projects, and Saved-search rows; a `[data-show-toolcalls="false"] ...` hide rule in `styles/session.css`; a `data-tool="1"` attribute on tool-tagged turns in `Transcript.tsx`; enabling the `display` section row in `routes/settings.tsx`. + +**Out of scope**: device-sync of preferences (no server-side preferences table — AS1); font-size selector or accent-color picker; splitting "show tool calls" into call vs. result toggles (UK1); applying density to `Transcript` turn padding; palette / search-results / future-route surfaces — they pick up `data-density` for free if they consume `--row-pad`, but no proactive retrofit beyond the existing row-list surfaces. + +### Chosen approach + +**Three sibling preference modules under `lib/`** — `theme.ts` (exists), `density.ts` (new), `toolCalls.ts` (new). Each exports `bootstrap*()`, `set*(value | null)`, and `get*Preference()`; sets a single `data-*` attribute on ``; persists to a single localStorage key. No shared abstraction — three ~30-line files of identical shape are easier to read top-to-bottom than a generic preference layer (GPC2: one concern per unit, GPC8: rule-of-three for DRY — three near-identical modules sit at the threshold but don't merit an abstraction yet). + +**CSS-driven application** — preferences are body-level data-attributes; CSS does the visual work, the React tree never reads the preference state to render conditionally: +- `[data-theme="dark"]` already drives the full token swap in `tokens.css:56`. +- `[data-density="compact"]` flips a new `--row-pad` token from a cozy value around today's 4–5px row padding to a tighter compact value. Surfaces that consume `--row-pad`: `SessionsTable` rows, `ProjectsTable` rows, and `SavedSearchesSection` table rows. Transcript turn padding stays unchanged because transcript bodies are reading surfaces, not scan-heavy rows. +- `[data-show-toolcalls="false"] .turn[data-tool="1"] { display: none }` — hides flagged turns in `Transcript.tsx`. The component adds `data-tool="1"` to a turn whenever `t.role === 'tool' || t.toolKind != null` (loose semantic: hides both tool invocations and tool-result turns). + +**UI: `DisplaySection.tsx`** — three labeled `
` blocks, each a row of radio inputs: +- Theme: `light` / `dark` / `system` +- Density: `cozy` / `compact` +- Show tool calls: `yes` / `no` + +Component state is `useState` seeded from the three getters on mount; `onChange` calls the matching setter. No global state, no context — the source of truth is the data-attribute on `` and localStorage. The component re-reads the getter on mount only; it is not subscribed to changes (no other UI mutates these preferences). + +**localStorage keys (bare, not namespaced)** — `theme` (existing), `density`, `showToolCalls`. Rationale: the existing `theme` key is shipped and working in `lib/theme.ts`; renaming it to `lethe.display.theme` would silently lose any saved preference for the user. Bare keys keep the three new preferences consistent with each other. Trade-off accepted: the codebase has mixed namespacing (auth code uses `lethe.pkce.pending`, `lethe.auth.failures`; display preferences stay bare). Documented as a known asymmetry, not a defect. + +**Defaults** — when no preference is stored: +- Theme follows OS (existing `prefers-color-scheme` listener in `theme.ts`). +- Density defaults to `cozy` (matches today's row-list scale). +- Show tool calls defaults to `yes` (matches today's transcript rendering — every shipped turn renders today). + +Bootstrap order in `main.tsx`: theme → density → tool-calls, before first render. Each bootstrap reads its key, applies the data-attribute, and (theme only) registers an OS-change listener. + +**One additive getter on `lib/theme.ts`** — `getThemePreference(): 'light' | 'dark' | 'system'` reads `localStorage.getItem('theme')` and returns the explicit value or `'system'` when null. Needed because the radio UI must seed its initial state with the user's *preference* (which could be `system`), not just the *applied* theme. The new modules each export their own getter with the same shape. + +### Backwards-compatibility check + +Greenfield modulo two additive surfaces: +- `lib/theme.ts` gains a new exported `getThemePreference()`. Existing callers of `setTheme`/`bootstrapTheme` are unchanged. +- `routes/settings.tsx` removes `disabled: true, tag: 'in #8'` from the `display` row of `SECTIONS` and renders `` when active. No deployed user flow today (the row was disabled — clicking did nothing). + +No API, schema, endpoint, or wire-format changes. No removed or renamed JSON fields. No migrations. localStorage gains two new keys (`density`, `showToolCalls`); no existing key is renamed or removed. + +### TDD: yes (scoped) + +- **Yes** for `lib/density.test.ts` and `lib/toolCalls.test.ts` — mirror `lib/theme.test.ts` shape: bootstrap reads stored value, falls back to default; `set*` writes localStorage and updates data-attribute; `set*(null)` clears localStorage and re-applies default; `get*Preference()` returns the right enum across (no-storage / explicit-value / cleared) states. Cover the assertion that density/tool-calls modules do **not** register an OS-change listener (only theme has that behavior). +- **Yes** for `lib/theme.test.ts` extension — `getThemePreference()` returns `'system'` when no key, `'light'` / `'dark'` when stored. +- **No** for `DisplaySection` chrome — three radio fieldsets, dominated by visual feel; vitest of radio-state-on-change is low-value coverage of `useState`. A manual smoke walk via the OIDC dev stub covers it. +- **No** for CSS rule wiring (`--row-pad` flip on `[data-density="compact"]`, tool-turn hide on `[data-show-toolcalls="false"]`) — visual; same smoke walk, with row density checked on Home sessions, Projects, and Saved searches. + +## Invariants + +- IV1 — All three preference values are read exclusively via their module's `get*Preference()` getter; `localStorage.getItem` for the three keys (`theme`, `density`, `showToolCalls`) is called only inside `lib/theme.ts`, `lib/density.ts`, `lib/toolCalls.ts`. No other file reaches into localStorage for these keys. +- IV2 — Each preference owns exactly one data-attribute on ``: `data-theme`, `data-density`, `data-show-toolcalls`. No CSS rule keys off the localStorage value directly; all visual rules key off the data-attribute (single seam between persistence and rendering). +- IV3 — `bootstrapDensity()` and `bootstrapToolCalls()` are side-effect-free at module-import time; effects happen only when called from `main.tsx` (mirrors the existing contract on `bootstrapTheme()`). +- IV4 — `DisplaySection` radio state is derived on mount from the three getters and on change calls the three setters; the component holds no other source of truth and does not subscribe to storage events. +- IV5 — Tool-call hiding is CSS-only via `[data-show-toolcalls="false"] .turn[data-tool="1"]`; the React tree always renders all turns. Reason: turns are deep-linkable by sequence id (`#turn-${t.i}`); hiding via CSS preserves anchor targets and selection state across toggles. +- IV6 — `data-tool="1"` is set on a turn `
` in `Transcript.tsx` exactly when `t.role === 'tool' || t.toolKind != null` (loose semantic — hides both tool invocations and tool-result turns). +- IV7 — Bootstrap order in `main.tsx` is theme → density → tool-calls, all before the first React render. No flash-of-wrong-density between mount and the user's preference taking effect. +- IV8 — Density affects Home sessions, Projects, and Saved-search rows only; `Transcript` turn padding remains unchanged in this task. + +## Principles + +- PC1 — When a preference's default differs from "do nothing in CSS", the default lives in exactly one place: the bootstrap function (e.g. `bootstrapDensity()` applies `data-density="cozy"` when no key is stored). **Why:** avoids two-place defaults that drift between TS and CSS. **How to apply:** do NOT also encode the default in `:root { --row-pad: ... }` without an attribute selector — let bootstrap apply the attribute and let `[data-density="cozy"]` rule own the value. (Mild deviation from GPC1's "no hidden config" — bootstrap is implicit at component-render time, but it runs before first render so the attribute is always present.) + +## Assumptions + +- AS1 — Personal-scale UI (single user, one browser at a time) means localStorage-only persistence is sufficient; no need for a server-side preference table or device-sync. **Why this matters:** if AS1 fails, density and tool-calls toggles diverge across devices. **Verify in Conclusion:** note as a known limitation if smoke surfaces a multi-device need; otherwise mark held. +- AS2 — The existing theme module can be called from `main.tsx` before React render without changing its applied theme semantics. + +## Unknowns + +- UK1 — Whether users will want to split "show tool calls" into separate "show tool invocations" and "show tool results" toggles in a future iteration. Initial answer (this task): single toggle, loose semantic — `t.role === 'tool' || t.toolKind != null` (IV6). Trade-off: this hides assistant turns that contain *both* prose and tool-call invocations (typical Anthropic `tool_use` shape), losing the prose with the tool indicator. Acceptable for v1; if smoke walk shows the prose loss is annoying, the natural refinement is "hide only `role === 'tool'` turns + CSS-hide the meta-line tool tags on assistant turns" (two rules instead of one). Revise during Conclusion only if smoke surfaces a need. + +## Plan + +Approach: two phases — PH1 adds the tested preference modules and exported contracts; PH2 wires those contracts into bootstrap, settings UI, and CSS-only visual behavior. + +### PH1 — Preference Modules + +- **1.1** `web/src/lib/theme.test.ts:1-172` (modify) + - Add RED tests for `getThemePreference(): 'light' | 'dark' | 'system'` returning `system` with no key and explicit values when stored. + - Respects: IV1, AS2. +- **1.2** `web/src/lib/theme.ts:1-47` (modify) + - `export type ThemePreference = 'light' | 'dark' | 'system'` — shared preference type. + - `export function getThemePreference(): ThemePreference` — returns stored `light`/`dark`, otherwise `system`. + - Move the bootstrap call-site comment from `main.tsx after the React root is mounted` to `main.tsx before render`. + - Respects: IV1, IV2, IV3, IV7, AS2. +- **1.3** `web/src/lib/density.test.ts` (create) + - Add RED tests for stored/default/cleared preferences, `data-density` writes, and no `matchMedia` listener registration. + - Respects: IV1, IV2, IV3, PC1. +- **1.4** `web/src/lib/density.ts` (create) + - `export type DensityPreference = 'cozy' | 'compact'`. + - `export function getDensityPreference(): DensityPreference`. + - `export function bootstrapDensity(): void`. + - `export function setDensity(density: DensityPreference | null): void`. + - Respects: IV1, IV2, IV3, PC1, AS1. +- **1.5** `web/src/lib/toolCalls.test.ts` (create) + - Add RED tests for stored/default/cleared preferences, `data-show-toolcalls` writes, and no `matchMedia` listener registration. + - Respects: IV1, IV2, IV3, PC1. +- **1.6** `web/src/lib/toolCalls.ts` (create) + - `export type ToolCallsPreference = 'yes' | 'no'`. + - `export function getToolCallsPreference(): ToolCallsPreference`. + - `export function bootstrapToolCalls(): void`. + - `export function setToolCalls(value: ToolCallsPreference | null): void`. + - Respects: IV1, IV2, IV3, PC1, AS1. +- Commit: `web: add display preference modules` + +### PH2 — Display UI And CSS Wiring + +- **2.1** `web/src/main.tsx:1-26` (modify) + - Import `bootstrapTheme`, `bootstrapDensity`, and `bootstrapToolCalls`. + - Call them in theme → density → tool-calls order before `createRoot(rootEl).render(...)`. + - Respects: IV2, IV3, IV7, AS2. +- **2.2** `web/src/routes/__root.tsx:1-79` (modify) + - Remove `bootstrapTheme` import and the mount-effect call so bootstrap has one call site. + - Respects: IV3, IV7, PC1. +- **2.3** `web/src/features/settings/DisplaySection.tsx` (create) + - `export function DisplaySection(): React.JSX.Element` — renders three radio fieldsets seeded from `getThemePreference`, `getDensityPreference`, and `getToolCallsPreference`. + - Map theme `system` to `setTheme(null)` and explicit theme values to `setTheme(value)`. + - Respects: IV1, IV4, AS1. +- **2.4** `web/src/routes/settings.tsx:1-36` (modify) + - Import `DisplaySection`, remove `disabled: true, tag: 'in #8'`, and render `` when `active === 'display'`. + - Respects: IV4, AS1. +- **2.5** `web/src/styles/settings.css:223-225` (modify) + - Add display-section styles for fieldsets, labels, radio rows, and preference help text using existing tokens only. + - Respects: IV4. +- **2.6** `web/src/styles/tokens.css:3-56` (modify) + - Add `[data-density="cozy"] { --row-pad: 4px; }` and `[data-density="compact"] { --row-pad: 2px; }` outside `:root`. + - Respects: IV2, IV8, PC1. +- **2.7** `web/src/styles/home.css:24-43`, `web/src/styles/projects.css:24-43`, `web/src/styles/settings.css:173-195` (modify) + - Replace row vertical padding with `var(--row-pad)` while preserving existing horizontal padding and cursor left-padding adjustment. + - Respects: IV2, IV8. +- **2.8** `web/src/features/session/Transcript.tsx:18-61` (modify) + - Add `data-tool="1"` exactly when `t.role === 'tool' || t.toolKind != null`. + - Respects: IV5, IV6, UK1. +- **2.9** `web/src/styles/session.css:42-51` (modify) + - Add `[data-show-toolcalls="false"] .turn[data-tool="1"] { display: none; }` without changing `.turn` padding. + - Respects: IV2, IV5, IV8, UK1. +- Commit: `web: wire display settings UI` + +### Test Strategy + +- PH1 follows RED-GREEN-REFACTOR for `theme.test.ts`, `density.test.ts`, and `toolCalls.test.ts`; each new behavior must fail before production code lands. +- PH2 verification runs `npm test`, `npm run lint`, and `npm run typecheck` from `web/`; visual behavior is smoke-checked on `/settings`, `/`, `/projects`, and a session transcript. + +### Backwards Compatibility + +- PH1 only adds `getThemePreference()` and new localStorage keys `density` / `showToolCalls`; existing `theme`, `setTheme`, and `bootstrapTheme` semantics stay intact. +- PH2 only enables a previously disabled Settings section and adds CSS keyed from new `data-*` attributes; no API, schema, endpoint, config, or wire-format changes. + +### Risks / Rollback + +- RK1 — Moving `bootstrapTheme()` from `__root.tsx` to `main.tsx` changes call timing; rollback is restoring the root mount-effect call if browser smoke shows an unexpected startup regression. + +### Interfaces + +- IF1 — `getThemePreference(): ThemePreference` — reads the stored theme preference without applying it. +- IF2 — `bootstrapDensity(): void`, `setDensity(density: DensityPreference | null): void`, `getDensityPreference(): DensityPreference` — owns the `density` key and `data-density` attribute. +- IF3 — `bootstrapToolCalls(): void`, `setToolCalls(value: ToolCallsPreference | null): void`, `getToolCallsPreference(): ToolCallsPreference` — owns the `showToolCalls` key and `data-show-toolcalls` attribute. + +### Interface Graph + +- PH1 -> IF1, IF2, IF3 @ `web/src/lib/` +- PH2 IF1, IF2, IF3 -> @ `web/src/main.tsx`, `web/src/routes/`, `web/src/features/`, `web/src/styles/` + +## Verify + +**Result:** passed + +Positive: +- CK1 — `npm test` → 105 tests passed. +- CK2 — `npm run typecheck` passed. +- CK3 — `npm run build` produced the production bundle. +- CK4 — changed TS/TSX files pass ESLint. + +Invariants / assumptions: +- CK5 (IV1) — `localStorage.getItem` for `theme`, `density`, and `showToolCalls` appears only in the three preference modules. +- CK6 (IV2, IV8) — row density CSS is limited to Home sessions, Projects, and Saved-search rows. +- CK7 (IV5, IV6) — tool-call hiding uses `[data-show-toolcalls="false"] .turn[data-tool="1"]` and `Transcript` sets `data-tool="1"` from `role === 'tool' || toolKind != null`. +- CK8 (IV7, AS2) — `main.tsx` calls theme → density → tool-calls bootstrap before React render. + +Interfaces: +- CK9 (IF1) — `DisplaySection` calls `getThemePreference(): ThemePreference`. +- CK10 (IF2) — `main.tsx` and `DisplaySection` call the declared density functions. +- CK11 (IF3) — `main.tsx` and `DisplaySection` call the declared tool-call functions. + +Smoke: `npm run build` — bundle OK. + +Notes: +- `npm run lint` still fails on pre-existing `web/src/routes/auth.callback.tsx:137:16` (`e` unused); changed TS/TSX files pass targeted ESLint. +- Real-browser click-through smoke was not run in this tool session. + +## Conclusion + +Outcome: Display preferences shipped and review passed at `7134607`. + +Invariants: +- IV1 — verified by grep: preference `localStorage.getItem` calls for the three keys are confined to `lib/theme.ts`, `lib/density.ts`, and `lib/toolCalls.ts`. +- IV2 — verified by grep: visual rules key off `data-theme`, `data-density`, and `data-show-toolcalls`. +- IV3 — held: density and tool-call modules are side-effect-free at import and are bootstrapped from `main.tsx`. +- IV4 — held: `DisplaySection` seeds state from getters and mutates preferences only through setters. +- IV5 — held after review fix: tool-call hiding is CSS-only via `[data-show-toolcalls="false"] .turn[data-tool="1"]`. +- IV6 — held: `Transcript` marks tool turns with `data-tool="1"` using `role === 'tool' || toolKind != null`. +- IV7 — held: `main.tsx` bootstraps theme, density, then tool-calls before React render. +- IV8 — held: density changes Home sessions, Projects, and Saved-search rows only. + +### Assumptions check +- AS1 — held for this scope: preferences are localStorage-only with no backend surface added. +- AS2 — held: moving `bootstrapTheme()` to `main.tsx` preserved test, typecheck, and build behavior. + +### Unknowns outcome +- UK1 — still-open: no browser smoke data yet on whether hiding assistant turns with tool invocations loses too much prose context. + +### Review findings +- Critical: tool-call hide selector mismatched the attribute value; resolved by mapping the `yes`/`no` preference to `data-show-toolcalls="true"/"false"` in `7134607`. + +### Verified by +- Full lint remains blocked by pre-existing `web/src/routes/auth.callback.tsx:137:16`; changed TS/TSX files pass targeted ESLint. +- Real-browser click-through smoke remains deferred because this tool session has no browser automation.