# Widget system (`@polymech/ui-next`) This document tracks the **implementation status** of the scaffold under [`src/widgets/`](../src/widgets/index.ts) and [`src/widgets-editor/`](../src/widgets-editor/index.ts). It maps every section of the master contract [`packages/ui/docs/widgets-api.md`](../../ui/docs/widgets-api.md) to either **Done**, **Partial**, or a **TODO** item. --- ## 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**. In edit mode the lazy **`EditorShell`** chunk loads on first activation. ```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 store["Zustand store"] LS[(useLayoutStore\npages / tree ops)] end subgraph react["React tree"] ES[ExtensionSlot slotId] NR[NodeRenderer\nview mode] EC[EditorContext\nisEditMode / selectedNodeId] ES2[EditorShell chunk\nEditableNodeRenderer\nNodePropertiesPanel\nEditToggle] end PM --> P1 PM --> P2 P1 --> API[PluginAPI] P2 --> API API --> WR API --> HR API --> SR WR --> NR WR --> ES2 SR --> ES LS --> NR LS --> ES2 EC -.lazy.-> ES2 ``` --- ## 2. Plugin lifecycle Registration order matters for **`requires`**: dependencies must already be registered. Higher **`priority`** runs first for ordered hooks. ```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 Hook handlers are stored with **plugin id** and **priority**. When the host calls **`HookRegistry.runSync`**, handlers run in **descending priority**. ```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`**. --- ## 5. Unified Node model — what's built The scaffold implements **§14** of the master plan in full. Every layout element is a `LayoutNode`; containers are just nodes with `canHaveChildren: true`. ``` PageLayout └── root: LayoutNode type: 'flex' ├── LayoutNode type: 'flex-row' (layout.columns = 1) │ └── LayoutNode type: 'text-block' └── LayoutNode type: 'flex-row' (layout.columns = 3) ├── LayoutNode type: 'text-block' ├── LayoutNode type: 'text-block' └── LayoutNode type: 'text-block' ``` Store tree operations: `initPage`, `loadPage`, `savePage`, `removePage`, `addChild`, `removeNode`, `moveNode`, `updateNodeProps`, `updateNodeLayout`. `findNode` is exported for external consumers (e.g. `NodePropertiesPanel`). --- ## 6. Edit-mode architecture (widgets-editor) The **`EditorShell`** is the single dynamic-import chunk boundary. Everything in it — DnD, overlays, properties panel, toggle bar — is absent from the view-only bundle. ``` main bundle editor chunk (lazy) ────────────────────────── ────────────────────────────── EditorContext EditableNodeRenderer isEditMode @dnd-kit/core + @dnd-kit/sortable selectedNodeId SortableContainer + SortableItem setSelectedNodeId NodeEditorOverlay (drag grip, delete, badge) lazy.tsx NodePropertiesPanel LazyEditableNodeRenderer NodePropertiesForm (field renderer) LazyNodePropertiesPanel EditToggle (mode switch + save) LazyEditToggle view path (NodeRenderer) ← never touches editor chunk ``` Build output (vite/rolldown): | Chunk | Size gz | Contents | |---|---|---| | `index.js` | ~49 KB | app + widget system + view renderer | | `EditorShell.js` | ~19 KB | all edit code + @dnd-kit | | `TextBlockWidgetEdit.js` | ~0.7 KB | inline edit component per widget | --- ## 7. Module map | Module | Role | |--------|------| | [`types.ts`](../src/widgets/types.ts) | `WidgetDefinition`, `LayoutNode`, `PageLayout`, `NodeLayout`, `ConfigSchema`, … | | [`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 | | [`ExtensionSlot`](../src/widgets/slots/ExtensionSlot.tsx) | Renders slot content | | [`NodeRenderer`](../src/widgets/renderer/NodeRenderer.tsx) | Recursive view renderer | | [`boot.ts`](../src/widgets/bootstrap/boot.ts) | `bootWidgetSystem()` | | [`corePlugin.tsx`](../src/widgets/bootstrap/corePlugin.tsx) | `flex`, `flex-row`, `text-block` registrations | | [`useLayoutStore`](../src/store/useLayoutStore.ts) | Zustand store — pages + tree ops | | [`PagePersistence`](../src/store/persistence/PagePersistence.ts) | Persistence interface | | [`LocalStoragePagePersistence`](../src/store/persistence/LocalStoragePagePersistence.ts) | localStorage impl | | [`EditorContext`](../src/widgets-editor/context/EditorContext.tsx) | `isEditMode`, `selectedNodeId` (main bundle) | | [`EditorShell`](../src/widgets-editor/EditorShell.tsx) | Lazy chunk boundary | | [`lazy.tsx`](../src/widgets-editor/lazy.tsx) | Thin wrappers — `LazyEditableNodeRenderer` etc. | | [`EditableNodeRenderer`](../src/widgets-editor/EditableNodeRenderer.tsx) | Edit canvas — DnD + overlays | | [`NodePropertiesPanel`](../src/widgets-editor/components/NodePropertiesPanel.tsx) | Side panel | | [`NodePropertiesForm`](../src/widgets-editor/components/NodePropertiesForm.tsx) | configSchema field renderer | | [`NodeEditorOverlay`](../src/widgets-editor/components/NodeEditorOverlay.tsx) | Per-node editing chrome | | [`EditToggle`](../src/widgets-editor/components/EditToggle.tsx) | Mode toggle + save | | [`TextBlockWidgetEdit`](../src/widgets/widgets/sample/TextBlockWidgetEdit.tsx) | Lazy inline edit for text-block | --- ## 8. Status vs master plan | Master plan section | Status | Notes | |---------------------|--------|-------| | §1 `WidgetDefinition` — `component`, `editComponent`, `metadata`, `validate` | ✅ Done | `editComponent` lazy-loaded via `React.lazy` | | §1 `onInit` / `onBeforeSave` / `onDestroy` lifecycle hooks | ❌ TODO | | | §1 `translatableProps` / `extractTranslatableContent` | ❌ TODO | | | §1 `renderStatic` (HTML/email/PDF export) | ❌ TODO | | | §1 `getNestedLayouts` / `nestedLayoutStrategy` | 🚫 N/A | Superseded by §14 unified tree | | §2 `BaseWidgetProps` full contract | ✅ Done | All fields defined in `types.ts` | | §3 `ConfigSchema` — text, number, boolean, select, color, markdown | ✅ Done | `NodePropertiesForm` renders these | | §3 `ConfigField.showWhen` / `disableWhen` conditional visibility | ❌ TODO | | | §3 `ConfigField.required` / `pattern` / `customValidate` validation | ❌ TODO | | | §3 field types: `range`, `classname`, `json`, `component` (custom editor) | ❌ TODO | | | §3 field type: `selectWithText` | ❌ TODO | | | §4 `WidgetInstance` | ✅ Done | Replaced by `LayoutNode` in Node model | | §5 Container types | ✅ Done | Replaced by `flex` / `flex-row` Node types | | §5 `ContainerSettings.visibleWhen` | ❌ TODO | | | §5 `ContainerSettings.background` / `padding` / `maxWidth` | ❌ TODO | | | §6 `PageLayout` with `root: Node` | ✅ Done | | | §6 `PageLayout.meta` (description, thumbnail, locked) | ❌ TODO | | | §7 `NestedLayoutManager` | 🚫 N/A | Not needed — tree is the layout | | §8 `WidgetLifecycleContext` | ❌ TODO | Needed for `onInit` / `onDestroy` | | §9 Container rendering contract | ✅ Done | Replaced by `NodeRenderer` | | §10 Layout context — page ops, widget ops | ✅ Done | Zustand store covers these | | §10 `nestedLayouts` on context | 🚫 N/A | | | §11 `ExportContext` / `renderStatic` | ❌ TODO | | | §12 Zustand store for layout state | ✅ Done | `useLayoutStore` — pages, tree ops | | §12 `isEditMode` / `selectedWidgetId` in Zustand (not React context) | ❌ TODO | Currently in `EditorContext` | | §13 Plugin system — `registerWidget`, `addHook`, `injectSlot` | ✅ Done | | | §13 `modifyWidget`, `wrapWidget`, `extendConfig` | ⚠️ Partial | Declared in `PluginAPI`; impl needs verification | | §13 `registerContainerType`, `extendContainerSettings` | ❌ TODO | Not in current `PluginAPI` | | §13 Hook pipeline wired for real hooks (not just `editor:widgetPalette`) | ❌ TODO | Registry exists; callers not wired | | §14 Unified Node model — `LayoutNode`, tree ops, `NodeRenderer` | ✅ Done | Core of the scaffold | | §14 `NodeLayout.rows` (RowDef[] for per-row column control) | ❌ TODO | Currently `columns` only | | §15 Level 1 — pre-registered widgets | ✅ Done | | | §15 Level 2 — lazy-imported (`editComponent`) | ✅ Done | | | §15 Level 3 — plugin-registered (npm) | ✅ Done | | | §15 Level 4 — remote components (`__remote` node type) | ❌ TODO | | --- ## 9. TODO list TODOs are grouped by theme. Items marked **[editor]** require `EditorShell` changes; items marked **[core]** live in the main bundle. ### 9.1 Add-widget / palette - **[editor]** Widget palette UI — list all `widgetRegistry.getAll()` entries by category; drag from palette → drop onto a container node. - **[editor]** `editor:widgetPalette` hook wired — filter palette list via plugin hook before rendering it. - **[editor]** `addChild` action exposed in `EditableNodeRenderer` — clicking a palette item calls `useLayoutStore().addChild(parentId, newNode)`. - **[editor]** Empty-container drop zone — render `container:empty` slot content when a `flex-row` or `flex` has no children (shows the palette or a prompt). ### 9.2 Editor UX - **[editor]** Undo / redo — snapshot the page tree on every mutation; expose `undo()` / `redo()` in the store or editor context. - **[editor]** Keyboard shortcuts — `Delete` to remove selected node, `Escape` to deselect, `Ctrl+Z` / `Ctrl+Shift+Z` for undo/redo. - **[editor]** Right-click context menu on `NodeEditorOverlay` — hook `editor:contextMenu` to allow plugins to inject items. - **[editor]** Multi-select — shift-click to select multiple nodes; bulk delete / move. - **[editor]** Node breadcrumb trail — show the ancestor path of the selected node (`flex > flex-row > text-block`). ### 9.3 ConfigSchema / NodePropertiesForm - **[editor]** `showWhen` / `disableWhen` — evaluate condition before rendering a field (see `ConfigField.showWhen` in master plan §3). - **[editor]** Field validation — `required`, `pattern`, `customValidate`; show error messages inline below each field. - **[editor]** New field types: - `range` — slider with min/max/step - `selectWithText` — preset select + freeform fallback input - `classname` — Tailwind class picker (port from `ui`) - `json` — code editor (Monaco or textarea) for raw JSON props - `component` — escape hatch: register a custom React component as the editor for a field ### 9.4 Widget lifecycle hooks - **[core]** Define `WidgetLifecycleContext` type (§8 of master plan). - **[core]** Call `def.onInit(ctx)` in the store's `addChild` action when a node is first inserted; merge returned props into `node.props`. - **[core]** Call `def.onBeforeSave(props, ctx)` in `savePage` before persisting. - **[core]** Call `def.onDestroy(props, ctx)` in `removeNode`. ### 9.5 Plugin API gaps - **[core]** `registerContainerType(type, renderer)` — allow plugins to define new structural node types with their own view + edit renderers (§13 Plugin API). - **[core]** `extendContainerSettings(type, fields)` — add `configSchema` fields to an existing node type from a plugin. - **[core]** Wire the full hook pipeline: `widget:beforeRender`, `widget:afterRender`, `widget:beforeAdd`, `widget:afterAdd`, `widget:beforeRemove`, `layout:beforeSave`, `layout:afterSave` — add `runSync` / `runAsync` call-sites in the store and renderer. - **[core]** Verify / fix `modifyWidget` and `wrapWidget` — write a unit test that registers a wrapper HOC and confirms the wrapped component renders. ### 9.6 State management consolidation - **[core]** Move `isEditMode` and `selectedNodeId` from `EditorContext` into `useLayoutStore` (§12 recommendation). `EditorContext` becomes a thin shim that reads from the store, or is removed entirely. - **[core]** Add `subscribeWithSelector` middleware to `useLayoutStore` so consumers can subscribe to narrow slices without re-rendering on unrelated mutations. ### 9.7 Node layout model - **[core]** Add `NodeLayout.rows: RowDef[]` support alongside `columns` — enables per-row independent column widths (`1fr 2fr`) rather than the current uniform `repeat(N, 1fr)`. - **[core]** `ContainerSettings.visibleWhen` — evaluate a `VisibilityCondition` rule (e.g. `'role:admin'`) and hide the node in view mode when not met. - **[core]** `ContainerSettings.background` / `padding` / `maxWidth` — surface these as `configSchema` fields on `flex` and `flex-row` node types. ### 9.8 Persistence - **[core]** Backend persistence adapter — replace `LocalStoragePagePersistence` with a `RestApiPagePersistence` that calls a real API; the `PagePersistence` interface is already defined, only the implementation changes. - **[core]** Conflict resolution / optimistic locking — compare `updatedAt` on save; show a merge conflict UI if the server version is newer. - **[core]** `PageLayout.meta` — add `description`, `thumbnail`, `locked` to the stored layout and expose `locked` in the editor (disable edit mode when `locked: true`). ### 9.9 Remote components (§15 Level 4) - **[core]** Register the `__remote` meta-widget that dynamic-imports a component from a URL at runtime. - **[core]** `ErrorBoundary` wrapper inside `RemoteComponent` so a crashing remote doesn't take down the page. - **[core]** Permission gate — hook `editor:widgetPalette` to hide `__remote` from non-admin users; hook `widget:beforeRender` to validate the URL against an allowlist. ### 9.10 Export / i18n - **[core]** `renderStatic(props, ExportContext): string` — add to `WidgetDefinition`; implement on `text-block` as a baseline; call from a new `exportPage(pageId, format)` utility. - **[core]** `translatableProps` on `WidgetDefinition` — used by an export pipeline or i18n plugin to extract strings for translation. --- ## 10. Try it 1. `npm run dev` in `packages/ui-next`. 2. **`/examples/widgets-system`** — plugin bootstrap, slot injection, registry inspection. 3. **`/examples/layout`** — unified Node model, `EditableNodeRenderer`, drag-reorder, properties panel, lazy `EditorShell` chunk.