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
+13 -13
View File
@@ -284,7 +284,7 @@ function SummaryBlock({
return (
<div className="border border-border rounded overflow-hidden bg-surface-3/30">
<div className="px-3 py-1 border-b border-border bg-black/20">
<span className="text-gray-500 text-[10px] uppercase tracking-wide font-semibold">
<span className="text-fg-muted text-[10px] uppercase tracking-wide font-semibold">
{t("eventDetail.summary")}
</span>
</div>
@@ -293,19 +293,19 @@ function SummaryBlock({
<span className="text-base leading-none" aria-hidden="true">
{summary.icon}
</span>
<span className="text-[12px] text-gray-100 font-medium break-words">
<span className="text-[12px] text-fg-primary font-medium break-words">
{summary.headline}
</span>
</div>
{summary.bullets.length > 0 && (
<ul className="list-disc pl-6 space-y-0.5 text-[11px] text-gray-400">
<ul className="list-disc pl-6 space-y-0.5 text-[11px] text-fg-secondary">
{summary.bullets.map((b, i) => (
<li key={i}>{b}</li>
))}
</ul>
)}
{hint && (
<div className="text-[11px] text-gray-500 italic pt-1 border-t border-border/40">
<div className="text-[11px] text-fg-muted italic pt-1 border-t border-border/40">
{hint}
</div>
)}
@@ -337,7 +337,7 @@ function FieldRow({
if (view) {
return (
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
<div className="text-gray-500 font-mono pt-2">{label}</div>
<div className="text-fg-muted font-mono pt-2">{label}</div>
<div>{view}</div>
</div>
);
@@ -348,7 +348,7 @@ function FieldRow({
if (view) {
return (
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
<div className="text-gray-500 font-mono pt-2">{label}</div>
<div className="text-fg-muted font-mono pt-2">{label}</div>
<div>{view}</div>
</div>
);
@@ -358,8 +358,8 @@ function FieldRow({
if (isInlineScalar(value)) {
return (
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
<div className="text-gray-500 font-mono pt-0.5">{label}</div>
<div className="text-gray-300 font-mono break-all">
<div className="text-fg-muted font-mono pt-0.5">{label}</div>
<div className="text-fg-secondary font-mono break-all">
<ScalarValue value={value} />
</div>
</div>
@@ -368,7 +368,7 @@ function FieldRow({
return (
<div className="grid grid-cols-[160px_1fr] gap-x-4 items-start text-[11px]">
<div className="text-gray-500 font-mono pt-2">{label}</div>
<div className="text-fg-muted font-mono pt-2">{label}</div>
<CodeView value={value} />
</div>
);
@@ -382,11 +382,11 @@ function isInlineScalar(value: unknown): boolean {
}
function ScalarValue({ value }: { value: unknown }) {
if (value == null) return <span className="text-gray-500 italic">null</span>;
if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean") {
const color = value
? "text-green-400 border-green-500/30 bg-green-500/10"
: "text-gray-400 border-gray-500/30 bg-gray-500/10";
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
return (
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
);
@@ -402,12 +402,12 @@ function CodeView({ value }: { value: unknown }) {
return (
<div className="relative bg-black/70 border border-border rounded font-mono text-[11px] overflow-hidden">
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border bg-black/40">
<span className="text-gray-500 text-[10px] uppercase tracking-wide">
<span className="text-fg-muted text-[10px] uppercase tracking-wide">
{typeof value === "string" ? "text" : Array.isArray(value) ? "array" : "json"}
</span>
<CopyButton text={text} />
</div>
<pre className="px-3 py-2 text-gray-200 whitespace-pre-wrap break-words max-h-96 overflow-auto">
<pre className="px-3 py-2 text-fg-secondary whitespace-pre-wrap break-words max-h-96 overflow-auto">
{text}
</pre>
</div>