@dfosco/storyboard-react 4.0.0-beta.24 → 4.0.0-beta.26

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.
@@ -1,16 +1,22 @@
1
1
  import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
- import { buildPrototypeIndex, getFlag } from '@dfosco/storyboard-core'
3
+ import { buildPrototypeIndex } from '@dfosco/storyboard-core'
4
4
  import WidgetWrapper from './WidgetWrapper.jsx'
5
5
  import { readProp, prototypeEmbedSchema } from './widgetProps.js'
6
- import { getEmbedChromeVars } from './embedTheme.js'
7
- import { uploadImage } from '../canvasApi.js'
8
- import { useIframeQueue } from './useViewportEntry.js'
6
+ import { getEmbedChromeVars, resolveCanvasTheme } from './embedTheme.js'
7
+ import { useIframeDevLogs } from './iframeDevLogs.js'
8
+ import { useSnapshotCapture } from './useSnapshotCapture.js'
9
9
  import styles from './PrototypeEmbed.module.css'
10
10
  import overlayStyles from './embedOverlay.module.css'
11
11
 
12
- function devLog(...args) {
13
- try { if (getFlag('dev-logs')) console.log('[canvas:prototype-embed]', ...args) } catch { /* */ }
12
+ function CollageFrameIcon({ size = 36 }) {
13
+ return (
14
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
15
+ <path d="M19.4 20H4.6C4.26863 20 4 19.7314 4 19.4V4.6C4 4.26863 4.26863 4 4.6 4H19.4C19.7314 4 20 4.26863 20 4.6V19.4C20 19.7314 19.7314 20 19.4 20Z" />
16
+ <path d="M11 12V4" />
17
+ <path d="M4 12H20" />
18
+ </svg>
19
+ )
14
20
  }
15
21
 
16
22
  function formatName(name) {
@@ -19,21 +25,39 @@ function formatName(name) {
19
25
  .replace(/\b\w/g, (c) => c.toUpperCase())
20
26
  }
21
27
 
22
- function resolveCanvasThemeFromStorage() {
23
- if (typeof localStorage === 'undefined') return 'light'
24
- let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
25
- try {
26
- const rawSync = localStorage.getItem('sb-theme-sync')
27
- if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
28
- } catch {
29
- // Ignore malformed sync settings
28
+ function listInternalPrototypes(index) {
29
+ const allProtos = []
30
+ const sortedFolders = index.sorted?.title?.folders
31
+ const sortedPrototypes = index.sorted?.title?.prototypes
32
+ const folderList = Array.isArray(sortedFolders) && sortedFolders.length > 0
33
+ ? sortedFolders
34
+ : (index.folders || [])
35
+ const standaloneList = Array.isArray(sortedPrototypes) && sortedPrototypes.length > 0
36
+ ? sortedPrototypes
37
+ : (index.prototypes || [])
38
+
39
+ for (const folder of folderList) {
40
+ for (const proto of folder.prototypes || []) {
41
+ if (!proto.isExternal) allProtos.push(proto)
42
+ }
43
+ }
44
+ for (const proto of standaloneList) {
45
+ if (!proto.isExternal) allProtos.push(proto)
46
+ }
47
+ return allProtos
48
+ }
49
+
50
+ function normalizeRoutePath(value, basePath = '') {
51
+ if (!value || /^https?:\/\//.test(value)) return ''
52
+ const noHash = value.split('#')[0]
53
+ let route = noHash.split('?')[0]
54
+ route = route.replace(/^\/branch--[^/]+/, '')
55
+ if (basePath && route.startsWith(basePath)) {
56
+ route = route.slice(basePath.length) || '/'
30
57
  }
31
- if (!sync.canvas) return 'light'
32
- const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
33
- if (attrTheme) return attrTheme
34
- const stored = localStorage.getItem('sb-color-scheme') || 'system'
35
- if (stored !== 'system') return stored
36
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
58
+ if (!route.startsWith('/')) route = `/${route}`
59
+ route = route.replace(/\/+$/, '')
60
+ return route || '/'
37
61
  }
38
62
 
39
63
  export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
@@ -42,10 +66,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
42
66
  const height = readProp(props, 'height', prototypeEmbedSchema)
43
67
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
44
68
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
45
-
46
- // Snapshot props for lazy loading
47
- const snapshotLight = props?.snapshotLight || null
48
- const snapshotDark = props?.snapshotDark || null
69
+ const snapshotLight = props?.snapshotLight || ''
70
+ const snapshotDark = props?.snapshotDark || ''
49
71
 
50
72
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
51
73
  const baseSegment = basePath.replace(/^\//, '')
@@ -58,73 +80,42 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
58
80
  return `${basePath}${cleaned}`
59
81
  }, [src, basePath, baseSegment])
60
82
 
61
- const isExternal = /^https?:\/\//.test(rawSrc)
62
83
  const scale = zoom / 100
63
84
 
64
85
  const [editing, setEditing] = useState(false)
65
86
  const [interactive, setInteractive] = useState(false)
87
+ const [showIframe, setShowIframe] = useState(false)
88
+ const [iframeLoaded, setIframeLoaded] = useState(false)
66
89
  const [expanded, setExpanded] = useState(false)
67
90
  const [filter, setFilter] = useState('')
68
- const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
69
-
70
- // Lazy loading state — only use snapshots that match this widget's ID
71
- const snapshotMatchesWidget = (url) => url && widgetId && url.includes(widgetId)
72
- const validSnapshotLight = snapshotMatchesWidget(snapshotLight) ? snapshotLight : null
73
- const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
74
- const currentSnapshot = canvasTheme?.startsWith('dark') ? validSnapshotDark : validSnapshotLight
75
- const hasSnapshot = !!currentSnapshot
76
-
77
- // Sequential iframe queue — prevents stampede when many embeds lack snapshots.
78
- // Widgets with snapshots skip the queue entirely; others load one at a time.
79
- const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot || isExternal, widgetId)
80
- const [preloadIframe, setPreloadIframe] = useState(hasSnapshot || isExternal)
81
- const [iframeLoaded, setIframeLoaded] = useState(false)
82
- const [showIframe, setShowIframe] = useState(hasSnapshot || isExternal)
83
- const [showSpinner, setShowSpinner] = useState(false)
84
- const capturingRef = useRef(false)
85
-
86
- devLog(widgetId, { hasSnapshot, isExternal, queueReady, preloadIframe, showIframe, iframeLoaded, src })
87
-
88
- // Start loading when the queue grants this widget a slot
89
- useEffect(() => {
90
- if (queueReady && !preloadIframe) {
91
- devLog(widgetId, 'queue ready → loading iframe')
92
- setPreloadIframe(true)
93
- setShowIframe(true)
94
- }
95
- }, [queueReady, preloadIframe])
96
-
97
- // Release the queue slot once the iframe has loaded or user clicked to interact
98
- useEffect(() => {
99
- if (iframeLoaded) {
100
- devLog(widgetId, 'iframe loaded')
101
- releaseSlot()
102
- }
103
- }, [iframeLoaded, releaseSlot])
104
-
105
- // Click-to-interact: immediately start iframe and release queue slot for others
106
- const activateIframe = useCallback(() => {
107
- devLog(widgetId, 'user activated → jumping queue')
108
- setShowIframe(true)
109
- setPreloadIframe(true)
110
- releaseSlot()
111
- }, [releaseSlot])
112
-
113
- // Show spinner only after 500ms of loading
114
- useEffect(() => {
115
- if (showIframe && !iframeLoaded && hasSnapshot) {
116
- const timer = setTimeout(() => setShowSpinner(true), 500)
117
- return () => clearTimeout(timer)
118
- }
119
- setShowSpinner(false)
120
- }, [showIframe, iframeLoaded, hasSnapshot])
91
+ const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasTheme())
92
+ const [brokenSnaps, setBrokenSnaps] = useState({})
121
93
 
122
94
  const inputRef = useRef(null)
123
95
  const filterRef = useRef(null)
124
96
  const embedRef = useRef(null)
125
97
  const iframeRef = useRef(null)
98
+ const captureOnReadyRef = useRef(false)
99
+ const exitSessionRef = useRef(0)
126
100
  const inlineContainerRef = useRef(null)
127
101
  const modalContainerRef = useRef(null)
102
+ const resizeTimerRef = useRef(null)
103
+ const prevInteractiveRef = useRef(false)
104
+
105
+ // Snapshot capture hook — only active in dev mode (onUpdate present)
106
+ const isExternal = /^https?:\/\//.test(src || '')
107
+ const { iframeReady, requestCapture } = useSnapshotCapture({
108
+ iframeRef,
109
+ widgetId,
110
+ onUpdate: isExternal ? null : onUpdate,
111
+ canvasTheme,
112
+ })
113
+
114
+ // Determine available snapshots for layered rendering
115
+ const isDark = canvasTheme?.startsWith('dark')
116
+ const hasLightSnap = !isExternal && !!(snapshotLight && snapshotLight.includes(widgetId) && !brokenSnaps[snapshotLight])
117
+ const hasDarkSnap = !isExternal && !!(snapshotDark && snapshotDark.includes(widgetId) && !brokenSnaps[snapshotDark])
118
+ const hasAnySnap = hasLightSnap || hasDarkSnap
128
119
 
129
120
  const iframeSrc = useMemo(() => {
130
121
  if (!rawSrc) return ''
@@ -137,6 +128,12 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
137
128
  return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
138
129
  }, [rawSrc, canvasTheme])
139
130
 
131
+ useIframeDevLogs({
132
+ widget: 'PrototypeEmbed',
133
+ loaded: showIframe && Boolean(iframeSrc),
134
+ src: iframeSrc,
135
+ })
136
+
140
137
  // Build prototype index for the picker
141
138
  const prototypeIndex = useMemo(() => {
142
139
  try {
@@ -151,16 +148,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
151
148
  const groups = []
152
149
  const idx = prototypeIndex
153
150
 
154
- // Collect all prototypes (from folders first, then ungrouped)
155
- const allProtos = []
156
- for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
157
- for (const proto of folder.prototypes || []) {
158
- if (!proto.isExternal) allProtos.push(proto)
159
- }
160
- }
161
- for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
162
- if (!proto.isExternal) allProtos.push(proto)
163
- }
151
+ const allProtos = listInternalPrototypes(idx)
164
152
 
165
153
  for (const proto of allProtos) {
166
154
  if (proto.hideFlows && proto.flows.length === 1) {
@@ -216,6 +204,35 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
216
204
  .filter(Boolean)
217
205
  }, [pickerGroups, filter])
218
206
 
207
+ const prototypeName = useMemo(() => {
208
+ const currentRoute = normalizeRoutePath(src, basePath) || normalizeRoutePath(rawSrc, basePath)
209
+ if (!currentRoute) return ''
210
+
211
+ let bestMatchName = ''
212
+ let bestMatchLength = -1
213
+
214
+ for (const proto of listInternalPrototypes(prototypeIndex)) {
215
+ const candidateRoutes = [
216
+ `/${proto.dirName}`,
217
+ ...(proto.flows || []).map((flow) => flow.route),
218
+ ]
219
+ for (const candidate of candidateRoutes) {
220
+ const candidateRoute = normalizeRoutePath(candidate, basePath)
221
+ if (!candidateRoute || candidateRoute === '/') continue
222
+ if (currentRoute === candidateRoute || currentRoute.startsWith(`${candidateRoute}/`)) {
223
+ if (candidateRoute.length > bestMatchLength) {
224
+ bestMatchLength = candidateRoute.length
225
+ bestMatchName = proto.name || ''
226
+ }
227
+ }
228
+ }
229
+ }
230
+
231
+ return bestMatchName
232
+ }, [prototypeIndex, src, rawSrc, basePath])
233
+
234
+ const prototypeTitle = prototypeName || label || 'Prototype'
235
+
219
236
  const hasPicker = pickerGroups.length > 0
220
237
 
221
238
  useEffect(() => {
@@ -227,27 +244,64 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
227
244
  }
228
245
  }, [editing, hasPicker])
229
246
 
230
- // Exit interactive mode when clicking outside the embed
231
247
  useEffect(() => {
232
- if (!interactive) return
248
+ if (!showIframe) setIframeLoaded(false)
249
+ }, [showIframe])
250
+
251
+ // Exit interactive mode when clicking outside the embed.
252
+ // Hides iframe immediately for a responsive feel, then captures
253
+ // snapshots in the background with the iframe hidden but still mounted.
254
+ useEffect(() => {
255
+ if (!interactive || expanded) return
233
256
  function handlePointerDown(e) {
234
257
  if (embedRef.current && !embedRef.current.contains(e.target)) {
258
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
259
+ if (chromeEl) return
260
+
235
261
  setInteractive(false)
262
+ if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
263
+ // Keep iframe mounted but hidden for background capture
264
+ if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
265
+ const session = ++exitSessionRef.current
266
+ requestCapture({ force: true }).then(() => {
267
+ if (exitSessionRef.current !== session) return
268
+ setShowIframe(false)
269
+ })
270
+ } else {
271
+ setShowIframe(false)
272
+ }
236
273
  }
237
274
  }
238
275
  document.addEventListener('pointerdown', handlePointerDown)
239
276
  return () => document.removeEventListener('pointerdown', handlePointerDown)
240
- }, [interactive])
277
+ }, [interactive, expanded, onUpdate, isExternal, iframeLoaded, requestCapture])
241
278
 
242
279
  useEffect(() => {
243
- function readToolbarTheme() {
244
- setCanvasTheme(resolveCanvasThemeFromStorage())
245
- }
246
- readToolbarTheme()
247
- document.addEventListener('storyboard:theme:changed', readToolbarTheme)
248
- return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
280
+ const readTheme = () => setCanvasTheme(resolveCanvasTheme())
281
+ readTheme()
282
+ document.addEventListener('storyboard:theme:changed', readTheme)
283
+ return () => document.removeEventListener('storyboard:theme:changed', readTheme)
249
284
  }, [])
250
285
 
286
+ // Capture snapshot on first iframe ready (when no existing snapshot)
287
+ useEffect(() => {
288
+ if (!iframeReady || !onUpdate || isExternal) return
289
+ if (!hasAnySnap) {
290
+ requestCapture()
291
+ }
292
+ }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
293
+
294
+ // Capture when iframe becomes ready after refresh-thumbnail requested it
295
+ useEffect(() => {
296
+ if (iframeReady && captureOnReadyRef.current) {
297
+ captureOnReadyRef.current = false
298
+ requestCapture()
299
+ }
300
+ }, [iframeReady, requestCapture])
301
+
302
+ // Cleanup resize timer on unmount
303
+ useEffect(() => () => clearTimeout(resizeTimerRef.current), [])
304
+
251
305
  // Close expanded modal on Escape
252
306
  useEffect(() => {
253
307
  if (!expanded) return
@@ -311,89 +365,18 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
311
365
  }
312
366
  return
313
367
  }
314
-
315
- // Snapshot capture responses
316
- if (e.data?.type === 'storyboard:embed:snapshot') {
317
- if (e.data.error) {
318
- console.warn('[canvas] Snapshot capture failed:', e.data.error)
319
- return
320
- }
321
- handleSnapshotResult(e.data.requestId, e.data.dataUrl)
322
- return
323
- }
324
-
325
- // Snapshot-ready signal — iframe content has fully rendered
326
- if (e.data?.type === 'storyboard:embed:snapshot-ready') {
327
- setIframeLoaded(true)
328
- if (onUpdate && !isExternal) requestSnapshotCapture()
329
- }
330
368
  }
331
369
  window.addEventListener('message', handleMessage)
332
370
  return () => window.removeEventListener('message', handleMessage)
333
- }, [src, props, onUpdate, isExternal])
334
-
335
- // Request a snapshot capture from the iframe
336
- const requestSnapshotCapture = useCallback(() => {
337
- if (!iframeRef.current?.contentWindow || capturingRef.current || isExternal) return
338
- capturingRef.current = true
339
- const requestId = `snap-${Date.now()}`
340
- iframeRef.current.contentWindow.postMessage({
341
- type: 'storyboard:embed:capture',
342
- requestId,
343
- }, '*')
344
- }, [isExternal])
345
-
346
- // Handle a completed snapshot — upload and persist as widget prop
347
- const handleSnapshotResult = useCallback(async (requestId, dataUrl) => {
348
- if (!dataUrl || !onUpdate || !widgetId) return
349
- capturingRef.current = false
350
- try {
351
- const result = await uploadImage(dataUrl, `snapshot-${widgetId}`)
352
- if (!result?.success || !result?.filename) return
353
- const imageUrl = `/_storyboard/canvas/images/${result.filename}`
354
- const themeKey = canvasTheme?.startsWith('dark') ? 'snapshotDark' : 'snapshotLight'
355
- onUpdate?.({ [themeKey]: imageUrl })
356
- } catch (err) {
357
- console.warn('[canvas] Failed to upload snapshot:', err)
358
- }
359
- }, [onUpdate, canvasTheme, widgetId])
360
-
361
- // Re-capture snapshots after resize (debounced)
362
- const resizeCaptureTimer = useRef(null)
363
- const triggerResizeCapture = useCallback(() => {
364
- if (!onUpdate || isExternal) return
365
- clearTimeout(resizeCaptureTimer.current)
366
- resizeCaptureTimer.current = setTimeout(() => {
367
- requestSnapshotCapture()
368
- }, 2000)
369
- }, [requestSnapshotCapture, isExternal, onUpdate])
370
-
371
- // Re-capture when src changes (new prototype selected)
372
- const prevSrcRef = useRef(src)
373
- useEffect(() => {
374
- if (src && src !== prevSrcRef.current && onUpdate && !isExternal && showIframe) {
375
- prevSrcRef.current = src
376
- // Wait for the new page to render
377
- const timer = setTimeout(() => requestSnapshotCapture(), 4000)
378
- return () => clearTimeout(timer)
379
- }
380
- prevSrcRef.current = src
381
- }, [src, onUpdate, isExternal, showIframe, requestSnapshotCapture])
382
-
383
- // Re-capture for the alternate theme variant when theme changes
384
- const prevThemeRef = useRef(canvasTheme)
385
- useEffect(() => {
386
- if (canvasTheme !== prevThemeRef.current && onUpdate && !isExternal && showIframe) {
387
- prevThemeRef.current = canvasTheme
388
- const timer = setTimeout(() => requestSnapshotCapture(), 3000)
389
- return () => clearTimeout(timer)
390
- }
391
- prevThemeRef.current = canvasTheme
392
- }, [canvasTheme, onUpdate, isExternal, showIframe, requestSnapshotCapture])
371
+ }, [src, props, onUpdate])
393
372
 
394
373
  const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
395
374
 
396
- const enterInteractive = useCallback(() => setInteractive(true), [])
375
+ const enterInteractive = useCallback(() => {
376
+ exitSessionRef.current++
377
+ setShowIframe(true)
378
+ setInteractive(true)
379
+ }, [])
397
380
 
398
381
  // Expose imperative action handlers for WidgetChrome
399
382
  useImperativeHandle(ref, () => ({
@@ -401,12 +384,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
401
384
  if (actionId === 'edit') {
402
385
  setEditing(true)
403
386
  } else if (actionId === 'expand') {
387
+ setShowIframe(true)
404
388
  setExpanded(true)
405
389
  } else if (actionId === 'open-external') {
406
390
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
391
+ } else if (actionId === 'refresh-thumbnail') {
392
+ if (iframeReady && iframeRef.current?.contentWindow) {
393
+ requestCapture()
394
+ } else {
395
+ captureOnReadyRef.current = true
396
+ setShowIframe(true)
397
+ }
407
398
  }
408
399
  },
409
- }), [rawSrc])
400
+ }), [rawSrc, iframeReady, requestCapture])
410
401
 
411
402
  function handlePickRoute(route) {
412
403
  onUpdate?.({ src: route })
@@ -435,6 +426,10 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
435
426
  className={styles.embed}
436
427
  style={{ width, height, ...chromeVars }}
437
428
  >
429
+ <div className={styles.header}>
430
+ <span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
431
+ <span className={styles.headerTitle}>{prototypeTitle}</span>
432
+ </div>
438
433
  {editing ? (
439
434
  <div
440
435
  className={styles.pickerPanel}
@@ -518,35 +513,35 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
518
513
  </div>
519
514
  ) : iframeSrc ? (
520
515
  <>
521
- {/* Snapshot image — shown until iframe is fully loaded */}
522
- {hasSnapshot && !(showIframe && iframeLoaded) && (
523
- <div className={styles.iframeContainer}>
516
+ <div
517
+ ref={inlineContainerRef}
518
+ className={styles.iframeContainer}
519
+ style={expanded ? { visibility: 'hidden' } : undefined}
520
+ >
521
+ {/* Snapshot layer — both themes always in DOM for instant swap */}
522
+ {hasLightSnap && (
524
523
  <img
525
- src={basePath + currentSnapshot}
526
- alt={label || 'Prototype preview'}
524
+ src={snapshotLight}
527
525
  className={styles.snapshotImage}
528
- style={{ width, height }}
526
+ style={(isDark && hasDarkSnap) ? { visibility: 'hidden' } : undefined}
527
+ alt={`${prototypeTitle} snapshot`}
529
528
  draggable={false}
529
+ onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotLight]: true }))}
530
530
  />
531
- {showIframe && !iframeLoaded && showSpinner && (
532
- <div className={styles.snapshotSpinner}>
533
- <div className={styles.spinner} />
534
- </div>
535
- )}
536
- </div>
537
- )}
531
+ )}
532
+ {hasDarkSnap && (
533
+ <img
534
+ src={snapshotDark}
535
+ className={styles.snapshotImage}
536
+ style={(!isDark && hasLightSnap) ? { visibility: 'hidden' } : undefined}
537
+ alt={`${prototypeTitle} snapshot`}
538
+ draggable={false}
539
+ onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotDark]: true }))}
540
+ />
541
+ )}
538
542
 
539
- {/* Iframe — preloaded on hover, revealed after load */}
540
- {(preloadIframe || showIframe) && (
541
- <div
542
- ref={inlineContainerRef}
543
- className={styles.iframeContainer}
544
- style={
545
- expanded ? { visibility: 'hidden' }
546
- : (hasSnapshot && !(showIframe && iframeLoaded)) ? { position: 'absolute', top: 0, left: 0, opacity: 0, pointerEvents: 'none' }
547
- : undefined
548
- }
549
- >
543
+ {/* Iframe layer — on top, transparent until loaded */}
544
+ {showIframe && (
550
545
  <iframe
551
546
  ref={iframeRef}
552
547
  src={iframeSrc}
@@ -556,22 +551,28 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
556
551
  height: height / scale,
557
552
  transform: `scale(${scale})`,
558
553
  transformOrigin: '0 0',
554
+ ...(iframeLoaded ? {} : { opacity: 0 }),
559
555
  }}
560
- title={label || 'Prototype embed'}
556
+ onLoad={() => setIframeLoaded(true)}
557
+ title={`${prototypeTitle} prototype`}
561
558
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
562
559
  />
563
- </div>
564
- )}
560
+ )}
561
+
562
+ {/* Placeholder — only when no snapshots and no iframe */}
563
+ {!hasAnySnap && !showIframe && (
564
+ <div className={styles.placeholder}>
565
+ <CollageFrameIcon size={36} />
566
+ <span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
567
+ </div>
568
+ )}
569
+ </div>
565
570
 
566
571
  {!interactive && !expanded && (
567
572
  <div
568
573
  className={overlayStyles.interactOverlay}
569
- onPointerEnter={() => {
570
- if (!preloadIframe) setPreloadIframe(true)
571
- }}
572
574
  onClick={(e) => {
573
575
  if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
574
- activateIframe()
575
576
  enterInteractive()
576
577
  }}
577
578
  role="button"
@@ -580,7 +581,6 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
580
581
  if (e.key === 'Enter' || e.key === ' ') {
581
582
  e.preventDefault()
582
583
  e.stopPropagation()
583
- activateIframe()
584
584
  enterInteractive()
585
585
  }
586
586
  }}
@@ -620,7 +620,9 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
620
620
  function onUp() {
621
621
  document.removeEventListener('mousemove', onMove)
622
622
  document.removeEventListener('mouseup', onUp)
623
- triggerResizeCapture()
623
+ // Recapture snapshot after resize (debounced)
624
+ clearTimeout(resizeTimerRef.current)
625
+ resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
624
626
  }
625
627
  document.addEventListener('mousemove', onMove)
626
628
  document.addEventListener('mouseup', onUp)
@@ -7,49 +7,91 @@
7
7
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
8
  }
9
9
 
10
- .iframeContainer {
11
- width: 100%;
12
- height: 100%;
10
+ .header {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 6px;
14
+ padding: 10px 10px;
15
+ font-size: 12px;
16
+ font-weight: 500;
17
+ color: var(--fgColor-muted, #656d76);
18
+ background: var(--bgColor-muted, #f6f8fa);
19
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
20
+ white-space: nowrap;
13
21
  overflow: hidden;
22
+ text-overflow: ellipsis;
23
+ user-select: none;
14
24
  }
15
25
 
16
- .iframe {
17
- border: none;
18
- display: block;
26
+ .headerIcon {
27
+ display: inline-flex;
28
+ flex-shrink: 0;
19
29
  }
20
30
 
21
- .snapshotImage {
22
- display: block;
23
- object-fit: cover;
24
- object-position: top left;
31
+ .headerTitle {
32
+ overflow: hidden;
33
+ text-overflow: ellipsis;
34
+ }
35
+
36
+ .iframeContainer {
37
+ position: relative;
38
+ width: 100%;
39
+ height: calc(100% - 37px);
40
+ overflow: hidden;
25
41
  }
26
42
 
27
- .snapshotSpinner {
43
+ .placeholder {
28
44
  position: absolute;
29
45
  inset: 0;
30
46
  display: flex;
47
+ flex-direction: column;
31
48
  align-items: center;
32
49
  justify-content: center;
33
- background: rgba(0, 0, 0, 0.08);
34
- animation: fadeIn 150ms ease;
50
+ gap: 8px;
51
+ color: var(--fgColor-muted, #656d76);
52
+ text-align: center;
53
+ }
54
+
55
+ .placeholderIcon {
56
+ width: 36px;
57
+ height: 36px;
58
+ }
59
+
60
+ .placeholderLabel {
61
+ font-size: 13px;
62
+ font-weight: 500;
35
63
  }
36
64
 
37
65
  .spinner {
38
66
  width: 24px;
39
67
  height: 24px;
40
- border: 2.5px solid var(--borderColor-default, #d0d7de);
41
- border-top-color: var(--fgColor-accent, #0969da);
68
+ border: 3px solid var(--borderColor-muted, #d0d7de);
69
+ border-top-color: var(--fgColor-accent, #2f81f7);
42
70
  border-radius: 50%;
43
- animation: spin 0.6s linear infinite;
71
+ animation: spin 0.8s linear infinite;
44
72
  }
45
73
 
46
74
  @keyframes spin {
75
+ from { transform: rotate(0deg); }
47
76
  to { transform: rotate(360deg); }
48
77
  }
49
78
 
50
- @keyframes fadeIn {
51
- from { opacity: 0; }
52
- to { opacity: 1; }
79
+ .iframe {
80
+ border: none;
81
+ display: block;
82
+ position: relative;
83
+ z-index: 1;
84
+ }
85
+
86
+ .snapshotImage {
87
+ position: absolute;
88
+ inset: 0;
89
+ width: 100%;
90
+ height: 100%;
91
+ object-fit: cover;
92
+ object-position: top left;
93
+ display: block;
94
+ pointer-events: none;
53
95
  }
54
96
 
55
97
  .empty {