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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +3 -3
  2. package/src/BranchBar/BranchBar.jsx +3 -1
  3. package/src/BranchBar/BranchBar.module.css +2 -2
  4. package/src/BranchBar/useBranches.js +20 -6
  5. package/src/BranchBar/useBranches.test.js +68 -0
  6. package/src/CommandPalette/CommandPalette.jsx +250 -61
  7. package/src/CommandPalette/command-palette.css +12 -0
  8. package/src/Icon.jsx +46 -11
  9. package/src/Viewfinder.jsx +53 -133
  10. package/src/Viewfinder.module.css +20 -91
  11. package/src/Workspace.jsx +7 -0
  12. package/src/canvas/CanvasPage.jsx +601 -62
  13. package/src/canvas/CanvasPage.module.css +15 -2
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
  15. package/src/canvas/ConnectorLayer.jsx +120 -152
  16. package/src/canvas/ConnectorLayer.module.css +69 -0
  17. package/src/canvas/canvasApi.js +68 -2
  18. package/src/canvas/connectorGeometry.js +132 -0
  19. package/src/canvas/hotPoolDevLogs.js +25 -0
  20. package/src/canvas/useMarqueeSelect.js +30 -4
  21. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  22. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  23. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  25. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  26. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  27. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  28. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  29. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  30. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  31. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  32. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  33. package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
  34. package/src/canvas/widgets/ImageWidget.jsx +129 -8
  35. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  36. package/src/canvas/widgets/LinkPreview.jsx +93 -44
  37. package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
  38. package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
  39. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  40. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  41. package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
  42. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  43. package/src/canvas/widgets/StoryWidget.jsx +65 -11
  44. package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
  45. package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
  46. package/src/canvas/widgets/TerminalWidget.jsx +301 -124
  47. package/src/canvas/widgets/TerminalWidget.module.css +121 -12
  48. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  49. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  50. package/src/canvas/widgets/WidgetChrome.jsx +67 -152
  51. package/src/canvas/widgets/WidgetChrome.module.css +20 -1
  52. package/src/canvas/widgets/expandUtils.js +385 -16
  53. package/src/canvas/widgets/expandUtils.test.js +155 -0
  54. package/src/canvas/widgets/index.js +6 -2
  55. package/src/canvas/widgets/tilePool.js +23 -0
  56. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  57. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  58. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  59. package/src/canvas/widgets/tiles/leaf.png +0 -0
  60. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  61. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  62. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  63. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  64. package/src/canvas/widgets/widgetConfig.js +37 -4
  65. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  66. package/src/canvas/widgets/widgetProps.js +1 -0
  67. package/src/context.jsx +47 -19
  68. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  69. package/src/index.js +4 -2
  70. package/src/story/ComponentSetPage.jsx +186 -0
  71. package/src/story/ComponentSetPage.module.css +121 -0
  72. package/src/story/StoryPage.jsx +32 -2
  73. package/src/vite/data-plugin.js +79 -35
  74. package/src/canvas/widgets/ActionWidget.jsx +0 -200
  75. package/src/canvas/widgets/ActionWidget.module.css +0 -122
  76. package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
  77. package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
  78. package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
  79. package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
@@ -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,12 +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'
8
- import { findConnectedSplitTarget, getPaneOrder, buildSecondaryIframeUrl, reparentTerminalInto, getSplitPaneLabel } from './expandUtils.js'
9
- import SplitScreenTopBar from './SplitScreenTopBar.jsx'
7
+ import { findAllConnectedSplitTargets, getSplitPaneLabel, buildPaneForWidget, buildSplitLayout } from './expandUtils.js'
8
+ import ExpandedPane from './ExpandedPane.jsx'
10
9
  import styles from './FigmaEmbed.module.css'
11
10
  import overlayStyles from './embedOverlay.module.css'
12
11
 
@@ -58,7 +57,8 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
58
57
 
59
58
  const [interactive, setInteractive] = useState(false)
60
59
  const [showIframe, setShowIframe] = useState(true)
61
- const [expanded, setExpanded] = useState(false)
60
+ const [expandMode, setExpandMode] = useState(null)
61
+ const expanded = expandMode !== null
62
62
 
63
63
  const iframeRef = useRef(null)
64
64
  const embedRef = useRef(null)
@@ -107,19 +107,6 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
107
107
 
108
108
  useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
109
109
 
110
- // Close expanded modal on Escape
111
- useEffect(() => {
112
- if (!expanded) return
113
- function handleKeyDown(e) {
114
- if (e.key === 'Escape') {
115
- e.stopPropagation()
116
- setExpanded(false)
117
- }
118
- }
119
- document.addEventListener('keydown', handleKeyDown, true)
120
- return () => document.removeEventListener('keydown', handleKeyDown, true)
121
- }, [expanded])
122
-
123
110
  // Reparent iframe DOM node between inline container and modal.
124
111
  // Uses moveBefore() (Chrome 133+) which preserves the iframe's
125
112
  // browsing context — no reload. Falls back to appendChild.
@@ -158,9 +145,12 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
158
145
  handleAction(actionId) {
159
146
  if (actionId === 'open-external') {
160
147
  if (url) window.open(url, '_blank', 'noopener')
161
- } else if (actionId === 'expand' || actionId === 'split-screen') {
148
+ } else if (actionId === 'expand' || actionId === 'expand-single') {
149
+ setShowIframe(true)
150
+ setExpandMode('single')
151
+ } else if (actionId === 'split-screen') {
162
152
  setShowIframe(true)
163
- setExpanded(true)
153
+ setExpandMode('split')
164
154
  }
165
155
  },
166
156
  }), [url])
@@ -187,6 +177,7 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
187
177
  className={styles.iframe}
188
178
  title={`Figma ${typeLabel}: ${title}`}
189
179
  allowFullScreen
180
+ onLoad={(e) => e.target.blur()}
190
181
  />
191
182
  </div>
192
183
  ) : (
@@ -248,102 +239,58 @@ export default forwardRef(function FigmaEmbed({ id: widgetId, props, onUpdate, r
248
239
  />
249
240
  )}
250
241
  </WidgetWrapper>
251
- {createPortal(
252
- <FigmaExpandModal
253
- expanded={expanded && !!embedUrl}
254
- onClose={() => setExpanded(false)}
255
- modalContainerRef={modalContainerRef}
242
+ {expanded && !!embedUrl && (
243
+ <FigmaExpandPane
256
244
  widgetId={widgetId}
257
- />,
258
- document.body
245
+ modalContainerRef={modalContainerRef}
246
+ splitMode={expandMode === 'split'}
247
+ onClose={() => setExpandMode(null)}
248
+ />
259
249
  )}
260
250
  </>
261
251
  )
262
252
  })
263
253
 
264
- function FigmaExpandModal({ expanded, onClose, modalContainerRef, widgetId }) {
265
- const connectedWidget = useMemo(
266
- () => (expanded ? findConnectedSplitTarget(widgetId) : null),
267
- [expanded, widgetId],
268
- )
269
- const hasSplit = Boolean(connectedWidget)
270
- const paneOrder = useMemo(
271
- () => (hasSplit ? getPaneOrder(widgetId, connectedWidget) : { primaryIsLeft: true }),
272
- [hasSplit, widgetId, connectedWidget],
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],
273
262
  )
274
- const secondaryUrl = useMemo(() => buildSecondaryIframeUrl(connectedWidget), [connectedWidget])
275
- const isTerminalSecondary = connectedWidget?.type === 'terminal' || connectedWidget?.type === 'terminal-read' || connectedWidget?.type === 'agent'
276
- const terminalRef = useRef(null)
277
- const cleanupRef = useRef(null)
278
- const [activePane, setActivePane] = useState('left')
279
-
280
263
  const primaryWidget = useMemo(() => {
281
264
  const bridge = window.__storyboardCanvasBridgeState
282
- return bridge?.widgets?.find((w) => w.id === widgetId) || { type: 'figma-embed', props: {} }
283
- }, [widgetId, expanded])
284
-
285
- const primaryLabel = useMemo(() => getSplitPaneLabel(primaryWidget), [primaryWidget])
286
- const secondaryLabel = useMemo(() => getSplitPaneLabel(connectedWidget), [connectedWidget])
287
- const leftLabel = paneOrder.primaryIsLeft ? primaryLabel : secondaryLabel
288
- const rightLabel = paneOrder.primaryIsLeft ? secondaryLabel : primaryLabel
265
+ return bridge?.widgets?.find((w) => w.id === widgetId) || { id: widgetId, type: 'figma-embed', position: { x: 0, y: 0 }, props: {} }
266
+ }, [widgetId])
289
267
 
290
- useEffect(() => {
291
- if (!isTerminalSecondary || !expanded || !terminalRef.current) return
292
- cleanupRef.current = reparentTerminalInto(connectedWidget.id, terminalRef.current)
293
- return () => { cleanupRef.current?.(); cleanupRef.current = null }
294
- }, [isTerminalSecondary, expanded, connectedWidget?.id])
295
-
296
- const primaryPane = (
297
- <div
298
- ref={modalContainerRef}
299
- className={hasSplit ? styles.expandContainerSplit : styles.expandContainer}
300
- onClick={(e) => e.stopPropagation()}
301
- onPointerDown={() => setActivePane(paneOrder.primaryIsLeft ? 'left' : 'right')}
302
- >
303
- {!hasSplit && <button className={styles.expandClose} onClick={onClose} aria-label="Close expanded view" autoFocus>✕</button>}
304
- </div>
305
- )
306
-
307
- let secondaryPane = null
308
- const secondarySide = paneOrder.primaryIsLeft ? 'right' : 'left'
309
- if (hasSplit) {
310
- if (secondaryUrl) {
311
- secondaryPane = <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}><iframe src={secondaryUrl} className={styles.expandSecondaryIframe} title="Connected widget" /></div>
312
- } else if (isTerminalSecondary) {
313
- secondaryPane = <div className={styles.expandSecondary} onClick={(e) => e.stopPropagation()} onPointerDown={() => setActivePane(secondarySide)}><div ref={terminalRef} className={styles.expandTerminal} /></div>
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
+ }
314
280
  }
315
- }
281
+ return buildPaneForWidget(widget)
282
+ }, [widgetId, primaryWidget, modalContainerRef])
316
283
 
317
- const leftPane = paneOrder.primaryIsLeft ? primaryPane : secondaryPane
318
- const rightPane = paneOrder.primaryIsLeft ? secondaryPane : primaryPane
284
+ const layout = useMemo(
285
+ () => buildSplitLayout(primaryWidget, connectedWidgets, buildPaneFn),
286
+ [primaryWidget, connectedWidgets, buildPaneFn],
287
+ )
319
288
 
320
289
  return (
321
- <div
322
- className={styles.expandBackdrop}
323
- style={expanded ? undefined : { display: 'none' }}
324
- onClick={onClose}
325
- onPointerDown={(e) => e.stopPropagation()}
326
- onKeyDown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose() }}
327
- onWheel={(e) => e.stopPropagation()}
328
- tabIndex={-1}
329
- ref={(el) => { if (el && expanded) el.focus() }}
330
- >
331
- {hasSplit ? (
332
- <div className={styles.expandSplitBody}>
333
- <SplitScreenTopBar
334
- leftLabel={leftLabel}
335
- rightLabel={rightLabel}
336
- activePane={activePane}
337
- onClose={onClose}
338
- />
339
- <div className={styles.expandSplitPanes}>
340
- <div className={styles.expandSplitLeft}>{leftPane}</div>
341
- <div className={styles.expandSplitRight}>{rightPane}</div>
342
- </div>
343
- </div>
344
- ) : (
345
- primaryPane
346
- )}
347
- </div>
290
+ <ExpandedPane
291
+ initialLayout={layout}
292
+ variant={layout.flat().length <= 1 ? 'modal' : 'full'}
293
+ onClose={onClose}
294
+ />
348
295
  )
349
296
  }