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

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 (48) hide show
  1. package/package.json +5 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +17 -5
  4. package/src/BranchBar/BranchBar.module.css +11 -2
  5. package/src/CommandPalette/CommandPalette.jsx +267 -164
  6. package/src/CommandPalette/command-palette.css +130 -78
  7. package/src/Icon.jsx +112 -48
  8. package/src/Viewfinder.jsx +511 -61
  9. package/src/Viewfinder.module.css +414 -2
  10. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  11. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  12. package/src/canvas/CanvasPage.jsx +157 -174
  13. package/src/canvas/CanvasPage.module.css +0 -15
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
  15. package/src/canvas/ConnectorLayer.jsx +5 -5
  16. package/src/canvas/PageSelector.test.jsx +15 -6
  17. package/src/canvas/useCanvas.js +1 -1
  18. package/src/canvas/widgets/ActionWidget.jsx +200 -0
  19. package/src/canvas/widgets/ActionWidget.module.css +122 -0
  20. package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
  21. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  22. package/src/canvas/widgets/ImageWidget.jsx +1 -1
  23. package/src/canvas/widgets/LinkPreview.jsx +64 -5
  24. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  25. package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
  26. package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
  27. package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
  28. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  29. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  30. package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
  31. package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
  32. package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
  33. package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
  34. package/src/canvas/widgets/StoryWidget.jsx +7 -4
  35. package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
  36. package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
  37. package/src/canvas/widgets/TerminalWidget.jsx +299 -49
  38. package/src/canvas/widgets/TerminalWidget.module.css +155 -1
  39. package/src/canvas/widgets/WidgetChrome.jsx +19 -14
  40. package/src/canvas/widgets/WidgetChrome.module.css +10 -0
  41. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  42. package/src/canvas/widgets/expandUtils.js +188 -0
  43. package/src/canvas/widgets/index.js +5 -0
  44. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  45. package/src/canvas/widgets/widgetConfig.js +19 -1
  46. package/src/hooks/useConfig.js +14 -0
  47. package/src/index.js +4 -0
  48. package/src/vite/data-plugin.js +264 -14
@@ -1,6 +1,6 @@
1
1
  import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
2
2
  import { Tooltip } from '@primer/react'
3
- import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } from '@primer/octicons-react'
3
+ import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold, ScreenFullIcon as OcticonScreenFull } from '@primer/octicons-react'
4
4
  import { getConnectorConfig, getInteractGate } from './widgetConfig.js'
5
5
  import styles from './WidgetChrome.module.css'
6
6
  import overlayStyles from './embedOverlay.module.css'
@@ -117,11 +117,7 @@ function DownloadIcon() {
117
117
  }
118
118
 
119
119
  function ExpandIcon() {
120
- return (
121
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
122
- <path d="M1.75 10a.75.75 0 0 1 .75.75v2.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 1 13.25v-2.5a.75.75 0 0 1 .75-.75Zm12.5 0a.75.75 0 0 1 .75.75v2.5A1.75 1.75 0 0 1 13.25 15h-2.5a.75.75 0 0 1 0-1.5h2.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 .75-.75ZM2.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0v-2.5C1 1.784 1.784 1 2.75 1Zm10.5 0C14.216 1 15 1.784 15 2.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-2.5a.75.75 0 0 1 0-1.5Z" />
123
- </svg>
124
- )
120
+ return <OcticonScreenFull size={12} />
125
121
  }
126
122
 
127
123
  function SyncIcon() {
@@ -140,6 +136,14 @@ function FoldIcon() {
140
136
  return <OcticonFold size={12} />
141
137
  }
142
138
 
139
+ function ColumnsIcon() {
140
+ return (
141
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
142
+ <path d="M12 3v18" /><rect x="3" y="3" width="18" height="18" rx="2" />
143
+ </svg>
144
+ )
145
+ }
146
+
143
147
  /** Icon registry — maps icon name strings from config to React components. */
144
148
  const ICON_REGISTRY = {
145
149
  'trash': DeleteIcon,
@@ -161,6 +165,7 @@ const ICON_REGISTRY = {
161
165
  'sync': SyncIcon,
162
166
  'unfold': UnfoldIcon,
163
167
  'fold': FoldIcon,
168
+ 'columns': ColumnsIcon,
164
169
  }
165
170
 
166
171
  /** Danger-styled actions in the overflow menu. */
@@ -438,8 +443,8 @@ export default function WidgetChrome({
438
443
  }
439
444
  // Widget-specific actions go through the widget's imperative ref
440
445
  if (widgetRef?.current?.handleAction) {
441
- widgetRef.current.handleAction(actionId)
442
- return
446
+ const handled = widgetRef.current.handleAction(actionId)
447
+ if (handled !== false) return
443
448
  }
444
449
  // Fallback to generic handler
445
450
  onAction?.(actionId)
@@ -614,10 +619,10 @@ export default function WidgetChrome({
614
619
  feature={feature}
615
620
  onAction={(actionId) => {
616
621
  if (widgetRef?.current?.handleAction) {
617
- widgetRef.current.handleAction(actionId)
618
- } else {
619
- onAction?.(actionId)
622
+ const handled = widgetRef.current.handleAction(actionId)
623
+ if (handled !== false) return
620
624
  }
625
+ onAction?.(actionId)
621
626
  }}
622
627
  />
623
628
  )
@@ -632,10 +637,10 @@ export default function WidgetChrome({
632
637
  onAction={(actionId) => {
633
638
  // Route overflow menu actions through the widget ref first
634
639
  if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
635
- widgetRef.current.handleAction(actionId)
636
- } else {
637
- onAction?.(actionId)
640
+ const handled = widgetRef.current.handleAction(actionId)
641
+ if (handled !== false) return
638
642
  }
643
+ onAction?.(actionId)
639
644
  }}
640
645
  />
641
646
  )}
@@ -19,6 +19,16 @@
19
19
  pointer-events: auto;
20
20
  }
21
21
 
22
+ /* Invisible expanded hit area — 15px padding around the visible dot */
23
+ .anchorPort::before {
24
+ content: '';
25
+ position: absolute;
26
+ top: -15px;
27
+ left: -15px;
28
+ right: -15px;
29
+ bottom: -15px;
30
+ }
31
+
22
32
  .chromeContainer:hover .anchorPort {
23
33
  opacity: 0.6;
24
34
  }
@@ -56,72 +56,70 @@ describe('Embed interaction overlay', () => {
56
56
  resizable: false,
57
57
  }
58
58
 
59
- it('renders "Click to open" hint when no snapshot exists', () => {
59
+ it('renders "Click to interact" hint when no snapshot exists', () => {
60
60
  render(<PrototypeEmbed {...defaultProps} />)
61
61
 
62
- const hint = screen.getByText('Click to open')
62
+ const hint = screen.getByText('Click to interact')
63
63
  expect(hint).toBeInTheDocument()
64
- // CSS modules mangle class names, just check the element exists
65
64
  })
66
65
 
67
66
  it('enters interactive mode on single click (not double-click)', async () => {
68
67
  const { container } = render(<PrototypeEmbed {...defaultProps} />)
69
68
 
70
- // Overlay should exist before interaction
71
- const overlay = screen.getByRole('button', { name: /click to open/i })
69
+ // Overlay should exist before interaction; iframe is always rendered
70
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
72
71
  expect(overlay).toBeInTheDocument()
73
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
74
- expect(screen.getByText('Design Overview')).toBeInTheDocument()
72
+ expect(container.querySelector('iframe')).toBeInTheDocument()
75
73
 
76
74
  // Single click should remove the overlay (enter interactive mode)
77
75
  fireEvent.click(overlay)
78
76
 
79
77
  // Overlay should no longer exist
80
- expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
78
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
81
79
  expect(container.querySelector('iframe')).toBeInTheDocument()
82
80
 
83
81
  fireEvent.pointerDown(document.body)
84
- expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
85
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
82
+ expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
83
+ expect(container.querySelector('iframe')).toBeInTheDocument()
86
84
  })
87
85
 
88
86
  it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
89
87
  render(<PrototypeEmbed {...defaultProps} />)
90
88
 
91
- const overlay = screen.getByRole('button', { name: /click to open/i })
89
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
92
90
  fireEvent.click(overlay, { shiftKey: true })
93
91
 
94
92
  // Overlay should still exist (did not enter interactive mode)
95
- expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
93
+ expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
96
94
  })
97
95
 
98
96
  it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
99
97
  render(<PrototypeEmbed {...defaultProps} />)
100
98
 
101
- const overlay = screen.getByRole('button', { name: /click to open/i })
99
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
102
100
  fireEvent.click(overlay, { metaKey: true })
103
101
 
104
- expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
102
+ expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
105
103
  })
106
104
 
107
105
  it('supports keyboard interaction (Enter key) with event prevention', () => {
108
106
  render(<PrototypeEmbed {...defaultProps} />)
109
107
 
110
- const overlay = screen.getByRole('button', { name: /click to open/i })
108
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
111
109
  const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
112
110
  fireEvent.keyDown(overlay, event)
113
111
 
114
- expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
112
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
115
113
  })
116
114
 
117
115
  it('supports keyboard interaction (Space key) with event prevention', () => {
118
116
  render(<PrototypeEmbed {...defaultProps} />)
119
117
 
120
- const overlay = screen.getByRole('button', { name: /click to open/i })
118
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
121
119
  const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
122
120
  fireEvent.keyDown(overlay, event)
123
121
 
124
- expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
122
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
125
123
  })
126
124
  })
127
125
 
@@ -143,7 +141,7 @@ describe('Embed interaction overlay', () => {
143
141
  const { container } = render(<FigmaEmbed {...defaultProps} />)
144
142
 
145
143
  const overlay = screen.getByRole('button', { name: /click to interact/i })
146
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
144
+ expect(container.querySelector('iframe')).toBeInTheDocument()
147
145
  fireEvent.click(overlay)
148
146
 
149
147
  expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
@@ -151,7 +149,7 @@ describe('Embed interaction overlay', () => {
151
149
 
152
150
  fireEvent.pointerDown(document.body)
153
151
  expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
154
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
152
+ expect(container.querySelector('iframe')).toBeInTheDocument()
155
153
  })
156
154
  })
157
155
 
@@ -162,20 +160,20 @@ describe('Embed interaction overlay', () => {
162
160
  resizable: false,
163
161
  }
164
162
 
165
- it('mounts iframe only after user activation', () => {
163
+ it('mounts iframe and shows overlay initially, removes overlay on click', () => {
166
164
  const { container } = render(<StoryWidget {...defaultProps} />)
167
165
 
168
- const overlay = screen.getByRole('button', { name: /click to open story component/i })
169
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
166
+ const overlay = screen.getByRole('button', { name: /click to interact$/i })
167
+ expect(container.querySelector('iframe')).toBeInTheDocument()
170
168
 
171
169
  fireEvent.click(overlay)
172
170
 
173
- expect(screen.queryByRole('button', { name: /click to open story component/i })).not.toBeInTheDocument()
171
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
174
172
  expect(container.querySelector('iframe')).toBeInTheDocument()
175
173
 
176
174
  fireEvent.pointerDown(document.body)
177
- expect(screen.getByRole('button', { name: /click to open story component/i })).toBeInTheDocument()
178
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
175
+ expect(screen.getByRole('button', { name: /click to interact$/i })).toBeInTheDocument()
176
+ expect(container.querySelector('iframe')).toBeInTheDocument()
179
177
  })
180
178
  })
181
179
 
@@ -0,0 +1,188 @@
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 { isSplitScreenCapable, getWidgetMeta } from './widgetConfig.js'
8
+
9
+ /**
10
+ * Find a connected widget that is split-screen capable.
11
+ * Returns the first match, or null.
12
+ * @param {string} widgetId — the primary (expanded) widget's ID
13
+ * @returns {{ id: string, type: string, position: { x: number, y: number }, props: Object } | null}
14
+ */
15
+ export function findConnectedSplitTarget(widgetId) {
16
+ const bridge = window.__storyboardCanvasBridgeState
17
+ if (!bridge?.connectors || !bridge?.widgets) return null
18
+
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
24
+
25
+ const conn = myConnections[0]
26
+ const otherId = conn.start?.widgetId === widgetId ? conn.end?.widgetId : conn.start?.widgetId
27
+
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,
31
+ )
32
+ if (otherConnections.length !== 1) return null
33
+
34
+ const other = bridge.widgets.find((w) => w.id === otherId)
35
+ if (other && isSplitScreenCapable(other.type)) return other
36
+ return null
37
+ }
38
+
39
+ /**
40
+ * Get the x-coordinate position of a widget from bridge state.
41
+ * @param {string} widgetId
42
+ * @returns {number}
43
+ */
44
+ export function getWidgetX(widgetId) {
45
+ const bridge = window.__storyboardCanvasBridgeState
46
+ if (!bridge?.widgets) return 0
47
+ const w = bridge.widgets.find((w) => w.id === widgetId)
48
+ return w?.position?.x ?? 0
49
+ }
50
+
51
+ /**
52
+ * Determine pane order (left/right) based on x-coordinates.
53
+ * Returns { left, right } where each is 'primary' or 'secondary'.
54
+ * @param {string} primaryId — the widget being expanded
55
+ * @param {{ id: string, position?: { x: number } }} secondaryWidget
56
+ * @returns {{ primaryIsLeft: boolean }}
57
+ */
58
+ export function getPaneOrder(primaryId, secondaryWidget) {
59
+ const primaryX = getWidgetX(primaryId)
60
+ const secondaryX = secondaryWidget?.position?.x ?? 0
61
+ return { primaryIsLeft: primaryX <= secondaryX }
62
+ }
63
+
64
+ /**
65
+ * Build an iframe URL for a widget to render in a secondary pane.
66
+ * Returns null if the widget type isn't iframe-embeddable.
67
+ * @param {{ type: string, props: Object }} widget
68
+ * @returns {string | null}
69
+ */
70
+ export function buildSecondaryIframeUrl(widget) {
71
+ if (!widget) return null
72
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
73
+ const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
74
+
75
+ if (widget.type === 'prototype') {
76
+ const src = widget.props?.src
77
+ if (!src) return null
78
+ if (/^https?:\/\//.test(src)) return src
79
+ return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype`
80
+ }
81
+
82
+ if (widget.type === 'figma-embed') {
83
+ const url = widget.props?.url
84
+ if (!url) return null
85
+ // Inline a minimal figma embed URL builder to avoid circular deps
86
+ try {
87
+ const u = new URL(url)
88
+ if (!u.hostname.endsWith('figma.com')) return null
89
+ return `https://www.figma.com/embed?embed_host=storyboard&url=${encodeURIComponent(url)}`
90
+ } catch { return null }
91
+ }
92
+
93
+ if (widget.type === 'codepen-embed') {
94
+ const url = widget.props?.url
95
+ if (!url) return null
96
+ try {
97
+ const u = new URL(url)
98
+ if (!u.hostname.endsWith('codepen.io')) return null
99
+ const path = u.pathname.replace(/\/(pen|full|details)\//, '/embed/')
100
+ return `https://codepen.io${path}?default-tab=result`
101
+ } catch { return null }
102
+ }
103
+
104
+ if (widget.type === 'story') {
105
+ const storyId = widget.props?.storyId
106
+ const exportName = widget.props?.exportName
107
+ if (!storyId) return null
108
+ const storyData = typeof window !== 'undefined' && window.__storyboardStoryIndex?.[storyId]
109
+ if (storyData?._route) {
110
+ const params = new URLSearchParams()
111
+ if (exportName) params.set('export', exportName)
112
+ params.set('_sb_embed', '')
113
+ params.set('_sb_hide_branch_bar', '')
114
+ return `${baseClean}${storyData._route}?${params}`
115
+ }
116
+ return null
117
+ }
118
+
119
+ return null
120
+ }
121
+
122
+ /**
123
+ * Reparent a terminal widget's xterm container into a target element.
124
+ * Returns a cleanup function to restore the original position.
125
+ * @param {string} widgetId
126
+ * @param {HTMLElement} targetEl
127
+ * @returns {(() => void) | null}
128
+ */
129
+ export function reparentTerminalInto(widgetId, targetEl) {
130
+ const widgetEl = document.querySelector(`[data-widget-id="${widgetId}"]`)
131
+ if (!widgetEl) return null
132
+
133
+ const xtermEl = widgetEl.querySelector('[class*="xtermContainer"]')
134
+ if (!xtermEl) return null
135
+
136
+ const originalParent = xtermEl.parentElement
137
+ const originalNextSibling = xtermEl.nextSibling
138
+
139
+ targetEl.appendChild(xtermEl)
140
+
141
+ return () => {
142
+ if (originalNextSibling) {
143
+ originalParent.insertBefore(xtermEl, originalNextSibling)
144
+ } else {
145
+ originalParent.appendChild(xtermEl)
146
+ }
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Build a "Type · Metadata" label for a widget in split-screen top bar.
152
+ * @param {{ type: string, props: Object }} widget
153
+ * @returns {string}
154
+ */
155
+ export function getSplitPaneLabel(widget) {
156
+ if (!widget) return ''
157
+ const meta = getWidgetMeta(widget.type)
158
+ const typeName = meta?.label || widget.type
159
+
160
+ if (widget.type === 'terminal' || widget.type === 'terminal-read') {
161
+ return `Terminal · ${widget.props?.prettyName || '…'}`
162
+ }
163
+ if (widget.type === 'prototype') {
164
+ return `Prototype · ${widget.props?.src || '…'}`
165
+ }
166
+ if (widget.type === 'figma-embed') {
167
+ const url = widget.props?.url || ''
168
+ let name = 'Figma'
169
+ try { name = new URL(url).pathname.split('/').pop() || 'Figma' } catch { /* */ }
170
+ return `Figma · ${name}`
171
+ }
172
+ if (widget.type === 'codepen-embed') {
173
+ return `CodePen · ${widget.props?.url || '…'}`
174
+ }
175
+ if (widget.type === 'story') {
176
+ return `Story · ${widget.props?.storyId || '…'}`
177
+ }
178
+ if (widget.type === 'markdown') {
179
+ const content = widget.props?.content || ''
180
+ const firstLine = content.split('\n').find((l) => l.trim()) || ''
181
+ const preview = firstLine.replace(/^#+\s*/, '').slice(0, 40)
182
+ return `Markdown · ${preview || '…'}`
183
+ }
184
+ if (widget.type === 'link-preview') {
185
+ return `${widget.props?.github ? 'GitHub' : 'Link'} · ${widget.props?.title || widget.props?.url || '…'}`
186
+ }
187
+ return typeName
188
+ }
@@ -7,6 +7,8 @@ import FigmaEmbed from './FigmaEmbed.jsx'
7
7
  import CodePenEmbed from './CodePenEmbed.jsx'
8
8
  import StoryWidget from './StoryWidget.jsx'
9
9
  import TerminalWidget from './TerminalWidget.jsx'
10
+ import TerminalReadWidget from './TerminalReadWidget.jsx'
11
+ import ActionWidget from './ActionWidget.jsx'
10
12
 
11
13
  /**
12
14
  * Maps widget type strings to their React components.
@@ -22,6 +24,9 @@ export const widgetRegistry = {
22
24
  'codepen-embed': CodePenEmbed,
23
25
  'story': StoryWidget,
24
26
  'terminal': TerminalWidget,
27
+ 'terminal-read': TerminalReadWidget,
28
+ 'action': ActionWidget,
29
+ 'agent': TerminalWidget,
25
30
  }
26
31
 
27
32
  /**
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tests for iframe snapshot display — single snapshot prop.
3
3
  */
4
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
- import { render, fireEvent, waitFor, act } from '@testing-library/react'
4
+ import { describe, it, expect, vi, afterEach } from 'vitest'
5
+ import { render } from '@testing-library/react'
6
6
  import PrototypeEmbed from './PrototypeEmbed.jsx'
7
7
  import StoryWidget from './StoryWidget.jsx'
8
8
 
@@ -54,9 +54,9 @@ afterEach(() => {
54
54
  document.querySelectorAll('[data-sb-canvas-theme]').forEach(el => el.remove())
55
55
  })
56
56
 
57
- describe('Snapshot display', () => {
57
+ describe('Snapshot display (snapshots removed — iframes always render)', () => {
58
58
  describe('PrototypeEmbed', () => {
59
- it('shows snapshot image when valid snapshot prop exists', () => {
59
+ it('renders iframe even when snapshot prop is provided', () => {
60
60
  const { wrapper } = renderInCanvas(
61
61
  <PrototypeEmbed
62
62
  id="proto-abc123"
@@ -72,13 +72,11 @@ describe('Snapshot display', () => {
72
72
  />
73
73
  )
74
74
 
75
- const img = wrapper.querySelector('img')
76
- expect(img).toBeInTheDocument()
77
- expect(img.src).toContain('snapshot-proto-abc123.webp')
78
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
75
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
76
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
79
77
  })
80
78
 
81
- it('falls back to snapshotLight for backward compat', () => {
79
+ it('renders iframe when snapshotLight prop is provided', () => {
82
80
  const { wrapper } = renderInCanvas(
83
81
  <PrototypeEmbed
84
82
  id="proto-abc123"
@@ -94,12 +92,11 @@ describe('Snapshot display', () => {
94
92
  />
95
93
  )
96
94
 
97
- const img = wrapper.querySelector('img')
98
- expect(img).toBeInTheDocument()
99
- expect(img.src).toContain('snapshot-proto-abc123--light.webp')
95
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
96
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
100
97
  })
101
98
 
102
- it('shows placeholder when no snapshot exists', () => {
99
+ it('renders iframe when no snapshot exists', () => {
103
100
  const { wrapper } = renderInCanvas(
104
101
  <PrototypeEmbed
105
102
  id="proto-xyz"
@@ -110,32 +107,10 @@ describe('Snapshot display', () => {
110
107
  )
111
108
 
112
109
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
113
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
114
- })
115
-
116
- it('falls back to placeholder when snapshot image fails to load', () => {
117
- const { wrapper } = renderInCanvas(
118
- <PrototypeEmbed
119
- id="proto-abc123"
120
- props={{
121
- src: '/test',
122
- width: 400,
123
- height: 300,
124
- zoom: 100,
125
- snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
126
- }}
127
- onUpdate={vi.fn()}
128
- resizable={false}
129
- />
130
- )
131
-
132
- const img = wrapper.querySelector('img')
133
- expect(img).toBeInTheDocument()
134
- fireEvent.error(img)
135
- expect(wrapper.querySelector('img')).not.toBeInTheDocument()
110
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
136
111
  })
137
112
 
138
- it('ignores snapshot that does not match widget ID', () => {
113
+ it('ignores snapshot prop that does not match widget ID', () => {
139
114
  const { wrapper } = renderInCanvas(
140
115
  <PrototypeEmbed
141
116
  id="proto-abc123"
@@ -152,9 +127,10 @@ describe('Snapshot display', () => {
152
127
  )
153
128
 
154
129
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
130
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
155
131
  })
156
132
 
157
- it('does not show snapshot for external URLs', () => {
133
+ it('renders iframe for external URLs regardless of snapshot', () => {
158
134
  const { wrapper } = renderInCanvas(
159
135
  <PrototypeEmbed
160
136
  id="proto-ext"
@@ -171,11 +147,12 @@ describe('Snapshot display', () => {
171
147
  )
172
148
 
173
149
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
150
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
174
151
  })
175
152
  })
176
153
 
177
154
  describe('StoryWidget', () => {
178
- it('shows snapshot image when valid snapshot prop exists', () => {
155
+ it('renders iframe even when snapshot prop is provided', () => {
179
156
  const { wrapper } = renderInCanvas(
180
157
  <StoryWidget
181
158
  id="story-abc123"
@@ -191,13 +168,11 @@ describe('Snapshot display', () => {
191
168
  />
192
169
  )
193
170
 
194
- const img = wrapper.querySelector('img')
195
- expect(img).toBeInTheDocument()
196
- expect(img.src).toContain('snapshot-story-abc123.webp')
197
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
171
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
172
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
198
173
  })
199
174
 
200
- it('falls back to snapshotDark for backward compat', () => {
175
+ it('renders iframe when snapshotDark prop is provided', () => {
201
176
  const { wrapper } = renderInCanvas(
202
177
  <StoryWidget
203
178
  id="story-abc123"
@@ -210,50 +185,27 @@ describe('Snapshot display', () => {
210
185
  />
211
186
  )
212
187
 
213
- const img = wrapper.querySelector('img')
214
- expect(img).toBeInTheDocument()
215
- expect(img.src).toContain('snapshot-story-abc123--dark.webp')
216
- })
217
-
218
- it('shows placeholder when no snapshot exists', () => {
219
- const { wrapper } = renderInCanvas(
220
- <StoryWidget
221
- id="story-xyz"
222
- props={{
223
- storyId: 'button-patterns',
224
- exportName: 'Primary',
225
- width: 400,
226
- height: 300,
227
- }}
228
- onUpdate={vi.fn()}
229
- resizable={false}
230
- />
231
- )
232
-
233
188
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
234
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
189
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
235
190
  })
236
191
 
237
- it('falls back to placeholder when snapshot image fails to load', () => {
192
+ it('renders iframe when no snapshot exists', () => {
238
193
  const { wrapper } = renderInCanvas(
239
194
  <StoryWidget
240
- id="story-abc123"
195
+ id="story-xyz"
241
196
  props={{
242
197
  storyId: 'button-patterns',
243
198
  exportName: 'Primary',
244
199
  width: 400,
245
200
  height: 300,
246
- snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
247
201
  }}
248
202
  onUpdate={vi.fn()}
249
203
  resizable={false}
250
204
  />
251
205
  )
252
206
 
253
- const img = wrapper.querySelector('img')
254
- expect(img).toBeInTheDocument()
255
- fireEvent.error(img)
256
207
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
208
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
257
209
  })
258
210
  })
259
211
  })
@@ -151,6 +151,24 @@ export function getWidgetMeta(type) {
151
151
  return { label: def.label, icon: def.icon }
152
152
  }
153
153
 
154
+ /**
155
+ * Check if a widget type supports expanding to a full-screen modal.
156
+ * @param {string} type — widget type string
157
+ * @returns {boolean}
158
+ */
159
+ export function isExpandable(type) {
160
+ return widgetTypes[type]?.expandable === true
161
+ }
162
+
163
+ /**
164
+ * Check if a widget type can appear in a split-screen pane.
165
+ * @param {string} type — widget type string
166
+ * @returns {boolean}
167
+ */
168
+ export function isSplitScreenCapable(type) {
169
+ return widgetTypes[type]?.splitScreen === true
170
+ }
171
+
154
172
  /**
155
173
  * Get the interact gate config for a widget type.
156
174
  * @returns {{ enabled: boolean, label: string }}
@@ -170,7 +188,7 @@ export function getInteractGate(type) {
170
188
  */
171
189
  export function getMenuWidgetTypes() {
172
190
  return Object.entries(widgetTypes)
173
- .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
191
+ .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story' && type !== 'terminal-read')
174
192
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
175
193
  }
176
194