@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.
@@ -0,0 +1,258 @@
1
+ /**
2
+ * useSnapshotCapture — parent-side capture orchestration hook.
3
+ *
4
+ * Listens for snapshot-ready signals from an embedded iframe and
5
+ * provides a requestCapture() function that triggers a capture.
6
+ *
7
+ * Performs TRUE dual-theme capture: captures current theme, then
8
+ * tells the iframe to switch theme (hidden via visibility:hidden),
9
+ * captures the alternate theme, and switches back. The user never
10
+ * sees the theme flash because the iframe is hidden during the switch.
11
+ *
12
+ * Optimized pipeline: the first theme's upload runs in parallel with
13
+ * the alternate theme's capture, and capture generation tokens prevent
14
+ * stale results from overwriting newer snapshots.
15
+ *
16
+ * Only active in dev mode (when onUpdate is provided).
17
+ */
18
+ import { useState, useEffect, useCallback, useRef } from 'react'
19
+ import { uploadImage } from '../canvasApi.js'
20
+
21
+ const CAPTURE_TIMEOUT = 3000
22
+ const THEME_SWITCH_TIMEOUT = 2000
23
+
24
+ /**
25
+ * Run a single capture request against the iframe.
26
+ * Returns the dataUrl or null on failure.
27
+ */
28
+ function captureOnce(iframeContentWindow, requestId, listeners) {
29
+ return new Promise((resolve) => {
30
+ const timer = setTimeout(() => {
31
+ cleanup()
32
+ resolve(null)
33
+ }, CAPTURE_TIMEOUT)
34
+
35
+ function cleanup() {
36
+ clearTimeout(timer)
37
+ const idx = listeners.indexOf(handler)
38
+ if (idx !== -1) listeners.splice(idx, 1)
39
+ }
40
+
41
+ function handler(data) {
42
+ if (data.requestId !== requestId) return
43
+ cleanup()
44
+ if (data.error || !data.dataUrl) {
45
+ if (data.error) console.warn('[snapshot] Capture failed:', data.error)
46
+ resolve(null)
47
+ } else {
48
+ resolve(data.dataUrl)
49
+ }
50
+ }
51
+
52
+ listeners.push(handler)
53
+ iframeContentWindow.postMessage({
54
+ type: 'storyboard:embed:capture',
55
+ requestId,
56
+ }, '*')
57
+ })
58
+ }
59
+
60
+ /**
61
+ * Tell the iframe to switch its Primer theme and wait for confirmation.
62
+ */
63
+ function switchTheme(iframeContentWindow, theme, requestId, listeners) {
64
+ return new Promise((resolve) => {
65
+ const timer = setTimeout(() => {
66
+ cleanup()
67
+ resolve(false)
68
+ }, THEME_SWITCH_TIMEOUT)
69
+
70
+ function cleanup() {
71
+ clearTimeout(timer)
72
+ const idx = listeners.indexOf(handler)
73
+ if (idx !== -1) listeners.splice(idx, 1)
74
+ }
75
+
76
+ function handler(data) {
77
+ if (data.requestId !== requestId) return
78
+ cleanup()
79
+ resolve(true)
80
+ }
81
+
82
+ listeners.push(handler)
83
+ iframeContentWindow.postMessage({
84
+ type: 'storyboard:embed:set-theme',
85
+ theme,
86
+ requestId,
87
+ }, '*')
88
+ })
89
+ }
90
+
91
+ /**
92
+ * Upload a captured dataUrl and return the resolved image URL.
93
+ */
94
+ async function uploadAndResolve(dataUrl, widgetId, themeLabel, base) {
95
+ const filename = `snapshot-${widgetId}--${themeLabel}.webp`
96
+ const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
97
+ if (result?.filename) {
98
+ const cacheBust = `?v=${Date.now()}`
99
+ return `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
100
+ }
101
+ return null
102
+ }
103
+
104
+ export function useSnapshotCapture({
105
+ iframeRef,
106
+ widgetId,
107
+ onUpdate,
108
+ canvasTheme,
109
+ }) {
110
+ const [iframeReady, setIframeReady] = useState(false)
111
+ const iframeReadyRef = useRef(false)
112
+ const capturingRef = useRef(false)
113
+ const requestIdCounter = useRef(0)
114
+ // Generation token — incremented on each capture request to detect stale results
115
+ const captureGeneration = useRef(0)
116
+ // Handlers for both snapshot and theme-applied responses
117
+ const responseHandlers = useRef([])
118
+
119
+ // Reset ready state when iframe is unmounted/remounted
120
+ useEffect(() => {
121
+ setIframeReady(false)
122
+ iframeReadyRef.current = false
123
+ }, [widgetId])
124
+
125
+ // Listen for postMessage events from the embedded iframe
126
+ useEffect(() => {
127
+ if (!onUpdate) return
128
+
129
+ function handler(e) {
130
+ if (!iframeRef.current) return
131
+ if (e.source !== iframeRef.current.contentWindow) return
132
+
133
+ if (e.data?.type === 'storyboard:embed:snapshot-ready') {
134
+ setIframeReady(true)
135
+ iframeReadyRef.current = true
136
+ }
137
+
138
+ if (e.data?.type === 'storyboard:embed:snapshot' ||
139
+ e.data?.type === 'storyboard:embed:theme-applied') {
140
+ for (const fn of responseHandlers.current) {
141
+ fn(e.data)
142
+ }
143
+ }
144
+ }
145
+
146
+ window.addEventListener('message', handler)
147
+ return () => window.removeEventListener('message', handler)
148
+ }, [iframeRef, onUpdate])
149
+
150
+ /**
151
+ * Dual-theme capture with pipelined uploads.
152
+ *
153
+ * Pipeline: capture theme-1 → start upload-1 in parallel with
154
+ * (hide iframe → switch theme → capture theme-2) → upload-2.
155
+ *
156
+ * Generation tokens prevent stale captures from overwriting newer ones.
157
+ *
158
+ * @param {Object} opts
159
+ * @param {boolean} opts.force - Skip the iframeReady guard
160
+ */
161
+ const requestCapture = useCallback(async ({ force = false } = {}) => {
162
+ if (!onUpdate) return {}
163
+ if (!iframeRef.current?.contentWindow) return {}
164
+ if (capturingRef.current) return {}
165
+ if (!force && !iframeReadyRef.current) return {}
166
+
167
+ capturingRef.current = true
168
+ const gen = ++captureGeneration.current
169
+ const cw = iframeRef.current.contentWindow
170
+ const iframe = iframeRef.current
171
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
172
+ const updates = {}
173
+ const currentTheme = canvasTheme || 'light'
174
+ const isCurrentDark = currentTheme.startsWith('dark')
175
+ const alternateTheme = isCurrentDark ? 'light' : 'dark'
176
+
177
+ try {
178
+ // 1. Capture current theme (iframe is visible, user sees current state)
179
+ const currentKey = isCurrentDark ? 'snapshotDark' : 'snapshotLight'
180
+ const currentLabel = isCurrentDark ? 'dark' : 'light'
181
+ const currentReqId = ++requestIdCounter.current
182
+ const currentDataUrl = await captureOnce(cw, currentReqId, responseHandlers.current)
183
+
184
+ // Bail if a newer capture started while we were waiting
185
+ if (gen !== captureGeneration.current) return {}
186
+
187
+ // 2. Start upload of theme-1 in parallel with alternate-theme capture
188
+ const uploadPromise1 = currentDataUrl
189
+ ? uploadAndResolve(currentDataUrl, widgetId, currentLabel, base)
190
+ : Promise.resolve(null)
191
+
192
+ // Publish theme-1 immediately so snapshot img is ready before iframe hides
193
+ if (currentDataUrl) {
194
+ uploadPromise1.then(url => {
195
+ if (url && gen === captureGeneration.current) {
196
+ updates[currentKey] = url
197
+ onUpdate?.({ [currentKey]: url })
198
+ }
199
+ }).catch(() => {})
200
+ }
201
+
202
+ // 3. Hide iframe, switch theme, capture alternate — overlaps with upload-1
203
+ const savedVisibility = iframe.style.visibility
204
+ iframe.style.visibility = 'hidden'
205
+
206
+ const switchReqId = ++requestIdCounter.current
207
+ const switched = await switchTheme(cw, alternateTheme, switchReqId, responseHandlers.current)
208
+
209
+ if (gen !== captureGeneration.current) {
210
+ iframe.style.visibility = savedVisibility || ''
211
+ return {}
212
+ }
213
+
214
+ if (switched) {
215
+ const altKey = isCurrentDark ? 'snapshotLight' : 'snapshotDark'
216
+ const altLabel = isCurrentDark ? 'light' : 'dark'
217
+ const altReqId = ++requestIdCounter.current
218
+ const altDataUrl = await captureOnce(cw, altReqId, responseHandlers.current)
219
+
220
+ if (gen !== captureGeneration.current) {
221
+ iframe.style.visibility = savedVisibility || ''
222
+ return {}
223
+ }
224
+
225
+ if (altDataUrl) {
226
+ const altUrl = await uploadAndResolve(altDataUrl, widgetId, altLabel, base)
227
+ if (altUrl && gen === captureGeneration.current) {
228
+ updates[altKey] = altUrl
229
+ }
230
+ }
231
+
232
+ // 4. Switch back to original theme
233
+ const switchBackReqId = ++requestIdCounter.current
234
+ await switchTheme(cw, currentTheme, switchBackReqId, responseHandlers.current)
235
+ }
236
+
237
+ // Ensure upload-1 is complete before final update
238
+ await uploadPromise1
239
+
240
+ // 5. Restore iframe visibility
241
+ iframe.style.visibility = savedVisibility || ''
242
+
243
+ if (gen === captureGeneration.current && Object.keys(updates).length > 0) {
244
+ onUpdate?.(updates)
245
+ }
246
+ return updates
247
+ } catch (err) {
248
+ // Always restore visibility on error
249
+ if (iframe) iframe.style.visibility = ''
250
+ console.warn('[snapshot] Capture failed:', err)
251
+ return {}
252
+ } finally {
253
+ capturingRef.current = false
254
+ }
255
+ }, [onUpdate, iframeRef, widgetId, canvasTheme])
256
+
257
+ return { iframeReady, requestCapture }
258
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Tests for useSnapshotCapture hook — parent-side capture orchestration.
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
+ import { renderHook, act } from '@testing-library/react'
6
+ import { useSnapshotCapture } from './useSnapshotCapture.js'
7
+
8
+ vi.mock('../canvasApi.js', () => ({
9
+ uploadImage: vi.fn().mockResolvedValue({ filename: 'snapshot-test-widget--light.webp' }),
10
+ }))
11
+
12
+ import { uploadImage } from '../canvasApi.js'
13
+
14
+ function createMockIframeRef(contentWindow = null) {
15
+ return { current: contentWindow ? { contentWindow } : null }
16
+ }
17
+
18
+ function createMockContentWindow() {
19
+ return { postMessage: vi.fn() }
20
+ }
21
+
22
+ describe('useSnapshotCapture', () => {
23
+ let listeners = []
24
+
25
+ beforeEach(() => {
26
+ vi.clearAllMocks()
27
+ listeners = []
28
+ const origAdd = window.addEventListener
29
+ const origRemove = window.removeEventListener
30
+ vi.spyOn(window, 'addEventListener').mockImplementation((type, fn, opts) => {
31
+ if (type === 'message') listeners.push(fn)
32
+ origAdd.call(window, type, fn, opts)
33
+ })
34
+ vi.spyOn(window, 'removeEventListener').mockImplementation((type, fn, opts) => {
35
+ if (type === 'message') listeners = listeners.filter(l => l !== fn)
36
+ origRemove.call(window, type, fn, opts)
37
+ })
38
+ // Mock Image so preload resolves immediately in tests
39
+ vi.stubGlobal('Image', class MockImage {
40
+ constructor() { this._onload = null }
41
+ set onload(fn) { this._onload = fn }
42
+ get onload() { return this._onload }
43
+ set onerror(fn) { this._onerror = fn }
44
+ set src(v) { this._src = v; Promise.resolve().then(() => this._onload?.()) }
45
+ get src() { return this._src }
46
+ })
47
+ })
48
+
49
+ afterEach(() => { vi.restoreAllMocks() })
50
+
51
+ function dispatchMessage(source, data) {
52
+ const event = new MessageEvent('message', { source, data })
53
+ listeners.forEach(fn => fn(event))
54
+ }
55
+
56
+ it('returns iframeReady=false initially', () => {
57
+ const iframeRef = createMockIframeRef()
58
+ const { result } = renderHook(() =>
59
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
60
+ )
61
+ expect(result.current.iframeReady).toBe(false)
62
+ })
63
+
64
+ it('sets iframeReady=true on snapshot-ready message', () => {
65
+ const cw = createMockContentWindow()
66
+ const iframeRef = createMockIframeRef(cw)
67
+ const { result } = renderHook(() =>
68
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
69
+ )
70
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
71
+ expect(result.current.iframeReady).toBe(true)
72
+ })
73
+
74
+ it('no-ops when onUpdate is null', async () => {
75
+ const cw = createMockContentWindow()
76
+ const iframeRef = createMockIframeRef(cw)
77
+ const { result } = renderHook(() =>
78
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: null, canvasTheme: 'light' })
79
+ )
80
+ await act(async () => { await result.current.requestCapture() })
81
+ expect(cw.postMessage).not.toHaveBeenCalled()
82
+ })
83
+
84
+ it('no-ops when iframe not ready', async () => {
85
+ const cw = createMockContentWindow()
86
+ const iframeRef = createMockIframeRef(cw)
87
+ const { result } = renderHook(() =>
88
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
89
+ )
90
+ await act(async () => { await result.current.requestCapture() })
91
+ expect(cw.postMessage).not.toHaveBeenCalled()
92
+ })
93
+
94
+ it('sends capture + set-theme + capture + set-theme for dual-theme', async () => {
95
+ const cw = createMockContentWindow()
96
+ // Need a real iframe element for style.visibility
97
+ const style = { visibility: "" }
98
+
99
+ const iframeRef = { current: { contentWindow: cw, style } }
100
+ const onUpdate = vi.fn()
101
+ const { result } = renderHook(() =>
102
+ useSnapshotCapture({ iframeRef, widgetId: 'test-widget', onUpdate, canvasTheme: 'light' })
103
+ )
104
+
105
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
106
+
107
+ uploadImage
108
+ .mockResolvedValueOnce({ filename: 'snapshot-test-widget--light.webp' })
109
+ .mockResolvedValueOnce({ filename: 'snapshot-test-widget--dark.webp' })
110
+
111
+ await act(async () => {
112
+ const promise = result.current.requestCapture()
113
+
114
+ // Respond to capture 1 (current=light)
115
+ await new Promise(r => setTimeout(r, 10))
116
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,LIGHT' })
117
+
118
+ // Respond to set-theme (switch to dark)
119
+ await new Promise(r => setTimeout(r, 10))
120
+ dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 2 })
121
+
122
+ // Respond to capture 2 (alternate=dark)
123
+ await new Promise(r => setTimeout(r, 10))
124
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 3, dataUrl: 'data:image/webp;base64,DARK' })
125
+
126
+ // Respond to set-theme back (switch to light)
127
+ await new Promise(r => setTimeout(r, 10))
128
+ dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 4 })
129
+
130
+ await promise
131
+ })
132
+
133
+ // 2 captures + 2 theme switches = 4 postMessages
134
+ expect(cw.postMessage).toHaveBeenCalledTimes(4)
135
+ expect(uploadImage).toHaveBeenCalledTimes(2)
136
+ // Intermediate onUpdate for current theme + final with both
137
+ expect(onUpdate).toHaveBeenCalledWith(
138
+ expect.objectContaining({
139
+ snapshotLight: expect.stringContaining('snapshot-test-widget--light.webp'),
140
+ })
141
+ )
142
+ expect(onUpdate).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ snapshotLight: expect.stringContaining('snapshot-test-widget--light.webp'),
145
+ snapshotDark: expect.stringContaining('snapshot-test-widget--dark.webp'),
146
+ })
147
+ )
148
+ })
149
+
150
+ it('discards stale capture results via generation token', async () => {
151
+ const cw = createMockContentWindow()
152
+ const style = { visibility: "" }
153
+ const iframeRef = { current: { contentWindow: cw, style } }
154
+ const onUpdate = vi.fn()
155
+ const { result } = renderHook(() =>
156
+ useSnapshotCapture({ iframeRef, widgetId: 'gen-widget', onUpdate, canvasTheme: 'light' })
157
+ )
158
+
159
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
160
+
161
+ uploadImage.mockResolvedValue({ filename: 'snapshot-gen-widget--light.webp' })
162
+
163
+ // Start first capture — will be waiting for response
164
+ let capture1Done = false
165
+ let capture2Done = false
166
+ await act(async () => {
167
+ const promise1 = result.current.requestCapture().then(() => { capture1Done = true })
168
+
169
+ // Respond to first capture
170
+ await new Promise(r => setTimeout(r, 10))
171
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FIRST' })
172
+
173
+ // Start second capture before first completes its alternate-theme work
174
+ // This won't start because capturingRef is still true, so it will return {}
175
+ const promise2 = result.current.requestCapture().then(() => { capture2Done = true })
176
+
177
+ // Complete first capture's theme switch and alternate capture
178
+ await new Promise(r => setTimeout(r, 10))
179
+ dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 2 })
180
+ await new Promise(r => setTimeout(r, 10))
181
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 3, dataUrl: 'data:image/webp;base64,DARK' })
182
+ await new Promise(r => setTimeout(r, 10))
183
+ dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 4 })
184
+
185
+ await promise1
186
+ await promise2
187
+ })
188
+
189
+ // The second capture should have no-opped (capturingRef guard)
190
+ expect(capture1Done).toBe(true)
191
+ expect(capture2Done).toBe(true)
192
+ })
193
+
194
+ it('restores iframe visibility on error', async () => {
195
+ const cw = createMockContentWindow()
196
+ const style = { visibility: "visible" }
197
+ const iframeRef = { current: { contentWindow: cw, style } }
198
+ const onUpdate = vi.fn()
199
+ const { result } = renderHook(() =>
200
+ useSnapshotCapture({ iframeRef, widgetId: 'err-widget', onUpdate, canvasTheme: 'light' })
201
+ )
202
+
203
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
204
+
205
+ // Make uploadImage throw to trigger error path
206
+ uploadImage.mockRejectedValueOnce(new Error('upload failed'))
207
+
208
+ await act(async () => {
209
+ const promise = result.current.requestCapture()
210
+
211
+ // Respond to capture
212
+ await new Promise(r => setTimeout(r, 10))
213
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FAIL' })
214
+
215
+ await promise
216
+ })
217
+
218
+ // Visibility should be restored after error
219
+ expect(style.visibility).toBe('')
220
+ // onUpdate should not be called with failed data
221
+ expect(onUpdate).not.toHaveBeenCalledWith(
222
+ expect.objectContaining({ snapshotLight: expect.any(String) })
223
+ )
224
+ })
225
+ })
package/src/context.jsx CHANGED
@@ -119,10 +119,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
119
119
  const params = useParams()
120
120
 
121
121
  // Canvas route detection — matches current URL against registered canvas routes
122
- const canvasName = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
122
+ const canvasId = useMemo(() => matchCanvasRoute(location.pathname), [location.pathname])
123
123
  const isMissingCanvasRoute = useMemo(
124
- () => isCanvasPath(location.pathname) && !canvasName && !matchStoryRoute(location.pathname),
125
- [location.pathname, canvasName],
124
+ () => isCanvasPath(location.pathname) && !canvasId && !matchStoryRoute(location.pathname),
125
+ [location.pathname, canvasId],
126
126
  )
127
127
 
128
128
  // Story route detection — matches current URL against registered story routes
@@ -139,7 +139,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
139
139
 
140
140
  // Resolve flow name with prototype scoping (skip for canvas/story pages)
141
141
  const activeFlowName = useMemo(() => {
142
- if (canvasName || isMissingCanvasRoute || storyName || isMissingStoryRoute) return null
142
+ if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return null
143
143
  const requested = sceneParam || flowName || sceneName
144
144
  if (requested) {
145
145
  // Allow fully-scoped flow names from URLs/widgets without re-prefixing
@@ -163,7 +163,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
163
163
  // 4. Global default — or null if no flow exists at all
164
164
  if (flowExists('default')) return 'default'
165
165
  return null
166
- }, [canvasName, isMissingCanvasRoute, storyName, isMissingStoryRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
166
+ }, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, sceneParam, flowName, sceneName, prototypeName, pageFlow])
167
167
 
168
168
  // Auto-install body class sync (sb-key--value classes on <body>)
169
169
  useEffect(() => installBodyClassSync(), [])
@@ -175,10 +175,10 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
175
175
  const branchSuffix = branchMatch ? ` (${branchMatch[1]})` : ''
176
176
 
177
177
  let title
178
- if (canvasName) {
179
- const canvasData = canvases?.[canvasName]
178
+ if (canvasId) {
179
+ const canvasData = canvases?.[canvasId]
180
180
  const meta = canvasData?._canvasMeta
181
- const pageTitle = canvasData?.title || canvasName.split('/').pop()
181
+ const pageTitle = canvasData?.title || canvasId.split('/').pop()
182
182
  title = (meta?.title || pageTitle) + ' · Storyboard'
183
183
  } else if (prototypeName) {
184
184
  title = prototypeName + ' · Storyboard'
@@ -187,7 +187,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
187
187
  }
188
188
 
189
189
  document.title = title + branchSuffix
190
- }, [canvasName, prototypeName])
190
+ }, [canvasId, prototypeName])
191
191
 
192
192
  // Mount design modes UI when enabled in storyboard.config.json
193
193
  useEffect(() => {
@@ -207,7 +207,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
207
207
 
208
208
  // Skip flow loading for canvas/story pages and flow-less pages
209
209
  const { data, error } = useMemo(() => {
210
- if (canvasName || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null }
210
+ if (canvasId || isMissingCanvasRoute || storyName || isMissingStoryRoute) return { data: null, error: null }
211
211
  if (!activeFlowName) return { data: {}, error: null }
212
212
  try {
213
213
  let flowData = loadFlow(activeFlowName)
@@ -226,11 +226,11 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
226
226
  } catch (err) {
227
227
  return { data: null, error: err.message }
228
228
  }
229
- }, [canvasName, isMissingCanvasRoute, storyName, isMissingStoryRoute, activeFlowName, recordName, recordParam, params, prototypeName])
229
+ }, [canvasId, isMissingCanvasRoute, storyName, isMissingStoryRoute, activeFlowName, recordName, recordParam, params, prototypeName])
230
230
 
231
231
  // Canvas pages get their own rendering path — no flow data needed
232
- if (canvasName) {
233
- const canvasData = canvases?.[canvasName]
232
+ if (canvasId) {
233
+ const canvasData = canvases?.[canvasId]
234
234
  const group = canvasData?._group
235
235
  const siblingPages = group ? canvasGroupMap.get(group) || [] : []
236
236
  const canvasMeta = canvasData?._canvasMeta || null
@@ -245,7 +245,7 @@ export default function StoryboardProvider({ flowName, sceneName, recordName, re
245
245
  return (
246
246
  <StoryboardContext.Provider value={canvasValue}>
247
247
  <Suspense fallback={null}>
248
- <CanvasPageLazy name={canvasName} siblingPages={siblingPages} canvasMeta={canvasMeta} />
248
+ <CanvasPageLazy canvasId={canvasId} siblingPages={siblingPages} canvasMeta={canvasMeta} />
249
249
  </Suspense>
250
250
  </StoryboardContext.Provider>
251
251
  )
@@ -59,15 +59,15 @@ export default function StoryPage({ name }) {
59
59
  return () => { cancelled = true }
60
60
  }, [name, story])
61
61
 
62
- // Signal snapshot-ready after story renders in embed mode
62
+ // Signal snapshot-ready after story renders in embed mode.
63
+ // Stories have no data loading so they render fast — fire
64
+ // the explicit ready signal instead of waiting for the 1.5s fallback.
63
65
  useEffect(() => {
64
66
  if (!isEmbed || !exports || window.parent === window) return
65
- // Wait for fonts + paint to settle before signaling ready
66
- Promise.all([
67
- document.fonts?.ready || Promise.resolve(),
68
- new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))),
69
- ]).then(() => {
70
- window.parent.postMessage({ type: 'storyboard:embed:snapshot-ready' }, '*')
67
+ document.fonts.ready.then(() => {
68
+ requestAnimationFrame(() => requestAnimationFrame(() => {
69
+ window.__sbSnapshotReady?.()
70
+ }))
71
71
  })
72
72
  }, [isEmbed, exports])
73
73