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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user