~bigbes/lethe

ref: 2d9d2b8ec08ee09cc64c5d925ab85716b1d7d1fb lethe/docs/tasks/lethe-web-ui-settings-display.md -rw-r--r-- 19.7 KiB
2d9d2b8e — Eugene Blikh search: add /api/v1/search API and opencode collector parser 23 days ago

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

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

  • 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 <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:

  • 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.tsgetThemePreference(): '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 <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.

#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 <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).
  • 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 <div> 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 <DisplaySection /> 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.