@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.
- package/package.json +3 -3
- package/src/BranchBar/BranchBar.jsx +7 -4
- package/src/BranchBar/BranchBar.module.css +7 -2
- package/src/CommandPalette/CommandPalette.jsx +20 -2
- package/src/Icon.jsx +4 -0
- package/src/Viewfinder.jsx +1 -1
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +22 -30
- package/src/canvas/CanvasPage.module.css +0 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/widgets/ImageWidget.jsx +1 -1
- package/src/canvas/widgets/PrototypeEmbed.jsx +16 -18
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/StoryWidget.jsx +18 -21
- package/src/canvas/widgets/TerminalWidget.jsx +100 -98
- package/src/canvas/widgets/TerminalWidget.module.css +49 -1
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/useEmbedsPaused.js +19 -0
- package/src/hooks/useConfig.js +14 -0
- package/src/index.js +1 -0
- package/src/vite/data-plugin.js +230 -13
- package/src/canvas/widgets/useEmbedController.jsx +0 -207
|
@@ -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
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
const [
|
|
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 [
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
162
|
+
// Connect terminal + WebSocket
|
|
189
163
|
useEffect(() => {
|
|
190
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
234
|
+
setReady(false)
|
|
235
|
+
setSessionEnded(true)
|
|
242
236
|
}
|
|
243
237
|
|
|
244
238
|
ws.onerror = () => {
|
|
245
239
|
if (disposed) return
|
|
246
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
326
|
-
if (
|
|
327
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
{
|
|
372
|
-
<div className={styles.
|
|
373
|
-
<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
|
-
{
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
397
|
-
{
|
|
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
|
-
|
|
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')
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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')).
|
|
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
|
|
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
|
|
85
|
-
expect(container.querySelector('iframe')).
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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')).
|
|
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')).
|
|
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
|
|
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
|
|
169
|
-
expect(container.querySelector('iframe')).
|
|
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
|
|
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
|
|
178
|
-
expect(container.querySelector('iframe')).
|
|
175
|
+
expect(screen.getByRole('button', { name: /click to interact$/i })).toBeInTheDocument()
|
|
176
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
179
177
|
})
|
|
180
178
|
})
|
|
181
179
|
|