mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 15:01:34 +08: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:
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 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user