@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
|
@@ -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
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
*
|
|
84
|
-
*
|
|
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
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
/**
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
354
|
+
return getInteractionOptions(type).splitScreen === true
|
|
203
355
|
}
|
|
204
356
|
|
|
205
357
|
/**
|
|
206
358
|
* Get the interact gate config for a widget type.
|
|
207
|
-
*
|
|
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
|
|
211
|
-
if (!
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 =
|
|
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,
|
|
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 }
|