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:
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user