ui:next widgets1/2

This commit is contained in:
lovebird 2026-04-09 19:21:47 +02:00
parent 5f75ee5bb1
commit 56164fdb31

View File

@ -1,12 +1,19 @@
# 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).
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**; injected components appear there without the core editor importing plugin packages directly.
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
@ -26,9 +33,15 @@ flowchart TB
SR[(SlotRegistry)]
end
subgraph store["Zustand store"]
LS[(useLayoutStore\npages / tree ops)]
end
subgraph react["React tree"]
ES[ExtensionSlot slotId]
W[Widget components]
NR[NodeRenderer\nview mode]
EC[EditorContext\nisEditMode / selectedNodeId]
ES2[EditorShell chunk\nEditableNodeRenderer\nNodePropertiesPanel\nEditToggle]
end
PM --> P1
@ -38,15 +51,20 @@ flowchart TB
API --> WR
API --> HR
API --> SR
WR --> W
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 (see §3).
Registration order matters for **`requires`**: dependencies must already be
registered. Higher **`priority`** runs first for ordered hooks.
```mermaid
sequenceDiagram
@ -71,9 +89,10 @@ sequenceDiagram
---
## 3. Hook pipeline (conceptual)
## 3. Hook pipeline
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.
Hook handlers are stored with **plugin id** and **priority**. When the host
calls **`HookRegistry.runSync`**, handlers run in **descending priority**.
```mermaid
flowchart LR
@ -93,86 +112,257 @@ flowchart LR
## 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]
```
**`PluginAPI.injectSlot(slotId, Component)`** appends a React component to
that slot. **`ExtensionSlot`** subscribes to **`SlotRegistry`** via
**`useSyncExternalStore`**.
---
## 5. Data flow: from plugin to screen
## 5. Unified Node model — what's built
```mermaid
flowchart LR
subgraph pluginSide["Plugin"]
R["registerWidget(metadata.id)"]
I["injectSlot(editor:toolbar:end)"]
end
The scaffold implements **§14** of the master plan in full. Every layout
element is a `LayoutNode`; containers are just nodes with `canHaveChildren:
true`.
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
```
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'
```
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`**.
Store tree operations: `initPage`, `loadPage`, `savePage`, `removePage`,
`addChild`, `removeNode`, `moveNode`, `updateNodeProps`, `updateNodeLayout`.
`findNode` is exported for external consumers (e.g. `NodePropertiesPanel`).
---
## 6. Module map (this package)
## 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`, `WidgetPlugin`, `PluginAPI`, `HookName`, `SlotId`, … |
| [`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 for the POC |
| [`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 |
---
## 7. Relationship to the full widgets API
## 8. Status vs master plan
| 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`**.
| 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 | |
---
## 8. Try it
## 9. TODO list
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.
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.