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
17 changed files with 1576 additions and 40 deletions

View File

@@ -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);
}
// ---------------------------------------------------------------------------

View File

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