@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
@@ -11,10 +11,14 @@ import TerminalWidget from './TerminalWidget.jsx'
11
11
  import TerminalReadWidget from './TerminalReadWidget.jsx'
12
12
  import PromptWidget from './PromptWidget.jsx'
13
13
  import TilesWidget from './TilesWidget.jsx'
14
+ import { getWidgetDefinition } from '../../../core/stores/widgetRegistry.js'
14
15
 
15
16
  /**
16
- * Maps widget type strings to their React components.
17
- * Each component receives: { id, props, onUpdate }
17
+ * Built-in widget components, keyed by type string.
18
+ * These are the fallback components when no consumer registration overrides
19
+ * a given type. Consumers can override any of these by registering a widget
20
+ * with the same `type` key via `mountStoryboardCore({ widgets })` or
21
+ * `registerWidget()`.
18
22
  */
19
23
  export const widgetRegistry = {
20
24
  'sticky-note': StickyNote,
@@ -34,9 +38,19 @@ export const widgetRegistry = {
34
38
  }
35
39
 
36
40
  /**
37
- * Resolve a widget type string to its component.
38
- * Returns null for unknown types.
41
+ * Resolve a widget type string to its React component.
42
+ *
43
+ * Lookup order:
44
+ * 1. Consumer registry (`registerWidget`/`mountStoryboardCore({ widgets })`)
45
+ * — `def.component` wins if present
46
+ * 2. Built-in `widgetRegistry` above
47
+ * 3. null (unknown type)
48
+ *
49
+ * @param {string} type
50
+ * @returns {React.ComponentType | null}
39
51
  */
40
52
  export function getWidgetComponent(type) {
53
+ const def = getWidgetDefinition(type)
54
+ if (def?.component) return def.component
41
55
  return widgetRegistry[type] ?? null
42
56
  }
@@ -1,16 +1,27 @@
1
1
  /**
2
2
  * Widget Config Loader
3
3
  *
4
- * Reads widgets.config.json from @dfosco/storyboard and builds
5
- * schema objects compatible with the existing readProp/readAllProps/getDefaults API.
4
+ * Resolves per-widget metadata (label, icon, prop schemas, features, chrome
5
+ * options, interaction switches, connector rules) by merging the built-in
6
+ * `widgets.config.json` baseline with consumer registrations from
7
+ * `core/stores/widgetRegistry.js`.
6
8
  *
7
- * The config is the single source of truth for widget definitions —
8
- * prop schemas, feature lists, labels, and icons all come from here.
9
+ * Resolution order, per widget type:
10
+ * 1. Consumer override registered via `registerWidget()` /
11
+ * `mountStoryboardCore({ widgets })` — REPLACES the core entry entirely
12
+ * for this type. Consumers that want to extend a core widget must
13
+ * re-declare the full merged definition.
14
+ * 2. Core entry from `widgets.config.json`.
9
15
  *
10
- * Supports `$variable` references in string values, resolved from
11
- * the top-level `variables` object in widgets.config.json.
16
+ * Supports `$variable` references in string values for core widgets only,
17
+ * resolved from the top-level `variables` object in `widgets.config.json`.
18
+ * (Consumer registrations are passed through verbatim.)
12
19
  */
13
20
  import widgetsConfig from '../../../../widgets.config.json'
21
+ import {
22
+ getWidgetDefinition,
23
+ getAllWidgetDefinitions,
24
+ } from '../../../core/stores/widgetRegistry.js'
14
25
 
15
26
  /** Variables defined in config — used to resolve `$key` references. */
16
27
  const variables = widgetsConfig.variables || {}
@@ -80,40 +91,178 @@ function configPropToSchema(propDef) {
80
91
  }
81
92
 
82
93
  /**
83
- * Build schema objects for all widget types from the config.
84
- * Returns the same shape as the old hardcoded schemas in widgetProps.js.
94
+ * Resolve the effective definition for a widget type.
95
+ * Consumer registry replaces the core entry entirely; consumers that need
96
+ * to extend a core widget should re-declare the merged definition.
97
+ *
98
+ * Features are NOT pre-resolved through `resolveFeature` for consumer
99
+ * entries — consumers are expected to pass plain values rather than
100
+ * `$label:foo` references, which are an internal storyboard.json idiom.
101
+ *
102
+ * @param {string} type
103
+ * @returns {object|null} The full widget definition with resolved features, or null
85
104
  */
86
- function buildSchemas() {
87
- const result = {}
88
- for (const [type, def] of Object.entries(widgetsConfig.widgets)) {
89
- const schema = {}
90
- for (const [key, propDef] of Object.entries(def.props || {})) {
91
- schema[key] = configPropToSchema(propDef)
105
+ function resolveDefinition(type) {
106
+ const consumer = getWidgetDefinition(type)
107
+ if (consumer) {
108
+ return {
109
+ ...consumer,
110
+ features: (consumer.features || []).map(resolveFeature),
92
111
  }
93
- result[type] = schema
94
112
  }
95
- return result
113
+ const core = widgetsConfig.widgets[type]
114
+ if (!core) return null
115
+ return {
116
+ ...core,
117
+ features: (core.features || []).map(resolveFeature),
118
+ }
96
119
  }
97
120
 
98
121
  /**
99
- * Build resolved widget type entries with variables expanded in features.
122
+ * Resolve schema (prop shape) for a widget type. Reads from the merged
123
+ * definition (consumer over core).
124
+ * @param {string} type
125
+ * @returns {Record<string, object>}
100
126
  */
101
- function buildWidgetTypes() {
127
+ function resolveSchema(type) {
128
+ const def = resolveDefinition(type) || consumerOrCorePropsOnly(type)
129
+ if (!def?.props) return {}
130
+ const schema = {}
131
+ for (const [key, propDef] of Object.entries(def.props)) {
132
+ schema[key] = configPropToSchema(propDef)
133
+ }
134
+ return schema
135
+ }
136
+
137
+ // Fallback for resolveSchema when the registry has a partial entry that
138
+ // lacks `props` — read from core so widgetProps' readProp helpers still work.
139
+ function consumerOrCorePropsOnly(type) {
140
+ const consumer = getWidgetDefinition(type)
141
+ if (consumer?.props) return consumer
142
+ return widgetsConfig.widgets[type] || null
143
+ }
144
+
145
+ function getAllTypes() {
146
+ // Set ensures consumer-only types AND core types are both included
147
+ return new Set([
148
+ ...Object.keys(widgetsConfig.widgets),
149
+ ...Object.keys(getAllWidgetDefinitions()),
150
+ ])
151
+ }
152
+
153
+ /** All widget schemas, keyed by type string. Reactive: re-resolved on each access. */
154
+ export const schemas = new Proxy({}, {
155
+ get(_, type) {
156
+ if (typeof type !== 'string') return undefined
157
+ return resolveSchema(type)
158
+ },
159
+ has(_, type) {
160
+ return typeof type === 'string' && (
161
+ !!widgetsConfig.widgets[type] || !!getWidgetDefinition(type)
162
+ )
163
+ },
164
+ ownKeys() {
165
+ return [...getAllTypes()]
166
+ },
167
+ getOwnPropertyDescriptor(_, type) {
168
+ if (typeof type !== 'string') return undefined
169
+ if (!widgetsConfig.widgets[type] && !getWidgetDefinition(type)) return undefined
170
+ return { enumerable: true, configurable: true, value: resolveSchema(type) }
171
+ },
172
+ })
173
+
174
+ /**
175
+ * Snapshot of all widget definitions with features resolved.
176
+ * Reactive helper kept for tests and tooling — prefer the typed getters below
177
+ * for runtime code.
178
+ */
179
+ export function widgetTypesSnapshot() {
102
180
  const result = {}
103
- for (const [type, def] of Object.entries(widgetsConfig.widgets)) {
104
- result[type] = {
105
- ...def,
106
- features: (def.features || []).map(resolveFeature),
107
- }
181
+ for (const type of getAllTypes()) {
182
+ const def = resolveDefinition(type)
183
+ if (def) result[type] = def
108
184
  }
109
185
  return result
110
186
  }
111
187
 
112
- /** All widget schemas, keyed by type string. */
113
- export const schemas = buildSchemas()
188
+ /**
189
+ * Back-compat: a Proxy that behaves like the previous `widgetTypes` constant.
190
+ * Reads are live (consumer overrides win at access time).
191
+ */
192
+ export const widgetTypes = new Proxy({}, {
193
+ get(_, type) {
194
+ if (typeof type !== 'string') return undefined
195
+ return resolveDefinition(type) || undefined
196
+ },
197
+ has(_, type) {
198
+ return typeof type === 'string' && (
199
+ !!widgetsConfig.widgets[type] || !!getWidgetDefinition(type)
200
+ )
201
+ },
202
+ ownKeys() {
203
+ return [...getAllTypes()]
204
+ },
205
+ getOwnPropertyDescriptor(_, type) {
206
+ if (typeof type !== 'string') return undefined
207
+ const def = resolveDefinition(type)
208
+ if (!def) return undefined
209
+ return { enumerable: true, configurable: true, value: def }
210
+ },
211
+ })
114
212
 
115
- /** Full widget config entries (with resolved variables), keyed by type string. */
116
- export const widgetTypes = buildWidgetTypes()
213
+ // ── Helpers to read the new `interaction` / `chrome` sub-blocks with
214
+ // back-compat fallback to the legacy top-level keys used by widgets.config.json.
215
+
216
+ function readInteraction(def) {
217
+ if (!def) return null
218
+ const i = def.interaction || {}
219
+ return {
220
+ selectable: i.selectable !== false, // default true
221
+ movable: i.movable !== false, // default true
222
+ resize: i.resize ?? def.resize ?? null,
223
+ expandable: i.expandable ?? def.expandable ?? false,
224
+ splitScreen: i.splitScreen ?? def.splitScreen ?? false,
225
+ interactGate: i.interactGate ?? def.interactGate ?? false,
226
+ interactGateLabel: i.interactGateLabel ?? def.interactGateLabel ?? 'Click to interact',
227
+ }
228
+ }
229
+
230
+ function readChrome(def) {
231
+ if (!def) return { enabled: true }
232
+ const c = def.chrome || {}
233
+ return {
234
+ enabled: c.enabled !== false, // default true
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Get the chrome options for a widget type (used by CanvasPage to decide
240
+ * whether to wrap the widget in WidgetChrome at all).
241
+ * @param {string} type
242
+ * @returns {{ enabled: boolean }}
243
+ */
244
+ export function getChromeOptions(type) {
245
+ return readChrome(resolveDefinition(type))
246
+ }
247
+
248
+ /**
249
+ * Get the per-widget interaction switches (selectable, movable, resize,
250
+ * expandable, splitScreen, interactGate). Merges new `interaction.*`
251
+ * sub-block over legacy top-level keys.
252
+ * @param {string} type
253
+ * @returns {{ selectable, movable, resize, expandable, splitScreen, interactGate, interactGateLabel }}
254
+ */
255
+ export function getInteractionOptions(type) {
256
+ return readInteraction(resolveDefinition(type)) || {
257
+ selectable: true,
258
+ movable: true,
259
+ resize: null,
260
+ expandable: false,
261
+ splitScreen: false,
262
+ interactGate: false,
263
+ interactGateLabel: 'Click to interact',
264
+ }
265
+ }
117
266
 
118
267
  /**
119
268
  * Get the feature list for a widget type.
@@ -130,7 +279,7 @@ export const widgetTypes = buildWidgetTypes()
130
279
  * @returns {Array} features array from config (variables resolved), or empty array
131
280
  */
132
281
  export function getFeatures(type, { isLocalDev = true } = {}) {
133
- const features = widgetTypes[type]?.features ?? []
282
+ const features = resolveDefinition(type)?.features ?? []
134
283
  let filtered = features.filter(f => {
135
284
  const surfaces = f.surfaces || ['toolbar']
136
285
  return surfaces.includes('toolbar')
@@ -150,7 +299,7 @@ export function getFeatures(type, { isLocalDev = true } = {}) {
150
299
  * @returns {Array} filtered features for the given surface
151
300
  */
152
301
  export function getFeaturesForSurface(type, surface, { isLocalDev = true } = {}) {
153
- let features = widgetTypes[type]?.features ?? []
302
+ let features = resolveDefinition(type)?.features ?? []
154
303
  if (import.meta.env?.PROD || !isLocalDev) {
155
304
  features = features.filter(f => f.prod)
156
305
  }
@@ -163,11 +312,12 @@ export function getFeaturesForSurface(type, surface, { isLocalDev = true } = {})
163
312
  /**
164
313
  * Check if a widget type supports resize in the current environment.
165
314
  * Returns false if resize is disabled, or if in production and prod is not true.
315
+ * Reads from `interaction.resize` (preferred) or legacy top-level `resize`.
166
316
  * @param {string} type — widget type string
167
317
  * @returns {boolean}
168
318
  */
169
319
  export function isResizable(type) {
170
- const resize = widgetTypes[type]?.resize
320
+ const resize = getInteractionOptions(type).resize
171
321
  if (!resize?.enabled) return false
172
322
  if (import.meta.env?.PROD && !resize.prod) return false
173
323
  return true
@@ -179,50 +329,57 @@ export function isResizable(type) {
179
329
  * @returns {{ label: string, icon: string } | null}
180
330
  */
181
331
  export function getWidgetMeta(type) {
182
- const def = widgetTypes[type]
332
+ const def = resolveDefinition(type)
183
333
  if (!def) return null
184
334
  return { label: def.label, icon: def.icon }
185
335
  }
186
336
 
187
337
  /**
188
338
  * Check if a widget type supports expanding to a full-screen modal.
339
+ * Reads from `interaction.expandable` (preferred) or legacy top-level.
189
340
  * @param {string} type — widget type string
190
341
  * @returns {boolean}
191
342
  */
192
343
  export function isExpandable(type) {
193
- return widgetTypes[type]?.expandable === true
344
+ return getInteractionOptions(type).expandable === true
194
345
  }
195
346
 
196
347
  /**
197
348
  * Check if a widget type can appear in a split-screen pane.
349
+ * Reads from `interaction.splitScreen` (preferred) or legacy top-level.
198
350
  * @param {string} type — widget type string
199
351
  * @returns {boolean}
200
352
  */
201
353
  export function isSplitScreenCapable(type) {
202
- return widgetTypes[type]?.splitScreen === true
354
+ return getInteractionOptions(type).splitScreen === true
203
355
  }
204
356
 
205
357
  /**
206
358
  * Get the interact gate config for a widget type.
207
- * @returns {{ enabled: boolean, label: string }}
359
+ * Reads from `interaction.interactGate` (preferred) or legacy top-level.
360
+ * @returns {{ enabled: boolean, label: string }}
208
361
  */
209
362
  export function getInteractGate(type) {
210
- const def = widgetTypes[type]
211
- if (!def || !def.interactGate) return { enabled: false, label: 'Click to interact' }
212
- return {
213
- enabled: true,
214
- label: def.interactGateLabel || 'Click to interact',
215
- }
363
+ const i = getInteractionOptions(type)
364
+ if (!i.interactGate) return { enabled: false, label: i.interactGateLabel || 'Click to interact' }
365
+ return { enabled: true, label: i.interactGateLabel || 'Click to interact' }
216
366
  }
217
367
 
218
368
  /**
219
369
  * Get all widget types as an array of { type, label, icon } for menus.
220
- * Excludes link-preview, image, and figma-embed which are created via paste only.
370
+ * Excludes hidden core widgets (created via paste only) and any widget with
371
+ * `unlisted: true` (consumer- or core-side).
221
372
  */
222
373
  export function getMenuWidgetTypes() {
223
- return Object.entries(widgetTypes)
224
- .filter(([type, def]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story' && type !== 'terminal-read' && !def.unlisted)
225
- .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
374
+ const hidden = new Set(['link-preview', 'image', 'figma-embed', 'codepen-embed', 'story', 'terminal-read'])
375
+ const out = []
376
+ for (const type of getAllTypes()) {
377
+ if (hidden.has(type)) continue
378
+ const def = resolveDefinition(type)
379
+ if (!def || def.unlisted) continue
380
+ out.push({ type, label: def.label, icon: def.icon })
381
+ }
382
+ return out
226
383
  }
227
384
 
228
385
  /**
@@ -231,7 +388,7 @@ export function getMenuWidgetTypes() {
231
388
  * @returns {{ anchors: Record<string, string>, accept: string[], exclude: string[], defaults: Object|undefined }}
232
389
  */
233
390
  export function getConnectorConfig(type) {
234
- const def = widgetTypes[type]?.connectors
391
+ const def = resolveDefinition(type)?.connectors
235
392
  return {
236
393
  anchors: def?.anchors ?? { top: 'available', bottom: 'available', left: 'available', right: 'available' },
237
394
  accept: def?.accept ?? ['*'],
@@ -0,0 +1,144 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import {
3
+ registerWidget,
4
+ _resetWidgetRegistry,
5
+ } from '../../../core/stores/widgetRegistry.js'
6
+ import {
7
+ getWidgetMeta,
8
+ isResizable,
9
+ isExpandable,
10
+ isSplitScreenCapable,
11
+ getInteractGate,
12
+ getMenuWidgetTypes,
13
+ getChromeOptions,
14
+ getInteractionOptions,
15
+ getFeatures,
16
+ getConnectorConfig,
17
+ schemas,
18
+ } from './widgetConfig.js'
19
+
20
+ describe('widgetConfig — merged consumer + built-in registry', () => {
21
+ beforeEach(() => {
22
+ _resetWidgetRegistry()
23
+ })
24
+
25
+ it('falls back to built-in widgets when nothing registered', () => {
26
+ expect(getWidgetMeta('sticky-note')?.label).toBeTruthy()
27
+ expect(getChromeOptions('sticky-note').enabled).toBe(true)
28
+ expect(getInteractionOptions('sticky-note').selectable).toBe(true)
29
+ expect(getInteractionOptions('sticky-note').movable).toBe(true)
30
+ })
31
+
32
+ it('consumer-registered widget exposes label/icon via getWidgetMeta', () => {
33
+ registerWidget('my-thing', { label: 'My Thing', icon: 'sparkle-fill' })
34
+ expect(getWidgetMeta('my-thing')).toEqual({ label: 'My Thing', icon: 'sparkle-fill' })
35
+ })
36
+
37
+ it('chrome.enabled defaults to true, overrideable to false', () => {
38
+ registerWidget('invisible', { label: 'Invisible', chrome: { enabled: false } })
39
+ expect(getChromeOptions('invisible').enabled).toBe(false)
40
+ registerWidget('visible', { label: 'Visible', chrome: { enabled: true } })
41
+ expect(getChromeOptions('visible').enabled).toBe(true)
42
+ registerWidget('default', { label: 'Default' })
43
+ expect(getChromeOptions('default').enabled).toBe(true)
44
+ })
45
+
46
+ it('interaction.selectable/movable default to true, overrideable to false', () => {
47
+ registerWidget('pinned', { label: 'Pinned', interaction: { movable: false } })
48
+ expect(getInteractionOptions('pinned').movable).toBe(false)
49
+ expect(getInteractionOptions('pinned').selectable).toBe(true)
50
+
51
+ registerWidget('decorative', { label: 'Deco', interaction: { selectable: false } })
52
+ expect(getInteractionOptions('decorative').selectable).toBe(false)
53
+ expect(getInteractionOptions('decorative').movable).toBe(true)
54
+ })
55
+
56
+ it('interaction.resize/expandable/splitScreen flow through to legacy getters', () => {
57
+ registerWidget('big', {
58
+ label: 'Big',
59
+ interaction: {
60
+ resize: { enabled: true, prod: true },
61
+ expandable: true,
62
+ splitScreen: true,
63
+ interactGate: true,
64
+ interactGateLabel: 'Tap to interact',
65
+ },
66
+ })
67
+ expect(isResizable('big')).toBe(true)
68
+ expect(isExpandable('big')).toBe(true)
69
+ expect(isSplitScreenCapable('big')).toBe(true)
70
+ expect(getInteractGate('big')).toEqual({ enabled: true, label: 'Tap to interact' })
71
+ })
72
+
73
+ it('legacy top-level keys still work as back-compat (widgets.config.json shape)', () => {
74
+ // No nested `interaction` block — the widgets.config.json baseline
75
+ // should keep working when consumer omits the block.
76
+ registerWidget('legacy', {
77
+ label: 'Legacy',
78
+ resize: { enabled: true, prod: false },
79
+ expandable: true,
80
+ splitScreen: true,
81
+ interactGate: true,
82
+ interactGateLabel: 'Old gate',
83
+ })
84
+ expect(isResizable('legacy')).toBe(true)
85
+ expect(isExpandable('legacy')).toBe(true)
86
+ expect(isSplitScreenCapable('legacy')).toBe(true)
87
+ expect(getInteractGate('legacy').enabled).toBe(true)
88
+ expect(getInteractGate('legacy').label).toBe('Old gate')
89
+ })
90
+
91
+ it('getMenuWidgetTypes includes consumer entries and excludes unlisted', () => {
92
+ registerWidget('shown', { label: 'Shown', icon: 'star' })
93
+ registerWidget('hidden', { label: 'Hidden', unlisted: true })
94
+ const types = getMenuWidgetTypes()
95
+ expect(types.some((t) => t.type === 'shown')).toBe(true)
96
+ expect(types.some((t) => t.type === 'hidden')).toBe(false)
97
+ })
98
+
99
+ it('consumer features flow through getFeatures', () => {
100
+ registerWidget('withFeat', {
101
+ label: 'With',
102
+ features: [
103
+ { id: 'foo', type: 'action', action: 'foo', label: 'Foo', icon: 'star', prod: true },
104
+ ],
105
+ })
106
+ const feats = getFeatures('withFeat', { isLocalDev: true })
107
+ expect(feats.length).toBe(1)
108
+ expect(feats[0].action).toBe('foo')
109
+ })
110
+
111
+ it('consumer connector config flows through getConnectorConfig', () => {
112
+ registerWidget('lockedConn', {
113
+ label: 'Locked',
114
+ connectors: { anchors: { top: 'unavailable', bottom: 'disabled', left: 'available', right: 'available' }, accept: ['sticky-note'] },
115
+ })
116
+ const cfg = getConnectorConfig('lockedConn')
117
+ expect(cfg.anchors.top).toBe('unavailable')
118
+ expect(cfg.anchors.bottom).toBe('disabled')
119
+ expect(cfg.accept).toEqual(['sticky-note'])
120
+ })
121
+
122
+ it('consumer schemas are exposed via the schemas proxy', () => {
123
+ registerWidget('withProps', {
124
+ label: 'WithProps',
125
+ props: {
126
+ title: { type: 'text', label: 'Title', default: 'Hi' },
127
+ width: { type: 'number', label: 'Width', default: 320 },
128
+ },
129
+ })
130
+ expect(schemas['withProps']).toEqual({
131
+ title: { type: 'text', label: 'Title', category: undefined, defaultValue: 'Hi' },
132
+ width: { type: 'number', label: 'Width', category: undefined, defaultValue: 320 },
133
+ })
134
+ })
135
+
136
+ it('consumer registration overrides core widget for same type', () => {
137
+ // sticky-note is movable by default
138
+ expect(getInteractionOptions('sticky-note').movable).toBe(true)
139
+ registerWidget('sticky-note', { label: 'Sticky', interaction: { movable: false } })
140
+ expect(getInteractionOptions('sticky-note').movable).toBe(false)
141
+ // After unregister via re-init the core entry returns
142
+ vi.clearAllMocks()
143
+ })
144
+ })
@@ -2,7 +2,7 @@ import { useState, useEffect, useMemo, useRef, Suspense, lazy, Component } from
2
2
  import { useParams, useLocation } from 'react-router-dom'
3
3
  // Named import seeds the core data index via init() AND provides canvas/story route data
4
4
  import { canvases, stories } from 'virtual:storyboard-data-index'
5
- import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, isModesEnabled, getPrototypeMetadata } from '../core/index.js'
5
+ import { loadFlow, flowExists, findRecord, deepMerge, setFlowClass, installBodyClassSync, resolveFlowName, resolveRecordName, getPrototypeMetadata } from '../core/index.js'
6
6
  import { StoryboardContext } from './StoryboardContext.js'
7
7
  import usePrototypeReloadGuard from './hooks/usePrototypeReloadGuard.js'
8
8
  import styles from './FlowError.module.css'
@@ -336,22 +336,6 @@ function StoryboardProviderInner({ flowName, sceneName, recordName, recordParam,
336
336
  document.title = title + branchSuffix
337
337
  }, [canvasId, prototypeName])
338
338
 
339
- // Mount design modes UI when enabled in storyboard.config.json
340
- useEffect(() => {
341
- if (!isModesEnabled()) return
342
-
343
- let cleanup
344
- import('@dfosco/storyboard/ui-runtime')
345
- .then(({ mountDesignModes }) => {
346
- cleanup = mountDesignModes()
347
- })
348
- .catch(() => {
349
- // UI not available — degrade gracefully
350
- })
351
-
352
- return () => cleanup?.()
353
- }, [])
354
-
355
339
  // Skip flow loading for canvas/story pages and flow-less pages
356
340
  const { data, error, flowTokens } = useMemo(() => {
357
341
  if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null, flowTokens: null }
@@ -1,7 +0,0 @@
1
- /**
2
- * Design modes UI mount — stub (Svelte UI removed).
3
- * These functions are no-ops. Mode switching is handled by React components.
4
- */
5
-
6
- export function mountDesignModesUI() {}
7
- export function unmountDesignModesUI() {}
@@ -1,7 +0,0 @@
1
- /**
2
- * Viewfinder mount — stub (Svelte UI removed).
3
- * These functions are no-ops. The viewfinder is rendered by React.
4
- */
5
-
6
- export function mountViewfinder() {}
7
- export function unmountViewfinder() {}
@@ -1,6 +0,0 @@
1
- /**
2
- * Workshop panel mount — stub (Svelte UI removed).
3
- */
4
-
5
- export function mountWorkshopPanel() {}
6
- export function unmountWorkshopPanel() {}