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

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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/canvas/CanvasPage.bridge.test.jsx +87 -2
  3. package/src/canvas/CanvasPage.jsx +161 -18
  4. package/src/canvas/CanvasPage.module.css +54 -0
  5. package/src/canvas/CanvasPage.multiselect.test.jsx +2 -0
  6. package/src/canvas/canvasApi.js +8 -0
  7. package/src/canvas/widgets/FigmaEmbed.jsx +42 -7
  8. package/src/canvas/widgets/FigmaEmbed.module.css +21 -0
  9. package/src/canvas/widgets/LinkPreview.jsx +247 -18
  10. package/src/canvas/widgets/LinkPreview.module.css +349 -8
  11. package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
  12. package/src/canvas/widgets/MarkdownBlock.jsx +2 -1
  13. package/src/canvas/widgets/MarkdownBlock.module.css +34 -11
  14. package/src/canvas/widgets/PrototypeEmbed.jsx +101 -44
  15. package/src/canvas/widgets/PrototypeEmbed.module.css +1 -0
  16. package/src/canvas/widgets/StoryWidget.jsx +86 -42
  17. package/src/canvas/widgets/StoryWidget.module.css +1 -0
  18. package/src/canvas/widgets/WidgetChrome.jsx +20 -1
  19. package/src/canvas/widgets/embedInteraction.test.jsx +16 -18
  20. package/src/canvas/widgets/embedTheme.js +37 -1
  21. package/src/canvas/widgets/githubUrl.js +82 -0
  22. package/src/canvas/widgets/githubUrl.test.js +74 -0
  23. package/src/canvas/widgets/refreshQueue.js +108 -0
  24. package/src/canvas/widgets/snapshotDisplay.test.jsx +60 -90
  25. package/src/canvas/widgets/useSnapshotCapture.js +38 -139
  26. package/src/canvas/widgets/useSnapshotCapture.test.jsx +30 -91
  27. package/src/canvas/widgets/widgetConfig.test.js +1 -1
  28. package/src/story/StoryPage.jsx +25 -60
  29. package/src/story/StoryPage.module.css +0 -55
@@ -0,0 +1,71 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import LinkPreview from './LinkPreview.jsx'
4
+
5
+ describe('LinkPreview', () => {
6
+ it('renders GitHub issue card with markdown body and author byline', () => {
7
+ const { container } = render(
8
+ <LinkPreview
9
+ id="link-1"
10
+ props={{
11
+ url: 'https://github.com/dfosco/storyboard/issues/42',
12
+ title: '#42 Ship GitHub embeds',
13
+ github: {
14
+ context: 'GitHub · dfosco/storyboard · Issue #42',
15
+ body: '## Summary\n\nThis is a **bold** point.\n\n- Item one\n- Item two',
16
+ authors: ['dfosco'],
17
+ createdAt: '2026-01-01T00:00:00Z',
18
+ },
19
+ }}
20
+ />,
21
+ )
22
+
23
+ // Title split: text + muted number
24
+ expect(screen.getByText('Ship GitHub embeds')).toBeInTheDocument()
25
+ expect(screen.getByText('#42')).toBeInTheDocument()
26
+
27
+ // Markdown body renders headings, bold, lists
28
+ const headings = container.querySelectorAll('h2')
29
+ expect(headings.length).toBeGreaterThanOrEqual(1)
30
+ // Find the body heading (not the title)
31
+ const summaryHeading = Array.from(headings).find(h => h.textContent === 'Summary')
32
+ expect(summaryHeading).toBeTruthy()
33
+ expect(container.querySelectorAll('li')).toHaveLength(2)
34
+
35
+ // Author byline
36
+ expect(screen.getByText('dfosco')).toBeInTheDocument()
37
+ })
38
+
39
+ it('does not render GitHub layout for non-GitHub links', () => {
40
+ render(
41
+ <LinkPreview
42
+ id="link-2"
43
+ props={{
44
+ url: 'https://example.com/docs',
45
+ title: 'Example docs',
46
+ }}
47
+ />,
48
+ )
49
+
50
+ expect(screen.getByText('Example docs')).toBeInTheDocument()
51
+ expect(screen.getByText('example.com')).toBeInTheDocument()
52
+ })
53
+
54
+ it('renders plain link-preview without github data', () => {
55
+ const { container } = render(
56
+ <LinkPreview
57
+ id="link-3"
58
+ props={{
59
+ url: 'https://figma.com/design/abc',
60
+ title: 'My design',
61
+ width: 320,
62
+ height: 120,
63
+ }}
64
+ />,
65
+ )
66
+
67
+ expect(screen.getByText('My design')).toBeInTheDocument()
68
+ // No issue card rendered
69
+ expect(container.querySelector('header')).toBeNull()
70
+ })
71
+ })
@@ -19,7 +19,8 @@ function renderMarkdown(text) {
19
19
  .use(remarkGfm)
20
20
  .use(remarkHtml, { sanitize: false })
21
21
  .processSync(text)
22
- return String(result)
22
+ // Open all links in new tabs
23
+ return String(result).replace(/<a\s/g, '<a target="_blank" rel="noopener noreferrer" ')
23
24
  }
24
25
 
25
26
  /**
@@ -24,6 +24,38 @@
24
24
  pointer-events: none;
25
25
  }
26
26
 
27
+ .preview a {
28
+ color: var(--sb--markdown-accent);
29
+ text-decoration: none;
30
+ pointer-events: auto;
31
+ cursor: pointer;
32
+ }
33
+
34
+ .preview a:hover {
35
+ text-decoration: underline;
36
+ }
37
+
38
+ .preview img {
39
+ max-width: 100%;
40
+ height: auto;
41
+ border-radius: 6px;
42
+ border: 1px solid var(--borderColor-default, #d0d7de);
43
+ margin: 8px 0;
44
+ display: block;
45
+ pointer-events: auto;
46
+ }
47
+
48
+ .preview video {
49
+ max-width: 100%;
50
+ height: auto;
51
+ border-radius: 6px;
52
+ border: 1px solid var(--borderColor-default, #d0d7de);
53
+ margin: 8px 0;
54
+ display: block;
55
+ pointer-events: auto;
56
+ background: var(--bgColor-muted, #f6f8fa);
57
+ }
58
+
27
59
  .preview h1 {
28
60
  font-size: 20px;
29
61
  font-weight: 700;
@@ -75,10 +107,11 @@
75
107
  list-style-type: decimal;
76
108
  }
77
109
 
78
- /* GFM: Task lists */
110
+ /* GFM: Task lists — accent-colored checkboxes */
79
111
  .preview input[type="checkbox"] {
80
112
  margin-right: 6px;
81
113
  pointer-events: none;
114
+ accent-color: var(--sb--markdown-accent);
82
115
  }
83
116
 
84
117
  .preview li:has(input[type="checkbox"]) {
@@ -112,16 +145,6 @@
112
145
  font-weight: 600;
113
146
  }
114
147
 
115
- /* GFM: Autolinks */
116
- .preview a {
117
- color: var(--sb--markdown-accent);
118
- text-decoration: none;
119
- }
120
-
121
- .preview a:hover {
122
- text-decoration: underline;
123
- }
124
-
125
148
  /* Code blocks */
126
149
  .preview pre {
127
150
  padding: 12px 16px;
@@ -3,9 +3,10 @@ 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, resolveCanvasTheme } from './embedTheme.js'
6
+ import { getEmbedChromeVars, subscribeCanvasTheme } from './embedTheme.js'
7
7
  import { useIframeDevLogs } from './iframeDevLogs.js'
8
8
  import { useSnapshotCapture } from './useSnapshotCapture.js'
9
+ import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
9
10
  import styles from './PrototypeEmbed.module.css'
10
11
  import overlayStyles from './embedOverlay.module.css'
11
12
 
@@ -66,8 +67,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
66
67
  const height = readProp(props, 'height', prototypeEmbedSchema)
67
68
  const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
68
69
  const label = readProp(props, 'label', prototypeEmbedSchema) || src
69
- const snapshotLight = props?.snapshotLight || ''
70
- const snapshotDark = props?.snapshotDark || ''
70
+ const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
71
71
 
72
72
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
73
73
  const baseSegment = basePath.replace(/^\//, '')
@@ -88,7 +88,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
88
88
  const [iframeLoaded, setIframeLoaded] = useState(false)
89
89
  const [expanded, setExpanded] = useState(false)
90
90
  const [filter, setFilter] = useState('')
91
- const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasTheme())
91
+ const [canvasTheme, setCanvasTheme] = useState('light')
92
92
  const [brokenSnaps, setBrokenSnaps] = useState({})
93
93
 
94
94
  const inputRef = useRef(null)
@@ -97,6 +97,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
97
97
  const iframeRef = useRef(null)
98
98
  const captureOnReadyRef = useRef(false)
99
99
  const exitSessionRef = useRef(0)
100
+ const teardownTimerRef = useRef(null)
100
101
  const inlineContainerRef = useRef(null)
101
102
  const modalContainerRef = useRef(null)
102
103
  const resizeTimerRef = useRef(null)
@@ -108,14 +109,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
108
109
  iframeRef,
109
110
  widgetId,
110
111
  onUpdate: isExternal ? null : onUpdate,
111
- canvasTheme,
112
+ showIframe,
112
113
  })
113
114
 
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
115
+ // Single snapshot backward compat reads snapshotLight/snapshotDark if snapshot is missing
116
+ const hasSnap = !isExternal && !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
119
117
 
120
118
  const iframeSrc = useMemo(() => {
121
119
  if (!rawSrc) return ''
@@ -125,8 +123,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
125
123
  const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
126
124
  const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
127
125
  const sep = base.includes('?') ? '&' : '?'
128
- return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
129
- }, [rawSrc, canvasTheme])
126
+ return `${base}${sep}_sb_embed&_sb_theme_target=prototype${hash}`
127
+ }, [rawSrc])
130
128
 
131
129
  useIframeDevLogs({
132
130
  widget: 'PrototypeEmbed',
@@ -260,13 +258,36 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
260
258
 
261
259
  setInteractive(false)
262
260
  if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
263
- // Keep iframe mounted but hidden for background capture
264
261
  if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
265
262
  const session = ++exitSessionRef.current
266
- requestCapture({ force: true }).then(() => {
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(() => {
267
288
  if (exitSessionRef.current !== session) return
268
289
  setShowIframe(false)
269
- })
290
+ }, 2 * 60 * 1000)
270
291
  } else {
271
292
  setShowIframe(false)
272
293
  }
@@ -276,17 +297,34 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
276
297
  return () => document.removeEventListener('pointerdown', handlePointerDown)
277
298
  }, [interactive, expanded, onUpdate, isExternal, iframeLoaded, requestCapture])
278
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)
279
309
  useEffect(() => {
280
- const readTheme = () => setCanvasTheme(resolveCanvasTheme())
281
- readTheme()
282
- document.addEventListener('storyboard:theme:changed', readTheme)
283
- return () => document.removeEventListener('storyboard:theme:changed', readTheme)
284
- }, [])
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
285
323
 
286
324
  // Capture snapshot on first iframe ready (when no existing snapshot)
287
325
  useEffect(() => {
288
326
  if (!iframeReady || !onUpdate || isExternal) return
289
- if (!hasAnySnap) {
327
+ if (!hasSnap) {
290
328
  requestCapture()
291
329
  }
292
330
  }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -295,12 +333,39 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
295
333
  useEffect(() => {
296
334
  if (iframeReady && captureOnReadyRef.current) {
297
335
  captureOnReadyRef.current = false
298
- requestCapture()
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
+ })
299
361
  }
300
362
  }, [iframeReady, requestCapture])
301
363
 
302
- // Cleanup resize timer on unmount
303
- useEffect(() => () => clearTimeout(resizeTimerRef.current), [])
364
+ // Cleanup timers on unmount
365
+ useEffect(() => () => {
366
+ clearTimeout(resizeTimerRef.current)
367
+ clearTimeout(teardownTimerRef.current)
368
+ }, [])
304
369
 
305
370
  // Close expanded modal on Escape
306
371
  useEffect(() => {
@@ -374,9 +439,11 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
374
439
 
375
440
  const enterInteractive = useCallback(() => {
376
441
  exitSessionRef.current++
442
+ clearTimeout(teardownTimerRef.current)
443
+ cancelRefresh(widgetId)
377
444
  setShowIframe(true)
378
445
  setInteractive(true)
379
- }, [])
446
+ }, [widgetId])
380
447
 
381
448
  // Expose imperative action handlers for WidgetChrome
382
449
  useImperativeHandle(ref, () => ({
@@ -518,25 +585,14 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
518
585
  className={styles.iframeContainer}
519
586
  style={expanded ? { visibility: 'hidden' } : undefined}
520
587
  >
521
- {/* Snapshot layer — both themes always in DOM for instant swap */}
522
- {hasLightSnap && (
523
- <img
524
- src={snapshotLight}
525
- className={styles.snapshotImage}
526
- style={(isDark && hasDarkSnap) ? { visibility: 'hidden' } : undefined}
527
- alt={`${prototypeTitle} snapshot`}
528
- draggable={false}
529
- onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotLight]: true }))}
530
- />
531
- )}
532
- {hasDarkSnap && (
588
+ {/* Snapshot layer — single image */}
589
+ {hasSnap && (
533
590
  <img
534
- src={snapshotDark}
591
+ src={snapshot}
535
592
  className={styles.snapshotImage}
536
- style={(!isDark && hasLightSnap) ? { visibility: 'hidden' } : undefined}
537
593
  alt={`${prototypeTitle} snapshot`}
538
594
  draggable={false}
539
- onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotDark]: true }))}
595
+ onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))}
540
596
  />
541
597
  )}
542
598
 
@@ -551,6 +607,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
551
607
  height: height / scale,
552
608
  transform: `scale(${scale})`,
553
609
  transformOrigin: '0 0',
610
+ transition: 'opacity 150ms ease',
554
611
  ...(iframeLoaded ? {} : { opacity: 0 }),
555
612
  }}
556
613
  onLoad={() => setIframeLoaded(true)}
@@ -559,8 +616,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
559
616
  />
560
617
  )}
561
618
 
562
- {/* Placeholder — only when no snapshots and no iframe */}
563
- {!hasAnySnap && !showIframe && (
619
+ {/* Placeholder — only when no snapshot and no iframe */}
620
+ {!hasSnap && !showIframe && (
564
621
  <div className={styles.placeholder}>
565
622
  <CollageFrameIcon size={36} />
566
623
  <span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
@@ -584,9 +641,9 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
584
641
  enterInteractive()
585
642
  }
586
643
  }}
587
- aria-label="Click to interact with prototype"
644
+ aria-label={hasSnap ? 'Click to interact with prototype' : 'Click to open prototype'}
588
645
  >
589
- <span className={overlayStyles.interactHint}>Click to interact</span>
646
+ <span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
590
647
  </div>
591
648
  )}
592
649
  </>
@@ -92,6 +92,7 @@
92
92
  object-position: top left;
93
93
  display: block;
94
94
  pointer-events: none;
95
+ transition: opacity 150ms ease;
95
96
  }
96
97
 
97
98
  .empty {
@@ -18,7 +18,8 @@ import WidgetWrapper from './WidgetWrapper.jsx'
18
18
  import ResizeHandle from './ResizeHandle.jsx'
19
19
  import { useIframeDevLogs } from './iframeDevLogs.js'
20
20
  import { useSnapshotCapture } from './useSnapshotCapture.js'
21
- import { resolveCanvasTheme } from './embedTheme.js'
21
+ import { subscribeCanvasTheme } from './embedTheme.js'
22
+ import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
22
23
  import styles from './StoryWidget.module.css'
23
24
  import overlayStyles from './embedOverlay.module.css'
24
25
 
@@ -39,7 +40,7 @@ function resolveStoryUrl(storyId, exportName) {
39
40
 
40
41
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
41
42
  const route = story._route
42
- const params = new URLSearchParams({ _sb_embed: '1' })
43
+ const params = new URLSearchParams({ _sb_embed: '1', _sb_theme_target: 'prototype' })
43
44
  if (exportName) params.set('export', exportName)
44
45
 
45
46
  return `${base}${route}?${params}`
@@ -86,14 +87,14 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
86
87
  const exportName = props?.exportName || ''
87
88
  const width = props?.width
88
89
  const height = props?.height
89
- const snapshotLight = props?.snapshotLight || ''
90
- const snapshotDark = props?.snapshotDark || ''
90
+ const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
91
91
 
92
92
  const containerRef = useRef(null)
93
93
  const iframeRef = useRef(null)
94
94
  const resizeTimerRef = useRef(null)
95
95
  const captureOnReadyRef = useRef(false)
96
96
  const exitSessionRef = useRef(0)
97
+ const refreshMetaRef = useRef(null)
97
98
  const [interactive, setInteractive] = useState(false)
98
99
  const [showIframe, setShowIframe] = useState(false)
99
100
  const [iframeLoaded, setIframeLoaded] = useState(false)
@@ -105,27 +106,39 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
105
106
  const [brokenSnaps, setBrokenSnaps] = useState({})
106
107
 
107
108
  // Resolve canvas theme — reactive to theme changes
108
- const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasTheme())
109
+ const [canvasTheme, setCanvasTheme] = useState('light')
109
110
 
111
+ useEffect(() => subscribeCanvasTheme({
112
+ anchorRef: containerRef,
113
+ onTheme: setCanvasTheme,
114
+ }), [])
115
+
116
+ // On canvas theme change, enqueue a background snapshot refresh
117
+ const canvasThemeInitRef = useRef(true)
110
118
  useEffect(() => {
111
- const readTheme = () => setCanvasTheme(resolveCanvasTheme())
112
- document.addEventListener('storyboard:theme:changed', readTheme)
113
- return () => document.removeEventListener('storyboard:theme:changed', readTheme)
114
- }, [])
119
+ if (canvasThemeInitRef.current) { canvasThemeInitRef.current = false; return }
120
+ if (!onUpdate || interactive) return
121
+ const rect = containerRef.current?.getBoundingClientRect()
122
+ enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
123
+ return new Promise((resolve) => {
124
+ refreshMetaRef.current = { revealOrder, batchStart, resolve }
125
+ captureOnReadyRef.current = true
126
+ setShowIframe(true)
127
+ setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
128
+ })
129
+ }, rect ? { x: rect.left, y: rect.top } : undefined)
130
+ }, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
115
131
 
116
132
  // Snapshot capture hook
117
133
  const { iframeReady, requestCapture } = useSnapshotCapture({
118
134
  iframeRef,
119
135
  widgetId,
120
136
  onUpdate,
121
- canvasTheme,
137
+ showIframe,
122
138
  })
123
139
 
124
- // Determine available snapshots for layered rendering
125
- const isDark = canvasTheme?.startsWith('dark')
126
- const hasLightSnap = !!(snapshotLight && snapshotLight.includes(widgetId) && !brokenSnaps[snapshotLight])
127
- const hasDarkSnap = !!(snapshotDark && snapshotDark.includes(widgetId) && !brokenSnaps[snapshotDark])
128
- const hasAnySnap = hasLightSnap || hasDarkSnap
140
+ // Single snapshot
141
+ const hasSnap = !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
129
142
 
130
143
  // Re-resolve story URL when the story index is live-patched (new story added)
131
144
  useEffect(() => {
@@ -147,9 +160,10 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
147
160
 
148
161
  const enterInteractive = useCallback(() => {
149
162
  exitSessionRef.current++
163
+ cancelRefresh(widgetId)
150
164
  setShowIframe(true)
151
165
  setInteractive(true)
152
- }, [])
166
+ }, [widgetId])
153
167
 
154
168
  useEffect(() => {
155
169
  if (!showIframe) setIframeLoaded(false)
@@ -167,13 +181,27 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
167
181
 
168
182
  setInteractive(false)
169
183
  if (onUpdate && iframeLoaded && iframeRef.current?.contentWindow) {
170
- // Keep iframe mounted but hidden for background capture
171
184
  if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
172
185
  const session = ++exitSessionRef.current
173
- requestCapture({ force: true }).then(() => {
186
+ setTimeout(() => {
174
187
  if (exitSessionRef.current !== session) return
175
- setShowIframe(false)
176
- })
188
+ requestCapture({ force: true }).then((updates) => {
189
+ if (exitSessionRef.current !== session) return
190
+ const snap = updates?.snapshot
191
+ if (snap) {
192
+ const img = new Image()
193
+ const done = () => {
194
+ if (exitSessionRef.current === session) setShowIframe(false)
195
+ }
196
+ img.onload = done
197
+ img.onerror = done
198
+ img.src = snap
199
+ setTimeout(done, 2000)
200
+ } else {
201
+ setShowIframe(false)
202
+ }
203
+ })
204
+ }, 0)
177
205
  } else {
178
206
  setShowIframe(false)
179
207
  }
@@ -193,7 +221,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
193
221
  // Capture snapshot on first iframe ready (when no existing snapshot)
194
222
  useEffect(() => {
195
223
  if (!iframeReady || !onUpdate) return
196
- if (!hasAnySnap) {
224
+ if (!hasSnap) {
197
225
  requestCapture()
198
226
  }
199
227
  }, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -202,7 +230,31 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
202
230
  useEffect(() => {
203
231
  if (iframeReady && captureOnReadyRef.current) {
204
232
  captureOnReadyRef.current = false
205
- requestCapture()
233
+ requestCapture().then((updates) => {
234
+ const meta = refreshMetaRef.current
235
+ if (meta) {
236
+ refreshMetaRef.current = null
237
+ const snap = updates?.snapshot
238
+ const reveal = () => {
239
+ if (snap) {
240
+ const img = new Image()
241
+ const done = () => setShowIframe(false)
242
+ img.onload = done
243
+ img.onerror = done
244
+ img.src = snap
245
+ setTimeout(done, 2000)
246
+ } else {
247
+ setShowIframe(false)
248
+ }
249
+ meta.resolve(!!snap)
250
+ }
251
+ // Wait for our reveal slot in the wave
252
+ const elapsed = Date.now() - meta.batchStart
253
+ const targetTime = meta.revealOrder * REVEAL_INTERVAL
254
+ const wait = Math.max(0, targetTime - elapsed)
255
+ setTimeout(reveal, wait)
256
+ }
257
+ })
206
258
  }
207
259
  }, [iframeReady, requestCapture])
208
260
 
@@ -388,25 +440,14 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
388
440
  ) : (
389
441
  <>
390
442
  <div className={styles.content}>
391
- {/* Snapshot layer — both themes always in DOM for instant swap */}
392
- {hasLightSnap && (
393
- <img
394
- src={snapshotLight}
395
- className={styles.snapshotImage}
396
- style={(isDark && hasDarkSnap) ? { visibility: 'hidden' } : undefined}
397
- alt={`${displayName} snapshot`}
398
- draggable={false}
399
- onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotLight]: true }))}
400
- />
401
- )}
402
- {hasDarkSnap && (
443
+ {/* Snapshot layer — single image */}
444
+ {hasSnap && (
403
445
  <img
404
- src={snapshotDark}
446
+ src={snapshot}
405
447
  className={styles.snapshotImage}
406
- style={(!isDark && hasLightSnap) ? { visibility: 'hidden' } : undefined}
407
448
  alt={`${displayName} snapshot`}
408
449
  draggable={false}
409
- onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshotDark]: true }))}
450
+ onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))}
410
451
  />
411
452
  )}
412
453
 
@@ -416,14 +457,17 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
416
457
  ref={iframeRef}
417
458
  src={iframeSrc}
418
459
  className={styles.iframe}
419
- style={iframeLoaded ? undefined : { opacity: 0 }}
460
+ style={{
461
+ ...(iframeLoaded ? undefined : { opacity: 0 }),
462
+ transition: 'opacity 150ms ease',
463
+ }}
420
464
  onLoad={() => setIframeLoaded(true)}
421
465
  title={displayName}
422
466
  />
423
467
  )}
424
468
 
425
- {/* Placeholder — only when no snapshots and no iframe */}
426
- {!hasAnySnap && !showIframe && (
469
+ {/* Placeholder — only when no snapshot and no iframe */}
470
+ {!hasSnap && !showIframe && (
427
471
  <div className={styles.placeholder}>
428
472
  <ComponentIcon size={36} />
429
473
  <span className={styles.placeholderLabel}>{displayName}</span>
@@ -447,9 +491,9 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
447
491
  enterInteractive()
448
492
  }
449
493
  }}
450
- aria-label="Click to interact with story component"
494
+ aria-label={hasSnap ? 'Click to interact with story component' : 'Click to open story component'}
451
495
  >
452
- <span className={overlayStyles.interactHint}>Click to interact</span>
496
+ <span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
453
497
  </div>
454
498
  )}
455
499
  </>
@@ -93,6 +93,7 @@
93
93
  object-position: top left;
94
94
  display: block;
95
95
  pointer-events: none;
96
+ transition: opacity 150ms ease;
96
97
  }
97
98
 
98
99
  .codeView {