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 herelethe-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 slotlethe-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 OPFill 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:
lib/theme.ts; this task adds the UI surface and a small getter).Transcript).All three persist to localStorage, drive a data-* attribute on <html>, 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.
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 <html>; 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 <fieldset> blocks, each a row of radio inputs:
light / dark / systemcozy / compactyes / noComponent 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 <html> 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:
prefers-color-scheme listener in theme.ts).cozy (matches today's row-list scale).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.
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 <DisplaySection /> 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.
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).lib/theme.test.ts extension — getThemePreference() returns 'system' when no key, 'light' / 'dark' when stored.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.--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.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.<html>: 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).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()).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.[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.data-tool="1" is set on a turn <div> in Transcript.tsx exactly when t.role === 'tool' || t.toolKind != null (loose semantic — hides both tool invocations and tool-result turns).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.Transcript turn padding remains unchanged in this task.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.)main.tsx before React render without changing its applied theme semantics.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.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.
web/src/lib/theme.test.ts:1-172 (modify)
getThemePreference(): 'light' | 'dark' | 'system' returning system with no key and explicit values when stored.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.main.tsx after the React root is mounted to main.tsx before render.web/src/lib/density.test.ts (create)
data-density writes, and no matchMedia listener registration.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.web/src/lib/toolCalls.test.ts (create)
data-show-toolcalls writes, and no matchMedia listener registration.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.web: add display preference modulesweb/src/main.tsx:1-26 (modify)
bootstrapTheme, bootstrapDensity, and bootstrapToolCalls.createRoot(rootEl).render(...).web/src/routes/__root.tsx:1-79 (modify)
bootstrapTheme import and the mount-effect call so bootstrap has one call site.web/src/features/settings/DisplaySection.tsx (create)
export function DisplaySection(): React.JSX.Element — renders three radio fieldsets seeded from getThemePreference, getDensityPreference, and getToolCallsPreference.system to setTheme(null) and explicit theme values to setTheme(value).web/src/routes/settings.tsx:1-36 (modify)
DisplaySection, remove disabled: true, tag: 'in #8', and render <DisplaySection /> when active === 'display'.web/src/styles/settings.css:223-225 (modify)
web/src/styles/tokens.css:3-56 (modify)
[data-density="cozy"] { --row-pad: 4px; } and [data-density="compact"] { --row-pad: 2px; } outside :root.web/src/styles/home.css:24-43, web/src/styles/projects.css:24-43, web/src/styles/settings.css:173-195 (modify)
var(--row-pad) while preserving existing horizontal padding and cursor left-padding adjustment.web/src/features/session/Transcript.tsx:18-61 (modify)
data-tool="1" exactly when t.role === 'tool' || t.toolKind != null.web/src/styles/session.css:42-51 (modify)
[data-show-toolcalls="false"] .turn[data-tool="1"] { display: none; } without changing .turn padding.web: wire display settings UItheme.test.ts, density.test.ts, and toolCalls.test.ts; each new behavior must fail before production code lands.npm test, npm run lint, and npm run typecheck from web/; visual behavior is smoke-checked on /settings, /, /projects, and a session transcript.getThemePreference() and new localStorage keys density / showToolCalls; existing theme, setTheme, and bootstrapTheme semantics stay intact.data-* attributes; no API, schema, endpoint, config, or wire-format changes.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.getThemePreference(): ThemePreference — reads the stored theme preference without applying it.bootstrapDensity(): void, setDensity(density: DensityPreference | null): void, getDensityPreference(): DensityPreference — owns the density key and data-density attribute.bootstrapToolCalls(): void, setToolCalls(value: ToolCallsPreference | null): void, getToolCallsPreference(): ToolCallsPreference — owns the showToolCalls key and data-show-toolcalls attribute.web/src/lib/web/src/main.tsx, web/src/routes/, web/src/features/, web/src/styles/Result: passed
Positive:
npm test → 105 tests passed.npm run typecheck passed.npm run build produced the production bundle.Invariants / assumptions:
localStorage.getItem for theme, density, and showToolCalls appears only in the three preference modules.[data-show-toolcalls="false"] .turn[data-tool="1"] and Transcript sets data-tool="1" from role === 'tool' || toolKind != null.main.tsx calls theme → density → tool-calls bootstrap before React render.Interfaces:
DisplaySection calls getThemePreference(): ThemePreference.main.tsx and DisplaySection call the declared density functions.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.Outcome: Display preferences shipped and review passed at 7134607.
Invariants:
localStorage.getItem calls for the three keys are confined to lib/theme.ts, lib/density.ts, and lib/toolCalls.ts.data-theme, data-density, and data-show-toolcalls.main.tsx.DisplaySection seeds state from getters and mutates preferences only through setters.[data-show-toolcalls="false"] .turn[data-tool="1"].Transcript marks tool turns with data-tool="1" using role === 'tool' || toolKind != null.main.tsx bootstraps theme, density, then tool-calls before React render.bootstrapTheme() to main.tsx preserved test, typecheck, and build behavior.yes/no preference to data-show-toolcalls="true"/"false" in 7134607.web/src/routes/auth.callback.tsx:137:16; changed TS/TSX files pass targeted ESLint.