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
@@ -83,15 +83,15 @@ function statusColor(status: string): string {
case "completed":
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
case "working":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
return "text-status-success bg-status-success/10 border-status-success/20";
case "error":
return "text-red-400 bg-red-500/10 border-red-500/20";
return "text-status-danger bg-status-danger/10 border-status-danger/20";
case "active":
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
return "text-status-success bg-status-success/10 border-status-success/20";
case "waiting":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
default:
return "text-gray-400 bg-gray-500/10 border-gray-500/20";
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
}
}
@@ -140,8 +140,8 @@ function TabBar({ active, onChange }: TabBarProps) {
className={[
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150",
active === tab.id
? "bg-surface-5 text-gray-100 shadow-sm"
: "text-gray-500 hover:text-gray-300",
? "bg-surface-5 text-fg-primary shadow-sm"
: "text-fg-muted hover:text-fg-secondary",
].join(" ")}
>
{tab.icon}
@@ -192,20 +192,20 @@ function TreeNode({ node, depth }: TreeNodeProps) {
{/* Name */}
<span
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-gray-200"}`}
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
>
{node.name}
</span>
{/* Subagent type */}
{node.subagent_type && (
<span className="text-xs text-gray-500 truncate flex-shrink-0">
<span className="text-xs text-fg-muted truncate flex-shrink-0">
[{node.subagent_type}]
</span>
)}
{/* Duration */}
<span className="ml-auto flex-shrink-0 text-xs text-gray-600 tabular-nums">{dur}</span>
<span className="ml-auto flex-shrink-0 text-xs text-fg-muted tabular-nums">{dur}</span>
</div>
{node.children.length > 0 && (
@@ -226,7 +226,7 @@ interface AgentTreeProps {
function AgentTree({ tree }: AgentTreeProps) {
const { t } = useTranslation("workflows");
if (tree.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noAgentTree")}</p>;
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noAgentTree")}</p>;
}
return (
@@ -249,7 +249,7 @@ interface ToolTimelineProps {
function ToolTimeline({ events }: ToolTimelineProps) {
const { t } = useTranslation("workflows");
if (events.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noToolEvents")}</p>;
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noToolEvents")}</p>;
}
return (
@@ -267,11 +267,13 @@ function ToolTimeline({ events }: ToolTimelineProps) {
{/* Summary */}
{ev.summary && (
<span className="text-xs text-gray-400 truncate flex-1 min-w-0">{ev.summary}</span>
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
{ev.summary}
</span>
)}
{/* Timestamp */}
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums ml-auto">
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums ml-auto">
{safeTimestamp(ev.created_at)}
</span>
</div>
@@ -288,22 +290,22 @@ interface EventSequenceProps {
}
const EVENT_TYPE_COLOR: Record<string, string> = {
tool_use: "text-blue-400",
tool_result: "text-emerald-400",
tool_use: "text-blue-500",
tool_result: "text-status-success",
agent_start: "text-indigo-400",
agent_stop: "text-violet-400",
compaction: "text-amber-400",
error: "text-red-400",
compaction: "text-status-warning",
error: "text-status-danger",
};
function eventTypeColor(type: string): string {
return EVENT_TYPE_COLOR[type] ?? "text-gray-400";
return EVENT_TYPE_COLOR[type] ?? "text-fg-secondary";
}
function EventSequence({ events }: EventSequenceProps) {
const { t } = useTranslation("workflows");
if (events.length === 0) {
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noEvents")}</p>;
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noEvents")}</p>;
}
const recent = events.slice(0, 100);
@@ -325,18 +327,18 @@ function EventSequence({ events }: EventSequenceProps) {
</span>
{/* Summary */}
<span className="text-xs text-gray-400 flex-1 min-w-0 truncate">
<span className="text-xs text-fg-secondary flex-1 min-w-0 truncate">
{ev.summary ?? ev.tool_name ?? "-"}
</span>
{/* Timestamp */}
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
{safeTimestamp(ev.created_at)}
</span>
</div>
))}
{events.length > 100 && (
<p className="text-xs text-gray-600 text-center py-2">
<p className="text-xs text-fg-muted text-center py-2">
{t("drillIn.showingOf", { total: events.length })}
</p>
)}
@@ -365,11 +367,11 @@ function ErrorState({ message }: ErrorStateProps) {
const { t } = useTranslation("workflows");
return (
<div className="flex flex-col items-center justify-center py-10 text-center px-4">
<div className="w-9 h-9 rounded-xl bg-red-500/10 border border-red-500/20 flex items-center justify-center mb-3">
<X className="w-4 h-4 text-red-400" />
<div className="w-9 h-9 rounded-xl bg-status-danger/10 border border-status-danger/20 flex items-center justify-center mb-3">
<X className="w-4 h-4 text-status-danger" />
</div>
<p className="text-sm font-medium text-red-400">{t("drillIn.failedLoad")}</p>
<p className="text-xs text-gray-600 mt-1 max-w-xs">{message}</p>
<p className="text-sm font-medium text-status-danger">{t("drillIn.failedLoad")}</p>
<p className="text-xs text-fg-muted mt-1 max-w-xs">{message}</p>
</div>
);
}
@@ -405,17 +407,19 @@ function NoSessionState({ onSelectSession }: NoSessionStateProps) {
<div className="flex flex-col items-center text-center mt-2">
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-4">
<GitFork className="w-5 h-5 text-gray-600" />
<GitFork className="w-5 h-5 text-fg-muted" />
</div>
<p className="text-sm font-medium text-gray-400 mb-1">{t("drillIn.noSessionSelected")}</p>
<p className="text-xs text-gray-600 max-w-xs">{t("drillIn.noSessionDesc")}</p>
<p className="text-sm font-medium text-fg-secondary mb-1">
{t("drillIn.noSessionSelected")}
</p>
<p className="text-xs text-fg-muted max-w-xs">{t("drillIn.noSessionDesc")}</p>
{/* Preview tab pills */}
<div className="flex gap-2 mt-5">
{tabs.map((tab) => (
<div
key={tab.id}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-gray-600 bg-surface-3 border border-border"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-fg-muted bg-surface-3 border border-border"
>
{tab.icon}
{tab.label}
@@ -444,10 +448,10 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<div className="flex flex-col gap-3 mb-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-gray-100 truncate">
<p className="text-sm font-semibold text-fg-primary truncate">
{session.name ?? session.id}
</p>
<p className="text-xs text-gray-500 mt-0.5 truncate">
<p className="text-xs text-fg-muted mt-0.5 truncate">
{formatModelName(session.model) ?? t("drillIn.unknownModel")} &middot;{" "}
{t(`common:status.${session.status}`, { defaultValue: session.status })}
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
@@ -456,7 +460,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
<button
type="button"
onClick={onClose}
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-gray-500 hover:text-gray-200 hover:bg-white/10 transition-colors"
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
aria-label={t("drillIn.closePanel")}
>
<X className="w-4 h-4" />
@@ -569,13 +573,13 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
inputRef.current?.focus();
}}
>
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={search}
placeholder={t("drillIn.searchPlaceholder")}
className="flex-1 bg-transparent text-xs text-gray-200 placeholder-gray-600 outline-none min-w-0"
className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
onFocus={() => setOpen(true)}
onChange={(e) => {
setSearch(e.target.value);
@@ -584,7 +588,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
/>
<ChevronDown
className={[
"w-3.5 h-3.5 text-gray-600 flex-shrink-0 transition-transform duration-150",
"w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform duration-150",
open ? "rotate-180" : "",
].join(" ")}
/>
@@ -605,7 +609,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
))}
</div>
) : filtered.length === 0 ? (
<p className="text-xs text-gray-600 text-center py-6 px-3">
<p className="text-xs text-fg-muted text-center py-6 px-3">
{search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")}
</p>
) : (
@@ -625,22 +629,22 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
{s.status}
</span>
<span className="flex-1 min-w-0">
<span className="block text-xs font-medium text-gray-200 truncate">
<span className="block text-xs font-medium text-fg-secondary truncate">
{s.name ?? s.id}
</span>
{s.name && (
<span className="block text-[10px] text-gray-600 font-mono truncate">
<span className="block text-[10px] text-fg-muted font-mono truncate">
{s.id}
</span>
)}
</span>
{s.model && (
<span className="flex-shrink-0 text-[10px] text-gray-500 truncate max-w-[80px]">
<span className="flex-shrink-0 text-[10px] text-fg-muted truncate max-w-[80px]">
{formatModelName(s.model)}
</span>
)}
{s.started_at && (
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums">
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums">
{safeTimestamp(s.started_at)}
</span>
)}
@@ -656,7 +660,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
type="button"
onClick={handleLoadMore}
disabled={loading}
className="w-full px-3 py-2 text-xs text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
>
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
</button>
@@ -737,11 +741,11 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
<div className="bg-surface-2 border border-border rounded-xl p-4">
<SessionSelector onSelectSession={onSelectSession} />
<div className="flex items-center justify-between mb-2">
<p className="text-xs text-gray-600 font-mono truncate">{sessionId}</p>
<p className="text-xs text-fg-muted font-mono truncate">{sessionId}</p>
<button
type="button"
onClick={onClose}
className="w-6 h-6 flex items-center justify-center rounded text-gray-600 hover:text-gray-300 hover:bg-white/10 transition-colors"
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
aria-label={t("drillIn.close")}
>
<X className="w-3.5 h-3.5" />