@dfosco/storyboard-react 4.2.0-alpha.15 → 4.2.0-alpha.16

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.
@@ -3,15 +3,13 @@ import { createPortal } from 'react-dom'
3
3
  import { readProp } from './widgetProps.js'
4
4
  import { schemas } from './widgetProps.js'
5
5
  import { getTerminalConfig } from '@dfosco/storyboard-core'
6
+ import { useOverride } from '../../hooks/useOverride.js'
6
7
  import ResizeHandle from './ResizeHandle.jsx'
7
8
  import styles from './TerminalWidget.module.css'
8
9
  import overlayStyles from './embedOverlay.module.css'
9
10
 
10
11
  const terminalSchema = schemas['terminal']
11
12
 
12
- /**
13
- * Lazy-load ghostty-web to avoid bundling WASM in prod.
14
- */
15
13
  let ghosttyPromise = null
16
14
  function loadGhostty() {
17
15
  if (!ghosttyPromise) {
@@ -29,11 +27,6 @@ function loadGhostty() {
29
27
  return ghosttyPromise
30
28
  }
31
29
 
32
- /**
33
- * Build the WebSocket URL for the terminal backend.
34
- * Includes the base path (e.g. /branch--4.2.0/) so the proxy routes correctly.
35
- * Passes canvasId as a query parameter for session scoping.
36
- */
37
30
  function getWsUrl(sessionId, prettyName) {
38
31
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
39
32
  const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
@@ -44,14 +37,10 @@ function getWsUrl(sessionId, prettyName) {
44
37
  return url
45
38
  }
46
39
 
47
- /**
48
- * Calculate terminal cols/rows from pixel dimensions.
49
- */
50
40
  function calcDimensions(widthPx, heightPx) {
51
- // Approximate character cell size for 13px monospace
52
41
  const cellWidth = 7.8
53
42
  const cellHeight = 17
54
- const padding = 24 // 12px each side
43
+ const padding = 24
55
44
  const cols = Math.max(10, Math.floor((widthPx - padding) / cellWidth))
56
45
  const rows = Math.max(4, Math.floor((heightPx - padding) / cellHeight))
57
46
  return { cols, rows }
@@ -59,9 +48,6 @@ function calcDimensions(widthPx, heightPx) {
59
48
 
60
49
  const EMBED_TYPES = new Set(['prototype', 'story'])
61
50
 
62
- /**
63
- * Find the first connected embed (prototype or story) widget via the canvas bridge.
64
- */
65
51
  function findConnectedEmbed(widgetId) {
66
52
  const bridge = window.__storyboardCanvasBridgeState
67
53
  if (!bridge?.connectors || !bridge?.widgets) return null
@@ -76,9 +62,6 @@ function findConnectedEmbed(widgetId) {
76
62
  return null
77
63
  }
78
64
 
79
- /**
80
- * Build an iframe URL for a connected embed widget.
81
- */
82
65
  function buildEmbedUrl(widget) {
83
66
  if (!widget) return null
84
67
  const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
@@ -136,27 +119,27 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
136
119
  const terminalRef = useRef(null)
137
120
  const wsRef = useRef(null)
138
121
 
139
- // State machine: dormant → connecting → live → ended
140
- // ↘ error
141
- const [phase, setPhase] = useState('dormant') // dormant | connecting | live | error | ended
142
- const [errorMsg, setErrorMsg] = useState(null)
143
- const [interactive, setInteractive] = useState(false)
122
+ const [ready, setReady] = useState(false)
123
+ const [error, setError] = useState(null)
124
+ const [sessionEnded, setSessionEnded] = useState(false)
144
125
  const [connectAttempt, setConnectAttempt] = useState(0)
145
- const [expanded, setExpanded] = useState(false)
126
+ const [interactive, setInteractive] = useState(false)
127
+ const [expandedOverride, setExpandedOverride, clearExpandedOverride] = useOverride(`_terminal_expanded_${id}`)
128
+ const expanded = expandedOverride === 'true'
129
+ const setExpanded = useCallback((val) => {
130
+ if (val) setExpandedOverride('true')
131
+ else clearExpandedOverride()
132
+ }, [setExpandedOverride, clearExpandedOverride])
146
133
  const [waking, setWaking] = useState(false)
134
+ const [showDragHint, setShowDragHint] = useState(false)
147
135
  const expandContainerRef = useRef(null)
136
+ const dragHintTimer = useRef(null)
148
137
 
149
- // Activate: transition from dormant to connecting
150
- const activate = useCallback(() => {
151
- if (phase === 'dormant') setPhase('connecting')
152
- }, [phase])
153
-
154
- const enterInteractive = useCallback(() => {
155
- if (phase === 'dormant') {
156
- setPhase('connecting')
157
- }
158
- setInteractive(true)
159
- }, [phase])
138
+ useImperativeHandle(ref, () => ({
139
+ handleAction(actionId) {
140
+ if (actionId === 'expand') setExpanded(true)
141
+ },
142
+ }), [setExpanded])
160
143
 
161
144
  // Exit interactive on click outside
162
145
  useEffect(() => {
@@ -172,22 +155,13 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
172
155
  return () => document.removeEventListener('pointerdown', handlePointerDown)
173
156
  }, [interactive, id])
174
157
 
175
- useImperativeHandle(ref, () => ({
176
- handleAction(actionId) {
177
- if (actionId === 'expand') {
178
- if (phase === 'dormant') setPhase('connecting')
179
- setExpanded(true)
180
- }
181
- },
182
- }), [phase])
183
-
184
158
  const handleResize = useCallback((w, h) => {
185
159
  onUpdate?.({ width: w, height: h })
186
160
  }, [onUpdate])
187
161
 
188
- // Connect terminal + WebSocket only when phase is 'connecting'
162
+ // Connect terminal + WebSocket
189
163
  useEffect(() => {
190
- if (phase !== 'connecting' || !containerRef.current) return
164
+ if (!containerRef.current) return
191
165
 
192
166
  let disposed = false
193
167
  let term = null
@@ -196,7 +170,11 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
196
170
  async function setup() {
197
171
  try {
198
172
  const ghostty = await loadGhostty()
199
- if (disposed || !ghostty) return
173
+ if (disposed) return
174
+ if (!ghostty) {
175
+ setError('ghostty-web not installed — add it to your dependencies to enable terminal widgets')
176
+ return
177
+ }
200
178
 
201
179
  const dims = calcDimensions(width, height)
202
180
  const cfg = getTerminalConfig()
@@ -214,13 +192,28 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
214
192
  term.open(containerRef.current)
215
193
  termRef.current = term
216
194
 
195
+ // SGR mouse wheel for tmux scroll in alternate screen
196
+ term.attachCustomWheelEventHandler((e) => {
197
+ if (!(term.wasmTerm?.isAlternateScreen?.() ?? false)) return false
198
+ const sock = wsRef.current
199
+ if (!sock || sock.readyState !== WebSocket.OPEN) return true
200
+ const btn = e.deltaY < 0 ? 64 : 65
201
+ const lines = Math.max(1, Math.min(5, Math.ceil(Math.abs(e.deltaY) / 33)))
202
+ for (let i = 0; i < lines; i++) {
203
+ sock.send(`\x1b[<${btn};1;1M`)
204
+ sock.send(`\x1b[<${btn};1;1m`)
205
+ }
206
+ return true
207
+ })
208
+
217
209
  const url = getWsUrl(id, prettyName)
218
210
  ws = new WebSocket(url)
219
211
  wsRef.current = ws
220
212
 
221
213
  ws.onopen = () => {
222
214
  if (disposed) return
223
- setPhase('live')
215
+ setReady(true)
216
+ setInteractive(true)
224
217
  ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
225
218
  }
226
219
 
@@ -238,22 +231,21 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
238
231
 
239
232
  ws.onclose = () => {
240
233
  if (disposed) return
241
- setPhase('ended')
234
+ setReady(false)
235
+ setSessionEnded(true)
242
236
  }
243
237
 
244
238
  ws.onerror = () => {
245
239
  if (disposed) return
246
- setPhase('ended')
240
+ setReady(false)
241
+ setSessionEnded(true)
247
242
  }
248
243
 
249
244
  term.onData((data) => {
250
245
  if (ws.readyState === WebSocket.OPEN) ws.send(data)
251
246
  })
252
247
  } catch (err) {
253
- if (!disposed) {
254
- setErrorMsg(err.message || 'Failed to load terminal')
255
- setPhase('error')
256
- }
248
+ if (!disposed) setError(err.message || 'Failed to load terminal')
257
249
  }
258
250
  }
259
251
 
@@ -266,7 +258,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
266
258
  termRef.current = null
267
259
  wsRef.current = null
268
260
  }
269
- }, [id, phase === 'connecting', connectAttempt])
261
+ }, [id, connectAttempt])
270
262
 
271
263
  // Resize terminal on dimension changes
272
264
  useEffect(() => {
@@ -281,7 +273,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
281
273
  return () => clearTimeout(timer)
282
274
  }, [width, height])
283
275
 
284
- // Resize terminal to fill the expand container
276
+ // Resize for expand
285
277
  useEffect(() => {
286
278
  if (!expanded || !termRef.current || !expandContainerRef.current) return
287
279
  const timer = setTimeout(() => {
@@ -292,14 +284,15 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
292
284
  if (wsRef.current?.readyState === WebSocket.OPEN) {
293
285
  wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
294
286
  }
287
+ setInteractive(true)
288
+ termRef.current?.focus?.()
295
289
  }, 100)
296
290
  return () => clearTimeout(timer)
297
291
  }, [expanded])
298
292
 
299
- // Restore terminal size when collapsing
293
+ // Restore size on collapse
300
294
  useEffect(() => {
301
- if (expanded) return
302
- if (!termRef.current) return
295
+ if (expanded || !termRef.current) return
303
296
  const timer = setTimeout(() => {
304
297
  const dims = calcDimensions(width, height)
305
298
  termRef.current?.resize?.(dims.cols, dims.rows)
@@ -310,7 +303,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
310
303
  return () => clearTimeout(timer)
311
304
  }, [expanded, width, height])
312
305
 
313
- // Reparent terminal DOM node between inline and expand
306
+ // Reparent terminal DOM between inline and expand
314
307
  useEffect(() => {
315
308
  const xtermEl = containerRef.current
316
309
  if (!xtermEl) return
@@ -322,38 +315,57 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
322
315
  }, [expanded])
323
316
 
324
317
  const handleClick = useCallback(() => {
325
- if (phase === 'ended') return
326
- if (phase === 'live') {
327
- // Save canvas scroll position — terminal focus can trigger browser scroll
318
+ if (sessionEnded) return
319
+ if (ready) {
320
+ setInteractive(true)
328
321
  const scrollEl = terminalRef.current?.closest('[class*="canvasScroll"]')
329
322
  const scrollTop = scrollEl?.scrollTop
330
323
  const scrollLeft = scrollEl?.scrollLeft
331
324
  termRef.current?.focus({ preventScroll: true })
332
- // Restore if browser scrolled anyway
333
325
  if (scrollEl && (scrollEl.scrollTop !== scrollTop || scrollEl.scrollLeft !== scrollLeft)) {
334
326
  scrollEl.scrollTop = scrollTop
335
327
  scrollEl.scrollLeft = scrollLeft
336
328
  }
337
329
  }
338
- }, [phase])
330
+ }, [sessionEnded, ready])
331
+
332
+ const handleTerminalPointerDown = useCallback((e) => {
333
+ if (!interactive) return
334
+ if (e.target.closest('.tc-drag-handle')) return
335
+ e.stopPropagation()
336
+ const startX = e.clientX
337
+ const startY = e.clientY
338
+ let moved = false
339
+ function onMove(me) {
340
+ if (!moved && (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5)) {
341
+ moved = true
342
+ setShowDragHint(true)
343
+ clearTimeout(dragHintTimer.current)
344
+ dragHintTimer.current = setTimeout(() => setShowDragHint(false), 2000)
345
+ }
346
+ }
347
+ function onUp() {
348
+ document.removeEventListener('pointermove', onMove)
349
+ document.removeEventListener('pointerup', onUp)
350
+ }
351
+ document.addEventListener('pointermove', onMove)
352
+ document.addEventListener('pointerup', onUp)
353
+ }, [interactive])
339
354
 
340
355
  const handleStartSession = useCallback(() => {
341
356
  setWaking(true)
342
357
  setTimeout(() => {
343
358
  setWaking(false)
344
- setErrorMsg(null)
345
- setPhase('connecting')
359
+ setSessionEnded(false)
360
+ setError(null)
346
361
  setConnectAttempt(c => c + 1)
347
362
  }, 1500)
348
363
  }, [])
349
364
 
350
- // Show interact gate when session is ready but not interacting
351
-
352
365
  const titleLabel = `terminal · ${prettyName || '...'}`
353
366
  const connectedEmbed = expanded ? findConnectedEmbed(id) : null
354
367
  const embedUrl = expanded ? buildEmbedUrl(connectedEmbed) : null
355
368
  const hasSplit = Boolean(embedUrl)
356
- const isDormant = phase === 'dormant'
357
369
 
358
370
  return (
359
371
  <>
@@ -367,44 +379,34 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
367
379
  ...(typeof height === 'number' ? { height: `${height}px` } : undefined),
368
380
  }}
369
381
  onClick={handleClick}
382
+ onPointerDown={handleTerminalPointerDown}
383
+ onKeyDown={interactive ? (e) => e.stopPropagation() : undefined}
370
384
  >
371
- {phase === 'error' && (
372
- <div className={styles.error}>
373
- <span>⚠ {errorMsg}</span>
385
+ {showDragHint && (
386
+ <div className={styles.dragHint}>
387
+ <span className={styles.dragHintArrow}>←</span> Drag here to move widget
374
388
  </div>
375
389
  )}
376
- {!expanded && <div ref={containerRef} className={styles.xtermContainer} />}
377
-
378
- {/* Dormant: not yet activated */}
379
- {isDormant && (
380
- <div
381
- className={overlayStyles.interactOverlay}
382
- style={{ backgroundColor: '#0d1117' }}
383
- onClick={(e) => {
384
- if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
385
- enterInteractive()
386
- }}
387
- role="button"
388
- tabIndex={0}
389
- onKeyDown={(e) => { if (e.key === 'Enter') enterInteractive() }}
390
- aria-label="Click to interact"
391
- >
392
- <span className={overlayStyles.interactHint}>Click to interact</span>
390
+ {error && !sessionEnded && (
391
+ <div className={styles.error}>
392
+ <span>⚠ {error}</span>
393
393
  </div>
394
394
  )}
395
+ <div ref={containerRef} className={styles.xtermContainer} />
395
396
 
396
- {/* Live but not interactive: gated overlay */}
397
- {phase === 'live' && !interactive && (
397
+ {/* Live but not interactive */}
398
+ {ready && !interactive && !sessionEnded && (
398
399
  <div
399
400
  className={overlayStyles.interactOverlay}
400
401
  style={{ backgroundColor: 'transparent' }}
401
402
  onClick={(e) => {
402
403
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
403
- enterInteractive()
404
+ setInteractive(true)
405
+ termRef.current?.focus({ preventScroll: true })
404
406
  }}
405
407
  role="button"
406
408
  tabIndex={0}
407
- onKeyDown={(e) => { if (e.key === 'Enter') enterInteractive() }}
409
+ onKeyDown={(e) => { if (e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
408
410
  aria-label="Click to interact"
409
411
  >
410
412
  <span className={overlayStyles.interactHint}>Click to interact</span>
@@ -412,7 +414,7 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
412
414
  )}
413
415
 
414
416
  {/* Session ended */}
415
- {phase === 'ended' && (
417
+ {sessionEnded && (
416
418
  <div
417
419
  className={overlayStyles.interactOverlay}
418
420
  style={{ backgroundColor: '#0d1117', flexDirection: 'column', gap: 0 }}
@@ -430,13 +432,13 @@ export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizab
430
432
  </div>
431
433
  )}
432
434
  <span className={overlayStyles.interactHint}>
433
- {waking ? 'Waking up...' : 'Start terminal session'}
435
+ {waking ? 'Waking up...' : connectAttempt > 0 ? 'Continue terminal session' : 'Start terminal session'}
434
436
  </span>
435
437
  </div>
436
438
  )}
437
439
 
438
440
  {/* Connecting */}
439
- {phase === 'connecting' && (
441
+ {!ready && !error && !sessionEnded && (
440
442
  <div className={styles.loading}>Connecting…</div>
441
443
  )}
442
444
  </div>
@@ -33,7 +33,7 @@
33
33
  background: #0d1117;
34
34
  border: 1px solid var(--borderColor-default, #30363d);
35
35
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
36
- padding: 8px;
36
+ padding: var(--base-size-24, 24px) var(--base-size-16, 16px);
37
37
  }
38
38
 
39
39
  .xtermContainer {
@@ -49,6 +49,22 @@
49
49
  padding: 12px;
50
50
  }
51
51
 
52
+ /* Hide the native caret on ghostty-web's helper textarea —
53
+ without this it renders a visible blinking cursor at (0,0)
54
+ and triggers scrollIntoView on focus (scroll-to-top bug). */
55
+ .xtermContainer :global(.xterm-helper-textarea),
56
+ .xtermContainer textarea {
57
+ caret-color: transparent !important;
58
+ opacity: 0 !important;
59
+ position: absolute !important;
60
+ top: 0 !important;
61
+ left: 0 !important;
62
+ width: 1px !important;
63
+ height: 1px !important;
64
+ overflow: hidden !important;
65
+ pointer-events: none !important;
66
+ }
67
+
52
68
  .xtermContainer :global(.xterm-viewport) {
53
69
  overflow-y: auto;
54
70
  }
@@ -158,6 +174,38 @@
158
174
  }
159
175
 
160
176
  /* Fullscreen expand */
177
+
178
+ /* Drag hint tooltip — appears when user tries to drag the terminal body */
179
+ .dragHint {
180
+ position: absolute;
181
+ bottom: -32px;
182
+ right: 0;
183
+ z-index: 10;
184
+ display: flex;
185
+ align-items: center;
186
+ gap: 4px;
187
+ padding: 4px 10px;
188
+ border-radius: 6px;
189
+ background: var(--bgColor-inverse, #1f2328);
190
+ color: var(--fgColor-onInverse, #ffffff);
191
+ font-size: 12px;
192
+ font-weight: 500;
193
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
194
+ white-space: nowrap;
195
+ pointer-events: none;
196
+ animation: dragHintIn 150ms ease;
197
+ }
198
+
199
+ .dragHintArrow {
200
+ font-size: 14px;
201
+ opacity: 0.7;
202
+ }
203
+
204
+ @keyframes dragHintIn {
205
+ from { opacity: 0; transform: translateY(-4px); }
206
+ to { opacity: 1; transform: translateY(0); }
207
+ }
208
+
161
209
  .expandBackdrop {
162
210
  position: fixed;
163
211
  inset: 0;
@@ -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