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:
+130
-116
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user