@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.
- 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 -79
- 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/canvas/CanvasPage.bridge.test.jsx +2 -0
- package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +2 -0
- package/src/internals/canvas/CanvasPage.jsx +19 -3
- package/src/internals/canvas/CanvasPage.multiselect.test.jsx +2 -0
- package/src/internals/canvas/widgets/WidgetChrome.jsx +50 -5
- package/src/internals/canvas/widgets/WidgetChrome.test.jsx +65 -0
- package/src/internals/canvas/widgets/index.js +18 -4
- package/src/internals/canvas/widgets/widgetConfig.js +202 -45
- package/src/internals/canvas/widgets/widgetConfig.merged.test.js +144 -0
- package/src/internals/context.jsx +1 -17
- package/src/core/ui/design-modes.ts +0 -7
- package/src/core/ui/viewfinder.ts +0 -7
- package/src/core/workshop/ui/mount.ts +0 -6
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Custom Widget Registry
|
|
3
|
+
*
|
|
4
|
+
* Consumer widget metadata loaded from `storyboard.config.json.widgets` at
|
|
5
|
+
* dev-server startup. Server-side modules (collision detection, widget
|
|
6
|
+
* placement, prop validation) consult this registry to resolve widget
|
|
7
|
+
* metadata for consumer-registered widget types.
|
|
8
|
+
*
|
|
9
|
+
* Mirrors the browser-side `core/stores/widgetRegistry.js` but holds only
|
|
10
|
+
* the JSON-serializable subset — no React components (those live in the
|
|
11
|
+
* browser only). The `definition` shape is otherwise identical:
|
|
12
|
+
*
|
|
13
|
+
* {
|
|
14
|
+
* label, icon,
|
|
15
|
+
* chrome: { enabled },
|
|
16
|
+
* interaction: { selectable, movable, resize, expandable, splitScreen, interactGate, interactGateLabel },
|
|
17
|
+
* connectors: { anchors, accept, exclude, defaults },
|
|
18
|
+
* features: [...],
|
|
19
|
+
* unlisted,
|
|
20
|
+
* props: { width: { type, default, ... }, ... }
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Consumer entries OVERRIDE the built-in `widgets.config.json` entry of the
|
|
24
|
+
* same type. The merge is whole-entry replacement (consumer wins) to match
|
|
25
|
+
* the browser-side behavior — consumers that want to extend a core widget
|
|
26
|
+
* must re-declare the merged definition.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import widgetsConfig from '../../../widgets.config.json' with { type: 'json' }
|
|
30
|
+
|
|
31
|
+
/** @type {Map<string, object>} */
|
|
32
|
+
const _registry = new Map()
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Replace the entire registry from a consumer-supplied map.
|
|
36
|
+
* @param {Record<string, object>|null|undefined} map
|
|
37
|
+
*/
|
|
38
|
+
export function initServerWidgets(map) {
|
|
39
|
+
_registry.clear()
|
|
40
|
+
if (map && typeof map === 'object') {
|
|
41
|
+
for (const [type, def] of Object.entries(map)) {
|
|
42
|
+
if (def && typeof def === 'object') {
|
|
43
|
+
_registry.set(type, def)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Register a single widget type. Replaces any existing entry.
|
|
51
|
+
* @param {string} type
|
|
52
|
+
* @param {object} definition
|
|
53
|
+
*/
|
|
54
|
+
export function registerServerWidget(type, definition) {
|
|
55
|
+
if (!type || typeof type !== 'string') {
|
|
56
|
+
throw new Error('[storyboard] registerServerWidget: type must be a non-empty string')
|
|
57
|
+
}
|
|
58
|
+
if (!definition || typeof definition !== 'object') {
|
|
59
|
+
throw new Error(`[storyboard] registerServerWidget("${type}"): definition must be an object`)
|
|
60
|
+
}
|
|
61
|
+
_registry.set(type, definition)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the effective server-side widget definition for a type.
|
|
66
|
+
* Returns the consumer entry if present, otherwise the built-in entry from
|
|
67
|
+
* `widgets.config.json`, otherwise null.
|
|
68
|
+
* @param {string} type
|
|
69
|
+
* @returns {object|null}
|
|
70
|
+
*/
|
|
71
|
+
export function getServerWidgetDefinition(type) {
|
|
72
|
+
if (_registry.has(type)) return _registry.get(type)
|
|
73
|
+
return widgetsConfig.widgets?.[type] || null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Snapshot all definitions (consumer + built-in) keyed by type.
|
|
78
|
+
* Consumer entries override built-ins.
|
|
79
|
+
* @returns {Record<string, object>}
|
|
80
|
+
*/
|
|
81
|
+
export function getAllServerWidgetDefinitions() {
|
|
82
|
+
const merged = { ...(widgetsConfig.widgets || {}) }
|
|
83
|
+
for (const [type, def] of _registry) {
|
|
84
|
+
merged[type] = def
|
|
85
|
+
}
|
|
86
|
+
return merged
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Reset all state. Only for tests. */
|
|
90
|
+
export function _resetServerWidgets() {
|
|
91
|
+
_registry.clear()
|
|
92
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
initServerWidgets,
|
|
4
|
+
registerServerWidget,
|
|
5
|
+
getServerWidgetDefinition,
|
|
6
|
+
getAllServerWidgetDefinitions,
|
|
7
|
+
_resetServerWidgets,
|
|
8
|
+
} from './customWidgets.js'
|
|
9
|
+
|
|
10
|
+
describe('customWidgets (server-side registry)', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
_resetServerWidgets()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('falls back to built-in widgets.config.json when nothing registered', () => {
|
|
16
|
+
const sticky = getServerWidgetDefinition('sticky-note')
|
|
17
|
+
expect(sticky).toBeTruthy()
|
|
18
|
+
expect(sticky.label).toBeTruthy()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns null for unknown widget types', () => {
|
|
22
|
+
expect(getServerWidgetDefinition('does-not-exist')).toBeNull()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('registerServerWidget adds a consumer entry', () => {
|
|
26
|
+
registerServerWidget('my-thing', { label: 'My Thing', props: { width: { type: 'number', default: 400 } } })
|
|
27
|
+
const def = getServerWidgetDefinition('my-thing')
|
|
28
|
+
expect(def?.label).toBe('My Thing')
|
|
29
|
+
expect(def?.props.width.default).toBe(400)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('consumer entry overrides built-in entry for the same type', () => {
|
|
33
|
+
registerServerWidget('sticky-note', { label: 'Custom Sticky', props: { width: { default: 999 } } })
|
|
34
|
+
const def = getServerWidgetDefinition('sticky-note')
|
|
35
|
+
expect(def.label).toBe('Custom Sticky')
|
|
36
|
+
expect(def.props.width.default).toBe(999)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('initServerWidgets replaces all consumer entries', () => {
|
|
40
|
+
registerServerWidget('a', { label: 'A' })
|
|
41
|
+
initServerWidgets({ b: { label: 'B' } })
|
|
42
|
+
expect(getServerWidgetDefinition('a')).not.toEqual({ label: 'A' })
|
|
43
|
+
// 'a' may still resolve to a core entry if one exists; here it doesn't,
|
|
44
|
+
// so it falls back to null
|
|
45
|
+
expect(getServerWidgetDefinition('a')).toBeNull()
|
|
46
|
+
expect(getServerWidgetDefinition('b')).toEqual({ label: 'B' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('initServerWidgets accepts null/undefined to clear', () => {
|
|
50
|
+
registerServerWidget('a', { label: 'A' })
|
|
51
|
+
initServerWidgets(null)
|
|
52
|
+
expect(getServerWidgetDefinition('a')).toBeNull()
|
|
53
|
+
initServerWidgets({ x: { label: 'X' } })
|
|
54
|
+
initServerWidgets(undefined)
|
|
55
|
+
expect(getServerWidgetDefinition('x')).toBeNull()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('getAllServerWidgetDefinitions includes both built-ins and consumer entries', () => {
|
|
59
|
+
registerServerWidget('my-thing', { label: 'My Thing' })
|
|
60
|
+
const all = getAllServerWidgetDefinitions()
|
|
61
|
+
expect(all['my-thing']?.label).toBe('My Thing')
|
|
62
|
+
expect(all['sticky-note']).toBeTruthy() // built-in still present
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('registerServerWidget throws on invalid input', () => {
|
|
66
|
+
expect(() => registerServerWidget('', {})).toThrow(/non-empty string/)
|
|
67
|
+
expect(() => registerServerWidget('foo', null)).toThrow(/must be an object/)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
@@ -54,16 +54,17 @@ import {
|
|
|
54
54
|
import { stampBounds, stampBoundsAll, resolvePosition, getWidgetBounds, findBestAnchors } from './collision.js'
|
|
55
55
|
import { markCanvasWrite, unmarkCanvasWrite } from './writeGuard.js'
|
|
56
56
|
import { devLog } from '../logger/devLogger.js'
|
|
57
|
-
import
|
|
57
|
+
import { getServerWidgetDefinition } from './customWidgets.js'
|
|
58
58
|
import { listHubRoles, getDefaultRoleId } from './hub-roles.js'
|
|
59
59
|
import { readAgentsConfig } from './configReader.js'
|
|
60
60
|
|
|
61
61
|
/**
|
|
62
|
-
* Read the prompt widget's execution config from
|
|
62
|
+
* Read the prompt widget's execution config from the merged widget registry.
|
|
63
63
|
* Returns { default, agents } where each agent has a command template.
|
|
64
64
|
*/
|
|
65
65
|
function getPromptExecution() {
|
|
66
|
-
|
|
66
|
+
const def = getServerWidgetDefinition('prompt')
|
|
67
|
+
return def?.execution || null
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/**
|
package/src/core/index.js
CHANGED
|
@@ -109,6 +109,19 @@ export {
|
|
|
109
109
|
getPresentationSnapshot,
|
|
110
110
|
} from './stores/presentationStore.js'
|
|
111
111
|
|
|
112
|
+
// Custom widgets — public API. Register your own canvas widget types with
|
|
113
|
+
// full control over rendering, chrome, interaction, and toolbar features.
|
|
114
|
+
// See DOCS/custom-widgets.md.
|
|
115
|
+
export {
|
|
116
|
+
initWidgetRegistry,
|
|
117
|
+
registerWidget,
|
|
118
|
+
unregisterWidget,
|
|
119
|
+
getWidgetDefinition,
|
|
120
|
+
getAllWidgetDefinitions,
|
|
121
|
+
subscribeToWidgetRegistry,
|
|
122
|
+
getWidgetRegistrySnapshot,
|
|
123
|
+
} from './stores/widgetRegistry.js'
|
|
124
|
+
|
|
112
125
|
// Canvas interaction (advanced, undocumented — scroll axis + zoom gesture gates)
|
|
113
126
|
export {
|
|
114
127
|
initCanvasInteraction,
|
|
@@ -16,6 +16,7 @@ import { installBodyClassSync } from './session/bodyClasses.js'
|
|
|
16
16
|
import { applyEarlyTheme as bootstrapEarlyTheme } from './stores/themeBootstrap.js'
|
|
17
17
|
import { getConfig as getSchemaDefaulted } from './stores/configSchema.js'
|
|
18
18
|
import { initPresentation } from './stores/presentationStore.js'
|
|
19
|
+
import { initWidgetRegistry } from './stores/widgetRegistry.js'
|
|
19
20
|
import { initCanvasInteraction } from './stores/canvasInteractionStore.js'
|
|
20
21
|
import {
|
|
21
22
|
initCommentsConfig, isCommentsEnabled,
|
|
@@ -395,6 +396,7 @@ function installCmdClickForwarder() {
|
|
|
395
396
|
* @param {HTMLElement} [options.container=document.body] - Where to mount devtools
|
|
396
397
|
* @param {Record<string, () => Promise<any>>} [options.handlers={}] - Custom tool handlers (key → lazy loader)
|
|
397
398
|
* @param {Record<string, object>} [options.presentation] - Optional, advanced: per-chrome-element presentation overrides. See `core/stores/presentationStore.js` for the shape. Keep undocumented — power-user surface for landing pages and heavy chrome customization.
|
|
399
|
+
* @param {Record<string, object>} [options.widgets] - Optional: custom widget registrations. Maps widget type string → WidgetDefinition. Defines the React component, label/icon, chrome wrapper opts, interaction switches (selectable/movable/resize/expandable/splitScreen/interactGate), connector anchors, toolbar features, and prop schema. Consumer entries override built-in widgets of the same type. See `DOCS/custom-widgets.md` and `core/stores/widgetRegistry.js` for the full shape.
|
|
398
400
|
*/
|
|
399
401
|
export async function mountStoryboardCore(config = {}, options = {}) {
|
|
400
402
|
if (_mounted) return
|
|
@@ -409,6 +411,14 @@ export async function mountStoryboardCore(config = {}, options = {}) {
|
|
|
409
411
|
initPresentation(options.presentation)
|
|
410
412
|
}
|
|
411
413
|
|
|
414
|
+
// Seed the widget registry before any canvas mounts so the first canvas
|
|
415
|
+
// render already resolves custom widget components, chrome opts, and
|
|
416
|
+
// interaction switches. Consumer entries override core widgets of the
|
|
417
|
+
// same type entirely.
|
|
418
|
+
if (options.widgets) {
|
|
419
|
+
initWidgetRegistry(options.widgets)
|
|
420
|
+
}
|
|
421
|
+
|
|
412
422
|
// Migrate renamed localStorage keys (4.3.0: viewfinder → workspace)
|
|
413
423
|
migrateLocalStorageKeys()
|
|
414
424
|
|
|
@@ -164,7 +164,8 @@
|
|
|
164
164
|
* @property {HotPoolConfig} [hotPool]
|
|
165
165
|
* @property {CommandPaletteConfig} [commandPalette]
|
|
166
166
|
* @property {CustomerModeConfig} [customerMode]
|
|
167
|
-
* @property {ThemesConfig} [
|
|
167
|
+
* @property {ThemesConfig} [theming]
|
|
168
|
+
* @property {Record<string, object>} [widgets] — Custom widget metadata (server-side). Maps widget type string → definition (label, icon, chrome, interaction, connectors, features, props). Consumer entries override built-in widget metadata for collision detection, default sizes, prompt-exec config, etc. Browser-side widget components are registered separately via `mountStoryboardCore({ widgets })`. See `DOCS/custom-widgets.md`.
|
|
168
169
|
*/
|
|
169
170
|
|
|
170
171
|
/** Built-in paste rules shipped with storyboard. */
|
|
@@ -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
|
|
119
|
+
// Subscribable mode state for useExternalStore
|
|
120
120
|
const modeState = {
|
|
121
121
|
get mode() { return getCurrentMode() },
|
|
122
122
|
get modes() { return getRegisteredModes() },
|
package/src/core/ui-entry.js
CHANGED
|
@@ -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,
|