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
@@ -88,9 +88,9 @@ const SENDER_STYLES: Record<
label: "User",
icon: User,
avatarRing:
"bg-gradient-to-br from-blue-500/30 to-cyan-500/20 text-blue-200 ring-1 ring-blue-400/30",
accentBar: "before:bg-blue-500/40",
headerText: "text-blue-200",
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
accentBar: "before:bg-blue-600/40",
headerText: "text-blue-300",
},
assistant: {
label: "Assistant",
@@ -104,7 +104,7 @@ const SENDER_STYLES: Record<
label: "Main agent",
icon: Workflow,
avatarRing:
"bg-gradient-to-br from-teal-500/30 to-emerald-500/20 text-teal-200 ring-1 ring-teal-400/30",
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
accentBar: "before:bg-teal-500/40",
headerText: "text-teal-200",
},
@@ -112,17 +112,17 @@ const SENDER_STYLES: Record<
label: "System",
icon: Cog,
avatarRing:
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-gray-300 ring-1 ring-slate-400/30",
accentBar: "before:bg-slate-500/40",
headerText: "text-gray-300",
"bg-gradient-to-br from-slate-500/30 to-gray-500/20 text-fg-secondary ring-1 ring-border-light/30",
accentBar: "before:bg-surface-4/40",
headerText: "text-fg-secondary",
},
tool: {
label: "Tool",
icon: Terminal,
avatarRing:
"bg-gradient-to-br from-amber-500/30 to-orange-500/20 text-amber-200 ring-1 ring-amber-400/30",
accentBar: "before:bg-amber-500/40",
headerText: "text-amber-200",
"bg-gradient-to-br from-status-warning/30 to-orange-500/20 text-status-warning ring-1 ring-status-warning/30",
accentBar: "before:bg-status-warning/40",
headerText: "text-status-warning",
},
};
import { ToolCallBlock } from "./ToolCallBlock";
@@ -174,12 +174,12 @@ function formatLocalTime(iso: string): string {
function SessionEventRow({ title, timestamp }: { title?: string; timestamp: string | null }) {
return (
<div className="flex items-center justify-center py-1">
<div className="inline-flex items-center gap-2 text-[11px] text-gray-400 bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
<span className="text-gray-500">Renamed session →</span>
<span className="text-gray-200 font-medium truncate">{title || "(untitled)"}</span>
<span className="text-fg-muted">Renamed session →</span>
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
{timestamp && (
<span className="text-[10px] text-gray-600 font-mono flex-shrink-0">
<span className="text-[10px] text-fg-muted font-mono flex-shrink-0">
{formatLocalTime(timestamp)}
</span>
)}
@@ -191,8 +191,8 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
/** Compact pill for /command invocations parsed out of TUI markup. */
function CommandPill({ display }: { display: string }) {
return (
<div className="inline-flex items-center gap-2 text-sm text-emerald-300 font-mono bg-emerald-500/10 border border-emerald-500/20 rounded-md px-3 py-1.5 max-w-full">
<span className="text-emerald-500/70">›</span>
<div className="inline-flex items-center gap-2 text-sm text-status-success font-mono bg-status-success/10 border border-status-success/20 rounded-md px-3 py-1.5 max-w-full">
<span className="text-status-success/70">›</span>
<span className="break-all">{display}</span>
</div>
);
@@ -203,9 +203,9 @@ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "std
const cleaned = stripAnsi(text).replace(/^\n+|\n+$/g, "");
const isErr = stream === "stderr";
const accent = isErr
? "border-red-500/30 bg-red-950/30 text-red-200/90"
: "border-surface-3 bg-surface-4/60 text-gray-200";
const labelColor = isErr ? "text-red-300/80" : "text-gray-400";
? "border-status-danger/30 bg-status-danger/30 text-status-danger/90"
: "border-surface-3 bg-surface-4/60 text-fg-secondary";
const labelColor = isErr ? "text-status-danger/80" : "text-fg-secondary";
return (
<div className={`rounded-lg border ${accent} overflow-hidden`}>
<div
@@ -224,7 +224,7 @@ function TerminalBlock({ text, stream }: { text: string; stream: "stdout" | "std
/** Subtle inline note for the local-command-caveat banner. */
function CaveatBlock({ text }: { text: string }) {
return (
<div className="flex items-start gap-2 rounded-md border border-amber-500/15 bg-amber-500/[0.05] px-3 py-1.5 text-[11px] text-amber-200/70">
<div className="flex items-start gap-2 rounded-md border border-status-warning/15 bg-status-warning/[0.05] px-3 py-1.5 text-[11px] text-status-warning/70">
<Info className="w-3.5 h-3.5 mt-px flex-shrink-0 opacity-60" />
<span className="leading-relaxed italic">{stripAnsi(text).trim()}</span>
</div>
@@ -247,11 +247,11 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
<CollapsibleBlock
key={key}
text={seg.text}
icon={<AlertTriangle className="w-3.5 h-3.5 text-amber-400/70 flex-shrink-0" />}
icon={<AlertTriangle className="w-3.5 h-3.5 text-status-warning/70 flex-shrink-0" />}
title="System reminder"
borderClass="border-amber-500/20"
bgClass="bg-amber-500/5"
textClass="text-amber-300/80"
borderClass="border-status-warning/20"
bgClass="bg-status-warning/5"
textClass="text-status-warning/80"
/>
);
case "persisted-output":
@@ -326,7 +326,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
if (loading) {
return (
<div className="flex items-center justify-center py-12 text-gray-500 text-sm">
<div className="flex items-center justify-center py-12 text-fg-muted text-sm">
Loading conversation...
</div>
);
@@ -334,7 +334,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
if (messages.length === 0) {
return (
<div className="text-center py-12 text-gray-500 text-sm">No conversation records found.</div>
<div className="text-center py-12 text-fg-muted text-sm">No conversation records found.</div>
);
}
@@ -392,19 +392,19 @@ export function MessageList({ messages, loading }: MessageListProps) {
{style.label}
</span>
{msg.model && (
<span className="text-[10px] text-gray-400 font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
<span className="text-[10px] text-fg-secondary font-mono bg-surface-3/60 border border-surface-3 rounded px-1.5 py-0.5">
{formatModelName(msg.model)}
</span>
)}
{msg.usage && (
<span className="text-[10px] text-gray-500 font-mono inline-flex items-center gap-1">
<span className="text-emerald-300/70">↓ {fmt(msg.usage.input_tokens)}</span>
<span className="text-gray-700">·</span>
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
<span className="text-fg-muted">·</span>
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
</span>
)}
{msg.timestamp && (
<span className="text-[10px] text-gray-600 ml-auto font-mono">
<span className="text-[10px] text-fg-muted ml-auto font-mono">
{formatLocalTime(msg.timestamp)}
</span>
)}
@@ -436,11 +436,11 @@ export function MessageList({ messages, loading }: MessageListProps) {
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-blue-400/60 flex-shrink-0" />}
icon={<ScrollText className="w-3.5 h-3.5 text-blue-500/60 flex-shrink-0" />}
title={skillPath}
borderClass="border-blue-500/20"
bgClass="bg-blue-500/5"
textClass="text-blue-400/80"
borderClass="border-blue-600/20"
bgClass="bg-blue-600/5"
textClass="text-blue-500/80"
/>
);
}
@@ -470,7 +470,7 @@ export function MessageList({ messages, loading }: MessageListProps) {
return (
<div
key={bIdx}
className="rounded-lg border border-amber-500/20 bg-amber-500/5 overflow-hidden"
className="rounded-lg border border-status-warning/20 bg-status-warning/5 overflow-hidden"
>
<button
onClick={() =>
@@ -481,23 +481,23 @@ export function MessageList({ messages, loading }: MessageListProps) {
return next;
})
}
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-amber-500/10 transition-colors"
className="w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-status-warning/10 transition-colors"
>
<ChevronRight
className={`w-3.5 h-3.5 text-amber-500/60 transition-transform duration-150 ${
className={`w-3.5 h-3.5 text-status-warning/60 transition-transform duration-150 ${
isExpanded ? "rotate-90" : ""
}`}
/>
<Brain className="w-3.5 h-3.5 text-amber-400/80" />
<span className="text-xs text-amber-200/90 font-medium">Thinking</span>
<Brain className="w-3.5 h-3.5 text-status-warning/80" />
<span className="text-xs text-status-warning/90 font-medium">Thinking</span>
{!isExpanded && (
<span className="text-[10px] text-amber-300/40 font-mono ml-auto">
<span className="text-[10px] text-status-warning/40 font-mono ml-auto">
{block.text.length.toLocaleString()} chars
</span>
)}
</button>
{isExpanded && (
<div className="border-t border-amber-500/10 px-3 py-2 text-amber-100/80">
<div className="border-t border-status-warning/10 px-3 py-2 text-status-warning/80">
<MarkdownContent text={block.text} dense />
</div>
)}