Files
AgentHub/docs/superpowers/plans/2026-09-23-appearance-themes.md
T
nntrivi2001 6cf8a82889 feat(ui): Appearance settings — dark/light/system mode and named themes; fix Add lane silently doing nothing (#42)
- Settings → Appearance: mode Dark / Light / System (follows the OS live) and
  a named theme remembered per mode — dark: Default, Darcula, Apple Dark;
  light: Default, Apple Light, IntelliJ Light, Canvas. Each theme is a
  CSS-variable override block keyed by `data-theme` on <html>; the sidebar
  quick toggle gains System and swaps between the two remembered themes.
- An inline boot script in index.html applies the stored mode and theme
  before first paint, so a reload no longer flashes the default colours.
- Diff +/- rows are easier to tell apart on every theme: a /20 status tint,
  a 3px status bar and a bold coloured marker; the changed-span emphasis no
  longer vanishes on light themes.
- #42: the Add-lane modal (Repo mode / "Current branch") asks
  POST /api/lanes/ensure with `exact: true`. Without it a folder nested inside
  an existing lane resolved to that parent lane with created:false, so the
  modal closed and no new card appeared. When the folder really already has a
  lane, the modal now says which one instead of closing.

Closes #42
2026-09-24 09:44:42 +07:00

8.0 KiB

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="<id>"], light ones [data-theme="<id>"] (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 <html> (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 <div data-mode|class=dark data-theme=id> 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. <html> 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.