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

View file

@ -9,6 +9,7 @@ import AnalyticsPage from "@/pages/AnalyticsPage";
import CronPage from "@/pages/CronPage";
import SkillsPage from "@/pages/SkillsPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n";
const NAV_ITEMS = [
@ -67,6 +68,7 @@ export default function App() {
</nav>
<div className="ml-auto flex items-center gap-2 px-2 sm:px-4">
<ThemeSwitcher />
<LanguageSwitcher />
<span className="hidden sm:inline font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
{t.app.webUi}

View file

@ -0,0 +1,115 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { Palette, Check } from "lucide-react";
import { useTheme } from "@/themes";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
/**
* Compact theme picker for the dashboard header.
* Shows a palette icon + current theme name; opens a dropdown of all
* available themes with color swatches for instant preview.
*/
export function ThemeSwitcher() {
const { themeName, availableThemes, setTheme } = useTheme();
const { t } = useI18n();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const close = useCallback(() => setOpen(false), []);
// Close on outside click.
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) close();
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open, close]);
// Close on Escape.
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open, close]);
const current = availableThemes.find((t) => t.name === themeName);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className={cn(
"group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs",
"text-muted-foreground hover:text-foreground transition-colors",
"cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
)}
title={t.theme?.switchTheme ?? "Switch theme"}
aria-label={t.theme?.switchTheme ?? "Switch theme"}
aria-expanded={open}
aria-haspopup="listbox"
>
<Palette className="h-3.5 w-3.5" />
<span className="hidden sm:inline font-display tracking-wide uppercase text-[0.65rem]">
{current?.label ?? themeName}
</span>
</button>
{open && (
<div
role="listbox"
className={cn(
"absolute right-0 top-full mt-1 z-50 min-w-[200px]",
"border border-border bg-popover text-popover-foreground shadow-lg",
"animate-[fade-in_100ms_ease-out]",
)}
>
<div className="px-3 py-2 border-b border-border">
<span className="font-display text-[0.7rem] tracking-[0.12em] uppercase text-muted-foreground">
{t.theme?.title ?? "Theme"}
</span>
</div>
{availableThemes.map((theme) => {
const isActive = theme.name === themeName;
return (
<button
key={theme.name}
type="button"
role="option"
aria-selected={isActive}
onClick={() => {
setTheme(theme.name);
close();
}}
className={cn(
"flex w-full items-center gap-2.5 px-3 py-2 text-left text-sm transition-colors cursor-pointer",
"hover:bg-foreground/10",
isActive ? "text-foreground" : "text-muted-foreground",
)}
>
<Check
className={cn(
"h-3 w-3 shrink-0",
isActive ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="font-medium text-xs truncate">{theme.label}</span>
<span className="text-[0.65rem] text-muted-foreground truncate">
{theme.description}
</span>
</div>
</button>
);
})}
</div>
)}
</div>
);
}

View file

@ -275,4 +275,9 @@ export const en: Translations = {
language: {
switchTo: "Switch to Chinese",
},
theme: {
title: "Theme",
switchTheme: "Switch theme",
},
};

View file

@ -287,4 +287,10 @@ export interface Translations {
language: {
switchTo: string;
};
// ── Theme switcher ──
theme: {
title: string;
switchTheme: string;
};
}

View file

@ -275,4 +275,9 @@ export const zh: Translations = {
language: {
switchTo: "切换到英文",
},
theme: {
title: "主题",
switchTheme: "切换主题",
},
};

View file

@ -182,6 +182,16 @@ export const api = {
},
);
},
// Dashboard themes
getThemes: () =>
fetchJSON<ThemeListResponse>("/api/dashboard/themes"),
setTheme: (name: string) =>
fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
};
export interface PlatformStatus {
@ -415,3 +425,10 @@ export interface OAuthPollResponse {
error_message?: string | null;
expires_at?: number | null;
}
// ── Dashboard theme types ──────────────────────────────────────────────
export interface ThemeListResponse {
themes: Array<{ name: string; label: string; description: string }>;
active: string;
}

View file

@ -3,11 +3,14 @@ import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
import { I18nProvider } from "./i18n";
import { ThemeProvider } from "./themes";
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<I18nProvider>
<App />
<ThemeProvider>
<App />
</ThemeProvider>
</I18nProvider>
</BrowserRouter>,
);

169
web/src/themes/context.tsx Normal file
View file

@ -0,0 +1,169 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
import type { DashboardTheme, ThemeColors, ThemeOverlay } from "./types";
import { BUILTIN_THEMES, defaultTheme } from "./presets";
import { api } from "@/lib/api";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Apply a theme's color overrides to `document.documentElement`. */
function applyColors(colors: ThemeColors) {
const root = document.documentElement;
for (const [key, value] of Object.entries(colors)) {
root.style.setProperty(`--color-${key}`, value);
}
}
/** Apply overlay overrides (noise + warm-glow). */
function applyOverlay(overlay: ThemeOverlay | undefined) {
const noiseEl = document.querySelector<HTMLElement>(".noise-overlay");
const glowEl = document.querySelector<HTMLElement>(".warm-glow");
if (noiseEl) {
noiseEl.style.opacity = String(overlay?.noiseOpacity ?? 0.10);
noiseEl.style.mixBlendMode = overlay?.noiseBlendMode ?? "color-dodge";
}
if (glowEl) {
glowEl.style.opacity = String(overlay?.warmGlowOpacity ?? 0.22);
if (overlay?.warmGlowColor) {
glowEl.style.background = `radial-gradient(ellipse at 0% 0%, ${overlay.warmGlowColor} 0%, rgba(0,0,0,0) 60%)`;
}
}
}
/** Remove all inline overrides — reverts to stylesheet defaults. */
function clearOverrides() {
const root = document.documentElement;
// Clear color overrides
for (const key of Object.keys(defaultTheme.colors)) {
root.style.removeProperty(`--color-${key}`);
}
// Clear overlay overrides
const noiseEl = document.querySelector<HTMLElement>(".noise-overlay");
const glowEl = document.querySelector<HTMLElement>(".warm-glow");
if (noiseEl) {
noiseEl.style.opacity = "";
noiseEl.style.mixBlendMode = "";
}
if (glowEl) {
glowEl.style.opacity = "";
glowEl.style.background = "";
}
}
function applyTheme(theme: DashboardTheme) {
if (theme.name === "default") {
clearOverrides();
} else {
applyColors(theme.colors);
applyOverlay(theme.overlay);
}
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
interface ThemeContextValue {
/** Currently active theme name. */
themeName: string;
/** Currently active theme object. */
theme: DashboardTheme;
/** Available theme names (built-in + any server-provided custom themes). */
availableThemes: Array<{ name: string; label: string; description: string }>;
/** Switch theme — applies CSS immediately and persists to config.yaml. */
setTheme: (name: string) => void;
/** True while initial theme is loading from server. */
loading: boolean;
}
const ThemeContext = createContext<ThemeContextValue>({
themeName: "default",
theme: defaultTheme,
availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name,
label: t.label,
description: t.description,
})),
setTheme: () => {},
loading: true,
});
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeName, setThemeName] = useState("default");
const [availableThemes, setAvailableThemes] = useState(
Object.values(BUILTIN_THEMES).map((t) => ({
name: t.name,
label: t.label,
description: t.description,
})),
);
const [loading, setLoading] = useState(true);
// Fetch active theme + available list from server on mount.
useEffect(() => {
api
.getThemes()
.then((resp) => {
if (resp.themes?.length) {
setAvailableThemes(resp.themes);
}
if (resp.active && resp.active !== "default") {
setThemeName(resp.active);
const t = BUILTIN_THEMES[resp.active];
if (t) applyTheme(t);
}
})
.catch(() => {
// Server might not support theme API yet — stay on default.
})
.finally(() => setLoading(false));
}, []);
const resolvedTheme = BUILTIN_THEMES[themeName] ?? defaultTheme;
const setTheme = useCallback(
(name: string) => {
const t = BUILTIN_THEMES[name] ?? defaultTheme;
setThemeName(t.name);
applyTheme(t);
// Persist to config.yaml — fire and forget.
api.setTheme(t.name).catch(() => {});
},
[],
);
return (
<ThemeContext.Provider
value={{
themeName,
theme: resolvedTheme,
availableThemes,
setTheme,
loading,
}}
>
{children}
</ThemeContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useTheme() {
return useContext(ThemeContext);
}

3
web/src/themes/index.ts Normal file
View file

@ -0,0 +1,3 @@
export { ThemeProvider, useTheme } from "./context";
export { BUILTIN_THEMES } from "./presets";
export type { DashboardTheme, ThemeColors, ThemeOverlay, ThemeListResponse } from "./types";

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,
};

44
web/src/themes/types.ts Normal file
View file

@ -0,0 +1,44 @@
/** Dashboard theme definition. Maps 1:1 to CSS custom properties in index.css. */
export interface ThemeColors {
background: string;
foreground: string;
card: string;
"card-foreground": string;
primary: string;
"primary-foreground": string;
secondary: string;
"secondary-foreground": string;
muted: string;
"muted-foreground": string;
accent: string;
"accent-foreground": string;
destructive: string;
"destructive-foreground": string;
success: string;
warning: string;
border: string;
input: string;
ring: string;
popover: string;
"popover-foreground": string;
}
export interface ThemeOverlay {
noiseOpacity?: number;
noiseBlendMode?: string;
warmGlowOpacity?: number;
warmGlowColor?: string;
}
export interface DashboardTheme {
name: string;
label: string;
description: string;
colors: ThemeColors;
overlay?: ThemeOverlay;
}
export interface ThemeListResponse {
themes: Array<{ name: string; label: string; description: string }>;
active: string;
}