From 56164fdb314a89500c2360f932e2ec1b542da79a Mon Sep 17 00:00:00 2001 From: Babayaga Date: Thu, 9 Apr 2026 19:21:47 +0200 Subject: [PATCH] ui:next widgets1/2 --- packages/ui-next/docs/widgets.md | 314 +++++++++++++++++++++++++------ 1 file changed, 252 insertions(+), 62 deletions(-) diff --git a/packages/ui-next/docs/widgets.md b/packages/ui-next/docs/widgets.md index b2600fd9..76843c59 100644 --- a/packages/ui-next/docs/widgets.md +++ b/packages/ui-next/docs/widgets.md @@ -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.