@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.30

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 (63) hide show
  1. package/package.json +7 -4
  2. package/src/canvas/CanvasControls.jsx +51 -2
  3. package/src/canvas/CanvasControls.module.css +31 -0
  4. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  5. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  6. package/src/canvas/CanvasPage.jsx +790 -302
  7. package/src/canvas/CanvasPage.module.css +70 -47
  8. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  9. package/src/canvas/CanvasToolbar.jsx +2 -2
  10. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  11. package/src/canvas/PageSelector.jsx +102 -0
  12. package/src/canvas/PageSelector.module.css +93 -0
  13. package/src/canvas/PageSelector.test.jsx +104 -0
  14. package/src/canvas/canvasApi.js +22 -8
  15. package/src/canvas/canvasReloadGuard.js +37 -0
  16. package/src/canvas/canvasReloadGuard.test.js +27 -0
  17. package/src/canvas/componentIsolate.jsx +135 -0
  18. package/src/canvas/useCanvas.js +15 -10
  19. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  20. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  21. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  22. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  23. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  24. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  25. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  26. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  27. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  28. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  29. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  30. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  31. package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
  32. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  33. package/src/canvas/widgets/StickyNote.module.css +5 -0
  34. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  35. package/src/canvas/widgets/StoryWidget.jsx +512 -0
  36. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  37. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  38. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  39. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  40. package/src/canvas/widgets/codepenUrl.js +75 -0
  41. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  42. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  43. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  44. package/src/canvas/widgets/embedTheme.js +56 -0
  45. package/src/canvas/widgets/githubUrl.js +82 -0
  46. package/src/canvas/widgets/githubUrl.test.js +74 -0
  47. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  48. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  49. package/src/canvas/widgets/index.js +4 -0
  50. package/src/canvas/widgets/pasteRules.js +295 -0
  51. package/src/canvas/widgets/pasteRules.test.js +474 -0
  52. package/src/canvas/widgets/refreshQueue.js +108 -0
  53. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  54. package/src/canvas/widgets/useSnapshotCapture.js +157 -0
  55. package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -0
  56. package/src/canvas/widgets/widgetConfig.js +16 -5
  57. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  58. package/src/context.jsx +141 -16
  59. package/src/hooks/useSceneData.js +4 -2
  60. package/src/story/StoryPage.jsx +117 -0
  61. package/src/story/StoryPage.module.css +18 -0
  62. package/src/vite/data-plugin.js +458 -71
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -3,8 +3,22 @@ import { createPortal } from 'react-dom'
3
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'
6
+ import { getEmbedChromeVars, subscribeCanvasTheme } from './embedTheme.js'
7
+ import { useIframeDevLogs } from './iframeDevLogs.js'
8
+ import { useSnapshotCapture } from './useSnapshotCapture.js'
9
+ import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
7
10
  import styles from './PrototypeEmbed.module.css'
11
+ import overlayStyles from './embedOverlay.module.css'
12
+
13
+ function CollageFrameIcon({ size = 36 }) {
14
+ return (
15
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
16
+ <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" />
17
+ <path d="M11 12V4" />
18
+ <path d="M4 12H20" />
19
+ </svg>
20
+ )
21
+ }
8
22
 
9
23
  function formatName(name) {
10
24
  return name
@@ -12,36 +26,54 @@ function formatName(name) {
12
26
  .replace(/\b\w/g, (c) => c.toUpperCase())
13
27
  }
14
28
 
15
- function resolveCanvasThemeFromStorage() {
16
- if (typeof localStorage === 'undefined') return 'light'
17
- let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
18
- try {
19
- const rawSync = localStorage.getItem('sb-theme-sync')
20
- if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
21
- } catch {
22
- // Ignore malformed sync settings
29
+ function listInternalPrototypes(index) {
30
+ const allProtos = []
31
+ const sortedFolders = index.sorted?.title?.folders
32
+ const sortedPrototypes = index.sorted?.title?.prototypes
33
+ const folderList = Array.isArray(sortedFolders) && sortedFolders.length > 0
34
+ ? sortedFolders
35
+ : (index.folders || [])
36
+ const standaloneList = Array.isArray(sortedPrototypes) && sortedPrototypes.length > 0
37
+ ? sortedPrototypes
38
+ : (index.prototypes || [])
39
+
40
+ for (const folder of folderList) {
41
+ for (const proto of folder.prototypes || []) {
42
+ if (!proto.isExternal) allProtos.push(proto)
43
+ }
44
+ }
45
+ for (const proto of standaloneList) {
46
+ if (!proto.isExternal) allProtos.push(proto)
47
+ }
48
+ return allProtos
49
+ }
50
+
51
+ function normalizeRoutePath(value, basePath = '') {
52
+ if (!value || /^https?:\/\//.test(value)) return ''
53
+ const noHash = value.split('#')[0]
54
+ let route = noHash.split('?')[0]
55
+ route = route.replace(/^\/branch--[^/]+/, '')
56
+ if (basePath && route.startsWith(basePath)) {
57
+ route = route.slice(basePath.length) || '/'
23
58
  }
24
- if (!sync.canvas) return 'light'
25
- const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
26
- if (attrTheme) return attrTheme
27
- const stored = localStorage.getItem('sb-color-scheme') || 'system'
28
- if (stored !== 'system') return stored
29
- return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
59
+ if (!route.startsWith('/')) route = `/${route}`
60
+ route = route.replace(/\/+$/, '')
61
+ return route || '/'
30
62
  }
31
63
 
32
- export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
64
+ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
33
65
  const src = readProp(props, 'src', prototypeEmbedSchema)
34
66
  const width = readProp(props, 'width', prototypeEmbedSchema)
35
67
  const height = readProp(props, 'height', prototypeEmbedSchema)
36
68
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
37
69
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
70
+ const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
38
71
 
39
72
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
40
73
  const baseSegment = basePath.replace(/^\//, '')
41
74
  const rawSrc = useMemo(() => {
42
75
  if (!src) return ''
43
76
  if (/^https?:\/\//.test(src)) return src
44
- // Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
45
77
  const cleaned = src.replace(/^\/branch--[^/]+/, '')
46
78
  if (baseSegment && cleaned.startsWith(basePath)) return cleaned
47
79
  if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
@@ -52,15 +84,36 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
52
84
 
53
85
  const [editing, setEditing] = useState(false)
54
86
  const [interactive, setInteractive] = useState(false)
87
+ const [showIframe, setShowIframe] = useState(false)
88
+ const [iframeLoaded, setIframeLoaded] = useState(false)
55
89
  const [expanded, setExpanded] = useState(false)
56
90
  const [filter, setFilter] = useState('')
57
- const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
91
+ const [canvasTheme, setCanvasTheme] = useState('light')
92
+ const [brokenSnaps, setBrokenSnaps] = useState({})
93
+
58
94
  const inputRef = useRef(null)
59
95
  const filterRef = useRef(null)
60
96
  const embedRef = useRef(null)
61
97
  const iframeRef = useRef(null)
98
+ const captureOnReadyRef = useRef(false)
99
+ const exitSessionRef = useRef(0)
100
+ const teardownTimerRef = useRef(null)
62
101
  const inlineContainerRef = useRef(null)
63
102
  const modalContainerRef = useRef(null)
103
+ const resizeTimerRef = useRef(null)
104
+ const prevInteractiveRef = useRef(false)
105
+
106
+ // Snapshot capture hook — only active in dev mode (onUpdate present)
107
+ const isExternal = /^https?:\/\//.test(src || '')
108
+ const { iframeReady, requestCapture } = useSnapshotCapture({
109
+ iframeRef,
110
+ widgetId,
111
+ onUpdate: isExternal ? null : onUpdate,
112
+ showIframe,
113
+ })
114
+
115
+ // Single snapshot — backward compat reads snapshotLight/snapshotDark if snapshot is missing
116
+ const hasSnap = !isExternal && !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
64
117
 
65
118
  const iframeSrc = useMemo(() => {
66
119
  if (!rawSrc) return ''
@@ -70,8 +123,14 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
70
123
  const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
71
124
  const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
72
125
  const sep = base.includes('?') ? '&' : '?'
73
- return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
74
- }, [rawSrc, canvasTheme])
126
+ return `${base}${sep}_sb_embed&_sb_theme_target=prototype${hash}`
127
+ }, [rawSrc])
128
+
129
+ useIframeDevLogs({
130
+ widget: 'PrototypeEmbed',
131
+ loaded: showIframe && Boolean(iframeSrc),
132
+ src: iframeSrc,
133
+ })
75
134
 
76
135
  // Build prototype index for the picker
77
136
  const prototypeIndex = useMemo(() => {
@@ -87,16 +146,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
87
146
  const groups = []
88
147
  const idx = prototypeIndex
89
148
 
90
- // Collect all prototypes (from folders first, then ungrouped)
91
- const allProtos = []
92
- for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
93
- for (const proto of folder.prototypes || []) {
94
- if (!proto.isExternal) allProtos.push(proto)
95
- }
96
- }
97
- for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
98
- if (!proto.isExternal) allProtos.push(proto)
99
- }
149
+ const allProtos = listInternalPrototypes(idx)
100
150
 
101
151
  for (const proto of allProtos) {
102
152
  if (proto.hideFlows && proto.flows.length === 1) {
@@ -152,6 +202,35 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
152
202
  .filter(Boolean)
153
203
  }, [pickerGroups, filter])
154
204
 
205
+ const prototypeName = useMemo(() => {
206
+ const currentRoute = normalizeRoutePath(src, basePath) || normalizeRoutePath(rawSrc, basePath)
207
+ if (!currentRoute) return ''
208
+
209
+ let bestMatchName = ''
210
+ let bestMatchLength = -1
211
+
212
+ for (const proto of listInternalPrototypes(prototypeIndex)) {
213
+ const candidateRoutes = [
214
+ `/${proto.dirName}`,
215
+ ...(proto.flows || []).map((flow) => flow.route),
216
+ ]
217
+ for (const candidate of candidateRoutes) {
218
+ const candidateRoute = normalizeRoutePath(candidate, basePath)
219
+ if (!candidateRoute || candidateRoute === '/') continue
220
+ if (currentRoute === candidateRoute || currentRoute.startsWith(`${candidateRoute}/`)) {
221
+ if (candidateRoute.length > bestMatchLength) {
222
+ bestMatchLength = candidateRoute.length
223
+ bestMatchName = proto.name || ''
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ return bestMatchName
230
+ }, [prototypeIndex, src, rawSrc, basePath])
231
+
232
+ const prototypeTitle = prototypeName || label || 'Prototype'
233
+
155
234
  const hasPicker = pickerGroups.length > 0
156
235
 
157
236
  useEffect(() => {
@@ -163,25 +242,129 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
163
242
  }
164
243
  }, [editing, hasPicker])
165
244
 
166
- // Exit interactive mode when clicking outside the embed
167
245
  useEffect(() => {
168
- if (!interactive) return
246
+ if (!showIframe) setIframeLoaded(false)
247
+ }, [showIframe])
248
+
249
+ // Exit interactive mode when clicking outside the embed.
250
+ // Hides iframe immediately for a responsive feel, then captures
251
+ // snapshots in the background with the iframe hidden but still mounted.
252
+ useEffect(() => {
253
+ if (!interactive || expanded) return
169
254
  function handlePointerDown(e) {
170
255
  if (embedRef.current && !embedRef.current.contains(e.target)) {
256
+ const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
257
+ if (chromeEl) return
258
+
171
259
  setInteractive(false)
260
+ if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
261
+ if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
262
+ const session = ++exitSessionRef.current
263
+ setTimeout(() => {
264
+ if (exitSessionRef.current !== session) return
265
+ requestCapture({ force: true }).then((updates) => {
266
+ if (exitSessionRef.current !== session) return
267
+ const snap = updates?.snapshot
268
+ if (snap) {
269
+ const img = new Image()
270
+ const done = () => {
271
+ if (exitSessionRef.current === session) setShowIframe(false)
272
+ }
273
+ img.onload = done
274
+ img.onerror = done
275
+ img.src = snap
276
+ setTimeout(done, 2000)
277
+ } else {
278
+ setShowIframe(false)
279
+ }
280
+ })
281
+ }, 0)
282
+ } else if (isExternal && showIframe) {
283
+ // External embeds (e.g. Figma) are slow to reload — keep the
284
+ // iframe mounted for 2 min so re-entering is instant.
285
+ const session = ++exitSessionRef.current
286
+ clearTimeout(teardownTimerRef.current)
287
+ teardownTimerRef.current = setTimeout(() => {
288
+ if (exitSessionRef.current !== session) return
289
+ setShowIframe(false)
290
+ }, 2 * 60 * 1000)
291
+ } else {
292
+ setShowIframe(false)
293
+ }
172
294
  }
173
295
  }
174
296
  document.addEventListener('pointerdown', handlePointerDown)
175
297
  return () => document.removeEventListener('pointerdown', handlePointerDown)
176
- }, [interactive])
298
+ }, [interactive, expanded, onUpdate, isExternal, iframeLoaded, requestCapture])
177
299
 
300
+ useEffect(() => subscribeCanvasTheme({
301
+ anchorRef: embedRef,
302
+ onTheme: setCanvasTheme,
303
+ }), [])
304
+
305
+ // On canvas theme change, enqueue a background snapshot refresh.
306
+ // Skips the initial render (canvasThemeInitRef tracks first value).
307
+ const canvasThemeInitRef = useRef(true)
308
+ const refreshMetaRef = useRef(null)
178
309
  useEffect(() => {
179
- function readToolbarTheme() {
180
- setCanvasTheme(resolveCanvasThemeFromStorage())
310
+ if (canvasThemeInitRef.current) { canvasThemeInitRef.current = false; return }
311
+ if (isExternal || !onUpdate || interactive) return
312
+ const rect = embedRef.current?.getBoundingClientRect()
313
+ enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
314
+ return new Promise((resolve) => {
315
+ refreshMetaRef.current = { revealOrder, batchStart, resolve }
316
+ captureOnReadyRef.current = true
317
+ setShowIframe(true)
318
+ // Safety timeout — report failure so retry pass picks it up
319
+ setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
320
+ })
321
+ }, rect ? { x: rect.left, y: rect.top } : undefined)
322
+ }, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
323
+
324
+ // Capture snapshot on first iframe ready (when no existing snapshot)
325
+ useEffect(() => {
326
+ if (!iframeReady || !onUpdate || isExternal) return
327
+ if (!hasSnap) {
328
+ requestCapture()
181
329
  }
182
- readToolbarTheme()
183
- document.addEventListener('storyboard:theme:changed', readToolbarTheme)
184
- return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
330
+ }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
331
+
332
+ // Capture when iframe becomes ready after refresh-thumbnail requested it
333
+ useEffect(() => {
334
+ if (iframeReady && captureOnReadyRef.current) {
335
+ captureOnReadyRef.current = false
336
+ requestCapture().then((updates) => {
337
+ const meta = refreshMetaRef.current
338
+ if (meta) {
339
+ refreshMetaRef.current = null
340
+ const snap = updates?.snapshot
341
+ const reveal = () => {
342
+ if (snap) {
343
+ const img = new Image()
344
+ const done = () => setShowIframe(false)
345
+ img.onload = done
346
+ img.onerror = done
347
+ img.src = snap
348
+ setTimeout(done, 2000)
349
+ } else {
350
+ setShowIframe(false)
351
+ }
352
+ meta.resolve(!!snap)
353
+ }
354
+ // Wait for our reveal slot in the wave
355
+ const elapsed = Date.now() - meta.batchStart
356
+ const targetTime = meta.revealOrder * REVEAL_INTERVAL
357
+ const wait = Math.max(0, targetTime - elapsed)
358
+ setTimeout(reveal, wait)
359
+ }
360
+ })
361
+ }
362
+ }, [iframeReady, requestCapture])
363
+
364
+ // Cleanup timers on unmount
365
+ useEffect(() => () => {
366
+ clearTimeout(resizeTimerRef.current)
367
+ clearTimeout(teardownTimerRef.current)
185
368
  }, [])
186
369
 
187
370
  // Close expanded modal on Escape
@@ -232,15 +415,20 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
232
415
  }
233
416
  }, [expanded])
234
417
 
235
- // Listen for navigation events from the embedded prototype iframe
418
+ // Listen for messages from the embedded prototype iframe
236
419
  useEffect(() => {
237
420
  function handleMessage(e) {
238
- if (e.source !== iframeRef.current?.contentWindow) return
239
- if (e.data?.type !== 'storyboard:embed:navigate') return
240
- const newSrc = e.data.src
241
- if (newSrc && newSrc !== src) {
242
- const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
243
- onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
421
+ if (!iframeRef.current?.contentWindow) return
422
+ if (e.source !== iframeRef.current.contentWindow) return
423
+
424
+ // Navigation events
425
+ if (e.data?.type === 'storyboard:embed:navigate') {
426
+ const newSrc = e.data.src
427
+ if (newSrc && newSrc !== src) {
428
+ const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
429
+ onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
430
+ }
431
+ return
244
432
  }
245
433
  }
246
434
  window.addEventListener('message', handleMessage)
@@ -249,7 +437,13 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
249
437
 
250
438
  const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
251
439
 
252
- const enterInteractive = useCallback(() => setInteractive(true), [])
440
+ const enterInteractive = useCallback(() => {
441
+ exitSessionRef.current++
442
+ clearTimeout(teardownTimerRef.current)
443
+ cancelRefresh(widgetId)
444
+ setShowIframe(true)
445
+ setInteractive(true)
446
+ }, [widgetId])
253
447
 
254
448
  // Expose imperative action handlers for WidgetChrome
255
449
  useImperativeHandle(ref, () => ({
@@ -257,18 +451,20 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
257
451
  if (actionId === 'edit') {
258
452
  setEditing(true)
259
453
  } else if (actionId === 'expand') {
454
+ setShowIframe(true)
260
455
  setExpanded(true)
261
456
  } else if (actionId === 'open-external') {
262
457
  if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
263
- } else if (actionId === 'zoom-in') {
264
- const step = zoom < 75 ? 5 : 25
265
- onUpdate?.({ zoom: Math.min(200, zoom + step) })
266
- } else if (actionId === 'zoom-out') {
267
- const step = zoom <= 75 ? 5 : 25
268
- onUpdate?.({ zoom: Math.max(25, zoom - step) })
458
+ } else if (actionId === 'refresh-thumbnail') {
459
+ if (iframeReady && iframeRef.current?.contentWindow) {
460
+ requestCapture()
461
+ } else {
462
+ captureOnReadyRef.current = true
463
+ setShowIframe(true)
464
+ }
269
465
  }
270
466
  },
271
- }), [rawSrc, zoom, onUpdate])
467
+ }), [rawSrc, iframeReady, requestCapture])
272
468
 
273
469
  function handlePickRoute(route) {
274
470
  onUpdate?.({ src: route })
@@ -297,6 +493,10 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
297
493
  className={styles.embed}
298
494
  style={{ width, height, ...chromeVars }}
299
495
  >
496
+ <div className={styles.header}>
497
+ <span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
498
+ <span className={styles.headerTitle}>{prototypeTitle}</span>
499
+ </div>
300
500
  {editing ? (
301
501
  <div
302
502
  className={styles.pickerPanel}
@@ -385,25 +585,66 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
385
585
  className={styles.iframeContainer}
386
586
  style={expanded ? { visibility: 'hidden' } : undefined}
387
587
  >
388
- <iframe
389
- ref={iframeRef}
390
- src={iframeSrc}
391
- className={styles.iframe}
392
- style={{
393
- width: width / scale,
394
- height: height / scale,
395
- transform: `scale(${scale})`,
396
- transformOrigin: '0 0',
397
- }}
398
- title={label || 'Prototype embed'}
399
- sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
400
- />
588
+ {/* Snapshot layer — single image */}
589
+ {hasSnap && (
590
+ <img
591
+ src={snapshot}
592
+ className={styles.snapshotImage}
593
+ alt={`${prototypeTitle} snapshot`}
594
+ draggable={false}
595
+ onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))}
596
+ />
597
+ )}
598
+
599
+ {/* Iframe layer — on top, transparent until loaded */}
600
+ {showIframe && (
601
+ <iframe
602
+ ref={iframeRef}
603
+ src={iframeSrc}
604
+ className={styles.iframe}
605
+ style={{
606
+ width: width / scale,
607
+ height: height / scale,
608
+ transform: `scale(${scale})`,
609
+ transformOrigin: '0 0',
610
+ transition: 'opacity 150ms ease',
611
+ ...(iframeLoaded ? {} : { opacity: 0 }),
612
+ }}
613
+ onLoad={() => setIframeLoaded(true)}
614
+ title={`${prototypeTitle} prototype`}
615
+ sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
616
+ />
617
+ )}
618
+
619
+ {/* Placeholder — only when no snapshot and no iframe */}
620
+ {!hasSnap && !showIframe && (
621
+ <div className={styles.placeholder}>
622
+ <CollageFrameIcon size={36} />
623
+ <span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
624
+ </div>
625
+ )}
401
626
  </div>
627
+
402
628
  {!interactive && !expanded && (
403
629
  <div
404
- className={styles.dragOverlay}
405
- onDoubleClick={enterInteractive}
406
- />
630
+ className={overlayStyles.interactOverlay}
631
+ onClick={(e) => {
632
+ if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
633
+ enterInteractive()
634
+ }}
635
+ role="button"
636
+ tabIndex={0}
637
+ onKeyDown={(e) => {
638
+ if (e.key === 'Enter' || e.key === ' ') {
639
+ e.preventDefault()
640
+ e.stopPropagation()
641
+ enterInteractive()
642
+ }
643
+ }}
644
+ aria-label={hasSnap ? 'Click to interact with prototype' : 'Click to open prototype'}
645
+ >
646
+ <span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
647
+ </div>
407
648
  )}
408
649
  </>
409
650
  ) : (
@@ -436,6 +677,9 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
436
677
  function onUp() {
437
678
  document.removeEventListener('mousemove', onMove)
438
679
  document.removeEventListener('mouseup', onUp)
680
+ // Recapture snapshot after resize (debounced)
681
+ clearTimeout(resizeTimerRef.current)
682
+ resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
439
683
  }
440
684
  document.addEventListener('mousemove', onMove)
441
685
  document.addEventListener('mouseup', onUp)
@@ -450,8 +694,13 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
450
694
  style={expanded ? undefined : { display: 'none' }}
451
695
  onClick={() => setExpanded(false)}
452
696
  onPointerDown={(e) => e.stopPropagation()}
453
- onKeyDown={(e) => e.stopPropagation()}
697
+ onKeyDown={(e) => {
698
+ e.stopPropagation()
699
+ if (e.key === 'Escape') setExpanded(false)
700
+ }}
454
701
  onWheel={(e) => e.stopPropagation()}
702
+ tabIndex={-1}
703
+ ref={(el) => { if (el && expanded) el.focus() }}
455
704
  >
456
705
  <div
457
706
  ref={modalContainerRef}
@@ -7,22 +7,92 @@
7
7
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
8
8
  }
9
9
 
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;
21
+ overflow: hidden;
22
+ text-overflow: ellipsis;
23
+ user-select: none;
24
+ }
25
+
26
+ .headerIcon {
27
+ display: inline-flex;
28
+ flex-shrink: 0;
29
+ }
30
+
31
+ .headerTitle {
32
+ overflow: hidden;
33
+ text-overflow: ellipsis;
34
+ }
35
+
10
36
  .iframeContainer {
37
+ position: relative;
11
38
  width: 100%;
12
- height: 100%;
39
+ height: calc(100% - 37px);
13
40
  overflow: hidden;
14
41
  }
15
42
 
43
+ .placeholder {
44
+ position: absolute;
45
+ inset: 0;
46
+ display: flex;
47
+ flex-direction: column;
48
+ align-items: center;
49
+ justify-content: center;
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;
63
+ }
64
+
65
+ .spinner {
66
+ width: 24px;
67
+ height: 24px;
68
+ border: 3px solid var(--borderColor-muted, #d0d7de);
69
+ border-top-color: var(--fgColor-accent, #2f81f7);
70
+ border-radius: 50%;
71
+ animation: spin 0.8s linear infinite;
72
+ }
73
+
74
+ @keyframes spin {
75
+ from { transform: rotate(0deg); }
76
+ to { transform: rotate(360deg); }
77
+ }
78
+
16
79
  .iframe {
17
80
  border: none;
18
81
  display: block;
82
+ position: relative;
83
+ z-index: 1;
19
84
  }
20
85
 
21
- .dragOverlay {
86
+ .snapshotImage {
22
87
  position: absolute;
23
88
  inset: 0;
24
- z-index: 1;
25
- cursor: grab;
89
+ width: 100%;
90
+ height: 100%;
91
+ object-fit: cover;
92
+ object-position: top left;
93
+ display: block;
94
+ pointer-events: none;
95
+ transition: opacity 150ms ease;
26
96
  }
27
97
 
28
98
  .empty {
@@ -20,6 +20,11 @@
20
20
  box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
21
21
  }
22
22
 
23
+ /* Hide own border when parent widget slot is selected (avoid double focus ring) */
24
+ :global([data-widget-selected]) .sticky {
25
+ border-color: transparent;
26
+ }
27
+
23
28
  .text {
24
29
  padding: 16px 20px;
25
30
  margin: 0;
@@ -13,16 +13,16 @@ describe('stickyNoteSchema', () => {
13
13
  )
14
14
  })
15
15
 
16
- it('does not include default values for width/height so new widgets size naturally', () => {
16
+ it('includes default values for width/height from config', () => {
17
17
  const defaults = getDefaults(stickyNoteSchema)
18
- expect(defaults).not.toHaveProperty('width')
19
- expect(defaults).not.toHaveProperty('height')
18
+ expect(defaults).toHaveProperty('width', 270)
19
+ expect(defaults).toHaveProperty('height', 170)
20
20
  })
21
21
 
22
- it('returns null when width/height are not saved in props', () => {
22
+ it('returns default value when width/height are not saved in props', () => {
23
23
  const props = { text: 'hello', color: 'yellow' }
24
- expect(readProp(props, 'width', stickyNoteSchema)).toBeNull()
25
- expect(readProp(props, 'height', stickyNoteSchema)).toBeNull()
24
+ expect(readProp(props, 'width', stickyNoteSchema)).toBe(270)
25
+ expect(readProp(props, 'height', stickyNoteSchema)).toBe(170)
26
26
  })
27
27
 
28
28
  it('returns saved width/height when present in props', () => {
@@ -33,11 +33,11 @@ describe('stickyNoteSchema', () => {
33
33
  })
34
34
 
35
35
  describe('StickyNote', () => {
36
- it('renders without explicit dimensions when width/height are not saved', () => {
36
+ it('applies default dimensions as inline styles when not saved in props', () => {
37
37
  const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
38
38
  const sticky = container.querySelector('article')
39
- expect(sticky.style.width).toBe('')
40
- expect(sticky.style.height).toBe('')
39
+ expect(sticky.style.width).toBe('270px')
40
+ expect(sticky.style.height).toBe('170px')
41
41
  })
42
42
 
43
43
  it('applies saved dimensions as inline styles', () => {