ui-next poc - widgets

This commit is contained in:
lovebird 2026-04-09 13:52:07 +02:00
parent 3e3efbe4d4
commit 5a43ce1cd1
18 changed files with 982 additions and 1 deletions

View File

@ -30,7 +30,7 @@ npm install
npm run dev
```
Open the app and use the header or **`/examples`** to walk through interactive migration examples (dynamic params, splat, typed search, loaders, redirects, 404, scroll proofs, lazy routes).
Open the app and use the header or **`/examples`** to walk through interactive migration examples (dynamic params, splat, typed search, loaders, redirects, 404, scroll proofs, lazy routes). For the **widget registry, plugins, and extension slots** scaffold (aligned with `packages/ui/docs/widgets-api.md`), open **`/examples/widgets-system`** or browse [`src/widgets/`](src/widgets/index.ts).
```bash
npm run build # tsc --noEmit && vite build
@ -46,6 +46,7 @@ npm run preview # production build preview
| [`src/router.tsx`](src/router.tsx) | Root layout, `createRouter`, scroll restoration, global 404 |
| [`src/main.tsx`](src/main.tsx) | `RouterProvider`, dev-only `router.subscribe` stub for analytics |
| [`src/examples/migration/`](src/examples/migration/) | Runnable demos + [`MIGRATION_EXAMPLES.md`](src/examples/migration/MIGRATION_EXAMPLES.md) |
| [`src/widgets/`](src/widgets/index.ts) | Widget registry, `PluginManager`, `PluginAPI`, hooks, `ExtensionSlot`; demo at `/examples/widgets-system` · [Architecture diagrams](docs/widgets.md) |
| [`vite.config.ts`](vite.config.ts) | Vite + React; comment shows where `TanStackRouterVite` plugs in for file-based routes later |
| [`tsconfig.json`](tsconfig.json) | Extends `../typescript-config/base.json`, tuned for Vite + `bundler` resolution |

View File

@ -0,0 +1,178 @@
# Widget system (`@polymech/ui-next`)
This document describes the **scaffold** under [`src/widgets/`](../src/widgets/index.ts): registry, plugins, hooks, and extension slots. It implements a subset of the full contract in [`packages/ui/docs/widgets-api.md`](../../ui/docs/widgets-api.md) (Polymech UI).
---
## 1. High-level architecture
Plugins run at bootstrap, receive a **capability-limited `PluginAPI`**, and register widgets, hooks, and slot components. The host UI renders **`ExtensionSlot`** at fixed **slot IDs**; injected components appear there without the core editor importing plugin packages directly.
```mermaid
flowchart TB
subgraph bootstrap["Bootstrap"]
Boot([bootWidgetSystem])
Boot --> PM[PluginManager.register]
end
subgraph plugins["Plugins"]
P1[coreWidgetsPlugin]
P2[demoToolbarPlugin]
end
subgraph registries["Registries"]
WR[(WidgetRegistry)]
HR[(HookRegistry)]
SR[(SlotRegistry)]
end
subgraph react["React tree"]
ES[ExtensionSlot slotId]
W[Widget components]
end
PM --> P1
PM --> P2
P1 --> API[PluginAPI]
P2 --> API
API --> WR
API --> HR
API --> SR
WR --> W
SR --> ES
```
---
## 2. Plugin lifecycle
Registration order matters for **`requires`**: dependencies must already be registered. Higher **`priority`** runs first for ordered hooks (see §3).
```mermaid
sequenceDiagram
participant App
participant PM as PluginManager
participant API as PluginAPI
participant Plugin
App->>PM: register(plugin)
PM->>PM: validate requires deps
PM->>API: createPluginAPI(scoped to plugin id)
PM->>Plugin: setup(api)
Plugin->>API: registerWidget / addHook / injectSlot
API-->>Plugin: void
PM->>PM: plugins.set(id, plugin)
Note over App,Plugin: unregister(pluginId)
App->>PM: unregister(id)
PM->>Plugin: teardown()
PM->>PM: removeHooksForPlugin, clearSlot entries
```
---
## 3. Hook pipeline (conceptual)
Hook handlers are stored with **plugin id** and **priority**. When the host calls **`HookRegistry.runSync`**, handlers run in **descending priority** (see widgets-api §13). Not every `HookName` is wired in the scaffold yet; the registry is ready for editor/layout integration.
```mermaid
flowchart LR
subgraph pipeline["editor:widgetPalette (example)"]
H1["Handler priority 100"]
H2["Handler priority 10"]
H3["Handler priority 0"]
end
InputIn["widgets[] in"] --> H1
H1 --> H2
H2 --> H3
H3 --> OutputOut["widgets[] out"]
```
---
## 4. Extension slots
**`PluginAPI.injectSlot(slotId, Component)`** appends a React component to that slot. **`ExtensionSlot`** subscribes to **`SlotRegistry`** via **`useSyncExternalStore`** and re-renders when injections change. Multiple plugins (or multiple calls from one plugin) can add entries; each entry gets a stable **`entryId`**.
```mermaid
flowchart TB
subgraph chrome["Editor chrome (conceptual)"]
TBStart["editor:toolbar:start"]
TBEnd["editor:toolbar:end"]
Side["editor:sidebar:top / …"]
end
Plugin["Plugin setup"]
Plugin --> Inject[injectSlot]
Inject --> SR[(SlotRegistry)]
SR --> ES[ExtensionSlot]
ES --> DOM[Rendered React tree]
```
---
## 5. Data flow: from plugin to screen
```mermaid
flowchart LR
subgraph pluginSide["Plugin"]
R["registerWidget(metadata.id)"]
I["injectSlot(editor:toolbar:end)"]
end
subgraph state["State"]
WR[(WidgetRegistry)]
SR[(SlotRegistry)]
end
subgraph ui["UI"]
Palette["Future: palette reads registry"]
Slot[ExtensionSlot]
end
R --> WR
I --> SR
WR -.-> Palette
SR --> Slot
```
The demo page [`WidgetSystemDemoPage`](../src/examples/widgets/WidgetSystemDemoPage.tsx) reads **`widgetRegistry.getAll()`** and mounts **`ExtensionSlot`** for **`editor:toolbar:end`**, plus a direct **`TextBlockWidget`** preview with mock **`BaseWidgetProps`**.
---
## 6. Module map (this package)
| Module | Role |
|--------|------|
| [`types.ts`](../src/widgets/types.ts) | `WidgetDefinition`, `WidgetPlugin`, `PluginAPI`, `HookName`, `SlotId`, … |
| [`WidgetRegistry`](../src/widgets/registry/WidgetRegistry.ts) | CRUD for widget definitions |
| [`HookRegistry`](../src/widgets/plugins/HookRegistry.ts) | Hook lists + `runSync` |
| [`SlotRegistry`](../src/widgets/plugins/SlotRegistry.ts) | Slot entries + `subscribe` |
| [`createPluginAPI`](../src/widgets/plugins/createPluginAPI.ts) | Facade passed into `plugin.setup` |
| [`PluginManager`](../src/widgets/plugins/PluginManager.ts) | `register` / `unregister` |
| [`system.ts`](../src/widgets/system.ts) | Process-wide singletons for the POC |
| [`ExtensionSlot`](../src/widgets/slots/ExtensionSlot.tsx) | Renders slot content |
| [`boot.ts`](../src/widgets/bootstrap/boot.ts) | `bootWidgetSystem()` |
---
## 7. Relationship to the full widgets API
| Full doc topic | Status in this scaffold |
|----------------|-------------------------|
| `BaseWidgetProps`, metadata, `configSchema` | Types + sample `text-block` |
| `PluginAPI` (widgets, hooks, slots) | Implemented (minimal `getStore` / `subscribe`) |
| Container types, `registerContainerType` | Not implemented — extend `PluginAPI` when needed |
| Real `LayoutStore` (Zustand) | Stub `layoutStore` object |
| Nested layouts, export, i18n | Not implemented |
For the authoritative interface proposal, keep using **`packages/ui/docs/widgets-api.md`**.
---
## 8. Try it
1. Run the app (`npm run dev` in `packages/ui-next`).
2. Open **`/examples/widgets-system`** (or **Migration examples → widgets + plugins**).
3. Confirm the toolbar slot pill and the **`text-block`** registry entry after **`bootWidgetSystem()`** runs.

View File

@ -20,6 +20,7 @@ import {
type MigrationSearch,
} from "@/examples/migration/searchSchema";
import { ScrollRestorationDemo } from "@/examples/migration/ScrollRestorationDemo";
import { WidgetSystemDemoPage } from "@/examples/widgets/WidgetSystemDemoPage";
export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
function ExamplesIndex() {
@ -90,6 +91,9 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
<Link to="/examples/lazy-scroll-restoration" className="link">
lazy + scroll
</Link>
<Link to="/examples/widgets-system" className="link">
widgets + plugins
</Link>
<Link to="/examples/loader-demo" className="link">
loader
</Link>
@ -284,6 +288,12 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
),
});
const widgetSystemDemoRoute = createRoute({
getParentRoute: () => examplesRoute,
path: "widgets-system",
component: WidgetSystemDemoPage,
});
const loaderDemoRoute = createRoute({
getParentRoute: () => examplesRoute,
path: "loader-demo",
@ -364,6 +374,7 @@ export function buildMigrationExamplesBranch(rootRoute: AnyRoute) {
searchRoute,
scrollRestorationDemoRoute,
lazyScrollRestorationRoute,
widgetSystemDemoRoute,
loaderDemoRoute,
navigateDemoRoute,
redirectRoute,

View File

@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
import { bootWidgetSystem } from "@/widgets/bootstrap/boot";
import { ExtensionSlot } from "@/widgets/slots/ExtensionSlot";
import { widgetRegistry } from "@/widgets/system";
import { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
import type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
const demoProps: TextBlockWidgetProps = {
widgetInstanceId: "demo-instance",
widgetDefId: "text-block",
isEditMode: false,
title: "Registered widget preview",
body: "Props match BaseWidgetProps + text-block fields (see packages/ui/docs/widgets-api.md).",
async onPropsChange() {},
};
export function WidgetSystemDemoPage() {
const [ready, setReady] = useState(false);
useEffect(() => {
void bootWidgetSystem().then(() => setReady(true));
}, []);
const defs = ready ? widgetRegistry.getAll() : [];
return (
<section className="nested">
<h1>Widget system scaffold</h1>
<p className="muted">
Implements registry, <code>PluginManager</code>, <code>PluginAPI</code>, hooks, and{" "}
<code>ExtensionSlot</code> per <code>packages/ui/docs/widgets-api.md</code> §12, §13.
</p>
<div className="widget-demo-toolbar">
<span className="muted">Slot preview (edit chrome)</span>
<div className="widget-demo-toolbar-inner">
<ExtensionSlot
slotId="editor:toolbar:end"
pageId="widget-system-demo"
isEditMode
/>
</div>
</div>
<article className="panel">
<h2>Registry ({ready ? defs.length : "…"})</h2>
{ready ? (
<ul>
{defs.map((d) => (
<li key={d.metadata.id}>
<code>{d.metadata.id}</code> {d.metadata.name}
</li>
))}
</ul>
) : (
<p>Bootstrapping plugins</p>
)}
</article>
<TextBlockWidget {...demoProps} />
</section>
);
}

View File

@ -166,3 +166,32 @@ code {
.lazy-chunk-intro {
border-left: 4px solid #6366f1;
}
.widget-text-block h3 {
margin-top: 0;
}
.widget-demo-toolbar {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background: #f1f5f9;
border: 1px solid #e2e8f0;
}
.widget-demo-toolbar-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
}
.widget-slot-pill {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
background: #312e81;
color: #e0e7ff;
}

View File

@ -0,0 +1,13 @@
import { demoToolbarPlugin } from "@/widgets/bootstrap/demoToolbarPlugin";
import { coreWidgetsPlugin } from "@/widgets/bootstrap/corePlugin";
import { pluginManager } from "@/widgets/system";
let booted = false;
/** Idempotent — safe to call from a route or app root. */
export async function bootWidgetSystem(): Promise<void> {
if (booted) return;
booted = true;
await pluginManager.register(coreWidgetsPlugin);
await pluginManager.register(demoToolbarPlugin);
}

View File

@ -0,0 +1,34 @@
import type { WidgetPlugin } from "@/widgets/types";
import { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
import type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";
/**
* Registers baseline widgets mirrors wrapping `registerWidgets()` as a plugin (widgets-api §13).
*/
export const coreWidgetsPlugin: WidgetPlugin = {
id: "polymech:core-widgets",
name: "Core widgets",
version: "0.0.0",
priority: 100,
setup(api) {
api.registerWidget<TextBlockWidgetProps>({
component: TextBlockWidget,
metadata: {
id: "text-block",
name: "Text block",
category: "display",
description: "Heading + body (scaffold)",
tags: ["text", "core"],
defaultProps: {
title: "Hello",
body: "Widget registry + plugin bootstrap.",
},
configSchema: {
title: { type: "text", label: "Title", group: "Content" },
body: { type: "markdown", label: "Body", group: "Content" },
},
},
});
},
};

View File

@ -0,0 +1,24 @@
import type { WidgetPlugin } from "@/widgets/types";
/**
* Second plugin: depends on core, injects `editor:toolbar:end` (widgets-api §13 slots).
*/
export const demoToolbarPlugin: WidgetPlugin = {
id: "polymech:demo-toolbar",
name: "Demo toolbar slot",
version: "0.0.0",
priority: 10,
requires: ["polymech:core-widgets"],
setup(api) {
api.injectSlot("editor:toolbar:end", function DemoToolbarEnd(props) {
return (
<span
className="widget-slot-pill"
title="Injected via PluginAPI.injectSlot"
>
editor:toolbar:end · {props.pageId}
</span>
);
});
},
};

View File

@ -0,0 +1,41 @@
/**
* Widget / plugin / extension-slot scaffolding (see `packages/ui/docs/widgets-api.md`).
*/
export type {
BaseWidgetProps,
ConfigField,
HookContext,
HookHandler,
HookName,
LayoutStore,
PluginAPI,
SlotId,
SlotProps,
WidgetDefinition,
WidgetInstance,
WidgetMetadata,
WidgetPlugin,
WidgetWrapper,
} from "@/widgets/types";
export { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
export { HookRegistry } from "@/widgets/plugins/HookRegistry";
export { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
export { PluginManager } from "@/widgets/plugins/PluginManager";
export { createPluginAPI } from "@/widgets/plugins/createPluginAPI";
export { ExtensionSlot } from "@/widgets/slots/ExtensionSlot";
export {
layoutStore,
widgetRegistry,
hookRegistry,
slotRegistry,
pluginManager,
} from "@/widgets/system";
export { bootWidgetSystem } from "@/widgets/bootstrap/boot";
export { coreWidgetsPlugin } from "@/widgets/bootstrap/corePlugin";
export { demoToolbarPlugin } from "@/widgets/bootstrap/demoToolbarPlugin";
export { TextBlockWidget } from "@/widgets/widgets/sample/TextBlockWidget";
export type { TextBlockWidgetProps } from "@/widgets/widgets/sample/TextBlockWidget";

View File

@ -0,0 +1,77 @@
import type {
HookContext,
HookHandler,
HookName,
LayoutStore,
} from "@/widgets/types";
type RegisteredHook = {
pluginId: string;
priority: number;
handler: HookHandler<HookName>;
};
export class HookRegistry {
private readonly hooks = new Map<HookName, RegisteredHook[]>();
add<T extends HookName>(
name: T,
pluginId: string,
priority: number,
handler: HookHandler<T>,
): void {
const list = this.hooks.get(name) ?? [];
list.push({
pluginId,
priority,
handler: handler as HookHandler<HookName>,
});
list.sort((a, b) => b.priority - a.priority);
this.hooks.set(name, list);
}
remove<T extends HookName>(
name: T,
handler: HookHandler<T>,
): void {
const list = this.hooks.get(name);
if (!list) return;
const next = list.filter((h) => h.handler !== handler);
if (next.length) this.hooks.set(name, next);
else this.hooks.delete(name);
}
getHandlers(name: HookName): RegisteredHook[] {
return [...(this.hooks.get(name) ?? [])];
}
removeHooksForPlugin(pluginId: string): void {
for (const [name, list] of this.hooks.entries()) {
const next = list.filter((h) => h.pluginId !== pluginId);
if (next.length !== list.length) {
if (next.length) this.hooks.set(name, next);
else this.hooks.delete(name);
}
}
}
/** Run pipeline for hooks that transform data (best-effort typing). */
runSync<T extends HookName>(
name: T,
initial: unknown,
ctx: HookContext,
): unknown {
let acc = initial;
for (const { handler } of this.getHandlers(name)) {
const fn = handler as (data: unknown, c: HookContext) => unknown;
acc = fn(acc, ctx);
if (acc === false) break;
}
return acc;
}
}
/** Stub store until real Zustand layout store is wired */
export function createStubLayoutStore(): LayoutStore {
return { __stub: true };
}

View File

@ -0,0 +1,64 @@
import { createPluginAPI } from "@/widgets/plugins/createPluginAPI";
import { HookRegistry } from "@/widgets/plugins/HookRegistry";
import { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
import type { WidgetPlugin } from "@/widgets/types";
export class PluginManager {
private readonly plugins = new Map<string, WidgetPlugin>();
constructor(
private readonly widgetRegistry: WidgetRegistry,
private readonly hookRegistry: HookRegistry,
private readonly slotRegistry: SlotRegistry,
private readonly layoutStore: Record<string, unknown>,
) {}
async register(plugin: WidgetPlugin): Promise<void> {
const missing = plugin.requires?.filter((dep) => !this.plugins.has(dep));
if (missing?.length) {
throw new Error(`Plugin ${plugin.id} missing deps: ${missing.join(", ")}`);
}
const api = createPluginAPI({
widgetRegistry: this.widgetRegistry,
hookRegistry: this.hookRegistry,
slotRegistry: this.slotRegistry,
layoutStore: this.layoutStore,
pluginId: plugin.id,
priority: plugin.priority ?? 0,
});
try {
await plugin.setup(api);
} catch (e) {
console.error(`[widgets] Plugin setup failed: ${plugin.id}`, e);
throw e;
}
this.plugins.set(plugin.id, plugin);
}
async unregister(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
if (!plugin) return;
try {
plugin.teardown?.();
} catch (e) {
console.error(`[widgets] Plugin teardown failed: ${pluginId}`, e);
}
this.hookRegistry.removeHooksForPlugin(pluginId);
this.slotRegistry.clearPlugin(pluginId);
this.plugins.delete(pluginId);
}
getPlugins(): WidgetPlugin[] {
return [...this.plugins.values()].sort(
(a, b) => (b.priority ?? 0) - (a.priority ?? 0),
);
}
hasPlugin(id: string): boolean {
return this.plugins.has(id);
}
}

View File

@ -0,0 +1,60 @@
import type { ComponentType } from "react";
import type { SlotId, SlotProps } from "@/widgets/types";
export type SlotEntry = {
/** Stable within session — supports multiple injections per plugin */
entryId: string;
pluginId: string;
component: ComponentType<SlotProps>;
};
const EMPTY: readonly SlotEntry[] = [];
/**
* Holds injected slot components; `subscribe` lets React re-render ExtensionSlot.
*/
export class SlotRegistry {
private version = 0;
private readonly slots = new Map<SlotId, SlotEntry[]>();
private readonly listeners = new Set<() => void>();
inject(slotId: SlotId, pluginId: string, component: ComponentType<SlotProps>): void {
const list = this.slots.get(slotId) ?? [];
const entryId = `${pluginId}:${list.length}:${Date.now()}`;
list.push({ entryId, pluginId, component });
this.slots.set(slotId, list);
this.bump();
}
clearPlugin(pluginId: string): void {
let changed = false;
for (const [id, list] of this.slots) {
const next = list.filter((e) => e.pluginId !== pluginId);
if (next.length !== list.length) {
changed = true;
if (next.length) this.slots.set(id, next);
else this.slots.delete(id);
}
}
if (changed) this.bump();
}
get(slotId: SlotId): readonly SlotEntry[] {
return this.slots.get(slotId) ?? EMPTY;
}
getVersion(): number {
return this.version;
}
subscribe = (onStoreChange: () => void): (() => void) => {
this.listeners.add(onStoreChange);
return () => this.listeners.delete(onStoreChange);
};
private bump(): void {
this.version += 1;
this.listeners.forEach((l) => l());
}
}

View File

@ -0,0 +1,88 @@
import type { ComponentType } from "react";
import type {
BaseWidgetProps,
ConfigField,
HookHandler,
HookName,
LayoutStore,
PluginAPI,
SlotId,
SlotProps,
} from "@/widgets/types";
import { HookRegistry } from "@/widgets/plugins/HookRegistry";
import { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
type CreatePluginAPIParams = {
widgetRegistry: WidgetRegistry;
hookRegistry: HookRegistry;
slotRegistry: SlotRegistry;
layoutStore: Record<string, unknown>;
pluginId: string;
priority: number;
};
export function createPluginAPI({
widgetRegistry,
hookRegistry,
slotRegistry,
layoutStore,
pluginId,
priority,
}: CreatePluginAPIParams): PluginAPI {
return {
registerWidget: (definition) => {
widgetRegistry.register(definition);
},
unregisterWidget: (widgetId) => {
widgetRegistry.unregister(widgetId);
},
modifyWidget: (widgetId, patch) => {
widgetRegistry.modify(widgetId, patch);
},
wrapWidget: (widgetId, wrapper) => {
const def = widgetRegistry.get(widgetId);
if (!def) return;
const Inner = def.component;
widgetRegistry.modify(widgetId, {
component: wrapper(Inner) as typeof Inner,
});
},
extendConfig: (widgetId, fields) => {
const def = widgetRegistry.get(widgetId);
if (!def) return;
const prev = def.metadata.configSchema ?? {};
widgetRegistry.modify(widgetId, {
metadata: {
...def.metadata,
configSchema: { ...prev, ...fields } as typeof def.metadata.configSchema,
},
});
},
addHook: <T extends HookName>(name: T, handler: HookHandler<T>) => {
hookRegistry.add(name, pluginId, priority, handler);
},
removeHook: <T extends HookName>(name: T, handler: HookHandler<T>) => {
hookRegistry.remove(name, handler);
},
injectSlot: (slotId: SlotId, component: ComponentType<SlotProps>) => {
slotRegistry.inject(slotId, pluginId, component);
},
getStore: () => layoutStore as Readonly<LayoutStore>,
subscribe: <T>(selector: (state: LayoutStore) => T, callback: (value: T) => void) => {
callback(selector(layoutStore as LayoutStore));
return () => {};
},
};
}

View File

@ -0,0 +1,35 @@
import type { BaseWidgetProps, WidgetDefinition } from "@/widgets/types";
export class WidgetRegistry {
private readonly defs = new Map<string, WidgetDefinition<BaseWidgetProps>>();
register<P extends BaseWidgetProps>(definition: WidgetDefinition<P>): void {
const id = definition.metadata.id;
this.defs.set(id, definition as WidgetDefinition<BaseWidgetProps>);
}
unregister(widgetId: string): void {
this.defs.delete(widgetId);
}
get(widgetId: string): WidgetDefinition<BaseWidgetProps> | undefined {
return this.defs.get(widgetId);
}
getAll(): WidgetDefinition<BaseWidgetProps>[] {
return [...this.defs.values()];
}
/** Non-destructive metadata merge + optional component replace */
modify(widgetId: string, patch: Partial<WidgetDefinition>): void {
const cur = this.defs.get(widgetId);
if (!cur) return;
this.defs.set(widgetId, {
...cur,
...patch,
metadata: patch.metadata
? { ...cur.metadata, ...patch.metadata }
: cur.metadata,
});
}
}

View File

@ -0,0 +1,32 @@
import { Fragment, useSyncExternalStore } from "react";
import { slotRegistry } from "@/widgets/system";
import type { SlotId, SlotProps } from "@/widgets/types";
type ExtensionSlotProps = SlotProps & {
slotId: SlotId;
};
/**
* Renders all components injected into a named slot (`PluginAPI.injectSlot`).
* See `packages/ui/docs/widgets-api.md` §13 (Slot Injection).
*/
export function ExtensionSlot(props: ExtensionSlotProps) {
const { slotId, ...slotProps } = props;
const entries = useSyncExternalStore(
slotRegistry.subscribe,
() => slotRegistry.get(slotId),
() => slotRegistry.get(slotId),
);
return (
<>
{entries.map(({ component: Component, entryId }) => (
<Fragment key={entryId}>
<Component {...slotProps} />
</Fragment>
))}
</>
);
}

View File

@ -0,0 +1,20 @@
/**
* Singleton widget system for the POC. For tests, instantiate `PluginManager` + registries manually.
*/
import { PluginManager } from "@/widgets/plugins/PluginManager";
import { HookRegistry } from "@/widgets/plugins/HookRegistry";
import { SlotRegistry } from "@/widgets/plugins/SlotRegistry";
import { WidgetRegistry } from "@/widgets/registry/WidgetRegistry";
export const layoutStore: Record<string, unknown> = {};
export const widgetRegistry = new WidgetRegistry();
export const hookRegistry = new HookRegistry();
export const slotRegistry = new SlotRegistry();
export const pluginManager = new PluginManager(
widgetRegistry,
hookRegistry,
slotRegistry,
layoutStore,
);

View File

@ -0,0 +1,194 @@
/**
* Types aligned with `packages/ui/docs/widgets-api.md` (§1, §2, §13).
* Intentionally minimal extend as the host app gains real layout stores.
*/
import type { ComponentType, ReactElement } from "react";
// ─── Widget props (§2) ─────────────────────────────────────────────────────
export interface BaseWidgetProps {
widgetInstanceId: string;
widgetDefId: string;
isEditMode: boolean;
enabled?: boolean;
onPropsChange: (partial: Record<string, unknown>) => Promise<void>;
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string, pageId?: string) => void;
editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void;
selectedContainerId?: string | null;
onSelectContainer?: (containerId: string, pageId?: string) => void;
contextVariables?: Record<string, unknown>;
pageContext?: Record<string, unknown>;
customClassName?: string;
}
export type WidgetCategory =
| "control"
| "display"
| "layout"
| "chart"
| "system"
| "custom";
export type WidgetCapability =
| "nested-layout"
| "translatable"
| "data-bound"
| "interactive"
| "exportable";
/** Property editor field — full union lives in widgets-api §3 */
export interface ConfigField {
type: string;
label: string;
description?: string;
default?: unknown;
group?: string;
order?: number;
[key: string]: unknown;
}
export type ConfigSchema<P extends Record<string, unknown>> = Partial<
Record<keyof P | string, ConfigField>
>;
export interface WidgetMetadata<P extends BaseWidgetProps = BaseWidgetProps> {
id: string;
name: string;
category: WidgetCategory;
description: string;
icon?: ComponentType;
thumbnail?: string;
defaultProps?: Partial<P>;
configSchema?: ConfigSchema<P & Record<string, unknown>>;
minSize?: { width: number; height: number };
resizable?: boolean;
tags?: string[];
capabilities?: WidgetCapability[];
maxInstances?: number;
}
export interface WidgetDefinition<P extends BaseWidgetProps = BaseWidgetProps> {
component: ComponentType<P>;
metadata: WidgetMetadata<P>;
editComponent?: React.LazyExoticComponent<ComponentType<P>>;
previewComponent?: ComponentType<P>;
validate?: (props: P) => Record<string, string> | null;
}
export interface WidgetInstance {
id: string;
widgetId: string;
props?: Record<string, unknown>;
order?: number;
}
// ─── Plugins & hooks (§13) ───────────────────────────────────────────────────
export type LayoutStore = Record<string, unknown>;
export interface HookContext {
pluginId: string;
pageId: string;
isEditMode: boolean;
store: Readonly<LayoutStore>;
}
export type HookName =
| "widget:beforeRender"
| "widget:afterRender"
| "widget:beforeSave"
| "widget:afterSave"
| "widget:beforeAdd"
| "widget:afterAdd"
| "widget:beforeRemove"
| "widget:afterRemove"
| "container:beforeRender"
| "container:afterRender"
| "container:beforeSave"
| "layout:beforeHydrate"
| "layout:afterHydrate"
| "layout:beforeSave"
| "layout:afterSave"
| "editor:beforeDrop"
| "editor:widgetPalette"
| "editor:contextMenu";
/** Slot identifiers from widgets-api §13 — extend as product adds chrome */
export type SlotId =
| "editor:toolbar:start"
| "editor:toolbar:end"
| "editor:sidebar:top"
| "editor:sidebar:bottom"
| "editor:panel:right:top"
| "editor:panel:right:bottom"
| "widget:toolbar"
| "container:toolbar"
| "container:empty"
| "page:header"
| "page:footer";
export interface SlotProps {
pageId: string;
isEditMode: boolean;
selectedWidgetId?: string | null;
selectedContainerId?: string | null;
}
export type WidgetWrapper = <P extends BaseWidgetProps>(
Component: ComponentType<P>,
) => ComponentType<P>;
export interface PluginAPI {
registerWidget: <P extends BaseWidgetProps>(definition: WidgetDefinition<P>) => void;
unregisterWidget: (widgetId: string) => void;
modifyWidget: (widgetId: string, patch: Partial<WidgetDefinition>) => void;
wrapWidget: (widgetId: string, wrapper: WidgetWrapper) => void;
extendConfig: (
widgetId: string,
fields: Record<string, ConfigField>,
) => void;
addHook: <T extends HookName>(name: T, handler: HookHandler<T>) => void;
removeHook: <T extends HookName>(name: T, handler: HookHandler<T>) => void;
injectSlot: (
slotId: SlotId,
component: ComponentType<SlotProps>,
) => void;
getStore: () => Readonly<LayoutStore>;
subscribe: <T>(
selector: (state: LayoutStore) => T,
callback: (value: T) => void,
) => () => void;
}
export interface WidgetPlugin {
id: string;
name: string;
version: string;
priority?: number;
requires?: string[];
setup: (api: PluginAPI) => void | Promise<void>;
teardown?: () => void;
}
/** Narrow hook handler typing — expand per hook as needed */
export type HookHandler<T extends HookName> =
T extends "editor:widgetPalette"
? (
widgets: WidgetDefinition[],
ctx: HookContext,
) => WidgetDefinition[]
: T extends "widget:beforeRender"
? (
props: Record<string, unknown>,
widget: WidgetInstance,
ctx: HookContext,
) => Record<string, unknown> | false
: T extends "widget:afterRender"
? (
element: ReactElement,
widget: WidgetInstance,
ctx: HookContext,
) => ReactElement
: (data: unknown, ctx: HookContext) => unknown;

View File

@ -0,0 +1,16 @@
import type { BaseWidgetProps } from "@/widgets/types";
export interface TextBlockWidgetProps extends BaseWidgetProps {
title?: string;
body?: string;
}
export function TextBlockWidget(props: TextBlockWidgetProps) {
const { title, body } = props;
return (
<div className="panel widget-text-block">
<h3>{title ?? "Text block"}</h3>
{body ? <p>{body}</p> : <p className="muted">Empty body</p>}
</div>
);
}