@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
|
@@ -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/
|
|
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
|
|
23
|
+
toolbar.config.json ← Tool declarations
|
|
22
24
|
↓
|
|
23
|
-
CoreUIBar.
|
|
25
|
+
packages/storyboard/src/core/ui/CoreUIBar.jsx ← React; reads config, mounts tools
|
|
24
26
|
↓
|
|
25
|
-
|
|
26
|
-
├──
|
|
27
|
-
├──
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
## Tool visibility
|
|
193
75
|
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
|
207
|
-
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
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.
|
|
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<
|
|
244
|
+
#### `component()` → `Promise<ReactComponent>`
|
|
245
245
|
|
|
246
|
-
Optional. Returns a
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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 =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
68
|
+
const def = getServerWidgetDefinition(type)
|
|
69
|
+
if (!def) return FALLBACK_SIZE
|
|
70
|
+
return resolveDefaultSize(type, def)
|
|
49
71
|
}
|
|
50
72
|
|
|
51
73
|
/**
|