From 5a43ce1cd1c1806d92406d07f9b2c3db934a5693 Mon Sep 17 00:00:00 2001 From: Babayaga Date: Thu, 9 Apr 2026 13:52:07 +0200 Subject: [PATCH] ui-next poc - widgets --- packages/ui-next/README.md | 3 +- packages/ui-next/docs/widgets.md | 178 ++++++++++++++++ .../src/examples/migration/routeTree.tsx | 11 + .../examples/widgets/WidgetSystemDemoPage.tsx | 64 ++++++ packages/ui-next/src/styles.css | 29 +++ .../ui-next/src/widgets/bootstrap/boot.ts | 13 ++ .../src/widgets/bootstrap/corePlugin.tsx | 34 +++ .../widgets/bootstrap/demoToolbarPlugin.tsx | 24 +++ packages/ui-next/src/widgets/index.ts | 41 ++++ .../src/widgets/plugins/HookRegistry.ts | 77 +++++++ .../src/widgets/plugins/PluginManager.ts | 64 ++++++ .../src/widgets/plugins/SlotRegistry.ts | 60 ++++++ .../src/widgets/plugins/createPluginAPI.ts | 88 ++++++++ .../src/widgets/registry/WidgetRegistry.ts | 35 ++++ .../src/widgets/slots/ExtensionSlot.tsx | 32 +++ packages/ui-next/src/widgets/system.ts | 20 ++ packages/ui-next/src/widgets/types.ts | 194 ++++++++++++++++++ .../widgets/sample/TextBlockWidget.tsx | 16 ++ 18 files changed, 982 insertions(+), 1 deletion(-) create mode 100644 packages/ui-next/docs/widgets.md create mode 100644 packages/ui-next/src/examples/widgets/WidgetSystemDemoPage.tsx create mode 100644 packages/ui-next/src/widgets/bootstrap/boot.ts create mode 100644 packages/ui-next/src/widgets/bootstrap/corePlugin.tsx create mode 100644 packages/ui-next/src/widgets/bootstrap/demoToolbarPlugin.tsx create mode 100644 packages/ui-next/src/widgets/index.ts create mode 100644 packages/ui-next/src/widgets/plugins/HookRegistry.ts create mode 100644 packages/ui-next/src/widgets/plugins/PluginManager.ts create mode 100644 packages/ui-next/src/widgets/plugins/SlotRegistry.ts create mode 100644 packages/ui-next/src/widgets/plugins/createPluginAPI.ts create mode 100644 packages/ui-next/src/widgets/registry/WidgetRegistry.ts create mode 100644 packages/ui-next/src/widgets/slots/ExtensionSlot.tsx create mode 100644 packages/ui-next/src/widgets/system.ts create mode 100644 packages/ui-next/src/widgets/types.ts create mode 100644 packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx diff --git a/packages/ui-next/README.md b/packages/ui-next/README.md index 075e01fc..68e60a83 100644 --- a/packages/ui-next/README.md +++ b/packages/ui-next/README.md @@ -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 | diff --git a/packages/ui-next/docs/widgets.md b/packages/ui-next/docs/widgets.md new file mode 100644 index 00000000..b2600fd9 --- /dev/null +++ b/packages/ui-next/docs/widgets.md @@ -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. diff --git a/packages/ui-next/src/examples/migration/routeTree.tsx b/packages/ui-next/src/examples/migration/routeTree.tsx index b6f10aea..e79b1fda 100644 --- a/packages/ui-next/src/examples/migration/routeTree.tsx +++ b/packages/ui-next/src/examples/migration/routeTree.tsx @@ -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) { lazy + scroll + + widgets + plugins + loader @@ -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, diff --git a/packages/ui-next/src/examples/widgets/WidgetSystemDemoPage.tsx b/packages/ui-next/src/examples/widgets/WidgetSystemDemoPage.tsx new file mode 100644 index 00000000..66cd30bf --- /dev/null +++ b/packages/ui-next/src/examples/widgets/WidgetSystemDemoPage.tsx @@ -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 ( +
+

Widget system scaffold

+

+ Implements registry, PluginManager, PluginAPI, hooks, and{" "} + ExtensionSlot per packages/ui/docs/widgets-api.md §1–2, §13. +

+ +
+ Slot preview (edit chrome) +
+ +
+
+ +
+

Registry ({ready ? defs.length : "…"})

+ {ready ? ( +
    + {defs.map((d) => ( +
  • + {d.metadata.id} — {d.metadata.name} +
  • + ))} +
+ ) : ( +

Bootstrapping plugins…

+ )} +
+ + +
+ ); +} diff --git a/packages/ui-next/src/styles.css b/packages/ui-next/src/styles.css index bb8599c0..5fa8d8f1 100644 --- a/packages/ui-next/src/styles.css +++ b/packages/ui-next/src/styles.css @@ -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; +} diff --git a/packages/ui-next/src/widgets/bootstrap/boot.ts b/packages/ui-next/src/widgets/bootstrap/boot.ts new file mode 100644 index 00000000..7334d8c9 --- /dev/null +++ b/packages/ui-next/src/widgets/bootstrap/boot.ts @@ -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 { + if (booted) return; + booted = true; + await pluginManager.register(coreWidgetsPlugin); + await pluginManager.register(demoToolbarPlugin); +} diff --git a/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx b/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx new file mode 100644 index 00000000..cdbed0d4 --- /dev/null +++ b/packages/ui-next/src/widgets/bootstrap/corePlugin.tsx @@ -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({ + 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" }, + }, + }, + }); + }, +}; diff --git a/packages/ui-next/src/widgets/bootstrap/demoToolbarPlugin.tsx b/packages/ui-next/src/widgets/bootstrap/demoToolbarPlugin.tsx new file mode 100644 index 00000000..103b8a24 --- /dev/null +++ b/packages/ui-next/src/widgets/bootstrap/demoToolbarPlugin.tsx @@ -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 ( + + editor:toolbar:end · {props.pageId} + + ); + }); + }, +}; diff --git a/packages/ui-next/src/widgets/index.ts b/packages/ui-next/src/widgets/index.ts new file mode 100644 index 00000000..8f114de4 --- /dev/null +++ b/packages/ui-next/src/widgets/index.ts @@ -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"; diff --git a/packages/ui-next/src/widgets/plugins/HookRegistry.ts b/packages/ui-next/src/widgets/plugins/HookRegistry.ts new file mode 100644 index 00000000..c726276a --- /dev/null +++ b/packages/ui-next/src/widgets/plugins/HookRegistry.ts @@ -0,0 +1,77 @@ +import type { + HookContext, + HookHandler, + HookName, + LayoutStore, +} from "@/widgets/types"; + +type RegisteredHook = { + pluginId: string; + priority: number; + handler: HookHandler; +}; + +export class HookRegistry { + private readonly hooks = new Map(); + + add( + name: T, + pluginId: string, + priority: number, + handler: HookHandler, + ): void { + const list = this.hooks.get(name) ?? []; + list.push({ + pluginId, + priority, + handler: handler as HookHandler, + }); + list.sort((a, b) => b.priority - a.priority); + this.hooks.set(name, list); + } + + remove( + name: T, + handler: HookHandler, + ): 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( + 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 }; +} diff --git a/packages/ui-next/src/widgets/plugins/PluginManager.ts b/packages/ui-next/src/widgets/plugins/PluginManager.ts new file mode 100644 index 00000000..9dee23a0 --- /dev/null +++ b/packages/ui-next/src/widgets/plugins/PluginManager.ts @@ -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(); + + constructor( + private readonly widgetRegistry: WidgetRegistry, + private readonly hookRegistry: HookRegistry, + private readonly slotRegistry: SlotRegistry, + private readonly layoutStore: Record, + ) {} + + async register(plugin: WidgetPlugin): Promise { + 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 { + 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); + } +} diff --git a/packages/ui-next/src/widgets/plugins/SlotRegistry.ts b/packages/ui-next/src/widgets/plugins/SlotRegistry.ts new file mode 100644 index 00000000..c4537701 --- /dev/null +++ b/packages/ui-next/src/widgets/plugins/SlotRegistry.ts @@ -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; +}; + +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(); + private readonly listeners = new Set<() => void>(); + + inject(slotId: SlotId, pluginId: string, component: ComponentType): 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()); + } +} diff --git a/packages/ui-next/src/widgets/plugins/createPluginAPI.ts b/packages/ui-next/src/widgets/plugins/createPluginAPI.ts new file mode 100644 index 00000000..725bf3ee --- /dev/null +++ b/packages/ui-next/src/widgets/plugins/createPluginAPI.ts @@ -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; + 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: (name: T, handler: HookHandler) => { + hookRegistry.add(name, pluginId, priority, handler); + }, + + removeHook: (name: T, handler: HookHandler) => { + hookRegistry.remove(name, handler); + }, + + injectSlot: (slotId: SlotId, component: ComponentType) => { + slotRegistry.inject(slotId, pluginId, component); + }, + + getStore: () => layoutStore as Readonly, + + subscribe: (selector: (state: LayoutStore) => T, callback: (value: T) => void) => { + callback(selector(layoutStore as LayoutStore)); + return () => {}; + }, + }; +} diff --git a/packages/ui-next/src/widgets/registry/WidgetRegistry.ts b/packages/ui-next/src/widgets/registry/WidgetRegistry.ts new file mode 100644 index 00000000..da8f1b05 --- /dev/null +++ b/packages/ui-next/src/widgets/registry/WidgetRegistry.ts @@ -0,0 +1,35 @@ +import type { BaseWidgetProps, WidgetDefinition } from "@/widgets/types"; + +export class WidgetRegistry { + private readonly defs = new Map>(); + + register

(definition: WidgetDefinition

): void { + const id = definition.metadata.id; + this.defs.set(id, definition as WidgetDefinition); + } + + unregister(widgetId: string): void { + this.defs.delete(widgetId); + } + + get(widgetId: string): WidgetDefinition | undefined { + return this.defs.get(widgetId); + } + + getAll(): WidgetDefinition[] { + return [...this.defs.values()]; + } + + /** Non-destructive metadata merge + optional component replace */ + modify(widgetId: string, patch: Partial): 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, + }); + } +} diff --git a/packages/ui-next/src/widgets/slots/ExtensionSlot.tsx b/packages/ui-next/src/widgets/slots/ExtensionSlot.tsx new file mode 100644 index 00000000..b89865ab --- /dev/null +++ b/packages/ui-next/src/widgets/slots/ExtensionSlot.tsx @@ -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 }) => ( + + + + ))} + + ); +} diff --git a/packages/ui-next/src/widgets/system.ts b/packages/ui-next/src/widgets/system.ts new file mode 100644 index 00000000..9a5f69a6 --- /dev/null +++ b/packages/ui-next/src/widgets/system.ts @@ -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 = {}; + +export const widgetRegistry = new WidgetRegistry(); +export const hookRegistry = new HookRegistry(); +export const slotRegistry = new SlotRegistry(); + +export const pluginManager = new PluginManager( + widgetRegistry, + hookRegistry, + slotRegistry, + layoutStore, +); diff --git a/packages/ui-next/src/widgets/types.ts b/packages/ui-next/src/widgets/types.ts new file mode 100644 index 00000000..a13a8030 --- /dev/null +++ b/packages/ui-next/src/widgets/types.ts @@ -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) => Promise; + 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; + pageContext?: Record; + 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

> = Partial< + Record +>; + +export interface WidgetMetadata

{ + id: string; + name: string; + category: WidgetCategory; + description: string; + icon?: ComponentType; + thumbnail?: string; + defaultProps?: Partial

; + configSchema?: ConfigSchema

>; + minSize?: { width: number; height: number }; + resizable?: boolean; + tags?: string[]; + capabilities?: WidgetCapability[]; + maxInstances?: number; +} + +export interface WidgetDefinition

{ + component: ComponentType

; + metadata: WidgetMetadata

; + editComponent?: React.LazyExoticComponent>; + previewComponent?: ComponentType

; + validate?: (props: P) => Record | null; +} + +export interface WidgetInstance { + id: string; + widgetId: string; + props?: Record; + order?: number; +} + +// ─── Plugins & hooks (§13) ─────────────────────────────────────────────────── + +export type LayoutStore = Record; + +export interface HookContext { + pluginId: string; + pageId: string; + isEditMode: boolean; + store: Readonly; +} + +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 =

( + Component: ComponentType

, +) => ComponentType

; + +export interface PluginAPI { + registerWidget:

(definition: WidgetDefinition

) => void; + unregisterWidget: (widgetId: string) => void; + modifyWidget: (widgetId: string, patch: Partial) => void; + wrapWidget: (widgetId: string, wrapper: WidgetWrapper) => void; + extendConfig: ( + widgetId: string, + fields: Record, + ) => void; + addHook: (name: T, handler: HookHandler) => void; + removeHook: (name: T, handler: HookHandler) => void; + injectSlot: ( + slotId: SlotId, + component: ComponentType, + ) => void; + getStore: () => Readonly; + subscribe: ( + 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; + teardown?: () => void; +} + +/** Narrow hook handler typing — expand per hook as needed */ +export type HookHandler = + T extends "editor:widgetPalette" + ? ( + widgets: WidgetDefinition[], + ctx: HookContext, + ) => WidgetDefinition[] + : T extends "widget:beforeRender" + ? ( + props: Record, + widget: WidgetInstance, + ctx: HookContext, + ) => Record | false + : T extends "widget:afterRender" + ? ( + element: ReactElement, + widget: WidgetInstance, + ctx: HookContext, + ) => ReactElement + : (data: unknown, ctx: HookContext) => unknown; diff --git a/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx b/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx new file mode 100644 index 00000000..ff24df89 --- /dev/null +++ b/packages/ui-next/src/widgets/widgets/sample/TextBlockWidget.tsx @@ -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 ( +

+

{title ?? "Text block"}

+ {body ?

{body}

:

Empty body

} +
+ ); +}