mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 01:31:41 +00:00
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:
parent
9a9b8cd1e4
commit
3f6c4346ac
13 changed files with 681 additions and 1 deletions
169
web/src/themes/context.tsx
Normal file
169
web/src/themes/context.tsx
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue