mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
470389e6a3
commit
f593c367be
17 changed files with 1576 additions and 40 deletions
|
|
@ -2370,6 +2370,29 @@ _THEME_OVERRIDE_KEYS = {
|
|||
"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]]:
|
||||
"""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():
|
||||
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] = {
|
||||
"name": 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,
|
||||
"typography": typography,
|
||||
"layout": layout,
|
||||
"layoutVariant": layout_variant,
|
||||
}
|
||||
if 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
|
||||
|
||||
|
||||
|
|
@ -2552,13 +2645,35 @@ def _discover_dashboard_plugins() -> list:
|
|||
if name in seen_names:
|
||||
continue
|
||||
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({
|
||||
"name": name,
|
||||
"label": data.get("label", name),
|
||||
"description": data.get("description", ""),
|
||||
"icon": data.get("icon", "Puzzle"),
|
||||
"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"),
|
||||
"css": data.get("css"),
|
||||
"has_api": bool(data.get("api")),
|
||||
|
|
|
|||
70
plugins/strike-freedom-cockpit/README.md
Normal file
70
plugins/strike-freedom-cockpit/README.md
Normal 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
|
||||
309
plugins/strike-freedom-cockpit/dashboard/dist/index.js
vendored
Normal file
309
plugins/strike-freedom-cockpit/dashboard/dist/index.js
vendored
Normal 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);
|
||||
})();
|
||||
14
plugins/strike-freedom-cockpit/dashboard/manifest.json
Normal file
14
plugins/strike-freedom-cockpit/dashboard/manifest.json
Normal 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"
|
||||
}
|
||||
126
plugins/strike-freedom-cockpit/theme/strike-freedom.yaml
Normal file
126
plugins/strike-freedom-cockpit/theme/strike-freedom.yaml
Normal 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;
|
||||
}
|
||||
|
|
@ -1473,3 +1473,207 @@ class TestDiscoverUserThemes:
|
|||
assert "ok" in names
|
||||
assert "bad" not in names # malformed YAML
|
||||
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"]
|
||||
|
|
|
|||
219
web/src/App.tsx
219
web/src/App.tsx
|
|
@ -36,8 +36,23 @@ import SkillsPage from "@/pages/SkillsPage";
|
|||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePlugins } from "@/plugins";
|
||||
import { PluginSlot, usePlugins } 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[] = [
|
||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||
|
|
@ -98,6 +113,13 @@ function buildNavItems(
|
|||
const items = [...builtIn];
|
||||
|
||||
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 = {
|
||||
path: manifest.tab.path,
|
||||
label: manifest.label,
|
||||
|
|
@ -123,19 +145,89 @@ function buildNavItems(
|
|||
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() {
|
||||
const { t } = useI18n();
|
||||
const { plugins } = usePlugins();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const navItems = useMemo(
|
||||
() => buildNavItems(BUILTIN_NAV, 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 (
|
||||
<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 />
|
||||
<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
|
||||
className={cn(
|
||||
|
|
@ -143,8 +235,17 @@ export default function App() {
|
|||
"border-b border-current/20",
|
||||
"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">
|
||||
<Grid
|
||||
className="h-full !border-t-0 !border-b-0"
|
||||
|
|
@ -180,6 +281,9 @@ export default function App() {
|
|||
: "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
clipPath: "var(--component-tab-clip-path)",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
|
|
@ -214,6 +318,7 @@ export default function App() {
|
|||
|
||||
<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">
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
<Typography
|
||||
|
|
@ -227,50 +332,92 @@ export default function App() {
|
|||
</div>
|
||||
</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">
|
||||
<Routes>
|
||||
<Route path="/" element={<StatusPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<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 />} />
|
||||
{/* Full-width banner slot under the nav, outside the main clamp —
|
||||
useful for marquee/alert/status strips themes want to show
|
||||
above page content. */}
|
||||
<PluginSlot name="header-banner" />
|
||||
|
||||
{plugins.map(({ manifest, component: PluginComponent }) => (
|
||||
<Route
|
||||
key={manifest.name}
|
||||
path={manifest.tab.path}
|
||||
element={<PluginComponent />}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-2 mx-auto w-full flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8",
|
||||
mainMaxWidth,
|
||||
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 />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<main className="min-w-0 flex-1">
|
||||
<PluginSlot name="pre-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">
|
||||
<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">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.footer.name}
|
||||
</Typography>
|
||||
<PluginSlot
|
||||
name="footer-left"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.footer.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
<PluginSlot
|
||||
name="footer-right"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,11 +38,27 @@ export function Backdrop() {
|
|||
<div
|
||||
aria-hidden
|
||||
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
|
||||
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"
|
||||
src="/ds-assets/filler-bg0.jpg"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,35 @@
|
|||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"border border-border bg-card/80 text-card-foreground w-full",
|
||||
className,
|
||||
)}
|
||||
style={{ ...CARD_STYLE, ...style }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -162,3 +162,14 @@ code { font-size: 0.875rem; }
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { Select, SelectOption } from "@/components/ui/select";
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { registerSlot, PluginSlot } from "./slots";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin registry — plugins call register() to add their component.
|
||||
|
|
@ -75,6 +76,7 @@ declare global {
|
|||
__HERMES_PLUGIN_SDK__: unknown;
|
||||
__HERMES_PLUGINS__: {
|
||||
register: typeof registerPlugin;
|
||||
registerSlot: typeof registerSlot;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -82,6 +84,7 @@ declare global {
|
|||
export function exposePluginSDK() {
|
||||
window.__HERMES_PLUGINS__ = {
|
||||
register: registerPlugin,
|
||||
registerSlot,
|
||||
};
|
||||
|
||||
window.__HERMES_PLUGIN_SDK__ = {
|
||||
|
|
@ -118,6 +121,7 @@ export function exposePluginSDK() {
|
|||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
PluginSlot,
|
||||
},
|
||||
|
||||
// Utilities
|
||||
|
|
|
|||
152
web/src/plugins/slots.ts
Normal file
152
web/src/plugins/slots.ts
Normal 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 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -9,7 +9,21 @@ export interface PluginManifest {
|
|||
tab: {
|
||||
path: string;
|
||||
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;
|
||||
css?: string | null;
|
||||
has_api: boolean;
|
||||
|
|
|
|||
|
|
@ -10,10 +10,13 @@ import {
|
|||
import { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
import type {
|
||||
DashboardTheme,
|
||||
ThemeAssets,
|
||||
ThemeColorOverrides,
|
||||
ThemeComponentStyles,
|
||||
ThemeDensity,
|
||||
ThemeLayer,
|
||||
ThemeLayout,
|
||||
ThemeLayoutVariant,
|
||||
ThemePalette,
|
||||
ThemeTypography,
|
||||
} from "./types";
|
||||
|
|
@ -122,6 +125,113 @@ function overrideVars(
|
|||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -157,18 +267,35 @@ function applyTheme(theme: DashboardTheme) {
|
|||
for (const cssVar of ALL_OVERRIDE_VARS) {
|
||||
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 = {
|
||||
...paletteVars(theme.palette),
|
||||
...typographyVars(theme.typography),
|
||||
...layoutVars(theme.layout),
|
||||
...overrideVars(theme.colorOverrides),
|
||||
...assetMap,
|
||||
...componentMap,
|
||||
};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
|
||||
injectFontStylesheet(theme.typography.fontUrl);
|
||||
applyCustomCSS(theme.customCSS);
|
||||
applyLayoutVariant(theme.layoutVariant);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -70,6 +70,55 @@ export interface ThemeLayout {
|
|||
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
|
||||
* `--color-` prefix). Any key set here wins over the DS cascade. */
|
||||
export interface ThemeColorOverrides {
|
||||
|
|
@ -101,6 +150,17 @@ export interface DashboardTheme {
|
|||
palette: ThemePalette;
|
||||
typography: ThemeTypography;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue