feat: dashboard theme system with live switching

Add a theme engine for the web dashboard that mirrors the CLI skin
engine philosophy — pure data, no code changes needed for new themes.

Frontend:
- ThemeProvider context that loads active theme from backend on mount
  and applies CSS variable overrides to document.documentElement
- ThemeSwitcher dropdown component in the header (next to language
  switcher) with instant preview on click
- 6 built-in themes: Hermes Teal (default), Midnight, Ember, Mono,
  Cyberpunk, Rosé — each defines all 21 color tokens + overlay settings
- Theme types, presets, and context in web/src/themes/

Backend:
- GET /api/dashboard/themes — returns available themes + active name
- PUT /api/dashboard/theme — persists selection to config.yaml
- User custom themes discoverable from ~/.hermes/dashboard-themes/*.yaml
- Theme list endpoint added to public API paths (no auth needed)

Config:
- dashboard.theme key in DEFAULT_CONFIG (default: 'default')
- Schema override for select dropdown in config page
- Category merged into 'display' tab in config UI

i18n: theme switcher strings added for en + zh.
This commit is contained in:
Teknium 2026-04-15 20:11:51 -07:00 committed by Teknium
parent 9a9b8cd1e4
commit 3f6c4346ac
13 changed files with 681 additions and 1 deletions

229
web/src/themes/presets.ts Normal file
View file

@ -0,0 +1,229 @@
import type { DashboardTheme } from "./types";
/**
* Built-in dashboard themes.
*
* The "default" theme matches the current index.css @theme values exactly,
* so applying it is a no-op (CSS vars stay at their stylesheet defaults).
* Other themes override only what they change.
*/
export const defaultTheme: DashboardTheme = {
name: "default",
label: "Hermes Teal",
description: "Classic dark teal — the canonical Hermes look",
colors: {
background: "#041C1C",
foreground: "#ffe6cb",
card: "#062424",
"card-foreground": "#ffe6cb",
primary: "#ffe6cb",
"primary-foreground": "#041C1C",
secondary: "#0a2e2e",
"secondary-foreground": "#ffe6cb",
muted: "#083030",
"muted-foreground": "#8aaa9a",
accent: "#0c3838",
"accent-foreground": "#ffe6cb",
destructive: "#fb2c36",
"destructive-foreground": "#fff",
success: "#4ade80",
warning: "#ffbd38",
border: "color-mix(in srgb, #ffe6cb 15%, transparent)",
input: "color-mix(in srgb, #ffe6cb 15%, transparent)",
ring: "#ffe6cb",
popover: "#062424",
"popover-foreground": "#ffe6cb",
},
overlay: {
noiseOpacity: 0.10,
noiseBlendMode: "color-dodge",
warmGlowOpacity: 0.22,
warmGlowColor: "rgba(255,189,56,0.35)",
},
};
export const midnightTheme: DashboardTheme = {
name: "midnight",
label: "Midnight",
description: "Deep blue-violet with cool accents",
colors: {
background: "#0a0a1a",
foreground: "#e0e0f0",
card: "#10102a",
"card-foreground": "#e0e0f0",
primary: "#a78bfa",
"primary-foreground": "#0a0a1a",
secondary: "#151530",
"secondary-foreground": "#e0e0f0",
muted: "#1a1a3a",
"muted-foreground": "#8888bb",
accent: "#1e1e44",
"accent-foreground": "#e0e0f0",
destructive: "#f43f5e",
"destructive-foreground": "#fff",
success: "#34d399",
warning: "#fbbf24",
border: "color-mix(in srgb, #a78bfa 15%, transparent)",
input: "color-mix(in srgb, #a78bfa 15%, transparent)",
ring: "#a78bfa",
popover: "#10102a",
"popover-foreground": "#e0e0f0",
},
overlay: {
noiseOpacity: 0.08,
noiseBlendMode: "color-dodge",
warmGlowOpacity: 0.15,
warmGlowColor: "rgba(120,80,220,0.3)",
},
};
export const emberTheme: DashboardTheme = {
name: "ember",
label: "Ember",
description: "Warm crimson and bronze — forge vibes",
colors: {
background: "#1a0a0a",
foreground: "#fde8d0",
card: "#241010",
"card-foreground": "#fde8d0",
primary: "#f97316",
"primary-foreground": "#1a0a0a",
secondary: "#2a1515",
"secondary-foreground": "#fde8d0",
muted: "#301818",
"muted-foreground": "#b08878",
accent: "#381e1e",
"accent-foreground": "#fde8d0",
destructive: "#ef4444",
"destructive-foreground": "#fff",
success: "#4ade80",
warning: "#fbbf24",
border: "color-mix(in srgb, #f97316 15%, transparent)",
input: "color-mix(in srgb, #f97316 15%, transparent)",
ring: "#f97316",
popover: "#241010",
"popover-foreground": "#fde8d0",
},
overlay: {
noiseOpacity: 0.10,
noiseBlendMode: "color-dodge",
warmGlowOpacity: 0.25,
warmGlowColor: "rgba(249,115,22,0.3)",
},
};
export const monoTheme: DashboardTheme = {
name: "mono",
label: "Mono",
description: "Clean grayscale — minimal and focused",
colors: {
background: "#111111",
foreground: "#e0e0e0",
card: "#1a1a1a",
"card-foreground": "#e0e0e0",
primary: "#e0e0e0",
"primary-foreground": "#111111",
secondary: "#1e1e1e",
"secondary-foreground": "#e0e0e0",
muted: "#222222",
"muted-foreground": "#888888",
accent: "#2a2a2a",
"accent-foreground": "#e0e0e0",
destructive: "#ef4444",
"destructive-foreground": "#fff",
success: "#4ade80",
warning: "#fbbf24",
border: "color-mix(in srgb, #e0e0e0 12%, transparent)",
input: "color-mix(in srgb, #e0e0e0 12%, transparent)",
ring: "#e0e0e0",
popover: "#1a1a1a",
"popover-foreground": "#e0e0e0",
},
overlay: {
noiseOpacity: 0.06,
noiseBlendMode: "color-dodge",
warmGlowOpacity: 0.0,
warmGlowColor: "rgba(255,255,255,0)",
},
};
export const cyberpunkTheme: DashboardTheme = {
name: "cyberpunk",
label: "Cyberpunk",
description: "Neon green on black — matrix terminal",
colors: {
background: "#050505",
foreground: "#00ff88",
card: "#0a0a0a",
"card-foreground": "#00ff88",
primary: "#00ff88",
"primary-foreground": "#050505",
secondary: "#0e0e0e",
"secondary-foreground": "#00ff88",
muted: "#121212",
"muted-foreground": "#00aa55",
accent: "#161616",
"accent-foreground": "#00ff88",
destructive: "#ff0055",
"destructive-foreground": "#fff",
success: "#00ff88",
warning: "#ffff00",
border: "color-mix(in srgb, #00ff88 12%, transparent)",
input: "color-mix(in srgb, #00ff88 12%, transparent)",
ring: "#00ff88",
popover: "#0a0a0a",
"popover-foreground": "#00ff88",
},
overlay: {
noiseOpacity: 0.12,
noiseBlendMode: "color-dodge",
warmGlowOpacity: 0.10,
warmGlowColor: "rgba(0,255,136,0.15)",
},
};
export const roseTheme: DashboardTheme = {
name: "rose",
label: "Rosé",
description: "Soft pink and warm ivory — easy on the eyes",
colors: {
background: "#1a1015",
foreground: "#f5e6e0",
card: "#221820",
"card-foreground": "#f5e6e0",
primary: "#f9a8d4",
"primary-foreground": "#1a1015",
secondary: "#281e28",
"secondary-foreground": "#f5e6e0",
muted: "#2e2230",
"muted-foreground": "#b08898",
accent: "#352838",
"accent-foreground": "#f5e6e0",
destructive: "#fb2c36",
"destructive-foreground": "#fff",
success: "#4ade80",
warning: "#fbbf24",
border: "color-mix(in srgb, #f9a8d4 14%, transparent)",
input: "color-mix(in srgb, #f9a8d4 14%, transparent)",
ring: "#f9a8d4",
popover: "#221820",
"popover-foreground": "#f5e6e0",
},
overlay: {
noiseOpacity: 0.08,
noiseBlendMode: "color-dodge",
warmGlowOpacity: 0.18,
warmGlowColor: "rgba(249,168,212,0.2)",
},
};
/** All built-in themes, keyed by name. */
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
default: defaultTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
cyberpunk: cyberpunkTheme,
rose: roseTheme,
};