# Color redesign + dark/light mode — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship a working Dark/Light toggle (Azure accent) that actually re-themes the whole dashboard, not just the components already on semantic tokens. **Architecture:** Tailwind `darkMode: "class"` + CSS custom properties (RGB triplets, so Tailwind opacity modifiers like `/70` keep working) for `surface.0-5`, `border`, `border-light`, `accent`, `accent-hover`, `accent-muted`, `fg.primary/secondary/muted`. A `useTheme()` hook toggles the `dark` class on `` and persists to `localStorage`. A scripted, table-driven find/replace converts every raw gray-scale utility (`neutral-*`/`gray-*`/`slate-*`/`zinc-*`) across `client/src` to the new tokens — that scale is unambiguously UI chrome. Status colors (emerald/red/amber) already carry real meaning (live/dead/needs-you) and are handled per-usage, not by blind substitution. **Tech Stack:** Tailwind CSS 3 (`darkMode: "class"`), React, i18next, `localStorage`. ## Global Constraints - Accent stays `#2563eb` / hover `#4c8bf5` in both themes (per design doc). - Dark surfaces: page `#1F2533`, sidebar `#232A3B`, card `#252E42`, border `#343F57`. - Light surfaces: page `#f4f7fd`, sidebar/card `#ffffff`, border `#dde6f5`. - Default theme: dark. No `prefers-color-scheme` fallback — `localStorage` only. - Toggle lives in `client/src/components/Sidebar.tsx`, same row as the EN/VI language buttons. - Every `.js/.ts/.tsx/.css` file touched or created must carry the project's file header (`@author Nguyễn Ngọc Trí Vĩ `) — already present on every file this plan modifies, so no new headers needed unless a new file is created. - Scope descoped from the design doc, stated explicitly here rather than silently: the categorical/decorative hue palette (violet, indigo, cyan, teal, sky, rose, pink, orange, yellow — used for tags, subagent-type badges, chart legends) is NOT touched by this plan. Recoloring it per-theme requires per-usage contrast review that the "mechanical, table-driven" approach this plan relies on cannot safely automate. Flagged as follow-up in the final task. --- ### Task 1: Theme tokens (Tailwind config + CSS variables) **Files:** - Modify: `client/tailwind.config.js` - Modify: `client/src/index.css` **Interfaces:** - Produces: Tailwind color tokens `surface.0-5`, `border`/`border.light`, `accent`/`accent.hover`/`accent.muted`, `fg.primary`/`fg.secondary`/`fg.muted` — every later task's class names (`bg-surface-1`, `text-fg-secondary`, etc.) resolve through these. - [x] **Step 1: Add CSS variables for both themes** Replace the top of `client/src/index.css` (before the existing `@layer base` block) with: ```css @tailwind base; @tailwind components; @tailwind utilities; :root { /* light theme — default (no class needed) */ --surface-0: 244 247 253; /* #f4f7fd */ --surface-1: 255 255 255; /* #ffffff */ --surface-2: 255 255 255; --surface-3: 255 255 255; --surface-4: 238 242 250; --surface-5: 221 230 245; /* #dde6f5 */ --border: 221 230 245; /* #dde6f5 */ --border-light: 200 212 235; --accent: 37 99 235; /* #2563eb */ --accent-hover: 76 139 245; /* #4c8bf5 */ --accent-muted: 37 99 235 / 0.12; --fg-primary: 27 37 54; /* #1b2536 */ --fg-secondary: 91 107 140; /* #5b6b8c */ --fg-muted: 130 150 184; } .dark { --surface-0: 31 37 51; /* #1F2533 */ --surface-1: 35 42 59; /* #232A3B */ --surface-2: 37 46 66; /* #252E42 */ --surface-3: 42 51 73; --surface-4: 52 63 87; /* #343F57 */ --surface-5: 61 73 100; --border: 52 63 87; /* #343F57 */ --border-light: 74 87 115; --accent: 37 99 235; --accent-hover: 76 139 245; --accent-muted: 37 99 235 / 0.15; --fg-primary: 226 233 245; /* #e2e9f5 */ --fg-secondary: 150 165 200; /* #96a5c8 */ --fg-muted: 110 126 163; } ``` - [x] **Step 2: Point Tailwind's tokens at the variables** In `client/tailwind.config.js`, replace the `colors` block under `theme.extend`: ```js colors: { surface: { 0: "rgb(var(--surface-0) / )", 1: "rgb(var(--surface-1) / )", 2: "rgb(var(--surface-2) / )", 3: "rgb(var(--surface-3) / )", 4: "rgb(var(--surface-4) / )", 5: "rgb(var(--surface-5) / )", }, border: { DEFAULT: "rgb(var(--border) / )", light: "rgb(var(--border-light) / )", }, accent: { DEFAULT: "rgb(var(--accent) / )", hover: "rgb(var(--accent-hover) / )", muted: "rgb(var(--accent) / 0.15)", }, fg: { primary: "rgb(var(--fg-primary) / )", secondary: "rgb(var(--fg-secondary) / )", muted: "rgb(var(--fg-muted) / )", }, }, ``` Also add `darkMode: "class",` as a top-level key in the exported config object (next to `content`). - [x] **Step 3: Verify the build picks up the new tokens** Run: `cd client && npx tailwindcss -i ./src/index.css -o /tmp/tw-check.css --content "./src/**/*.tsx"` Expected: exits 0, and `grep -c "surface-1" /tmp/tw-check.css` is non-zero (confirms the token compiled into utility classes somewhere it's used). - [x] **Step 4: Commit** ```bash git add client/tailwind.config.js client/src/index.css git commit -m "feat(theme): CSS-variable-backed color tokens for dark/light mode" ``` --- ### Task 2: `useTheme` hook + Sidebar toggle **Files:** - Create: `client/src/hooks/useTheme.ts` - Create: `client/src/hooks/__tests__/useTheme.test.ts` - Modify: `client/src/components/Sidebar.tsx` - Modify: `client/src/i18n/locales/en/lanes.json` → actually `client/src/i18n/locales/en/nav.json` and `client/src/i18n/locales/vi/nav.json` (the `nav:` namespace Sidebar already uses for `language`/`languageNames`/`switchLanguage`) **Interfaces:** - Consumes: nothing new (Sidebar already imports `useTranslation(["nav", ...])`, confirm the exact import list in the file before editing). - Produces: `useTheme(): { theme: "dark" | "light"; setTheme: (t: "dark" | "light") => void; toggleTheme: () => void }`, exported from `client/src/hooks/useTheme.ts`. Later tasks do not depend on this, but any future screen wanting to read the active theme imports this hook. - [x] **Step 1: Write the failing test** ```ts /** * @file Tests for useTheme — the dark/light mode hook. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; import { useTheme } from "../useTheme"; describe("useTheme", () => { beforeEach(() => { localStorage.clear(); document.documentElement.classList.remove("dark"); }); afterEach(() => { document.documentElement.classList.remove("dark"); }); it("defaults to dark when localStorage is empty", () => { const { result } = renderHook(() => useTheme()); expect(result.current.theme).toBe("dark"); expect(document.documentElement.classList.contains("dark")).toBe(true); }); it("reads a persisted light theme on mount", () => { localStorage.setItem("theme", "light"); const { result } = renderHook(() => useTheme()); expect(result.current.theme).toBe("light"); expect(document.documentElement.classList.contains("dark")).toBe(false); }); it("toggleTheme flips the theme, the DOM class, and persists it", () => { const { result } = renderHook(() => useTheme()); act(() => result.current.toggleTheme()); expect(result.current.theme).toBe("light"); expect(document.documentElement.classList.contains("dark")).toBe(false); expect(localStorage.getItem("theme")).toBe("light"); }); it("setTheme sets an explicit value", () => { const { result } = renderHook(() => useTheme()); act(() => result.current.setTheme("light")); expect(result.current.theme).toBe("light"); act(() => result.current.setTheme("dark")); expect(result.current.theme).toBe("dark"); expect(document.documentElement.classList.contains("dark")).toBe(true); }); }); ``` - [x] **Step 2: Run test to verify it fails** Run: `cd client && npx vitest run src/hooks/__tests__/useTheme.test.ts` Expected: FAIL — `useTheme` module does not exist. - [x] **Step 3: Write the implementation** ```ts /** * @file useTheme — dark/light mode state, backed by localStorage and the * `dark` class on that Tailwind's `darkMode: "class"` reads. * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useState } from "react"; export type Theme = "dark" | "light"; const STORAGE_KEY = "theme"; function readStoredTheme(): Theme { return localStorage.getItem(STORAGE_KEY) === "light" ? "light" : "dark"; } function applyTheme(theme: Theme) { document.documentElement.classList.toggle("dark", theme === "dark"); } export function useTheme(): { theme: Theme; setTheme: (theme: Theme) => void; toggleTheme: () => void; } { const [theme, setThemeState] = useState(() => readStoredTheme()); useEffect(() => { applyTheme(theme); }, [theme]); const setTheme = useCallback((next: Theme) => { localStorage.setItem(STORAGE_KEY, next); setThemeState(next); }, []); const toggleTheme = useCallback(() => { setTheme(theme === "dark" ? "light" : "dark"); }, [theme, setTheme]); return { theme, setTheme, toggleTheme }; } ``` - [x] **Step 4: Run test to verify it passes** Run: `cd client && npx vitest run src/hooks/__tests__/useTheme.test.ts` Expected: PASS, 4/4. - [x] **Step 5: Add i18n keys** In `client/src/i18n/locales/en/nav.json`, add next to the existing `language`/`languageNames`/`switchLanguage` keys: ```json "theme": "Theme", "themeNames": { "dark": "Dark", "light": "Light" }, "switchTheme": "Switch to {{theme}}" ``` In `client/src/i18n/locales/vi/nav.json`: ```json "theme": "Giao diện", "themeNames": { "dark": "Tối", "light": "Sáng" }, "switchTheme": "Chuyển sang {{theme}}" ``` (Read the existing file first — insert alongside the current `language`/`languageNames` keys rather than duplicating the object.) - [x] **Step 6: Wire the toggle into Sidebar, same row as EN/VI** Read `client/src/components/Sidebar.tsx` fully before editing — it already has `SUPPORTED_LANGUAGES.map(...)` rendering the EN/VI grid (search for `nav:language` and `languageShort`). Add, in the same row-container as that language grid (the `
` block for the expanded state, and the collapsed-state single button above it): 1. Import `useTheme` from `../hooks/useTheme`. 2. Call `const { theme, toggleTheme } = useTheme();` alongside the existing `i18n`/`currentLanguage` locals. 3. In the collapsed-state single button (the one showing `languageShort.${currentLanguage}`), add a second icon button right after it, same size/classes, that calls `toggleTheme()` and shows a sun/moon glyph (reuse whatever icon import convention the file already uses — check the top `import { ... } from "lucide-react"` line for `Sun`/`Moon`, add them if missing). 4. In the expanded-state block (the `rounded-lg border border-border bg-surface-2 p-2` panel with the language grid), add a second 2-button row below the language grid — same `grid grid-cols-2 gap-1` shape as the language grid but 2 columns instead of 4 — with `Dark`/`Light` buttons calling `() => setTheme("dark")` / `() => setTheme("light")`, `aria-pressed={theme === "dark"}` etc., mirroring the exact `active ? ... : ...` className ternary the language buttons already use. 5. Labels via `t("theme")` (small header, same style as the existing `t("nav:language")` label) and `t(\`themeNames.${t}\`)` per button. - [x] **Step 7: Run the existing Sidebar tests** Run: `cd client && npx vitest run src/components/__tests__/Sidebar.test.tsx` (adjust path if the test file lives elsewhere — `find client/src -iname "*Sidebar*test*"` first) Expected: PASS. If the file doesn't exist yet, skip this step (no regression to protect). - [x] **Step 8: Commit** ```bash git add client/src/hooks/useTheme.ts client/src/hooks/__tests__/useTheme.test.ts client/src/components/Sidebar.tsx client/src/i18n/locales/en/nav.json client/src/i18n/locales/vi/nav.json git commit -m "feat(theme): dark/light toggle next to the language switcher" ``` --- ### Task 3: Mechanical gray-scale → semantic token migration **Files:** - Modify: every `client/src/**/*.tsx` file matching raw `neutral-*`/`gray-*`/`slate-*`/`zinc-*`/`stone-*` color utilities (64 files at design time — re-run the grep below to get the current, exact list; do not hand-pick a subset). - Create: `scripts/migrate-color-tokens.mjs` (one-off, deleted after use in the final commit of this task — or left in `scripts/` if the user wants it kept for a future pass; ask nothing, just note it in the commit message). **Interfaces:** - Consumes: the tokens from Task 1 (`surface.0-5`, `border`, `border.light`, `fg.primary/secondary/muted`). - Produces: no new interface — this task only changes class strings. - [x] **Step 1: Enumerate the exact strings to replace** Run: ```bash cd client/src && grep -rohE "(bg|text|border|placeholder|fill|ring)-(neutral|gray|slate|zinc|stone)-[0-9]+(/[0-9]+)?" --include=*.tsx . | sort -u ``` This is the authoritative input list for the mapping table in Step 2 — if it has grown or shrunk since this plan was written, update the table to match rather than silently ignoring new entries. - [x] **Step 2: Write the mapping table and apply it** Create `scripts/migrate-color-tokens.mjs`: ```js #!/usr/bin/env node /** * @file One-off codemod: rewrites raw Tailwind gray-scale utility classes * (neutral-*/gray-*/slate-*/zinc-*/stone-*) across client/src to the * semantic surface/border/fg tokens introduced for dark/light mode. * Status colors (emerald/red/amber) and the categorical hue palette * (violet/indigo/cyan/teal/sky/rose/pink/orange/yellow) are deliberately * out of scope — see docs/superpowers/plans/2026-07-31-color-redesign-dark-light-mode.md. * @author Nguyễn Ngọc Trí Vĩ */ import { readdirSync, readFileSync, writeFileSync, statSync } from "node:fs"; import { join } from "node:path"; // prop: bg | text | border | placeholder | fill | ring // Ordered longest-shade-string-first within each prop so e.g. "gray-900" is // matched before a hypothetical "gray-90" prefix collision (none exist today, // kept for safety). const MAP = { text: { "gray-100": "fg-primary", "gray-50": "fg-primary", "neutral-50": "fg-primary", "neutral-100": "fg-primary", "gray-200": "fg-secondary", "gray-300": "fg-secondary", "gray-400": "fg-secondary", "neutral-300": "fg-secondary", "neutral-400": "fg-secondary", "slate-300": "fg-secondary", "gray-500": "fg-muted", "gray-600": "fg-muted", "gray-700": "fg-muted", "neutral-500": "fg-muted", "neutral-600": "fg-muted", }, placeholder: { "gray-500": "fg-muted", "gray-600": "fg-muted", }, fill: { "gray-600": "fg-muted", "gray-300": "fg-secondary", "gray-100": "fg-primary", }, bg: { "neutral-900": "surface-0", "gray-900": "surface-0", "neutral-800": "surface-2", "gray-800": "surface-2", "neutral-700": "surface-3", "gray-700": "surface-3", "neutral-500": "surface-4", "gray-500": "surface-4", "gray-400": "surface-4", "gray-600": "surface-4", }, border: { "neutral-800": "border", "gray-800": "border", "neutral-700": "border-light", "gray-700": "border-light", "neutral-500": "border-light", "gray-500": "border-light", }, ring: { "slate-500": "border-light", "slate-400": "border-light", }, }; function rewriteLine(text) { let out = text; for (const [prop, shadeMap] of Object.entries(MAP)) { for (const [rawShade, token] of Object.entries(shadeMap)) { // Matches `bg-gray-500`, `bg-gray-500/10`, `bg-gray-500/50` etc. — the // opacity suffix is preserved verbatim since the token's CSS var // already supports ``. const re = new RegExp(`\\b${prop}-${rawShade}(\\/[0-9]+)?\\b`, "g"); out = out.replace(re, (_m, opacity) => `${prop}-${token}${opacity || ""}`); } } return out; } function walk(dir, files = []) { for (const entry of readdirSync(dir)) { const full = join(dir, entry); const st = statSync(full); if (st.isDirectory()) { if (entry === "node_modules" || entry === "__tests__") continue; walk(full, files); } else if (entry.endsWith(".tsx")) { files.push(full); } } return files; } const root = join(process.cwd(), "src"); const files = walk(root); let changed = 0; for (const file of files) { const before = readFileSync(file, "utf8"); const after = rewriteLine(before); if (after !== before) { writeFileSync(file, after); changed++; } } console.log(`rewrote ${changed} file(s)`); ``` Run: `cd client && node ../scripts/migrate-color-tokens.mjs` (adjust the relative path so it runs from `client/` and walks `client/src`). - [x] **Step 3: Confirm no raw gray-scale utility survives** Run: ```bash cd client/src && grep -rlE "(bg|text|border|placeholder|fill|ring)-(neutral|gray|slate|zinc|stone)-[0-9]+" --include=*.tsx . ``` Expected: no output. If any file still matches, its exact string is missing from `MAP` in Step 2 — add it and re-run Step 2, don't hand-patch the file directly (keeps the table authoritative for the next person who re-runs this). - [x] **Step 4: Typecheck** Run: `cd client && npx tsc --noEmit` Expected: `TypeScript: No errors found` (class-string rewrites cannot introduce type errors, but a botched regex could corrupt a `.tsx` file's syntax — this is the safety net for that). - [x] **Step 5: Run the client test suite and review the snapshot diff** Run: `cd client && npx vitest run` Expected: only `src/pages/__tests__/screens.snapshot.test.tsx` shows diffs (every other test is behavior, not color, so it must still pass unchanged). Read the diff — confirm it is exactly the token renames (e.g. `bg-neutral-900` → `bg-surface-0`) and nothing structural. Then regenerate: `npx vitest run -u`. - [x] **Step 6: Manual visual pass** Start the app (`npm run dev` from repo root, or rely on whatever dev server the user already has running), toggle Dark ↔ Light from the Sidebar, and check: Dashboard, Workspace (lane strip + detail + console), Sidebar itself. Confirm no screen still reads hardcoded dark in light mode. This cannot be scripted — say explicitly which screens were checked. - [x] **Step 7: Commit** ```bash git add -A git commit -m "refactor(theme): migrate raw gray-scale utilities to semantic tokens" ``` --- ### Task 4: Descope note for categorical/status colors (no code — documentation only) **Files:** - Modify: `docs/superpowers/specs/2026-07-31-color-redesign-dark-light-mode-design.md` (append, don't rewrite) **Interfaces:** none. - [x] **Step 1: Append a "Follow-up" section to the design doc** ```markdown ## Follow-up (not built in this pass) Task 3 of the implementation plan migrated only the gray-scale chrome (`neutral-*`/`gray-*`/`slate-*`/`zinc-*`) — 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. ``` - [x] **Step 2: Commit** ```bash git add docs/superpowers/specs/2026-07-31-color-redesign-dark-light-mode-design.md git commit -m "docs: note descoped status/categorical color follow-up" ```