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"}` |