# 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.