@dfosco/storyboard-react 4.2.0-beta.1 → 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 (85) hide show
  1. package/package.json +5 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  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 +478 -186
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +561 -191
  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 +10 -6
  15. package/src/canvas/CanvasPage.jsx +738 -216
  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 -153
  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/connectorGeometry.js +132 -0
  23. package/src/canvas/hotPoolDevLogs.js +25 -0
  24. package/src/canvas/useCanvas.js +1 -1
  25. package/src/canvas/useMarqueeSelect.js +30 -4
  26. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  27. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  28. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  29. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  30. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  31. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  32. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  33. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  34. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  35. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  38. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  39. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  40. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  41. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  42. package/src/canvas/widgets/LinkPreview.jsx +112 -4
  43. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  44. package/src/canvas/widgets/MarkdownBlock.jsx +164 -17
  45. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  46. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  47. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  48. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -38
  49. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  50. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  51. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  52. package/src/canvas/widgets/StoryWidget.jsx +72 -15
  53. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  54. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  55. package/src/canvas/widgets/TerminalWidget.jsx +496 -69
  56. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  57. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  58. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  59. package/src/canvas/widgets/WidgetChrome.jsx +73 -153
  60. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  61. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  62. package/src/canvas/widgets/expandUtils.js +557 -0
  63. package/src/canvas/widgets/expandUtils.test.js +155 -0
  64. package/src/canvas/widgets/index.js +9 -0
  65. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  66. package/src/canvas/widgets/tilePool.js +23 -0
  67. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  68. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  70. package/src/canvas/widgets/tiles/leaf.png +0 -0
  71. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  73. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  75. package/src/canvas/widgets/widgetConfig.js +55 -4
  76. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  77. package/src/canvas/widgets/widgetProps.js +1 -0
  78. package/src/context.jsx +47 -19
  79. package/src/hooks/useConfig.js +14 -0
  80. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  81. package/src/index.js +8 -2
  82. package/src/story/ComponentSetPage.jsx +186 -0
  83. package/src/story/ComponentSetPage.module.css +121 -0
  84. package/src/story/StoryPage.jsx +32 -2
  85. package/src/vite/data-plugin.js +324 -30
@@ -0,0 +1,557 @@
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
+ 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
+ }
233
+
234
+ /**
235
+ * Find a connected widget that is split-screen capable.
236
+ * Returns the first match, or null.
237
+ * @param {string} widgetId — the primary (expanded) widget's ID
238
+ * @returns {{ id: string, type: string, position: { x: number, y: number }, props: Object } | null}
239
+ */
240
+ export function findConnectedSplitTarget(widgetId) {
241
+ const bridge = window.__storyboardCanvasBridgeState
242
+ if (!bridge?.connectors || !bridge?.widgets) return null
243
+
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 []
269
+
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 []
276
+
277
+ const candidates = bridge.widgets.filter(
278
+ (w) => connectedIds.has(w.id) && isSplitScreenCapable(w.type),
279
+ )
280
+
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
398
+ }
399
+
400
+ /**
401
+ * Get the x-coordinate position of a widget from bridge state.
402
+ * @param {string} widgetId
403
+ * @returns {number}
404
+ */
405
+ export function getWidgetX(widgetId) {
406
+ const bridge = window.__storyboardCanvasBridgeState
407
+ if (!bridge?.widgets) return 0
408
+ const w = bridge.widgets.find((w) => w.id === widgetId)
409
+ return w?.position?.x ?? 0
410
+ }
411
+
412
+ /**
413
+ * Determine pane order (left/right) based on x-coordinates.
414
+ * Returns { left, right } where each is 'primary' or 'secondary'.
415
+ * @param {string} primaryId — the widget being expanded
416
+ * @param {{ id: string, position?: { x: number } }} secondaryWidget
417
+ * @returns {{ primaryIsLeft: boolean }}
418
+ */
419
+ export function getPaneOrder(primaryId, secondaryWidget) {
420
+ const primaryX = getWidgetX(primaryId)
421
+ const secondaryX = secondaryWidget?.position?.x ?? 0
422
+ return { primaryIsLeft: primaryX <= secondaryX }
423
+ }
424
+
425
+ /**
426
+ * Build an iframe URL for a widget to render in a secondary pane.
427
+ * Returns null if the widget type isn't iframe-embeddable.
428
+ * @param {{ type: string, props: Object }} widget
429
+ * @returns {string | null}
430
+ */
431
+ export function buildSecondaryIframeUrl(widget) {
432
+ if (!widget) return null
433
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
434
+ const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
435
+
436
+ if (widget.type === 'prototype') {
437
+ const src = widget.props?.src
438
+ if (!src) return null
439
+ if (/^https?:\/\//.test(src)) return src
440
+ return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype`
441
+ }
442
+
443
+ if (widget.type === 'figma-embed') {
444
+ const url = widget.props?.url
445
+ if (!url) return null
446
+ // Inline a minimal figma embed URL builder to avoid circular deps
447
+ try {
448
+ const u = new URL(url)
449
+ if (!u.hostname.endsWith('figma.com')) return null
450
+ return `https://www.figma.com/embed?embed_host=storyboard&url=${encodeURIComponent(url)}`
451
+ } catch { return null }
452
+ }
453
+
454
+ if (widget.type === 'codepen-embed') {
455
+ const url = widget.props?.url
456
+ if (!url) return null
457
+ try {
458
+ const u = new URL(url)
459
+ if (!u.hostname.endsWith('codepen.io')) return null
460
+ const path = u.pathname.replace(/\/(pen|full|details)\//, '/embed/')
461
+ return `https://codepen.io${path}?default-tab=result`
462
+ } catch { return null }
463
+ }
464
+
465
+ if (widget.type === 'story') {
466
+ const storyId = widget.props?.storyId
467
+ const exportName = widget.props?.exportName
468
+ if (!storyId) return null
469
+ const storyData = getStoryData(storyId)
470
+ if (storyData?._route) {
471
+ const params = new URLSearchParams()
472
+ if (exportName) params.set('export', exportName)
473
+ params.set('_sb_embed', '')
474
+ params.set('_sb_hide_branch_bar', '')
475
+ return `${baseClean}${storyData._route}?${params}`
476
+ }
477
+ return null
478
+ }
479
+
480
+ return null
481
+ }
482
+
483
+ /**
484
+ * Reparent a terminal widget's xterm container into a target element.
485
+ * Returns a cleanup function to restore the original position.
486
+ * @param {string} widgetId
487
+ * @param {HTMLElement} targetEl
488
+ * @returns {(() => void) | null}
489
+ */
490
+ export function reparentTerminalInto(widgetId, targetEl) {
491
+ const widgetEl = document.querySelector(`[data-widget-id="${widgetId}"]`)
492
+ if (!widgetEl) return null
493
+
494
+ const xtermEl = widgetEl.querySelector('[class*="xtermContainer"]')
495
+ if (!xtermEl) return null
496
+
497
+ const originalParent = xtermEl.parentElement
498
+ const originalNextSibling = xtermEl.nextSibling
499
+
500
+ targetEl.appendChild(xtermEl)
501
+
502
+ return () => {
503
+ if (originalNextSibling) {
504
+ originalParent.insertBefore(xtermEl, originalNextSibling)
505
+ } else {
506
+ originalParent.appendChild(xtermEl)
507
+ }
508
+ }
509
+ }
510
+
511
+ /**
512
+ * Build a "Type · Metadata" label for a widget in split-screen top bar.
513
+ * @param {{ type: string, props: Object }} widget
514
+ * @returns {string}
515
+ */
516
+ export function getSplitPaneLabel(widget) {
517
+ if (!widget) return ''
518
+ const meta = getWidgetMeta(widget.type)
519
+ const typeName = meta?.label || widget.type
520
+
521
+ if (widget.type === 'terminal' || widget.type === 'terminal-read') {
522
+ return `Terminal · ${widget.props?.prettyName || '…'}`
523
+ }
524
+ if (widget.type === 'agent') {
525
+ return `Agent · ${widget.props?.prettyName || '…'}`
526
+ }
527
+ if (widget.type === 'prototype') {
528
+ return `Prototype · ${widget.props?.src || '…'}`
529
+ }
530
+ if (widget.type === 'figma-embed') {
531
+ const url = widget.props?.url || ''
532
+ let name = 'Figma'
533
+ try { name = new URL(url).pathname.split('/').pop() || 'Figma' } catch { /* */ }
534
+ return `Figma · ${name}`
535
+ }
536
+ if (widget.type === 'codepen-embed') {
537
+ return `CodePen · ${widget.props?.url || '…'}`
538
+ }
539
+ if (widget.type === 'story') {
540
+ return `Story · ${widget.props?.storyId || '…'}`
541
+ }
542
+ if (widget.type === 'markdown') {
543
+ const content = widget.props?.content || ''
544
+ const firstLine = content.split('\n').find((l) => l.trim()) || ''
545
+ const preview = firstLine.replace(/^#+\s*/, '').slice(0, 40)
546
+ return `Markdown · ${preview || '…'}`
547
+ }
548
+ if (widget.type === 'link-preview') {
549
+ return `${widget.props?.github ? 'GitHub' : 'Link'} · ${widget.props?.title || widget.props?.url || '…'}`
550
+ }
551
+ if (widget.type === 'image') {
552
+ const filename = widget.props?.src || ''
553
+ const name = filename.replace(/^~/, '').replace(/\.[^.]+$/, '') || '…'
554
+ return `Image · ${name}`
555
+ }
556
+ return typeName
557
+ }