Main-process plugin
extends BasePlugin. Registers where the renderer bundle lives.
apps/desktop-lite/ to wire it inIn TeamHUD, widgets are registered by plugins. A plugin is the full extension unit
(BasePlugin): it runs in-app with full trust and can integrate with in-app data and
services — a widget is just one of the things it provides. This tutorial builds the
smallest plugin that provides a widget. The finished result lives at
plugins/widget-hello/ — copy it as your starting point, or follow along to understand
each piece. widget-hello is a reference package only: it is not wired into the app
(plugin-entries.ts does not list it), so §4 shows the three edits that make it load.
If you just want a standalone UI with no in-app data, the custom widget path is faster (no build step, no plugin).
A Hello hub that drags, snaps, hover-reveals, and auto-collapses — none of which you
write. You implement a few small overrides; the OverlayHubWidget base class gives you the rest.
pnpm install, then pnpm start
(standard Node 24 / pnpm setup; the exact version is in .nvmrc).Main-process plugin
extends BasePlugin. Registers where the renderer bundle lives.
Renderer Lit component
extends OverlayHubWidget. The actual UI.
Make plugins/widget-hello/ with the hand-written files (src/index.ts, src/WidgetHelloPlugin.ts, src/components/index.ts, src/components/widget-hello-widget.ts) plus package.json and
tsconfig.json. The package.json carries the discovery manifest:
// plugins/widget-hello/package.json (excerpt){ "name": "@teamhud/widget-hello", "type": "module", "scripts": { "build": "tsc", "type-check": "tsc --noEmit" }, "dependencies": { "@mythic/logging": "*", "@mythic/plugins": "*", "@teamhud/component-bundle-registry": "workspace:*", "@teamhud/pipeline-primitives": "workspace:*", "@teamhud/renderer": "workspace:*", "@teamhud/ui-core": "workspace:*", "@teamhud/widget-mixins": "workspace:*", "lit": "^3.0.0" }, "teamhud": { "plugins": [ { "slug": "widget-hello", "mainEntry": "./src/WidgetHelloPlugin.ts", "displayName": "Hello", "tagline": "The minimal plugin that ships a widget.", "category": "developer" } ] }}The teamhud.plugins[] block is how the build tooling discovers your plugin. It does
not add the plugin to the running app — that’s a separate manual step (§4). Copy
tsconfig.json from any plugins/widget-* — it must emit to dist/, never next to sources.
The plugin’s only job is startup(), which tells the overlay where to find the bundle:
// plugins/widget-hello/src/WidgetHelloPlugin.ts (the important part)export class WidgetHelloPlugin extends BasePlugin { info(): IPluginInfo { return { // ...name/version/etc... tags: { canDisable: true, isWidget: true }, resources: [{ name: 'widget-hello', kind: 'widget', displayName: 'Hello Widget', canDisable: true }], }; }
override async startup(): Promise<void> { ComponentBundleRegistry.getInstance().registerBundle({ bundleName: 'widget-hello-widget', // must match the built bundle key targetWindows: ['overlay'], federationModule: '/plugins/widget-hello/components', pluginSlug: 'widget-hello', resourceName: 'widget-hello', // must match resources[].name above }); }}Two names must line up: bundleName must equal the bundle key the build derives from your
folder (a widget- plugin with src/components/index.ts → widget-hello-widget), and
resourceName must match a resources[].name so the enable/disable toggle works.
The bundle entry mounts the element:
import { mountCapableWidget } from '@teamhud/widget-mixins';import './widget-hello-widget.js';
let active: HTMLElement | null = null;export function initialize(context) { dispose(); active = mountCapableWidget('widget-hello-widget', context); }export function dispose() { active?.remove(); active = null; }Extend OverlayHubWidget and implement only what’s yours: the region id, the hub button,
the hover-visual hook (overlayRefreshHoveredVisual) and the panel body. Drag, placement, hover-reveal, auto-collapse, and region-sync are
inherited.
// plugins/widget-hello/src/components/widget-hello-widget.ts (the overrides)@customElement('widget-hello-widget')export class WidgetHelloWidget extends OverlayHubWidget { @state() private accessor _count = 0;
protected overlayRegionOwnerId(): string { return 'widget-hello'; }
protected overlayHubRegistryEntry() { return { id: 'widget-hello', label: 'Hello', icon: HELLO_ICON /* inline SVG, currentColor */ }; }
protected overlayRefreshHoveredVisual(hovered: boolean): void { this._hovered = hovered; }
// render() mounts #hub-btn-mount and, when open, a <th-hub-panel> whose body is yours.}Everything visual reads from --th-* tokens — never hardcode colors, fonts, or motion.
Don’t guess token names: use only names from the catalog (for example --th-text-sm, --th-radius-md,
--th-bg-surface). The full catalog and rules are in Theming & styling. The complete
render() + <th-hub-panel> wiring is in plugins/widget-hello/.
Three edits in apps/desktop-lite/:
package.json — add the workspace dependency:
"@teamhud/widget-hello": "workspace:*",src/plugin-entries.ts — import the class and register it:
import { WidgetHelloPlugin } from '@teamhud/widget-hello';// ...inside getEnabledPluginEntries(), add:toPluginEntry(WidgetHelloPlugin),src/preload.ts — keep E2E_DESKTOP_LITE_PLUGIN_NAMES in sync — it lists plugin class names, so add
"WidgetHelloPlugin" (not the slug).plugin-entries-discoverable.test.ts fails any booted entry whose package.json lacks a
teamhud.plugins block, which is why §1 includes it.
pnpm --filter @teamhud/widget-hello build # tsc → dist/pnpm --filter @teamhud/desktop-lite build:component-bundles # → dist-plugins/widget-hello-widget.jspnpm start # from the repo root; runs apps/desktop-lite's dev scriptThe Hello hub appears on the overlay. Click it → the panel opens with a working counter;
drag it → it snaps to the edge; move the pointer away → it auto-collapses.
Before running the app, the scoped checks catch most mistakes:
pnpm --filter @teamhud/widget-hello type-checkpnpm check:widget-contract # fails on hand-rolled drag/placement, off-palette or font literals