Files
AgentHub/docs/superpowers/plans/2026-07-31-color-redesign-dark-light-mode.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

20 KiB

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 <html> 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ĩ <vinnt@smartgift.vn>) — 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.

  • Step 1: Add CSS variables for both themes

Replace the top of client/src/index.css (before the existing @layer base block) with:

@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;
}
  • Step 2: Point Tailwind's tokens at the variables

In client/tailwind.config.js, replace the colors block under theme.extend:

colors: {
  surface: {
    0: "rgb(var(--surface-0) / <alpha-value>)",
    1: "rgb(var(--surface-1) / <alpha-value>)",
    2: "rgb(var(--surface-2) / <alpha-value>)",
    3: "rgb(var(--surface-3) / <alpha-value>)",
    4: "rgb(var(--surface-4) / <alpha-value>)",
    5: "rgb(var(--surface-5) / <alpha-value>)",
  },
  border: {
    DEFAULT: "rgb(var(--border) / <alpha-value>)",
    light: "rgb(var(--border-light) / <alpha-value>)",
  },
  accent: {
    DEFAULT: "rgb(var(--accent) / <alpha-value>)",
    hover: "rgb(var(--accent-hover) / <alpha-value>)",
    muted: "rgb(var(--accent) / 0.15)",
  },
  fg: {
    primary: "rgb(var(--fg-primary) / <alpha-value>)",
    secondary: "rgb(var(--fg-secondary) / <alpha-value>)",
    muted: "rgb(var(--fg-muted) / <alpha-value>)",
  },
},

Also add darkMode: "class", as a top-level key in the exported config object (next to content).

  • 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).

  • Step 4: Commit
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.

  • Step 1: Write the failing test

/**
 * @file Tests for useTheme — the dark/light mode hook.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */
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);
  });
});
  • 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.

  • Step 3: Write the implementation
/**
 * @file useTheme — dark/light mode state, backed by localStorage and the
 * `dark` class on <html> that Tailwind's `darkMode: "class"` reads.
 * @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
 */
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<Theme>(() => 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 };
}
  • Step 4: Run test to verify it passes

Run: cd client && npx vitest run src/hooks/__tests__/useTheme.test.ts Expected: PASS, 4/4.

  • Step 5: Add i18n keys

In client/src/i18n/locales/en/nav.json, add next to the existing language/languageNames/switchLanguage keys:

"theme": "Theme",
"themeNames": { "dark": "Dark", "light": "Light" },
"switchTheme": "Switch to {{theme}}"

In client/src/i18n/locales/vi/nav.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.)

  • 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 <div className="mt-2 grid grid-cols-4 gap-1"> 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.
  • 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).

  • Step 8: Commit
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.

  • Step 1: Enumerate the exact strings to replace

Run:

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.

  • Step 2: Write the mapping table and apply it

Create scripts/migrate-color-tokens.mjs:

#!/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ĩ <vinnt@smartgift.vn>
 */
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 `<alpha-value>`.
      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).

  • Step 3: Confirm no raw gray-scale utility survives

Run:

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).

  • 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).

  • 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.

  • 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.

  • Step 7: Commit
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.

  • Step 1: Append a "Follow-up" section to the design doc
## 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.
  • Step 2: Commit
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"