@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
@@ -13,200 +13,91 @@ Guide for adding new menu buttons to the storyboard CoreUIBar — the floating t
13
13
 
14
14
  ## Overview
15
15
 
16
- The CoreUIBar is a config-driven floating toolbar rendered by `packages/core/src/CoreUIBar.svelte`. All buttons are defined in `packages/core/toolbar.config.json` under the `menus` key. The toolbar reads this config at startup, filters menus by the current mode, and renders buttons in JSON key order (reversed, so top = rightmost after the command menu).
16
+ The CoreUIBar is a config-driven floating React toolbar rendered by `packages/storyboard/src/core/ui/CoreUIBar.jsx`. All buttons are defined in `packages/storyboard/toolbar.config.json` under the `tools` key. The toolbar reads this config at startup, filters tools by the current mode and surface, and renders them in JSON key order.
17
+
18
+ > **For the full how-to (config schema, render types, handler module interface, surface reference, recipes, registration of custom client handlers via `mountStoryboardCore({ handlers })`), see the canonical [`storyboard-tools`](../storyboard-tools/SKILL.md) skill.** This skill keeps only what's specific to working **inside** storyboard-core itself.
17
19
 
18
20
  ## Architecture
19
21
 
20
22
  ```
21
- toolbar.config.json Menu declarations (icon, modes, behavior)
23
+ toolbar.config.json Tool declarations
22
24
 
23
- CoreUIBar.svelte Reads config, renders buttons via {#each}
25
+ packages/storyboard/src/core/ui/CoreUIBar.jsx React; reads config, mounts tools
24
26
 
25
- ├── TriggerButton + Icon ← Sidepanel buttons (docs, inspector)
26
- ├── ActionMenuButton.svelte Generic action-driven dropdown (config "action" key)
27
- ├── CreateMenuButton.svelte Workshop feature launcher (config "actions" + registry)
28
- ├── CommentsMenuButton.svelte Auth-aware comments toggle
29
- └── CommandMenu.svelte ← Always rightmost, special handling
30
- ```
31
-
32
- ## Menu Button Types
33
-
34
- There are three patterns, from simplest to most custom:
35
-
36
- ### 1. Sidepanel button (config-only, no component)
37
-
38
- For buttons that toggle a side panel. Just add a config entry — no Svelte component needed.
39
-
40
- ```json
41
- "docs": {
42
- "ariaLabel": "Documentation",
43
- "icon": "primer/book",
44
- "modes": ["*"],
45
- "sidepanel": "docs"
46
- }
47
- ```
48
-
49
- ### 2. Action menu button (config + registered action handler)
50
-
51
- **This is the preferred pattern for new dynamic menus.** The config declares an `"action"` key pointing to a command action ID. CoreUIBar registers the handler in `onMount`. The generic `ActionMenuButton.svelte` renders the items.
52
-
53
- ```json
54
- "flows": {
55
- "label": "Flows",
56
- "ariaLabel": "Switch flow",
57
- "icon": "feather/fast-forward",
58
- "modes": ["*"],
59
- "action": "core/flows"
60
- }
61
- ```
62
-
63
- The handler is registered in CoreUIBar's `onMount`:
64
-
65
- ```ts
66
- registerCommandAction('core/flows', {
67
- getChildren: () => {
68
- // return array of { id, label, type, active?, execute }
69
- return items.map(item => ({
70
- id: item.key,
71
- label: item.title,
72
- type: 'radio', // or 'default', 'toggle'
73
- active: item.isActive,
74
- execute: () => { /* action */ },
75
- }))
76
- },
77
- })
78
- ```
79
-
80
- **Supported child types:** `default` (plain item), `toggle` (checkbox), `radio` (radio group with check indicator).
81
-
82
- The button auto-hides when `getChildren()` returns an empty array.
83
-
84
- ### 3. Custom component (for complex UI that doesn't fit action items)
85
-
86
- Only use when the menu needs UI beyond what action items support (e.g., auth flows, overlay panels, feature registries). Must still be declared in config and dynamically imported.
87
-
88
- ```json
89
- "comments": {
90
- "ariaLabel": "Comments",
91
- "icon": "primer/comment",
92
- "modes": ["*"]
93
- }
94
- ```
95
-
96
- ## Adding a New Menu Button
97
-
98
- ### Step 1: Add config entry
99
-
100
- Add an entry to `packages/core/toolbar.config.json` under `menus`. The key order determines position (top = leftmost, bottom = rightmost before command menu).
101
-
102
- ### Config field reference
103
-
104
- | Field | Required | Description |
105
- |-------|----------|-------------|
106
- | `ariaLabel` | yes | Accessible label, also shown in tooltip |
107
- | `icon` | yes | Namespaced icon name (see Icon section) |
108
- | `modes` | yes | Array of mode names or `["*"]` for all modes |
109
- | `action` | no | Command action ID — renders via generic `ActionMenuButton.svelte` (preferred for dynamic menus) |
110
- | `meta` | no | Passed as props to `<Icon>` (e.g. `{ "strokeWeight": 2, "scale": 1.1 }`) |
111
- | `sidepanel` | no | If set, button toggles this side panel tab (no custom component needed) |
112
- | `label` | no | Display label (used in dropdown headers) |
113
- | `actions` | no | Array of static action items with `feature` references (used by create menu) |
114
- | `excludeRoutes` | no | Array of route patterns where this menu is hidden |
115
-
116
- ### Step 2: Register the action handler (for action menus)
117
-
118
- In `CoreUIBar.svelte`'s `onMount`, register a handler for your action ID:
119
-
120
- ```ts
121
- registerCommandAction('core/my-feature', {
122
- getChildren: () => {
123
- // Compute items dynamically based on current state
124
- return items.map(item => ({
125
- id: item.key,
126
- label: item.title,
127
- type: 'radio', // 'default', 'toggle', or 'radio'
128
- active: item.isActive, // for radio/toggle: marks the selected item
129
- execute: () => { /* what happens on click */ },
130
- }))
131
- },
132
- })
133
- ```
134
-
135
- **That's it.** The generic `ActionMenuButton.svelte` handles all rendering. It:
136
- - Reads children from the action registry via `getActionChildren()`
137
- - Renders `RadioGroup` for `radio` type, `CheckboxItem` for `toggle`, `Item` for `default`
138
- - Auto-hides when `getChildren()` returns an empty array
139
- - Refreshes items on each open via `onOpenChange`
140
-
141
- ### Step 3 (only for custom components): Wire into CoreUIBar.svelte
142
-
143
- Only needed when the action system can't express the UI you need (auth flows, overlay panels, etc.).
144
-
145
- **1. Add state variable:**
146
-
147
- ```ts
148
- let MyFeatureButton: any = $state(null)
149
- ```
150
-
151
- **2. Add visibility filter in `visibleMenus`:**
152
-
153
- ```ts
154
- if (menu.key === 'my-feature') return !!MyFeatureButton
155
- ```
156
-
157
- **3. Dynamic import in `onMount`:**
158
-
159
- ```ts
160
- try {
161
- const mod = await import('./MyFeatureButton.svelte')
162
- MyFeatureButton = mod.default
163
- } catch {}
164
- ```
165
-
166
- **4. Rendering branch in template:**
167
-
168
- ```svelte
169
- {:else if menu.key === 'my-feature'}
170
- <MyFeatureButton config={menu} {basePath} tabindex={getTabindex(i)} />
27
+ packages/storyboard/src/core/tools/
28
+ ├── registry.js Core handler module map (core:* prefix)
29
+ ├── handlers/<name>.js One per built-in tool (flows, theme, )
30
+ └── surfaces/ Per-surface render orchestration
31
+
32
+ React renderers in core/ui/ (TriggerButton, ActionMenuButton, CommandMenu, …)
33
+ or the custom React component returned by the handler's component() export.
171
34
  ```
172
35
 
173
- ### Important: Data index timing
174
-
175
- The storyboard data index (`virtual:storyboard-data-index`) is seeded by the React app, which initializes *after* the Svelte CoreUIBar mounts. If your handler reads from the data index (flows, objects, records), the action registry handles this naturally — `getChildren()` is called lazily when the menu opens, not at registration time.
36
+ The handler module interface — `id`, `guard(ctx)`, `setup(ctx)`, `handler(ctx)`, `component()` — is documented in [`storyboard-tools/SKILL.md`](../storyboard-tools/SKILL.md#handler-module-interface) and is identical for core (`core:*`) and consumer (`custom:*`) handlers.
37
+
38
+ ## Adding a built-in tool to storyboard-core
39
+
40
+ 1. **Add the config entry** to `packages/storyboard/toolbar.config.json` under `tools`. See the [config reference](../storyboard-tools/SKILL.md#config-reference) for required fields.
41
+ 2. **Create the handler module** at `packages/storyboard/src/core/tools/handlers/<name>.js`. Export at minimum `id`. Add `guard`, `setup`, `handler`, and/or `component` as needed for the render type.
42
+ 3. **Register the lazy loader** in `packages/storyboard/src/core/tools/registry.js`:
43
+ ```js
44
+ export const coreHandlers = {
45
+ // …
46
+ 'my-tool': () => import('./handlers/myTool.js'),
47
+ }
48
+ ```
49
+ 4. **(Custom React component, optional)** If you need a custom component instead of the default `TriggerButton` / `ActionMenuButton` renderer, drop the `.jsx` file in `packages/storyboard/src/core/ui/` and lazy-import it from `component()`:
50
+ ```js
51
+ export async function component() {
52
+ const mod = await import('../../ui/MyToolButton.jsx')
53
+ return mod.default
54
+ }
55
+ ```
56
+
57
+ The CoreUIBar load loop (`packages/storyboard/src/core/ui/CoreUIBar.jsx` — search for `Object.entries(toolConfigs).map`) iterates **every** tool across every surface and resolves `core:` vs `custom:` via the `handler` field's prefix.
176
58
 
177
59
  ## Icon namespaces
178
60
 
179
- The `Icon.svelte` component supports multiple icon sources via namespaced names:
61
+ `packages/storyboard/src/core/ui/Icon.jsx` resolves multi-source icon names:
180
62
 
181
63
  | Prefix | Source | Style | Example |
182
64
  |--------|--------|-------|---------|
183
65
  | `primer/` | Primer Octicons | fill | `primer/repo`, `primer/gear`, `primer/comment` |
184
66
  | `feather/` | Feather Icons | stroke | `feather/fast-forward`, `feather/tablet` |
185
67
  | `iconoir/` | Iconoir (registered) | stroke | `iconoir/plus-circle`, `iconoir/square-dashed` |
186
- | *(none)* | Custom overrides | fill | `folder`, `folder-open` |
68
+ | *(none)* | Custom overrides | fill | `home`, `folder`, `folder-open`, `prototype`, `canvas` |
187
69
 
188
- Icon meta props: `strokeWeight`, `scale`, `rotate`, `flipX`, `offsetX`, `offsetY`.
70
+ Icon meta props (passed via tool config `meta`): `strokeWeight`, `scale`, `rotate`, `flipX`, `offsetX`, `offsetY`.
189
71
 
190
- ## Menu visibility
72
+ > Primer icon names must be verified against `.agents/data/primer-octicons.json` before use — agents frequently invent names that don't exist.
191
73
 
192
- Menus can be hidden via:
74
+ ## Tool visibility
193
75
 
194
- - **Mode filtering**: `"modes": ["inspect"]` only visible in inspect mode
195
- - **Route exclusion**: `"excludeRoutes": ["/viewfinder"]` — hidden on specific routes
196
- - **UI config**: `storyboard.config.json` → `ui.hide.menus: ["my-feature"]`
197
- - **Action menus**: auto-hide when `getChildren()` returns empty array
198
- - **Conditional logic**: custom visibility checks in the `visibleMenus` derived block
76
+ A tool may be hidden by any of the following:
199
77
 
200
- ## Existing menu buttons for reference
78
+ - **Mode filtering** `"modes": ["canvas"]` only shows the tool when the canvas mode is active. `["*"]` always.
79
+ - **Surface filtering** — `surface` controls *where* a tool can appear; tools in `command-palette` only show inside ⌘K, etc.
80
+ - **Route exclusion** — `"excludeRoutes": ["^/viewfinder"]` hides on regex-matching routes.
81
+ - **UI config** — `storyboard.config.json` → `ui.hide.menus: ["my-tool"]` (legacy key, still honored).
82
+ - **Tool state store** — `toolStateStore` can mark a tool `'hidden'` or `'disabled'` at runtime.
83
+ - **`guard()` returning `false`** — handler-level gate; skips `setup`/`handler`/`component` entirely.
84
+ - **`getChildren()` returning `[]`** — menu/submenu tools auto-hide when empty.
201
85
 
202
- | Key | Type | Config | Behavior |
203
- |-----|------|--------|----------|
204
- | `command` | custom | `trigger: "command"` | Always rightmost, command palette |
205
- | `create` | custom | `actions` + `feature` refs | Workshop feature launcher, overlay panels |
206
- | `flows` | action | `action: "core/flows"` | Lists prototype flows, radio-select to switch |
207
- | `comments` | custom | *(none)* | Auth-aware comments toggle |
208
- | `docs` | sidepanel | `sidepanel: "docs"` | Toggles docs side panel |
209
- | `inspector` | sidepanel | `sidepanel: "inspector"` | Toggles inspector side panel |
86
+ ## Built-in tools (selected reference)
87
+
88
+ A non-exhaustive list of currently shipped core tools — useful as code-reading entry points. See `packages/storyboard/toolbar.config.json` for the live list and `packages/storyboard/src/core/tools/handlers/` for the implementations.
89
+
90
+ | Tool key | Render | Surface | Handler module |
91
+ |----------|--------|---------|----------------|
92
+ | `command-palette` | `button` | `command-toolbar` | `handlers/commandPalette.js` |
93
+ | `flows` | `menu` | `command-toolbar` | `handlers/flows.js` |
94
+ | `theme` | `menu` | `command-toolbar` | `handlers/theme.js` |
95
+ | `create` | `menu` | `command-toolbar` | `handlers/create.js` |
96
+ | `comments` | `button` | `command-toolbar` | `handlers/comments.js` |
97
+ | `hide-chrome` | `button` | `command-toolbar` | `handlers/hideChrome.js` |
98
+ | `inspector` | `sidepanel` | `command-toolbar` | `handlers/inspector.js` |
99
+ | `devtools` | `submenu` | `command-palette` | `handlers/devtools.js` |
100
+ | `canvas-zoom` | `zoom-control` | `canvas-toolbar` | `handlers/canvasToolbar.js` |
210
101
 
211
102
  ## Optional & Heavy Dependencies in Published Packages
212
103
 
@@ -247,3 +138,4 @@ function loadOptionalDep() {
247
138
  const mod = await loadOptionalDep()
248
139
  if (!mod) return // graceful degradation
249
140
  ```
141
+
@@ -0,0 +1,172 @@
1
+ ---
2
+ name: storyboard-widget
3
+ description: Guide for creating custom canvas widgets — register a React component as a new widget type with full control over chrome (decorated or invisible), interaction switches (selectable / movable / resizable / expandable / split-screen / interact-gate), connector anchors, toolbar features, and prop schemas. Use when asked to create a custom widget, register a widget, add a new canvas widget type, build an invisible/locked/pinned widget, expose a component as a widget, or override a core widget.
4
+ metadata:
5
+ author: Daniel Fosco
6
+ version: "2026.6.6"
7
+ ---
8
+
9
+ # Skill: storyboard-widget — Custom Canvas Widget Creation
10
+
11
+ Creating a new canvas widget type means registering a React component plus a small metadata definition with the storyboard widget registry. The same configuration surface drives the built-in core widgets (sticky-note, image, terminal, etc.) — so anything they can do, your widget can do too.
12
+
13
+ > **Authoritative reference:** [`DOCS/custom-widgets.md` on GitHub](https://github.com/dfosco/storyboard/blob/main/DOCS/custom-widgets.md). When in doubt, fetch that doc and follow it.
14
+
15
+ ## Triggers
16
+
17
+ - "create a custom widget", "register a widget", "add a new canvas widget type"
18
+ - "expose this component as a widget", "make X a canvas widget"
19
+ - "build an invisible widget", "build a locked / pinned / decorative widget"
20
+ - "override the sticky note / image / terminal widget"
21
+ - "add custom toolbar actions to a widget"
22
+ - Any prompt mentioning `mountStoryboardCore({ widgets })`, `registerWidget`, or `chrome.enabled`
23
+
24
+ ---
25
+
26
+ ## Decision flow — collect these inputs before writing code
27
+
28
+ Ask the user (one question per turn — use `ask_user`) until you have all of:
29
+
30
+ 1. **Widget `type` string** — kebab-case, e.g. `kpi-tile`, `code-runner`. This goes into the canvas JSONL and never changes after the first widget instance is saved.
31
+ 2. **Display label + icon** — what appears in the `+ Add widget` menu (skip if the widget should be hidden via `unlisted: true`).
32
+ 3. **Chrome decision** — three choices:
33
+ - **Decorated (default)** — wrap in standard WidgetChrome (hover toolbar, anchor ports, select handle, optional gate). Use when the widget should feel like a native storyboard widget.
34
+ - **Invisible (`chrome.enabled: false`)** — no built-in chrome. Component owns 100 % of the rendered surface. Use for landing-page decorations, brand chrome, fully custom interaction surfaces.
35
+ - **Decorated but locked** (`chrome.enabled: true` + `interaction.movable: false` and/or `interaction.selectable: false`) — keep the chrome for actions but pin in place / hide select handle.
36
+ 4. **Interaction switches** — confirm defaults or override:
37
+ - `selectable` (default `true`)
38
+ - `movable` (default `true`)
39
+ - `resize` (default disabled — pass `{ enabled: true, prod: false }` to enable dev-only resize, `prod: true` for prod too)
40
+ - `expandable` (default `false`)
41
+ - `splitScreen` (default `false`)
42
+ - `interactGate` (default `false`) — when `true`, overlays "Click to interact" until clicked. Use for embedded iframes, third-party components, anything that swallows pointer events you don't want stealing canvas pan/zoom.
43
+ 5. **Connectors** — accept all (`accept: ['*']`) or restrict to specific types? Any anchors that should be `disabled` or `unavailable`?
44
+ 6. **Toolbar features** — most widgets only need `copy` + `delete` in the overflow menu. Skip for invisible widgets that own their own UI.
45
+ 7. **Props schema** — at minimum `width` and `height` (used by the autoplacement engine for default size). Any other persisted state (`text`, `url`, `variant`, etc.) goes here.
46
+ 8. **Where to register** — almost always `mountStoryboardCore({ widgets })` in the consumer's main entry. If the user wants per-canvas dynamic registration, use `registerWidget()` instead.
47
+
48
+ If the user gives you incomplete info, fill in safe defaults and tell them.
49
+
50
+ ---
51
+
52
+ ## Implementation steps
53
+
54
+ ### 1. Create the component file
55
+
56
+ `src/widgets/<WidgetName>/<WidgetName>.jsx` (follow the project convention — components in their own directory). Use CSS modules for styles.
57
+
58
+ The component receives these props by the contract:
59
+
60
+ | Prop | Type |
61
+ |---|---|
62
+ | `id` | `string` — stable widget id |
63
+ | `props` | `object` — current persisted props (matches your `props` schema) |
64
+ | `onUpdate(updates)` | `(object) => void` — patch persisted props |
65
+ | `resizable` | `boolean` |
66
+ | `selected` | `boolean` |
67
+ | `onSelect(shiftKey?)` | `(boolean) => void` |
68
+ | `multiSelected` | `boolean` |
69
+
70
+ **For invisible widgets (`chrome.enabled: false`):** render your own select affordance when `selected` is true. Otherwise the user can't tell the widget is selected.
71
+
72
+ **For widgets with custom toolbar features:** wrap the component in `forwardRef` + `useImperativeHandle(ref, () => ({ handleAction(actionId) { ... } }))`. Return `false` to fall through to the default canvas handler; return anything else (or undefined) to claim the action.
73
+
74
+ ### 2. Register at mount
75
+
76
+ In the consumer's entry (typically `src/main.jsx` or a `_app.jsx` file):
77
+
78
+ ```js
79
+ import { mountStoryboardCore } from '@dfosco/storyboard'
80
+ import storyboardConfig from './storyboard.config.json'
81
+ import MyWidget from './widgets/MyWidget/MyWidget'
82
+
83
+ mountStoryboardCore(storyboardConfig, {
84
+ basePath: import.meta.env.BASE_URL || '/',
85
+ widgets: {
86
+ 'my-widget': {
87
+ component: MyWidget,
88
+ label: 'My Widget',
89
+ icon: 'sparkle-fill',
90
+ chrome: { enabled: true },
91
+ interaction: { selectable: true, movable: true },
92
+ connectors: { anchors: { top: 'available', bottom: 'available', left: 'available', right: 'available' }, accept: ['*'], exclude: [] },
93
+ features: [
94
+ { id: 'copy', type: 'action', action: 'copy', label: 'Duplicate', icon: 'duplicate', menu: true, prod: true },
95
+ { id: 'delete', type: 'action', action: 'delete', label: 'Delete', icon: 'trash', menu: true, prod: true },
96
+ ],
97
+ props: {
98
+ width: { type: 'number', label: 'Width', default: 360 },
99
+ height: { type: 'number', label: 'Height', default: 240 },
100
+ },
101
+ },
102
+ },
103
+ })
104
+ ```
105
+
106
+ ### 3. Register server-side metadata in `storyboard.config.json`
107
+
108
+ So autoplacement (the `+ Add widget` flow) and collision detection use the right default size:
109
+
110
+ ```json
111
+ {
112
+ "widgets": {
113
+ "my-widget": {
114
+ "label": "My Widget",
115
+ "props": {
116
+ "width": { "type": "number", "default": 360 },
117
+ "height": { "type": "number", "default": 240 }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ **Important:** the browser-side and server-side metadata must agree on default sizes. The server side does not load React components — it only needs the JSON-safe metadata.
125
+
126
+ ### 4. Verify icon exists
127
+
128
+ Before picking an `icon` value, check that the name exists in `.agents/data/primer-octicons.json` (the authoritative list). Storyboard uses the Primer Octicons set. If your preferred icon doesn't exist, pick the closest available — never guess.
129
+
130
+ ### 5. (Optional) Test the widget
131
+
132
+ If the consumer wants tests, add `MyWidget.test.jsx` next to the component using Vitest. Focus on:
133
+
134
+ - Renders without crashing given minimal props
135
+ - `onUpdate` is called with the right patch when user interacts
136
+ - `selected` state triggers any custom selection affordance (for chrome-disabled widgets)
137
+
138
+ ---
139
+
140
+ ## Recipe table — pick the right configuration
141
+
142
+ | Use case | `chrome.enabled` | `interaction.movable` | `interaction.selectable` | Notes |
143
+ |---|---|---|---|---|
144
+ | Standard widget (markdown-like) | `true` | `true` | `true` | Default — same as core widgets |
145
+ | Resizable rich widget | `true` | `true` | `true` | Add `interaction.resize: { enabled: true, prod: false }` |
146
+ | Iframe / embed | `true` | `true` | `true` | Add `interaction.interactGate: true` to prevent canvas scroll from being swallowed |
147
+ | Hero canvas decoration | `false` | `false` | `false` | Fully custom, no interaction. Combine with `unlisted: true` to hide from menu. |
148
+ | Background sticker (selectable to delete) | `false` | `false` | `true` | Render your own selection ring inside the component |
149
+ | Floating accent (custom UI, draggable) | `false` | `true` | `true` | Component handles its visual chrome; canvas handles drag |
150
+ | Override a core widget (e.g. sticky-note) | match core | match core | match core | **Replaces** the entire built-in definition — re-declare all features/anchors/props if you want them |
151
+
152
+ ---
153
+
154
+ ## Common pitfalls
155
+
156
+ - **Forgetting server-side metadata** → `+ Add widget` places the widget at the generic 270 × 170 default, not your intended size. Always add the same `widgets.<type>` block to `storyboard.config.json`.
157
+ - **`chrome.enabled: false` without rendering a select affordance** → users can't tell the widget is selected before deleting. Render an outline / ring / handle when `selected` is true.
158
+ - **`movable: false` without `selectable: false`** → user can still select and delete the "locked" widget. That's usually what you want (so they can clean up). Pair both to `false` only for truly immutable decoration.
159
+ - **Overriding a core widget partially** → consumer registrations REPLACE the entire core entry. Copy the baseline from `packages/storyboard/widgets.config.json` first if you want to tweak just one field.
160
+ - **Using the wrong icon name** → check `.agents/data/primer-octicons.json` first. Invented icon names crash the page at runtime.
161
+ - **Not stamping `width`/`height` defaults in the props schema** → collision detection treats unknown widgets as 270 × 170 fallback.
162
+ - **Naming the type with uppercase/spaces** → use kebab-case strings only. The type is a stable identifier persisted in canvas JSONL.
163
+
164
+ ---
165
+
166
+ ## Reference
167
+
168
+ - Full API reference + TypeScript-flavored type: [`DOCS/custom-widgets.md`](https://github.com/dfosco/storyboard/blob/main/DOCS/custom-widgets.md)
169
+ - Live examples of every feature shape, connector config, prop schema: `node_modules/@dfosco/storyboard/widgets.config.json`
170
+ - Browser-side registry source: `node_modules/@dfosco/storyboard/src/core/stores/widgetRegistry.js`
171
+ - Server-side registry source: `node_modules/@dfosco/storyboard/src/core/canvas/customWidgets.js`
172
+ - Chrome wrapper (consumes per-widget switches): `node_modules/@dfosco/storyboard/src/internals/canvas/widgets/WidgetChrome.jsx`
@@ -65,11 +65,11 @@ export async function handler(ctx) {
65
65
 
66
66
  ### 3. (Optional) Add a custom component
67
67
 
68
- If the default rendering for your render type isn't enough, export a `component()` function:
68
+ If the default rendering for your render type isn't enough, export a `component()` function that returns a React component:
69
69
 
70
70
  ```js
71
71
  export async function component() {
72
- const mod = await import('../../MyToolButton.svelte')
72
+ const mod = await import('../../MyToolButton.jsx')
73
73
  return mod.default
74
74
  }
75
75
  ```
@@ -241,13 +241,13 @@ export async function handler(ctx) {
241
241
  }
242
242
  ```
243
243
 
244
- #### `component()` → `Promise<SvelteComponent>`
244
+ #### `component()` → `Promise<ReactComponent>`
245
245
 
246
- Optional. Returns a Svelte component to render the tool. If not provided, the surface uses its default renderer (e.g., `TriggerButton` for sidepanels, `ActionMenuButton` for menus).
246
+ Optional. Returns a React component to render the tool. If not provided, the surface uses its default renderer (e.g., `TriggerButton` for sidepanels, `ActionMenuButton` for menus — both shipped with storyboard-core).
247
247
 
248
248
  ```js
249
249
  export async function component() {
250
- const mod = await import('../../MyToolButton.svelte')
250
+ const mod = await import('../../MyToolButton.jsx')
251
251
  return mod.default
252
252
  }
253
253
  ```
@@ -379,7 +379,7 @@ A specialized compound control for canvas zoom (zoom in, zoom out, reset, curren
379
379
 
380
380
  - **Surface:** `canvas-toolbar`
381
381
  - **Handler return:** `{ zoomIn(zoom), zoomOut(zoom), zoomReset(), ZOOM_MIN, ZOOM_MAX }` (min/max read from `canvas.zoom` config)
382
- - **Component:** Custom Svelte component required
382
+ - **Component:** Custom React component required
383
383
 
384
384
  ### `link`
385
385
 
@@ -472,7 +472,7 @@ export async function guard() {
472
472
  }
473
473
 
474
474
  export async function component() {
475
- const mod = await import('../../components/CursorPresence.svelte')
475
+ const mod = await import('../../components/CursorPresence.jsx')
476
476
  return mod.default
477
477
  }
478
478
  ```
@@ -584,7 +584,7 @@ export async function handler(ctx) {
584
584
  }
585
585
 
586
586
  export async function component() {
587
- const mod = await import('../../ActionMenuButton.svelte')
587
+ const mod = await import('../../ActionMenuButton.jsx')
588
588
  return mod.default
589
589
  }
590
590
  ```
@@ -674,7 +674,7 @@ export async function handler() {
674
674
  }
675
675
 
676
676
  export async function component() {
677
- const mod = await import('../../CanvasZoomControl.svelte')
677
+ const mod = await import('../../CanvasZoomControl.jsx')
678
678
  return mod.default
679
679
  }
680
680
  ```
@@ -776,7 +776,7 @@ export async function guard() {
776
776
  }
777
777
 
778
778
  export async function component() {
779
- const mod = await import('../../CommentsMenuButton.svelte')
779
+ const mod = await import('../../CommentsMenuButton.jsx')
780
780
  return mod.default
781
781
  }
782
782
  ```
@@ -830,7 +830,7 @@ export async function guard(ctx) {
830
830
  }
831
831
 
832
832
  export async function component() {
833
- const mod = await import('../../CreateMenuButton.svelte')
833
+ const mod = await import('../../CreateMenuButton.jsx')
834
834
  return mod.default
835
835
  }
836
836
  ```
@@ -897,7 +897,7 @@ The tool system follows this sequence at startup:
897
897
 
898
898
  8. COMPONENT RESOLUTION
899
899
  If the handler exports component(), it is called to lazy-load
900
- the Svelte component. If not exported, the surface uses its
900
+ the React component. If not exported, the surface uses its
901
901
  default component for the render type.
902
902
 
903
903
  9. SURFACE RENDER
@@ -3,9 +3,13 @@
3
3
  *
4
4
  * When placing or moving widgets, this module checks for overlaps with
5
5
  * existing widgets and adjusts the position until no collisions remain.
6
+ *
7
+ * Widget default sizes are resolved from the merged widget registry
8
+ * (consumer-registered via `storyboard.config.json.widgets` over built-in
9
+ * `widgets.config.json`) so custom widget types autoplace correctly.
6
10
  */
7
11
 
8
- import widgetsConfig from '../../../widgets.config.json' with { type: 'json' }
12
+ import { getServerWidgetDefinition, getAllServerWidgetDefinitions } from './customWidgets.js'
9
13
 
10
14
  const FALLBACK_SIZE = { width: 270, height: 170 }
11
15
 
@@ -20,32 +24,50 @@ const HARDCODED_DEFAULTS = {
20
24
  'story': { width: 780, height: 420 },
21
25
  }
22
26
 
27
+ function resolveDefaultSize(type, def) {
28
+ const props = def?.props || {}
29
+ const hardcoded = HARDCODED_DEFAULTS[type]
30
+ const w = props.width?.default ?? hardcoded?.width ?? FALLBACK_SIZE.width
31
+ const h = props.height?.default ?? hardcoded?.height ?? FALLBACK_SIZE.height
32
+ return { width: w, height: h }
33
+ }
34
+
23
35
  /**
24
- * Default widget sizes derived from widgets.config.json prop defaults,
25
- * with hardcoded fallbacks for types that don't specify both width+height.
36
+ * Snapshot of default sizes for all known widget types. Re-resolved on
37
+ * every access so consumer-registered widget sizes are reflected without
38
+ * a server restart.
26
39
  */
27
- export const DEFAULT_SIZES = buildDefaultSizes()
28
-
29
- function buildDefaultSizes() {
30
- const sizes = {}
31
- const widgets = widgetsConfig.widgets || {}
32
- for (const [type, config] of Object.entries(widgets)) {
33
- const props = config.props || {}
34
- const hardcoded = HARDCODED_DEFAULTS[type]
35
- const w = props.width?.default ?? hardcoded?.width ?? FALLBACK_SIZE.width
36
- const h = props.height?.default ?? hardcoded?.height ?? FALLBACK_SIZE.height
37
- sizes[type] = { width: w, height: h }
38
- }
39
- return sizes
40
- }
40
+ export const DEFAULT_SIZES = new Proxy({}, {
41
+ get(_, type) {
42
+ if (typeof type !== 'string') return undefined
43
+ const def = getServerWidgetDefinition(type)
44
+ if (!def) return undefined
45
+ return resolveDefaultSize(type, def)
46
+ },
47
+ has(_, type) {
48
+ return typeof type === 'string' && !!getServerWidgetDefinition(type)
49
+ },
50
+ ownKeys() {
51
+ return Object.keys(getAllServerWidgetDefinitions())
52
+ },
53
+ getOwnPropertyDescriptor(_, type) {
54
+ if (typeof type !== 'string') return undefined
55
+ const def = getServerWidgetDefinition(type)
56
+ if (!def) return undefined
57
+ return { enumerable: true, configurable: true, value: resolveDefaultSize(type, def) }
58
+ },
59
+ })
41
60
 
42
61
  /**
43
- * Get the default size for a widget type from widgets.config.json.
62
+ * Get the default size for a widget type. Resolves from the merged registry
63
+ * (consumer-registered widgets override built-ins).
44
64
  * @param {string} type - Widget type
45
65
  * @returns {{ width: number, height: number }}
46
66
  */
47
67
  export function getDefaultSize(type) {
48
- return DEFAULT_SIZES[type] || FALLBACK_SIZE
68
+ const def = getServerWidgetDefinition(type)
69
+ if (!def) return FALLBACK_SIZE
70
+ return resolveDefaultSize(type, def)
49
71
  }
50
72
 
51
73
  /**