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:
2026-07-31 10:54:31 +07:00
parent 4905d63b97
commit b673363351
82 changed files with 3776 additions and 2578 deletions
@@ -130,7 +130,7 @@ export function CopyButton({ text }: { text: string }) {
<button
type="button"
onClick={copy}
className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-gray-400 hover:text-gray-200 hover:bg-surface-2 cursor-pointer"
className="flex items-center gap-1 text-[10px] py-0.5 px-1.5 rounded text-fg-secondary hover:text-fg-primary hover:bg-surface-2 cursor-pointer"
aria-label={t("eventDetail.copy")}
>
{copied ? <Check className="w-3 h-3" /> : <Copy className="w-3 h-3" />}
@@ -145,13 +145,13 @@ export function Terminal({ command, description }: { command: string; descriptio
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">terminal</span>
<span className="text-fg-muted text-[10px] uppercase tracking-wide">terminal</span>
<CopyButton text={command} />
</div>
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words">
{description && <div className="text-gray-500 mb-1"># {description}</div>}
<pre className="px-3 py-2 text-fg-secondary whitespace-pre-wrap break-words">
{description && <div className="text-fg-muted mb-1"># {description}</div>}
<div>
<span className="text-emerald-400 select-none">$ </span>
<span className="text-status-success select-none">$ </span>
{command}
</div>
</pre>
@@ -176,11 +176,14 @@ export function TerminalOutput({
const hasStderr = typeof stderr === "string" && stderr.length > 0;
const flag =
interrupted === true
? { label: "interrupted", color: "text-red-400 border-red-500/40 bg-red-500/10" }
? {
label: "interrupted",
color: "text-status-danger border-status-danger/40 bg-status-danger/10",
}
: typeof exitCode === "number" && exitCode !== 0
? {
label: `exit ${exitCode}`,
color: "text-red-400 border-red-500/40 bg-red-500/10",
color: "text-status-danger border-status-danger/40 bg-status-danger/10",
}
: null;
@@ -208,11 +211,11 @@ function OutputBlock({
text: string;
variant: "out" | "err";
}) {
const color = variant === "err" ? "text-red-300" : "text-gray-200";
const color = variant === "err" ? "text-status-danger" : "text-fg-secondary";
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
<CopyButton text={text} />
</div>
<pre className={`px-3 py-2 whitespace-pre-wrap break-words max-h-96 overflow-auto ${color}`}>
@@ -240,7 +243,7 @@ export function LineNumberedCode({
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
{label && (
<div className="flex items-center justify-between px-3 py-1 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">{label}</span>
<span className="text-fg-muted text-[10px] uppercase tracking-wide">{label}</span>
<CopyButton text={text} />
</div>
)}
@@ -249,10 +252,10 @@ export function LineNumberedCode({
<tbody>
{lines.map((line, i) => (
<tr key={i}>
<td className="px-2 text-right text-gray-600 select-none bg-black/40 border-r border-border align-top">
<td className="px-2 text-right text-fg-muted select-none bg-black/40 border-r border-border align-top">
{i + startLine}
</td>
<td className="px-3 text-gray-200 whitespace-pre-wrap break-words">{line}</td>
<td className="px-3 text-fg-secondary whitespace-pre-wrap break-words">{line}</td>
</tr>
))}
</tbody>
@@ -274,7 +277,7 @@ export type DiffHunk = {
export function UnifiedDiff({ hunks }: { hunks: DiffHunk[] }) {
if (hunks.length === 0) {
return <p className="text-[11px] text-gray-500 italic">no diff</p>;
return <p className="text-[11px] text-fg-muted italic">no diff</p>;
}
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
@@ -306,17 +309,17 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
kind === "add"
? "bg-green-500/10 text-green-200"
: kind === "remove"
? "bg-red-500/10 text-red-200"
: "text-gray-300";
? "bg-status-danger/10 text-status-danger"
: "text-fg-secondary";
const oldCell = showOld ? oldLine++ : "";
const newCell = showNew ? newLine++ : "";
const sign = kind === "add" ? "+" : kind === "remove" ? "-" : " ";
return (
<tr key={i} className={rowBg}>
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
<td className="px-2 text-right text-fg-muted select-none border-r border-border/40 w-[36px]">
{oldCell}
</td>
<td className="px-2 text-right text-gray-600 select-none border-r border-border/40 w-[36px]">
<td className="px-2 text-right text-fg-muted select-none border-r border-border/40 w-[36px]">
{newCell}
</td>
<td className="px-1 text-center select-none w-[16px]">{sign}</td>
@@ -347,7 +350,7 @@ export function KeyValueCard({
const ordered = [...priorityEntries, ...restEntries];
if (ordered.length === 0) {
return <p className="text-[11px] text-gray-500 italic">empty</p>;
return <p className="text-[11px] text-fg-muted italic">empty</p>;
}
return (
@@ -355,10 +358,10 @@ export function KeyValueCard({
<tbody>
{ordered.map(([k, v], i) => (
<tr key={k} className={i > 0 ? "border-t border-border" : ""}>
<td className="text-gray-500 align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all">
<td className="text-fg-muted align-top py-1.5 px-2 font-mono bg-surface-3/60 w-[28%] break-all">
{k}
</td>
<td className="text-gray-300 align-top py-1.5 px-2">
<td className="text-fg-secondary align-top py-1.5 px-2">
<ValueCell value={v} />
</td>
</tr>
@@ -369,32 +372,33 @@ export function KeyValueCard({
}
function ValueCell({ value }: { value: unknown }) {
if (value == null) return <span className="text-gray-500 italic">null</span>;
if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean")
return (
<span
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
value
? "text-green-400 border-green-500/30 bg-green-500/10"
: "text-gray-400 border-gray-500/30 bg-gray-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10"
}`}
>
{String(value)}
</span>
);
if (typeof value === "number") return <span className="font-mono text-gray-300">{value}</span>;
if (typeof value === "number")
return <span className="font-mono text-fg-secondary">{value}</span>;
if (typeof value === "string") {
if (value.length > 120 || value.includes("\n")) {
return (
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
<pre className="bg-surface-3 text-fg-secondary text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
{value}
</pre>
);
}
return <span className="font-mono text-gray-300 break-all">{value}</span>;
return <span className="font-mono text-fg-secondary break-all">{value}</span>;
}
if (Array.isArray(value)) {
if (value.length === 0) return <span className="text-gray-500 italic">[]</span>;
if (value.length === 0) return <span className="text-fg-muted italic">[]</span>;
return (
<ol className="list-decimal pl-4 space-y-1">
{value.map((item, i) => (
@@ -406,7 +410,7 @@ function ValueCell({ value }: { value: unknown }) {
);
}
return (
<pre className="bg-surface-3 text-gray-300 text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
<pre className="bg-surface-3 text-fg-secondary text-[11px] font-mono p-2 rounded border border-border whitespace-pre-wrap break-words max-h-48 overflow-auto">
{safeStringify(value)}
</pre>
);
@@ -423,11 +427,11 @@ function safeStringify(value: unknown): string {
// ───────────────────────── File list / match list ─────────────────────────
export function FileList({ paths }: { paths: string[] }) {
if (paths.length === 0) return <p className="text-[11px] text-gray-500 italic">no files</p>;
if (paths.length === 0) return <p className="text-[11px] text-fg-muted italic">no files</p>;
return (
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto">
{paths.map((p, i) => (
<li key={i} className="px-3 py-1 font-mono text-gray-300 break-all">
<li key={i} className="px-3 py-1 font-mono text-fg-secondary break-all">
{p}
</li>
))}
@@ -442,14 +446,14 @@ export type GrepMatch = {
};
export function MatchList({ matches }: { matches: GrepMatch[] }) {
if (matches.length === 0) return <p className="text-[11px] text-gray-500 italic">no matches</p>;
if (matches.length === 0) return <p className="text-[11px] text-fg-muted italic">no matches</p>;
return (
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
{matches.map((m, i) => (
<li key={i} className="px-3 py-1 text-gray-300 break-all">
<li key={i} className="px-3 py-1 text-fg-secondary break-all">
{m.file && <span className="text-cyan-300">{m.file}</span>}
{m.line != null && <span className="text-gray-500">:{m.line}</span>}
{m.text && <span className="text-gray-400">: {m.text}</span>}
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
</li>
))}
</ul>