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 -116
View File
@@ -267,6 +267,19 @@ export function Workspace() {
});
}, [refreshLanes]);
// A reconnect (e.g. the dashboard server restarting) resumes the WS but does
// not replay missed lane_update diffs, so a lane whose fields changed while
// disconnected — status, needs_action — would keep showing its pre-restart
// badges forever with no further server-side change to broadcast. Refetch
// the full list whenever the socket comes back up.
useEffect(
() =>
eventBus.onConnection((isConnected) => {
if (isConnected) void refreshLanes();
}),
[refreshLanes]
);
const refreshList = useCallback(() => {
api.run
.list()
@@ -782,96 +795,95 @@ export function Workspace() {
const consoleSection = (
<>
{/* Always attached under the pipeline - no header, no collapse. The
{/* Always attached under the pipeline - no header, no collapse. The
pipeline panel above already names the lane; unmounting RunConsole
would throw away a live run's rendered history and scroll
position, so this stays mounted for the page's whole life. */}
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
<Header
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
wsConnected={wsConnected}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
<div data-testid="console-body" className="flex min-h-0 flex-1 flex-col gap-5">
<Header
activeRuns={activeRuns}
currentHandleId={handle?.id || null}
onAttach={attachToRun}
wsConnected={wsConnected}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
onViewFromHistory={onViewFromHistory}
onRefresh={refreshList}
/>
{binaryStatus && !binaryStatus.found && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing")}</span>
</div>
)}
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
<button
onClick={() => setError(null)}
className="text-red-200/70 hover:text-red-100 p-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{!handle ? (
// Config card uses normal page flow - page scrolls if needed.
<RunSetup
mode={mode}
onModeChange={(m) => {
setMode(m);
// Headless can't resume - clearing keeps the UI honest if the
// user had a session pinned and then switched mode.
if (m === "headless") setResumeSession(null);
}}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={start}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
slashCommands={slashCommands}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
// Run session is wrapped in a flex container so its inner chat panel
// can take all remaining viewport height; long chats scroll inside.
<div className="flex-1 min-h-0 flex flex-col">
<RunConsole
handle={handle}
envelopes={displayEnvelopes}
mode={handle.mode}
isLive={isLive}
hasFinished={hasFinished}
followUp={followUp}
onFollowUpChange={setFollowUp}
busy={busy}
onSend={send}
onStop={stop}
onNewRun={newRun}
slashCommands={slashCommands}
/>
</div>
)}
{binaryStatus && !binaryStatus.found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing")}</span>
</div>
)}
{error && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1 break-all">{error}</span>
<button
onClick={() => setError(null)}
className="text-status-danger/70 hover:text-status-danger p-0.5"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{!handle ? (
// Config card uses normal page flow - page scrolls if needed.
<RunSetup
mode={mode}
onModeChange={(m) => {
setMode(m);
// Headless can't resume - clearing keeps the UI honest if the
// user had a session pinned and then switched mode.
if (m === "headless") setResumeSession(null);
}}
prompt={prompt}
onPromptChange={setPrompt}
cwd={cwd}
onCwdChange={setCwd}
cwdSuggestions={cwdSuggestions}
model={model}
onModelChange={setModel}
permissionMode={permissionMode}
onPermissionModeChange={setPermissionMode}
effort={effort}
onEffortChange={setEffort}
binaryFound={binaryStatus?.found ?? true}
busy={busy === "start"}
onStart={start}
activeRuns={activeRuns}
laneCwd={currentLane?.cwd}
resumeSession={resumeSession}
onResumeSessionChange={setResumeSession}
slashCommands={slashCommands}
runHistory={runHistory}
onResumeFromHistory={onResumeFromHistory}
/>
) : (
// Run session is wrapped in a flex container so its inner chat panel
// can take all remaining viewport height; long chats scroll inside.
<div className="flex-1 min-h-0 flex flex-col">
<RunConsole
handle={handle}
envelopes={displayEnvelopes}
mode={handle.mode}
isLive={isLive}
hasFinished={hasFinished}
followUp={followUp}
onFollowUpChange={setFollowUp}
busy={busy}
onSend={send}
onStop={stop}
onNewRun={newRun}
slashCommands={slashCommands}
/>
</div>
)}
</div>
</>
);
@@ -885,27 +897,27 @@ export function Workspace() {
>
{/* Page header: what this screen is, and the four numbers that say
whether anything needs a human right now. */}
<div className="flex flex-wrap items-center gap-3 border-b border-neutral-800 pb-3">
<h2 className="text-base font-semibold tracking-tight text-neutral-100">
<div className="flex flex-wrap items-center gap-3 border-b border-border pb-3">
<h2 className="text-base font-semibold tracking-tight text-fg-primary">
{tLanes("title")}
</h2>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<span
data-testid="count-total"
className="rounded-full bg-neutral-800 px-2.5 py-0.5 text-neutral-300"
className="rounded-full bg-surface-2 px-2.5 py-0.5 text-fg-secondary"
>
{counts.total} {tLanes("countTotal")}
</span>
<span
data-testid="count-running"
className="rounded-full bg-blue-500/20 px-2.5 py-0.5 text-blue-300"
className="rounded-full bg-blue-600/20 px-2.5 py-0.5 text-blue-400"
>
{counts.running} {tLanes("countRunning")}
</span>
{counts.needs_you > 0 && (
<span
data-testid="count-needs-you"
className="rounded-full bg-amber-500/20 px-2.5 py-0.5 text-amber-300"
className="rounded-full bg-status-warning/20 px-2.5 py-0.5 text-status-warning"
>
{counts.needs_you} {tLanes("countNeedsYou")}
</span>
@@ -913,7 +925,7 @@ export function Workspace() {
{counts.dead > 0 && (
<span
data-testid="count-dead"
className="rounded-full bg-red-500/20 px-2.5 py-0.5 text-red-300"
className="rounded-full bg-status-danger/20 px-2.5 py-0.5 text-status-danger"
>
{counts.dead} {tLanes("countDead")}
</span>
@@ -921,7 +933,7 @@ export function Workspace() {
</div>
<button
onClick={() => setAddLaneOpen(true)}
className="ml-auto flex items-center gap-1.5 rounded border border-neutral-700 px-3 py-1 text-xs text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100"
className="ml-auto flex items-center gap-1.5 rounded border border-border-light px-3 py-1 text-xs text-fg-secondary transition-colors hover:border-border-light hover:text-fg-primary"
title={tLanes("addLane")}
>
<Plus className="h-3.5 w-3.5" />
@@ -944,7 +956,7 @@ export function Workspace() {
/>
))}
{!lanes.length && (
<p className="text-sm text-neutral-500">
<p className="text-sm text-fg-muted">
{tLanes("emptyState")} <code>ccam lanes add --cwd $(pwd)</code>
</p>
)}
@@ -953,26 +965,32 @@ export function Workspace() {
{/* The selected lane's pipeline, full width — the thing you actually
come to this page to read. */}
{currentLane && (
<section
data-testid="lane-detail"
className="rounded-xl border border-neutral-800 bg-neutral-900/40 p-4"
>
<section data-testid="lane-detail" className="card p-4">
<div className="mb-3 flex flex-wrap items-baseline gap-2">
<span className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500">
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
{tLanes("cardId", { id: currentLane.id })}
</span>
<span className="truncate text-sm font-semibold text-neutral-100">
<span className="truncate text-sm font-semibold text-fg-primary">
{currentLane.title || currentLane.cwd}
</span>
<span className="text-[11px] text-neutral-600">{currentLane.pipeline_name}</span>
<span className="rounded bg-neutral-800 px-2 py-0.5 text-xs text-neutral-200">
{currentLane.stage}
<span className="text-[11px] text-fg-muted">{currentLane.pipeline_name}</span>
{/* `stage` defaults to the DB sentinel "idle" until the driving
session ever calls `ccam stage` — that string collides with
`status`'s own "idle"/"running" vocabulary, so a lane that is
actively running but has never declared a stage read as if it
were sitting idle. Show a distinct label instead of the raw
sentinel whenever it doesn't match any node this pipeline
actually has. */}
<span className="rounded bg-surface-2 px-2 py-0.5 text-xs text-fg-secondary">
{currentLane.pipeline_nodes.some((n) => n.id === currentLane.stage)
? currentLane.stage
: tLanes("stageUndeclared")}
</span>
{currentLane.detected_stage && (
<span
data-testid="detail-auto-stage"
title={currentLane.detected_signal || undefined}
className="rounded border border-dashed border-amber-500 px-2 py-0.5 text-xs text-amber-300"
className="rounded border border-dashed border-status-warning px-2 py-0.5 text-xs text-status-warning"
>
{tLanes("autoStage", { stage: currentLane.detected_stage })}
</span>
@@ -990,7 +1008,7 @@ export function Workspace() {
detectedSignal={currentLane.detected_signal}
/>
</div>
<div className="flex min-h-0 flex-col gap-2 border-t border-neutral-800 pt-3">
<div className="flex min-h-0 flex-col gap-2 border-t border-border pt-3">
{consoleSection}
</div>
</section>
@@ -1010,20 +1028,16 @@ export function Workspace() {
{laneActionError && (
<p
role="alert"
className="rounded border border-red-800 bg-red-950/40 px-3 py-2 text-sm text-red-300"
className="rounded border border-status-danger bg-status-danger/40 px-3 py-2 text-sm text-status-danger"
>
{tLanes("actionError", { message: laneActionError })}
</p>
)}
{/* No lane selected (none exist, or nothing picked yet): the console has
nowhere to attach, so it falls back to page level. Without this the
start form would be unreachable on a fresh install. */}
{!currentLane && (
<div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>
)}
{!currentLane && <div className="flex min-h-0 flex-col gap-2">{consoleSection}</div>}
</div>
);
}
@@ -1087,20 +1101,20 @@ function Header({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold text-gray-100">{t("title")}</h1>
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
{wsConnected ? (
<span className="flex items-center gap-1.5 text-[11px] text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse-dot" />
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
{tCommon("live")}
</span>
) : (
<span className="flex items-center gap-1.5 text-[11px] text-gray-400 bg-gray-500/10 border border-gray-500/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-gray-400" />
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
{tCommon("offline")}
</span>
)}
</div>
<p className="text-xs text-gray-500 max-w-3xl">{t("subtitle")}</p>
<p className="text-xs text-fg-muted max-w-3xl">{t("subtitle")}</p>
</div>
<ActiveRunsSwitcher
activeRuns={activeRuns}