feat(dashboard): reskin extension points for themes and plugins (#14776)

Themes and plugins can now pull off arbitrary dashboard reskins (cockpit
HUD, retro terminal, etc.) without touching core code.

Themes gain four new fields:
- layoutVariant: standard | cockpit | tiled — shell layout selector
- assets: {bg, hero, logo, crest, sidebar, header, custom: {...}} —
  artwork URLs exposed as --theme-asset-* CSS vars
- customCSS: raw CSS injected as a scoped <style> tag on theme apply
  (32 KiB cap, cleaned up on theme switch)
- componentStyles: per-component CSS-var overrides (clipPath,
  borderImage, background, boxShadow, ...) for card/header/sidebar/
  backdrop/tab/progress/badge/footer/page

Plugin manifests gain three new fields:
- tab.override: replaces a built-in route instead of adding a tab
- tab.hidden: register component + slots without adding a nav entry
- slots: declares shell slots the plugin populates

10 named shell slots: backdrop, header-left/right/banner, sidebar,
pre-main, post-main, footer-left/right, overlay. Plugins register via
window.__HERMES_PLUGINS__.registerSlot(name, slot, Component). A
<PluginSlot> React helper is exported on the plugin SDK.

Ships a full demo at plugins/strike-freedom-cockpit/ — theme YAML +
slot-only plugin that reproduces a Gundam cockpit dashboard: MS-STATUS
sidebar with live telemetry, COMPASS crest in header, notched card
corners via componentStyles, scanline overlay via customCSS, gold/cyan
palette, Orbitron typography.

Validation:
- 15 new tests in test_web_server.py covering every extended field
- tests/hermes_cli/: 2615 passed (3 pre-existing unrelated failures)
- tsc -b --noEmit: clean
- vite build: 418 kB bundle, ~2 kB delta for slots/theme extensions

Co-authored-by: Teknium <p@nousresearch.com>
This commit is contained in:
Teknium 2026-04-23 15:31:01 -07:00 committed by GitHub
parent 470389e6a3
commit f593c367be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1576 additions and 40 deletions

View file

@ -2370,6 +2370,29 @@ _THEME_OVERRIDE_KEYS = {
"border", "input", "ring", "border", "input", "ring",
} }
# Well-known named asset slots themes can populate. Any other keys under
# ``assets.custom`` are exposed as ``--theme-asset-custom-<key>`` CSS vars
# for plugin/shell use.
_THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"}
# Component-style buckets themes can override. The value under each bucket
# is a mapping from camelCase property name to CSS string; each pair emits
# ``--component-<bucket>-<kebab-property>`` on :root. The frontend's shell
# components (Card, App header, Backdrop, etc.) consume these vars so themes
# can restyle chrome (clip-path, border-image, segmented progress, etc.)
# without shipping their own CSS.
_THEME_COMPONENT_BUCKETS = {
"card", "header", "footer", "sidebar", "tab",
"progress", "badge", "backdrop", "page",
}
_THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"}
# Cap on customCSS length so a malformed/oversized theme YAML can't blow up
# the response payload or the <style> tag. 32 KiB is plenty for every
# practical reskin (the Strike Freedom demo is ~2 KiB).
_THEME_CUSTOM_CSS_MAX = 32 * 1024
def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Normalise a user theme YAML into the wire format `ThemeProvider` """Normalise a user theme YAML into the wire format `ThemeProvider`
@ -2433,6 +2456,69 @@ def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]
if key in _THEME_OVERRIDE_KEYS and isinstance(val, str) and val.strip(): if key in _THEME_OVERRIDE_KEYS and isinstance(val, str) and val.strip():
color_overrides[key] = val color_overrides[key] = val
# Assets — named slots + arbitrary user-defined keys. Values must be
# strings (URLs or CSS ``url(...)``/``linear-gradient(...)`` expressions).
# We don't fetch remote assets here; the frontend just injects them as
# CSS vars. Empty values are dropped so a theme can explicitly clear a
# slot by setting ``hero: ""``.
assets_out: Dict[str, Any] = {}
assets_src = data.get("assets", {}) if isinstance(data.get("assets"), dict) else {}
for key in _THEME_NAMED_ASSET_KEYS:
val = assets_src.get(key)
if isinstance(val, str) and val.strip():
assets_out[key] = val
custom_assets_src = assets_src.get("custom")
if isinstance(custom_assets_src, dict):
custom_assets: Dict[str, str] = {}
for key, val in custom_assets_src.items():
if (
isinstance(key, str)
and key.replace("-", "").replace("_", "").isalnum()
and isinstance(val, str)
and val.strip()
):
custom_assets[key] = val
if custom_assets:
assets_out["custom"] = custom_assets
# Custom CSS — raw CSS text the frontend injects as a scoped <style>
# tag on theme apply. Clipped to _THEME_CUSTOM_CSS_MAX to keep the
# payload bounded. We intentionally do NOT parse/sanitise the CSS
# here — the dashboard is localhost-only and themes are user-authored
# YAML in ~/.hermes/, same trust level as the config file itself.
custom_css_val = data.get("customCSS")
custom_css: Optional[str] = None
if isinstance(custom_css_val, str) and custom_css_val.strip():
custom_css = custom_css_val[:_THEME_CUSTOM_CSS_MAX]
# Component style overrides — per-bucket dicts of camelCase CSS
# property -> CSS string. The frontend converts these into CSS vars
# that shell components (Card, App header, Backdrop) consume.
component_styles_src = data.get("componentStyles", {})
component_styles: Dict[str, Dict[str, str]] = {}
if isinstance(component_styles_src, dict):
for bucket, props in component_styles_src.items():
if bucket not in _THEME_COMPONENT_BUCKETS or not isinstance(props, dict):
continue
clean: Dict[str, str] = {}
for prop, value in props.items():
if (
isinstance(prop, str)
and prop.replace("-", "").replace("_", "").isalnum()
and isinstance(value, (str, int, float))
and str(value).strip()
):
clean[prop] = str(value)
if clean:
component_styles[bucket] = clean
layout_variant_src = data.get("layoutVariant")
layout_variant = (
layout_variant_src
if isinstance(layout_variant_src, str) and layout_variant_src in _THEME_LAYOUT_VARIANTS
else "standard"
)
result: Dict[str, Any] = { result: Dict[str, Any] = {
"name": name, "name": name,
"label": data.get("label") or name, "label": data.get("label") or name,
@ -2440,9 +2526,16 @@ def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]
"palette": palette, "palette": palette,
"typography": typography, "typography": typography,
"layout": layout, "layout": layout,
"layoutVariant": layout_variant,
} }
if color_overrides: if color_overrides:
result["colorOverrides"] = color_overrides result["colorOverrides"] = color_overrides
if assets_out:
result["assets"] = assets_out
if custom_css is not None:
result["customCSS"] = custom_css
if component_styles:
result["componentStyles"] = component_styles
return result return result
@ -2552,13 +2645,35 @@ def _discover_dashboard_plugins() -> list:
if name in seen_names: if name in seen_names:
continue continue
seen_names.add(name) seen_names.add(name)
# Tab options: ``path`` + ``position`` for a new tab, optional
# ``override`` to replace a built-in route, and ``hidden`` to
# register the plugin component/slots without adding a tab
# (useful for slot-only plugins like a header-crest injector).
raw_tab = data.get("tab", {}) if isinstance(data.get("tab"), dict) else {}
tab_info = {
"path": raw_tab.get("path", f"/{name}"),
"position": raw_tab.get("position", "end"),
}
override_path = raw_tab.get("override")
if isinstance(override_path, str) and override_path.startswith("/"):
tab_info["override"] = override_path
if bool(raw_tab.get("hidden")):
tab_info["hidden"] = True
# Slots: list of named slot locations this plugin populates.
# The frontend exposes ``registerSlot(pluginName, slotName, Component)``
# on window; plugins with non-empty slots call it from their JS bundle.
slots_src = data.get("slots")
slots: List[str] = []
if isinstance(slots_src, list):
slots = [s for s in slots_src if isinstance(s, str) and s]
plugins.append({ plugins.append({
"name": name, "name": name,
"label": data.get("label", name), "label": data.get("label", name),
"description": data.get("description", ""), "description": data.get("description", ""),
"icon": data.get("icon", "Puzzle"), "icon": data.get("icon", "Puzzle"),
"version": data.get("version", "0.0.0"), "version": data.get("version", "0.0.0"),
"tab": data.get("tab", {"path": f"/{name}", "position": "end"}), "tab": tab_info,
"slots": slots,
"entry": data.get("entry", "dist/index.js"), "entry": data.get("entry", "dist/index.js"),
"css": data.get("css"), "css": data.get("css"),
"has_api": bool(data.get("api")), "has_api": bool(data.get("api")),

View file

@ -0,0 +1,70 @@
# Strike Freedom Cockpit — dashboard skin demo
Demonstrates how the dashboard skin+plugin system can be used to build a
fully custom cockpit-style reskin without touching the core dashboard.
Two pieces:
- `theme/strike-freedom.yaml` — a dashboard theme YAML that paints the
palette, typography, layout variant (`cockpit`), component chrome
(notched card corners, scanlines, accent colors), and declares asset
slots (`hero`, `crest`, `bg`).
- `dashboard/` — a plugin that populates the `sidebar`, `header-left`,
and `footer-right` slots reserved by the cockpit layout. The sidebar
renders an MS-STATUS panel with segmented telemetry bars driven by
real agent status; the header-left injects a COMPASS crest; the
footer-right replaces the default org tagline.
## Install
1. **Theme** — copy the theme YAML into your Hermes home:
```
cp theme/strike-freedom.yaml ~/.hermes/dashboard-themes/
```
2. **Plugin** — the `dashboard/` directory gets auto-discovered because
it lives under `plugins/` in the repo. On a user install, copy the
whole plugin directory into `~/.hermes/plugins/`:
```
cp -r . ~/.hermes/plugins/strike-freedom-cockpit
```
3. Restart the web UI (or `GET /api/dashboard/plugins/rescan`), open it,
pick **Strike Freedom** from the theme switcher.
## Customising the artwork
The sidebar plugin reads `--theme-asset-hero` and `--theme-asset-crest`
from the active theme. Drop your own URLs into the theme YAML:
```yaml
assets:
hero: "/my-images/strike-freedom.png"
crest: "/my-images/compass-crest.svg"
bg: "/my-images/cosmic-era-bg.jpg"
```
The plugin reads those at render time — no plugin code changes needed
to swap artwork across themes.
## What this demo proves
The dashboard skin+plugin system supports (ref: `web/src/themes/types.ts`,
`web/src/plugins/slots.ts`):
- Palette, typography, font URLs, density, radius — already present
- **Asset URLs exposed as CSS vars** (bg / hero / crest / logo /
sidebar / header + arbitrary `custom.*`)
- **Raw `customCSS` blocks** injected as scoped `<style>` tags
- **Per-component style overrides** (card / header / sidebar / backdrop /
tab / progress / footer / badge / page) via CSS vars
- **`layoutVariant`** — `standard`, `cockpit`, or `tiled`
- **Plugin slots** — 10 named shell slots plugins can inject into
(`backdrop`, `header-left/right/banner`, `sidebar`, `pre-main`,
`post-main`, `footer-left/right`, `overlay`)
- **Route overrides** — plugins can replace a built-in page entirely
(`tab.override: "/"`) instead of just adding a tab
- **Hidden plugins** — slot-only plugins that never show in the nav
(`tab.hidden: true`) — as used here

View file

@ -0,0 +1,309 @@
/**
* Strike Freedom Cockpit dashboard plugin demo.
*
* A slot-only plugin (manifest sets tab.hidden: true) that populates
* three shell slots when the user has the ``strike-freedom`` theme
* selected (or any theme that picks layoutVariant: cockpit):
*
* - sidebar MS-STATUS panel: ENERGY / SHIELD / POWER bars,
* ZGMF-X20A identity line, pilot block, hero
* render (from --theme-asset-hero when the theme
* provides one).
* - header-left COMPASS faction crest (uses --theme-asset-crest
* if provided, falls back to a geometric SVG).
* - footer-right COSMIC ERA tagline that replaces the default
* footer org line.
*
* The plugin demonstrates every extension point added alongside the
* slot system: registerSlot, tab.hidden, reading theme asset CSS vars
* from plugin code, and rendering above the built-in route content.
*/
(function () {
"use strict";
const SDK = window.__HERMES_PLUGIN_SDK__;
const PLUGINS = window.__HERMES_PLUGINS__;
if (!SDK || !PLUGINS || !PLUGINS.registerSlot) {
// Old dashboard bundle without slot support — bail silently rather
// than breaking the page.
return;
}
const { React } = SDK;
const { useState, useEffect } = SDK.hooks;
const { api } = SDK;
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
/** Read a CSS custom property from :root. Empty string when unset. */
function cssVar(name) {
if (typeof document === "undefined") return "";
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
/** Segmented chip progress bar — 10 cells filled proportionally to value. */
function TelemetryBar(props) {
const { label, value, color } = props;
const cells = [];
for (let i = 0; i < 10; i++) {
const filled = Math.round(value / 10) > i;
cells.push(
React.createElement("span", {
key: i,
style: {
flex: 1,
height: 8,
background: filled ? color : "rgba(255,255,255,0.06)",
transition: "background 200ms",
clipPath: "polygon(2px 0, 100% 0, calc(100% - 2px) 100%, 0 100%)",
},
}),
);
}
return React.createElement(
"div",
{ style: { display: "flex", flexDirection: "column", gap: 4 } },
React.createElement(
"div",
{
style: {
display: "flex",
justifyContent: "space-between",
fontSize: "0.65rem",
letterSpacing: "0.12em",
opacity: 0.75,
},
},
React.createElement("span", null, label),
React.createElement("span", { style: { color, fontWeight: 700 } }, value + "%"),
),
React.createElement(
"div",
{ style: { display: "flex", gap: 2 } },
cells,
),
);
}
// ---------------------------------------------------------------------
// Sidebar: MS-STATUS panel
// ---------------------------------------------------------------------
function SidebarSlot() {
// Pull live-ish numbers from the status API so the plugin isn't just
// a static decoration. Fall back to full bars if the API is slow /
// unavailable.
const [status, setStatus] = useState(null);
useEffect(function () {
let cancel = false;
api.getStatus()
.then(function (s) { if (!cancel) setStatus(s); })
.catch(function () {});
return function () { cancel = true; };
}, []);
// Map real status signals to HUD telemetry. Energy/shield/power
// aren't literal concepts on a software agent, so we read them from
// adjacent signals: active sessions, gateway connected-platforms,
// and agent-online health.
const energy = status && status.gateway_online ? 92 : 18;
const shield = status && status.connected_platforms
? Math.min(100, 40 + (status.connected_platforms.length * 15))
: 70;
const power = status && status.active_sessions
? Math.min(100, 55 + (status.active_sessions.length * 10))
: 87;
const hero = cssVar("--theme-asset-hero");
return React.createElement(
"div",
{
style: {
padding: "1rem 0.75rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
fontFamily: "var(--theme-font-display, sans-serif)",
letterSpacing: "0.08em",
textTransform: "uppercase",
fontSize: "0.65rem",
},
},
// Header line
React.createElement(
"div",
{
style: {
borderBottom: "1px solid rgba(64,200,255,0.3)",
paddingBottom: 8,
display: "flex",
flexDirection: "column",
gap: 2,
},
},
React.createElement("span", { style: { opacity: 0.6 } }, "ms status"),
React.createElement("span", { style: { fontWeight: 700, fontSize: "0.85rem" } }, "zgmf-x20a"),
React.createElement("span", { style: { opacity: 0.6, fontSize: "0.6rem" } }, "strike freedom"),
),
// Hero slot — only renders when the theme provides one.
hero
? React.createElement("div", {
style: {
width: "100%",
aspectRatio: "3 / 4",
backgroundImage: hero,
backgroundSize: "contain",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
opacity: 0.85,
},
"aria-hidden": true,
})
: React.createElement("div", {
style: {
width: "100%",
aspectRatio: "3 / 4",
border: "1px dashed rgba(64,200,255,0.25)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.55rem",
opacity: 0.4,
},
}, "hero slot — set assets.hero in theme"),
// Pilot block
React.createElement(
"div",
{
style: {
borderTop: "1px solid rgba(64,200,255,0.18)",
borderBottom: "1px solid rgba(64,200,255,0.18)",
padding: "8px 0",
display: "flex",
flexDirection: "column",
gap: 2,
},
},
React.createElement("span", { style: { opacity: 0.5, fontSize: "0.55rem" } }, "pilot"),
React.createElement("span", { style: { fontWeight: 700 } }, "hermes agent"),
React.createElement("span", { style: { opacity: 0.5, fontSize: "0.55rem" } }, "compass"),
),
// Telemetry bars
React.createElement(TelemetryBar, { label: "energy", value: energy, color: "#ffce3a" }),
React.createElement(TelemetryBar, { label: "shield", value: shield, color: "#3fd3ff" }),
React.createElement(TelemetryBar, { label: "power", value: power, color: "#ff3a5e" }),
// System online
React.createElement(
"div",
{
style: {
marginTop: 4,
padding: "6px 8px",
border: "1px solid rgba(74,222,128,0.4)",
color: "#4ade80",
textAlign: "center",
fontWeight: 700,
fontSize: "0.6rem",
},
},
status && status.gateway_online ? "system online" : "system offline",
),
);
}
// ---------------------------------------------------------------------
// Header-left: COMPASS crest
// ---------------------------------------------------------------------
function HeaderCrestSlot() {
const crest = cssVar("--theme-asset-crest");
const inner = crest
? React.createElement("div", {
style: {
width: 28,
height: 28,
backgroundImage: crest,
backgroundSize: "contain",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
},
"aria-hidden": true,
})
: React.createElement(
"svg",
{
width: 28,
height: 28,
viewBox: "0 0 28 28",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.5,
"aria-hidden": true,
},
React.createElement("path", { d: "M14 2 L26 14 L14 26 L2 14 Z" }),
React.createElement("path", { d: "M14 8 L20 14 L14 20 L8 14 Z" }),
React.createElement("circle", { cx: 14, cy: 14, r: 2, fill: "currentColor" }),
);
return React.createElement(
"div",
{
style: {
display: "flex",
alignItems: "center",
paddingLeft: 12,
paddingRight: 8,
color: "var(--color-accent, #3fd3ff)",
},
},
inner,
);
}
// ---------------------------------------------------------------------
// Footer-right: COSMIC ERA tagline
// ---------------------------------------------------------------------
function FooterTaglineSlot() {
return React.createElement(
"span",
{
style: {
fontFamily: "var(--theme-font-display, sans-serif)",
fontSize: "0.6rem",
letterSpacing: "0.18em",
textTransform: "uppercase",
opacity: 0.75,
mixBlendMode: "plus-lighter",
},
},
"compass hermes systems / cosmic era 71",
);
}
// ---------------------------------------------------------------------
// Hidden tab placeholder — tab.hidden=true means this never renders in
// the nav, but we still register something sensible in case someone
// manually navigates to /strike-freedom-cockpit (e.g. via a bookmark).
// ---------------------------------------------------------------------
function HiddenPage() {
return React.createElement(
"div",
{ style: { padding: "2rem", opacity: 0.6, fontSize: "0.8rem" } },
"Strike Freedom cockpit is a slot-only plugin — it populates the sidebar, header, and footer instead of showing a tab page.",
);
}
// ---------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------
const NAME = "strike-freedom-cockpit";
PLUGINS.register(NAME, HiddenPage);
PLUGINS.registerSlot(NAME, "sidebar", SidebarSlot);
PLUGINS.registerSlot(NAME, "header-left", HeaderCrestSlot);
PLUGINS.registerSlot(NAME, "footer-right", FooterTaglineSlot);
})();

View file

@ -0,0 +1,14 @@
{
"name": "strike-freedom-cockpit",
"label": "Strike Freedom Cockpit",
"description": "MS-STATUS sidebar + header crest for the Strike Freedom theme",
"icon": "Shield",
"version": "1.0.0",
"tab": {
"path": "/strike-freedom-cockpit",
"position": "end",
"hidden": true
},
"slots": ["sidebar", "header-left", "footer-right"],
"entry": "dist/index.js"
}

View file

@ -0,0 +1,126 @@
# Strike Freedom — Hermes dashboard theme demo
#
# Copy this file to ~/.hermes/dashboard-themes/strike-freedom.yaml and
# restart the web UI (or hit `/api/dashboard/plugins/rescan`). Pair with
# the `strike-freedom-cockpit` plugin (plugins/strike-freedom-cockpit/)
# for the full cockpit experience — this theme paints the palette,
# chrome, and layout; the plugin supplies the MS-STATUS sidebar + header
# crest that the cockpit layout variant reserves space for.
#
# Demonstrates every theme extension point added alongside the plugin
# slot system: palette, typography, layoutVariant, assets, customCSS,
# componentStyles, colorOverrides.
name: strike-freedom
label: "Strike Freedom"
description: "Cockpit HUD — deep navy + cyan + gold accents"
# ------- palette (3-layer) -------
palette:
background: "#05091a"
midground: "#d8f0ff"
foreground:
hex: "#ffffff"
alpha: 0
warmGlow: "rgba(255, 199, 55, 0.24)"
noiseOpacity: 0.7
# ------- typography -------
typography:
fontSans: '"Orbitron", "Eurostile", "Bank Gothic", "Impact", sans-serif'
fontMono: '"Share Tech Mono", "JetBrains Mono", ui-monospace, monospace'
fontDisplay: '"Orbitron", "Eurostile", "Impact", sans-serif'
fontUrl: "https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800&family=Share+Tech+Mono&display=swap"
baseSize: "14px"
lineHeight: "1.5"
letterSpacing: "0.04em"
# ------- layout -------
layout:
radius: "0"
density: "compact"
# ``cockpit`` reserves a 260px left rail that the shell renders when the
# user is on this theme. A paired plugin populates the rail via the
# ``sidebar`` slot; with no plugin the rail shows a placeholder.
layoutVariant: cockpit
# ------- assets -------
# Use any URL (https, data:, /dashboard-plugins/...) or a pre-wrapped
# ``url(...)``/``linear-gradient(...)`` expression. The shell exposes
# each as a CSS var so plugins can read the same imagery.
assets:
bg: "linear-gradient(140deg, #05091a 0%, #0a1530 55%, #102048 100%)"
# Plugin reads --theme-asset-hero / --theme-asset-crest to populate
# its sidebar hero render + header crest. Replace these URLs with your
# own artwork (copy files into ~/.hermes/dashboard-themes/assets/ and
# reference them as /dashboard-themes-assets/strike-freedom/hero.png
# once that static route is wired up — for now use inline data URLs or
# remote URLs).
hero: ""
crest: ""
# ------- component chrome -------
# Each bucket's props become CSS vars (--component-<bucket>-<kebab>) that
# built-in shell components (Card, header, sidebar, backdrop) consume.
componentStyles:
card:
# Notched corners on the top-left + bottom-right — classic mecha UI.
clipPath: "polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px)"
background: "linear-gradient(180deg, rgba(10, 22, 52, 0.85) 0%, rgba(5, 9, 26, 0.92) 100%)"
boxShadow: "inset 0 0 0 1px rgba(64, 200, 255, 0.28), 0 0 18px -6px rgba(64, 200, 255, 0.4)"
header:
background: "linear-gradient(180deg, rgba(16, 32, 72, 0.95) 0%, rgba(5, 9, 26, 0.9) 100%)"
sidebar:
background: "linear-gradient(180deg, rgba(8, 18, 42, 0.88) 0%, rgba(5, 9, 26, 0.85) 100%)"
tab:
clipPath: "polygon(6px 0, 100% 0, calc(100% - 6px) 100%, 0 100%)"
backdrop:
backgroundSize: "cover"
backgroundPosition: "center"
fillerOpacity: "1"
fillerBlendMode: "normal"
# ------- color overrides -------
colorOverrides:
primary: "#ffce3a"
primaryForeground: "#05091a"
accent: "#3fd3ff"
accentForeground: "#05091a"
ring: "#3fd3ff"
success: "#4ade80"
warning: "#ffce3a"
destructive: "#ff3a5e"
border: "rgba(64, 200, 255, 0.28)"
# ------- customCSS -------
# Raw CSS injected as a scoped <style> tag on theme apply. Use this for
# selector-level tweaks componentStyles can't express (pseudo-elements,
# animations, media queries). Bounded to 32 KiB per theme.
customCSS: |
/* Scanline overlay — subtle, only when theme is active. */
:root[data-layout-variant="cockpit"] body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
background: repeating-linear-gradient(
to bottom,
transparent 0px,
transparent 2px,
rgba(64, 200, 255, 0.035) 3px,
rgba(64, 200, 255, 0.035) 4px
);
mix-blend-mode: screen;
}
/* Chevron pips on card corners. */
[data-layout-variant="cockpit"] .border-border::before,
[data-layout-variant="cockpit"] .border-border::after {
content: "";
position: absolute;
width: 8px;
height: 8px;
border: 1px solid rgba(64, 200, 255, 0.55);
pointer-events: none;
}

View file

@ -1473,3 +1473,207 @@ class TestDiscoverUserThemes:
assert "ok" in names assert "ok" in names
assert "bad" not in names # malformed YAML assert "bad" not in names # malformed YAML
assert len(results) == 1 # only the valid one assert len(results) == 1 # only the valid one
class TestNormaliseThemeExtensions:
"""Tests for the extended normaliser fields (assets, customCSS,
componentStyles, layoutVariant) the surfaces themes use to reskin
the dashboard without shipping code."""
def test_layout_variant_defaults_to_standard(self):
from hermes_cli.web_server import _normalise_theme_definition
result = _normalise_theme_definition({"name": "t"})
assert result["layoutVariant"] == "standard"
def test_layout_variant_accepts_known_values(self):
from hermes_cli.web_server import _normalise_theme_definition
for variant in ("standard", "cockpit", "tiled"):
r = _normalise_theme_definition({"name": "t", "layoutVariant": variant})
assert r["layoutVariant"] == variant
def test_layout_variant_rejects_unknown(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({"name": "t", "layoutVariant": "warship"})
assert r["layoutVariant"] == "standard"
r2 = _normalise_theme_definition({"name": "t", "layoutVariant": 12})
assert r2["layoutVariant"] == "standard"
def test_assets_named_slots_passthrough(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"assets": {
"bg": "https://example.com/bg.jpg",
"hero": "linear-gradient(180deg, red, blue)",
"crest": "/ds-assets/crest.svg",
"logo": " ", # whitespace-only — dropped
"notAKnownKey": "ignored",
},
})
assert r["assets"]["bg"] == "https://example.com/bg.jpg"
assert r["assets"]["hero"].startswith("linear-gradient")
assert r["assets"]["crest"] == "/ds-assets/crest.svg"
assert "logo" not in r["assets"] # whitespace-only rejected
assert "notAKnownKey" not in r["assets"] # unknown slot ignored
def test_assets_custom_block(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"assets": {
"custom": {
"scan-lines": "/img/scan.png",
"my_overlay": "/img/ov.png",
"bad key!": "x", # non-alnum key — rejected
"empty": "", # empty value — rejected
},
},
})
assert r["assets"]["custom"] == {
"scan-lines": "/img/scan.png",
"my_overlay": "/img/ov.png",
}
def test_assets_absent_means_no_field(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({"name": "t"})
assert "assets" not in r
def test_custom_css_passthrough_and_capped(self):
from hermes_cli.web_server import _normalise_theme_definition
# Small CSS passes through verbatim.
r = _normalise_theme_definition({
"name": "t",
"customCSS": "body { color: red; }",
})
assert r["customCSS"] == "body { color: red; }"
# 40 KiB of CSS gets clipped to the 32 KiB cap.
huge = "/* x */ " * (40 * 1024 // 8 + 10)
r2 = _normalise_theme_definition({"name": "t", "customCSS": huge})
assert len(r2["customCSS"]) <= 32 * 1024
def test_custom_css_empty_dropped(self):
from hermes_cli.web_server import _normalise_theme_definition
for val in ("", " \n\t", None):
r = _normalise_theme_definition({"name": "t", "customCSS": val})
assert "customCSS" not in r
def test_component_styles_per_bucket(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"componentStyles": {
"card": {
"clipPath": "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
"boxShadow": "inset 0 0 0 1px red",
"bad prop!": "ignored", # non-alnum prop rejected
},
"header": {"background": "linear-gradient(red, blue)"},
"rogueBucket": {"foo": "bar"}, # not a known bucket — rejected
},
})
assert r["componentStyles"]["card"] == {
"clipPath": "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
"boxShadow": "inset 0 0 0 1px red",
}
assert r["componentStyles"]["header"]["background"].startswith("linear-gradient")
assert "rogueBucket" not in r["componentStyles"]
def test_component_styles_empty_buckets_dropped(self):
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"componentStyles": {
"card": {}, # empty — dropped entirely
"header": {"bad prop!": "ignored"}, # all props rejected — bucket dropped
"footer": {"background": "black"},
},
})
assert "card" not in r.get("componentStyles", {})
assert "header" not in r.get("componentStyles", {})
assert r["componentStyles"]["footer"]["background"] == "black"
def test_component_styles_accepts_numeric_values(self):
"""Numeric values (e.g. opacity: 0.8) are coerced to strings."""
from hermes_cli.web_server import _normalise_theme_definition
r = _normalise_theme_definition({
"name": "t",
"componentStyles": {"card": {"opacity": 0.8, "zIndex": 5}},
})
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
class TestDashboardPluginManifestExtensions:
"""Tests for the extended plugin manifest fields (tab.override,
tab.hidden, slots) read by _discover_dashboard_plugins()."""
def _write_plugin(self, tmp_path, name, manifest):
import json
plug_dir = tmp_path / "plugins" / name / "dashboard"
plug_dir.mkdir(parents=True)
(plug_dir / "manifest.json").write_text(json.dumps(manifest))
return plug_dir
def test_override_and_hidden_carried_through(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "skin-home", {
"name": "skin-home",
"label": "Skin Home",
"tab": {"path": "/skin-home", "override": "/", "hidden": True},
"slots": ["sidebar", "header-left"],
"entry": "dist/index.js",
})
from hermes_cli import web_server
# Bust the process-level cache so the test plugin is picked up.
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "skin-home")
assert entry["tab"]["override"] == "/"
assert entry["tab"]["hidden"] is True
assert entry["slots"] == ["sidebar", "header-left"]
def test_override_requires_leading_slash(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "bad-override", {
"name": "bad-override",
"label": "Bad",
"tab": {"path": "/bad", "override": "no-leading-slash"},
"entry": "dist/index.js",
})
from hermes_cli import web_server
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "bad-override")
assert "override" not in entry["tab"]
def test_slots_default_empty(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "no-slots", {
"name": "no-slots",
"label": "No Slots",
"tab": {"path": "/no-slots"},
"entry": "dist/index.js",
})
from hermes_cli import web_server
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "no-slots")
assert entry["slots"] == []
assert "hidden" not in entry["tab"]
assert "override" not in entry["tab"]
def test_slots_filters_non_string_entries(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
self._write_plugin(tmp_path, "mixed-slots", {
"name": "mixed-slots",
"label": "Mixed",
"tab": {"path": "/mixed-slots"},
"slots": ["sidebar", "", 42, None, "header-right"],
"entry": "dist/index.js",
})
from hermes_cli import web_server
web_server._dashboard_plugins_cache = None
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "mixed-slots")
assert entry["slots"] == ["sidebar", "header-right"]

View file

@ -36,8 +36,23 @@ import SkillsPage from "@/pages/SkillsPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher"; import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { usePlugins } from "@/plugins"; import { PluginSlot, usePlugins } from "@/plugins";
import type { RegisteredPlugin } from "@/plugins"; import type { RegisteredPlugin } from "@/plugins";
import { useTheme } from "@/themes";
/** Built-in route default page component. Used both for standard routing
* and for resolving plugin `tab.override` values. Keys must match the
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
"/": StatusPage,
"/sessions": SessionsPage,
"/analytics": AnalyticsPage,
"/logs": LogsPage,
"/cron": CronPage,
"/skills": SkillsPage,
"/config": ConfigPage,
"/env": EnvPage,
};
const BUILTIN_NAV: NavItem[] = [ const BUILTIN_NAV: NavItem[] = [
{ path: "/", labelKey: "status", label: "Status", icon: Activity }, { path: "/", labelKey: "status", label: "Status", icon: Activity },
@ -98,6 +113,13 @@ function buildNavItems(
const items = [...builtIn]; const items = [...builtIn];
for (const { manifest } of plugins) { for (const { manifest } of plugins) {
// Plugins that replace a built-in route don't add a new tab entry —
// they reuse the existing tab. The nav just lights up the original
// built-in entry when the user visits `/`.
if (manifest.tab.override) continue;
// Hidden plugins register their component + slots but skip the nav.
if (manifest.tab.hidden) continue;
const pluginItem: NavItem = { const pluginItem: NavItem = {
path: manifest.tab.path, path: manifest.tab.path,
label: manifest.label, label: manifest.label,
@ -123,19 +145,89 @@ function buildNavItems(
return items; return items;
} }
/** Build the final route table, letting plugins override built-in pages.
*
* Returns (path, Component, key) tuples. Plugins with `tab.override`
* win over both built-ins and other plugins (last registration wins if
* two plugins claim the same override, but we warn in dev). Plugins with
* a regular `tab.path` register alongside built-ins as standalone
* routes. */
function buildRoutes(
plugins: RegisteredPlugin[],
): Array<{ key: string; path: string; Component: React.ComponentType }> {
const overrides = new Map<string, RegisteredPlugin>();
const addons: RegisteredPlugin[] = [];
for (const p of plugins) {
if (p.manifest.tab.override) {
overrides.set(p.manifest.tab.override, p);
} else {
addons.push(p);
}
}
const routes: Array<{
key: string;
path: string;
Component: React.ComponentType;
}> = [];
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
const override = overrides.get(path);
if (override) {
routes.push({
key: `override:${override.manifest.name}`,
path,
Component: override.component,
});
} else {
routes.push({ key: `builtin:${path}`, path, Component });
}
}
for (const addon of addons) {
// Don't double-register a plugin that shadows a built-in path via
// `tab.path` — `override` is the supported mechanism for that.
if (BUILTIN_ROUTES[addon.manifest.tab.path]) continue;
routes.push({
key: `plugin:${addon.manifest.name}`,
path: addon.manifest.tab.path,
Component: addon.component,
});
}
return routes;
}
export default function App() { export default function App() {
const { t } = useI18n(); const { t } = useI18n();
const { plugins } = usePlugins(); const { plugins } = usePlugins();
const { theme } = useTheme();
const navItems = useMemo( const navItems = useMemo(
() => buildNavItems(BUILTIN_NAV, plugins), () => buildNavItems(BUILTIN_NAV, plugins),
[plugins], [plugins],
); );
const routes = useMemo(() => buildRoutes(plugins), [plugins]);
const layoutVariant = theme.layoutVariant ?? "standard";
const showSidebar = layoutVariant === "cockpit";
// Tiled layout drops the 1600px clamp so pages can use the full viewport;
// standard + cockpit keep the centered reading width.
const mainMaxWidth = layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
return ( return (
<div className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden"> <div
data-layout-variant={layoutVariant}
className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden"
>
<SelectionSwitcher /> <SelectionSwitcher />
<Backdrop /> <Backdrop />
{/* Themes can style backdrop chrome via `componentStyles.backdrop.*`
CSS vars read by <Backdrop />. Plugins can also inject full
components into the backdrop layer via the `backdrop` slot
useful for scanlines, parallax stars, hero artwork, etc. */}
<PluginSlot name="backdrop" />
<header <header
className={cn( className={cn(
@ -143,8 +235,17 @@ export default function App() {
"border-b border-current/20", "border-b border-current/20",
"bg-background-base/90 backdrop-blur-sm", "bg-background-base/90 backdrop-blur-sm",
)} )}
style={{
// Themes can tweak header chrome (background, border-image,
// clip-path) via these CSS vars. Unset vars compute to the
// property's initial value, so themes opt in per-property.
background: "var(--component-header-background)",
borderImage: "var(--component-header-border-image)",
clipPath: "var(--component-header-clip-path)",
}}
> >
<div className="mx-auto flex h-12 max-w-[1600px]"> <div className={cn("mx-auto flex h-12", mainMaxWidth)}>
<PluginSlot name="header-left" />
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none"> <div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<Grid <Grid
className="h-full !border-t-0 !border-b-0" className="h-full !border-t-0 !border-b-0"
@ -180,6 +281,9 @@ export default function App() {
: "opacity-60 hover:opacity-100", : "opacity-60 hover:opacity-100",
) )
} }
style={{
clipPath: "var(--component-tab-clip-path)",
}}
> >
{({ isActive }) => ( {({ isActive }) => (
<> <>
@ -214,6 +318,7 @@ export default function App() {
<Grid className="h-full shrink-0 !border-t-0 !border-b-0"> <Grid className="h-full shrink-0 !border-t-0 !border-b-0">
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4"> <Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
<PluginSlot name="header-right" />
<ThemeSwitcher /> <ThemeSwitcher />
<LanguageSwitcher /> <LanguageSwitcher />
<Typography <Typography
@ -227,50 +332,92 @@ export default function App() {
</div> </div>
</header> </header>
<main className="relative z-2 mx-auto w-full max-w-[1600px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8"> {/* Full-width banner slot under the nav, outside the main clamp
<Routes> useful for marquee/alert/status strips themes want to show
<Route path="/" element={<StatusPage />} /> above page content. */}
<Route path="/sessions" element={<SessionsPage />} /> <PluginSlot name="header-banner" />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/cron" element={<CronPage />} />
<Route path="/skills" element={<SkillsPage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/env" element={<EnvPage />} />
{plugins.map(({ manifest, component: PluginComponent }) => ( <div
<Route className={cn(
key={manifest.name} "relative z-2 mx-auto w-full flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8",
path={manifest.tab.path} mainMaxWidth,
element={<PluginComponent />} showSidebar && "flex gap-4 sm:gap-6",
)}
>
{showSidebar && (
<aside
className={cn(
"w-[260px] shrink-0 border-r border-current/20 pr-3 sm:pr-4",
"hidden lg:block",
)}
style={{
background: "var(--component-sidebar-background)",
clipPath: "var(--component-sidebar-clip-path)",
borderImage: "var(--component-sidebar-border-image)",
}}
>
<PluginSlot
name="sidebar"
fallback={
<div className="p-4 text-xs opacity-60 font-mondwest tracking-wide">
{/* Cockpit layout with no sidebar plugin rare but valid;
the space still exists so the grid doesn't shift when
a plugin loads asynchronously. */}
sidebar slot empty
</div>
}
/> />
))} </aside>
)}
<Route path="*" element={<Navigate to="/" replace />} /> <main className="min-w-0 flex-1">
</Routes> <PluginSlot name="pre-main" />
</main> <Routes>
{routes.map(({ key, path, Component }) => (
<Route key={key} path={path} element={<Component />} />
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<PluginSlot name="post-main" />
</main>
</div>
<footer className="relative z-2 border-t border-current/20"> <footer className="relative z-2 border-t border-current/20">
<Grid className="mx-auto max-w-[1600px] !border-t-0 !border-b-0"> <Grid className={cn("mx-auto !border-t-0 !border-b-0", mainMaxWidth)}>
<Cell className="flex items-center !px-3 sm:!px-6 !py-3"> <Cell className="flex items-center !px-3 sm:!px-6 !py-3">
<Typography <PluginSlot
mondwest name="footer-left"
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60" fallback={
> <Typography
{t.app.footer.name} mondwest
</Typography> className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
>
{t.app.footer.name}
</Typography>
}
/>
</Cell> </Cell>
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3"> <Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
<Typography <PluginSlot
mondwest name="footer-right"
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground" fallback={
style={{ mixBlendMode: "plus-lighter" }} <Typography
> mondwest
{t.app.footer.org} className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
</Typography> style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</Typography>
}
/>
</Cell> </Cell>
</Grid> </Grid>
</footer> </footer>
{/* Fixed-position overlay plugins (scanlines, vignettes, etc.) render
above everything else. Each plugin is responsible for its own
pointer-events and z-index. */}
<PluginSlot name="overlay" />
</div> </div>
); );
} }

View file

@ -38,11 +38,27 @@ export function Backdrop() {
<div <div
aria-hidden aria-hidden
className="pointer-events-none fixed inset-0 z-[2]" className="pointer-events-none fixed inset-0 z-[2]"
style={{ mixBlendMode: "difference", opacity: 0.033 }} style={
{
// Themes can override the filler background by setting
// `assets.bg` — the <img> hides itself when a CSS bg is set
// so the two don't double-darken. CSS var fallbacks keep the
// default behaviour unchanged when no theme customises these.
mixBlendMode: "var(--component-backdrop-filler-blend-mode, difference)",
opacity: "var(--component-backdrop-filler-opacity, 0.033)",
backgroundImage: "var(--theme-asset-bg)",
backgroundSize: "var(--component-backdrop-background-size, cover)",
backgroundPosition: "var(--component-backdrop-background-position, center)",
} as unknown as React.CSSProperties
}
> >
{/* Default filler image only renders when no theme-asset-bg is
set. Themes that provide their own `assets.bg` override the
<div>'s backgroundImage above, so hiding the <img> in that
case prevents the two from compositing incorrectly. */}
<img <img
alt="" alt=""
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert" className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
fetchPriority="low" fetchPriority="low"
src="/ds-assets/filler-bg0.jpg" src="/ds-assets/filler-bg0.jpg"
/> />

View file

@ -1,12 +1,35 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { /**
* Themed card primitive. Themes can restyle every card without touching
* call sites by setting CSS vars under the `card` component-style bucket:
*
* componentStyles:
* card:
* clipPath: "polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px)"
* border: "1px solid var(--color-ring)"
* background: "linear-gradient(180deg, var(--color-card) 0%, transparent 100%)"
* boxShadow: "0 0 0 1px var(--color-ring) inset, 0 0 24px -8px var(--warm-glow)"
*
* All properties are optional vars that aren't set compute to their
* CSS initial value, so the default shadcn-y card keeps looking normal
* for themes that don't override anything.
*/
const CARD_STYLE: React.CSSProperties = {
clipPath: "var(--component-card-clip-path)",
borderImage: "var(--component-card-border-image)",
background: "var(--component-card-background)",
boxShadow: "var(--component-card-box-shadow)",
};
export function Card({ className, style, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <div
className={cn( className={cn(
"border border-border bg-card/80 text-card-foreground w-full", "border border-border bg-card/80 text-card-foreground w-full",
className, className,
)} )}
style={{ ...CARD_STYLE, ...style }}
{...props} {...props}
/> />
); );

View file

@ -162,3 +162,14 @@ code { font-size: 0.875rem; }
2px 2px; 2px 2px;
} }
/* When a theme provides `assets.bg`, the backdrop's <div> renders it as
a CSS background; the default filler <img> is hidden to prevent
double-compositing. Unset initial empty, so the :not() selector
matches and the default image stays visible. */
:root:not([style*="--theme-asset-bg:"]) .theme-default-filler {
display: block;
}
:root[style*="--theme-asset-bg:"] .theme-default-filler {
display: none;
}

View file

@ -1,3 +1,5 @@
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry"; export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
export { usePlugins } from "./usePlugins"; export { usePlugins } from "./usePlugins";
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
export type { KnownSlotName } from "./slots";
export type { PluginManifest, RegisteredPlugin } from "./types"; export type { PluginManifest, RegisteredPlugin } from "./types";

View file

@ -28,6 +28,7 @@ import { Select, SelectOption } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { registerSlot, PluginSlot } from "./slots";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin registry — plugins call register() to add their component. // Plugin registry — plugins call register() to add their component.
@ -75,6 +76,7 @@ declare global {
__HERMES_PLUGIN_SDK__: unknown; __HERMES_PLUGIN_SDK__: unknown;
__HERMES_PLUGINS__: { __HERMES_PLUGINS__: {
register: typeof registerPlugin; register: typeof registerPlugin;
registerSlot: typeof registerSlot;
}; };
} }
} }
@ -82,6 +84,7 @@ declare global {
export function exposePluginSDK() { export function exposePluginSDK() {
window.__HERMES_PLUGINS__ = { window.__HERMES_PLUGINS__ = {
register: registerPlugin, register: registerPlugin,
registerSlot,
}; };
window.__HERMES_PLUGIN_SDK__ = { window.__HERMES_PLUGIN_SDK__ = {
@ -118,6 +121,7 @@ export function exposePluginSDK() {
Tabs, Tabs,
TabsList, TabsList,
TabsTrigger, TabsTrigger,
PluginSlot,
}, },
// Utilities // Utilities

152
web/src/plugins/slots.ts Normal file
View file

@ -0,0 +1,152 @@
/**
* Plugin slot registry.
*
* Plugins can inject components into named locations in the app shell
* (header-left, sidebar, backdrop, etc.) by calling
* `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
* from their JS bundle. Multiple plugins can populate the same slot they
* render stacked in registration order.
*
* The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
* registry accepts any string so plugin ecosystems can define their own
* slots; the shell only renders `<PluginSlot name="..." />` for the slots
* it knows about.
*/
import React, { Fragment, useEffect, useState } from "react";
/** Slot locations the built-in shell renders. Plugins declaring any of
* these in their manifest's `slots` field get wired in automatically.
*
* - `backdrop` rendered inside `<Backdrop />`, above the noise layer
* - `header-left` injected before the Hermes brand in the top bar
* - `header-right` injected before the theme/language switchers
* - `header-banner` injected below the top nav bar, full-width
* - `sidebar` the cockpit sidebar rail (only rendered when
* `layoutVariant === "cockpit"`)
* - `pre-main` rendered above the route outlet (inside `<main>`)
* - `post-main` rendered below the route outlet (inside `<main>`)
* - `footer-left` replaces the left footer cell content
* - `footer-right` replaces the right footer cell content
* - `overlay` fixed-position layer above everything else;
* useful for chrome (scanlines, vignettes) the
* theme's customCSS can't achieve alone
*/
export const KNOWN_SLOT_NAMES = [
"backdrop",
"header-left",
"header-right",
"header-banner",
"sidebar",
"pre-main",
"post-main",
"footer-left",
"footer-right",
"overlay",
] as const;
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
type SlotListener = () => void;
interface SlotEntry {
plugin: string;
component: React.ComponentType;
}
/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
const _slotListeners: Set<SlotListener> = new Set();
function _notifySlots() {
for (const fn of _slotListeners) {
try {
fn();
} catch {
/* ignore */
}
}
}
/** Register a component for a slot. Called by plugin bundles via
* `window.__HERMES_PLUGINS__.registerSlot(...)`.
*
* If the same (plugin, slot) pair is registered twice, the later call
* replaces the earlier one this matches how React HMR expects plugin
* re-mounts to behave. */
export function registerSlot(
plugin: string,
slot: string,
component: React.ComponentType,
): void {
const existing = _slotRegistry.get(slot) ?? [];
const filtered = existing.filter((e) => e.plugin !== plugin);
filtered.push({ plugin, component });
_slotRegistry.set(slot, filtered);
_notifySlots();
}
/** Read current entries for a slot. Returns a copy so callers can't mutate
* registry state. */
export function getSlotEntries(slot: string): SlotEntry[] {
return (_slotRegistry.get(slot) ?? []).slice();
}
/** Subscribe to registry changes. Returns an unsubscribe function. */
export function onSlotRegistered(fn: SlotListener): () => void {
_slotListeners.add(fn);
return () => {
_slotListeners.delete(fn);
};
}
/** Clear a specific plugin's slot registrations. Useful for HMR /
* plugin reload flows not wired in by default. */
export function unregisterPluginSlots(plugin: string): void {
let changed = false;
for (const [slot, entries] of _slotRegistry.entries()) {
const kept = entries.filter((e) => e.plugin !== plugin);
if (kept.length !== entries.length) {
changed = true;
if (kept.length === 0) _slotRegistry.delete(slot);
else _slotRegistry.set(slot, kept);
}
}
if (changed) _notifySlots();
}
interface PluginSlotProps {
/** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
name: string;
/** Optional content rendered when no plugins have claimed the slot.
* Useful for built-in defaults the plugin would replace. */
fallback?: React.ReactNode;
}
/** Render all components registered for a given slot, stacked in order.
*
* Component re-renders when the slot registry changes so plugins that
* arrive after initial mount show up without a manual refresh. */
export function PluginSlot({ name, fallback }: PluginSlotProps) {
const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
useEffect(() => {
// Pick up anything registered between the initial `useState` call
// and the first effect tick, then subscribe for future changes.
setEntries(getSlotEntries(name));
const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
return unsub;
}, [name]);
if (entries.length === 0) {
return fallback ? React.createElement(Fragment, null, fallback) : null;
}
return React.createElement(
Fragment,
null,
...entries.map((entry) =>
React.createElement(entry.component, { key: entry.plugin }),
),
);
}

View file

@ -9,7 +9,21 @@ export interface PluginManifest {
tab: { tab: {
path: string; path: string;
position: string; // "end", "after:<tab>", "before:<tab>" position: string; // "end", "after:<tab>", "before:<tab>"
/** When set to a built-in route path (e.g. `"/"`, `"/sessions"`), this
* plugin's component replaces the built-in page at that route rather
* than adding a new tab. Useful for themes that want a custom home
* page without losing the rest of the dashboard. */
override?: string;
/** When true, the plugin registers its component and slot contributors
* without adding a tab to the nav. Used by slot-only plugins (e.g. a
* plugin that just injects a header crest). */
hidden?: boolean;
}; };
/** Named shell slots this plugin populates. Mirrored by the backend's
* manifest discovery; used purely as a documentation/discovery aid
* actual slot registration happens when the plugin's JS bundle calls
* `window.__HERMES_PLUGINS__.registerSlot(name, slot, Component)`. */
slots?: string[];
entry: string; entry: string;
css?: string | null; css?: string | null;
has_api: boolean; has_api: boolean;

View file

@ -10,10 +10,13 @@ import {
import { BUILTIN_THEMES, defaultTheme } from "./presets"; import { BUILTIN_THEMES, defaultTheme } from "./presets";
import type { import type {
DashboardTheme, DashboardTheme,
ThemeAssets,
ThemeColorOverrides, ThemeColorOverrides,
ThemeComponentStyles,
ThemeDensity, ThemeDensity,
ThemeLayer, ThemeLayer,
ThemeLayout, ThemeLayout,
ThemeLayoutVariant,
ThemePalette, ThemePalette,
ThemeTypography, ThemeTypography,
} from "./types"; } from "./types";
@ -122,6 +125,113 @@ function overrideVars(
return out; return out;
} }
// ---------------------------------------------------------------------------
// Asset + component-style + layout variant vars
// ---------------------------------------------------------------------------
/** Well-known named asset slots a theme may populate. Kept in sync with
* `_THEME_NAMED_ASSET_KEYS` in `hermes_cli/web_server.py`. */
const NAMED_ASSET_KEYS = ["bg", "hero", "logo", "crest", "sidebar", "header"] as const;
/** Component buckets mirrored from the backend's `_THEME_COMPONENT_BUCKETS`.
* Each bucket emits `--component-<bucket>-<kebab-prop>` CSS vars. */
const COMPONENT_BUCKETS = [
"card", "header", "footer", "sidebar", "tab",
"progress", "badge", "backdrop", "page",
] as const;
/** Camel → kebab (`clipPath` → `clip-path`). */
function toKebab(s: string): string {
return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
}
/** Build `--theme-asset-*` CSS vars from the assets block. Values are wrapped
* in `url(...)` when they look like a bare path/URL; raw CSS expressions
* (`linear-gradient(...)`, pre-wrapped `url(...)`, `none`) pass through. */
function assetVars(assets: ThemeAssets | undefined): Record<string, string> {
if (!assets) return {};
const out: Record<string, string> = {};
const wrap = (v: string): string => {
const trimmed = v.trim();
if (!trimmed) return "";
// Already a CSS image/gradient/url/none — don't re-wrap.
if (/^(url\(|linear-gradient|radial-gradient|conic-gradient|none$)/i.test(trimmed)) {
return trimmed;
}
// Bare path / http(s) URL / data: URL → wrap in url().
return `url("${trimmed.replace(/"/g, '\\"')}")`;
};
for (const key of NAMED_ASSET_KEYS) {
const val = assets[key];
if (typeof val === "string" && val.trim()) {
out[`--theme-asset-${key}`] = wrap(val);
out[`--theme-asset-${key}-raw`] = val;
}
}
if (assets.custom) {
for (const [key, val] of Object.entries(assets.custom)) {
if (typeof val !== "string" || !val.trim()) continue;
if (!/^[a-zA-Z0-9_-]+$/.test(key)) continue;
out[`--theme-asset-custom-${key}`] = wrap(val);
out[`--theme-asset-custom-${key}-raw`] = val;
}
}
return out;
}
/** Build `--component-<bucket>-<prop>` CSS vars from the componentStyles
* block. Values pass through untouched so themes can use any CSS expression. */
function componentStyleVars(
styles: ThemeComponentStyles | undefined,
): Record<string, string> {
if (!styles) return {};
const out: Record<string, string> = {};
for (const bucket of COMPONENT_BUCKETS) {
const props = (styles as Record<string, Record<string, string> | undefined>)[bucket];
if (!props) continue;
for (const [prop, value] of Object.entries(props)) {
if (typeof value !== "string" || !value.trim()) continue;
// Same guardrail as backend — camelCase or kebab-case alnum only.
if (!/^[a-zA-Z0-9_-]+$/.test(prop)) continue;
out[`--component-${bucket}-${toKebab(prop)}`] = value;
}
}
return out;
}
// Tracks keys we set on the previous theme so we can clear them when the
// next theme has fewer assets / component vars. Without this, switching
// from a richly-decorated theme to a plain one would leave stale vars.
let _PREV_DYNAMIC_VAR_KEYS: Set<string> = new Set();
/** ID for the injected <style> tag that carries a theme's customCSS.
* A single tag is reused + replaced on every theme switch. */
const CUSTOM_CSS_STYLE_ID = "hermes-theme-custom-css";
function applyCustomCSS(css: string | undefined) {
if (typeof document === "undefined") return;
let el = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
if (!css || !css.trim()) {
if (el) el.remove();
return;
}
if (!el) {
el = document.createElement("style");
el.id = CUSTOM_CSS_STYLE_ID;
el.setAttribute("data-hermes-theme-css", "true");
document.head.appendChild(el);
}
el.textContent = css;
}
function applyLayoutVariant(variant: ThemeLayoutVariant | undefined) {
if (typeof document === "undefined") return;
const root = document.documentElement;
const final: ThemeLayoutVariant = variant ?? "standard";
root.dataset.layoutVariant = final;
root.style.setProperty("--theme-layout-variant", final);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Font stylesheet injection // Font stylesheet injection
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -157,18 +267,35 @@ function applyTheme(theme: DashboardTheme) {
for (const cssVar of ALL_OVERRIDE_VARS) { for (const cssVar of ALL_OVERRIDE_VARS) {
root.style.removeProperty(cssVar); root.style.removeProperty(cssVar);
} }
// Clear dynamic (asset/component) vars from the previous theme so the
// new one starts clean — otherwise stale notched clip-paths, hero URLs,
// etc. would bleed across theme switches.
for (const prevKey of _PREV_DYNAMIC_VAR_KEYS) {
root.style.removeProperty(prevKey);
}
const assetMap = assetVars(theme.assets);
const componentMap = componentStyleVars(theme.componentStyles);
_PREV_DYNAMIC_VAR_KEYS = new Set([
...Object.keys(assetMap),
...Object.keys(componentMap),
]);
const vars = { const vars = {
...paletteVars(theme.palette), ...paletteVars(theme.palette),
...typographyVars(theme.typography), ...typographyVars(theme.typography),
...layoutVars(theme.layout), ...layoutVars(theme.layout),
...overrideVars(theme.colorOverrides), ...overrideVars(theme.colorOverrides),
...assetMap,
...componentMap,
}; };
for (const [k, v] of Object.entries(vars)) { for (const [k, v] of Object.entries(vars)) {
root.style.setProperty(k, v); root.style.setProperty(k, v);
} }
injectFontStylesheet(theme.typography.fontUrl); injectFontStylesheet(theme.typography.fontUrl);
applyCustomCSS(theme.customCSS);
applyLayoutVariant(theme.layoutVariant);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -70,6 +70,55 @@ export interface ThemeLayout {
density: ThemeDensity; density: ThemeDensity;
} }
/** Overall layout variant the shell renders. `standard` = default single-
* column page layout. `cockpit` = reserves a left sidebar rail for a
* plugin slot (intended for HUD-style themes with persistent status panels).
* `tiled` = relaxes the main content max-width so pages can use the full
* viewport width. Themes set this; plugins react via CSS vars /
* `[data-layout-variant="..."]` selectors. */
export type ThemeLayoutVariant = "standard" | "cockpit" | "tiled";
/** Named hero/background assets a theme can populate. Each value is
* emitted as a CSS var (`--theme-asset-<name>`). The default shell
* consumes `bg` in `<Backdrop />` when present; other slots are
* plugin-facing a cockpit sidebar plugin reads `--theme-asset-hero`
* to render its hero render without coupling to the theme name. */
export interface ThemeAssets {
/** Full-viewport background image URL, injected under the noise layer. */
bg?: string;
/** Hero render (Gundam, mascot, wallpaper) — for plugin sidebars/overlays. */
hero?: string;
/** Logo mark — header slot consumers use this. */
logo?: string;
/** Faction/brand crest — header-left decoration. */
crest?: string;
/** Secondary sidebar illustration. */
sidebar?: string;
/** Alternate header artwork. */
header?: string;
/** User-defined named assets. Keyed by [a-zA-Z0-9_-] only.
* Emitted as `--theme-asset-custom-<key>`. */
custom?: Record<string, string>;
}
/** Component-style override buckets. Each bucket's entries become CSS
* vars (`--component-<bucket>-<kebab-property>`) that shell components
* (Card, Backdrop, App header/footer, etc.) read. Values are plain CSS
* strings we don't parse them, so themes can use `clip-path`,
* `border-image`, `background`, `box-shadow`, and anything else CSS
* accepts. */
export interface ThemeComponentStyles {
card?: Record<string, string>;
header?: Record<string, string>;
footer?: Record<string, string>;
sidebar?: Record<string, string>;
tab?: Record<string, string>;
progress?: Record<string, string>;
badge?: Record<string, string>;
backdrop?: Record<string, string>;
page?: Record<string, string>;
}
/** Optional hex overrides keyed by shadcn-compat token name (without the /** Optional hex overrides keyed by shadcn-compat token name (without the
* `--color-` prefix). Any key set here wins over the DS cascade. */ * `--color-` prefix). Any key set here wins over the DS cascade. */
export interface ThemeColorOverrides { export interface ThemeColorOverrides {
@ -101,6 +150,17 @@ export interface DashboardTheme {
palette: ThemePalette; palette: ThemePalette;
typography: ThemeTypography; typography: ThemeTypography;
layout: ThemeLayout; layout: ThemeLayout;
/** Overall shell layout. Defaults to `"standard"` when absent. */
layoutVariant?: ThemeLayoutVariant;
/** Named + custom asset URLs exposed as CSS vars on theme apply. */
assets?: ThemeAssets;
/** Raw CSS injected as a scoped `<style>` tag on theme apply, cleaned up
* on theme switch. Intended for selector-level chrome that's too
* expressive for componentStyles alone (e.g. `::before` pseudo-elements,
* complex animations, media queries). */
customCSS?: string;
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
componentStyles?: ThemeComponentStyles;
colorOverrides?: ThemeColorOverrides; colorOverrides?: ThemeColorOverrides;
} }

View file

@ -422,6 +422,148 @@ Supported keys: `card`, `cardForeground`, `popover`, `popoverForeground`, `prima
Any key set here overrides the derived value for the active theme only — switching to another theme clears the overrides. Any key set here overrides the derived value for the active theme only — switching to another theme clears the overrides.
### Layout variants
`layoutVariant` selects the overall shell layout. Defaults to `standard`.
| Variant | Behaviour |
|---------|-----------|
| `standard` | Single column, 1600px max-width (default) |
| `cockpit` | Left sidebar rail (260px) + main content. Populated by plugins via the `sidebar` slot |
| `tiled` | Drops the max-width clamp so pages can use the full viewport |
```yaml
layoutVariant: cockpit
```
The current variant is exposed as `document.documentElement.dataset.layoutVariant` so custom CSS can target it via `:root[data-layout-variant="cockpit"]`.
### Theme assets
Ship artwork URLs with a theme. Each named slot becomes a CSS var (`--theme-asset-<name>`) that plugins and the built-in shell read; the `bg` slot is automatically wired into the backdrop.
```yaml
assets:
bg: "https://example.com/hero-bg.jpg" # full-viewport background
hero: "/my-images/strike-freedom.png" # for plugin sidebars
crest: "/my-images/crest.svg" # for header slot plugins
logo: "/my-images/logo.png"
sidebar: "/my-images/rail.png"
header: "/my-images/header-art.png"
custom:
scanLines: "/my-images/scanlines.png" # → --theme-asset-custom-scanLines
```
Values accept bare URLs (wrapped in `url(...)` automatically), pre-wrapped `url(...)`/`linear-gradient(...)`/`radial-gradient(...)` expressions, and `none`.
### Component chrome overrides
Themes can restyle individual shell components without writing CSS selectors via the `componentStyles` block. Each bucket's entries become CSS vars (`--component-<bucket>-<kebab-property>`) that the shell's shared components read — so `card:` overrides apply to every `<Card>`, `header:` to the app bar, etc.
```yaml
componentStyles:
card:
clipPath: "polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px)"
background: "linear-gradient(180deg, rgba(10, 22, 52, 0.85), rgba(5, 9, 26, 0.92))"
boxShadow: "inset 0 0 0 1px rgba(64, 200, 255, 0.28)"
header:
background: "linear-gradient(180deg, rgba(16, 32, 72, 0.95), rgba(5, 9, 26, 0.9))"
tab:
clipPath: "polygon(6px 0, 100% 0, calc(100% - 6px) 100%, 0 100%)"
sidebar: {...}
backdrop: {...}
footer: {...}
progress: {...}
badge: {...}
page: {...}
```
Supported buckets: `card`, `header`, `footer`, `sidebar`, `tab`, `progress`, `badge`, `backdrop`, `page`. Property names use camelCase (`clipPath`) and are emitted as kebab (`clip-path`). Values are plain CSS strings — anything CSS accepts (`clip-path`, `border-image`, `background`, `box-shadow`, animations, etc.).
### Custom CSS
For selector-level chrome that doesn't fit `componentStyles` — pseudo-elements, animations, media queries, theme-scoped overrides — drop raw CSS into the `customCSS` field:
```yaml
customCSS: |
:root[data-layout-variant="cockpit"] body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
background: repeating-linear-gradient(to bottom,
transparent 0px, transparent 2px,
rgba(64, 200, 255, 0.035) 3px, rgba(64, 200, 255, 0.035) 4px);
mix-blend-mode: screen;
}
```
The CSS is injected as a single scoped `<style data-hermes-theme-css>` tag on theme apply and cleaned up on theme switch. Capped at 32 KiB per theme.
## Dashboard plugins
Plugins live in `~/.hermes/plugins/<name>/dashboard/` (user) or repo `plugins/<name>/dashboard/` (bundled). Each ships a `manifest.json` plus a plain JS bundle that uses the plugin SDK exposed on `window.__HERMES_PLUGIN_SDK__`.
### Manifest
```json
{
"name": "my-plugin",
"label": "My Plugin",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {
"path": "/my-plugin",
"position": "after:skills",
"override": "/",
"hidden": false
},
"slots": ["sidebar", "header-left"],
"entry": "dist/index.js",
"css": "dist/index.css",
"api": "api.py"
}
```
| Field | Description |
|-------|-------------|
| `tab.path` | Route path the plugin component renders at |
| `tab.position` | `end`, `after:<tab>`, or `before:<tab>` |
| `tab.override` | When set to a built-in path (`/`, `/sessions`, etc.), this plugin replaces that page instead of adding a new tab |
| `tab.hidden` | When true, register component + slots but skip the nav entry. Used by slot-only plugins |
| `slots` | Shell slots this plugin populates (documentation aid; actual registration happens from the JS bundle) |
### Shell slots
Plugins inject components into named shell locations by calling `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`. Multiple plugins can populate the same slot — they render stacked in registration order.
| Slot | Location |
|------|----------|
| `backdrop` | Inside the backdrop layer stack |
| `header-left` | Before the Hermes brand in the top bar |
| `header-right` | Before the theme/language switchers |
| `header-banner` | Full-width strip below the nav |
| `sidebar` | Cockpit sidebar rail (only rendered when `layoutVariant === "cockpit"`) |
| `pre-main` | Above the route outlet |
| `post-main` | Below the route outlet |
| `footer-left` / `footer-right` | Footer cell content (replaces default) |
| `overlay` | Fixed-position layer above everything else |
### Plugin SDK
Exposed on `window.__HERMES_PLUGIN_SDK__`:
- `React` + `hooks` (useState, useEffect, useCallback, useMemo, useRef, useContext, createContext)
- `components` — Card, Badge, Button, Input, Label, Select, Separator, Tabs, **PluginSlot**
- `api` — Hermes API client, plus raw `fetchJSON`
- `utils``cn()`, `timeAgo()`, `isoTimeAgo()`
- `useI18n` — i18n hook for multi-language plugins
### Demo: Strike Freedom Cockpit
`plugins/strike-freedom-cockpit/` ships a complete skin demo showing every extension point — cockpit layout variant, theme-supplied hero/crest assets, notched card corners via `componentStyles`, scanlines via `customCSS`, and a slot-only plugin that populates the sidebar, header, and footer. Copy the theme YAML into `~/.hermes/dashboard-themes/` and the plugin directory into `~/.hermes/plugins/` to try it.
### Theme API ### Theme API
| Endpoint | Method | Description | | Endpoint | Method | Description |