# 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: <stage>" 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](https://www.radix-ui.com/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.
