@dfosco/storyboard-react 4.2.0-beta.17 → 4.2.0-beta.18

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 (79) hide show
  1. package/package.json +3 -3
  2. package/src/BranchBar/BranchBar.jsx +3 -1
  3. package/src/BranchBar/BranchBar.module.css +2 -2
  4. package/src/BranchBar/useBranches.js +20 -6
  5. package/src/BranchBar/useBranches.test.js +68 -0
  6. package/src/CommandPalette/CommandPalette.jsx +250 -61
  7. package/src/CommandPalette/command-palette.css +12 -0
  8. package/src/Icon.jsx +46 -11
  9. package/src/Viewfinder.jsx +53 -133
  10. package/src/Viewfinder.module.css +20 -91
  11. package/src/Workspace.jsx +7 -0
  12. package/src/canvas/CanvasPage.jsx +601 -62
  13. package/src/canvas/CanvasPage.module.css +15 -2
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
  15. package/src/canvas/ConnectorLayer.jsx +120 -152
  16. package/src/canvas/ConnectorLayer.module.css +69 -0
  17. package/src/canvas/canvasApi.js +68 -2
  18. package/src/canvas/connectorGeometry.js +132 -0
  19. package/src/canvas/hotPoolDevLogs.js +25 -0
  20. package/src/canvas/useMarqueeSelect.js +30 -4
  21. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  22. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  23. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  25. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  26. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  27. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  28. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  29. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  30. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  31. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  32. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  33. package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
  34. package/src/canvas/widgets/ImageWidget.jsx +129 -8
  35. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  36. package/src/canvas/widgets/LinkPreview.jsx +93 -44
  37. package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
  38. package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
  39. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  40. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  41. package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
  42. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  43. package/src/canvas/widgets/StoryWidget.jsx +65 -11
  44. package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
  45. package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
  46. package/src/canvas/widgets/TerminalWidget.jsx +301 -124
  47. package/src/canvas/widgets/TerminalWidget.module.css +121 -12
  48. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  49. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  50. package/src/canvas/widgets/WidgetChrome.jsx +67 -152
  51. package/src/canvas/widgets/WidgetChrome.module.css +20 -1
  52. package/src/canvas/widgets/expandUtils.js +385 -16
  53. package/src/canvas/widgets/expandUtils.test.js +155 -0
  54. package/src/canvas/widgets/index.js +6 -2
  55. package/src/canvas/widgets/tilePool.js +23 -0
  56. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  57. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  58. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  59. package/src/canvas/widgets/tiles/leaf.png +0 -0
  60. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  61. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  62. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  63. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  64. package/src/canvas/widgets/widgetConfig.js +37 -4
  65. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  66. package/src/canvas/widgets/widgetProps.js +1 -0
  67. package/src/context.jsx +47 -19
  68. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  69. package/src/index.js +4 -2
  70. package/src/story/ComponentSetPage.jsx +186 -0
  71. package/src/story/ComponentSetPage.module.css +121 -0
  72. package/src/story/StoryPage.jsx +32 -2
  73. package/src/vite/data-plugin.js +79 -35
  74. package/src/canvas/widgets/ActionWidget.jsx +0 -200
  75. package/src/canvas/widgets/ActionWidget.module.css +0 -122
  76. package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
  77. package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
  78. package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
  79. package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
@@ -4,7 +4,232 @@
4
4
  * Reads the canvas bridge state to find connected widgets eligible
5
5
  * for split-screen, and builds iframe URLs for secondary panes.
6
6
  */
7
- import { isSplitScreenCapable, getWidgetMeta } from './widgetConfig.js'
7
+ import { createElement, useCallback, useState } from 'react'
8
+ import { getStoryData } from '@dfosco/storyboard-core'
9
+ import { isSplitScreenCapable, getWidgetMeta, getFeaturesForSurface } from './widgetConfig.js'
10
+ import { ExpandedMarkdownEditor } from './MarkdownBlock.jsx'
11
+ import { getImageUrl } from './ImageWidget.jsx'
12
+ import linkStyles from './LinkPreview.module.css'
13
+ import imageStyles from './ImageWidget.module.css'
14
+
15
+ // Re-export for convenience
16
+ export { isSplitScreenCapable }
17
+
18
+ /**
19
+ * Stateful wrapper for markdown in secondary split-screen panes.
20
+ * Manages editing state and syncs it to the shared editingRef so
21
+ * the title bar features' getState stays in sync.
22
+ */
23
+ function MarkdownSecondaryPane({ widget, editingRef, onUpdate }) {
24
+ const [editing, setEditing] = useState(false)
25
+ editingRef.current = editing
26
+ editingRef.setter = (v) => {
27
+ setEditing(v)
28
+ // Notify ExpandedPane to re-render so titlebar features resolve updated toggle state
29
+ document.dispatchEvent(new CustomEvent('storyboard:expanded-pane:refresh'))
30
+ }
31
+
32
+ const content = widget.props?.content || ''
33
+
34
+ return createElement(ExpandedMarkdownEditor, {
35
+ content,
36
+ onUpdate,
37
+ editing,
38
+ onToggleEdit: () => editingRef.setter(!editing),
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Build a pane config for a connected widget to use with ExpandedPane.
44
+ * Returns a ReactPane or ExternalPane config depending on the widget type.
45
+ *
46
+ * @param {{ id: string, type: string, props: Object }} widget
47
+ * @param {'fullbar' | 'splitbar'} [surface='splitbar'] — surface for titlebar features
48
+ * @returns {import('./ExpandedPane.jsx').PaneConfig | null}
49
+ */
50
+ export function buildPaneForWidget(widget, surface = 'splitbar') {
51
+ if (!widget) return null
52
+
53
+ const label = getSplitPaneLabel(widget)
54
+
55
+ // Terminal/agent: external pane with DOM reparenting
56
+ if (widget.type === 'terminal' || widget.type === 'terminal-read' || widget.type === 'agent') {
57
+ return {
58
+ id: widget.id,
59
+ label,
60
+ widgetType: widget.type,
61
+ kind: 'external',
62
+ attach: (container) => reparentTerminalInto(widget.id, container),
63
+ onResize: (rect) => {
64
+ // fitTerminalToElement is in TerminalWidget.jsx (module-level).
65
+ // We call it via the global registry if available.
66
+ if (typeof window !== 'undefined' && window.__storyboardTerminalRegistry) {
67
+ const entry = window.__storyboardTerminalRegistry.get(widget.id)
68
+ if (entry) {
69
+ const { term, ws } = entry
70
+ const cw = term.renderer?.charWidth
71
+ const ch = term.renderer?.charHeight
72
+ if (cw && ch && rect.width > 50 && rect.height > 50) {
73
+ const cols = Math.max(10, Math.floor(rect.width / cw))
74
+ const rows = Math.max(4, Math.floor(rect.height / ch))
75
+ term.resize?.(cols, rows)
76
+ if (ws?.readyState === WebSocket.OPEN) {
77
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }))
78
+ }
79
+ }
80
+ }
81
+ }
82
+ },
83
+ }
84
+ }
85
+
86
+ // iframe-embeddable types: build iframe URL
87
+ const iframeUrl = buildSecondaryIframeUrl(widget)
88
+ if (iframeUrl) {
89
+ return {
90
+ id: widget.id,
91
+ label,
92
+ widgetType: widget.type,
93
+ kind: 'react',
94
+ render: () => createElement('iframe', {
95
+ src: iframeUrl,
96
+ style: { border: 'none', width: '100%', height: '100%', display: 'block' },
97
+ title: label,
98
+ }),
99
+ }
100
+ }
101
+
102
+ // Markdown: same editable pane as primary, updates via custom event
103
+ if (widget.type === 'markdown') {
104
+ // Shared mutable ref for editing state — lets getState and render stay in sync
105
+ const editingRef = { current: false, setter: null }
106
+ const toggleEdit = () => { editingRef.setter?.(!editingRef.current) }
107
+ const onUpdate = (updates) => {
108
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:update-widget', {
109
+ detail: { widgetId: widget.id, updates },
110
+ }))
111
+ }
112
+ const surfaceFeatures = getFeaturesForSurface('markdown', surface)
113
+ return {
114
+ id: widget.id,
115
+ label,
116
+ widgetType: widget.type,
117
+ kind: 'react',
118
+ features: surfaceFeatures,
119
+ getState: (key) => {
120
+ if (key === 'editing') return editingRef.current
121
+ return undefined
122
+ },
123
+ onAction: (actionId) => {
124
+ if (actionId === 'toggle-edit') toggleEdit()
125
+ },
126
+ render: () => createElement(MarkdownSecondaryPane, { widget, editingRef, onUpdate }),
127
+ }
128
+ }
129
+
130
+ // Link-preview
131
+ if (widget.type === 'link-preview') {
132
+ return {
133
+ id: widget.id,
134
+ label,
135
+ widgetType: widget.type,
136
+ kind: 'react',
137
+ render: () => createElement(LazyLinkPreviewPane, { widget }),
138
+ }
139
+ }
140
+
141
+ // Image: display at full size within the pane
142
+ if (widget.type === 'image') {
143
+ return {
144
+ id: widget.id,
145
+ label,
146
+ widgetType: widget.type,
147
+ kind: 'react',
148
+ render: () => createElement(LazyImagePane, { widget }),
149
+ }
150
+ }
151
+
152
+ return null
153
+ }
154
+
155
+ /**
156
+ * Link-preview renderer for expanded panes — matches the primary expanded
157
+ * rendering used when link-preview triggers its own expand/split-screen.
158
+ */
159
+ function LazyLinkPreviewPane({ widget }) {
160
+ const { url, title, github, ogImage, description } = widget.props || {}
161
+
162
+ let hostname = ''
163
+ try { hostname = new URL(url).hostname } catch { /* */ }
164
+
165
+ if (github) {
166
+ const titleText = title || github.title || ''
167
+ const issueNumber = github.number ? `#${github.number}` : ''
168
+ const primaryAuthor = github.author || ''
169
+ const createdAgo = github.createdAgo || ''
170
+ const bodyHtml = github.bodyHtml || ''
171
+
172
+ return createElement('div', { className: linkStyles.expandedIssue },
173
+ createElement('header', { className: linkStyles.expandedIssueHeader },
174
+ createElement('h2', { className: linkStyles.expandedIssueTitle },
175
+ createElement('a', { href: url || '#', target: '_blank', rel: 'noopener noreferrer' },
176
+ titleText || url,
177
+ issueNumber && createElement('span', { className: linkStyles.expandedIssueNumber }, ` ${issueNumber}`),
178
+ ),
179
+ ),
180
+ createElement('div', { className: linkStyles.expandedByline },
181
+ primaryAuthor && createElement('a', {
182
+ href: `https://github.com/${primaryAuthor}`,
183
+ target: '_blank',
184
+ rel: 'noopener noreferrer',
185
+ className: linkStyles.expandedAuthor,
186
+ },
187
+ createElement('img', {
188
+ src: `https://github.com/${primaryAuthor}.png?size=40`,
189
+ alt: '', width: '20', height: '20',
190
+ className: linkStyles.avatar, loading: 'lazy',
191
+ }),
192
+ primaryAuthor,
193
+ ),
194
+ createdAgo && createElement('span', { className: linkStyles.expandedBylineText },
195
+ primaryAuthor ? ` opened ${createdAgo}` : `Opened ${createdAgo}`,
196
+ ),
197
+ ),
198
+ ),
199
+ bodyHtml && createElement(DangerousHtmlDiv, { html: bodyHtml, className: linkStyles.expandedIssueBody }),
200
+ )
201
+ }
202
+
203
+ return createElement('div', { className: linkStyles.expandedLink },
204
+ ogImage && createElement('img', { className: linkStyles.expandedOgImage, src: ogImage, alt: '', loading: 'lazy' }),
205
+ createElement('h2', { className: linkStyles.expandedTitle }, title || hostname || url || 'Untitled'),
206
+ description && createElement('p', { className: linkStyles.expandedDescription }, description),
207
+ url && createElement('a', {
208
+ href: url, target: '_blank', rel: 'noopener noreferrer',
209
+ className: linkStyles.expandedUrl,
210
+ }, url),
211
+ )
212
+ }
213
+
214
+ /** Renders raw HTML into a div via a callback ref (avoids ref-during-render lint). */
215
+ function DangerousHtmlDiv({ html, className }) {
216
+ const setRef = useCallback((el) => { if (el) el.innerHTML = html }, [html])
217
+ return createElement('div', { ref: setRef, className })
218
+ }
219
+
220
+ /** Image renderer for secondary expanded/split-screen panes. */
221
+ function LazyImagePane({ widget }) {
222
+ const src = widget.props?.src
223
+ if (!src) return null
224
+ return createElement('div', { className: imageStyles.expandedImageContainer },
225
+ createElement('img', {
226
+ src: getImageUrl(src),
227
+ alt: '',
228
+ className: imageStyles.expandedImage,
229
+ draggable: false,
230
+ }),
231
+ )
232
+ }
8
233
 
9
234
  /**
10
235
  * Find a connected widget that is split-screen capable.
@@ -16,24 +241,160 @@ export function findConnectedSplitTarget(widgetId) {
16
241
  const bridge = window.__storyboardCanvasBridgeState
17
242
  if (!bridge?.connectors || !bridge?.widgets) return null
18
243
 
19
- // Only allow split-screen when this widget has exactly one connection
20
- const myConnections = bridge.connectors.filter(
21
- (c) => c.start?.widgetId === widgetId || c.end?.widgetId === widgetId,
22
- )
23
- if (myConnections.length !== 1) return null
244
+ // Find all widgets connected to this one
245
+ const connectedIds = new Set()
246
+ for (const c of bridge.connectors) {
247
+ if (c.start?.widgetId === widgetId && c.end?.widgetId) connectedIds.add(c.end.widgetId)
248
+ if (c.end?.widgetId === widgetId && c.start?.widgetId) connectedIds.add(c.start.widgetId)
249
+ }
250
+ if (connectedIds.size === 0) return null
251
+
252
+ // Return the first connected widget that is split-screen capable
253
+ for (const w of bridge.widgets) {
254
+ if (connectedIds.has(w.id) && isSplitScreenCapable(w.type)) return w
255
+ }
256
+ return null
257
+ }
258
+
259
+ /**
260
+ * Find ALL connected widgets that are split-screen capable.
261
+ * If more than maxCount, picks the nearest by Euclidean distance from primary.
262
+ * @param {string} widgetId — the primary (expanded) widget's ID
263
+ * @param {number} [maxCount=3] — max connected widgets to return
264
+ * @returns {Array<{ id: string, type: string, position: { x: number, y: number }, props: Object }>}
265
+ */
266
+ export function findAllConnectedSplitTargets(widgetId, maxCount = 3) {
267
+ const bridge = window.__storyboardCanvasBridgeState
268
+ if (!bridge?.connectors || !bridge?.widgets) return []
24
269
 
25
- const conn = myConnections[0]
26
- const otherId = conn.start?.widgetId === widgetId ? conn.end?.widgetId : conn.start?.widgetId
270
+ const connectedIds = new Set()
271
+ for (const c of bridge.connectors) {
272
+ if (c.start?.widgetId === widgetId && c.end?.widgetId) connectedIds.add(c.end.widgetId)
273
+ if (c.end?.widgetId === widgetId && c.start?.widgetId) connectedIds.add(c.start.widgetId)
274
+ }
275
+ if (connectedIds.size === 0) return []
27
276
 
28
- // The other widget must also have exactly one connection
29
- const otherConnections = bridge.connectors.filter(
30
- (c) => c.start?.widgetId === otherId || c.end?.widgetId === otherId,
277
+ const candidates = bridge.widgets.filter(
278
+ (w) => connectedIds.has(w.id) && isSplitScreenCapable(w.type),
31
279
  )
32
- if (otherConnections.length !== 1) return null
33
280
 
34
- const other = bridge.widgets.find((w) => w.id === otherId)
35
- if (other && isSplitScreenCapable(other.type)) return other
36
- return null
281
+ if (candidates.length <= maxCount) return candidates
282
+
283
+ // Too many — pick the nearest by Euclidean distance from primary
284
+ const primary = bridge.widgets.find((w) => w.id === widgetId)
285
+ const px = primary?.position?.x ?? 0
286
+ const py = primary?.position?.y ?? 0
287
+ return candidates
288
+ .map((w) => {
289
+ const dx = (w.position?.x ?? 0) - px
290
+ const dy = (w.position?.y ?? 0) - py
291
+ return { widget: w, dist: dx * dx + dy * dy }
292
+ })
293
+ .sort((a, b) => a.dist - b.dist)
294
+ .slice(0, maxCount)
295
+ .map((e) => e.widget)
296
+ }
297
+
298
+ /**
299
+ * Build a 2D split layout (PaneConfig[][]) from a primary widget and connected widgets.
300
+ * Uses quadrant-based spatial assignment: compute centroid, assign each widget to
301
+ * TL/TR/BL/BR, then build columns (left = TL+BL, right = TR+BR).
302
+ *
303
+ * @param {{ id: string, type: string, position?: { x: number, y: number }, props: Object }} primaryWidget
304
+ * @param {Array<{ id: string, type: string, position?: { x: number, y: number }, props: Object }>} connectedWidgets
305
+ * @param {(widget: Object) => import('./ExpandedPane.jsx').PaneConfig | null} buildPaneFn — builds a PaneConfig for a widget
306
+ * @returns {import('./ExpandedPane.jsx').PaneConfig[][]} — 2D layout: outer = columns, inner = rows
307
+ */
308
+ export function buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn) {
309
+ const allWidgets = [primaryWidget, ...connectedWidgets]
310
+
311
+ // Build panes, filter nulls, keep widget reference for positioning
312
+ const entries = allWidgets
313
+ .map((w) => ({ widget: w, pane: buildPaneFn(w) }))
314
+ .filter((e) => e.pane !== null)
315
+
316
+ if (entries.length === 0) return []
317
+ if (entries.length === 1) return [[entries[0].pane]]
318
+
319
+ // Assign to quadrants
320
+ const assigned = assignToQuadrants(entries.map((e) => ({
321
+ x: e.widget.position?.x ?? 0,
322
+ y: e.widget.position?.y ?? 0,
323
+ data: e.pane,
324
+ })))
325
+
326
+ // Build columns: left = TL + BL (top first), right = TR + BR (top first)
327
+ const leftCol = [assigned.tl, assigned.bl].filter(Boolean)
328
+ const rightCol = [assigned.tr, assigned.br].filter(Boolean)
329
+
330
+ const layout = []
331
+ if (leftCol.length > 0) layout.push(leftCol)
332
+ if (rightCol.length > 0) layout.push(rightCol)
333
+ return layout
334
+ }
335
+
336
+ /**
337
+ * Assign items to a 2×2 quadrant grid using centroid splitting.
338
+ * Falls back to TL→TR→BL→BR cycling when positions are degenerate (all same x or y).
339
+ *
340
+ * @template T
341
+ * @param {Array<{ x: number, y: number, data: T }>} items — 2-4 positioned items
342
+ * @returns {{ tl: T|null, tr: T|null, bl: T|null, br: T|null }}
343
+ */
344
+ export function assignToQuadrants(items) {
345
+ const result = { tl: null, tr: null, bl: null, br: null }
346
+ if (items.length === 0) return result
347
+
348
+ // Centroid
349
+ const cx = items.reduce((s, i) => s + i.x, 0) / items.length
350
+ const cy = items.reduce((s, i) => s + i.y, 0) / items.length
351
+
352
+ // Check if all x or all y are identical (degenerate)
353
+ const allSameX = items.every((i) => i.x === items[0].x)
354
+ const allSameY = items.every((i) => i.y === items[0].y)
355
+
356
+ if (allSameX && allSameY) {
357
+ // All positions identical — cycle TL→TR→BL→BR
358
+ const slots = ['tl', 'tr', 'bl', 'br']
359
+ for (let i = 0; i < Math.min(items.length, 4); i++) {
360
+ result[slots[i]] = items[i].data
361
+ }
362
+ return result
363
+ }
364
+
365
+ // Assign to quadrants based on centroid
366
+ // Use buckets to handle collisions (multiple items in same quadrant)
367
+ const buckets = { tl: [], tr: [], bl: [], br: [] }
368
+ for (const item of items) {
369
+ const col = item.x < cx ? 'l' : 'r'
370
+ const row = item.y < cy ? 't' : 'b'
371
+ buckets[`${row}${col}`].push(item)
372
+ }
373
+
374
+ // If centroid splits are degenerate (e.g. 2 items with same x = centroid),
375
+ // we may have empty quadrants and overflow. Redistribute overflow.
376
+ const filled = []
377
+ const overflow = []
378
+ for (const [slot, bucket] of Object.entries(buckets)) {
379
+ if (bucket.length > 0) {
380
+ // Sort by position for deterministic order: top-left first
381
+ bucket.sort((a, b) => a.y - b.y || a.x - b.x)
382
+ result[slot] = bucket[0].data
383
+ filled.push(slot)
384
+ for (let i = 1; i < bucket.length; i++) overflow.push(bucket[i])
385
+ }
386
+ }
387
+
388
+ // Place overflow into empty quadrant slots
389
+ if (overflow.length > 0) {
390
+ const allSlots = ['tl', 'tr', 'bl', 'br']
391
+ const emptySlots = allSlots.filter((s) => result[s] === null)
392
+ for (let i = 0; i < Math.min(overflow.length, emptySlots.length); i++) {
393
+ result[emptySlots[i]] = overflow[i].data
394
+ }
395
+ }
396
+
397
+ return result
37
398
  }
38
399
 
39
400
  /**
@@ -105,7 +466,7 @@ export function buildSecondaryIframeUrl(widget) {
105
466
  const storyId = widget.props?.storyId
106
467
  const exportName = widget.props?.exportName
107
468
  if (!storyId) return null
108
- const storyData = typeof window !== 'undefined' && window.__storyboardStoryIndex?.[storyId]
469
+ const storyData = getStoryData(storyId)
109
470
  if (storyData?._route) {
110
471
  const params = new URLSearchParams()
111
472
  if (exportName) params.set('export', exportName)
@@ -160,6 +521,9 @@ export function getSplitPaneLabel(widget) {
160
521
  if (widget.type === 'terminal' || widget.type === 'terminal-read') {
161
522
  return `Terminal · ${widget.props?.prettyName || '…'}`
162
523
  }
524
+ if (widget.type === 'agent') {
525
+ return `Agent · ${widget.props?.prettyName || '…'}`
526
+ }
163
527
  if (widget.type === 'prototype') {
164
528
  return `Prototype · ${widget.props?.src || '…'}`
165
529
  }
@@ -184,5 +548,10 @@ export function getSplitPaneLabel(widget) {
184
548
  if (widget.type === 'link-preview') {
185
549
  return `${widget.props?.github ? 'GitHub' : 'Link'} · ${widget.props?.title || widget.props?.url || '…'}`
186
550
  }
551
+ if (widget.type === 'image') {
552
+ const filename = widget.props?.src || ''
553
+ const name = filename.replace(/^~/, '').replace(/\.[^.]+$/, '') || '…'
554
+ return `Image · ${name}`
555
+ }
187
556
  return typeName
188
557
  }
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { assignToQuadrants, buildSplitLayout } from './expandUtils.js'
3
+
4
+ describe('assignToQuadrants', () => {
5
+ it('assigns 2 items to left/right columns', () => {
6
+ const result = assignToQuadrants([
7
+ { x: 100, y: 200, data: 'A' },
8
+ { x: 500, y: 200, data: 'B' },
9
+ ])
10
+ // centroid.y = 200 = both y values, so both get 'b' row (>= centroid)
11
+ expect(result.bl).toBe('A')
12
+ expect(result.br).toBe('B')
13
+ expect(result.tl).toBeNull()
14
+ expect(result.tr).toBeNull()
15
+ })
16
+
17
+ it('assigns 2 items stacked vertically to top/bottom in same column', () => {
18
+ const result = assignToQuadrants([
19
+ { x: 100, y: 100, data: 'A' },
20
+ { x: 100, y: 500, data: 'B' },
21
+ ])
22
+ // Both same x → degenerate x, but different y
23
+ // centroid = (100, 300). A.y=100 < 300 → top, B.y=500 >= 300 → bottom
24
+ // A.x=100 is NOT < centroid.x=100, so both go to 'r'
25
+ // → tr='A', br='B'
26
+ expect(result.tr).toBe('A')
27
+ expect(result.br).toBe('B')
28
+ })
29
+
30
+ it('assigns 4 items to all quadrants', () => {
31
+ const result = assignToQuadrants([
32
+ { x: 100, y: 100, data: 'TL' },
33
+ { x: 500, y: 100, data: 'TR' },
34
+ { x: 100, y: 500, data: 'BL' },
35
+ { x: 500, y: 500, data: 'BR' },
36
+ ])
37
+ expect(result.tl).toBe('TL')
38
+ expect(result.tr).toBe('TR')
39
+ expect(result.bl).toBe('BL')
40
+ expect(result.br).toBe('BR')
41
+ })
42
+
43
+ it('assigns 3 items: 2 in left column, 1 in right', () => {
44
+ const result = assignToQuadrants([
45
+ { x: 100, y: 100, data: 'TL' },
46
+ { x: 100, y: 500, data: 'BL' },
47
+ { x: 500, y: 300, data: 'R' },
48
+ ])
49
+ // centroid x = (100+100+500)/3 ≈ 233, y = (100+500+300)/3 = 300
50
+ // TL: x=100 < 233 → l, y=100 < 300 → t → tl
51
+ // BL: x=100 < 233 → l, y=500 >= 300 → b → bl
52
+ // R: x=500 >= 233 → r, y=300 >= 300 → b → br
53
+ expect(result.tl).toBe('TL')
54
+ expect(result.bl).toBe('BL')
55
+ expect(result.br).toBe('R')
56
+ })
57
+
58
+ it('cycles TL→TR→BL→BR when all positions are identical', () => {
59
+ const result = assignToQuadrants([
60
+ { x: 0, y: 0, data: 'A' },
61
+ { x: 0, y: 0, data: 'B' },
62
+ { x: 0, y: 0, data: 'C' },
63
+ ])
64
+ expect(result.tl).toBe('A')
65
+ expect(result.tr).toBe('B')
66
+ expect(result.bl).toBe('C')
67
+ expect(result.br).toBeNull()
68
+ })
69
+
70
+ it('handles overflow: 2 items in same quadrant', () => {
71
+ // Two items very close, both land in same quadrant → overflow redistributes
72
+ const result = assignToQuadrants([
73
+ { x: 100, y: 100, data: 'A' },
74
+ { x: 110, y: 110, data: 'B' },
75
+ { x: 500, y: 500, data: 'C' },
76
+ ])
77
+ // centroid = (236, 236). A: x<236 y<236 → tl. B: x<236 y<236 → tl (overflow!)
78
+ // C: x>=236 y>=236 → br
79
+ // A wins tl, B overflows to first empty slot (tr)
80
+ expect(result.tl).toBe('A')
81
+ expect(result.br).toBe('C')
82
+ // B goes to overflow → first empty slot (tr)
83
+ expect(result.tr).toBe('B')
84
+ })
85
+
86
+ it('returns all nulls for empty input', () => {
87
+ const result = assignToQuadrants([])
88
+ expect(result.tl).toBeNull()
89
+ expect(result.tr).toBeNull()
90
+ expect(result.bl).toBeNull()
91
+ expect(result.br).toBeNull()
92
+ })
93
+ })
94
+
95
+ describe('buildSplitLayout', () => {
96
+ function mockPaneFn(widget) {
97
+ return { id: widget.id, label: widget.id, kind: 'react', render: () => null }
98
+ }
99
+
100
+ it('returns single column for 1 widget (no connected)', () => {
101
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
102
+ const layout = buildSplitLayout(primary, [], mockPaneFn)
103
+ expect(layout.length).toBe(1)
104
+ expect(layout[0].length).toBe(1)
105
+ expect(layout[0][0].id).toBe('a')
106
+ })
107
+
108
+ it('returns 2 columns for 2 horizontally-spaced widgets', () => {
109
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 100 }, props: {} }
110
+ const connected = [
111
+ { id: 'b', type: 'prototype', position: { x: 500, y: 100 }, props: {} },
112
+ ]
113
+ const layout = buildSplitLayout(primary, connected, mockPaneFn)
114
+ expect(layout.length).toBe(2)
115
+ expect(layout[0].length).toBe(1) // left column
116
+ expect(layout[1].length).toBe(1) // right column
117
+ })
118
+
119
+ it('returns 2 columns with row split for 3 widgets', () => {
120
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
121
+ const connected = [
122
+ { id: 'b', type: 'prototype', position: { x: 500, y: 0 }, props: {} },
123
+ { id: 'c', type: 'markdown', position: { x: 500, y: 500 }, props: {} },
124
+ ]
125
+ const layout = buildSplitLayout(primary, connected, mockPaneFn)
126
+ const totalPanes = layout.flat().length
127
+ expect(totalPanes).toBe(3)
128
+ // One column should have 2 panes (row split)
129
+ const hasRowSplit = layout.some((col) => col.length === 2)
130
+ expect(hasRowSplit).toBe(true)
131
+ })
132
+
133
+ it('returns 2×2 grid for 4 widgets', () => {
134
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
135
+ const connected = [
136
+ { id: 'b', type: 'prototype', position: { x: 500, y: 0 }, props: {} },
137
+ { id: 'c', type: 'markdown', position: { x: 0, y: 500 }, props: {} },
138
+ { id: 'd', type: 'agent', position: { x: 500, y: 500 }, props: {} },
139
+ ]
140
+ const layout = buildSplitLayout(primary, connected, mockPaneFn)
141
+ expect(layout.length).toBe(2)
142
+ expect(layout[0].length).toBe(2)
143
+ expect(layout[1].length).toBe(2)
144
+ })
145
+
146
+ it('skips widgets where buildPaneFn returns null', () => {
147
+ const primary = { id: 'a', type: 'terminal', position: { x: 0, y: 0 }, props: {} }
148
+ const connected = [
149
+ { id: 'b', type: 'unknown', position: { x: 500, y: 0 }, props: {} },
150
+ ]
151
+ const paneFn = (w) => w.id === 'a' ? mockPaneFn(w) : null
152
+ const layout = buildSplitLayout(primary, connected, paneFn)
153
+ expect(layout.flat().length).toBe(1)
154
+ })
155
+ })
@@ -6,9 +6,11 @@ import ImageWidget from './ImageWidget.jsx'
6
6
  import FigmaEmbed from './FigmaEmbed.jsx'
7
7
  import CodePenEmbed from './CodePenEmbed.jsx'
8
8
  import StoryWidget from './StoryWidget.jsx'
9
+ import ComponentSetWidget from './ComponentSetWidget.jsx'
9
10
  import TerminalWidget from './TerminalWidget.jsx'
10
11
  import TerminalReadWidget from './TerminalReadWidget.jsx'
11
- import ActionWidget from './ActionWidget.jsx'
12
+ import PromptWidget from './PromptWidget.jsx'
13
+ import TilesWidget from './TilesWidget.jsx'
12
14
 
13
15
  /**
14
16
  * Maps widget type strings to their React components.
@@ -23,10 +25,12 @@ export const widgetRegistry = {
23
25
  'figma-embed': FigmaEmbed,
24
26
  'codepen-embed': CodePenEmbed,
25
27
  'story': StoryWidget,
28
+ 'component-set': ComponentSetWidget,
26
29
  'terminal': TerminalWidget,
27
30
  'terminal-read': TerminalReadWidget,
28
- 'action': ActionWidget,
29
31
  'agent': TerminalWidget,
32
+ 'prompt': PromptWidget,
33
+ 'tiles': TilesWidget,
30
34
  }
31
35
 
32
36
  /**
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Static tile image pool.
3
+ * Each import resolves to a Vite asset URL at build time.
4
+ */
5
+ import solidA from './tiles/solid-a.png'
6
+ import solidB from './tiles/solid-b.png'
7
+ import quarterTL from './tiles/quarter-tl.png'
8
+ import quarterTR from './tiles/quarter-tr.png'
9
+ import diagonalBR from './tiles/diagonal-br.png'
10
+ import diagonalBL from './tiles/diagonal-bl.png'
11
+ import diagonalTL from './tiles/diagonal-tl.png'
12
+ import leaf from './tiles/leaf.png'
13
+
14
+ export const TILE_POOL = [
15
+ solidA,
16
+ solidB,
17
+ quarterTL,
18
+ quarterTR,
19
+ diagonalBR,
20
+ diagonalBL,
21
+ diagonalTL,
22
+ leaf,
23
+ ]