@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.
Files changed (35) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +1 -10
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +1 -4
  6. package/scaffold/AGENTS.md +2 -0
  7. package/scaffold/skills/storyboard-core/SKILL.md +63 -171
  8. package/scaffold/skills/storyboard-widget/SKILL.md +172 -0
  9. package/scaffold/skills/tools/SKILL.md +12 -12
  10. package/src/core/canvas/collision.js +41 -19
  11. package/src/core/canvas/customWidgets.js +92 -0
  12. package/src/core/canvas/customWidgets.test.js +69 -0
  13. package/src/core/canvas/server.js +4 -3
  14. package/src/core/index.js +13 -0
  15. package/src/core/mountStoryboardCore.js +10 -0
  16. package/src/core/stores/configSchema.js +2 -1
  17. package/src/core/stores/widgetRegistry.js +184 -0
  18. package/src/core/stores/widgetRegistry.test.js +109 -0
  19. package/src/core/ui/CoreUIBar.jsx +1 -1
  20. package/src/core/ui-entry.js +0 -6
  21. package/src/core/vite/server-plugin.js +12 -0
  22. package/src/internals/BranchBar/BranchBar.jsx +27 -20
  23. package/src/internals/canvas/CanvasPage.bridge.test.jsx +2 -0
  24. package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +2 -0
  25. package/src/internals/canvas/CanvasPage.jsx +19 -3
  26. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +2 -0
  27. package/src/internals/canvas/widgets/WidgetChrome.jsx +50 -5
  28. package/src/internals/canvas/widgets/WidgetChrome.test.jsx +65 -0
  29. package/src/internals/canvas/widgets/index.js +18 -4
  30. package/src/internals/canvas/widgets/widgetConfig.js +202 -45
  31. package/src/internals/canvas/widgets/widgetConfig.merged.test.js +144 -0
  32. package/src/internals/context.jsx +1 -17
  33. package/src/core/ui/design-modes.ts +0 -7
  34. package/src/core/ui/viewfinder.ts +0 -7
  35. 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.svelte')
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<SvelteComponent>`
244
+ #### `component()` → `Promise<ReactComponent>`
245
245
 
246
- Optional. Returns a Svelte component to render the tool. If not provided, the surface uses its default renderer (e.g., `TriggerButton` for sidepanels, `ActionMenuButton` for menus).
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.svelte')
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 Svelte component required
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.svelte')
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.svelte')
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.svelte')
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.svelte')
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.svelte')
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 Svelte component. If not exported, the surface uses its
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 widgetsConfig from '../../../widgets.config.json' with { type: 'json' }
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
- * Default widget sizes derived from widgets.config.json prop defaults,
25
- * with hardcoded fallbacks for types that don't specify both width+height.
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 = buildDefaultSizes()
28
-
29
- function buildDefaultSizes() {
30
- const sizes = {}
31
- const widgets = widgetsConfig.widgets || {}
32
- for (const [type, config] of Object.entries(widgets)) {
33
- const props = config.props || {}
34
- const hardcoded = HARDCODED_DEFAULTS[type]
35
- const w = props.width?.default ?? hardcoded?.width ?? FALLBACK_SIZE.width
36
- const h = props.height?.default ?? hardcoded?.height ?? FALLBACK_SIZE.height
37
- sizes[type] = { width: w, height: h }
38
- }
39
- return sizes
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 widgets.config.json.
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
- return DEFAULT_SIZES[type] || FALLBACK_SIZE
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 widgetsConfig from '../../../widgets.config.json' with { type: 'json' }
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 widgets.config.json.
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
- return widgetsConfig?.widgets?.prompt?.execution || null
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} [themes]
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. */