@dfosco/storyboard-core 4.1.0 → 4.2.0-alpha.10

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.
@@ -12,6 +12,12 @@
12
12
  { "id": "hide-toolbars", "label": "Hide Toolbars" }
13
13
  ]
14
14
  },
15
+ { "id": "sep0", "items": [] },
16
+ {
17
+ "id": "starred",
18
+ "title": "Starred",
19
+ "source": "starred"
20
+ },
15
21
  { "id": "sep1", "items": [] },
16
22
  {
17
23
  "id": "tools2",
@@ -31,6 +37,12 @@
31
37
  "title": "Create new",
32
38
  "source": "create"
33
39
  },
40
+ { "id": "sep3b", "items": [] },
41
+ {
42
+ "id": "create-widget",
43
+ "title": "Add widget to canvas",
44
+ "source": "create-widget"
45
+ },
34
46
  { "id": "sep3", "items": [] },
35
47
  {
36
48
  "id": "prototypes",
@@ -46,7 +58,7 @@
46
58
  { "id": "sep5", "items": [] },
47
59
  {
48
60
  "id": "stories",
49
- "title": "Stories",
61
+ "title": "Components",
50
62
  "source": "stories"
51
63
  },
52
64
  { "id": "sep6", "items": [] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "4.1.0",
3
+ "version": "4.2.0-alpha.10",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -66,7 +66,8 @@
66
66
  "highlight.js": "^11.11.1",
67
67
  "html-to-image": "^1.11.13",
68
68
  "iconoir": "^7.11.0",
69
- "jsonc-parser": "^3.3.1"
69
+ "jsonc-parser": "^3.3.1",
70
+ "ws": "^8.0.0"
70
71
  },
71
72
  "devDependencies": {
72
73
  "@lucide/svelte": "^1.7.0",
@@ -83,5 +84,8 @@
83
84
  "tailwindcss": "^4.2.2",
84
85
  "tw-animate-css": "^1.4.0",
85
86
  "vite": "^6.0.0"
87
+ },
88
+ "optionalDependencies": {
89
+ "node-pty": "^1.0.0"
86
90
  }
87
- }
91
+ }
@@ -0,0 +1,144 @@
1
+ ---
2
+ name: terminal-agent
3
+ description: "Canvas-aware terminal agent that reads connected widget context and signals completion via the storyboard API."
4
+ tools:
5
+ - read
6
+ - edit
7
+ - shell
8
+ - search
9
+ ---
10
+
11
+ # Terminal Agent Context
12
+
13
+ Before processing ANY user prompt, read the terminal config file for this session.
14
+
15
+ ## Step 1: Read terminal config
16
+
17
+ Your widget ID is available via `$STORYBOARD_WIDGET_ID`. Use it to read your config directly:
18
+ ```bash
19
+ cat .storyboard/terminals/${STORYBOARD_WIDGET_ID}.json
20
+ ```
21
+
22
+ If the env var is empty, source it from the terminal env file first:
23
+ ```bash
24
+ # Find the env file for this tmux session
25
+ ENV_FILE=$(ls -t .storyboard/terminals/*.env 2>/dev/null | head -1)
26
+ if [ -n "$ENV_FILE" ]; then source "$ENV_FILE"; fi
27
+ cat .storyboard/terminals/${STORYBOARD_WIDGET_ID}.json
28
+ ```
29
+
30
+ As a last resort, list all configs and pick the most recent non-deleted one with `connectedWidgets`:
31
+ ```bash
32
+ cat .storyboard/terminals/*.json
33
+ ```
34
+
35
+ The config file contains everything you need — no additional API calls required:
36
+
37
+ ```json
38
+ {
39
+ "widgetId": "terminal-abc123",
40
+ "canvasId": "storyboarding/my-canvas",
41
+ "branch": "4.2.0--terminal-agents",
42
+ "worktree": "4.2.0--terminal-agents",
43
+ "devDomain": "storyboard-core",
44
+ "serverUrl": "http://localhost:1269",
45
+ "workingDirectory": "/path/to/worktree",
46
+ "connectedWidgets": [
47
+ {
48
+ "id": "sticky-def456",
49
+ "type": "sticky-note",
50
+ "props": { "text": "Build a login form", "color": "yellow" }
51
+ },
52
+ {
53
+ "id": "markdown-ghi789",
54
+ "type": "markdown",
55
+ "props": { "content": "# Requirements\n- Email + password\n- OAuth support" }
56
+ }
57
+ ]
58
+ }
59
+ ```
60
+
61
+ ## Step 2: Use connected widget context
62
+
63
+ The `connectedWidgets` array contains the FULL props of every widget connected to this terminal. This is your highest priority context:
64
+
65
+ - **sticky-note**: `props.text` — instructions, notes, or requirements
66
+ - **markdown**: `props.content` — documentation, specs, or prose
67
+ - **image**: `props.src` — image filename at `assets/canvas/images/{props.src}`
68
+ - **story**: `props.storyId` + `props.exportName` — component to work with
69
+ - **link-preview**: `props.url` — external reference
70
+ - **prototype**: `props.src` — prototype path
71
+
72
+ Interpret the user's prompt in light of these connected widgets.
73
+
74
+ ## Step 3: Prefer CLI commands for canvas operations
75
+
76
+ **Always prefer `npx storyboard` CLI commands over HTTP API calls.** CLI commands run directly in the worktree and resolve the dev server automatically — no port numbers or URLs needed.
77
+
78
+ ### Reading canvas state
79
+ ```bash
80
+ npx storyboard canvas read <canvas-name> --json
81
+ npx storyboard canvas read <canvas-name> --id <widget-id> --json
82
+ ```
83
+
84
+ ### Updating a widget
85
+ ```bash
86
+ # Update text on a sticky note
87
+ npx storyboard canvas update <widget-id> --canvas <canvas-name> --text "New text"
88
+
89
+ # Update markdown content
90
+ npx storyboard canvas update <widget-id> --canvas <canvas-name> --content "# New heading"
91
+
92
+ # Update arbitrary props
93
+ npx storyboard canvas update <widget-id> --canvas <canvas-name> --props '{"key":"value"}'
94
+
95
+ # Move a widget
96
+ npx storyboard canvas update <widget-id> --canvas <canvas-name> --x 100 --y 200
97
+
98
+ # Shorthand flags: --text, --content, --src, --url, --color
99
+ ```
100
+
101
+ ### Adding a widget
102
+ ```bash
103
+ npx storyboard canvas add sticky-note --canvas <canvas-name> --props '{"text":"Hello"}'
104
+ npx storyboard canvas add markdown --canvas <canvas-name> --x 100 --y 200
105
+ ```
106
+
107
+ **Why CLI over API:** The CLI resolves the correct dev server port automatically via the Caddy proxy or ports.json. You never need to know the port number. All commands work from any worktree directory.
108
+
109
+ ## Step 4: Signal completion
110
+
111
+ When your task is complete:
112
+ ```bash
113
+ npx storyboard agent signal --status done --message "Brief summary"
114
+ ```
115
+
116
+ On failure:
117
+ ```bash
118
+ npx storyboard agent signal --status error --message "What went wrong"
119
+ ```
120
+
121
+ **IMPORTANT:**
122
+ - NEVER write directly to `.canvas.jsonl` files — use the canvas CLI or server API
123
+ - **Prefer CLI commands** (`npx storyboard canvas ...`) over direct HTTP calls — they resolve ports automatically
124
+ - Only fall back to HTTP API (`{serverUrl}/_storyboard/canvas/`) if the CLI doesn't support the operation
125
+ - Environment variables `$STORYBOARD_WIDGET_ID`, `$STORYBOARD_CANVAS_ID`, `$STORYBOARD_BRANCH`, `$STORYBOARD_SERVER_URL` are also available in the shell
126
+
127
+ ## HTTP API Reference (fallback only)
128
+
129
+ If the CLI fails, use these endpoints. The `serverUrl` is in your terminal config or `$STORYBOARD_SERVER_URL`.
130
+
131
+ ### Safe: Update a single widget (PATCH)
132
+ ```bash
133
+ curl -s -X PATCH "${STORYBOARD_SERVER_URL}/_storyboard/canvas/widget" \
134
+ -H "Content-Type: application/json" \
135
+ -d '{"name":"<canvasId>","widgetId":"<widgetId>","props":{"text":"new value"}}'
136
+ ```
137
+
138
+ ### Safe: Read canvas state (GET)
139
+ ```bash
140
+ curl -s "${STORYBOARD_SERVER_URL}/_storyboard/canvas/<canvasId>"
141
+ ```
142
+
143
+ ### ⚠️ NEVER use `PUT /_storyboard/canvas/update` with a `widgets` array
144
+ That endpoint **replaces ALL widgets** in the canvas. Sending one widget = deleting everything else.
@@ -5,17 +5,47 @@
5
5
  * existing widgets and adjusts the position until no collisions remain.
6
6
  */
7
7
 
8
+ import widgetsConfig from '../../widgets.config.json' with { type: 'json' }
9
+
10
+ const FALLBACK_SIZE = { width: 270, height: 170 }
11
+
8
12
  /**
9
- * Default widget sizes by type (from widgets.config.json).
13
+ * Hardcoded fallbacks for widget types that don't specify defaults in config.
10
14
  */
11
- export const DEFAULT_SIZES = {
12
- 'sticky-note': { width: 270, height: 170 },
15
+ const HARDCODED_DEFAULTS = {
13
16
  'markdown': { width: 530, height: 240 },
14
- 'prototype': { width: 800, height: 600 },
15
- 'figma-embed': { width: 800, height: 450 },
16
17
  'image': { width: 400, height: 300 },
17
- 'link-preview': { width: 400, height: 200 },
18
+ 'link-preview': { width: 320, height: 200 },
18
19
  'component': { width: 300, height: 200 },
20
+ 'story': { width: 780, height: 420 },
21
+ }
22
+
23
+ /**
24
+ * Default widget sizes derived from widgets.config.json prop defaults,
25
+ * with hardcoded fallbacks for types that don't specify both width+height.
26
+ */
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
+ }
41
+
42
+ /**
43
+ * Get the default size for a widget type from widgets.config.json.
44
+ * @param {string} type - Widget type
45
+ * @returns {{ width: number, height: number }}
46
+ */
47
+ export function getDefaultSize(type) {
48
+ return DEFAULT_SIZES[type] || FALLBACK_SIZE
19
49
  }
20
50
 
21
51
  /**
@@ -25,7 +55,7 @@ export const DEFAULT_SIZES = {
25
55
  */
26
56
  export function getWidgetBounds(widget) {
27
57
  const { position = { x: 0, y: 0 }, props = {}, type } = widget
28
- const defaults = DEFAULT_SIZES[type] || { width: 270, height: 170 }
58
+ const defaults = getDefaultSize(type)
29
59
  return {
30
60
  x: position.x,
31
61
  y: position.y,
@@ -34,6 +64,42 @@ export function getWidgetBounds(widget) {
34
64
  }
35
65
  }
36
66
 
67
+ /**
68
+ * Compute persistent bounds metadata for a widget.
69
+ * Returns { width, height, startX, startY, endX, endY }.
70
+ * @param {object} widget - Widget with position, props, and type
71
+ * @returns {{ width: number, height: number, startX: number, startY: number, endX: number, endY: number }}
72
+ */
73
+ export function computeWidgetBounds(widget) {
74
+ const { x, y, width, height } = getWidgetBounds(widget)
75
+ return {
76
+ width,
77
+ height,
78
+ startX: x,
79
+ startY: y,
80
+ endX: x + width,
81
+ endY: y + height,
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Stamp bounds metadata onto a widget, returning a new widget object.
87
+ * @param {object} widget
88
+ * @returns {object} Widget with `bounds` field
89
+ */
90
+ export function stampBounds(widget) {
91
+ return { ...widget, bounds: computeWidgetBounds(widget) }
92
+ }
93
+
94
+ /**
95
+ * Stamp bounds on every widget in an array.
96
+ * @param {object[]} widgets
97
+ * @returns {object[]}
98
+ */
99
+ export function stampBoundsAll(widgets) {
100
+ return widgets.map(stampBounds)
101
+ }
102
+
37
103
  /**
38
104
  * Check if two rectangles overlap.
39
105
  * @param {{ x: number, y: number, width: number, height: number }} a
@@ -54,17 +120,31 @@ export function rectsOverlap(a, b) {
54
120
  * @param {{ x: number, y: number, width: number, height: number }} rect - Proposed bounds
55
121
  * @param {object[]} widgets - Existing widgets array
56
122
  * @param {string} [excludeId] - Widget ID to exclude (for move operations)
57
- * @returns {object|null} - The first colliding widget, or null if no collision
123
+ * @returns {object[]} - All colliding widgets (empty array if none)
58
124
  */
59
- export function findCollision(rect, widgets, excludeId = null) {
125
+ export function findCollisions(rect, widgets, excludeId = null) {
126
+ const colliders = []
60
127
  for (const widget of widgets) {
61
128
  if (excludeId && widget.id === excludeId) continue
62
129
  const bounds = getWidgetBounds(widget)
63
130
  if (rectsOverlap(rect, bounds)) {
64
- return widget
131
+ colliders.push(widget)
65
132
  }
66
133
  }
67
- return null
134
+ return colliders
135
+ }
136
+
137
+ /**
138
+ * Check if a proposed position collides with any existing widget.
139
+ * Returns the first colliding widget (for backwards compatibility).
140
+ * @param {{ x: number, y: number, width: number, height: number }} rect - Proposed bounds
141
+ * @param {object[]} widgets - Existing widgets array
142
+ * @param {string} [excludeId] - Widget ID to exclude (for move operations)
143
+ * @returns {object|null} - The first colliding widget, or null if no collision
144
+ */
145
+ export function findCollision(rect, widgets, excludeId = null) {
146
+ const colliders = findCollisions(rect, widgets, excludeId)
147
+ return colliders.length > 0 ? colliders[0] : null
68
148
  }
69
149
 
70
150
  /**
@@ -82,9 +162,10 @@ export function snapToGrid(value, gridSize) {
82
162
  *
83
163
  * Strategy:
84
164
  * 1. Try the initial position
85
- * 2. If collision, move right by (collider.width + gap)
86
- * 3. If still colliding after maxIterations, try moving down instead
87
- * 4. Snap final position to grid
165
+ * 2. If collision, find the max endX among all colliders and move past it
166
+ * 3. Repeat until no collisions or maxIterations exhausted
167
+ * 4. If horizontal resolution exhausted, fall back to moving down
168
+ * 5. Snap final position to grid
88
169
  *
89
170
  * @param {object} options
90
171
  * @param {number} options.x - Initial X position
@@ -113,15 +194,13 @@ export function findFreePosition({
113
194
  let currentX = x
114
195
  let currentY = y
115
196
  let adjusted = false
116
- let iteration = 0
117
197
 
118
- // Phase 1: Try moving right
119
- while (iteration < maxIterations) {
198
+ // Phase 1: Try moving right past all colliders
199
+ for (let i = 0; i < maxIterations; i++) {
120
200
  const rect = { x: currentX, y: currentY, width, height }
121
- const collider = findCollision(rect, widgets, excludeId)
201
+ const colliders = findCollisions(rect, widgets, excludeId)
122
202
 
123
- if (!collider) {
124
- // No collision — snap and return
203
+ if (colliders.length === 0) {
125
204
  return {
126
205
  x: snapToGrid(currentX, gridSize),
127
206
  y: snapToGrid(currentY, gridSize),
@@ -129,22 +208,25 @@ export function findFreePosition({
129
208
  }
130
209
  }
131
210
 
132
- // Move right past the collider
133
- const colliderBounds = getWidgetBounds(collider)
134
- currentX = colliderBounds.x + colliderBounds.width + spacing
211
+ // Jump past the rightmost edge of all colliders
212
+ let maxEndX = 0
213
+ for (const c of colliders) {
214
+ const b = getWidgetBounds(c)
215
+ const endX = b.x + b.width
216
+ if (endX > maxEndX) maxEndX = endX
217
+ }
218
+ currentX = maxEndX + spacing
135
219
  adjusted = true
136
- iteration++
137
220
  }
138
221
 
139
222
  // Phase 2: Reset X, try moving down
140
223
  currentX = x
141
- iteration = 0
142
224
 
143
- while (iteration < maxIterations) {
225
+ for (let i = 0; i < maxIterations; i++) {
144
226
  const rect = { x: currentX, y: currentY, width, height }
145
- const collider = findCollision(rect, widgets, excludeId)
227
+ const colliders = findCollisions(rect, widgets, excludeId)
146
228
 
147
- if (!collider) {
229
+ if (colliders.length === 0) {
148
230
  return {
149
231
  x: snapToGrid(currentX, gridSize),
150
232
  y: snapToGrid(currentY, gridSize),
@@ -152,11 +234,15 @@ export function findFreePosition({
152
234
  }
153
235
  }
154
236
 
155
- // Move down past the collider
156
- const colliderBounds = getWidgetBounds(collider)
157
- currentY = colliderBounds.y + colliderBounds.height + spacing
237
+ // Jump past the bottommost edge of all colliders
238
+ let maxEndY = 0
239
+ for (const c of colliders) {
240
+ const b = getWidgetBounds(c)
241
+ const endY = b.y + b.height
242
+ if (endY > maxEndY) maxEndY = endY
243
+ }
244
+ currentY = maxEndY + spacing
158
245
  adjusted = true
159
- iteration++
160
246
  }
161
247
 
162
248
  // Fallback: return the last attempted position (snapped)
@@ -190,7 +276,7 @@ export function resolvePosition({
190
276
  excludeId = null,
191
277
  gridSize = 24,
192
278
  }) {
193
- const defaults = DEFAULT_SIZES[type] || { width: 270, height: 170 }
279
+ const defaults = getDefaultSize(type)
194
280
  const width = props.width ?? defaults.width
195
281
  const height = props.height ?? defaults.height
196
282
 
@@ -1,8 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  getWidgetBounds,
4
+ computeWidgetBounds,
5
+ stampBounds,
6
+ stampBoundsAll,
4
7
  rectsOverlap,
5
8
  findCollision,
9
+ findCollisions,
6
10
  snapToGrid,
7
11
  findFreePosition,
8
12
  resolvePosition,
@@ -57,6 +61,24 @@ describe('getWidgetBounds', () => {
57
61
  height: 170,
58
62
  })
59
63
  })
64
+
65
+ it('resolves story widget type from DEFAULT_SIZES', () => {
66
+ const widget = { type: 'story', position: { x: 0, y: 0 }, props: {} }
67
+ expect(getWidgetBounds(widget)).toEqual({
68
+ x: 0, y: 0,
69
+ width: DEFAULT_SIZES['story'].width,
70
+ height: DEFAULT_SIZES['story'].height,
71
+ })
72
+ })
73
+
74
+ it('resolves codepen-embed widget type from DEFAULT_SIZES', () => {
75
+ const widget = { type: 'codepen-embed', position: { x: 0, y: 0 }, props: {} }
76
+ expect(getWidgetBounds(widget)).toEqual({
77
+ x: 0, y: 0,
78
+ width: DEFAULT_SIZES['codepen-embed'].width,
79
+ height: DEFAULT_SIZES['codepen-embed'].height,
80
+ })
81
+ })
60
82
  })
61
83
 
62
84
  describe('rectsOverlap', () => {
@@ -117,6 +139,32 @@ describe('findCollision', () => {
117
139
  })
118
140
  })
119
141
 
142
+ describe('findCollisions', () => {
143
+ const widgets = [
144
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
145
+ { id: 'w2', type: 'sticky-note', position: { x: 200, y: 0 }, props: { width: 270, height: 170 } },
146
+ ]
147
+
148
+ it('returns all colliding widgets', () => {
149
+ const rect = { x: 100, y: 50, width: 270, height: 170 }
150
+ const colliders = findCollisions(rect, widgets)
151
+ expect(colliders).toHaveLength(2)
152
+ expect(colliders.map((w) => w.id).sort()).toEqual(['w1', 'w2'])
153
+ })
154
+
155
+ it('returns empty array when no collision', () => {
156
+ const rect = { x: 0, y: 200, width: 270, height: 170 }
157
+ expect(findCollisions(rect, widgets)).toEqual([])
158
+ })
159
+
160
+ it('excludes specified widget ID', () => {
161
+ const rect = { x: 100, y: 50, width: 270, height: 170 }
162
+ const colliders = findCollisions(rect, widgets, 'w1')
163
+ expect(colliders).toHaveLength(1)
164
+ expect(colliders[0].id).toBe('w2')
165
+ })
166
+ })
167
+
120
168
  describe('snapToGrid', () => {
121
169
  it('snaps to nearest grid line', () => {
122
170
  expect(snapToGrid(25, 24)).toBe(24)
@@ -269,3 +317,55 @@ describe('resolvePosition', () => {
269
317
  expect(result).toEqual({ x: 504, y: 504, adjusted: false })
270
318
  })
271
319
  })
320
+
321
+ describe('computeWidgetBounds', () => {
322
+ it('computes bounds from position and size', () => {
323
+ const widget = { type: 'sticky-note', position: { x: 100, y: 200 }, props: { width: 270, height: 170 } }
324
+ expect(computeWidgetBounds(widget)).toEqual({
325
+ width: 270, height: 170,
326
+ startX: 100, startY: 200,
327
+ endX: 370, endY: 370,
328
+ })
329
+ })
330
+
331
+ it('uses defaults when props missing', () => {
332
+ const widget = { type: 'sticky-note', position: { x: 0, y: 0 }, props: {} }
333
+ expect(computeWidgetBounds(widget)).toEqual({
334
+ width: 270, height: 170,
335
+ startX: 0, startY: 0,
336
+ endX: 270, endY: 170,
337
+ })
338
+ })
339
+ })
340
+
341
+ describe('stampBounds', () => {
342
+ it('adds bounds field to widget', () => {
343
+ const widget = { id: 'w1', type: 'sticky-note', position: { x: 48, y: 24 }, props: { width: 270, height: 170 } }
344
+ const stamped = stampBounds(widget)
345
+ expect(stamped.bounds).toEqual({
346
+ width: 270, height: 170,
347
+ startX: 48, startY: 24,
348
+ endX: 318, endY: 194,
349
+ })
350
+ expect(stamped.id).toBe('w1')
351
+ })
352
+
353
+ it('does not mutate original widget', () => {
354
+ const widget = { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: {} }
355
+ stampBounds(widget)
356
+ expect(widget.bounds).toBeUndefined()
357
+ })
358
+ })
359
+
360
+ describe('stampBoundsAll', () => {
361
+ it('stamps bounds on all widgets', () => {
362
+ const widgets = [
363
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: { width: 270, height: 170 } },
364
+ { id: 'w2', type: 'markdown', position: { x: 300, y: 0 }, props: { width: 530, height: 240 } },
365
+ ]
366
+ const stamped = stampBoundsAll(widgets)
367
+ expect(stamped).toHaveLength(2)
368
+ expect(stamped[0].bounds.endX).toBe(270)
369
+ expect(stamped[1].bounds.endX).toBe(830)
370
+ })
371
+ })
@@ -13,6 +13,8 @@
13
13
  * settings_updated — patch canvas-level settings
14
14
  * source_updated — replace the sources array
15
15
  * widgets_replaced — replace the entire widgets array (bulk update)
16
+ * connector_added — append a connector between two widgets
17
+ * connector_removed — remove a connector by id
16
18
  */
17
19
 
18
20
  /**
@@ -101,6 +103,7 @@ export function materialize(events) {
101
103
  const initial = { ...evt }
102
104
  delete initial.event
103
105
  delete initial.timestamp
106
+ if (!initial.connectors) initial.connectors = []
104
107
  state = initial
105
108
  break
106
109
  }
@@ -131,6 +134,12 @@ export function materialize(events) {
131
134
  state.widgets = (state.widgets || []).filter(
132
135
  (w) => w.id !== evt.widgetId,
133
136
  )
137
+ // Cascade: remove connectors referencing the deleted widget
138
+ if (state.connectors?.length) {
139
+ state.connectors = state.connectors.filter(
140
+ (c) => c.start.widgetId !== evt.widgetId && c.end.widgetId !== evt.widgetId,
141
+ )
142
+ }
134
143
  break
135
144
  }
136
145
 
@@ -150,6 +159,26 @@ export function materialize(events) {
150
159
 
151
160
  case 'widgets_replaced': {
152
161
  state.widgets = evt.widgets
162
+ // Orphan cleanup: remove connectors referencing deleted widgets
163
+ if (state.connectors?.length) {
164
+ const widgetIds = new Set((state.widgets || []).map((w) => w.id))
165
+ state.connectors = state.connectors.filter(
166
+ (c) => widgetIds.has(c.start.widgetId) && widgetIds.has(c.end.widgetId),
167
+ )
168
+ }
169
+ break
170
+ }
171
+
172
+ case 'connector_added': {
173
+ if (!state.connectors) state.connectors = []
174
+ state.connectors = [...state.connectors, evt.connector]
175
+ break
176
+ }
177
+
178
+ case 'connector_removed': {
179
+ state.connectors = (state.connectors || []).filter(
180
+ (c) => c.id !== evt.connectorId,
181
+ )
153
182
  break
154
183
  }
155
184
 
@@ -157,6 +186,24 @@ export function materialize(events) {
157
186
  }
158
187
  }
159
188
 
189
+ // Derive connectorIds on widgets from the connectors array
190
+ if (state.connectors?.length && state.widgets?.length) {
191
+ // Build a map: widgetId → Set of connectorIds
192
+ const widgetConnMap = new Map()
193
+ for (const conn of state.connectors) {
194
+ for (const endpoint of [conn.start, conn.end]) {
195
+ if (!widgetConnMap.has(endpoint.widgetId)) {
196
+ widgetConnMap.set(endpoint.widgetId, new Set())
197
+ }
198
+ widgetConnMap.get(endpoint.widgetId).add(conn.id)
199
+ }
200
+ }
201
+ state.widgets = state.widgets.map((w) => {
202
+ const ids = widgetConnMap.get(w.id)
203
+ return ids ? { ...w, connectorIds: [...ids] } : w
204
+ })
205
+ }
206
+
160
207
  return state
161
208
  }
162
209