mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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
8 changed files with 898 additions and 92 deletions
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue