mono/packages/ui-next/docs/widgets.md
2026-04-09 19:21:47 +02:00

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 WidgetDefinitioncomponent, 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.