From 3f6c4346acb5d2ddb398df068e085d7a1cab8465 Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 15 Apr 2026 20:11:51 -0700 Subject: [PATCH] feat: dashboard theme system with live switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/config.py | 5 + hermes_cli/web_server.py | 77 +++++++++ web/src/App.tsx | 2 + web/src/components/ThemeSwitcher.tsx | 115 ++++++++++++++ web/src/i18n/en.ts | 5 + web/src/i18n/types.ts | 6 + web/src/i18n/zh.ts | 5 + web/src/lib/api.ts | 17 ++ web/src/main.tsx | 5 +- web/src/themes/context.tsx | 169 ++++++++++++++++++++ web/src/themes/index.ts | 3 + web/src/themes/presets.ts | 229 +++++++++++++++++++++++++++ web/src/themes/types.ts | 44 +++++ 13 files changed, 681 insertions(+), 1 deletion(-) create mode 100644 web/src/components/ThemeSwitcher.tsx create mode 100644 web/src/themes/context.tsx create mode 100644 web/src/themes/index.ts create mode 100644 web/src/themes/presets.ts create mode 100644 web/src/themes/types.ts diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a85997f8f..eed9d5c3a 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -559,6 +559,11 @@ DEFAULT_CONFIG = { "platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}} }, + # Web dashboard settings + "dashboard": { + "theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose" + }, + # Privacy settings "privacy": { "redact_pii": False, # When True, hash user IDs and strip phone numbers from LLM context diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 22265faa5..4d39d379b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -96,6 +96,7 @@ _PUBLIC_API_PATHS: frozenset = frozenset({ "/api/config/defaults", "/api/config/schema", "/api/model/info", + "/api/dashboard/themes", }) @@ -166,6 +167,11 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "description": "CLI visual theme", "options": ["default", "ares", "mono", "slate"], }, + "dashboard.theme": { + "type": "select", + "description": "Web dashboard visual theme", + "options": ["default", "midnight", "ember", "mono", "cyberpunk", "rose"], + }, "display.resume_display": { "type": "select", "description": "How resumed sessions display history", @@ -224,6 +230,7 @@ _CATEGORY_MERGE: Dict[str, str] = { "approvals": "security", "human_delay": "display", "smart_model_routing": "agent", + "dashboard": "display", } # Display order for tabs — unlisted categories sort alphabetically after these. @@ -2068,6 +2075,76 @@ def mount_spa(application: FastAPI): return _serve_index() +# --------------------------------------------------------------------------- +# Dashboard theme endpoints +# --------------------------------------------------------------------------- + +# Built-in dashboard themes — label + description only. The actual color +# definitions live in the frontend (web/src/themes/presets.ts). +_BUILTIN_DASHBOARD_THEMES = [ + {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, + {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, + {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, + {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, + {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, + {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, +] + + +def _discover_user_themes() -> list: + """Scan ~/.hermes/dashboard-themes/*.yaml for user-created themes.""" + themes_dir = get_hermes_home() / "dashboard-themes" + if not themes_dir.is_dir(): + return [] + result = [] + for f in sorted(themes_dir.glob("*.yaml")): + try: + data = yaml.safe_load(f.read_text(encoding="utf-8")) + if isinstance(data, dict) and data.get("name"): + result.append({ + "name": data["name"], + "label": data.get("label", data["name"]), + "description": data.get("description", ""), + }) + except Exception: + continue + return result + + +@app.get("/api/dashboard/themes") +async def get_dashboard_themes(): + """Return available themes and the currently active one.""" + config = load_config() + active = config.get("dashboard", {}).get("theme", "default") + user_themes = _discover_user_themes() + # Merge built-in + user, user themes override built-in by name. + seen = set() + themes = [] + for t in _BUILTIN_DASHBOARD_THEMES: + seen.add(t["name"]) + themes.append(t) + for t in user_themes: + if t["name"] not in seen: + themes.append(t) + seen.add(t["name"]) + return {"themes": themes, "active": active} + + +class ThemeSetBody(BaseModel): + name: str + + +@app.put("/api/dashboard/theme") +async def set_dashboard_theme(body: ThemeSetBody): + """Set the active dashboard theme (persists to config.yaml).""" + config = load_config() + if "dashboard" not in config: + config["dashboard"] = {} + config["dashboard"]["theme"] = body.name + save_config(config) + return {"ok": True, "theme": body.name} + + mount_spa(app) diff --git a/web/src/App.tsx b/web/src/App.tsx index 4bbc13fac..dfadf1067 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() {
+ {t.app.webUi} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx new file mode 100644 index 000000000..03801bebf --- /dev/null +++ b/web/src/components/ThemeSwitcher.tsx @@ -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(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 ( +
+ + + {open && ( +
+
+ + {t.theme?.title ?? "Theme"} + +
+ + {availableThemes.map((theme) => { + const isActive = theme.name === themeName; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 3bf693f21..07e931995 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -275,4 +275,9 @@ export const en: Translations = { language: { switchTo: "Switch to Chinese", }, + + theme: { + title: "Theme", + switchTheme: "Switch theme", + }, }; diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 34813c68f..55f5cffc4 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -287,4 +287,10 @@ export interface Translations { language: { switchTo: string; }; + + // ── Theme switcher ── + theme: { + title: string; + switchTheme: string; + }; } diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 18cb3ee38..869ec9ed9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -275,4 +275,9 @@ export const zh: Translations = { language: { switchTo: "切换到英文", }, + + theme: { + title: "主题", + switchTheme: "切换主题", + }, }; diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e61043993..9121b83cd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -182,6 +182,16 @@ export const api = { }, ); }, + + // Dashboard themes + getThemes: () => + fetchJSON("/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; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 3b77464d5..f04290ada 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( - + + + , ); diff --git a/web/src/themes/context.tsx b/web/src/themes/context.tsx new file mode 100644 index 000000000..cdceb1532 --- /dev/null +++ b/web/src/themes/context.tsx @@ -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(".noise-overlay"); + const glowEl = document.querySelector(".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(".noise-overlay"); + const glowEl = document.querySelector(".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({ + 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 ( + + {children} + + ); +} + +// --------------------------------------------------------------------------- +// Hook +// --------------------------------------------------------------------------- + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/web/src/themes/index.ts b/web/src/themes/index.ts new file mode 100644 index 000000000..2c3509e8e --- /dev/null +++ b/web/src/themes/index.ts @@ -0,0 +1,3 @@ +export { ThemeProvider, useTheme } from "./context"; +export { BUILTIN_THEMES } from "./presets"; +export type { DashboardTheme, ThemeColors, ThemeOverlay, ThemeListResponse } from "./types"; diff --git a/web/src/themes/presets.ts b/web/src/themes/presets.ts new file mode 100644 index 000000000..65fcd4655 --- /dev/null +++ b/web/src/themes/presets.ts @@ -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 = { + default: defaultTheme, + midnight: midnightTheme, + ember: emberTheme, + mono: monoTheme, + cyberpunk: cyberpunkTheme, + rose: roseTheme, +}; diff --git a/web/src/themes/types.ts b/web/src/themes/types.ts new file mode 100644 index 000000000..b6cd371a5 --- /dev/null +++ b/web/src/themes/types.ts @@ -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; +}