@dfosco/storyboard-react 4.2.0-beta.2 → 4.2.0-beta.21

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 +9 -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,240 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, act } from '@testing-library/react'
3
+ import ExpandedPane from './ExpandedPane.jsx'
4
+
5
+ // Mock createPortal to render inline for testing
6
+ vi.mock('react-dom', async () => {
7
+ const actual = await vi.importActual('react-dom')
8
+ return {
9
+ ...actual,
10
+ createPortal: (children) => children,
11
+ }
12
+ })
13
+
14
+ // Polyfill ResizeObserver for jsdom
15
+ if (typeof globalThis.ResizeObserver === 'undefined') {
16
+ globalThis.ResizeObserver = class {
17
+ constructor(cb) { this._cb = cb }
18
+ observe() {}
19
+ unobserve() {}
20
+ disconnect() {}
21
+ }
22
+ }
23
+
24
+ function makeReactPane(id, label = `Pane ${id}`) {
25
+ return {
26
+ id,
27
+ label,
28
+ kind: 'react',
29
+ render: () => <div data-testid={`content-${id}`}>{label} content</div>,
30
+ }
31
+ }
32
+
33
+ function makeExternalPane(id, label = `External ${id}`) {
34
+ const detach = vi.fn()
35
+ const attach = vi.fn(() => detach)
36
+ const onResize = vi.fn()
37
+ return {
38
+ pane: { id, label, kind: 'external', attach, onResize },
39
+ attach,
40
+ detach,
41
+ onResize,
42
+ }
43
+ }
44
+
45
+ describe('ExpandedPane', () => {
46
+ describe('single-pane modal variant', () => {
47
+ it('renders modal container with title and content', () => {
48
+ const pane = makeReactPane('md-1', 'Markdown · Notes')
49
+ render(<ExpandedPane initialPanes={[pane]} variant="modal" onClose={vi.fn()} />)
50
+ expect(screen.getByText('Markdown · Notes')).toBeTruthy()
51
+ expect(screen.getByText('Markdown · Notes content')).toBeTruthy()
52
+ expect(screen.getByLabelText('Close expanded view')).toBeTruthy()
53
+ })
54
+
55
+ it('calls onClose when close button is clicked', () => {
56
+ const onClose = vi.fn()
57
+ render(<ExpandedPane initialPanes={[makeReactPane('md-1')]} variant="modal" onClose={onClose} />)
58
+ fireEvent.click(screen.getByLabelText('Close expanded view'))
59
+ expect(onClose).toHaveBeenCalledOnce()
60
+ })
61
+
62
+ it('calls onClose on Escape key', () => {
63
+ const onClose = vi.fn()
64
+ render(<ExpandedPane initialPanes={[makeReactPane('md-1')]} variant="modal" onClose={onClose} />)
65
+ fireEvent.keyDown(document, { key: 'Escape' })
66
+ expect(onClose).toHaveBeenCalledOnce()
67
+ })
68
+ })
69
+
70
+ describe('single-pane full variant', () => {
71
+ it('renders full-screen container with top bar', () => {
72
+ const pane = makeReactPane('term-1', 'Terminal · wren')
73
+ render(<ExpandedPane initialPanes={[pane]} variant="full" onClose={vi.fn()} />)
74
+ expect(screen.getByText('Terminal · wren')).toBeTruthy()
75
+ expect(screen.getByText('Terminal · wren content')).toBeTruthy()
76
+ expect(screen.getByLabelText('Close expanded view')).toBeTruthy()
77
+ })
78
+ })
79
+
80
+ describe('multi-pane (split) layout', () => {
81
+ it('renders both panes with grid layout', () => {
82
+ const paneA = makeReactPane('term-1', 'Terminal · wren')
83
+ const paneB = makeReactPane('proto-1', 'Prototype · /Signup')
84
+ const { container } = render(
85
+ <ExpandedPane initialPanes={[paneA, paneB]} variant="full" onClose={vi.fn()} />
86
+ )
87
+ expect(screen.getByText('Terminal · wren content')).toBeTruthy()
88
+ expect(screen.getByText('Prototype · /Signup content')).toBeTruthy()
89
+ // Check grid exists
90
+ const grid = container.querySelector('[class*="grid"]')
91
+ expect(grid).toBeTruthy()
92
+ })
93
+
94
+ it('renders 3 panes', () => {
95
+ const panes = [
96
+ makeReactPane('a', 'Pane A'),
97
+ makeReactPane('b', 'Pane B'),
98
+ makeReactPane('c', 'Pane C'),
99
+ ]
100
+ render(<ExpandedPane initialPanes={panes} variant="full" onClose={vi.fn()} />)
101
+ expect(screen.getByText('Pane A content')).toBeTruthy()
102
+ expect(screen.getByText('Pane B content')).toBeTruthy()
103
+ expect(screen.getByText('Pane C content')).toBeTruthy()
104
+ })
105
+
106
+ it('renders both pane labels in their respective panes', () => {
107
+ const paneA = makeReactPane('term-1', 'Terminal · wren')
108
+ const paneB = makeReactPane('proto-1', 'Prototype · /Signup')
109
+ render(
110
+ <ExpandedPane initialPanes={[paneA, paneB]} variant="full" onClose={vi.fn()} />
111
+ )
112
+ // Each pane has its own title bar with left-aligned label
113
+ expect(screen.getByText('Terminal · wren')).toBeTruthy()
114
+ expect(screen.getByText('Prototype · /Signup')).toBeTruthy()
115
+ })
116
+
117
+ it('renders divider between panes', () => {
118
+ const panes = [makeReactPane('a'), makeReactPane('b')]
119
+ const { container } = render(
120
+ <ExpandedPane initialPanes={panes} variant="full" onClose={vi.fn()} />
121
+ )
122
+ const dividers = container.querySelectorAll('[role="separator"]')
123
+ expect(dividers.length).toBe(1)
124
+ })
125
+
126
+ it('renders N-1 dividers for N panes', () => {
127
+ const panes = [makeReactPane('a'), makeReactPane('b'), makeReactPane('c')]
128
+ const { container } = render(
129
+ <ExpandedPane initialPanes={panes} variant="full" onClose={vi.fn()} />
130
+ )
131
+ const dividers = container.querySelectorAll('[role="separator"]')
132
+ expect(dividers.length).toBe(2)
133
+ })
134
+ })
135
+
136
+ describe('external pane attach/detach', () => {
137
+ it('calls attach with container element on mount', async () => {
138
+ const { pane, attach, detach } = makeExternalPane('term-1')
139
+ render(<ExpandedPane initialPanes={[pane]} variant="full" onClose={vi.fn()} />)
140
+ // useLayoutEffect runs synchronously in test
141
+ expect(attach).toHaveBeenCalledOnce()
142
+ expect(attach.mock.calls[0][0]).toBeInstanceOf(HTMLElement)
143
+ })
144
+
145
+ it('calls detach on unmount', () => {
146
+ const { pane, detach } = makeExternalPane('term-1')
147
+ const { unmount } = render(
148
+ <ExpandedPane initialPanes={[pane]} variant="full" onClose={vi.fn()} />
149
+ )
150
+ unmount()
151
+ expect(detach).toHaveBeenCalled()
152
+ })
153
+ })
154
+
155
+ describe('returns null for empty panes', () => {
156
+ it('returns null when no panes provided', () => {
157
+ const { container } = render(
158
+ <ExpandedPane initialPanes={[]} variant="modal" onClose={vi.fn()} />
159
+ )
160
+ // Modal variant with no panes returns null
161
+ expect(container.innerHTML).toBe('')
162
+ })
163
+ })
164
+
165
+ describe('initialLayout (2D layout)', () => {
166
+ it('renders 2 columns from initialLayout', () => {
167
+ const layout = [
168
+ [makeReactPane('a', 'Left')],
169
+ [makeReactPane('b', 'Right')],
170
+ ]
171
+ const { container } = render(
172
+ <ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
173
+ )
174
+ expect(screen.getByText('Left content')).toBeTruthy()
175
+ expect(screen.getByText('Right content')).toBeTruthy()
176
+ const grid = container.querySelector('[class*="grid"]')
177
+ expect(grid).toBeTruthy()
178
+ })
179
+
180
+ it('renders 3 panes: 1 column + 2-row column', () => {
181
+ const layout = [
182
+ [makeReactPane('a', 'Left')],
183
+ [makeReactPane('b', 'Top Right'), makeReactPane('c', 'Bottom Right')],
184
+ ]
185
+ render(
186
+ <ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
187
+ )
188
+ expect(screen.getByText('Left content')).toBeTruthy()
189
+ expect(screen.getByText('Top Right content')).toBeTruthy()
190
+ expect(screen.getByText('Bottom Right content')).toBeTruthy()
191
+ })
192
+
193
+ it('renders 4 panes in 2×2 grid', () => {
194
+ const layout = [
195
+ [makeReactPane('a', 'TL'), makeReactPane('b', 'BL')],
196
+ [makeReactPane('c', 'TR'), makeReactPane('d', 'BR')],
197
+ ]
198
+ render(
199
+ <ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
200
+ )
201
+ expect(screen.getByText('TL content')).toBeTruthy()
202
+ expect(screen.getByText('BL content')).toBeTruthy()
203
+ expect(screen.getByText('TR content')).toBeTruthy()
204
+ expect(screen.getByText('BR content')).toBeTruthy()
205
+ })
206
+
207
+ it('renders column divider between columns', () => {
208
+ const layout = [
209
+ [makeReactPane('a')],
210
+ [makeReactPane('b')],
211
+ ]
212
+ const { container } = render(
213
+ <ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
214
+ )
215
+ const vSeparators = container.querySelectorAll('[role="separator"][aria-orientation="vertical"]')
216
+ expect(vSeparators.length).toBe(1)
217
+ })
218
+
219
+ it('renders row dividers within 2-row columns', () => {
220
+ const layout = [
221
+ [makeReactPane('a'), makeReactPane('b')],
222
+ [makeReactPane('c'), makeReactPane('d')],
223
+ ]
224
+ const { container } = render(
225
+ <ExpandedPane initialLayout={layout} variant="full" onClose={vi.fn()} />
226
+ )
227
+ const hSeparators = container.querySelectorAll('[role="separator"][aria-orientation="horizontal"]')
228
+ expect(hSeparators.length).toBe(2) // one per 2-row column
229
+ })
230
+
231
+ it('renders single-pane modal from 1-element layout', () => {
232
+ const layout = [[makeReactPane('a', 'Only Pane')]]
233
+ render(
234
+ <ExpandedPane initialLayout={layout} variant="modal" onClose={vi.fn()} />
235
+ )
236
+ expect(screen.getByText('Only Pane')).toBeTruthy()
237
+ expect(screen.getByLabelText('Close expanded view')).toBeTruthy()
238
+ })
239
+ })
240
+ })
@@ -0,0 +1,111 @@
1
+ /**
2
+ * ExpandedPaneTopBar — dark per-pane title bar for expanded/split-screen views.
3
+ *
4
+ * Each pane gets its own bar with a left-aligned label.
5
+ * The rightmost (or only) pane shows the close button.
6
+ *
7
+ * Actions render as toolbar-style buttons (same as WidgetChrome) forced to dark mode.
8
+ * Tooltips show labels on hover (Primer <Tooltip>, not title attr).
9
+ *
10
+ * Supports two action sources:
11
+ * 1. `actions` array/function — legacy inline actions ({ icon, label, ariaLabel, onClick })
12
+ * 2. `features` array — config-driven features resolved via ICON_REGISTRY
13
+ * Combined with `getState` for toggle resolution and `onAction` for dispatch.
14
+ */
15
+ import { ScreenNormalIcon } from '@primer/octicons-react'
16
+ import { Tooltip } from '@primer/react'
17
+ import { ICON_REGISTRY } from './widgetIcons.jsx'
18
+ import { getWidgetMeta } from './widgetConfig.js'
19
+ import Icon from '../../Icon.jsx'
20
+ import styles from './ExpandedPaneTopBar.module.css'
21
+
22
+ /** Named icons use only lowercase alphanumeric, hyphens, and slashes. */
23
+ function isNamedIcon(str) {
24
+ return str && /^[a-z0-9/-]+$/i.test(str)
25
+ }
26
+
27
+ /**
28
+ * Resolve a feature's icon and label, applying toggle state if configured.
29
+ * @param {Object} feature — config feature object
30
+ * @param {((key: string) => any) | undefined} getState — state accessor
31
+ * @returns {{ Icon: Function|null, label: string }}
32
+ */
33
+ function resolveFeatureDisplay(feature, getState) {
34
+ let Icon = ICON_REGISTRY[feature.icon]
35
+ let label = feature.label || feature.action
36
+
37
+ if (feature.toggle && getState) {
38
+ const isActive = getState(feature.toggle.stateKey)
39
+ if (isActive) {
40
+ if (feature.toggle.activeIcon) Icon = ICON_REGISTRY[feature.toggle.activeIcon] || Icon
41
+ if (feature.toggle.activeLabel) label = feature.toggle.activeLabel
42
+ }
43
+ }
44
+
45
+ return { Icon, label }
46
+ }
47
+
48
+ /**
49
+ * @param {Object} props
50
+ * @param {string} props.label — pane display label
51
+ * @param {string} [props.widgetType] — widget type string for icon resolution
52
+ * @param {boolean} [props.showClose] — show close button (rightmost pane)
53
+ * @param {() => void} [props.onClose] — close entire ExpandedPane
54
+ * @param {Array<{ label: string, onClick: () => void }>} [props.actions] — legacy action buttons
55
+ * @param {Array} [props.features] — config-driven features for this surface
56
+ * @param {(key: string) => any} [props.getState] — state accessor for toggle resolution
57
+ * @param {(actionId: string) => void} [props.onAction] — action dispatch callback
58
+ */
59
+ export default function ExpandedPaneTopBar({ label, widgetType, showClose, onClose, actions, features, getState, onAction }) {
60
+ const resolvedActions = typeof actions === 'function' ? actions() : actions
61
+ const meta = widgetType ? getWidgetMeta(widgetType) : null
62
+ const iconName = meta?.icon || null
63
+
64
+ return (
65
+ <div className={styles.bar}>
66
+ {iconName && (
67
+ <span className={styles.widgetIcon} aria-hidden="true">
68
+ {isNamedIcon(iconName) ? <Icon name={iconName} size={12} /> : iconName}
69
+ </span>
70
+ )}
71
+ <span className={styles.label}>{label}</span>
72
+
73
+ {/* Config-driven feature actions */}
74
+ {features?.map((feature) => {
75
+ const { Icon, label: featureLabel } = resolveFeatureDisplay(feature, getState)
76
+ return (
77
+ <Tooltip key={feature.id} text={featureLabel} direction="s">
78
+ <button
79
+ className={styles.actionBtn}
80
+ onClick={() => onAction?.(feature.action)}
81
+ aria-label={featureLabel}
82
+ >
83
+ {Icon ? <Icon /> : featureLabel}
84
+ </button>
85
+ </Tooltip>
86
+ )
87
+ })}
88
+
89
+ {/* Legacy inline actions (backward compat) */}
90
+ {resolvedActions?.map((action, i) => (
91
+ <Tooltip key={i} text={action.label || action.ariaLabel || 'Action'} direction="s">
92
+ <button
93
+ className={styles.actionBtn}
94
+ onClick={action.onClick}
95
+ aria-label={action.ariaLabel || action.label}
96
+ >
97
+ {action.icon || action.label}
98
+ </button>
99
+ </Tooltip>
100
+ ))}
101
+
102
+ {showClose && (
103
+ <Tooltip text="Close fullscreen" direction="s">
104
+ <button className={styles.actionBtn} onClick={onClose} aria-label="Close expanded view" autoFocus>
105
+ <ScreenNormalIcon size={12} />
106
+ </button>
107
+ </Tooltip>
108
+ )}
109
+ </div>
110
+ )
111
+ }
@@ -0,0 +1,59 @@
1
+ /* ── ExpandedPaneTopBar — dark per-pane title bar ─────────────────── */
2
+
3
+ .bar {
4
+ display: flex;
5
+ align-items: center;
6
+ height: 40px;
7
+ padding: 0 12px;
8
+ gap: 6px;
9
+ background: #21262d;
10
+ border-bottom: 1px solid #30363d;
11
+ flex-shrink: 0;
12
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
13
+ font-size: 12px;
14
+ font-weight: 500;
15
+ user-select: none;
16
+ }
17
+
18
+ .widgetIcon {
19
+ flex-shrink: 0;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ color: #8b949e;
24
+ font-size: 12px;
25
+ line-height: 1;
26
+ }
27
+
28
+ .label {
29
+ white-space: nowrap;
30
+ overflow: hidden;
31
+ text-overflow: ellipsis;
32
+ min-width: 0;
33
+ color: #8b949e;
34
+ margin-right: auto;
35
+ }
36
+
37
+ /* Action buttons — same shape/size as widget toolbar, forced dark mode */
38
+ .actionBtn {
39
+ all: unset;
40
+ cursor: pointer;
41
+ flex-shrink: 0;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: center;
45
+ width: 24px;
46
+ height: 24px;
47
+ border-radius: 12px;
48
+ border: 1.6px solid #373e47;
49
+ background: #161b22;
50
+ color: #8b949e;
51
+ font-size: 12px;
52
+ transition: background 100ms, color 100ms, border-color 100ms;
53
+ }
54
+
55
+ .actionBtn:hover {
56
+ background: #272c33;
57
+ color: #e6edf3;
58
+ border-color: #484f58;
59
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import ExpandedPaneTopBar from './ExpandedPaneTopBar.jsx'
4
+
5
+ describe('ExpandedPaneTopBar', () => {
6
+ it('renders pane label left-aligned', () => {
7
+ render(
8
+ <ExpandedPaneTopBar label="Terminal · pearl-wren" onClose={vi.fn()} />
9
+ )
10
+ expect(screen.getByText('Terminal · pearl-wren')).toBeTruthy()
11
+ })
12
+
13
+ it('renders close button when showClose is true', () => {
14
+ render(
15
+ <ExpandedPaneTopBar label="Terminal · pearl-wren" showClose onClose={vi.fn()} />
16
+ )
17
+ expect(screen.getByLabelText('Close expanded view')).toBeTruthy()
18
+ })
19
+
20
+ it('does not render close button when showClose is false', () => {
21
+ render(
22
+ <ExpandedPaneTopBar label="Terminal · pearl-wren" onClose={vi.fn()} />
23
+ )
24
+ expect(screen.queryByLabelText('Close expanded view')).toBeNull()
25
+ })
26
+
27
+ it('calls onClose when close button is clicked', () => {
28
+ const onClose = vi.fn()
29
+ render(
30
+ <ExpandedPaneTopBar label="Terminal · pearl-wren" showClose onClose={onClose} />
31
+ )
32
+ fireEvent.click(screen.getByLabelText('Close expanded view'))
33
+ expect(onClose).toHaveBeenCalledOnce()
34
+ })
35
+
36
+ it('has dark background styling', () => {
37
+ const { container } = render(
38
+ <ExpandedPaneTopBar label="Agent · wren" showClose onClose={vi.fn()} />
39
+ )
40
+ const bar = container.firstChild
41
+ expect(bar).toBeTruthy()
42
+ // Bar should have the dark background class
43
+ expect(bar.className).toMatch(/bar/)
44
+ })
45
+ })
@@ -1,10 +1,11 @@
1
1
  import { forwardRef, useImperativeHandle, useMemo, useCallback, useState, useEffect, useRef } from 'react'
2
- import { createPortal } from 'react-dom'
3
2
  import WidgetWrapper from './WidgetWrapper.jsx'
4
3
  import { readProp } from './widgetProps.js'
5
4
  import { schemas } from './widgetConfig.js'
6
5
  import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
7
6
  import { useIframeDevLogs } from './iframeDevLogs.js'
7
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
8
+ import ExpandedPane from './ExpandedPane.jsx'
8
9
  import styles from './FigmaEmbed.module.css'
9
10
  import overlayStyles from './embedOverlay.module.css'
10
11
 
@@ -49,14 +50,15 @@ function FigmaLogo() {
49
50
 
50
51
  const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
51
52
 
52
- export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, ref) {
53
+ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
53
54
  const url = readProp(props, 'url', figmaEmbedSchema)
54
55
  const width = readProp(props, 'width', figmaEmbedSchema)
55
56
  const height = readProp(props, 'height', figmaEmbedSchema)
56
57
 
57
58
  const [interactive, setInteractive] = useState(false)
58
59
  const [showIframe, setShowIframe] = useState(true)
59
- const [expanded, setExpanded] = useState(false)
60
+ const [expandMode, setExpandMode] = useState(null)
61
+ const expanded = expandMode !== null
60
62
 
61
63
  const iframeRef = useRef(null)
62
64
  const embedRef = useRef(null)
@@ -105,19 +107,6 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
105
107
 
106
108
  useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
107
109
 
108
- // Close expanded modal on Escape
109
- useEffect(() => {
110
- if (!expanded) return
111
- function handleKeyDown(e) {
112
- if (e.key === 'Escape') {
113
- e.stopPropagation()
114
- setExpanded(false)
115
- }
116
- }
117
- document.addEventListener('keydown', handleKeyDown, true)
118
- return () => document.removeEventListener('keydown', handleKeyDown, true)
119
- }, [expanded])
120
-
121
110
  // Reparent iframe DOM node between inline container and modal.
122
111
  // Uses moveBefore() (Chrome 133+) which preserves the iframe's
123
112
  // browsing context — no reload. Falls back to appendChild.
@@ -156,9 +145,12 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
156
145
  handleAction(actionId) {
157
146
  if (actionId === 'open-external') {
158
147
  if (url) window.open(url, '_blank', 'noopener')
159
- } else if (actionId === 'expand') {
148
+ } else if (actionId === 'expand' || actionId === 'expand-single') {
160
149
  setShowIframe(true)
161
- setExpanded(true)
150
+ setExpandMode('single')
151
+ } else if (actionId === 'split-screen') {
152
+ setShowIframe(true)
153
+ setExpandMode('split')
162
154
  }
163
155
  },
164
156
  }), [url])
@@ -185,6 +177,7 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
185
177
  className={styles.iframe}
186
178
  title={`Figma ${typeLabel}: ${title}`}
187
179
  allowFullScreen
180
+ onLoad={(e) => e.target.blur()}
188
181
  />
189
182
  </div>
190
183
  ) : (
@@ -246,36 +239,58 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
246
239
  />
247
240
  )}
248
241
  </WidgetWrapper>
249
- {createPortal(
250
- <div
251
- className={styles.expandBackdrop}
252
- style={expanded && embedUrl ? undefined : { display: 'none' }}
253
- onClick={() => setExpanded(false)}
254
- onPointerDown={(e) => e.stopPropagation()}
255
- onKeyDown={(e) => {
256
- e.stopPropagation()
257
- if (e.key === 'Escape') setExpanded(false)
258
- }}
259
- onWheel={(e) => e.stopPropagation()}
260
- tabIndex={-1}
261
- ref={(el) => { if (el && expanded) el.focus() }}
262
- >
263
- <div
264
- ref={modalContainerRef}
265
- className={styles.expandContainer}
266
- onClick={(e) => e.stopPropagation()}
267
- >
268
- {/* iframe is reparented here via useEffect */}
269
- <button
270
- className={styles.expandClose}
271
- onClick={() => setExpanded(false)}
272
- aria-label="Close expanded view"
273
- autoFocus
274
- >✕</button>
275
- </div>
276
- </div>,
277
- document.body
242
+ {expanded && !!embedUrl && (
243
+ <FigmaExpandPane
244
+ widgetId={widgetId}
245
+ modalContainerRef={modalContainerRef}
246
+ splitMode={expandMode === 'split'}
247
+ onClose={() => setExpandMode(null)}
248
+ />
278
249
  )}
279
250
  </>
280
251
  )
281
252
  })
253
+
254
+ /**
255
+ * Builds pane configs and renders ExpandedPane for an expanded Figma widget.
256
+ * The primary pane is an external pane that receives the iframe via reparenting.
257
+ */
258
+ function FigmaExpandPane({ widgetId, modalContainerRef, splitMode, onClose }) {
259
+ const connectedWidgets = useMemo(
260
+ () => splitMode ? findAllConnectedSplitTargets(widgetId) : [],
261
+ [widgetId, splitMode],
262
+ )
263
+ const primaryWidget = useMemo(() => {
264
+ const bridge = window.__storyboardCanvasBridgeState
265
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'figma-embed', position: { x: 0, y: 0 }, props: {} }
266
+ }, [widgetId])
267
+
268
+ const buildPaneFn = useCallback((widget) => {
269
+ if (widget.id === widgetId) {
270
+ return {
271
+ id: widgetId,
272
+ label: getSplitPaneLabel(primaryWidget),
273
+ widgetType: 'figma-embed',
274
+ kind: 'external',
275
+ attach: (container) => {
276
+ modalContainerRef.current = container
277
+ return () => { modalContainerRef.current = null }
278
+ },
279
+ }
280
+ }
281
+ return buildPaneForWidget(widget)
282
+ }, [widgetId, primaryWidget, modalContainerRef])
283
+
284
+ const layout = useMemo(
285
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
286
+ [primaryWidget, connectedWidgets, buildPaneFn],
287
+ )
288
+
289
+ return (
290
+ <ExpandedPane
291
+ initialLayout={layout}
292
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
293
+ onClose={onClose}
294
+ />
295
+ )
296
+ }
@@ -139,6 +139,67 @@
139
139
  background: rgba(0, 0, 0, 0.7);
140
140
  }
141
141
 
142
+ /* ── Split-screen layout ──────────────────────────────────────────── */
143
+
144
+ .expandContainerSplit {
145
+ flex: 1;
146
+ min-width: 0;
147
+ height: 100%;
148
+ position: relative;
149
+ overflow: hidden;
150
+ background: var(--bgColor-default, #ffffff);
151
+ }
152
+
153
+ .expandSplitBody {
154
+ position: fixed;
155
+ inset: 0;
156
+ display: flex;
157
+ flex-direction: column;
158
+ overflow: hidden;
159
+ animation: expandScaleIn 0.2s ease;
160
+ }
161
+
162
+ .expandSplitPanes {
163
+ flex: 1;
164
+ min-height: 0;
165
+ display: flex;
166
+ }
167
+
168
+ .expandSplitLeft {
169
+ flex: 1;
170
+ min-width: 0;
171
+ height: 100%;
172
+ overflow: hidden;
173
+ border-right: 1px solid var(--borderColor-muted, #d8dee4);
174
+ }
175
+
176
+ .expandSplitRight {
177
+ flex: 1;
178
+ min-width: 0;
179
+ height: 100%;
180
+ overflow: hidden;
181
+ }
182
+
183
+ .expandSecondary {
184
+ width: 100%;
185
+ height: 100%;
186
+ overflow: auto;
187
+ background: var(--bgColor-default, #ffffff);
188
+ }
189
+
190
+ .expandSecondaryIframe {
191
+ border: none;
192
+ width: 100%;
193
+ height: 100%;
194
+ display: block;
195
+ }
196
+
197
+ .expandTerminal {
198
+ width: 100%;
199
+ height: 100%;
200
+ background: #0d1117;
201
+ }
202
+
142
203
  .emptyState {
143
204
  width: 100%;
144
205
  height: calc(100% - 10px);