Files
AgentHub/docs/superpowers/specs/2026-07-31-color-redesign-dark-light-mode-design.md
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.

- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
  tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
  fg.primary/secondary/muted, status.success/danger/warning. One class flip
  on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
  prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
  scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
  every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
  client/src onto the new tokens, so every badge/button/component pulls the
  same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
  constants — slate/blue/green/red/amber steps 1-12 — adopted after three
  rounds of hand-picked values that kept overshooting (flat, then too dark,
  then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
  dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
  visual language (border + text + translucent wash of the same status
  color); `current` alone stays a solid accent fill, the one state that
  gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
  Workspace lane-detail header already showing them.

Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
2026-07-31 10:54:31 +07:00

12 KiB

Color redesign + dark/light mode — design

Status: approved 2026-07-31 (color, architecture, and scope all confirmed in chat; user asked to plan and implement without further review gates).

Problem

The dashboard has exactly one theme (dark), and it is not even applied consistently: tailwind.config.js defines semantic tokens (surface-*, border, accent) but most components (e.g. LaneCard.tsx) bypass them and use raw Tailwind scale colors directly — bg-neutral-900/70, text-gray-500, border-neutral-800. A grep across client/src/**/*.tsx finds raw neutral-*/gray-*/slate-*/zinc-*/stone-* color utilities in 64 files, ~1,400 occurrences. Redefining the token values alone would only re-theme the handful of components that use the tokens — the rest would stay hard-dark regardless of the toggle.

Goal

A working Dark/Light toggle, next to the language switcher, same row as the EN/VI buttons, that actually changes every screen — not just the ones already using semantic tokens.

Decisions already taken (chat)

  • Accent: Azure, darkened after initial ship. #1d4ed8 (hover #2563eb, the original base value), unchanged between themes. border/border-light darkened the same pass, both themes — fg-* (text) intentionally untouched: darkening text in dark mode would cut its contrast against the dark background.
  • Dark surfaces (page/sidebar/card): #1F2533 / #232A3B / #252E42 — the original dark direction lightened ~20% total, per user's two rounds of feedback on the visual mockup. Dark border/border-light: #2a3246 / #3b4660 (darkened from the surface-matched #343F57 / lighter).
  • Light surfaces: page #f4f7fd, sidebar/card #ffffff. Light border/border-light: #becde6 / #a5b6d7 (darkened from #dde6f5 / lighter, for visibility against white).
  • 2 states only (Dark/Light) — no "System" option.
  • Default: dark, matching current behavior. Persisted in localStorage; no prefers-color-scheme fallback.
  • Toggle placement: client/src/components/Sidebar.tsx, in the language block, same row as the EN/VI buttons (mirrors toggleLang's click-to-flip pattern for the collapsed state).
  • Full rewrite: every raw-color usage across client/src migrates to semantic tokens. No screen is left on hardcoded dark.

Architecture

Mechanism: Tailwind darkMode: "class" + CSS custom properties. A dark class on <html> selects which variable set is active; Tailwind's color tokens resolve to var(--token-name). This is a values-only flip (one class toggle, no per-element dark: variant pairs to maintain), which is what makes "full rewrite" tractable: every component only ever needs ONE semantic class name; the theme decides what color that resolves to.

Token set (tailwind.config.js theme.extend.colors, each backed by a CSS var):

Token Purpose Replaces
surface.0..5 page/panel/card backgrounds (already exists, redefined as vars) bg-neutral-900/950/800, bg-gray-900
border / border-light (already exists, redefined as vars) border-neutral-700/800, border-gray-700/800
accent / accent-hover / accent-muted (already exists, unchanged value both themes) bg-blue-500/600, text-blue-300/400
fg.primary / fg.secondary / fg.muted body text, 3 weights text-gray-100/200/50, text-gray-400/500/600, text-neutral-300/400/500
status.success / status.danger / status.warning liveness dot, destructive buttons, "needs you" banner — each needs a DIFFERENT shade per theme for contrast (e.g. emerald-400 on #252E42 reads fine; the same hex on #ffffff is too light) emerald-400/500, red-400/500/600, amber-300/400/500/600

CSS vars live in client/src/index.css, one block under :root (light — since class strategy needs a class-free default; light is the CSS default, .dark overrides it) and one under .dark (dark, and default at runtime via the toggle setting document.documentElement.classList).

Migration is mechanical, not creative. Every raw-scale usage in scope maps to exactly one semantic token via a fixed lookup table (below); there is no "reconsider this component's palette" step. A script applies the table across all 64 files; a human (me) spot-checks the diff and the screens snapshot rather than hand-editing each file.

Lookup table (raw → semantic, illustrative — full table lives in the implementation)

Raw Semantic
text-gray-100, text-neutral-50/100 text-fg-primary
text-gray-300/400, text-neutral-300/400 text-fg-secondary
text-gray-500/600, text-neutral-500 text-fg-muted
bg-neutral-900/950, bg-gray-900 bg-surface-0 / bg-surface-1 (by role — page vs. panel)
bg-neutral-800, bg-gray-800 bg-surface-2 / bg-surface-3
border-neutral-700/800, border-gray-700/800 border-border / border-border-light
text-emerald-400, bg-emerald-* text-status-success / bg-status-success
text-red-400/500, bg-red-* text-status-danger / bg-status-danger
text-amber-300/400, border-amber-* text-status-warning / border-status-warning

Toggle component

Reuses Sidebar.tsx's existing language-switcher shape: a 2-button row (Dark / Light) in the expanded state, a single icon button that flips on click in the collapsed state — same interaction as toggleLang/changeLanguage. New useTheme() hook: reads localStorage.getItem("theme") on mount (default "dark"), applies/removes the dark class on document.documentElement, and exposes theme/setTheme. i18n keys added under nav: (theme, themeNames.dark, themeNames.light, switchTheme), mirroring the existing language/languageNames/switchLanguage keys.

Risks and how they are contained

  • Scale (64 files, ~1,400 occurrences). Contained by the lookup table being fixed and mechanical — a scripted replace, not a rewrite of each file's markup. Anything the table doesn't cover is left untouched and flagged rather than guessed at.
  • Screens snapshot test (client/src/pages/__tests__/screens.snapshot.test.tsx) will diff on every visual change. Per project policy, snapshots are reviewed and regenerated deliberately (npx vitest run -u), never blindly accepted.
  • Contrast regressions in light mode, especially status colors and the accent-on-white combination. Checked by eye against the approved mockup values; no automated contrast gate exists in this repo, so this is a manual pass, not a new CI check (not asked for).
  • The Workspace lane-detail header and LaneCard were already touched in this session (chip cleanup) — the migration must not reintroduce the chips that were deliberately removed.

Testing

  • npm run test:client after the token/config change and again after the mechanical migration; screenshot diffs reviewed, not rubber-stamped.
  • tsc --noEmit (Tailwind class strings are not type-checked, but the new useTheme hook and Sidebar changes are).
  • Manual pass: toggle Dark ↔ Light on the Workspace, Dashboard, and Sidebar screens, confirm no screen is left hardcoded dark and no light-mode contrast failure on status colors.

Follow-up (not built in this pass)

The mechanical migration (scripts/migrate-color-tokens.mjs) rewrote only the gray-scale chrome (neutral-*/gray-*/slate-*) — unambiguously UI structure, safe to blanket-replace. Two color families were deliberately left untouched:

  • Status colors (emerald-*/red-*/amber-* used for liveness dots, destructive buttons, the "needs you" banner). These carry real meaning and read fine on the new light background at a glance, but were not checked pixel-by-pixel for contrast — a future pass should audit each usage against WCAG AA on #f4f7fd/#ffffff, not just eyeball it.
  • The categorical/decorative hue palette (violet, indigo, cyan, teal, sky, rose, pink, orange, yellow — tags, subagent-type badges, chart legends). These are chosen for visual distinction between categories, not for theme-appropriateness, and there is no single correct light-mode remap — each usage would need its own review. Left as-is.

2026-07-31, second darkening pass: the accent/border darkening above only touched the tokenized CSS-variable colors. The raw blue-* (PipelineMap's current node, "info" accents scattered across ~30 files) and amber-* (the dashed "auto: " chip, warning banners, badges — same ~30 files) were still untokenized and unaffected. scripts/darken-status-colors.mjs shifts every raw blue/amber utility one Tailwind shade darker (matching the accent's own 100→200 ... 700→800 step; 950 left alone, already darkest), same treatment in both themes since these colors had no dark/light split before this pass either. Text (fg-*) still untouched, per the same readability reasoning as the first pass.

One mapping bug found and fixed during migration, worth recording: the first pass grouped gray-200 into fg-secondary alongside gray-300/gray-400, which collapsed the common text-gray-400 hover:text-gray-200 pattern into a no-op hover (text-fg-secondary hover:text-fg-secondary — same color before and after). Fixed by moving gray-200 into fg-primary (closer to its actual brightness) and patching the 16 already-migrated files where the no-op had landed.

2026-07-31, third pass — real contrast bug + status tokens. The second darkening pass had a real bug, not just a taste call: --border in dark mode was set DARKER than the surfaces it outlines, so a card's border landed almost indistinguishable from its own background (dark-mode borders need to be lighter than the surface, not darker — the opposite of the light-mode rule). Fixed by relighting --border/--border-light in .dark back above the surface scale. amber-600 text/border on dark surfaces was also flagged as dull; folded into the fix below rather than patched standalone.

Also added status-success/status-danger/status-warning CSS-variable tokens (mirroring fg-*) and ran scripts/tokenize-status-colors.mjs to collapse every raw emerald-*/red-*/amber-* shade across client/src (41 files) onto them — every badge/button/component that means success/danger/warning now pulls the same shade per theme instead of each picking its own. PipelineMap's done/failed also dropped their solid white-text fills in favor of the same border+text+translucent-wash language every other state uses; current stays the one solid (accent-colored) exception, since it alone needs to look bolder ("you are here"). The categorical/decorative hue palette (violet, indigo, cyan, teal, sky, rose, pink, orange, yellow, and blue where it plays a categorical role e.g. message-bubble coloring) is still explicitly out of scope — collapsing those would erase the distinction between different kinds of thing, not a status.

2026-07-31, fourth pass — stopped hand-picking, adopted Radix Colors. Three rounds of manually-tuned values (flat → too dark → glaring) without ever rendering the app is what caused each regression; user asked to research an established palette instead of continuing to guess. Adopted Radix Colors (@radix-ui/colors package, fetched directly): a 12-step accessible scale (1-2 app background, 3-5 component background, 6-8 borders, 9-10 solid/vibrant, 11-12 text), contrast-checked with APCA, with the scale direction inverted between light and dark so both themes share the same role mapping. slate → surface 0-5/border/border-light (steps 1-7), blue → accent/accent-hover (step 9/10 — blue-9 is #0090ff in BOTH themes, Radix's own vibrant-solid anchor), green/red/amber step 11 → status-success/danger/warning (the same "readable text" step used for fg-secondary, so status colors sit at ordinary text weight rather than shouting). Every value below is the literal Radix hex constant, not a hand-tuned guess. Because every component already routes through the surface-*/border/accent/fg-*/status-* token names (no raw Tailwind color classes for these), this pass only touched client/src/index.css — no component files needed changes.