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
+43 -40
View File
@@ -101,14 +101,17 @@ const EMPTY_FORM: RemoteSourceInput = {
/** Compact status pill for a source's last-known sync state. */
function StatusPill({ status }: { status: RemoteSource["status"] }) {
const map: Record<RemoteSource["status"], { cls: string; label: string; pulse?: boolean }> = {
idle: { cls: "text-gray-400 bg-gray-500/10 border-gray-500/20", label: "Idle" },
idle: { cls: "text-fg-secondary bg-surface-4/10 border-border-light/20", label: "Idle" },
syncing: {
cls: "text-amber-300 bg-amber-500/10 border-amber-500/25",
cls: "text-status-warning bg-status-warning/10 border-status-warning/25",
label: "Syncing",
pulse: true,
},
ok: { cls: "text-emerald-300 bg-emerald-500/10 border-emerald-500/25", label: "OK" },
error: { cls: "text-red-300 bg-red-500/10 border-red-500/25", label: "Error" },
ok: { cls: "text-status-success bg-status-success/10 border-status-success/25", label: "OK" },
error: {
cls: "text-status-danger bg-status-danger/10 border-status-danger/25",
label: "Error",
},
};
const s = map[status] || map.idle;
return (
@@ -318,17 +321,17 @@ export function RemoteSources() {
return (
<div>
<h3 className="text-sm font-medium text-gray-300 flex items-center gap-2 mb-1">
<Cloud className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Cloud className="w-4 h-4 text-fg-muted" />
{t("remoteSources.title", "Remote Data Sources")}
</h3>
<p className="text-xs text-gray-500 mb-4">
<p className="text-xs text-fg-muted mb-4">
{t(
"remoteSources.description",
"Collect Claude Code usage from other machines over SSH — e.g. a dev box or cloud VM you drive over SSH while running this dashboard locally. Authentication uses your own SSH setup (~/.ssh/config, keys, agent); no passwords are stored here."
)}
</p>
<p className="text-[11px] text-gray-600 italic mb-4 leading-snug">
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">
{t(
"cursorPathsNote",
"Informational: Cursor sessions count here too — Cursor happens to use the same ~/.claude paths as Claude Code (locally and on synced remotes)."
@@ -339,11 +342,11 @@ export function RemoteSources() {
<div className="card p-5 mb-4">
<div className="flex items-center gap-2 mb-1">
<Wifi className="w-4 h-4 text-accent" />
<span className="text-sm font-medium text-gray-200">
<span className="text-sm font-medium text-fg-secondary">
{t("remoteSources.scopeTitle", "Data scope")}
</span>
</div>
<p className="text-xs text-gray-500 mb-3">
<p className="text-xs text-fg-muted mb-3">
{t(
"remoteSources.scopeDesc",
"Choose which machines' data the whole dashboard shows. Changes apply immediately across every page — sessions, analytics, and cost."
@@ -399,13 +402,13 @@ export function RemoteSources() {
{active && (
<CheckCircle className="w-4 h-4 text-accent absolute top-2.5 right-2.5" />
)}
<Icon className={`w-5 h-5 mb-2 ${active ? "text-accent" : "text-gray-400"}`} />
<Icon className={`w-5 h-5 mb-2 ${active ? "text-accent" : "text-fg-secondary"}`} />
<div
className={`text-sm font-medium ${active ? "text-gray-100" : "text-gray-300"}`}
className={`text-sm font-medium ${active ? "text-fg-primary" : "text-fg-secondary"}`}
>
{title}
</div>
<div className="text-[11px] text-gray-500 mt-0.5 leading-snug">{desc}</div>
<div className="text-[11px] text-fg-muted mt-0.5 leading-snug">{desc}</div>
{mode === "selected" && scope.mode === "selected" && (
<div className="text-[11px] text-accent mt-1">
{t("remoteSources.scopeSelectedCount", "{{n}} of {{total}} selected", {
@@ -420,7 +423,7 @@ export function RemoteSources() {
</div>
{scope.mode === "selected" && (
<div className="mt-3 pt-3 border-t border-border">
<div className="text-[11px] uppercase tracking-wider text-gray-500 mb-2">
<div className="text-[11px] uppercase tracking-wider text-fg-muted mb-2">
{t("remoteSources.scopePickMachines", "Machines to include")}
</div>
<div className="flex flex-wrap gap-2">
@@ -434,7 +437,7 @@ export function RemoteSources() {
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border transition-colors ${
on
? "bg-accent/15 border-accent/40 text-accent"
: "bg-surface-2 border-border text-gray-400 hover:text-gray-200"
: "bg-surface-2 border-border text-fg-secondary hover:text-fg-primary"
}`}
>
{on ? <Check className="w-3 h-3" /> : <Server className="w-3 h-3" />}
@@ -449,7 +452,7 @@ export function RemoteSources() {
{/* Sources list header + add button */}
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wider">
<span className="text-xs font-medium text-fg-secondary uppercase tracking-wider">
{t("remoteSources.listTitle", "Configured sources")}
</span>
<div className="flex items-center gap-2">
@@ -473,14 +476,14 @@ export function RemoteSources() {
{/* Add/Edit form */}
{showForm && (
<div className="card p-5 mb-4 space-y-3 border-accent/30">
<div className="text-sm font-medium text-gray-200">
<div className="text-sm font-medium text-fg-secondary">
{editingId
? t("remoteSources.editTitle", "Edit source")
: t("remoteSources.addTitle", "Add a remote source")}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-500 mb-1">
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldLabel", "Label")} *
</label>
<input
@@ -491,7 +494,7 @@ export function RemoteSources() {
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldHost", "SSH host")} *
</label>
<input
@@ -502,7 +505,7 @@ export function RemoteSources() {
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldPort", "Port (optional)")}
</label>
<input
@@ -519,7 +522,7 @@ export function RemoteSources() {
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldIdentity", "Identity file (optional)")}
</label>
<input
@@ -530,7 +533,7 @@ export function RemoteSources() {
/>
</div>
<div className="sm:col-span-2">
<label className="block text-xs text-gray-500 mb-1">
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldRemoteHome", "Remote Claude home (optional)")}
</label>
<input
@@ -539,7 +542,7 @@ export function RemoteSources() {
value={form.remote_home ?? ""}
onChange={(e) => setForm((f) => ({ ...f, remote_home: e.target.value }))}
/>
<p className="mt-1 text-[11px] text-gray-500 leading-snug">
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
{t(
"remoteSources.fieldRemoteHomeHint",
"Linux/macOS: default ~/.claude (or an absolute path like /home/you/.claude). Windows SSH + Claude in WSL: leave blank (auto-detect) or use wsl:~/.claude. Native Windows: C:/Users/you/.claude."
@@ -554,12 +557,12 @@ export function RemoteSources() {
checked={!!form.enabled}
onChange={(e) => setForm((f) => ({ ...f, enabled: e.target.checked }))}
/>
<span className="text-sm text-gray-300">
<span className="text-sm text-fg-secondary">
{t("remoteSources.fieldEnabled", "Sync automatically in the background")}
</span>
</label>
{formError && (
<div className="text-xs text-red-300 bg-red-500/10 border border-red-500/25 rounded-lg px-3 py-2">
<div className="text-xs text-status-danger bg-status-danger/10 border border-status-danger/25 rounded-lg px-3 py-2">
{formError}
</div>
)}
@@ -585,14 +588,14 @@ export function RemoteSources() {
{/* Sources list */}
{loading ? (
<div className="text-xs text-gray-500">{t("common:loading", "Loading…")}</div>
<div className="text-xs text-fg-muted">{t("common:loading", "Loading…")}</div>
) : sources.length === 0 ? (
<div className="card p-6 text-center">
<Server className="w-6 h-6 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-400">
<Server className="w-6 h-6 text-fg-muted mx-auto mb-2" />
<p className="text-sm text-fg-secondary">
{t("remoteSources.empty", "No remote sources yet.")}
</p>
<p className="text-xs text-gray-600 mt-1">
<p className="text-xs text-fg-muted mt-1">
{t(
"remoteSources.emptyHint",
"Add a machine you reach over SSH to pull its Claude Code usage in."
@@ -610,16 +613,16 @@ export function RemoteSources() {
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Server className="w-4 h-4 text-accent shrink-0" />
<span className="text-sm font-medium text-gray-200">{s.label}</span>
<span className="text-sm font-medium text-fg-secondary">{s.label}</span>
<StatusPill status={s.status} />
{!s.enabled && (
<span className="text-[10px] text-gray-500 bg-surface-2 border border-border px-1.5 py-0.5 rounded-full">
<span className="text-[10px] text-fg-muted bg-surface-2 border border-border px-1.5 py-0.5 rounded-full">
{t("remoteSources.paused", "Auto-sync off")}
</span>
)}
{s.session_count != null && s.session_count > 0 && (
<span
className="text-[10px] text-gray-400 bg-surface-2 border border-border px-1.5 py-0.5 rounded-full"
className="text-[10px] text-fg-secondary bg-surface-2 border border-border px-1.5 py-0.5 rounded-full"
title={t(
"remoteSources.sessionCountHint",
"Sessions linked to this source (not every session visible when data scope is All)"
@@ -631,12 +634,12 @@ export function RemoteSources() {
</span>
)}
</div>
<div className="text-[11px] text-gray-500 font-mono mt-1 truncate">
<div className="text-[11px] text-fg-muted font-mono mt-1 truncate">
{s.host}
{s.ssh_port ? `:${s.ssh_port}` : ""}
{s.remote_home ? ` · ${s.remote_home}` : ""}
</div>
<div className="text-[11px] text-gray-600 mt-1">
<div className="text-[11px] text-fg-muted mt-1">
{s.last_sync_at
? t("remoteSources.lastSync", "Last sync: {{when}}", {
when: new Date(s.last_sync_at).toLocaleString(),
@@ -652,14 +655,14 @@ export function RemoteSources() {
})}`}
</div>
{s.status === "error" && s.last_error && (
<div className="text-[11px] text-red-300 mt-1 break-words">
<div className="text-[11px] text-status-danger mt-1 break-words">
{s.last_error}
</div>
)}
{test && test.message && (
<div
className={`flex items-start gap-1.5 text-[11px] mt-2 ${
test.ok ? "text-emerald-300" : "text-red-300"
test.ok ? "text-status-success" : "text-status-danger"
}`}
>
{test.ok ? (
@@ -712,7 +715,7 @@ export function RemoteSources() {
</button>
<button
onClick={() => setConfirmDelete({ id: s.id, purge: false })}
className="btn-ghost text-xs text-red-300 hover:text-red-200"
className="btn-ghost text-xs text-status-danger hover:text-status-danger"
title={t("common:delete", "Delete")}
>
<Trash2 className="w-3.5 h-3.5" />
@@ -723,7 +726,7 @@ export function RemoteSources() {
{/* Inline delete confirmation */}
{confirmDelete?.id === s.id && (
<div className="mt-3 pt-3 border-t border-border">
<p className="text-xs text-gray-300 mb-2">
<p className="text-xs text-fg-secondary mb-2">
{t("remoteSources.confirmDelete", "Remove this source?")}
</p>
<label className="flex items-center gap-2 mb-3 cursor-pointer">
@@ -735,7 +738,7 @@ export function RemoteSources() {
setConfirmDelete((c) => c && { ...c, purge: e.target.checked })
}
/>
<span className="text-xs text-gray-400">
<span className="text-xs text-fg-secondary">
{t(
"remoteSources.purgeData",
"Also delete the sessions imported from this source (cannot be undone)"
@@ -746,7 +749,7 @@ export function RemoteSources() {
<button
onClick={doDelete}
disabled={busy}
className="btn-primary text-xs bg-red-600 hover:bg-red-500 disabled:opacity-40"
className="btn-primary text-xs bg-status-danger hover:bg-status-danger disabled:opacity-40"
>
{busy ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />