@dfosco/storyboard-core 4.1.0 → 4.2.0-beta.0

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-beta.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -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
 
@@ -176,6 +176,110 @@ describe('materialize', () => {
176
176
  })
177
177
  })
178
178
 
179
+ describe('connectors', () => {
180
+ const baseEvents = [
181
+ { event: 'canvas_created', timestamp: '1', title: 'Test', widgets: [
182
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: {} },
183
+ { id: 'w2', type: 'markdown', position: { x: 200, y: 0 }, props: {} },
184
+ { id: 'w3', type: 'sticky-note', position: { x: 400, y: 0 }, props: {} },
185
+ ] },
186
+ ]
187
+
188
+ const connector = {
189
+ id: 'connector-001',
190
+ type: 'connector',
191
+ connectorType: 'default',
192
+ start: { widgetId: 'w1', anchor: 'right' },
193
+ end: { widgetId: 'w2', anchor: 'left' },
194
+ meta: {},
195
+ }
196
+
197
+ it('materializes connector_added events into connectors array', () => {
198
+ const state = materialize([
199
+ ...baseEvents,
200
+ { event: 'connector_added', timestamp: '2', connector },
201
+ ])
202
+ expect(state.connectors).toHaveLength(1)
203
+ expect(state.connectors[0].id).toBe('connector-001')
204
+ })
205
+
206
+ it('derives connectorIds on widgets from connectors', () => {
207
+ const state = materialize([
208
+ ...baseEvents,
209
+ { event: 'connector_added', timestamp: '2', connector },
210
+ ])
211
+ expect(state.widgets.find((w) => w.id === 'w1').connectorIds).toEqual(['connector-001'])
212
+ expect(state.widgets.find((w) => w.id === 'w2').connectorIds).toEqual(['connector-001'])
213
+ expect(state.widgets.find((w) => w.id === 'w3').connectorIds).toBeUndefined()
214
+ })
215
+
216
+ it('removes connectors with connector_removed', () => {
217
+ const state = materialize([
218
+ ...baseEvents,
219
+ { event: 'connector_added', timestamp: '2', connector },
220
+ { event: 'connector_removed', timestamp: '3', connectorId: 'connector-001' },
221
+ ])
222
+ expect(state.connectors).toHaveLength(0)
223
+ // connectorIds should not appear on widgets when no connectors reference them
224
+ expect(state.widgets.find((w) => w.id === 'w1').connectorIds).toBeUndefined()
225
+ })
226
+
227
+ it('cascades connector removal when widget is removed', () => {
228
+ const state = materialize([
229
+ ...baseEvents,
230
+ { event: 'connector_added', timestamp: '2', connector },
231
+ { event: 'widget_removed', timestamp: '3', widgetId: 'w1' },
232
+ ])
233
+ expect(state.connectors).toHaveLength(0)
234
+ expect(state.widgets).toHaveLength(2)
235
+ })
236
+
237
+ it('cleans orphaned connectors on widgets_replaced', () => {
238
+ const state = materialize([
239
+ ...baseEvents,
240
+ { event: 'connector_added', timestamp: '2', connector },
241
+ { event: 'widgets_replaced', timestamp: '3', widgets: [
242
+ { id: 'w2', type: 'markdown', position: { x: 200, y: 0 }, props: {} },
243
+ { id: 'w3', type: 'sticky-note', position: { x: 400, y: 0 }, props: {} },
244
+ ] },
245
+ ])
246
+ // w1 removed by replacement, so connector referencing w1 is orphaned
247
+ expect(state.connectors).toHaveLength(0)
248
+ })
249
+
250
+ it('supports multiple connectors on the same widget', () => {
251
+ const conn2 = {
252
+ id: 'connector-002',
253
+ type: 'connector',
254
+ connectorType: 'default',
255
+ start: { widgetId: 'w2', anchor: 'right' },
256
+ end: { widgetId: 'w3', anchor: 'left' },
257
+ meta: {},
258
+ }
259
+ const state = materialize([
260
+ ...baseEvents,
261
+ { event: 'connector_added', timestamp: '2', connector },
262
+ { event: 'connector_added', timestamp: '3', connector: conn2 },
263
+ ])
264
+ expect(state.connectors).toHaveLength(2)
265
+ expect(state.widgets.find((w) => w.id === 'w2').connectorIds).toEqual(
266
+ expect.arrayContaining(['connector-001', 'connector-002']),
267
+ )
268
+ expect(state.widgets.find((w) => w.id === 'w2').connectorIds).toHaveLength(2)
269
+ })
270
+
271
+ it('initializes connectors array when none existed', () => {
272
+ const state = materialize([
273
+ { event: 'canvas_created', timestamp: '1', title: 'Empty', widgets: [
274
+ { id: 'w1', type: 'sticky-note', position: { x: 0, y: 0 }, props: {} },
275
+ { id: 'w2', type: 'sticky-note', position: { x: 100, y: 0 }, props: {} },
276
+ ] },
277
+ { event: 'connector_added', timestamp: '2', connector },
278
+ ])
279
+ expect(state.connectors).toHaveLength(1)
280
+ })
281
+ })
282
+
179
283
  describe('materializeFromText', () => {
180
284
  it('parses and materializes in one step', () => {
181
285
  const text = '{"event":"canvas_created","timestamp":"2026-01-01","title":"Test","widgets":[]}\n{"event":"widget_added","timestamp":"2026-01-02","widget":{"id":"w1","type":"sticky-note","position":{"x":0,"y":0},"props":{"text":"Hello"}}}\n'