# Appearance: mode + named themes, and readable diff rows ## Requirement (from the user, 2026-09-23) - `/settings` gets an **Appearance** section: mode **Dark / Light / System**, plus a named theme per mode. - Themes: dark = **Default** (current Radix slate), **Darcula**, **Apple Dark**; light = **Default** (current ivory), **Apple Light**, **IntelliJ Light**, **Canvas** (linen). - A theme also recolours code syntax (`--syn-*`). - The sidebar's dark/light control stays; each mode remembers its own theme (dark=Darcula, light=Canvas → toggling swaps between those two). - The diff's +/- rows in Code changes (`LaneDiffView`) get visibly more contrast, in every theme. ## Approach Themes are pure CSS-variable overrides — every colour in the app already reads `--surface-*`, `--fg-*`, `--accent*`, `--status-*`, `--tint-base`, `--syn-*`. No component learns theme names. 1. **`client/src/index.css`** - Light default block becomes `:root, [data-mode="light"]` (so a light preview can be scoped inside a dark page). - After the `.dark` block, one block per theme: dark ones `.dark[data-theme=""]`, light ones `[data-theme=""]` (placed after the defaults, same specificity as `[data-mode=light]`, later wins). Each overrides only what differs; `--tint-base` always = its `--surface-3`. - New `--diff-add` / `--diff-del` NOT needed: rows use existing `status-*` tokens. 2. **`client/src/hooks/useTheme.ts`** → module-level store read via `useSyncExternalStore`, so Settings and Sidebar never disagree. - Storage: `theme` key now holds `dark|light|system` (old values stay valid → no migration); `agenthub.theme.dark` / `agenthub.theme.light` hold theme ids; unknown id ⇒ `default`. - `system` resolves via `matchMedia("(prefers-color-scheme: dark)")` and follows its `change` event. - Applies `.dark` class + `data-theme` attribute on `` (attribute removed for `default`). - Keeps today's API (`theme` = resolved `dark|light`, `setTheme`, `toggleTheme` — toggle sets the explicit opposite mode) and adds `mode`, `setMode`, `themeFor`, `setThemeFor(mode, id)`, plus exported `THEMES` registry `{ dark: [...ids], light: [...ids] }`. 3. **`client/src/pages/Settings.tsx`** — new first section `appearance` in `SETTINGS_SECTIONS`: a 3-button mode segment, then two grids (Dark themes / Light themes) of cards. Each card's swatch is a `
` painting `bg-surface-0`, a `bg-surface-3` card, an accent dot and two syntax colours — the CSS vars re-scope inside it, so previews need zero duplicated hex. Selected card = accent ring + `aria-pressed`. 4. **`client/src/components/Sidebar.tsx`** — expanded panel's theme grid gains **System** (3 buttons, Monitor icon); collapsed toggle unchanged. 5. **`client/src/components/lanes/LaneDiffView.tsx`** `rowTint` → `bg-status-*/20` + a 3px inset left bar in the status colour; the `+`/`-` marker coloured `text-status-*` and bold. `highlight.ts`'s `add`/`del` classes use the same `/20` status tokens instead of fixed `emerald/red-500/10`. 6. **i18n** `en`/`vi`: `nav.themeNames.system`, `settings.appearance.*` (title, mode, darkThemes, lightThemes, theme names). ## Revisions after the plan challenge 1. Store keeps NO cached module state: `getSnapshot` reads localStorage + `matchMedia` and returns a primitive string; `subscribe` listens to a custom `agenthub-theme` event, `storage` (cross-tab) and the media-query `change`. No DOM work at import. DOM applied synchronously in the setter and in a `useLayoutEffect` (no one-frame flash). 2. `window.matchMedia?.(...)` guarded (jsdom lacks it); the system test installs its own listener-capable mock. 3. `` carries `data-mode="dark|light"` too; light themes scoped `[data-mode="light"][data-theme=x]`, dark `.dark[data-theme=x]` — no source-order dependency. 4. `--tint-base: var(--surface-3)` and `--syn-plain: var(--fg-primary)` declared once in each default block; theme blocks never set them. 5. Diff emphasis `LaneDiffView.tsx:48` `bg-white/15` → `bg-fg-primary/15` (invisible on light otherwise). 6. Swatch root sets `text-fg-primary` + `bg-surface-0`. 7. Palettes pinned below; contrast checked with a small node script (white-on-accent, fg-muted on surface-3, status on its /20 chip) — dark themes held to the SAME bar the current dark default meets (its #0090ff is 3.3:1 under white text), light themes to ≥4.5:1. 8. Appearance section lives in its own component `client/src/components/settings/AppearanceSettings.tsx`; Settings.tsx gains only the section entry + render. 9. Sidebar: check the expanded panel width with 3 theme buttons; collapsed toggle moves System → the explicit opposite mode (documented). ## Palettes (hex; surface-0..5, border, border-light, accent, accent-hover, fg 1/2/muted, success/danger/warning; syntax) - **darcula**: #2b2b2b #303234 #36383a #3c3f41 #45494a #4e5254 · #4b4f51 #5e6366 · #3592c4 #4aa3d6 · #dcdcdc #a9b7c6 #8c9296 · #6aab73 #ff6b68 #e0b25c · keyword #cc7832 string #6a8759 number #6897bb comment #808080 function #ffc66d property #9876aa tag #e8bf6a builtin #8888c6 operator/punct #a9b7c6 add #6aab73 del #ff6b68 - **apple-dark**: #161618 #1c1c1e #242426 #2c2c2e #363638 #3a3a3c · #38383a #48484a · #0a84ff #409cff · #f5f5f7 #aeaeb2 #8e8e93 · #30d158 #ff6961 #ffd60a · keyword #fc5fa3 string #fc6a5d number #d0bf69 comment #7f8c98 function #67b7a4 builtin #5dd8ff property #a167e6 tag #fc5fa3 operator/punct #a1a1a6 add #30d158 del #ff6961 - **apple-light**: #e9e9ee #efeff4 #f5f5f8 #fbfbfd #fdfdfe #ffffff · #d1d1d6 #e5e5ea · #0066cc #0055b3 · #1d1d1f #515154 #6e6e73 · #1f7a36 #c5000f #8a5a00 · keyword #9b2393 string #c41a16 number #1c00cf comment #5d6c79 function #326d74 builtin #0b4f79 property #3900a0 tag #9b2393 operator/punct #4b4b50 add #1f7a36 del #c5000f - **intellij-light**: #eceef2 #f1f2f5 #f7f8fa #fdfdfe #ffffff #ffffff · #d3d5db #e4e6eb · #3574f0 #2e63cf · #1e1f22 #494b57 #6c707e · #1d7a35 #c01f2a #8f5f00 · keyword #0033b3 string #067d17 number #1750eb comment #707070 function #00627a builtin #000080 property #871094 tag #0033b3 operator/punct #4b4b4b add #067d17 del #c01f2a - **canvas**: #e3dccd #e8e2d4 #ede8dc #f3efe6 #f7f4ed #faf8f3 · #cbbfa6 #dcd3c0 · #7a5230 #663f22 · #2b2620 #5e554a #6b6155 · #3f6a2a #9f2f23 #7d5700 · keyword #8a3b12 string #4d6b1f number #9a5b00 comment #7d7262 function #7a5a00 builtin #2f5e6e property #3b6660 tag #8e2f2f operator #8a3050 punct #6b6155 add #4d6b1f del #9f2f23 (Values that fail the contrast script are darkened/lightened until they pass; the final CSS is the source of truth.) ## Out of scope - Server-side persistence (theme stays a per-browser preference, like lane order). - ~~A no-flash inline boot script~~ — added after acceptance at the user's request (`client/index.html`). - User-defined/custom themes. - Surfaces that already bypass the CSS variables (they clash with today's light default too): the xterm terminal (no `theme`, TerminalView.tsx), Dashboard/workflow chart hardcoded hex (Dashboard.tsx, OrchestrationDAG.tsx, ErrorPropagationMap.tsx), ~415 raw Tailwind palette classes (toolStyle.ts, types.ts, …), `index.html`'s `text-gray-200` body. ## Tests - `hooks/__tests__/useTheme.test.ts`: existing 4 stay green; add — `system` follows a mocked `matchMedia` and its change event; `setThemeFor` sets `data-theme` only for the active mode and persists; unknown stored id ⇒ default (no attribute); two hook instances stay in sync. - Settings: a test that clicking a theme card sets `data-theme` / clicking System sets mode. - Snapshot diff for Settings/Sidebar reviewed, then regenerated deliberately. - `npm run test:client`, `tsc` via build, `check-headers.sh`. Manual: build + open `/settings`, screenshot each theme. ## Acceptance - [ ] Mode Dark/Light/System works and persists across reload; System follows the OS live. - [ ] Each of the 7 themes selectable; per-mode memory; sidebar toggle swaps between the two remembered themes. - [ ] Code syntax recolours per theme. - [ ] Diff +/- rows clearly distinguishable in every theme. - [ ] Docs (client README / CLAUDE.md marker) updated.