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:
parent
8f5fee3e3e
commit
255ba5bf26
@ -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}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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> = {
|
||||
|
||||
@ -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 (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 <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[];
|
||||
}
|
||||
|
||||
@ -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` — 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, `<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"}` |
|
||||
|
||||
Loading…
Reference in New Issue
Block a user