@dfosco/storyboard 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/storyboard-ui.css +1 -1
- package/dist/storyboard-ui.js +1 -10
- package/dist/storyboard-ui.js.map +1 -1
- package/dist/tailwind.css +1 -1
- package/package.json +1 -4
- package/scaffold/AGENTS.md +2 -0
- package/scaffold/skills/storyboard-core/SKILL.md +63 -171
- package/scaffold/skills/storyboard-widget/SKILL.md +172 -0
- package/scaffold/skills/tools/SKILL.md +12 -12
- package/src/core/canvas/collision.js +41 -19
- package/src/core/canvas/customWidgets.js +92 -0
- package/src/core/canvas/customWidgets.test.js +69 -0
- package/src/core/canvas/server.js +4 -3
- package/src/core/index.js +13 -0
- package/src/core/mountStoryboardCore.js +10 -0
- package/src/core/stores/configSchema.js +2 -1
- package/src/core/stores/widgetRegistry.js +184 -0
- package/src/core/stores/widgetRegistry.test.js +109 -0
- package/src/core/ui/CoreUIBar.jsx +1 -1
- package/src/core/ui-entry.js +0 -6
- package/src/core/vite/server-plugin.js +12 -0
- package/src/internals/BranchBar/BranchBar.jsx +27 -20
- package/src/internals/canvas/CanvasPage.bridge.test.jsx +2 -0
- package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +2 -0
- package/src/internals/canvas/CanvasPage.jsx +19 -3
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +2 -0
- package/src/internals/canvas/widgets/WidgetChrome.jsx +50 -5
- package/src/internals/canvas/widgets/WidgetChrome.test.jsx +65 -0
- package/src/internals/canvas/widgets/index.js +18 -4
- package/src/internals/canvas/widgets/widgetConfig.js +202 -45
- package/src/internals/canvas/widgets/widgetConfig.merged.test.js +144 -0
- package/src/internals/context.jsx +1 -17
- package/src/core/ui/design-modes.ts +0 -7
- package/src/core/ui/viewfinder.ts +0 -7
- package/src/core/workshop/ui/mount.ts +0 -6
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: storyboard-widget
|
|
3
|
+
description: Guide for creating custom canvas widgets — register a React component as a new widget type with full control over chrome (decorated or invisible), interaction switches (selectable / movable / resizable / expandable / split-screen / interact-gate), connector anchors, toolbar features, and prop schemas. Use when asked to create a custom widget, register a widget, add a new canvas widget type, build an invisible/locked/pinned widget, expose a component as a widget, or override a core widget.
|
|
4
|
+
metadata:
|
|
5
|
+
author: Daniel Fosco
|
|
6
|
+
version: "2026.6.6"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Skill: storyboard-widget — Custom Canvas Widget Creation
|
|
10
|
+
|
|
11
|
+
Creating a new canvas widget type means registering a React component plus a small metadata definition with the storyboard widget registry. The same configuration surface drives the built-in core widgets (sticky-note, image, terminal, etc.) — so anything they can do, your widget can do too.
|
|
12
|
+
|
|
13
|
+
> **Authoritative reference:** [`DOCS/custom-widgets.md` on GitHub](https://github.com/dfosco/storyboard/blob/main/DOCS/custom-widgets.md). When in doubt, fetch that doc and follow it.
|
|
14
|
+
|
|
15
|
+
## Triggers
|
|
16
|
+
|
|
17
|
+
- "create a custom widget", "register a widget", "add a new canvas widget type"
|
|
18
|
+
- "expose this component as a widget", "make X a canvas widget"
|
|
19
|
+
- "build an invisible widget", "build a locked / pinned / decorative widget"
|
|
20
|
+
- "override the sticky note / image / terminal widget"
|
|
21
|
+
- "add custom toolbar actions to a widget"
|
|
22
|
+
- Any prompt mentioning `mountStoryboardCore({ widgets })`, `registerWidget`, or `chrome.enabled`
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Decision flow — collect these inputs before writing code
|
|
27
|
+
|
|
28
|
+
Ask the user (one question per turn — use `ask_user`) until you have all of:
|
|
29
|
+
|
|
30
|
+
1. **Widget `type` string** — kebab-case, e.g. `kpi-tile`, `code-runner`. This goes into the canvas JSONL and never changes after the first widget instance is saved.
|
|
31
|
+
2. **Display label + icon** — what appears in the `+ Add widget` menu (skip if the widget should be hidden via `unlisted: true`).
|
|
32
|
+
3. **Chrome decision** — three choices:
|
|
33
|
+
- **Decorated (default)** — wrap in standard WidgetChrome (hover toolbar, anchor ports, select handle, optional gate). Use when the widget should feel like a native storyboard widget.
|
|
34
|
+
- **Invisible (`chrome.enabled: false`)** — no built-in chrome. Component owns 100 % of the rendered surface. Use for landing-page decorations, brand chrome, fully custom interaction surfaces.
|
|
35
|
+
- **Decorated but locked** (`chrome.enabled: true` + `interaction.movable: false` and/or `interaction.selectable: false`) — keep the chrome for actions but pin in place / hide select handle.
|
|
36
|
+
4. **Interaction switches** — confirm defaults or override:
|
|
37
|
+
- `selectable` (default `true`)
|
|
38
|
+
- `movable` (default `true`)
|
|
39
|
+
- `resize` (default disabled — pass `{ enabled: true, prod: false }` to enable dev-only resize, `prod: true` for prod too)
|
|
40
|
+
- `expandable` (default `false`)
|
|
41
|
+
- `splitScreen` (default `false`)
|
|
42
|
+
- `interactGate` (default `false`) — when `true`, overlays "Click to interact" until clicked. Use for embedded iframes, third-party components, anything that swallows pointer events you don't want stealing canvas pan/zoom.
|
|
43
|
+
5. **Connectors** — accept all (`accept: ['*']`) or restrict to specific types? Any anchors that should be `disabled` or `unavailable`?
|
|
44
|
+
6. **Toolbar features** — most widgets only need `copy` + `delete` in the overflow menu. Skip for invisible widgets that own their own UI.
|
|
45
|
+
7. **Props schema** — at minimum `width` and `height` (used by the autoplacement engine for default size). Any other persisted state (`text`, `url`, `variant`, etc.) goes here.
|
|
46
|
+
8. **Where to register** — almost always `mountStoryboardCore({ widgets })` in the consumer's main entry. If the user wants per-canvas dynamic registration, use `registerWidget()` instead.
|
|
47
|
+
|
|
48
|
+
If the user gives you incomplete info, fill in safe defaults and tell them.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Implementation steps
|
|
53
|
+
|
|
54
|
+
### 1. Create the component file
|
|
55
|
+
|
|
56
|
+
`src/widgets/<WidgetName>/<WidgetName>.jsx` (follow the project convention — components in their own directory). Use CSS modules for styles.
|
|
57
|
+
|
|
58
|
+
The component receives these props by the contract:
|
|
59
|
+
|
|
60
|
+
| Prop | Type |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `id` | `string` — stable widget id |
|
|
63
|
+
| `props` | `object` — current persisted props (matches your `props` schema) |
|
|
64
|
+
| `onUpdate(updates)` | `(object) => void` — patch persisted props |
|
|
65
|
+
| `resizable` | `boolean` |
|
|
66
|
+
| `selected` | `boolean` |
|
|
67
|
+
| `onSelect(shiftKey?)` | `(boolean) => void` |
|
|
68
|
+
| `multiSelected` | `boolean` |
|
|
69
|
+
|
|
70
|
+
**For invisible widgets (`chrome.enabled: false`):** render your own select affordance when `selected` is true. Otherwise the user can't tell the widget is selected.
|
|
71
|
+
|
|
72
|
+
**For widgets with custom toolbar features:** wrap the component in `forwardRef` + `useImperativeHandle(ref, () => ({ handleAction(actionId) { ... } }))`. Return `false` to fall through to the default canvas handler; return anything else (or undefined) to claim the action.
|
|
73
|
+
|
|
74
|
+
### 2. Register at mount
|
|
75
|
+
|
|
76
|
+
In the consumer's entry (typically `src/main.jsx` or a `_app.jsx` file):
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { mountStoryboardCore } from '@dfosco/storyboard'
|
|
80
|
+
import storyboardConfig from './storyboard.config.json'
|
|
81
|
+
import MyWidget from './widgets/MyWidget/MyWidget'
|
|
82
|
+
|
|
83
|
+
mountStoryboardCore(storyboardConfig, {
|
|
84
|
+
basePath: import.meta.env.BASE_URL || '/',
|
|
85
|
+
widgets: {
|
|
86
|
+
'my-widget': {
|
|
87
|
+
component: MyWidget,
|
|
88
|
+
label: 'My Widget',
|
|
89
|
+
icon: 'sparkle-fill',
|
|
90
|
+
chrome: { enabled: true },
|
|
91
|
+
interaction: { selectable: true, movable: true },
|
|
92
|
+
connectors: { anchors: { top: 'available', bottom: 'available', left: 'available', right: 'available' }, accept: ['*'], exclude: [] },
|
|
93
|
+
features: [
|
|
94
|
+
{ id: 'copy', type: 'action', action: 'copy', label: 'Duplicate', icon: 'duplicate', menu: true, prod: true },
|
|
95
|
+
{ id: 'delete', type: 'action', action: 'delete', label: 'Delete', icon: 'trash', menu: true, prod: true },
|
|
96
|
+
],
|
|
97
|
+
props: {
|
|
98
|
+
width: { type: 'number', label: 'Width', default: 360 },
|
|
99
|
+
height: { type: 'number', label: 'Height', default: 240 },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### 3. Register server-side metadata in `storyboard.config.json`
|
|
107
|
+
|
|
108
|
+
So autoplacement (the `+ Add widget` flow) and collision detection use the right default size:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"widgets": {
|
|
113
|
+
"my-widget": {
|
|
114
|
+
"label": "My Widget",
|
|
115
|
+
"props": {
|
|
116
|
+
"width": { "type": "number", "default": 360 },
|
|
117
|
+
"height": { "type": "number", "default": 240 }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Important:** the browser-side and server-side metadata must agree on default sizes. The server side does not load React components — it only needs the JSON-safe metadata.
|
|
125
|
+
|
|
126
|
+
### 4. Verify icon exists
|
|
127
|
+
|
|
128
|
+
Before picking an `icon` value, check that the name exists in `.agents/data/primer-octicons.json` (the authoritative list). Storyboard uses the Primer Octicons set. If your preferred icon doesn't exist, pick the closest available — never guess.
|
|
129
|
+
|
|
130
|
+
### 5. (Optional) Test the widget
|
|
131
|
+
|
|
132
|
+
If the consumer wants tests, add `MyWidget.test.jsx` next to the component using Vitest. Focus on:
|
|
133
|
+
|
|
134
|
+
- Renders without crashing given minimal props
|
|
135
|
+
- `onUpdate` is called with the right patch when user interacts
|
|
136
|
+
- `selected` state triggers any custom selection affordance (for chrome-disabled widgets)
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Recipe table — pick the right configuration
|
|
141
|
+
|
|
142
|
+
| Use case | `chrome.enabled` | `interaction.movable` | `interaction.selectable` | Notes |
|
|
143
|
+
|---|---|---|---|---|
|
|
144
|
+
| Standard widget (markdown-like) | `true` | `true` | `true` | Default — same as core widgets |
|
|
145
|
+
| Resizable rich widget | `true` | `true` | `true` | Add `interaction.resize: { enabled: true, prod: false }` |
|
|
146
|
+
| Iframe / embed | `true` | `true` | `true` | Add `interaction.interactGate: true` to prevent canvas scroll from being swallowed |
|
|
147
|
+
| Hero canvas decoration | `false` | `false` | `false` | Fully custom, no interaction. Combine with `unlisted: true` to hide from menu. |
|
|
148
|
+
| Background sticker (selectable to delete) | `false` | `false` | `true` | Render your own selection ring inside the component |
|
|
149
|
+
| Floating accent (custom UI, draggable) | `false` | `true` | `true` | Component handles its visual chrome; canvas handles drag |
|
|
150
|
+
| Override a core widget (e.g. sticky-note) | match core | match core | match core | **Replaces** the entire built-in definition — re-declare all features/anchors/props if you want them |
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Common pitfalls
|
|
155
|
+
|
|
156
|
+
- **Forgetting server-side metadata** → `+ Add widget` places the widget at the generic 270 × 170 default, not your intended size. Always add the same `widgets.<type>` block to `storyboard.config.json`.
|
|
157
|
+
- **`chrome.enabled: false` without rendering a select affordance** → users can't tell the widget is selected before deleting. Render an outline / ring / handle when `selected` is true.
|
|
158
|
+
- **`movable: false` without `selectable: false`** → user can still select and delete the "locked" widget. That's usually what you want (so they can clean up). Pair both to `false` only for truly immutable decoration.
|
|
159
|
+
- **Overriding a core widget partially** → consumer registrations REPLACE the entire core entry. Copy the baseline from `packages/storyboard/widgets.config.json` first if you want to tweak just one field.
|
|
160
|
+
- **Using the wrong icon name** → check `.agents/data/primer-octicons.json` first. Invented icon names crash the page at runtime.
|
|
161
|
+
- **Not stamping `width`/`height` defaults in the props schema** → collision detection treats unknown widgets as 270 × 170 fallback.
|
|
162
|
+
- **Naming the type with uppercase/spaces** → use kebab-case strings only. The type is a stable identifier persisted in canvas JSONL.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Reference
|
|
167
|
+
|
|
168
|
+
- Full API reference + TypeScript-flavored type: [`DOCS/custom-widgets.md`](https://github.com/dfosco/storyboard/blob/main/DOCS/custom-widgets.md)
|
|
169
|
+
- Live examples of every feature shape, connector config, prop schema: `node_modules/@dfosco/storyboard/widgets.config.json`
|
|
170
|
+
- Browser-side registry source: `node_modules/@dfosco/storyboard/src/core/stores/widgetRegistry.js`
|
|
171
|
+
- Server-side registry source: `node_modules/@dfosco/storyboard/src/core/canvas/customWidgets.js`
|
|
172
|
+
- Chrome wrapper (consumes per-widget switches): `node_modules/@dfosco/storyboard/src/internals/canvas/widgets/WidgetChrome.jsx`
|
|
@@ -65,11 +65,11 @@ export async function handler(ctx) {
|
|
|
65
65
|
|
|
66
66
|
### 3. (Optional) Add a custom component
|
|
67
67
|
|
|
68
|
-
If the default rendering for your render type isn't enough, export a `component()` function:
|
|
68
|
+
If the default rendering for your render type isn't enough, export a `component()` function that returns a React component:
|
|
69
69
|
|
|
70
70
|
```js
|
|
71
71
|
export async function component() {
|
|
72
|
-
const mod = await import('../../MyToolButton.
|
|
72
|
+
const mod = await import('../../MyToolButton.jsx')
|
|
73
73
|
return mod.default
|
|
74
74
|
}
|
|
75
75
|
```
|
|
@@ -241,13 +241,13 @@ export async function handler(ctx) {
|
|
|
241
241
|
}
|
|
242
242
|
```
|
|
243
243
|
|
|
244
|
-
#### `component()` → `Promise<
|
|
244
|
+
#### `component()` → `Promise<ReactComponent>`
|
|
245
245
|
|
|
246
|
-
Optional. Returns a
|
|
246
|
+
Optional. Returns a React component to render the tool. If not provided, the surface uses its default renderer (e.g., `TriggerButton` for sidepanels, `ActionMenuButton` for menus — both shipped with storyboard-core).
|
|
247
247
|
|
|
248
248
|
```js
|
|
249
249
|
export async function component() {
|
|
250
|
-
const mod = await import('../../MyToolButton.
|
|
250
|
+
const mod = await import('../../MyToolButton.jsx')
|
|
251
251
|
return mod.default
|
|
252
252
|
}
|
|
253
253
|
```
|
|
@@ -379,7 +379,7 @@ A specialized compound control for canvas zoom (zoom in, zoom out, reset, curren
|
|
|
379
379
|
|
|
380
380
|
- **Surface:** `canvas-toolbar`
|
|
381
381
|
- **Handler return:** `{ zoomIn(zoom), zoomOut(zoom), zoomReset(), ZOOM_MIN, ZOOM_MAX }` (min/max read from `canvas.zoom` config)
|
|
382
|
-
- **Component:** Custom
|
|
382
|
+
- **Component:** Custom React component required
|
|
383
383
|
|
|
384
384
|
### `link`
|
|
385
385
|
|
|
@@ -472,7 +472,7 @@ export async function guard() {
|
|
|
472
472
|
}
|
|
473
473
|
|
|
474
474
|
export async function component() {
|
|
475
|
-
const mod = await import('../../components/CursorPresence.
|
|
475
|
+
const mod = await import('../../components/CursorPresence.jsx')
|
|
476
476
|
return mod.default
|
|
477
477
|
}
|
|
478
478
|
```
|
|
@@ -584,7 +584,7 @@ export async function handler(ctx) {
|
|
|
584
584
|
}
|
|
585
585
|
|
|
586
586
|
export async function component() {
|
|
587
|
-
const mod = await import('../../ActionMenuButton.
|
|
587
|
+
const mod = await import('../../ActionMenuButton.jsx')
|
|
588
588
|
return mod.default
|
|
589
589
|
}
|
|
590
590
|
```
|
|
@@ -674,7 +674,7 @@ export async function handler() {
|
|
|
674
674
|
}
|
|
675
675
|
|
|
676
676
|
export async function component() {
|
|
677
|
-
const mod = await import('../../CanvasZoomControl.
|
|
677
|
+
const mod = await import('../../CanvasZoomControl.jsx')
|
|
678
678
|
return mod.default
|
|
679
679
|
}
|
|
680
680
|
```
|
|
@@ -776,7 +776,7 @@ export async function guard() {
|
|
|
776
776
|
}
|
|
777
777
|
|
|
778
778
|
export async function component() {
|
|
779
|
-
const mod = await import('../../CommentsMenuButton.
|
|
779
|
+
const mod = await import('../../CommentsMenuButton.jsx')
|
|
780
780
|
return mod.default
|
|
781
781
|
}
|
|
782
782
|
```
|
|
@@ -830,7 +830,7 @@ export async function guard(ctx) {
|
|
|
830
830
|
}
|
|
831
831
|
|
|
832
832
|
export async function component() {
|
|
833
|
-
const mod = await import('../../CreateMenuButton.
|
|
833
|
+
const mod = await import('../../CreateMenuButton.jsx')
|
|
834
834
|
return mod.default
|
|
835
835
|
}
|
|
836
836
|
```
|
|
@@ -897,7 +897,7 @@ The tool system follows this sequence at startup:
|
|
|
897
897
|
|
|
898
898
|
8. COMPONENT RESOLUTION
|
|
899
899
|
If the handler exports component(), it is called to lazy-load
|
|
900
|
-
the
|
|
900
|
+
the React component. If not exported, the surface uses its
|
|
901
901
|
default component for the render type.
|
|
902
902
|
|
|
903
903
|
9. SURFACE RENDER
|
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* When placing or moving widgets, this module checks for overlaps with
|
|
5
5
|
* existing widgets and adjusts the position until no collisions remain.
|
|
6
|
+
*
|
|
7
|
+
* Widget default sizes are resolved from the merged widget registry
|
|
8
|
+
* (consumer-registered via `storyboard.config.json.widgets` over built-in
|
|
9
|
+
* `widgets.config.json`) so custom widget types autoplace correctly.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
|
-
import
|
|
12
|
+
import { getServerWidgetDefinition, getAllServerWidgetDefinitions } from './customWidgets.js'
|
|
9
13
|
|
|
10
14
|
const FALLBACK_SIZE = { width: 270, height: 170 }
|
|
11
15
|
|
|
@@ -20,32 +24,50 @@ const HARDCODED_DEFAULTS = {
|
|
|
20
24
|
'story': { width: 780, height: 420 },
|
|
21
25
|
}
|
|
22
26
|
|
|
27
|
+
function resolveDefaultSize(type, def) {
|
|
28
|
+
const props = def?.props || {}
|
|
29
|
+
const hardcoded = HARDCODED_DEFAULTS[type]
|
|
30
|
+
const w = props.width?.default ?? hardcoded?.width ?? FALLBACK_SIZE.width
|
|
31
|
+
const h = props.height?.default ?? hardcoded?.height ?? FALLBACK_SIZE.height
|
|
32
|
+
return { width: w, height: h }
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
36
|
+
* Snapshot of default sizes for all known widget types. Re-resolved on
|
|
37
|
+
* every access so consumer-registered widget sizes are reflected without
|
|
38
|
+
* a server restart.
|
|
26
39
|
*/
|
|
27
|
-
export const DEFAULT_SIZES =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
export const DEFAULT_SIZES = new Proxy({}, {
|
|
41
|
+
get(_, type) {
|
|
42
|
+
if (typeof type !== 'string') return undefined
|
|
43
|
+
const def = getServerWidgetDefinition(type)
|
|
44
|
+
if (!def) return undefined
|
|
45
|
+
return resolveDefaultSize(type, def)
|
|
46
|
+
},
|
|
47
|
+
has(_, type) {
|
|
48
|
+
return typeof type === 'string' && !!getServerWidgetDefinition(type)
|
|
49
|
+
},
|
|
50
|
+
ownKeys() {
|
|
51
|
+
return Object.keys(getAllServerWidgetDefinitions())
|
|
52
|
+
},
|
|
53
|
+
getOwnPropertyDescriptor(_, type) {
|
|
54
|
+
if (typeof type !== 'string') return undefined
|
|
55
|
+
const def = getServerWidgetDefinition(type)
|
|
56
|
+
if (!def) return undefined
|
|
57
|
+
return { enumerable: true, configurable: true, value: resolveDefaultSize(type, def) }
|
|
58
|
+
},
|
|
59
|
+
})
|
|
41
60
|
|
|
42
61
|
/**
|
|
43
|
-
* Get the default size for a widget type from
|
|
62
|
+
* Get the default size for a widget type. Resolves from the merged registry
|
|
63
|
+
* (consumer-registered widgets override built-ins).
|
|
44
64
|
* @param {string} type - Widget type
|
|
45
65
|
* @returns {{ width: number, height: number }}
|
|
46
66
|
*/
|
|
47
67
|
export function getDefaultSize(type) {
|
|
48
|
-
|
|
68
|
+
const def = getServerWidgetDefinition(type)
|
|
69
|
+
if (!def) return FALLBACK_SIZE
|
|
70
|
+
return resolveDefaultSize(type, def)
|
|
49
71
|
}
|
|
50
72
|
|
|
51
73
|
/**
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Custom Widget Registry
|
|
3
|
+
*
|
|
4
|
+
* Consumer widget metadata loaded from `storyboard.config.json.widgets` at
|
|
5
|
+
* dev-server startup. Server-side modules (collision detection, widget
|
|
6
|
+
* placement, prop validation) consult this registry to resolve widget
|
|
7
|
+
* metadata for consumer-registered widget types.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the browser-side `core/stores/widgetRegistry.js` but holds only
|
|
10
|
+
* the JSON-serializable subset — no React components (those live in the
|
|
11
|
+
* browser only). The `definition` shape is otherwise identical:
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* label, icon,
|
|
15
|
+
* chrome: { enabled },
|
|
16
|
+
* interaction: { selectable, movable, resize, expandable, splitScreen, interactGate, interactGateLabel },
|
|
17
|
+
* connectors: { anchors, accept, exclude, defaults },
|
|
18
|
+
* features: [...],
|
|
19
|
+
* unlisted,
|
|
20
|
+
* props: { width: { type, default, ... }, ... }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Consumer entries OVERRIDE the built-in `widgets.config.json` entry of the
|
|
24
|
+
* same type. The merge is whole-entry replacement (consumer wins) to match
|
|
25
|
+
* the browser-side behavior — consumers that want to extend a core widget
|
|
26
|
+
* must re-declare the merged definition.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import widgetsConfig from '../../../widgets.config.json' with { type: 'json' }
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, object>} */
|
|
32
|
+
const _registry = new Map()
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Replace the entire registry from a consumer-supplied map.
|
|
36
|
+
* @param {Record<string, object>|null|undefined} map
|
|
37
|
+
*/
|
|
38
|
+
export function initServerWidgets(map) {
|
|
39
|
+
_registry.clear()
|
|
40
|
+
if (map && typeof map === 'object') {
|
|
41
|
+
for (const [type, def] of Object.entries(map)) {
|
|
42
|
+
if (def && typeof def === 'object') {
|
|
43
|
+
_registry.set(type, def)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Register a single widget type. Replaces any existing entry.
|
|
51
|
+
* @param {string} type
|
|
52
|
+
* @param {object} definition
|
|
53
|
+
*/
|
|
54
|
+
export function registerServerWidget(type, definition) {
|
|
55
|
+
if (!type || typeof type !== 'string') {
|
|
56
|
+
throw new Error('[storyboard] registerServerWidget: type must be a non-empty string')
|
|
57
|
+
}
|
|
58
|
+
if (!definition || typeof definition !== 'object') {
|
|
59
|
+
throw new Error(`[storyboard] registerServerWidget("${type}"): definition must be an object`)
|
|
60
|
+
}
|
|
61
|
+
_registry.set(type, definition)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the effective server-side widget definition for a type.
|
|
66
|
+
* Returns the consumer entry if present, otherwise the built-in entry from
|
|
67
|
+
* `widgets.config.json`, otherwise null.
|
|
68
|
+
* @param {string} type
|
|
69
|
+
* @returns {object|null}
|
|
70
|
+
*/
|
|
71
|
+
export function getServerWidgetDefinition(type) {
|
|
72
|
+
if (_registry.has(type)) return _registry.get(type)
|
|
73
|
+
return widgetsConfig.widgets?.[type] || null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Snapshot all definitions (consumer + built-in) keyed by type.
|
|
78
|
+
* Consumer entries override built-ins.
|
|
79
|
+
* @returns {Record<string, object>}
|
|
80
|
+
*/
|
|
81
|
+
export function getAllServerWidgetDefinitions() {
|
|
82
|
+
const merged = { ...(widgetsConfig.widgets || {}) }
|
|
83
|
+
for (const [type, def] of _registry) {
|
|
84
|
+
merged[type] = def
|
|
85
|
+
}
|
|
86
|
+
return merged
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Reset all state. Only for tests. */
|
|
90
|
+
export function _resetServerWidgets() {
|
|
91
|
+
_registry.clear()
|
|
92
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
initServerWidgets,
|
|
4
|
+
registerServerWidget,
|
|
5
|
+
getServerWidgetDefinition,
|
|
6
|
+
getAllServerWidgetDefinitions,
|
|
7
|
+
_resetServerWidgets,
|
|
8
|
+
} from './customWidgets.js'
|
|
9
|
+
|
|
10
|
+
describe('customWidgets (server-side registry)', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
_resetServerWidgets()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('falls back to built-in widgets.config.json when nothing registered', () => {
|
|
16
|
+
const sticky = getServerWidgetDefinition('sticky-note')
|
|
17
|
+
expect(sticky).toBeTruthy()
|
|
18
|
+
expect(sticky.label).toBeTruthy()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns null for unknown widget types', () => {
|
|
22
|
+
expect(getServerWidgetDefinition('does-not-exist')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('registerServerWidget adds a consumer entry', () => {
|
|
26
|
+
registerServerWidget('my-thing', { label: 'My Thing', props: { width: { type: 'number', default: 400 } } })
|
|
27
|
+
const def = getServerWidgetDefinition('my-thing')
|
|
28
|
+
expect(def?.label).toBe('My Thing')
|
|
29
|
+
expect(def?.props.width.default).toBe(400)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('consumer entry overrides built-in entry for the same type', () => {
|
|
33
|
+
registerServerWidget('sticky-note', { label: 'Custom Sticky', props: { width: { default: 999 } } })
|
|
34
|
+
const def = getServerWidgetDefinition('sticky-note')
|
|
35
|
+
expect(def.label).toBe('Custom Sticky')
|
|
36
|
+
expect(def.props.width.default).toBe(999)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('initServerWidgets replaces all consumer entries', () => {
|
|
40
|
+
registerServerWidget('a', { label: 'A' })
|
|
41
|
+
initServerWidgets({ b: { label: 'B' } })
|
|
42
|
+
expect(getServerWidgetDefinition('a')).not.toEqual({ label: 'A' })
|
|
43
|
+
// 'a' may still resolve to a core entry if one exists; here it doesn't,
|
|
44
|
+
// so it falls back to null
|
|
45
|
+
expect(getServerWidgetDefinition('a')).toBeNull()
|
|
46
|
+
expect(getServerWidgetDefinition('b')).toEqual({ label: 'B' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('initServerWidgets accepts null/undefined to clear', () => {
|
|
50
|
+
registerServerWidget('a', { label: 'A' })
|
|
51
|
+
initServerWidgets(null)
|
|
52
|
+
expect(getServerWidgetDefinition('a')).toBeNull()
|
|
53
|
+
initServerWidgets({ x: { label: 'X' } })
|
|
54
|
+
initServerWidgets(undefined)
|
|
55
|
+
expect(getServerWidgetDefinition('x')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('getAllServerWidgetDefinitions includes both built-ins and consumer entries', () => {
|
|
59
|
+
registerServerWidget('my-thing', { label: 'My Thing' })
|
|
60
|
+
const all = getAllServerWidgetDefinitions()
|
|
61
|
+
expect(all['my-thing']?.label).toBe('My Thing')
|
|
62
|
+
expect(all['sticky-note']).toBeTruthy() // built-in still present
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('registerServerWidget throws on invalid input', () => {
|
|
66
|
+
expect(() => registerServerWidget('', {})).toThrow(/non-empty string/)
|
|
67
|
+
expect(() => registerServerWidget('foo', null)).toThrow(/must be an object/)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -54,16 +54,17 @@ import {
|
|
|
54
54
|
import { stampBounds, stampBoundsAll, resolvePosition, getWidgetBounds, findBestAnchors } from './collision.js'
|
|
55
55
|
import { markCanvasWrite, unmarkCanvasWrite } from './writeGuard.js'
|
|
56
56
|
import { devLog } from '../logger/devLogger.js'
|
|
57
|
-
import
|
|
57
|
+
import { getServerWidgetDefinition } from './customWidgets.js'
|
|
58
58
|
import { listHubRoles, getDefaultRoleId } from './hub-roles.js'
|
|
59
59
|
import { readAgentsConfig } from './configReader.js'
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Read the prompt widget's execution config from
|
|
62
|
+
* Read the prompt widget's execution config from the merged widget registry.
|
|
63
63
|
* Returns { default, agents } where each agent has a command template.
|
|
64
64
|
*/
|
|
65
65
|
function getPromptExecution() {
|
|
66
|
-
|
|
66
|
+
const def = getServerWidgetDefinition('prompt')
|
|
67
|
+
return def?.execution || null
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/**
|
package/src/core/index.js
CHANGED
|
@@ -109,6 +109,19 @@ export {
|
|
|
109
109
|
getPresentationSnapshot,
|
|
110
110
|
} from './stores/presentationStore.js'
|
|
111
111
|
|
|
112
|
+
// Custom widgets — public API. Register your own canvas widget types with
|
|
113
|
+
// full control over rendering, chrome, interaction, and toolbar features.
|
|
114
|
+
// See DOCS/custom-widgets.md.
|
|
115
|
+
export {
|
|
116
|
+
initWidgetRegistry,
|
|
117
|
+
registerWidget,
|
|
118
|
+
unregisterWidget,
|
|
119
|
+
getWidgetDefinition,
|
|
120
|
+
getAllWidgetDefinitions,
|
|
121
|
+
subscribeToWidgetRegistry,
|
|
122
|
+
getWidgetRegistrySnapshot,
|
|
123
|
+
} from './stores/widgetRegistry.js'
|
|
124
|
+
|
|
112
125
|
// Canvas interaction (advanced, undocumented — scroll axis + zoom gesture gates)
|
|
113
126
|
export {
|
|
114
127
|
initCanvasInteraction,
|
|
@@ -16,6 +16,7 @@ import { installBodyClassSync } from './session/bodyClasses.js'
|
|
|
16
16
|
import { applyEarlyTheme as bootstrapEarlyTheme } from './stores/themeBootstrap.js'
|
|
17
17
|
import { getConfig as getSchemaDefaulted } from './stores/configSchema.js'
|
|
18
18
|
import { initPresentation } from './stores/presentationStore.js'
|
|
19
|
+
import { initWidgetRegistry } from './stores/widgetRegistry.js'
|
|
19
20
|
import { initCanvasInteraction } from './stores/canvasInteractionStore.js'
|
|
20
21
|
import {
|
|
21
22
|
initCommentsConfig, isCommentsEnabled,
|
|
@@ -395,6 +396,7 @@ function installCmdClickForwarder() {
|
|
|
395
396
|
* @param {HTMLElement} [options.container=document.body] - Where to mount devtools
|
|
396
397
|
* @param {Record<string, () => Promise<any>>} [options.handlers={}] - Custom tool handlers (key → lazy loader)
|
|
397
398
|
* @param {Record<string, object>} [options.presentation] - Optional, advanced: per-chrome-element presentation overrides. See `core/stores/presentationStore.js` for the shape. Keep undocumented — power-user surface for landing pages and heavy chrome customization.
|
|
399
|
+
* @param {Record<string, object>} [options.widgets] - Optional: custom widget registrations. Maps widget type string → WidgetDefinition. Defines the React component, label/icon, chrome wrapper opts, interaction switches (selectable/movable/resize/expandable/splitScreen/interactGate), connector anchors, toolbar features, and prop schema. Consumer entries override built-in widgets of the same type. See `DOCS/custom-widgets.md` and `core/stores/widgetRegistry.js` for the full shape.
|
|
398
400
|
*/
|
|
399
401
|
export async function mountStoryboardCore(config = {}, options = {}) {
|
|
400
402
|
if (_mounted) return
|
|
@@ -409,6 +411,14 @@ export async function mountStoryboardCore(config = {}, options = {}) {
|
|
|
409
411
|
initPresentation(options.presentation)
|
|
410
412
|
}
|
|
411
413
|
|
|
414
|
+
// Seed the widget registry before any canvas mounts so the first canvas
|
|
415
|
+
// render already resolves custom widget components, chrome opts, and
|
|
416
|
+
// interaction switches. Consumer entries override core widgets of the
|
|
417
|
+
// same type entirely.
|
|
418
|
+
if (options.widgets) {
|
|
419
|
+
initWidgetRegistry(options.widgets)
|
|
420
|
+
}
|
|
421
|
+
|
|
412
422
|
// Migrate renamed localStorage keys (4.3.0: viewfinder → workspace)
|
|
413
423
|
migrateLocalStorageKeys()
|
|
414
424
|
|
|
@@ -164,7 +164,8 @@
|
|
|
164
164
|
* @property {HotPoolConfig} [hotPool]
|
|
165
165
|
* @property {CommandPaletteConfig} [commandPalette]
|
|
166
166
|
* @property {CustomerModeConfig} [customerMode]
|
|
167
|
-
* @property {ThemesConfig} [
|
|
167
|
+
* @property {ThemesConfig} [theming]
|
|
168
|
+
* @property {Record<string, object>} [widgets] — Custom widget metadata (server-side). Maps widget type string → definition (label, icon, chrome, interaction, connectors, features, props). Consumer entries override built-in widget metadata for collision detection, default sizes, prompt-exec config, etc. Browser-side widget components are registered separately via `mountStoryboardCore({ widgets })`. See `DOCS/custom-widgets.md`.
|
|
168
169
|
*/
|
|
169
170
|
|
|
170
171
|
/** Built-in paste rules shipped with storyboard. */
|