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
+135 -75
View File
@@ -79,10 +79,13 @@ import {
Gauge,
ChevronUp,
ChevronDown,
Sun,
Moon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { useTheme } from "../hooks/useTheme";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
@@ -180,6 +183,7 @@ interface SidebarProps {
export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const { t, i18n } = useTranslation();
const { theme, setTheme, toggleTheme } = useTheme();
// Track whether nav items are clipped by overflow so we can render
// chevron affordances pointing toward the hidden items. Recomputed on
// scroll, resize, and any structural change (e.g. collapse toggle).
@@ -376,6 +380,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
const switchLanguageTitle = t("nav:switchLanguage", {
language: t(`nav:languageNames.${nextLanguage}`),
});
const nextTheme = theme === "dark" ? "light" : "dark";
const switchThemeTitle = t("nav:switchTheme", { theme: t(`nav:themeNames.${nextTheme}`) });
const toggleLang = () => {
i18n.changeLanguage(nextLanguage);
@@ -401,8 +407,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
</div>
{!collapsed && (
<div className="min-w-0">
<h1 className="text-sm font-semibold text-gray-100 truncate">{t("nav:brand")}</h1>
<p className="text-[11px] text-gray-500">{t("nav:brandSub")}</p>
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
</div>
)}
</div>
@@ -428,7 +434,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
} ${
isActive
? "bg-accent/10 text-accent border border-accent/20"
: "text-gray-400 hover:text-gray-200 hover:bg-surface-3 border border-transparent"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-3 border border-transparent"
}`
}
>
@@ -444,7 +450,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
onClick={() => scrollNavBy(-160)}
aria-label={t("nav:scrollUp")}
title={t("nav:scrollUp")}
className="absolute top-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-gray-300 hover:text-gray-50 hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
className="absolute top-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-fg-secondary hover:text-fg-primary hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
>
<ChevronUp className="w-3.5 h-3.5" aria-hidden />
</button>
@@ -455,52 +461,106 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
onClick={() => scrollNavBy(160)}
aria-label={t("nav:scrollDown")}
title={t("nav:scrollDown")}
className="absolute bottom-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-gray-300 hover:text-gray-50 hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
className="absolute bottom-1.5 right-[7px] z-10 inline-flex items-center justify-center w-6 h-6 rounded-md border border-border bg-surface-2/90 text-fg-secondary hover:text-fg-primary hover:bg-surface-3 shadow-md backdrop-blur-sm transition-colors animate-fade-in"
>
<ChevronDown className="w-3.5 h-3.5" aria-hidden />
</button>
)}
</div>
{/* Language controls */}
{/* Language + theme controls. Theme sits next to language: same icon
row when collapsed, same row as the EN/VI buttons when expanded -
both are 2-state click-to-flip toggles, so they read as one family
of controls rather than two unrelated settings. */}
<div className="px-2 pb-2 flex-shrink-0">
{collapsed ? (
<button
onClick={toggleLang}
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-gray-300 hover:bg-surface-3 hover:text-gray-100 transition-colors flex flex-col items-center justify-center gap-0.5"
title={switchLanguageTitle}
aria-label={switchLanguageTitle}
>
<Languages className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold leading-none">
{t(`nav:languageShort.${currentLanguage}`)}
</span>
</button>
<div className="flex flex-col gap-1">
<button
onClick={toggleLang}
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
title={switchLanguageTitle}
aria-label={switchLanguageTitle}
>
<Languages className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold leading-none">
{t(`nav:languageShort.${currentLanguage}`)}
</span>
</button>
<button
onClick={toggleTheme}
className="w-full h-9 rounded-lg border border-border bg-surface-2 text-fg-secondary hover:bg-surface-3 hover:text-fg-primary transition-colors flex flex-col items-center justify-center gap-0.5"
title={switchThemeTitle}
aria-label={switchThemeTitle}
>
{theme === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Sun className="w-3.5 h-3.5" />
)}
<span className="text-[10px] font-semibold leading-none">
{t(`nav:themeNames.${theme}`).slice(0, 1)}
</span>
</button>
</div>
) : (
<div className="rounded-lg border border-border bg-surface-2 p-2">
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500">
{t("nav:language")}
</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{SUPPORTED_LANGUAGES.map((language) => {
const active = language === currentLanguage;
return (
<button
key={language}
onClick={() => changeLanguage(language)}
aria-pressed={active}
aria-label={t(`nav:languageNames.${language}`)}
title={t(`nav:languageNames.${language}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-gray-400 border border-border hover:bg-surface-3 hover:text-gray-200"
}`}
>
{t(`nav:languageShort.${language}`)}
</button>
);
})}
<div className="rounded-lg border border-border bg-surface-2 p-2 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1">
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:language")}
</p>
<div className="mt-2 grid grid-cols-4 gap-1">
{SUPPORTED_LANGUAGES.map((language) => {
const active = language === currentLanguage;
return (
<button
key={language}
onClick={() => changeLanguage(language)}
aria-pressed={active}
aria-label={t(`nav:languageNames.${language}`)}
title={t(`nav:languageNames.${language}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{t(`nav:languageShort.${language}`)}
</button>
);
})}
</div>
</div>
<div>
<p className="px-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("nav:theme")}
</p>
<div className="mt-2 grid grid-cols-2 gap-1">
{(["dark", "light"] as const).map((option) => {
const active = option === theme;
return (
<button
key={option}
onClick={() => setTheme(option)}
aria-pressed={active}
aria-label={t(`nav:themeNames.${option}`)}
title={t(`nav:themeNames.${option}`)}
className={`rounded-md px-2 py-1.5 text-[11px] font-semibold transition-colors flex items-center justify-center ${
active
? "bg-accent/20 text-accent border border-accent/30"
: "bg-surface-1 text-fg-secondary border border-border hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{option === "dark" ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Sun className="w-3.5 h-3.5" />
)}
</button>
);
})}
</div>
</div>
</div>
</div>
)}
@@ -512,8 +572,8 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
onClick={onToggle}
className={`w-full h-10 rounded-lg border border-border bg-surface-2 transition-colors ${
collapsed
? "flex items-center justify-center text-gray-400 hover:text-gray-200 hover:bg-surface-3"
: "flex items-center gap-2.5 px-3 text-gray-300 hover:text-gray-100 hover:bg-surface-3"
? "flex items-center justify-center text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
: "flex items-center gap-2.5 px-3 text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
title={collapsed ? t("nav:expand") : t("nav:collapse")}
aria-label={collapsed ? t("nav:expand") : t("nav:collapse")}
@@ -551,7 +611,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
>
<span
className={`inline-flex items-center gap-2 ${
wsConnected ? "text-emerald-400" : "text-gray-500"
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
>
{wsConnected ? (
@@ -566,7 +626,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
)}
</span>
{!collapsed && (
<span className="text-[11px] font-medium text-gray-600">{`v${__APP_VERSION__}`}</span>
<span className="text-[11px] font-medium text-fg-muted">{`v${__APP_VERSION__}`}</span>
)}
</div>
</button>
@@ -579,15 +639,15 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
aria-label={checkTitle}
className={`relative w-8 h-8 mx-auto flex items-center justify-center rounded-lg border bg-surface-2 transition-colors disabled:opacity-60 ${
updateAvailable
? "border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/10"
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: checkError
? "border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
: "border-border text-gray-400 hover:text-gray-200 hover:bg-surface-3"
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{updateAvailable && !checking && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-emerald-400" />
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-status-success" />
)}
</button>
) : (
@@ -598,10 +658,10 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
title={checkTitle}
className={`w-full rounded-lg border bg-surface-2 px-2.5 py-2 text-xs transition-colors disabled:opacity-60 flex items-center justify-between gap-2 ${
updateAvailable
? "border-emerald-500/40 text-emerald-300 hover:bg-emerald-500/10"
? "border-status-success/40 text-status-success hover:bg-status-success/10"
: checkError
? "border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
: "border-border text-gray-300 hover:text-gray-100 hover:bg-surface-3"
? "border-status-warning/40 text-status-warning hover:bg-status-warning/10"
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
}`}
>
<span className="inline-flex items-center gap-2 truncate">
@@ -612,7 +672,7 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
<span className="font-medium truncate">{checkTitle}</span>
</span>
{updateAvailable && !checking && (
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
<span className="w-1.5 h-1.5 rounded-full bg-status-success flex-shrink-0" />
)}
</button>
)}
@@ -741,8 +801,8 @@ function ConnectionStatusModal({
<div
className={`w-9 h-9 rounded-lg border flex items-center justify-center flex-shrink-0 ${
wsConnected
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
: "bg-surface-3 border-border text-gray-400"
? "bg-status-success/10 border-status-success/30 text-status-success"
: "bg-surface-3 border-border text-fg-secondary"
}`}
>
{wsConnected ? (
@@ -754,19 +814,19 @@ function ConnectionStatusModal({
<div className="min-w-0">
<h2
id="connection-status-title"
className="text-base font-semibold text-gray-50 truncate tracking-tight leading-tight"
className="text-base font-semibold text-fg-primary truncate tracking-tight leading-tight"
>
{t("nav:connectionDetails")}
</h2>
<p
className={`text-[11px] font-medium inline-flex items-center gap-1.5 leading-tight ${
wsConnected ? "text-emerald-400" : "text-gray-500"
wsConnected ? "text-status-success" : "text-fg-muted"
}`}
>
{wsConnected && (
<span className="relative flex w-1.5 h-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-400" />
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-success opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-status-success" />
</span>
)}
{wsConnected ? t("nav:live") : t("nav:disconnected")}
@@ -777,7 +837,7 @@ function ConnectionStatusModal({
type="button"
onClick={close}
aria-label={t("nav:close")}
className="p-1.5 -m-1 rounded-lg text-gray-500 hover:text-gray-200 hover:bg-surface-4 transition-colors flex-shrink-0"
className="p-1.5 -m-1 rounded-lg text-fg-muted hover:text-fg-secondary hover:bg-surface-4 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
@@ -835,7 +895,7 @@ function ConnectionStatusModal({
{/* Top event types */}
<Section title={t("nav:topEventTypes")} icon={BarChart3}>
{topTypes.length === 0 ? (
<p className="text-xs text-gray-500 italic">{t("nav:noEventsYet")}</p>
<p className="text-xs text-fg-muted italic">{t("nav:noEventsYet")}</p>
) : (
<div className="space-y-1.5">
{topTypes.map(([type, count]) => (
@@ -848,7 +908,7 @@ function ConnectionStatusModal({
{/* Recent activity */}
<Section title={t("nav:recentActivity")} icon={Clock}>
{recentEvents.length === 0 ? (
<p className="text-xs text-gray-500 italic">{t("nav:noEventsYet")}</p>
<p className="text-xs text-fg-muted italic">{t("nav:noEventsYet")}</p>
) : (
<ul className="space-y-1">
{recentEvents.map((evt, i) => (
@@ -856,8 +916,8 @@ function ConnectionStatusModal({
key={`${evt.at}-${i}`}
className="flex items-center justify-between gap-3 text-[11px] font-mono px-2 py-1 rounded bg-surface-2/50"
>
<span className="text-gray-200 truncate">{evt.type}</span>
<span className="text-gray-500 flex-shrink-0">{formatRelative(evt.at, t)}</span>
<span className="text-fg-secondary truncate">{evt.type}</span>
<span className="text-fg-muted flex-shrink-0">{formatRelative(evt.at, t)}</span>
</li>
))}
</ul>
@@ -866,11 +926,11 @@ function ConnectionStatusModal({
</div>
<div className="flex items-center justify-between gap-2 px-5 py-3 border-t border-border bg-surface-2/40">
<span className="text-[10px] text-gray-500">{t("nav:statsPersisted")}</span>
<span className="text-[10px] text-fg-muted">{t("nav:statsPersisted")}</span>
<button
type="button"
onClick={onResetStats}
className="text-[11px] font-medium text-gray-400 hover:text-gray-100 hover:bg-surface-3 px-2 py-1 rounded transition-colors"
className="text-[11px] font-medium text-fg-secondary hover:text-fg-primary hover:bg-surface-3 px-2 py-1 rounded transition-colors"
>
{t("nav:resetStats")}
</button>
@@ -896,7 +956,7 @@ function Section({
<span className="w-5 h-5 rounded-md bg-accent/15 border border-accent/25 flex items-center justify-center flex-shrink-0">
<Icon className="w-3 h-3 text-accent" aria-hidden />
</span>
<h3 className="text-[13px] font-semibold text-gray-100 tracking-tight">{title}</h3>
<h3 className="text-[13px] font-semibold text-fg-primary tracking-tight">{title}</h3>
</div>
{children}
</section>
@@ -906,12 +966,12 @@ function Section({
function KpiTile({ label, value, unit }: { label: string; value: string; unit: string }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-2.5 py-2">
<div className="text-[9px] font-semibold uppercase tracking-wider text-gray-500 truncate">
<div className="text-[9px] font-semibold uppercase tracking-wider text-fg-muted truncate">
{label}
</div>
<div className="mt-0.5 flex items-baseline gap-1 truncate">
<span className="text-base font-semibold text-gray-100 font-mono">{value}</span>
<span className="text-[10px] font-medium text-gray-500 truncate">{unit}</span>
<span className="text-base font-semibold text-fg-primary font-mono">{value}</span>
<span className="text-[10px] font-medium text-fg-muted truncate">{unit}</span>
</div>
</div>
);
@@ -920,10 +980,10 @@ function KpiTile({ label, value, unit }: { label: string; value: string; unit: s
function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div className="flex items-start justify-between gap-3 text-xs">
<span className="text-gray-500 font-medium uppercase tracking-wider text-[10px] pt-0.5">
<span className="text-fg-muted font-medium uppercase tracking-wider text-[10px] pt-0.5">
{label}
</span>
<span className={`text-gray-200 text-right break-all min-w-0 ${mono ? "font-mono" : ""}`}>
<span className={`text-fg-secondary text-right break-all min-w-0 ${mono ? "font-mono" : ""}`}>
{value}
</span>
</div>
@@ -946,8 +1006,8 @@ function TypeBar({
return (
<div className="text-[11px]">
<div className="flex items-center justify-between gap-2 mb-0.5">
<span className="font-mono text-gray-200 truncate">{type}</span>
<span className="text-gray-500 flex-shrink-0 font-mono">
<span className="font-mono text-fg-secondary truncate">{type}</span>
<span className="text-fg-muted flex-shrink-0 font-mono">
{count} · {sharePct}%
</span>
</div>
@@ -1005,7 +1065,7 @@ function Sparkline({
/>
)}
</svg>
<div className="flex items-center justify-between mt-1.5 text-[10px] text-gray-500 font-mono">
<div className="flex items-center justify-between mt-1.5 text-[10px] text-fg-muted font-mono">
<span>60s</span>
<span>{avgLabel}</span>
<span>{"now"}</span>