mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
115
web/src/components/ThemeSwitcher.tsx
Normal file
115
web/src/components/ThemeSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -275,4 +275,9 @@ export const en: Translations = {
|
|||
language: {
|
||||
switchTo: "Switch to Chinese",
|
||||
},
|
||||
|
||||
theme: {
|
||||
title: "Theme",
|
||||
switchTheme: "Switch theme",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -287,4 +287,10 @@ export interface Translations {
|
|||
language: {
|
||||
switchTo: string;
|
||||
};
|
||||
|
||||
// ── Theme switcher ──
|
||||
theme: {
|
||||
title: string;
|
||||
switchTheme: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -275,4 +275,9 @@ export const zh: Translations = {
|
|||
language: {
|
||||
switchTo: "切换到英文",
|
||||
},
|
||||
|
||||
theme: {
|
||||
title: "主题",
|
||||
switchTheme: "切换主题",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
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);
|
||||
}
|
||||
3
web/src/themes/index.ts
Normal file
3
web/src/themes/index.ts
Normal 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
229
web/src/themes/presets.ts
Normal 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
44
web/src/themes/types.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue