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
+96 -122
View File
@@ -1,11 +1,13 @@
/**
* @file One lane's card: title, stage badge with time-on-phase, progress bar,
* branch/CI/PR facts, the "needs you" banner sourced from Claude Code's
* Notification hook, and the control row. A dead lane (its driving session went
* silent while it should have been working) is called out loudly — that is the
* failure this view exists to catch. An "auto: <stage>" chip appears only when
* the server's detected stage is ahead of the agent's own declaration — when the
* declaration leads or matches, it stays the sole headline.
* @file One lane's card: title, progress bar with time-on-phase, branch/CI/PR
* facts, the "needs you" banner sourced from Claude Code's Notification hook,
* and the control row — which ends in the two deletions (lane, history) as
* plain buttons, each gated by DestructiveLaneModal. A dead lane (its driving
* session went silent while it should have been working) is called out
* loudly — that is the failure this view exists to catch. Stage, kind, and
* the "auto: <stage>" chip are NOT repeated here — this card only ever
* renders inside the Workspace lane-detail section, whose header above it
* already shows them.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -54,9 +56,9 @@ function useLaneGitFacts(laneId: number): LaneGitFacts | null {
}
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-emerald-400",
idle: "bg-neutral-500",
dead: "bg-red-500",
active: "bg-status-success",
idle: "bg-surface-4",
dead: "bg-status-danger",
};
function since(sec: number | null): string {
@@ -66,15 +68,6 @@ function since(sec: number | null): string {
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
}
/** Whether `lane.detected_stage` is strictly ahead of `lane.stage` in the
* pipeline's node order. Unknown ids sort as "not found" (-1), so an unmatched
* detected stage never outranks a matched declaration. */
function detectionLeadsDeclaration(lane: Lane): boolean {
if (!lane.detected_stage) return false;
const ids = lane.pipeline_nodes.map((n) => n.id);
return ids.indexOf(lane.detected_stage) > ids.indexOf(lane.stage);
}
export default function LaneCard({
lane,
onAction,
@@ -93,15 +86,15 @@ export default function LaneCard({
<>
<div
data-testid={`lane-card-${lane.id}`}
className="flex flex-col rounded-xl border border-neutral-800 bg-neutral-900/70 p-4 transition-colors hover:border-neutral-700"
className="flex flex-col rounded-xl border border-border bg-surface-4 p-4 transition-colors hover:border-border-light"
>
{/* Identity strip: which lane, and is it alive. Kept on one line and in
uppercase so a wall of cards can be scanned vertically. */}
<div className="mb-3 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500">
<span className="text-[11px] font-semibold uppercase tracking-widest text-fg-muted">
{t("cardId", { id: lane.id })}
</span>
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-neutral-300">
<span className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-fg-secondary">
<span className={`h-2 w-2 rounded-full ${LIVENESS_DOT[lane.liveness]}`} />
{/* i18next returns the KEY on a miss, so `|| raw` never fires and a
non-standard status rendered as the literal "status.foo".
@@ -112,81 +105,57 @@ export default function LaneCard({
</span>
</div>
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-neutral-50">
<h3 className="mb-3 text-[15px] font-semibold leading-snug text-fg-primary">
{lane.title || lane.cwd}
</h3>
{/* Stage line: the declared stage, how far through, and how long it has
been sitting there — the three facts that say whether a lane is
moving. The inferred chip sits beside them, never instead of them. */}
{/* Progress line: how far through the pipeline and how long the
current stage has been sitting there. The stage's own name is
already in the Workspace header above; not repeated here. */}
<div className="mb-2 flex items-center gap-2 text-xs">
<span
data-testid="lane-stage"
className="rounded bg-neutral-800 px-2 py-0.5 font-medium text-neutral-200"
>
{lane.stage}
</span>
<div
className={`h-1 flex-1 overflow-hidden rounded-full ${
lane.progress > 0 ? "bg-neutral-800" : "bg-transparent"
lane.progress > 0 ? "bg-surface-2" : "bg-transparent"
}`}
>
<div
data-testid="lane-progress-fill"
className="h-full rounded-full bg-blue-500 transition-[width]"
className="h-full rounded-full bg-blue-600 transition-[width]"
style={{ width: `${lane.progress}%` }}
/>
</div>
{lane.progress > 0 && (
<span className="tabular-nums text-neutral-400">{lane.progress}%</span>
<span className="tabular-nums text-fg-secondary">{lane.progress}%</span>
)}
<span className="tabular-nums text-neutral-600">{since(lane.stage_seconds)}</span>
<span className="tabular-nums text-fg-muted">{since(lane.stage_seconds)}</span>
</div>
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
<span
className={`rounded px-1.5 py-0.5 ${
lane.kind === "managed"
? "bg-blue-500/15 text-blue-300"
: "bg-violet-500/15 text-violet-300"
}`}
>
{t(`kind.${lane.kind}`)}
</span>
{detectionLeadsDeclaration(lane) && (
<span
data-testid="lane-auto-stage"
title={lane.detected_signal || undefined}
className="rounded border border-dashed border-amber-500 px-1.5 py-0.5 text-amber-300"
>
{t("autoStage", { stage: lane.detected_stage })}
</span>
)}
{lane.ci_status && (
<span className="rounded bg-neutral-800 px-1.5 py-0.5 text-neutral-300">
{lane.ci_status && (
<div className="mb-3 flex flex-wrap items-center gap-1.5 text-[11px]">
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
)}
</div>
</div>
)}
{lane.needs_action && (
<div className="mb-3 rounded border border-amber-600/50 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
<div className="mb-3 rounded border border-status-warning/50 bg-status-warning/10 px-2 py-1.5 text-xs text-status-warning">
{lane.needs_action}
</div>
)}
<dl className="mb-3 space-y-1 font-mono text-[11px] text-neutral-400">
<dl className="mb-3 space-y-1 font-mono text-[11px] text-fg-secondary">
{git?.available && (
<div data-testid="lane-git" className="space-y-1">
<div className="truncate">
{git.branch}
<span className="ml-2 text-neutral-500">{git.head}</span>
<span className="ml-2 text-fg-muted">{git.head}</span>
</div>
<div className="truncate text-neutral-500" title={git.subject}>
<div className="truncate text-fg-muted" title={git.subject}>
{git.subject}
</div>
{(git.dirty > 0 || git.untracked > 0) && (
<div className="text-amber-400/80">
<div className="text-status-warning/80">
{t("git.uncommitted", { dirty: git.dirty, untracked: git.untracked })}
</div>
)}
@@ -195,14 +164,14 @@ export default function LaneCard({
{/* The lane's own recorded branch, shown only when git could not be
read — otherwise it duplicates the live branch above. */}
{!git?.available && lane.branch && <div className="truncate"> {lane.branch}</div>}
<div className="truncate text-neutral-500" title={lane.cwd}>
<div className="truncate text-fg-muted" title={lane.cwd}>
{lane.cwd}
</div>
</dl>
{/* mt-auto pins the controls to the bottom so cards of differing height
in one grid row still line their buttons up. */}
<div className="mt-auto flex items-center gap-1 border-t border-neutral-800 pt-2 text-xs">
<div className="mt-auto flex items-center gap-1 border-t border-border pt-2 text-xs">
{(["start", "stop", "clear"] as const).map((a) => (
<button
key={a}
@@ -214,77 +183,82 @@ export default function LaneCard({
}}
className={`rounded px-2 py-1 transition-colors ${
a === "start"
? "bg-blue-500/15 text-blue-300 hover:bg-blue-500/25"
: "text-neutral-400 hover:bg-neutral-800 hover:text-neutral-200"
? "bg-blue-600/15 text-blue-400 hover:bg-blue-600/25"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-secondary"
}`}
title={a === "start" ? t("tooltipStart") : undefined}
>
{t(`action.${a}`)}
</button>
))}
{/* Destructive verbs sit behind a menu so the card is not a wall of
red. Red is kept for the items inside, where it means something. */}
<div className="relative ml-auto">
{/* Deleting the lane and deleting its history are each their own
button, by request: hiding "delete" behind a ⋯ made it unfindable,
and the one label that read as "delete" was `clear`. Neither fires
directly — both open DestructiveLaneModal, which shows the exact
counts and demands a confirmation. `reset` stays in the menu; it
is the rare one and only exists for managed lanes. */}
<div className="ml-auto flex items-center gap-1">
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
data-testid="lane-action-purge"
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
setDestructiveAction("purge");
}}
className="rounded px-2 py-1 text-neutral-500 hover:bg-neutral-800 hover:text-neutral-200"
title={t("moreActions")}
className="rounded px-2 py-1 text-status-danger/80 transition-colors hover:bg-status-danger/10 hover:text-status-danger"
>
{t("action.purge")}
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 py-1 shadow-lg shadow-black/40"
>
{lane.kind === "managed" && (
<button
type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-amber-300 hover:bg-amber-500/10"
<button
type="button"
data-testid="lane-action-remove"
onClick={(e) => {
e.stopPropagation();
setDestructiveAction("remove");
}}
className="rounded px-2 py-1 text-status-danger transition-colors hover:bg-status-danger/15 hover:text-status-danger"
>
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
</button>
{/* Adopted lanes have no worktree to reset, so the menu would be
empty — it is not rendered at all rather than opening onto
nothing. */}
{lane.kind === "managed" && (
<div className="relative">
<button
type="button"
data-testid="lane-more"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={(e) => {
e.stopPropagation();
setMenuOpen((v) => !v);
}}
className="rounded px-2 py-1 text-fg-muted hover:bg-surface-2 hover:text-fg-secondary"
title={t("moreActions")}
>
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 z-10 mt-1 min-w-40 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
{t("action.reset")}
</button>
<button
type="button"
role="menuitem"
data-testid="lane-action-reset"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("reset");
}}
className="block w-full px-3 py-1.5 text-left text-status-warning hover:bg-status-warning/10"
>
{t("action.reset")}
</button>
</div>
)}
<button
type="button"
role="menuitem"
data-testid="lane-action-remove"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("remove");
}}
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
>
{lane.kind === "adopted" ? t("action.forget") : t("action.remove")}
</button>
<button
type="button"
role="menuitem"
data-testid="lane-action-purge"
onClick={(e) => {
e.stopPropagation();
setMenuOpen(false);
setDestructiveAction("purge");
}}
className="block w-full px-3 py-1.5 text-left text-red-400 hover:bg-red-500/10"
>
{t("action.purge")}
</button>
</div>
)}
</div>