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:
@@ -83,15 +83,15 @@ function statusColor(status: string): string {
|
||||
case "completed":
|
||||
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
|
||||
case "working":
|
||||
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
|
||||
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||
case "error":
|
||||
return "text-red-400 bg-red-500/10 border-red-500/20";
|
||||
return "text-status-danger bg-status-danger/10 border-status-danger/20";
|
||||
case "active":
|
||||
return "text-emerald-400 bg-emerald-500/10 border-emerald-500/20";
|
||||
return "text-status-success bg-status-success/10 border-status-success/20";
|
||||
case "waiting":
|
||||
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
|
||||
default:
|
||||
return "text-gray-400 bg-gray-500/10 border-gray-500/20";
|
||||
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,8 +140,8 @@ function TabBar({ active, onChange }: TabBarProps) {
|
||||
className={[
|
||||
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors duration-150",
|
||||
active === tab.id
|
||||
? "bg-surface-5 text-gray-100 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-300",
|
||||
? "bg-surface-5 text-fg-primary shadow-sm"
|
||||
: "text-fg-muted hover:text-fg-secondary",
|
||||
].join(" ")}
|
||||
>
|
||||
{tab.icon}
|
||||
@@ -192,20 +192,20 @@ function TreeNode({ node, depth }: TreeNodeProps) {
|
||||
|
||||
{/* Name */}
|
||||
<span
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-gray-200"}`}
|
||||
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
|
||||
>
|
||||
{node.name}
|
||||
</span>
|
||||
|
||||
{/* Subagent type */}
|
||||
{node.subagent_type && (
|
||||
<span className="text-xs text-gray-500 truncate flex-shrink-0">
|
||||
<span className="text-xs text-fg-muted truncate flex-shrink-0">
|
||||
[{node.subagent_type}]
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
<span className="ml-auto flex-shrink-0 text-xs text-gray-600 tabular-nums">{dur}</span>
|
||||
<span className="ml-auto flex-shrink-0 text-xs text-fg-muted tabular-nums">{dur}</span>
|
||||
</div>
|
||||
|
||||
{node.children.length > 0 && (
|
||||
@@ -226,7 +226,7 @@ interface AgentTreeProps {
|
||||
function AgentTree({ tree }: AgentTreeProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
if (tree.length === 0) {
|
||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noAgentTree")}</p>;
|
||||
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noAgentTree")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -249,7 +249,7 @@ interface ToolTimelineProps {
|
||||
function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
if (events.length === 0) {
|
||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noToolEvents")}</p>;
|
||||
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noToolEvents")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -267,11 +267,13 @@ function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
|
||||
{/* Summary */}
|
||||
{ev.summary && (
|
||||
<span className="text-xs text-gray-400 truncate flex-1 min-w-0">{ev.summary}</span>
|
||||
<span className="text-xs text-fg-secondary truncate flex-1 min-w-0">
|
||||
{ev.summary}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums ml-auto">
|
||||
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums ml-auto">
|
||||
{safeTimestamp(ev.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -288,22 +290,22 @@ interface EventSequenceProps {
|
||||
}
|
||||
|
||||
const EVENT_TYPE_COLOR: Record<string, string> = {
|
||||
tool_use: "text-blue-400",
|
||||
tool_result: "text-emerald-400",
|
||||
tool_use: "text-blue-500",
|
||||
tool_result: "text-status-success",
|
||||
agent_start: "text-indigo-400",
|
||||
agent_stop: "text-violet-400",
|
||||
compaction: "text-amber-400",
|
||||
error: "text-red-400",
|
||||
compaction: "text-status-warning",
|
||||
error: "text-status-danger",
|
||||
};
|
||||
|
||||
function eventTypeColor(type: string): string {
|
||||
return EVENT_TYPE_COLOR[type] ?? "text-gray-400";
|
||||
return EVENT_TYPE_COLOR[type] ?? "text-fg-secondary";
|
||||
}
|
||||
|
||||
function EventSequence({ events }: EventSequenceProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
if (events.length === 0) {
|
||||
return <p className="text-sm text-gray-500 text-center py-8">{t("drillIn.noEvents")}</p>;
|
||||
return <p className="text-sm text-fg-muted text-center py-8">{t("drillIn.noEvents")}</p>;
|
||||
}
|
||||
|
||||
const recent = events.slice(0, 100);
|
||||
@@ -325,18 +327,18 @@ function EventSequence({ events }: EventSequenceProps) {
|
||||
</span>
|
||||
|
||||
{/* Summary */}
|
||||
<span className="text-xs text-gray-400 flex-1 min-w-0 truncate">
|
||||
<span className="text-xs text-fg-secondary flex-1 min-w-0 truncate">
|
||||
{ev.summary ?? ev.tool_name ?? "-"}
|
||||
</span>
|
||||
|
||||
{/* Timestamp */}
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{safeTimestamp(ev.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{events.length > 100 && (
|
||||
<p className="text-xs text-gray-600 text-center py-2">
|
||||
<p className="text-xs text-fg-muted text-center py-2">
|
||||
{t("drillIn.showingOf", { total: events.length })}
|
||||
</p>
|
||||
)}
|
||||
@@ -365,11 +367,11 @@ function ErrorState({ message }: ErrorStateProps) {
|
||||
const { t } = useTranslation("workflows");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 text-center px-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-red-500/10 border border-red-500/20 flex items-center justify-center mb-3">
|
||||
<X className="w-4 h-4 text-red-400" />
|
||||
<div className="w-9 h-9 rounded-xl bg-status-danger/10 border border-status-danger/20 flex items-center justify-center mb-3">
|
||||
<X className="w-4 h-4 text-status-danger" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-red-400">{t("drillIn.failedLoad")}</p>
|
||||
<p className="text-xs text-gray-600 mt-1 max-w-xs">{message}</p>
|
||||
<p className="text-sm font-medium text-status-danger">{t("drillIn.failedLoad")}</p>
|
||||
<p className="text-xs text-fg-muted mt-1 max-w-xs">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -405,17 +407,19 @@ function NoSessionState({ onSelectSession }: NoSessionStateProps) {
|
||||
|
||||
<div className="flex flex-col items-center text-center mt-2">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-4 flex items-center justify-center mb-4">
|
||||
<GitFork className="w-5 h-5 text-gray-600" />
|
||||
<GitFork className="w-5 h-5 text-fg-muted" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-400 mb-1">{t("drillIn.noSessionSelected")}</p>
|
||||
<p className="text-xs text-gray-600 max-w-xs">{t("drillIn.noSessionDesc")}</p>
|
||||
<p className="text-sm font-medium text-fg-secondary mb-1">
|
||||
{t("drillIn.noSessionSelected")}
|
||||
</p>
|
||||
<p className="text-xs text-fg-muted max-w-xs">{t("drillIn.noSessionDesc")}</p>
|
||||
|
||||
{/* Preview tab pills */}
|
||||
<div className="flex gap-2 mt-5">
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-gray-600 bg-surface-3 border border-border"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium text-fg-muted bg-surface-3 border border-border"
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
@@ -444,10 +448,10 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-gray-100 truncate">
|
||||
<p className="text-sm font-semibold text-fg-primary truncate">
|
||||
{session.name ?? session.id}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-0.5 truncate">
|
||||
<p className="text-xs text-fg-muted mt-0.5 truncate">
|
||||
{formatModelName(session.model) ?? t("drillIn.unknownModel")} ·{" "}
|
||||
{t(`common:status.${session.status}`, { defaultValue: session.status })}
|
||||
{session.started_at && ` \u00b7 ${safeTimestamp(session.started_at)}`}
|
||||
@@ -456,7 +460,7 @@ function SessionHeader({ drillIn, onClose, activeTab, onTabChange }: SessionHead
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-gray-500 hover:text-gray-200 hover:bg-white/10 transition-colors"
|
||||
className="flex-shrink-0 w-7 h-7 rounded-md flex items-center justify-center text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||
aria-label={t("drillIn.closePanel")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
@@ -569,13 +573,13 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5 text-gray-500 flex-shrink-0" />
|
||||
<Search className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
placeholder={t("drillIn.searchPlaceholder")}
|
||||
className="flex-1 bg-transparent text-xs text-gray-200 placeholder-gray-600 outline-none min-w-0"
|
||||
className="flex-1 bg-transparent text-xs text-fg-secondary placeholder-fg-muted outline-none min-w-0"
|
||||
onFocus={() => setOpen(true)}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
@@ -584,7 +588,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
/>
|
||||
<ChevronDown
|
||||
className={[
|
||||
"w-3.5 h-3.5 text-gray-600 flex-shrink-0 transition-transform duration-150",
|
||||
"w-3.5 h-3.5 text-fg-muted flex-shrink-0 transition-transform duration-150",
|
||||
open ? "rotate-180" : "",
|
||||
].join(" ")}
|
||||
/>
|
||||
@@ -605,7 +609,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="text-xs text-gray-600 text-center py-6 px-3">
|
||||
<p className="text-xs text-fg-muted text-center py-6 px-3">
|
||||
{search.trim() ? t("drillIn.noMatch") : t("drillIn.notFound")}
|
||||
</p>
|
||||
) : (
|
||||
@@ -625,22 +629,22 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
{s.status}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-xs font-medium text-gray-200 truncate">
|
||||
<span className="block text-xs font-medium text-fg-secondary truncate">
|
||||
{s.name ?? s.id}
|
||||
</span>
|
||||
{s.name && (
|
||||
<span className="block text-[10px] text-gray-600 font-mono truncate">
|
||||
<span className="block text-[10px] text-fg-muted font-mono truncate">
|
||||
{s.id}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{s.model && (
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-500 truncate max-w-[80px]">
|
||||
<span className="flex-shrink-0 text-[10px] text-fg-muted truncate max-w-[80px]">
|
||||
{formatModelName(s.model)}
|
||||
</span>
|
||||
)}
|
||||
{s.started_at && (
|
||||
<span className="flex-shrink-0 text-[10px] text-gray-600 tabular-nums">
|
||||
<span className="flex-shrink-0 text-[10px] text-fg-muted tabular-nums">
|
||||
{safeTimestamp(s.started_at)}
|
||||
</span>
|
||||
)}
|
||||
@@ -656,7 +660,7 @@ function SessionSelector({ onSelectSession }: SessionSelectorProps) {
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 text-xs text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
className="w-full px-3 py-2 text-xs text-fg-muted hover:text-fg-secondary hover:bg-white/5 transition-colors border-t border-border/50 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t("drillIn.loading") : t("drillIn.loadMore")}
|
||||
</button>
|
||||
@@ -737,11 +741,11 @@ export function SessionDrillIn({ sessionId, onClose, onSelectSession }: SessionD
|
||||
<div className="bg-surface-2 border border-border rounded-xl p-4">
|
||||
<SessionSelector onSelectSession={onSelectSession} />
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-gray-600 font-mono truncate">{sessionId}</p>
|
||||
<p className="text-xs text-fg-muted font-mono truncate">{sessionId}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-600 hover:text-gray-300 hover:bg-white/10 transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-fg-muted hover:text-fg-secondary hover:bg-white/10 transition-colors"
|
||||
aria-label={t("drillIn.close")}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
|
||||
Reference in New Issue
Block a user