@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.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.
Files changed (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -165
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +363 -67
  89. package/src/vite/data-plugin.test.js +1 -1
@@ -0,0 +1,560 @@
1
+ /**
2
+ * Shared utilities for expandable widget modals and split-screen.
3
+ *
4
+ * Reads the canvas bridge state to find connected widgets eligible
5
+ * for split-screen, and builds iframe URLs for secondary panes.
6
+ */
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
+ // eslint-disable-next-line react-hooks/refs
26
+ editingRef.current = editing
27
+ // eslint-disable-next-line react-hooks/refs
28
+ editingRef.setter = (v) => {
29
+ setEditing(v)
30
+ // Notify ExpandedPane to re-render so titlebar features resolve updated toggle state
31
+ document.dispatchEvent(new CustomEvent('storyboard:expanded-pane:refresh'))
32
+ }
33
+
34
+ const content = widget.props?.content || ''
35
+
36
+ // eslint-disable-next-line react-hooks/refs
37
+ return createElement(ExpandedMarkdownEditor, {
38
+ content,
39
+ onUpdate,
40
+ editing,
41
+ onToggleEdit: () => editingRef.setter(!editing),
42
+ })
43
+ }
44
+
45
+ /**
46
+ * Build a pane config for a connected widget to use with ExpandedPane.
47
+ * Returns a ReactPane or ExternalPane config depending on the widget type.
48
+ *
49
+ * @param {{ id: string, type: string, props: Object }} widget
50
+ * @param {'fullbar' | 'splitbar'} [surface='splitbar'] — surface for titlebar features
51
+ * @returns {import('./ExpandedPane.jsx').PaneConfig | null}
52
+ */
53
+ export function buildPaneForWidget(widget, surface = 'splitbar') {
54
+ if (!widget) return null
55
+
56
+ const label = getSplitPaneLabel(widget)
57
+
58
+ // Terminal/agent: external pane with DOM reparenting
59
+ if (widget.type === 'terminal' || widget.type === 'terminal-read' || widget.type === 'agent') {
60
+ return {
61
+ id: widget.id,
62
+ label,
63
+ widgetType: widget.type,
64
+ kind: 'external',
65
+ attach: (container) => reparentTerminalInto(widget.id, container),
66
+ onResize: (rect) => {
67
+ // fitTerminalToElement is in TerminalWidget.jsx (module-level).
68
+ // We call it via the global registry if available.
69
+ if (typeof window !== 'undefined' && window.__storyboardTerminalRegistry) {
70
+ const entry = window.__storyboardTerminalRegistry.get(widget.id)
71
+ if (entry) {
72
+ const { term, ws } = entry
73
+ const cw = term.renderer?.charWidth
74
+ const ch = term.renderer?.charHeight
75
+ if (cw && ch && rect.width > 50 && rect.height > 50) {
76
+ const cols = Math.max(10, Math.floor(rect.width / cw))
77
+ const rows = Math.max(4, Math.floor(rect.height / ch))
78
+ term.resize?.(cols, rows)
79
+ if (ws?.readyState === WebSocket.OPEN) {
80
+ ws.send(JSON.stringify({ type: 'resize', cols, rows }))
81
+ }
82
+ }
83
+ }
84
+ }
85
+ },
86
+ }
87
+ }
88
+
89
+ // iframe-embeddable types: build iframe URL
90
+ const iframeUrl = buildSecondaryIframeUrl(widget)
91
+ if (iframeUrl) {
92
+ return {
93
+ id: widget.id,
94
+ label,
95
+ widgetType: widget.type,
96
+ kind: 'react',
97
+ render: () => createElement('iframe', {
98
+ src: iframeUrl,
99
+ style: { border: 'none', width: '100%', height: '100%', display: 'block' },
100
+ title: label,
101
+ }),
102
+ }
103
+ }
104
+
105
+ // Markdown: same editable pane as primary, updates via custom event
106
+ if (widget.type === 'markdown') {
107
+ // Shared mutable ref for editing state — lets getState and render stay in sync
108
+ const editingRef = { current: false, setter: null }
109
+ const toggleEdit = () => { editingRef.setter?.(!editingRef.current) }
110
+ const onUpdate = (updates) => {
111
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:update-widget', {
112
+ detail: { widgetId: widget.id, updates },
113
+ }))
114
+ }
115
+ const surfaceFeatures = getFeaturesForSurface('markdown', surface)
116
+ return {
117
+ id: widget.id,
118
+ label,
119
+ widgetType: widget.type,
120
+ kind: 'react',
121
+ features: surfaceFeatures,
122
+ getState: (key) => {
123
+ if (key === 'editing') return editingRef.current
124
+ return undefined
125
+ },
126
+ onAction: (actionId) => {
127
+ if (actionId === 'toggle-edit') toggleEdit()
128
+ },
129
+ render: () => createElement(MarkdownSecondaryPane, { widget, editingRef, onUpdate }),
130
+ }
131
+ }
132
+
133
+ // Link-preview
134
+ if (widget.type === 'link-preview') {
135
+ return {
136
+ id: widget.id,
137
+ label,
138
+ widgetType: widget.type,
139
+ kind: 'react',
140
+ render: () => createElement(LazyLinkPreviewPane, { widget }),
141
+ }
142
+ }
143
+
144
+ // Image: display at full size within the pane
145
+ if (widget.type === 'image') {
146
+ return {
147
+ id: widget.id,
148
+ label,
149
+ widgetType: widget.type,
150
+ kind: 'react',
151
+ render: () => createElement(LazyImagePane, { widget }),
152
+ }
153
+ }
154
+
155
+ return null
156
+ }
157
+
158
+ /**
159
+ * Link-preview renderer for expanded panes — matches the primary expanded
160
+ * rendering used when link-preview triggers its own expand/split-screen.
161
+ */
162
+ function LazyLinkPreviewPane({ widget }) {
163
+ const { url, title, github, ogImage, description } = widget.props || {}
164
+
165
+ let hostname = ''
166
+ try { hostname = new URL(url).hostname } catch { /* */ }
167
+
168
+ if (github) {
169
+ const titleText = title || github.title || ''
170
+ const issueNumber = github.number ? `#${github.number}` : ''
171
+ const primaryAuthor = github.author || ''
172
+ const createdAgo = github.createdAgo || ''
173
+ const bodyHtml = github.bodyHtml || ''
174
+
175
+ return createElement('div', { className: linkStyles.expandedIssue },
176
+ createElement('header', { className: linkStyles.expandedIssueHeader },
177
+ createElement('h2', { className: linkStyles.expandedIssueTitle },
178
+ createElement('a', { href: url || '#', target: '_blank', rel: 'noopener noreferrer' },
179
+ titleText || url,
180
+ issueNumber && createElement('span', { className: linkStyles.expandedIssueNumber }, ` ${issueNumber}`),
181
+ ),
182
+ ),
183
+ createElement('div', { className: linkStyles.expandedByline },
184
+ primaryAuthor && createElement('a', {
185
+ href: `https://github.com/${primaryAuthor}`,
186
+ target: '_blank',
187
+ rel: 'noopener noreferrer',
188
+ className: linkStyles.expandedAuthor,
189
+ },
190
+ createElement('img', {
191
+ src: `https://github.com/${primaryAuthor}.png?size=40`,
192
+ alt: '', width: '20', height: '20',
193
+ className: linkStyles.avatar, loading: 'lazy',
194
+ }),
195
+ primaryAuthor,
196
+ ),
197
+ createdAgo && createElement('span', { className: linkStyles.expandedBylineText },
198
+ primaryAuthor ? ` opened ${createdAgo}` : `Opened ${createdAgo}`,
199
+ ),
200
+ ),
201
+ ),
202
+ bodyHtml && createElement(DangerousHtmlDiv, { html: bodyHtml, className: linkStyles.expandedIssueBody }),
203
+ )
204
+ }
205
+
206
+ return createElement('div', { className: linkStyles.expandedLink },
207
+ ogImage && createElement('img', { className: linkStyles.expandedOgImage, src: ogImage, alt: '', loading: 'lazy' }),
208
+ createElement('h2', { className: linkStyles.expandedTitle }, title || hostname || url || 'Untitled'),
209
+ description && createElement('p', { className: linkStyles.expandedDescription }, description),
210
+ url && createElement('a', {
211
+ href: url, target: '_blank', rel: 'noopener noreferrer',
212
+ className: linkStyles.expandedUrl,
213
+ }, url),
214
+ )
215
+ }
216
+
217
+ /** Renders raw HTML into a div via a callback ref (avoids ref-during-render lint). */
218
+ function DangerousHtmlDiv({ html, className }) {
219
+ const setRef = useCallback((el) => { if (el) el.innerHTML = html }, [html])
220
+ return createElement('div', { ref: setRef, className })
221
+ }
222
+
223
+ /** Image renderer for secondary expanded/split-screen panes. */
224
+ function LazyImagePane({ widget }) {
225
+ const src = widget.props?.src
226
+ if (!src) return null
227
+ return createElement('div', { className: imageStyles.expandedImageContainer },
228
+ createElement('img', {
229
+ src: getImageUrl(src),
230
+ alt: '',
231
+ className: imageStyles.expandedImage,
232
+ draggable: false,
233
+ }),
234
+ )
235
+ }
236
+
237
+ /**
238
+ * Find a connected widget that is split-screen capable.
239
+ * Returns the first match, or null.
240
+ * @param {string} widgetId — the primary (expanded) widget's ID
241
+ * @returns {{ id: string, type: string, position: { x: number, y: number }, props: Object } | null}
242
+ */
243
+ export function findConnectedSplitTarget(widgetId) {
244
+ const bridge = window.__storyboardCanvasBridgeState
245
+ if (!bridge?.connectors || !bridge?.widgets) return null
246
+
247
+ // Find all widgets connected to this one
248
+ const connectedIds = new Set()
249
+ for (const c of bridge.connectors) {
250
+ if (c.start?.widgetId === widgetId && c.end?.widgetId) connectedIds.add(c.end.widgetId)
251
+ if (c.end?.widgetId === widgetId && c.start?.widgetId) connectedIds.add(c.start.widgetId)
252
+ }
253
+ if (connectedIds.size === 0) return null
254
+
255
+ // Return the first connected widget that is split-screen capable
256
+ for (const w of bridge.widgets) {
257
+ if (connectedIds.has(w.id) && isSplitScreenCapable(w.type)) return w
258
+ }
259
+ return null
260
+ }
261
+
262
+ /**
263
+ * Find ALL connected widgets that are split-screen capable.
264
+ * If more than maxCount, picks the nearest by Euclidean distance from primary.
265
+ * @param {string} widgetId — the primary (expanded) widget's ID
266
+ * @param {number} [maxCount=3] — max connected widgets to return
267
+ * @returns {Array<{ id: string, type: string, position: { x: number, y: number }, props: Object }>}
268
+ */
269
+ export function findAllConnectedSplitTargets(widgetId, maxCount = 3) {
270
+ const bridge = window.__storyboardCanvasBridgeState
271
+ if (!bridge?.connectors || !bridge?.widgets) return []
272
+
273
+ const connectedIds = new Set()
274
+ for (const c of bridge.connectors) {
275
+ if (c.start?.widgetId === widgetId && c.end?.widgetId) connectedIds.add(c.end.widgetId)
276
+ if (c.end?.widgetId === widgetId && c.start?.widgetId) connectedIds.add(c.start.widgetId)
277
+ }
278
+ if (connectedIds.size === 0) return []
279
+
280
+ const candidates = bridge.widgets.filter(
281
+ (w) => connectedIds.has(w.id) && isSplitScreenCapable(w.type),
282
+ )
283
+
284
+ if (candidates.length <= maxCount) return candidates
285
+
286
+ // Too many — pick the nearest by Euclidean distance from primary
287
+ const primary = bridge.widgets.find((w) => w.id === widgetId)
288
+ const px = primary?.position?.x ?? 0
289
+ const py = primary?.position?.y ?? 0
290
+ return candidates
291
+ .map((w) => {
292
+ const dx = (w.position?.x ?? 0) - px
293
+ const dy = (w.position?.y ?? 0) - py
294
+ return { widget: w, dist: dx * dx + dy * dy }
295
+ })
296
+ .sort((a, b) => a.dist - b.dist)
297
+ .slice(0, maxCount)
298
+ .map((e) => e.widget)
299
+ }
300
+
301
+ /**
302
+ * Build a 2D split layout (PaneConfig[][]) from a primary widget and connected widgets.
303
+ * Uses quadrant-based spatial assignment: compute centroid, assign each widget to
304
+ * TL/TR/BL/BR, then build columns (left = TL+BL, right = TR+BR).
305
+ *
306
+ * @param {{ id: string, type: string, position?: { x: number, y: number }, props: Object }} primaryWidget
307
+ * @param {Array<{ id: string, type: string, position?: { x: number, y: number }, props: Object }>} connectedWidgets
308
+ * @param {(widget: Object) => import('./ExpandedPane.jsx').PaneConfig | null} buildPaneFn — builds a PaneConfig for a widget
309
+ * @returns {import('./ExpandedPane.jsx').PaneConfig[][]} — 2D layout: outer = columns, inner = rows
310
+ */
311
+ export function buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn) {
312
+ const allWidgets = [primaryWidget, ...connectedWidgets]
313
+
314
+ // Build panes, filter nulls, keep widget reference for positioning
315
+ const entries = allWidgets
316
+ .map((w) => ({ widget: w, pane: buildPaneFn(w) }))
317
+ .filter((e) => e.pane !== null)
318
+
319
+ if (entries.length === 0) return []
320
+ if (entries.length === 1) return [[entries[0].pane]]
321
+
322
+ // Assign to quadrants
323
+ const assigned = assignToQuadrants(entries.map((e) => ({
324
+ x: e.widget.position?.x ?? 0,
325
+ y: e.widget.position?.y ?? 0,
326
+ data: e.pane,
327
+ })))
328
+
329
+ // Build columns: left = TL + BL (top first), right = TR + BR (top first)
330
+ const leftCol = [assigned.tl, assigned.bl].filter(Boolean)
331
+ const rightCol = [assigned.tr, assigned.br].filter(Boolean)
332
+
333
+ const layout = []
334
+ if (leftCol.length > 0) layout.push(leftCol)
335
+ if (rightCol.length > 0) layout.push(rightCol)
336
+ return layout
337
+ }
338
+
339
+ /**
340
+ * Assign items to a 2×2 quadrant grid using centroid splitting.
341
+ * Falls back to TL→TR→BL→BR cycling when positions are degenerate (all same x or y).
342
+ *
343
+ * @template T
344
+ * @param {Array<{ x: number, y: number, data: T }>} items — 2-4 positioned items
345
+ * @returns {{ tl: T|null, tr: T|null, bl: T|null, br: T|null }}
346
+ */
347
+ export function assignToQuadrants(items) {
348
+ const result = { tl: null, tr: null, bl: null, br: null }
349
+ if (items.length === 0) return result
350
+
351
+ // Centroid
352
+ const cx = items.reduce((s, i) => s + i.x, 0) / items.length
353
+ const cy = items.reduce((s, i) => s + i.y, 0) / items.length
354
+
355
+ // Check if all x or all y are identical (degenerate)
356
+ const allSameX = items.every((i) => i.x === items[0].x)
357
+ const allSameY = items.every((i) => i.y === items[0].y)
358
+
359
+ if (allSameX && allSameY) {
360
+ // All positions identical — cycle TL→TR→BL→BR
361
+ const slots = ['tl', 'tr', 'bl', 'br']
362
+ for (let i = 0; i < Math.min(items.length, 4); i++) {
363
+ result[slots[i]] = items[i].data
364
+ }
365
+ return result
366
+ }
367
+
368
+ // Assign to quadrants based on centroid
369
+ // Use buckets to handle collisions (multiple items in same quadrant)
370
+ const buckets = { tl: [], tr: [], bl: [], br: [] }
371
+ for (const item of items) {
372
+ const col = item.x < cx ? 'l' : 'r'
373
+ const row = item.y < cy ? 't' : 'b'
374
+ buckets[`${row}${col}`].push(item)
375
+ }
376
+
377
+ // If centroid splits are degenerate (e.g. 2 items with same x = centroid),
378
+ // we may have empty quadrants and overflow. Redistribute overflow.
379
+ const filled = []
380
+ const overflow = []
381
+ for (const [slot, bucket] of Object.entries(buckets)) {
382
+ if (bucket.length > 0) {
383
+ // Sort by position for deterministic order: top-left first
384
+ bucket.sort((a, b) => a.y - b.y || a.x - b.x)
385
+ result[slot] = bucket[0].data
386
+ filled.push(slot)
387
+ for (let i = 1; i < bucket.length; i++) overflow.push(bucket[i])
388
+ }
389
+ }
390
+
391
+ // Place overflow into empty quadrant slots
392
+ if (overflow.length > 0) {
393
+ const allSlots = ['tl', 'tr', 'bl', 'br']
394
+ const emptySlots = allSlots.filter((s) => result[s] === null)
395
+ for (let i = 0; i < Math.min(overflow.length, emptySlots.length); i++) {
396
+ result[emptySlots[i]] = overflow[i].data
397
+ }
398
+ }
399
+
400
+ return result
401
+ }
402
+
403
+ /**
404
+ * Get the x-coordinate position of a widget from bridge state.
405
+ * @param {string} widgetId
406
+ * @returns {number}
407
+ */
408
+ export function getWidgetX(widgetId) {
409
+ const bridge = window.__storyboardCanvasBridgeState
410
+ if (!bridge?.widgets) return 0
411
+ const w = bridge.widgets.find((w) => w.id === widgetId)
412
+ return w?.position?.x ?? 0
413
+ }
414
+
415
+ /**
416
+ * Determine pane order (left/right) based on x-coordinates.
417
+ * Returns { left, right } where each is 'primary' or 'secondary'.
418
+ * @param {string} primaryId — the widget being expanded
419
+ * @param {{ id: string, position?: { x: number } }} secondaryWidget
420
+ * @returns {{ primaryIsLeft: boolean }}
421
+ */
422
+ export function getPaneOrder(primaryId, secondaryWidget) {
423
+ const primaryX = getWidgetX(primaryId)
424
+ const secondaryX = secondaryWidget?.position?.x ?? 0
425
+ return { primaryIsLeft: primaryX <= secondaryX }
426
+ }
427
+
428
+ /**
429
+ * Build an iframe URL for a widget to render in a secondary pane.
430
+ * Returns null if the widget type isn't iframe-embeddable.
431
+ * @param {{ type: string, props: Object }} widget
432
+ * @returns {string | null}
433
+ */
434
+ export function buildSecondaryIframeUrl(widget) {
435
+ if (!widget) return null
436
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
437
+ const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
438
+
439
+ if (widget.type === 'prototype') {
440
+ const src = widget.props?.src
441
+ if (!src) return null
442
+ if (/^https?:\/\//.test(src)) return src
443
+ return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype`
444
+ }
445
+
446
+ if (widget.type === 'figma-embed') {
447
+ const url = widget.props?.url
448
+ if (!url) return null
449
+ // Inline a minimal figma embed URL builder to avoid circular deps
450
+ try {
451
+ const u = new URL(url)
452
+ if (!u.hostname.endsWith('figma.com')) return null
453
+ return `https://www.figma.com/embed?embed_host=storyboard&url=${encodeURIComponent(url)}`
454
+ } catch { return null }
455
+ }
456
+
457
+ if (widget.type === 'codepen-embed') {
458
+ const url = widget.props?.url
459
+ if (!url) return null
460
+ try {
461
+ const u = new URL(url)
462
+ if (!u.hostname.endsWith('codepen.io')) return null
463
+ const path = u.pathname.replace(/\/(pen|full|details)\//, '/embed/')
464
+ return `https://codepen.io${path}?default-tab=result`
465
+ } catch { return null }
466
+ }
467
+
468
+ if (widget.type === 'story') {
469
+ const storyId = widget.props?.storyId
470
+ const exportName = widget.props?.exportName
471
+ if (!storyId) return null
472
+ const storyData = getStoryData(storyId)
473
+ if (storyData?._route) {
474
+ const params = new URLSearchParams()
475
+ if (exportName) params.set('export', exportName)
476
+ params.set('_sb_embed', '')
477
+ params.set('_sb_hide_branch_bar', '')
478
+ return `${baseClean}${storyData._route}?${params}`
479
+ }
480
+ return null
481
+ }
482
+
483
+ return null
484
+ }
485
+
486
+ /**
487
+ * Reparent a terminal widget's xterm container into a target element.
488
+ * Returns a cleanup function to restore the original position.
489
+ * @param {string} widgetId
490
+ * @param {HTMLElement} targetEl
491
+ * @returns {(() => void) | null}
492
+ */
493
+ export function reparentTerminalInto(widgetId, targetEl) {
494
+ const widgetEl = document.querySelector(`[data-widget-id="${widgetId}"]`)
495
+ if (!widgetEl) return null
496
+
497
+ const xtermEl = widgetEl.querySelector('[class*="xtermContainer"]')
498
+ if (!xtermEl) return null
499
+
500
+ const originalParent = xtermEl.parentElement
501
+ const originalNextSibling = xtermEl.nextSibling
502
+
503
+ targetEl.appendChild(xtermEl)
504
+
505
+ return () => {
506
+ if (originalNextSibling) {
507
+ originalParent.insertBefore(xtermEl, originalNextSibling)
508
+ } else {
509
+ originalParent.appendChild(xtermEl)
510
+ }
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Build a "Type · Metadata" label for a widget in split-screen top bar.
516
+ * @param {{ type: string, props: Object }} widget
517
+ * @returns {string}
518
+ */
519
+ export function getSplitPaneLabel(widget) {
520
+ if (!widget) return ''
521
+ const meta = getWidgetMeta(widget.type)
522
+ const typeName = meta?.label || widget.type
523
+
524
+ if (widget.type === 'terminal' || widget.type === 'terminal-read') {
525
+ return `Terminal · ${widget.props?.prettyName || '…'}`
526
+ }
527
+ if (widget.type === 'agent') {
528
+ return `Agent · ${widget.props?.prettyName || '…'}`
529
+ }
530
+ if (widget.type === 'prototype') {
531
+ return `Prototype · ${widget.props?.src || '…'}`
532
+ }
533
+ if (widget.type === 'figma-embed') {
534
+ const url = widget.props?.url || ''
535
+ let name = 'Figma'
536
+ try { name = new URL(url).pathname.split('/').pop() || 'Figma' } catch { /* */ }
537
+ return `Figma · ${name}`
538
+ }
539
+ if (widget.type === 'codepen-embed') {
540
+ return `CodePen · ${widget.props?.url || '…'}`
541
+ }
542
+ if (widget.type === 'story') {
543
+ return `Story · ${widget.props?.storyId || '…'}`
544
+ }
545
+ if (widget.type === 'markdown') {
546
+ const content = widget.props?.content || ''
547
+ const firstLine = content.split('\n').find((l) => l.trim()) || ''
548
+ const preview = firstLine.replace(/^#+\s*/, '').slice(0, 40)
549
+ return `Markdown · ${preview || '…'}`
550
+ }
551
+ if (widget.type === 'link-preview') {
552
+ return `${widget.props?.github ? 'GitHub' : 'Link'} · ${widget.props?.title || widget.props?.url || '…'}`
553
+ }
554
+ if (widget.type === 'image') {
555
+ const filename = widget.props?.src || ''
556
+ const name = filename.replace(/^~/, '').replace(/\.[^.]+$/, '') || '…'
557
+ return `Image · ${name}`
558
+ }
559
+ return typeName
560
+ }