From 255ba5bf26a10911925c5cd01d14b0cd9adb639c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:49:51 -0700 Subject: [PATCH] feat(dashboard): expand themes to fonts, layout, density (#14725) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard themes now control typography and layout, not just colors. Each built-in theme picks its own fonts, base size, radius, and density so switching produces visible changes beyond hue. Schema additions (per theme): - typography — fontSans, fontMono, fontDisplay, fontUrl, baseSize, lineHeight, letterSpacing. fontUrl is injected as on switch so Google/Bunny/self-hosted stylesheets all work. - layout — radius (any CSS length) and density (compact | comfortable | spacious, multiplies Tailwind spacing). - colorOverrides (optional) — pin individual shadcn tokens that would otherwise derive from the palette. Built-in themes are now distinct beyond palette: - default — system stack, 15px, 0.5rem radius, comfortable - midnight — Inter + JetBrains Mono, 14px, 0.75rem, comfortable - ember — Spectral (serif) + IBM Plex Mono, 15px, 0.25rem - mono — IBM Plex Sans + Mono, 13px, 0 radius, compact - cyberpunk— Share Tech Mono everywhere, 14px, 0 radius, compact - rose — Fraunces (serif) + DM Mono, 16px, 1rem, spacious Also fixes two bugs: 1. Custom user themes silently fell back to default. ThemeProvider only applied BUILTIN_THEMES[name], so YAML files in ~/.hermes/dashboard-themes/ showed in the picker but did nothing. Server now ships the full normalised definition; client applies it. 2. Docs documented a 21-token flat colors schema that never matched the code (applyPalette reads a 3-layer palette). Rewrote the Themes section against the actual shape. Implementation: - web/src/themes/types.ts: extend DashboardTheme with typography, layout, colorOverrides; ThemeListEntry carries optional definition. - web/src/themes/presets.ts: 6 built-ins with distinct typography+layout. - web/src/themes/context.tsx: applyTheme() writes palette+typography+ layout+overrides as CSS vars, injects fontUrl stylesheet, fixes the fallback-to-default bug via resolveTheme(name). - web/src/index.css: html/body/code read the new theme-font vars; --radius-sm/md/lg/xl derive from --theme-radius; --spacing scales with --theme-spacing-mul so Tailwind utilities shift with density. - hermes_cli/web_server.py: _normalise_theme_definition() parses loose YAML (bare hex strings, partial blocks) into the canonical wire shape; /api/dashboard/themes ships full definitions for user themes. - tests/hermes_cli/test_web_server.py: 16 new tests covering the normaliser and discovery (rejection cases, clamping, defaults). - website/docs/user-guide/features/web-dashboard.md: rewrite Themes section with real schema, per-model tables, full YAML example. --- hermes_cli/web_server.py | 159 +++++++++++- tests/hermes_cli/test_web_server.py | 184 ++++++++++++++ web/src/index.css | 53 +++- web/src/lib/api.ts | 5 + web/src/themes/context.tsx | 228 ++++++++++++++++-- web/src/themes/presets.ts | 112 ++++++++- web/src/themes/types.ts | 99 +++++++- .../docs/user-guide/features/web-dashboard.md | 150 ++++++++---- 8 files changed, 898 insertions(+), 92 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 9cdfdb37d..10b92f69a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2304,8 +2304,134 @@ _BUILTIN_DASHBOARD_THEMES = [ ] +def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: + """Normalise a theme layer spec from YAML into `{hex, alpha}` form. + + Accepts shorthand (a bare hex string) or full dict form. Returns + ``None`` on garbage input so the caller can fall back to a built-in + default rather than blowing up. + """ + if value is None: + return {"hex": default_hex, "alpha": default_alpha} + if isinstance(value, str): + return {"hex": value, "alpha": default_alpha} + if isinstance(value, dict): + hex_val = value.get("hex", default_hex) + alpha_val = value.get("alpha", default_alpha) + if not isinstance(hex_val, str): + return None + try: + alpha_f = float(alpha_val) + except (TypeError, ValueError): + alpha_f = default_alpha + return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} + return None + + +_THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = { + "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', + "baseSize": "15px", + "lineHeight": "1.55", + "letterSpacing": "0", +} + +_THEME_DEFAULT_LAYOUT: Dict[str, str] = { + "radius": "0.5rem", + "density": "comfortable", +} + +_THEME_OVERRIDE_KEYS = { + "card", "cardForeground", "popover", "popoverForeground", + "primary", "primaryForeground", "secondary", "secondaryForeground", + "muted", "mutedForeground", "accent", "accentForeground", + "destructive", "destructiveForeground", "success", "warning", + "border", "input", "ring", +} + + +def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Normalise a user theme YAML into the wire format `ThemeProvider` + expects. Returns ``None`` if the theme is unusable. + + Accepts both the full schema (palette/typography/layout) and a loose + form with bare hex strings, so hand-written YAMLs stay friendly. + """ + if not isinstance(data, dict): + return None + name = data.get("name") + if not isinstance(name, str) or not name.strip(): + return None + + # Palette + palette_src = data.get("palette", {}) if isinstance(data.get("palette"), dict) else {} + # Allow top-level `colors.background` as a shorthand too. + colors_src = data.get("colors", {}) if isinstance(data.get("colors"), dict) else {} + + def _layer(key: str, default_hex: str, default_alpha: float = 1.0) -> Dict[str, Any]: + spec = palette_src.get(key, colors_src.get(key)) + parsed = _parse_theme_layer(spec, default_hex, default_alpha) + return parsed if parsed is not None else {"hex": default_hex, "alpha": default_alpha} + + palette = { + "background": _layer("background", "#041c1c", 1.0), + "midground": _layer("midground", "#ffe6cb", 1.0), + "foreground": _layer("foreground", "#ffffff", 0.0), + "warmGlow": palette_src.get("warmGlow") or data.get("warmGlow") or "rgba(255, 189, 56, 0.35)", + "noiseOpacity": 1.0, + } + raw_noise = palette_src.get("noiseOpacity", data.get("noiseOpacity")) + try: + palette["noiseOpacity"] = float(raw_noise) if raw_noise is not None else 1.0 + except (TypeError, ValueError): + palette["noiseOpacity"] = 1.0 + + # Typography + typo_src = data.get("typography", {}) if isinstance(data.get("typography"), dict) else {} + typography = dict(_THEME_DEFAULT_TYPOGRAPHY) + for key in ("fontSans", "fontMono", "fontDisplay", "fontUrl", "baseSize", "lineHeight", "letterSpacing"): + val = typo_src.get(key) + if isinstance(val, str) and val.strip(): + typography[key] = val + + # Layout + layout_src = data.get("layout", {}) if isinstance(data.get("layout"), dict) else {} + layout = dict(_THEME_DEFAULT_LAYOUT) + radius = layout_src.get("radius") + if isinstance(radius, str) and radius.strip(): + layout["radius"] = radius + density = layout_src.get("density") + if isinstance(density, str) and density in ("compact", "comfortable", "spacious"): + layout["density"] = density + + # Color overrides — keep only valid keys with string values. + overrides_src = data.get("colorOverrides", {}) + color_overrides: Dict[str, str] = {} + if isinstance(overrides_src, dict): + for key, val in overrides_src.items(): + if key in _THEME_OVERRIDE_KEYS and isinstance(val, str) and val.strip(): + color_overrides[key] = val + + result: Dict[str, Any] = { + "name": name, + "label": data.get("label") or name, + "description": data.get("description", ""), + "palette": palette, + "typography": typography, + "layout": layout, + } + if color_overrides: + result["colorOverrides"] = color_overrides + return result + + def _discover_user_themes() -> list: - """Scan ~/.hermes/dashboard-themes/*.yaml for user-created themes.""" + """Scan ~/.hermes/dashboard-themes/*.yaml for user-created themes. + + Returns a list of fully-normalised theme definitions ready to ship + to the frontend, so the client can apply them without a secondary + round-trip or a built-in stub. + """ themes_dir = get_hermes_home() / "dashboard-themes" if not themes_dir.is_dir(): return [] @@ -2313,33 +2439,42 @@ def _discover_user_themes() -> list: 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 + normalised = _normalise_theme_definition(data) + if normalised is not None: + result.append(normalised) return result @app.get("/api/dashboard/themes") async def get_dashboard_themes(): - """Return available themes and the currently active one.""" + """Return available themes and the currently active one. + + Built-in entries ship name/label/description only (the frontend owns + their full definitions in `web/src/themes/presets.ts`). User themes + from `~/.hermes/dashboard-themes/*.yaml` ship with their full + normalised definition under `definition`, so the client can apply + them without a stub. + """ 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"]) + if t["name"] in seen: + continue + themes.append({ + "name": t["name"], + "label": t["label"], + "description": t["description"], + "definition": t, + }) + seen.add(t["name"]) return {"themes": themes, "active": active} diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index f990ed56a..572549bd4 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1256,3 +1256,187 @@ class TestStatusRemoteGateway: assert data["gateway_running"] is True assert data["gateway_pid"] is None assert data["gateway_state"] == "running" + + +# --------------------------------------------------------------------------- +# Dashboard theme normaliser tests +# --------------------------------------------------------------------------- + + +class TestNormaliseThemeDefinition: + """Tests for _normalise_theme_definition() — parses YAML theme files.""" + + def test_rejects_missing_name(self): + from hermes_cli.web_server import _normalise_theme_definition + assert _normalise_theme_definition({}) is None + assert _normalise_theme_definition({"name": ""}) is None + assert _normalise_theme_definition({"name": " "}) is None + + def test_rejects_non_dict(self): + from hermes_cli.web_server import _normalise_theme_definition + assert _normalise_theme_definition("string") is None + assert _normalise_theme_definition(None) is None + assert _normalise_theme_definition([1, 2, 3]) is None + + def test_loose_colors_shorthand(self): + """Bare hex strings under `colors` parse as {hex, alpha=1.0}.""" + from hermes_cli.web_server import _normalise_theme_definition + result = _normalise_theme_definition({ + "name": "loose", + "colors": {"background": "#000000", "midground": "#ffffff"}, + }) + assert result is not None + assert result["palette"]["background"] == {"hex": "#000000", "alpha": 1.0} + assert result["palette"]["midground"] == {"hex": "#ffffff", "alpha": 1.0} + # foreground falls back to default (transparent white) + assert result["palette"]["foreground"]["hex"] == "#ffffff" + assert result["palette"]["foreground"]["alpha"] == 0.0 + + def test_full_palette_form(self): + from hermes_cli.web_server import _normalise_theme_definition + result = _normalise_theme_definition({ + "name": "full", + "palette": { + "background": {"hex": "#0a1628", "alpha": 1.0}, + "midground": {"hex": "#a8d0ff", "alpha": 0.9}, + "warmGlow": "rgba(255, 0, 0, 0.5)", + "noiseOpacity": 0.5, + }, + }) + assert result["palette"]["background"]["hex"] == "#0a1628" + assert result["palette"]["midground"]["alpha"] == 0.9 + assert result["palette"]["warmGlow"] == "rgba(255, 0, 0, 0.5)" + assert result["palette"]["noiseOpacity"] == 0.5 + + def test_default_typography_applied_when_missing(self): + from hermes_cli.web_server import _normalise_theme_definition + result = _normalise_theme_definition({"name": "minimal"}) + typo = result["typography"] + assert "fontSans" in typo + assert "fontMono" in typo + assert typo["baseSize"] == "15px" + assert typo["lineHeight"] == "1.55" + assert typo["letterSpacing"] == "0" + + def test_partial_typography_merges_with_defaults(self): + from hermes_cli.web_server import _normalise_theme_definition + result = _normalise_theme_definition({ + "name": "partial", + "typography": { + "fontSans": "MyFont, sans-serif", + "baseSize": "12px", + }, + }) + assert result["typography"]["fontSans"] == "MyFont, sans-serif" + assert result["typography"]["baseSize"] == "12px" + # fontMono defaulted + assert "monospace" in result["typography"]["fontMono"] + + def test_layout_defaults(self): + from hermes_cli.web_server import _normalise_theme_definition + result = _normalise_theme_definition({"name": "minimal"}) + assert result["layout"]["radius"] == "0.5rem" + assert result["layout"]["density"] == "comfortable" + + def test_invalid_density_falls_back(self): + from hermes_cli.web_server import _normalise_theme_definition + result = _normalise_theme_definition({ + "name": "bad", + "layout": {"density": "ultra-spacious"}, + }) + assert result["layout"]["density"] == "comfortable" + + def test_valid_densities_accepted(self): + from hermes_cli.web_server import _normalise_theme_definition + for d in ("compact", "comfortable", "spacious"): + r = _normalise_theme_definition({"name": "x", "layout": {"density": d}}) + assert r["layout"]["density"] == d + + def test_color_overrides_filter_unknown_keys(self): + from hermes_cli.web_server import _normalise_theme_definition + result = _normalise_theme_definition({ + "name": "o", + "colorOverrides": { + "card": "#123456", + "fakeToken": "#abcdef", + "primary": 42, # non-string rejected + "destructive": "#ff0000", + }, + }) + assert result["colorOverrides"] == { + "card": "#123456", + "destructive": "#ff0000", + } + + def test_color_overrides_omitted_when_empty(self): + from hermes_cli.web_server import _normalise_theme_definition + result = _normalise_theme_definition({"name": "x"}) + assert "colorOverrides" not in result + + def test_alpha_clamped_to_unit_range(self): + from hermes_cli.web_server import _normalise_theme_definition + r = _normalise_theme_definition({ + "name": "c", + "palette": {"background": {"hex": "#000", "alpha": 99.5}}, + }) + assert r["palette"]["background"]["alpha"] == 1.0 + r2 = _normalise_theme_definition({ + "name": "c", + "palette": {"background": {"hex": "#000", "alpha": -5}}, + }) + assert r2["palette"]["background"]["alpha"] == 0.0 + + def test_invalid_alpha_uses_default(self): + from hermes_cli.web_server import _normalise_theme_definition + r = _normalise_theme_definition({ + "name": "c", + "palette": {"background": {"hex": "#000", "alpha": "not a number"}}, + }) + assert r["palette"]["background"]["alpha"] == 1.0 + + +class TestDiscoverUserThemes: + """Tests for _discover_user_themes() — scans ~/.hermes/dashboard-themes/.""" + + def test_returns_empty_when_dir_missing(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from hermes_cli import web_server + assert web_server._discover_user_themes() == [] + + def test_loads_and_normalises_yaml(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + themes_dir = tmp_path / "dashboard-themes" + themes_dir.mkdir() + (themes_dir / "ocean.yaml").write_text( + "name: ocean\n" + "label: Ocean\n" + "palette:\n" + " background:\n" + " hex: \"#0a1628\"\n" + " alpha: 1.0\n" + "layout:\n" + " density: spacious\n" + ) + from hermes_cli import web_server + results = web_server._discover_user_themes() + assert len(results) == 1 + assert results[0]["name"] == "ocean" + assert results[0]["label"] == "Ocean" + assert results[0]["palette"]["background"]["hex"] == "#0a1628" + assert results[0]["layout"]["density"] == "spacious" + # defaults filled in + assert "fontSans" in results[0]["typography"] + + def test_malformed_yaml_skipped(self, tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + themes_dir = tmp_path / "dashboard-themes" + themes_dir.mkdir() + (themes_dir / "bad.yaml").write_text("::: not valid yaml :::\n\tindent wrong") + (themes_dir / "nameless.yaml").write_text("label: No Name Here\n") + (themes_dir / "ok.yaml").write_text("name: ok\n") + from hermes_cli import web_server + results = web_server._discover_user_themes() + names = [r["name"] for r in results] + assert "ok" in names + assert "bad" not in names # malformed YAML + assert len(results) == 1 # only the valid one diff --git a/web/src/index.css b/web/src/index.css index b602361e2..24260c6b4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -29,6 +29,48 @@ /* Consumed by ; also theme-switchable. */ --warm-glow: rgba(255, 189, 56, 0.35); --noise-opacity-mul: 1; + + /* Typography tokens — rewritten by ThemeProvider. Defaults match the + system stack so themes that don't override look native. */ + --theme-font-sans: system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --theme-font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, + Consolas, monospace; + --theme-font-display: var(--theme-font-sans); + --theme-base-size: 15px; + --theme-line-height: 1.55; + --theme-letter-spacing: 0; + + /* Layout tokens. */ + --radius: 0.5rem; + --theme-radius: 0.5rem; + --theme-spacing-mul: 1; + --theme-density: comfortable; +} + +/* Theme tokens cascade into the document root so every descendant inherits + the font stack, base size, and letter spacing without explicit calls. */ +html { + font-family: var(--theme-font-sans); + font-size: var(--theme-base-size); + line-height: var(--theme-line-height); + letter-spacing: var(--theme-letter-spacing); +} + +body { + font-family: var(--theme-font-sans); +} + +code, kbd, pre, samp, .font-mono, .font-mono-ui { + font-family: var(--theme-font-mono); +} + +/* Density: scale the shadcn spacing utilities via a multiplier. The DS + components use `p-N` / `gap-N` / `space-*` classes which resolve against + Tailwind's spacing scale; multiplying `--spacing` at :root scales them + all proportionally in Tailwind v4. */ +@theme inline { + --spacing: calc(0.25rem * var(--theme-spacing-mul, 1)); } /* Nousnet's hermes-agent layout bumps `small` and `code` to readable @@ -65,6 +107,11 @@ code { font-size: 0.875rem; } --color-ring: var(--midground); --color-popover: color-mix(in srgb, var(--midground-base) 4%, var(--background-base)); --color-popover-foreground: var(--midground); + + --radius-sm: calc(var(--theme-radius) - 4px); + --radius-md: calc(var(--theme-radius) - 2px); + --radius-lg: var(--theme-radius); + --radius-xl: calc(var(--theme-radius) + 4px); } @@ -94,9 +141,11 @@ code { font-size: 0.875rem; } /* System UI-monospace stack — distinct from `font-courier` (Courier Prime), used for dense data readouts where the display font would - break the grid. */ + break the grid. Routes through the theme's mono stack so themes + with a different monospace (JetBrains Mono, IBM Plex Mono, etc.) + still apply here. */ .font-mono-ui { - font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, monospace; + font-family: var(--theme-font-mono); } /* Subtle grain overlay for badges. */ diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 04951c02b..45c0618a5 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,5 +1,7 @@ const BASE = ""; +import type { DashboardTheme } from "@/themes/types"; + // Ephemeral session token for protected endpoints. // Injected into index.html by the server — never fetched via API. declare global { @@ -486,6 +488,9 @@ export interface DashboardThemeSummary { description: string; label: string; name: string; + /** Full theme definition for user themes; undefined for built-ins + * (which the frontend already has locally). */ + definition?: DashboardTheme; } export interface DashboardThemesResponse { diff --git a/web/src/themes/context.tsx b/web/src/themes/context.tsx index 4bc50f9b3..1fa1c1632 100644 --- a/web/src/themes/context.tsx +++ b/web/src/themes/context.tsx @@ -8,16 +8,35 @@ import { type ReactNode, } from "react"; import { BUILTIN_THEMES, defaultTheme } from "./presets"; -import type { DashboardTheme, ThemeLayer, ThemePalette } from "./types"; +import type { + DashboardTheme, + ThemeColorOverrides, + ThemeDensity, + ThemeLayer, + ThemeLayout, + ThemePalette, + ThemeTypography, +} from "./types"; import { api } from "@/lib/api"; /** LocalStorage key — pre-applied before the React tree mounts to avoid * a visible flash of the default palette on theme-overridden installs. */ const STORAGE_KEY = "hermes-dashboard-theme"; +/** Tracks fontUrls we've already injected so multiple theme switches don't + * pile up tags. Keyed by URL. */ +const INJECTED_FONT_URLS = new Set(); + +// --------------------------------------------------------------------------- +// CSS variable builders +// --------------------------------------------------------------------------- + /** Turn a ThemeLayer into the two CSS expressions the DS consumes: * `--` (color-mix'd with alpha) and `---base` (opaque hex). */ -function layerVars(name: "background" | "midground" | "foreground", layer: ThemeLayer) { +function layerVars( + name: "background" | "midground" | "foreground", + layer: ThemeLayer, +): Record { const pct = Math.round(layer.alpha * 100); return { [`--${name}`]: `color-mix(in srgb, ${layer.hex} ${pct}%, transparent)`, @@ -26,28 +45,145 @@ function layerVars(name: "background" | "midground" | "foreground", layer: Theme }; } -/** Write a theme's palette to `document.documentElement` as inline styles. - * Inline styles beat the `:root { }` rule in index.css, so this cascades - * into every shadcn-compat token defined over the DS triplet. */ -function applyPalette(palette: ThemePalette) { - const root = document.documentElement; - const vars = { +function paletteVars(palette: ThemePalette): Record { + return { ...layerVars("background", palette.background), ...layerVars("midground", palette.midground), ...layerVars("foreground", palette.foreground), "--warm-glow": palette.warmGlow, "--noise-opacity-mul": String(palette.noiseOpacity), }; +} + +const DENSITY_MULTIPLIERS: Record = { + compact: "0.85", + comfortable: "1", + spacious: "1.2", +}; + +function typographyVars(typo: ThemeTypography): Record { + return { + "--theme-font-sans": typo.fontSans, + "--theme-font-mono": typo.fontMono, + "--theme-font-display": typo.fontDisplay ?? typo.fontSans, + "--theme-base-size": typo.baseSize, + "--theme-line-height": typo.lineHeight, + "--theme-letter-spacing": typo.letterSpacing, + }; +} + +function layoutVars(layout: ThemeLayout): Record { + return { + "--radius": layout.radius, + "--theme-radius": layout.radius, + "--theme-spacing-mul": DENSITY_MULTIPLIERS[layout.density] ?? "1", + "--theme-density": layout.density, + }; +} + +/** Map a color-overrides key (camelCase) to its `--color-*` CSS var. */ +const OVERRIDE_KEY_TO_VAR: Record = { + card: "--color-card", + cardForeground: "--color-card-foreground", + popover: "--color-popover", + popoverForeground: "--color-popover-foreground", + primary: "--color-primary", + primaryForeground: "--color-primary-foreground", + secondary: "--color-secondary", + secondaryForeground: "--color-secondary-foreground", + muted: "--color-muted", + mutedForeground: "--color-muted-foreground", + accent: "--color-accent", + accentForeground: "--color-accent-foreground", + destructive: "--color-destructive", + destructiveForeground: "--color-destructive-foreground", + success: "--color-success", + warning: "--color-warning", + border: "--color-border", + input: "--color-input", + ring: "--color-ring", +}; + +/** Keys we might have written on a previous theme — needed to know which + * properties to clear when a theme with fewer overrides replaces one + * with more. */ +const ALL_OVERRIDE_VARS = Object.values(OVERRIDE_KEY_TO_VAR); + +function overrideVars( + overrides: ThemeColorOverrides | undefined, +): Record { + if (!overrides) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (!value) continue; + const cssVar = OVERRIDE_KEY_TO_VAR[key as keyof ThemeColorOverrides]; + if (cssVar) out[cssVar] = value; + } + return out; +} + +// --------------------------------------------------------------------------- +// Font stylesheet injection +// --------------------------------------------------------------------------- + +function injectFontStylesheet(url: string | undefined) { + if (!url || typeof document === "undefined") return; + if (INJECTED_FONT_URLS.has(url)) return; + // Also skip if the page already has this href (e.g. SSR'd or persisted). + const existing = document.querySelector( + `link[rel="stylesheet"][href="${CSS.escape(url)}"]`, + ); + if (existing) { + INJECTED_FONT_URLS.add(url); + return; + } + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = url; + link.setAttribute("data-hermes-theme-font", "true"); + document.head.appendChild(link); + INJECTED_FONT_URLS.add(url); +} + +// --------------------------------------------------------------------------- +// Apply a full theme to :root +// --------------------------------------------------------------------------- + +function applyTheme(theme: DashboardTheme) { + if (typeof document === "undefined") return; + const root = document.documentElement; + + // Clear any overrides from a previous theme before applying the new set. + for (const cssVar of ALL_OVERRIDE_VARS) { + root.style.removeProperty(cssVar); + } + + const vars = { + ...paletteVars(theme.palette), + ...typographyVars(theme.typography), + ...layoutVars(theme.layout), + ...overrideVars(theme.colorOverrides), + }; for (const [k, v] of Object.entries(vars)) { root.style.setProperty(k, v); } + + injectFontStylesheet(theme.typography.fontUrl); } +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + export function ThemeProvider({ children }: { children: ReactNode }) { + /** Name of the currently active theme (built-in id or user YAML name). */ const [themeName, setThemeName] = useState(() => { if (typeof window === "undefined") return "default"; return window.localStorage.getItem(STORAGE_KEY) ?? "default"; }); + + /** All selectable themes (shown in the picker). Starts with just the + * built-ins; the API call below merges in user themes. */ const [availableThemes, setAvailableThemes] = useState< Array<{ description: string; label: string; name: string }> >(() => @@ -58,18 +194,56 @@ export function ThemeProvider({ children }: { children: ReactNode }) { })), ); - useEffect(() => { - const t = BUILTIN_THEMES[themeName] ?? defaultTheme; - applyPalette(t.palette); - }, [themeName]); + /** Full definitions for user themes keyed by name — the API provides + * these so custom YAMLs apply without a client-side stub. */ + const [userThemeDefs, setUserThemeDefs] = useState< + Record + >({}); + // Resolve a theme name to a full DashboardTheme, falling back to default + // only when neither a built-in nor a user theme is found. + const resolveTheme = useCallback( + (name: string): DashboardTheme => { + return ( + BUILTIN_THEMES[name] ?? + userThemeDefs[name] ?? + defaultTheme + ); + }, + [userThemeDefs], + ); + + // Re-apply on every themeName change, or when user themes arrive from + // the API (since the active theme might be a user theme whose definition + // hadn't loaded yet on first render). + useEffect(() => { + applyTheme(resolveTheme(themeName)); + }, [themeName, resolveTheme]); + + // Load server-side themes (built-ins + user YAMLs) once on mount. useEffect(() => { let cancelled = false; api .getThemes() .then((resp) => { if (cancelled) return; - if (resp.themes?.length) setAvailableThemes(resp.themes); + if (resp.themes?.length) { + setAvailableThemes( + resp.themes.map((t) => ({ + name: t.name, + label: t.label, + description: t.description, + })), + ); + // Index any definitions the server shipped (user themes). + const defs: Record = {}; + for (const entry of resp.themes) { + if (entry.definition) { + defs[entry.name] = entry.definition; + } + } + if (Object.keys(defs).length > 0) setUserThemeDefs(defs); + } if (resp.active && resp.active !== themeName) { setThemeName(resp.active); window.localStorage.setItem(STORAGE_KEY, resp.active); @@ -79,23 +253,35 @@ export function ThemeProvider({ children }: { children: ReactNode }) { return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const setTheme = useCallback((name: string) => { - const next = BUILTIN_THEMES[name] ? name : "default"; - setThemeName(next); - window.localStorage.setItem(STORAGE_KEY, next); - api.setTheme(next).catch(() => {}); - }, []); + const setTheme = useCallback( + (name: string) => { + // Accept any name the server told us exists OR any built-in. + const knownNames = new Set([ + ...Object.keys(BUILTIN_THEMES), + ...availableThemes.map((t) => t.name), + ...Object.keys(userThemeDefs), + ]); + const next = knownNames.has(name) ? name : "default"; + setThemeName(next); + if (typeof window !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, next); + } + api.setTheme(next).catch(() => {}); + }, + [availableThemes, userThemeDefs], + ); const value = useMemo( () => ({ - theme: BUILTIN_THEMES[themeName] ?? defaultTheme, + theme: resolveTheme(themeName), themeName, availableThemes, setTheme, }), - [themeName, availableThemes, setTheme], + [themeName, availableThemes, setTheme, resolveTheme], ); return {children}; diff --git a/web/src/themes/presets.ts b/web/src/themes/presets.ts index 20a7b47c2..d8ae293cd 100644 --- a/web/src/themes/presets.ts +++ b/web/src/themes/presets.ts @@ -1,17 +1,43 @@ -import type { DashboardTheme } from "./types"; +import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types"; /** * Built-in dashboard themes. * - * The `default` theme mirrors LENS_0 (canonical Hermes teal) exactly — the - * same triplet `src/index.css` declares on `:root`. Applying it should be a - * visual no-op; other themes override the triplet + warm-glow and let the DS - * cascade handle every derived surface. + * Each theme defines its own palette, typography, and layout so switching + * themes produces visible changes beyond just color — fonts, density, and + * corner-radius all shift to match the theme's personality. * * Theme names must stay in sync with the backend's * `_BUILTIN_DASHBOARD_THEMES` list in `hermes_cli/web_server.py`. */ +// --------------------------------------------------------------------------- +// Shared typography / layout presets +// --------------------------------------------------------------------------- + +/** Default system stack — neutral, safe fallback for every platform. */ +const SYSTEM_SANS = + 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; +const SYSTEM_MONO = + 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace'; + +const DEFAULT_TYPOGRAPHY: ThemeTypography = { + fontSans: SYSTEM_SANS, + fontMono: SYSTEM_MONO, + baseSize: "15px", + lineHeight: "1.55", + letterSpacing: "0", +}; + +const DEFAULT_LAYOUT: ThemeLayout = { + radius: "0.5rem", + density: "comfortable", +}; + +// --------------------------------------------------------------------------- +// Themes +// --------------------------------------------------------------------------- + export const defaultTheme: DashboardTheme = { name: "default", label: "Hermes Teal", @@ -23,6 +49,8 @@ export const defaultTheme: DashboardTheme = { warmGlow: "rgba(255, 189, 56, 0.35)", noiseOpacity: 1, }, + typography: DEFAULT_TYPOGRAPHY, + layout: DEFAULT_LAYOUT, }; export const midnightTheme: DashboardTheme = { @@ -36,6 +64,19 @@ export const midnightTheme: DashboardTheme = { warmGlow: "rgba(167, 139, 250, 0.32)", noiseOpacity: 0.8, }, + typography: { + fontSans: `"Inter", ${SYSTEM_SANS}`, + fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`, + fontUrl: + "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap", + baseSize: "14px", + lineHeight: "1.6", + letterSpacing: "-0.005em", + }, + layout: { + radius: "0.75rem", + density: "comfortable", + }, }; export const emberTheme: DashboardTheme = { @@ -49,6 +90,23 @@ export const emberTheme: DashboardTheme = { warmGlow: "rgba(249, 115, 22, 0.38)", noiseOpacity: 1, }, + typography: { + fontSans: `"Spectral", Georgia, "Times New Roman", serif`, + fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`, + fontUrl: + "https://fonts.googleapis.com/css2?family=Spectral:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;700&display=swap", + baseSize: "15px", + lineHeight: "1.6", + letterSpacing: "0", + }, + layout: { + radius: "0.25rem", + density: "comfortable", + }, + colorOverrides: { + destructive: "#c92d0f", + warning: "#f97316", + }, }; export const monoTheme: DashboardTheme = { @@ -62,6 +120,19 @@ export const monoTheme: DashboardTheme = { warmGlow: "rgba(255, 255, 255, 0.1)", noiseOpacity: 0.6, }, + typography: { + fontSans: `"IBM Plex Sans", ${SYSTEM_SANS}`, + fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`, + fontUrl: + "https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap", + baseSize: "13px", + lineHeight: "1.5", + letterSpacing: "0", + }, + layout: { + radius: "0", + density: "compact", + }, }; export const cyberpunkTheme: DashboardTheme = { @@ -75,6 +146,24 @@ export const cyberpunkTheme: DashboardTheme = { warmGlow: "rgba(0, 255, 136, 0.22)", noiseOpacity: 1.2, }, + typography: { + fontSans: `"Share Tech Mono", "JetBrains Mono", ${SYSTEM_MONO}`, + fontMono: `"Share Tech Mono", "JetBrains Mono", ${SYSTEM_MONO}`, + fontUrl: + "https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=JetBrains+Mono:wght@400;700&display=swap", + baseSize: "14px", + lineHeight: "1.5", + letterSpacing: "0.02em", + }, + layout: { + radius: "0", + density: "compact", + }, + colorOverrides: { + success: "#00ff88", + warning: "#ffd700", + destructive: "#ff0055", + }, }; export const roseTheme: DashboardTheme = { @@ -88,6 +177,19 @@ export const roseTheme: DashboardTheme = { warmGlow: "rgba(249, 168, 212, 0.3)", noiseOpacity: 0.9, }, + typography: { + fontSans: `"Fraunces", Georgia, serif`, + fontMono: `"DM Mono", ${SYSTEM_MONO}`, + fontUrl: + "https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=DM+Mono:wght@400;500&display=swap", + baseSize: "16px", + lineHeight: "1.7", + letterSpacing: "0", + }, + layout: { + radius: "1rem", + density: "spacious", + }, }; export const BUILTIN_THEMES: Record = { diff --git a/web/src/themes/types.ts b/web/src/themes/types.ts index 4a423aeee..c83c6464d 100644 --- a/web/src/themes/types.ts +++ b/web/src/themes/types.ts @@ -1,13 +1,22 @@ /** * Dashboard theme model. * - * Unlike the pre-DS implementation (which overrode 21 shadcn tokens directly), - * themes are now expressed in the Nous DS's own 3-triplet vocabulary — - * `background`, `midground`, `foreground` — plus a warm-glow tint for the - * vignette in . All downstream shadcn-compat tokens - * (`--color-card`, `--color-muted-foreground`, `--color-border`, etc.) are - * defined in `src/index.css` as `color-mix()` expressions over the triplets, - * so overriding the triplets at runtime cascades to every surface. + * Themes customise three orthogonal layers: + * + * 1. `palette` — the 3-layer color triplet (background/midground/ + * foreground) + warm-glow + noise opacity. The + * design-system cascade in `src/index.css` derives + * every shadcn-compat token (card, muted, border, + * primary, etc.) from this triplet via `color-mix()`. + * 2. `typography` — font families, base font size, line height, + * letter spacing. An optional `fontUrl` is injected + * as `` so self-hosted and + * Google/Bunny/etc-hosted fonts both work. + * 3. `layout` — corner radius and density (spacing multiplier). + * + * Plus an optional `colorOverrides` escape hatch for themes that want to + * pin specific shadcn tokens to exact values (e.g. a pastel theme that + * needs a softer `destructive` red than the derived default). */ /** A color layer: hex base + alpha (0–1). */ @@ -31,14 +40,88 @@ export interface ThemePalette { noiseOpacity: number; } +export interface ThemeTypography { + /** CSS font-family stack for sans-serif body copy. */ + fontSans: string; + /** CSS font-family stack for monospace / code blocks. */ + fontMono: string; + /** Optional display/heading font stack. Falls back to `fontSans`. */ + fontDisplay?: string; + /** Optional external stylesheet URL (e.g. Google Fonts, Bunny Fonts, + * self-hosted .woff2 @font-face sheet). Injected as a in + * on theme switch. Same URL is never injected twice. */ + fontUrl?: string; + /** Root font size (controls rem scale). Example: `"14px"`, `"16px"`. */ + baseSize: string; + /** Default line-height. Example: `"1.5"`, `"1.65"`. */ + lineHeight: string; + /** Default letter-spacing. Example: `"0"`, `"0.01em"`, `"-0.01em"`. */ + letterSpacing: string; +} + +export type ThemeDensity = "compact" | "comfortable" | "spacious"; + +export interface ThemeLayout { + /** Corner-radius token. Example: `"0"`, `"0.25rem"`, `"0.5rem"`, + * `"1rem"`. Maps to `--radius` and cascades into every component. */ + radius: string; + /** Spacing multiplier. `compact` = 0.85, `comfortable` = 1.0 (default), + * `spacious` = 1.2. Applied via the `--spacing-mul` CSS var. */ + density: ThemeDensity; +} + +/** Optional hex overrides keyed by shadcn-compat token name (without the + * `--color-` prefix). Any key set here wins over the DS cascade. */ +export interface ThemeColorOverrides { + card?: string; + cardForeground?: string; + popover?: string; + popoverForeground?: string; + primary?: string; + primaryForeground?: string; + secondary?: string; + secondaryForeground?: string; + muted?: string; + mutedForeground?: string; + accent?: string; + accentForeground?: string; + destructive?: string; + destructiveForeground?: string; + success?: string; + warning?: string; + border?: string; + input?: string; + ring?: string; +} + export interface DashboardTheme { description: string; label: string; name: string; palette: ThemePalette; + typography: ThemeTypography; + layout: ThemeLayout; + colorOverrides?: ThemeColorOverrides; +} + +/** + * Wire response shape for `GET /api/dashboard/themes`. + * + * The `themes` list is intentionally partial — built-in themes are fully + * defined in `presets.ts`; user themes carry their full definition so the + * client can apply them without a second round-trip. + */ +export interface ThemeListEntry { + description: string; + label: string; + name: string; + /** Full theme definition. Present for user-defined themes loaded from + * `~/.hermes/dashboard-themes/*.yaml`; undefined for built-ins (the + * client already has those in `BUILTIN_THEMES`). */ + definition?: DashboardTheme; } export interface ThemeListResponse { active: string; - themes: Array<{ description: string; label: string; name: string }>; + themes: ThemeListEntry[]; } diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 2ef04297d..ebcfe3698 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -301,68 +301,130 @@ When you run `hermes update`, the web frontend is automatically rebuilt if `npm` ## Themes -The dashboard supports visual themes that change colors, overlay effects, and overall feel. Switch themes live from the header bar — click the palette icon next to the language switcher. +Themes control the dashboard's visual presentation across three layers: -### Built-in Themes +- **Palette** — colors (background, text, accents, warm glow, noise) +- **Typography** — font families, base size, line height, letter spacing +- **Layout** — corner radius and density (spacing multiplier) -| Theme | Description | -|-------|-------------| -| **Hermes Teal** | Classic dark teal (default) | -| **Midnight** | Deep blue-violet with cool accents | -| **Ember** | Warm crimson and bronze | -| **Mono** | Clean grayscale, minimal | -| **Cyberpunk** | Neon green on black | -| **Rosé** | Soft pink and warm ivory | +Switch themes live from the header bar — click the palette icon next to the language switcher. Selection persists to `config.yaml` under `dashboard.theme` and is restored on page load. -Theme selection is persisted to `config.yaml` under `dashboard.theme` and restored on page load. +### Built-in themes -### Custom Themes +Each built-in ships its own palette, typography, and layout — switching produces visible changes beyond color alone. -Create a YAML file in `~/.hermes/dashboard-themes/`: +| Theme | Palette | Typography | Layout | +|-------|---------|------------|--------| +| **Hermes Teal** (`default`) | Dark teal + cream | System stack, 15px | 0.5rem radius, comfortable | +| **Midnight** (`midnight`) | Deep blue-violet | Inter + JetBrains Mono, 14px | 0.75rem radius, comfortable | +| **Ember** (`ember`) | Warm crimson / bronze | Spectral (serif) + IBM Plex Mono, 15px | 0.25rem radius, comfortable | +| **Mono** (`mono`) | Grayscale | IBM Plex Sans + IBM Plex Mono, 13px | 0 radius, compact | +| **Cyberpunk** (`cyberpunk`) | Neon green on black | Share Tech Mono everywhere, 14px | 0 radius, compact | +| **Rosé** (`rose`) | Pink and ivory | Fraunces (serif) + DM Mono, 16px | 1rem radius, spacious | + +Themes that reference Google Fonts (everything except Hermes Teal) load the stylesheet on demand — the first time you switch to them, a `` tag is injected into ``. + +### Custom themes + +Drop a YAML file in `~/.hermes/dashboard-themes/` and it appears in the picker automatically. The file can be as minimal as a name plus the fields you want to override — every missing field inherits a sane default. + +Minimal example (colors only, bare hex shorthand): + +```yaml +# ~/.hermes/dashboard-themes/neon.yaml +name: neon +label: Neon +description: Pure magenta on black +colors: + background: "#000000" + midground: "#ff00ff" +``` + +Full example (every knob): ```yaml # ~/.hermes/dashboard-themes/ocean.yaml name: ocean -label: Ocean +label: Ocean Deep description: Deep sea blues with coral accents -colors: - background: "#0a1628" - foreground: "#e0f0ff" - card: "#0f1f35" - card-foreground: "#e0f0ff" - primary: "#ff6b6b" - primary-foreground: "#0a1628" - secondary: "#152540" - secondary-foreground: "#e0f0ff" - muted: "#1a2d4a" - muted-foreground: "#7899bb" - accent: "#1f3555" - accent-foreground: "#e0f0ff" - destructive: "#fb2c36" - destructive-foreground: "#fff" - success: "#4ade80" - warning: "#fbbf24" - border: "color-mix(in srgb, #ff6b6b 15%, transparent)" - input: "color-mix(in srgb, #ff6b6b 15%, transparent)" - ring: "#ff6b6b" - popover: "#0f1f35" - popover-foreground: "#e0f0ff" +palette: + background: + hex: "#0a1628" + alpha: 1.0 + midground: + hex: "#a8d0ff" + alpha: 1.0 + foreground: + hex: "#ffffff" + alpha: 0.0 + warmGlow: "rgba(255, 107, 107, 0.35)" + noiseOpacity: 0.7 -overlay: - noiseOpacity: 0.08 - noiseBlendMode: color-dodge - warmGlowOpacity: 0.15 - warmGlowColor: "rgba(255,107,107,0.2)" +typography: + fontSans: "Poppins, system-ui, sans-serif" + fontMono: "Fira Code, ui-monospace, monospace" + fontDisplay: "Poppins, system-ui, sans-serif" # optional, falls back to fontSans + fontUrl: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Fira+Code:wght@400;500&display=swap" + baseSize: "15px" + lineHeight: "1.6" + letterSpacing: "-0.003em" + +layout: + radius: "0.75rem" # 0 | 0.25rem | 0.5rem | 0.75rem | 1rem | any length + density: comfortable # compact | comfortable | spacious + +# Optional — pin individual shadcn tokens that would otherwise derive from +# the palette. Any key listed here wins over the palette cascade. +colorOverrides: + destructive: "#ff6b6b" + ring: "#ff6b6b" ``` -The 21 color tokens map directly to the CSS custom properties used throughout the dashboard. All fields are required for custom themes. The `overlay` section is optional — it controls the grain texture and ambient glow effects. +Refresh the dashboard after creating the file. -Refresh the dashboard after creating the file. Custom themes appear in the theme picker alongside built-ins. +### Palette model + +The palette is a 3-layer triplet — **background**, **midground**, **foreground** — plus a warm-glow rgba() string and a noise-opacity multiplier. Every shadcn token (card, muted, border, primary, popover, etc.) is derived from this triplet via CSS `color-mix()` in the dashboard's stylesheet, so overriding three colors cascades into the whole UI. + +- `background` — deepest canvas color (typically near-black). The page background and card fill come from this. +- `midground` — primary text and accent. Most UI chrome reads this. +- `foreground` — top-layer highlight. In the default theme this is white at alpha 0 (invisible); themes that want a bright accent on top can raise its alpha. +- `warmGlow` — rgba() vignette color used by the ambient backdrop. +- `noiseOpacity` — 0–1.2 multiplier on the grain overlay. Lower = softer, higher = grittier. + +Each layer accepts `{hex, alpha}` or a bare hex string (alpha defaults to 1.0). + +### Typography model + +| Key | Type | Description | +|-----|------|-------------| +| `fontSans` | string | CSS font-family stack for body copy (applied to `html`, `body`) | +| `fontMono` | string | CSS font-family stack for code blocks, ``, `.font-mono` utilities, dense readouts | +| `fontDisplay` | string | Optional heading/display font stack. Falls back to `fontSans` | +| `fontUrl` | string | Optional external stylesheet URL. Injected as `` in `` on theme switch. Same URL is never injected twice. Works with Google Fonts, Bunny Fonts, self-hosted `@font-face` sheets, anything you can link | +| `baseSize` | string | Root font size — controls the rem scale for the whole dashboard. Example: `"14px"`, `"16px"` | +| `lineHeight` | string | Default line-height, e.g. `"1.5"`, `"1.65"` | +| `letterSpacing` | string | Default letter-spacing, e.g. `"0"`, `"0.01em"`, `"-0.01em"` | + +### Layout model + +| Key | Values | Description | +|-----|--------|-------------| +| `radius` | any CSS length | Corner-radius token. Cascades into `--radius-sm/md/lg/xl` so every rounded element shifts together. | +| `density` | `compact` \| `comfortable` \| `spacious` | Spacing multiplier. Compact = 0.85×, comfortable = 1.0× (default), spacious = 1.2×. Scales Tailwind's base spacing, so padding, gap, and space-between utilities all shift proportionally. | + +### Color overrides (optional) + +Most themes won't need this — the 3-layer palette derives every shadcn token. But if you want a specific accent that the derivation won't produce (a softer destructive red for a pastel theme, a specific success green for a brand), pin individual tokens here. + +Supported keys: `card`, `cardForeground`, `popover`, `popoverForeground`, `primary`, `primaryForeground`, `secondary`, `secondaryForeground`, `muted`, `mutedForeground`, `accent`, `accentForeground`, `destructive`, `destructiveForeground`, `success`, `warning`, `border`, `input`, `ring`. + +Any key set here overrides the derived value for the active theme only — switching to another theme clears the overrides. ### Theme API | Endpoint | Method | Description | |----------|--------|-------------| -| `/api/dashboard/themes` | GET | List available themes + active name | +| `/api/dashboard/themes` | GET | List available themes + active name. Built-ins return `{name, label, description}`; user themes also include a `definition` field with the full normalised theme object. | | `/api/dashboard/theme` | PUT | Set active theme. Body: `{"name": "midnight"}` |