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.
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
# 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.
|
||||
|
||||
- [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) / <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`).
|
||||
|
||||
- [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ĩ <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);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [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 <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 };
|
||||
}
|
||||
```
|
||||
|
||||
- [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 `<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.
|
||||
|
||||
- [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ĩ <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`).
|
||||
|
||||
- [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"
|
||||
```
|
||||
@@ -0,0 +1,149 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user