@dfosco/storyboard 0.9.3 → 0.9.5

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 (34) 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 -79
  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/canvas/CanvasPage.bridge.test.jsx +2 -0
  23. package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +2 -0
  24. package/src/internals/canvas/CanvasPage.jsx +19 -3
  25. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +2 -0
  26. package/src/internals/canvas/widgets/WidgetChrome.jsx +50 -5
  27. package/src/internals/canvas/widgets/WidgetChrome.test.jsx +65 -0
  28. package/src/internals/canvas/widgets/index.js +18 -4
  29. package/src/internals/canvas/widgets/widgetConfig.js +202 -45
  30. package/src/internals/canvas/widgets/widgetConfig.merged.test.js +144 -0
  31. package/src/internals/context.jsx +1 -17
  32. package/src/core/ui/design-modes.ts +0 -7
  33. package/src/core/ui/viewfinder.ts +0 -7
  34. package/src/core/workshop/ui/mount.ts +0 -6
@@ -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. */
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Widget Registry — consumer-supplied custom widget types.
3
+ *
4
+ * Consumers register widgets through `mountStoryboardCore({ widgets })`:
5
+ *
6
+ * mountStoryboardCore(config, {
7
+ * widgets: {
8
+ * 'my-thing': {
9
+ * component: MyThing,
10
+ * label: 'My Thing',
11
+ * icon: 'sparkle',
12
+ * chrome: { enabled: true }, // optional, default true
13
+ * interaction: {
14
+ * selectable: true, // default true
15
+ * movable: true, // default true
16
+ * resize: { enabled: true, prod: false }, // default disabled
17
+ * expandable: false,
18
+ * splitScreen: false,
19
+ * interactGate: false,
20
+ * interactGateLabel: 'Click to interact',
21
+ * },
22
+ * connectors: { anchors: {...}, accept: ['*'], exclude: [] },
23
+ * features: [ ... ], // toolbar actions
24
+ * unlisted: false, // hide from "+ Add widget"
25
+ * props: { width: { type: 'number', default: 400 }, ... },
26
+ * },
27
+ * },
28
+ * })
29
+ *
30
+ * Definitions registered through this store are merged with — and override —
31
+ * the built-in widget configs from `widgets.config.json` on a per-type basis.
32
+ *
33
+ * Built-in core widgets (sticky-note, markdown, image, etc.) ALSO live in
34
+ * this registry once `seedCoreWidgets()` runs at module load. Consumer
35
+ * overrides for those types replace the core entry entirely; if a consumer
36
+ * wants to extend a core widget they should re-declare the merged definition.
37
+ *
38
+ * Framework-agnostic (zero npm dependencies). React typing is structural —
39
+ * the `component` field is opaque from this store's perspective.
40
+ *
41
+ * @typedef {object} WidgetChromeOptions
42
+ * @property {boolean} [enabled] Render component inside WidgetChrome. Default: true.
43
+ * When false, the widget renders raw with no toolbar,
44
+ * no anchor ports, no select handle, no interact gate.
45
+ * Consumer owns 100% of the visible UI.
46
+ *
47
+ * @typedef {object} WidgetInteractionOptions
48
+ * @property {boolean} [selectable] Default: true. False → no select handle, single-click
49
+ * does not select.
50
+ * @property {boolean} [movable] Default: true. False → drag surface class is omitted,
51
+ * widget cannot be dragged.
52
+ * @property {{ enabled: boolean, prod?: boolean }} [resize]
53
+ * Default: disabled. Same shape as widgets.config.json.
54
+ * @property {boolean} [expandable] Default: false. Adds the "Expand" action.
55
+ * @property {boolean} [splitScreen] Default: false. Eligible as a split-screen pane.
56
+ * @property {boolean} [interactGate] Default: false. Overlays "Click to interact" until clicked.
57
+ * @property {string} [interactGateLabel] Default: 'Click to interact'.
58
+ *
59
+ * @typedef {object} WidgetDefinition
60
+ * @property {*} [component] React component or render function.
61
+ * Required for consumer widgets; not stored for
62
+ * core widgets when they fall back to widgets.config.json.
63
+ * @property {string} [label] Display label for menus and selectors.
64
+ * @property {string} [icon] Icon key from ICON_REGISTRY.
65
+ * @property {WidgetChromeOptions} [chrome]
66
+ * @property {WidgetInteractionOptions} [interaction]
67
+ * @property {object} [connectors] { anchors, accept, exclude, defaults }.
68
+ * @property {Array<object>} [features] Toolbar feature list.
69
+ * @property {boolean} [unlisted] Hide from "+ Add widget" menu.
70
+ * @property {Record<string, object>} [props] Prop schema.
71
+ *
72
+ * The following back-compat fields are still read (mirrors widgets.config.json
73
+ * legacy shape so an unchanged JSON entry continues to work after migration):
74
+ * @property {{ enabled: boolean, prod?: boolean }} [resize]
75
+ * @property {boolean} [expandable]
76
+ * @property {boolean} [splitScreen]
77
+ * @property {boolean} [interactGate]
78
+ * @property {string} [interactGateLabel]
79
+ */
80
+
81
+ /** @type {Map<string, WidgetDefinition>} */
82
+ let _registry = new Map()
83
+
84
+ /** @type {Set<Function>} */
85
+ const _listeners = new Set()
86
+
87
+ let _snapshotVersion = 0
88
+
89
+ /**
90
+ * Replace the entire registry from a consumer-supplied map.
91
+ * Pass null/undefined to clear.
92
+ * @param {Record<string, WidgetDefinition>|null|undefined} map
93
+ */
94
+ export function initWidgetRegistry(map) {
95
+ _registry = new Map()
96
+ if (map && typeof map === 'object') {
97
+ for (const [type, def] of Object.entries(map)) {
98
+ if (def && typeof def === 'object') {
99
+ _registry.set(type, def)
100
+ }
101
+ }
102
+ }
103
+ _notify()
104
+ }
105
+
106
+ /**
107
+ * Register a single widget type (consumer-side). Replaces any existing entry.
108
+ * @param {string} type
109
+ * @param {WidgetDefinition} definition
110
+ */
111
+ export function registerWidget(type, definition) {
112
+ if (!type || typeof type !== 'string') {
113
+ throw new Error('[storyboard] registerWidget: type must be a non-empty string')
114
+ }
115
+ if (!definition || typeof definition !== 'object') {
116
+ throw new Error(`[storyboard] registerWidget("${type}"): definition must be an object`)
117
+ }
118
+ _registry.set(type, definition)
119
+ _notify()
120
+ }
121
+
122
+ /**
123
+ * Remove a registry entry. Built-in widgets re-seeded via seedCoreWidgets()
124
+ * are unaffected; only the consumer override is removed.
125
+ * @param {string} type
126
+ */
127
+ export function unregisterWidget(type) {
128
+ if (_registry.delete(type)) _notify()
129
+ }
130
+
131
+ /**
132
+ * Get the registered definition for a widget type, or null.
133
+ * Consumers should prefer `getMergedWidgetDefinition()` which also folds in
134
+ * the static widgets.config.json shape.
135
+ * @param {string} type
136
+ * @returns {WidgetDefinition|null}
137
+ */
138
+ export function getWidgetDefinition(type) {
139
+ return _registry.get(type) || null
140
+ }
141
+
142
+ /**
143
+ * Snapshot of the entire registry as a plain object.
144
+ * @returns {Record<string, WidgetDefinition>}
145
+ */
146
+ export function getAllWidgetDefinitions() {
147
+ return Object.fromEntries(_registry)
148
+ }
149
+
150
+ /**
151
+ * Subscribe to registry changes. Returns unsubscribe fn.
152
+ * Compatible with `useSyncExternalStore`.
153
+ * @param {Function} callback
154
+ * @returns {Function}
155
+ */
156
+ export function subscribeToWidgetRegistry(callback) {
157
+ _listeners.add(callback)
158
+ return () => _listeners.delete(callback)
159
+ }
160
+
161
+ /**
162
+ * Snapshot version string for change detection.
163
+ * Compatible with `useSyncExternalStore`.
164
+ * @returns {string}
165
+ */
166
+ export function getWidgetRegistrySnapshot() {
167
+ return String(_snapshotVersion)
168
+ }
169
+
170
+ function _notify() {
171
+ _snapshotVersion++
172
+ for (const cb of _listeners) {
173
+ try { cb() } catch (err) {
174
+ console.error('[storyboard] Error in widgetRegistry subscriber:', err)
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Reset all state. Only for tests. */
180
+ export function _resetWidgetRegistry() {
181
+ _registry = new Map()
182
+ _listeners.clear()
183
+ _snapshotVersion = 0
184
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import {
3
+ initWidgetRegistry,
4
+ registerWidget,
5
+ unregisterWidget,
6
+ getWidgetDefinition,
7
+ getAllWidgetDefinitions,
8
+ subscribeToWidgetRegistry,
9
+ getWidgetRegistrySnapshot,
10
+ _resetWidgetRegistry,
11
+ } from './widgetRegistry.js'
12
+
13
+ describe('widgetRegistry', () => {
14
+ beforeEach(() => {
15
+ _resetWidgetRegistry()
16
+ })
17
+
18
+ it('starts empty', () => {
19
+ expect(getAllWidgetDefinitions()).toEqual({})
20
+ expect(getWidgetDefinition('anything')).toBeNull()
21
+ })
22
+
23
+ it('initWidgetRegistry seeds the store with valid entries', () => {
24
+ const Component = () => null
25
+ initWidgetRegistry({
26
+ 'my-thing': { component: Component, label: 'My Thing' },
27
+ 'other': { label: 'Other' },
28
+ })
29
+ expect(getWidgetDefinition('my-thing')).toEqual({ component: Component, label: 'My Thing' })
30
+ expect(getWidgetDefinition('other')).toEqual({ label: 'Other' })
31
+ expect(Object.keys(getAllWidgetDefinitions()).sort()).toEqual(['my-thing', 'other'])
32
+ })
33
+
34
+ it('initWidgetRegistry replaces previous entries', () => {
35
+ initWidgetRegistry({ a: { label: 'A' } })
36
+ initWidgetRegistry({ b: { label: 'B' } })
37
+ expect(getWidgetDefinition('a')).toBeNull()
38
+ expect(getWidgetDefinition('b')).toEqual({ label: 'B' })
39
+ })
40
+
41
+ it('initWidgetRegistry ignores null/undefined and non-object entries', () => {
42
+ initWidgetRegistry({
43
+ ok: { label: 'OK' },
44
+ nullish: null,
45
+ undef: undefined,
46
+ notObj: 'string',
47
+ })
48
+ expect(Object.keys(getAllWidgetDefinitions())).toEqual(['ok'])
49
+ })
50
+
51
+ it('initWidgetRegistry with null/undefined clears the store', () => {
52
+ initWidgetRegistry({ a: { label: 'A' } })
53
+ initWidgetRegistry(null)
54
+ expect(getAllWidgetDefinitions()).toEqual({})
55
+ })
56
+
57
+ it('registerWidget adds a single entry', () => {
58
+ registerWidget('foo', { label: 'Foo' })
59
+ expect(getWidgetDefinition('foo')).toEqual({ label: 'Foo' })
60
+ })
61
+
62
+ it('registerWidget replaces an existing entry', () => {
63
+ registerWidget('foo', { label: 'Foo' })
64
+ registerWidget('foo', { label: 'Foo2' })
65
+ expect(getWidgetDefinition('foo')).toEqual({ label: 'Foo2' })
66
+ })
67
+
68
+ it('registerWidget throws on invalid type', () => {
69
+ expect(() => registerWidget('', { label: 'X' })).toThrow(/non-empty string/)
70
+ expect(() => registerWidget(null, { label: 'X' })).toThrow(/non-empty string/)
71
+ })
72
+
73
+ it('registerWidget throws on invalid definition', () => {
74
+ expect(() => registerWidget('foo', null)).toThrow(/must be an object/)
75
+ expect(() => registerWidget('foo', 'string')).toThrow(/must be an object/)
76
+ })
77
+
78
+ it('unregisterWidget removes an entry', () => {
79
+ registerWidget('foo', { label: 'Foo' })
80
+ unregisterWidget('foo')
81
+ expect(getWidgetDefinition('foo')).toBeNull()
82
+ })
83
+
84
+ it('unregisterWidget on missing entry is a no-op', () => {
85
+ expect(() => unregisterWidget('nope')).not.toThrow()
86
+ })
87
+
88
+ it('subscribeToWidgetRegistry fires on init/register/unregister', () => {
89
+ let calls = 0
90
+ const unsubscribe = subscribeToWidgetRegistry(() => { calls++ })
91
+ initWidgetRegistry({ a: { label: 'A' } })
92
+ expect(calls).toBe(1)
93
+ registerWidget('b', { label: 'B' })
94
+ expect(calls).toBe(2)
95
+ unregisterWidget('a')
96
+ expect(calls).toBe(3)
97
+ unsubscribe()
98
+ registerWidget('c', { label: 'C' })
99
+ expect(calls).toBe(3)
100
+ })
101
+
102
+ it('getWidgetRegistrySnapshot returns a stable string per state', () => {
103
+ const a = getWidgetRegistrySnapshot()
104
+ expect(a).toBe(getWidgetRegistrySnapshot())
105
+ registerWidget('foo', { label: 'Foo' })
106
+ const b = getWidgetRegistrySnapshot()
107
+ expect(b).not.toBe(a)
108
+ })
109
+ })
@@ -116,7 +116,7 @@ function menuVisibleInMode(menu, mode) {
116
116
  return menu.modes.includes('*') || menu.modes.includes(mode)
117
117
  }
118
118
 
119
- // Subscribable mode state for useExternalStore (replaces Svelte modeState)
119
+ // Subscribable mode state for useExternalStore
120
120
  const modeState = {
121
121
  get mode() { return getCurrentMode() },
122
122
  get modes() { return getRegisteredModes() },
@@ -22,9 +22,3 @@ export { mountDevTools, unmountDevTools } from './devtools/devtools.js'
22
22
 
23
23
  // Comments UI
24
24
  export { mountComments } from './comments/ui/mount.js'
25
-
26
- // Viewfinder dashboard
27
- export { mountViewfinder, unmountViewfinder } from './ui/viewfinder.ts'
28
-
29
- // Design modes
30
- export { mountDesignModesUI as mountDesignModes } from './ui/design-modes.ts'
@@ -14,6 +14,7 @@ import fs from 'node:fs'
14
14
  import path from 'node:path'
15
15
  import { parse as parseJsonc } from 'jsonc-parser'
16
16
  import { getConfig } from '../stores/configSchema.js'
17
+ import { initServerWidgets } from '../canvas/customWidgets.js'
17
18
  import { createDevLogger, setDevLogger } from '../logger/devLogger.js'
18
19
  import { serverFeatures as workshopFeatures } from '../workshop/features/registry-server.js'
19
20
  import { docsHandler, collectFiles } from './docs-handler.js'
@@ -161,6 +162,17 @@ export default function storyboardServer() {
161
162
  base = viteConfig.base || '/'
162
163
  config = readConfig(root)
163
164
  isDev = viteConfig.command === 'serve'
165
+
166
+ // Seed the server-side custom-widget registry from the consumer's
167
+ // `storyboard.config.json.widgets` block. Consumer entries override
168
+ // built-in widget metadata for collision detection, default sizes,
169
+ // prompt-exec config, etc. Server-side does not load React components
170
+ // — those are seeded in the browser via `mountStoryboardCore({ widgets })`.
171
+ try {
172
+ initServerWidgets(config?.widgets || null)
173
+ } catch (err) {
174
+ console.warn('[storyboard] Failed to init custom widget registry:', err.message)
175
+ }
164
176
  },
165
177
 
166
178
  configureServer(server) {
@@ -72,6 +72,8 @@ vi.mock('./widgets/widgetConfig.js', async () => {
72
72
  getAnchorState: () => ({}),
73
73
  canAcceptConnection: () => false,
74
74
  isSplitScreenCapable: () => false,
75
+ getChromeOptions: () => ({ enabled: true }),
76
+ getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
75
77
  schemas: {},
76
78
  getMenuWidgetTypes: () => [],
77
79
  getConnectorDefaults: actual.getConnectorDefaults,
@@ -57,6 +57,8 @@ vi.mock('./widgets/widgetConfig.js', async () => {
57
57
  getAnchorState: () => ({}),
58
58
  canAcceptConnection: () => false,
59
59
  isSplitScreenCapable: () => false,
60
+ getChromeOptions: () => ({ enabled: true }),
61
+ getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
60
62
  schemas: {},
61
63
  getMenuWidgetTypes: () => [],
62
64
  getConnectorDefaults: actual.getConnectorDefaults,