16 KiB
Widget system (@polymech/ui-next)
This document tracks the implementation status of the scaffold under
src/widgets/ and src/widgets-editor/.
It maps every section of the master contract
packages/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.
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.
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.
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 |
WidgetDefinition, LayoutNode, PageLayout, NodeLayout, ConfigSchema, … |
WidgetRegistry |
CRUD for widget definitions |
HookRegistry |
Hook lists + runSync |
SlotRegistry |
Slot entries + subscribe |
createPluginAPI |
Facade passed into plugin.setup |
PluginManager |
register / unregister |
system.ts |
Process-wide singletons |
ExtensionSlot |
Renders slot content |
NodeRenderer |
Recursive view renderer |
boot.ts |
bootWidgetSystem() |
corePlugin.tsx |
flex, flex-row, text-block registrations |
useLayoutStore |
Zustand store — pages + tree ops |
PagePersistence |
Persistence interface |
LocalStoragePagePersistence |
localStorage impl |
EditorContext |
isEditMode, selectedNodeId (main bundle) |
EditorShell |
Lazy chunk boundary |
lazy.tsx |
Thin wrappers — LazyEditableNodeRenderer etc. |
EditableNodeRenderer |
Edit canvas — DnD + overlays |
NodePropertiesPanel |
Side panel |
NodePropertiesForm |
configSchema field renderer |
NodeEditorOverlay |
Per-node editing chrome |
EditToggle |
Mode toggle + save |
TextBlockWidgetEdit |
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:widgetPalettehook wired — filter palette list via plugin hook before rendering it. - [editor]
addChildaction exposed inEditableNodeRenderer— clicking a palette item callsuseLayoutStore().addChild(parentId, newNode). - [editor] Empty-container drop zone — render
container:emptyslot content when aflex-roworflexhas 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 —
Deleteto remove selected node,Escapeto deselect,Ctrl+Z/Ctrl+Shift+Zfor undo/redo. - [editor] Right-click context menu on
NodeEditorOverlay— hookeditor:contextMenuto 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 (seeConfigField.showWhenin 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/stepselectWithText— preset select + freeform fallback inputclassname— Tailwind class picker (port fromui)json— code editor (Monaco or textarea) for raw JSON propscomponent— escape hatch: register a custom React component as the editor for a field
9.4 Widget lifecycle hooks
- [core] Define
WidgetLifecycleContexttype (§8 of master plan). - [core] Call
def.onInit(ctx)in the store'saddChildaction when a node is first inserted; merge returned props intonode.props. - [core] Call
def.onBeforeSave(props, ctx)insavePagebefore persisting. - [core] Call
def.onDestroy(props, ctx)inremoveNode.
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)— addconfigSchemafields 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— addrunSync/runAsynccall-sites in the store and renderer. - [core] Verify / fix
modifyWidgetandwrapWidget— write a unit test that registers a wrapper HOC and confirms the wrapped component renders.
9.6 State management consolidation
- [core] Move
isEditModeandselectedNodeIdfromEditorContextintouseLayoutStore(§12 recommendation).EditorContextbecomes a thin shim that reads from the store, or is removed entirely. - [core] Add
subscribeWithSelectormiddleware touseLayoutStoreso consumers can subscribe to narrow slices without re-rendering on unrelated mutations.
9.7 Node layout model
- [core] Add
NodeLayout.rows: RowDef[]support alongsidecolumns— enables per-row independent column widths (1fr 2fr) rather than the current uniformrepeat(N, 1fr). - [core]
ContainerSettings.visibleWhen— evaluate aVisibilityConditionrule (e.g.'role:admin') and hide the node in view mode when not met. - [core]
ContainerSettings.background/padding/maxWidth— surface these asconfigSchemafields onflexandflex-rownode types.
9.8 Persistence
- [core] Backend persistence adapter — replace
LocalStoragePagePersistencewith aRestApiPagePersistencethat calls a real API; thePagePersistenceinterface is already defined, only the implementation changes. - [core] Conflict resolution / optimistic locking — compare
updatedAton save; show a merge conflict UI if the server version is newer. - [core]
PageLayout.meta— adddescription,thumbnail,lockedto the stored layout and exposelockedin the editor (disable edit mode whenlocked: true).
9.9 Remote components (§15 Level 4)
- [core] Register the
__remotemeta-widget that dynamic-imports a component from a URL at runtime. - [core]
ErrorBoundarywrapper insideRemoteComponentso a crashing remote doesn't take down the page. - [core] Permission gate — hook
editor:widgetPaletteto hide__remotefrom non-admin users; hookwidget:beforeRenderto validate the URL against an allowlist.
9.10 Export / i18n
- [core]
renderStatic(props, ExportContext): string— add toWidgetDefinition; implement ontext-blockas a baseline; call from a newexportPage(pageId, format)utility. - [core]
translatablePropsonWidgetDefinition— used by an export pipeline or i18n plugin to extract strings for translation.
10. Try it
npm run devinpackages/ui-next./examples/widgets-system— plugin bootstrap, slot injection, registry inspection./examples/layout— unified Node model,EditableNodeRenderer, drag-reorder, properties panel, lazyEditorShellchunk.