feat(dashboard): expand themes to fonts, layout, density (#14725)

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 <link> 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.
This commit is contained in:
Teknium 2026-04-23 13:49:51 -07:00 committed by GitHub
parent 8f5fee3e3e
commit 255ba5bf26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 898 additions and 92 deletions

View File

@ -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}

View File

@ -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

View File

@ -29,6 +29,48 @@
/* Consumed by <Backdrop />; 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. */

View File

@ -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 {

View File

@ -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 <link> tags. Keyed by URL. */
const INJECTED_FONT_URLS = new Set<string>();
// ---------------------------------------------------------------------------
// CSS variable builders
// ---------------------------------------------------------------------------
/** Turn a ThemeLayer into the two CSS expressions the DS consumes:
* `--<name>` (color-mix'd with alpha) and `--<name>-base` (opaque hex). */
function layerVars(name: "background" | "midground" | "foreground", layer: ThemeLayer) {
function layerVars(
name: "background" | "midground" | "foreground",
layer: ThemeLayer,
): Record<string, string> {
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<string, string> {
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<ThemeDensity, string> = {
compact: "0.85",
comfortable: "1",
spacious: "1.2",
};
function typographyVars(typo: ThemeTypography): Record<string, string> {
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<string, string> {
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<keyof ThemeColorOverrides, string> = {
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<string, string> {
if (!overrides) return {};
const out: Record<string, string> = {};
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<HTMLLinkElement>(
`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<string>(() => {
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<string, DashboardTheme>
>({});
// 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<string, DashboardTheme> = {};
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<string>([
...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<ThemeContextValue>(
() => ({
theme: BUILTIN_THEMES[themeName] ?? defaultTheme,
theme: resolveTheme(themeName),
themeName,
availableThemes,
setTheme,
}),
[themeName, availableThemes, setTheme],
[themeName, availableThemes, setTheme, resolveTheme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;

View File

@ -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<string, DashboardTheme> = {

View File

@ -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 <Backdrop />. 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 `<link rel="stylesheet">` 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 (01). */
@ -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 <link> in <head>
* 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[];
}

View File

@ -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 `<link>` tag is injected into `<head>`.
### 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` — 01.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, `<code>`, `.font-mono` utilities, dense readouts |
| `fontDisplay` | string | Optional heading/display font stack. Falls back to `fontSans` |
| `fontUrl` | string | Optional external stylesheet URL. Injected as `<link rel="stylesheet">` in `<head>` 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"}` |