@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
@@ -1,8 +1,8 @@
1
1
  /**
2
- * Tests for iframe snapshot display — layered dual-theme rendering.
2
+ * Tests for iframe snapshot display — single snapshot prop.
3
3
  */
4
- import { describe, it, expect, vi } from 'vitest'
5
- import { render, screen, fireEvent } from '@testing-library/react'
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
+ import { render, fireEvent, waitFor, act } from '@testing-library/react'
6
6
  import PrototypeEmbed from './PrototypeEmbed.jsx'
7
7
  import StoryWidget from './StoryWidget.jsx'
8
8
 
@@ -32,16 +32,32 @@ vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
32
32
  createInspectorHighlighter: async () => ({
33
33
  codeToHtml: () => '<pre><code></code></pre>',
34
34
  }),
35
- }))
35
+ }), { virtual: true })
36
36
 
37
37
  vi.mock('./ResizeHandle.jsx', () => ({
38
38
  default: () => <div data-testid="resize-handle" />,
39
39
  }))
40
40
 
41
+ /**
42
+ * Render inside a wrapper with data-sb-canvas-theme, matching the real
43
+ * CanvasPage DOM structure that subscribeCanvasTheme reads from.
44
+ */
45
+ function renderInCanvas(ui, theme = 'light') {
46
+ const wrapper = document.createElement('div')
47
+ wrapper.setAttribute('data-sb-canvas-theme', theme)
48
+ document.body.appendChild(wrapper)
49
+ const result = render(ui, { container: wrapper })
50
+ return { ...result, wrapper }
51
+ }
52
+
53
+ afterEach(() => {
54
+ document.querySelectorAll('[data-sb-canvas-theme]').forEach(el => el.remove())
55
+ })
56
+
41
57
  describe('Snapshot display', () => {
42
58
  describe('PrototypeEmbed', () => {
43
59
  it('shows snapshot image when valid snapshot prop exists', () => {
44
- const { container } = render(
60
+ const { wrapper } = renderInCanvas(
45
61
  <PrototypeEmbed
46
62
  id="proto-abc123"
47
63
  props={{
@@ -49,22 +65,21 @@ describe('Snapshot display', () => {
49
65
  width: 400,
50
66
  height: 300,
51
67
  zoom: 100,
52
- snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=123',
68
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
53
69
  }}
54
70
  onUpdate={vi.fn()}
55
71
  resizable={false}
56
72
  />
57
73
  )
58
74
 
59
- const img = container.querySelector('img')
75
+ const img = wrapper.querySelector('img')
60
76
  expect(img).toBeInTheDocument()
61
- expect(img.src).toContain('snapshot-proto-abc123--light.webp')
62
- expect(img.alt).toContain('snapshot')
63
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
77
+ expect(img.src).toContain('snapshot-proto-abc123.webp')
78
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
64
79
  })
65
80
 
66
- it('renders both themed snapshots with correct visibility', () => {
67
- const { container } = render(
81
+ it('falls back to snapshotLight for backward compat', () => {
82
+ const { wrapper } = renderInCanvas(
68
83
  <PrototypeEmbed
69
84
  id="proto-abc123"
70
85
  props={{
@@ -73,24 +88,19 @@ describe('Snapshot display', () => {
73
88
  height: 300,
74
89
  zoom: 100,
75
90
  snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
76
- snapshotDark: '/_storyboard/canvas/images/snapshot-proto-abc123--dark.webp?v=1',
77
91
  }}
78
92
  onUpdate={vi.fn()}
79
93
  resizable={false}
80
94
  />
81
95
  )
82
96
 
83
- const imgs = container.querySelectorAll('img')
84
- expect(imgs).toHaveLength(2)
85
- // Default theme is light — light visible, dark hidden
86
- expect(imgs[0].src).toContain('--light.webp')
87
- expect(imgs[0].style.visibility).not.toBe('hidden')
88
- expect(imgs[1].src).toContain('--dark.webp')
89
- expect(imgs[1].style.visibility).toBe('hidden')
97
+ const img = wrapper.querySelector('img')
98
+ expect(img).toBeInTheDocument()
99
+ expect(img.src).toContain('snapshot-proto-abc123--light.webp')
90
100
  })
91
101
 
92
102
  it('shows placeholder when no snapshot exists', () => {
93
- const { container } = render(
103
+ const { wrapper } = renderInCanvas(
94
104
  <PrototypeEmbed
95
105
  id="proto-xyz"
96
106
  props={{ src: '/test', width: 400, height: 300, zoom: 100 }}
@@ -99,13 +109,12 @@ describe('Snapshot display', () => {
99
109
  />
100
110
  )
101
111
 
102
- expect(container.querySelector('img')).not.toBeInTheDocument()
103
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
104
- expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
112
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
113
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
105
114
  })
106
115
 
107
116
  it('falls back to placeholder when snapshot image fails to load', () => {
108
- const { container } = render(
117
+ const { wrapper } = renderInCanvas(
109
118
  <PrototypeEmbed
110
119
  id="proto-abc123"
111
120
  props={{
@@ -113,25 +122,21 @@ describe('Snapshot display', () => {
113
122
  width: 400,
114
123
  height: 300,
115
124
  zoom: 100,
116
- snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=123',
125
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
117
126
  }}
118
127
  onUpdate={vi.fn()}
119
128
  resizable={false}
120
129
  />
121
130
  )
122
131
 
123
- const img = container.querySelector('img')
132
+ const img = wrapper.querySelector('img')
124
133
  expect(img).toBeInTheDocument()
125
-
126
134
  fireEvent.error(img)
127
-
128
- // After error, img should be gone and placeholder shown
129
- expect(container.querySelector('img')).not.toBeInTheDocument()
130
- expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
135
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
131
136
  })
132
137
 
133
138
  it('ignores snapshot that does not match widget ID', () => {
134
- const { container } = render(
139
+ const { wrapper } = renderInCanvas(
135
140
  <PrototypeEmbed
136
141
  id="proto-abc123"
137
142
  props={{
@@ -139,20 +144,18 @@ describe('Snapshot display', () => {
139
144
  width: 400,
140
145
  height: 300,
141
146
  zoom: 100,
142
- snapshotLight: '/_storyboard/canvas/images/snapshot-other-widget--light.webp?v=123',
147
+ snapshot: '/_storyboard/canvas/images/snapshot-other-widget.webp?v=123',
143
148
  }}
144
149
  onUpdate={vi.fn()}
145
150
  resizable={false}
146
151
  />
147
152
  )
148
153
 
149
- // Should show placeholder, not the mismatched snapshot
150
- expect(container.querySelector('img')).not.toBeInTheDocument()
151
- expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
154
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
152
155
  })
153
156
 
154
157
  it('does not show snapshot for external URLs', () => {
155
- const { container } = render(
158
+ const { wrapper } = renderInCanvas(
156
159
  <PrototypeEmbed
157
160
  id="proto-ext"
158
161
  props={{
@@ -160,45 +163,20 @@ describe('Snapshot display', () => {
160
163
  width: 400,
161
164
  height: 300,
162
165
  zoom: 100,
163
- snapshotLight: '/_storyboard/canvas/images/snapshot-proto-ext--light.webp?v=123',
164
- }}
165
- onUpdate={vi.fn()}
166
- resizable={false}
167
- />
168
- )
169
-
170
- // External URLs should never show snapshots
171
- expect(container.querySelector('img')).not.toBeInTheDocument()
172
- })
173
-
174
- it('falls back to light snapshot when dark snapshot is missing', () => {
175
- const { container } = render(
176
- <PrototypeEmbed
177
- id="proto-abc123"
178
- props={{
179
- src: '/test',
180
- width: 400,
181
- height: 300,
182
- zoom: 100,
183
- snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
184
- // no snapshotDark — light snapshot should show regardless of theme
166
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-ext.webp?v=123',
185
167
  }}
186
168
  onUpdate={vi.fn()}
187
169
  resizable={false}
188
170
  />
189
171
  )
190
172
 
191
- const imgs = container.querySelectorAll('img')
192
- expect(imgs).toHaveLength(1)
193
- expect(imgs[0].src).toContain('--light.webp')
194
- // Should be visible (no visibility: hidden) since it's the only snapshot
195
- expect(imgs[0].style.visibility).not.toBe('hidden')
173
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
196
174
  })
197
175
  })
198
176
 
199
177
  describe('StoryWidget', () => {
200
178
  it('shows snapshot image when valid snapshot prop exists', () => {
201
- const { container } = render(
179
+ const { wrapper } = renderInCanvas(
202
180
  <StoryWidget
203
181
  id="story-abc123"
204
182
  props={{
@@ -206,26 +184,25 @@ describe('Snapshot display', () => {
206
184
  exportName: 'Primary',
207
185
  width: 400,
208
186
  height: 300,
209
- snapshotLight: '/_storyboard/canvas/images/snapshot-story-abc123--light.webp?v=456',
187
+ snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
210
188
  }}
211
189
  onUpdate={vi.fn()}
212
190
  resizable={false}
213
191
  />
214
192
  )
215
193
 
216
- const img = container.querySelector('img')
194
+ const img = wrapper.querySelector('img')
217
195
  expect(img).toBeInTheDocument()
218
- expect(img.src).toContain('snapshot-story-abc123--light.webp')
219
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
196
+ expect(img.src).toContain('snapshot-story-abc123.webp')
197
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
220
198
  })
221
199
 
222
- it('renders both themed snapshots with correct visibility', () => {
223
- const { container } = render(
200
+ it('falls back to snapshotDark for backward compat', () => {
201
+ const { wrapper } = renderInCanvas(
224
202
  <StoryWidget
225
203
  id="story-abc123"
226
204
  props={{
227
205
  storyId: 'button-patterns',
228
- snapshotLight: '/_storyboard/canvas/images/snapshot-story-abc123--light.webp?v=1',
229
206
  snapshotDark: '/_storyboard/canvas/images/snapshot-story-abc123--dark.webp?v=1',
230
207
  }}
231
208
  onUpdate={vi.fn()}
@@ -233,17 +210,13 @@ describe('Snapshot display', () => {
233
210
  />
234
211
  )
235
212
 
236
- const imgs = container.querySelectorAll('img')
237
- expect(imgs).toHaveLength(2)
238
- // Default theme is light — light visible, dark hidden
239
- expect(imgs[0].src).toContain('--light.webp')
240
- expect(imgs[0].style.visibility).not.toBe('hidden')
241
- expect(imgs[1].src).toContain('--dark.webp')
242
- expect(imgs[1].style.visibility).toBe('hidden')
213
+ const img = wrapper.querySelector('img')
214
+ expect(img).toBeInTheDocument()
215
+ expect(img.src).toContain('snapshot-story-abc123--dark.webp')
243
216
  })
244
217
 
245
218
  it('shows placeholder when no snapshot exists', () => {
246
- const { container } = render(
219
+ const { wrapper } = renderInCanvas(
247
220
  <StoryWidget
248
221
  id="story-xyz"
249
222
  props={{
@@ -257,12 +230,12 @@ describe('Snapshot display', () => {
257
230
  />
258
231
  )
259
232
 
260
- expect(container.querySelector('img')).not.toBeInTheDocument()
261
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
233
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
234
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
262
235
  })
263
236
 
264
237
  it('falls back to placeholder when snapshot image fails to load', () => {
265
- const { container } = render(
238
+ const { wrapper } = renderInCanvas(
266
239
  <StoryWidget
267
240
  id="story-abc123"
268
241
  props={{
@@ -270,20 +243,17 @@ describe('Snapshot display', () => {
270
243
  exportName: 'Primary',
271
244
  width: 400,
272
245
  height: 300,
273
- snapshotLight: '/_storyboard/canvas/images/snapshot-story-abc123--light.webp?v=456',
246
+ snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
274
247
  }}
275
248
  onUpdate={vi.fn()}
276
249
  resizable={false}
277
250
  />
278
251
  )
279
252
 
280
- const img = container.querySelector('img')
253
+ const img = wrapper.querySelector('img')
281
254
  expect(img).toBeInTheDocument()
282
-
283
255
  fireEvent.error(img)
284
-
285
- // After error, img should be gone and placeholder shown
286
- expect(container.querySelector('img')).not.toBeInTheDocument()
256
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
287
257
  })
288
258
  })
289
259
  })
@@ -2,24 +2,16 @@
2
2
  * useSnapshotCapture — parent-side capture orchestration hook.
3
3
  *
4
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.
5
+ * provides a requestCapture() function that triggers a single capture
6
+ * of whatever the iframe is currently showing.
15
7
  *
8
+ * Saves a single `snapshot` prop — overwritten every time.
16
9
  * Only active in dev mode (when onUpdate is provided).
17
10
  */
18
11
  import { useState, useEffect, useCallback, useRef } from 'react'
19
12
  import { uploadImage } from '../canvasApi.js'
20
13
 
21
14
  const CAPTURE_TIMEOUT = 3000
22
- const THEME_SWITCH_TIMEOUT = 2000
23
15
 
24
16
  /**
25
17
  * Run a single capture request against the iframe.
@@ -57,64 +49,20 @@ function captureOnce(iframeContentWindow, requestId, listeners) {
57
49
  })
58
50
  }
59
51
 
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
52
  export function useSnapshotCapture({
105
53
  iframeRef,
106
54
  widgetId,
107
55
  onUpdate,
108
- canvasTheme,
56
+ showIframe,
109
57
  }) {
110
58
  const [iframeReady, setIframeReady] = useState(false)
111
59
  const iframeReadyRef = useRef(false)
112
60
  const capturingRef = useRef(false)
113
61
  const requestIdCounter = useRef(0)
114
- // Generation token — incremented on each capture request to detect stale results
115
62
  const captureGeneration = useRef(0)
116
- // Handlers for both snapshot and theme-applied responses
117
63
  const responseHandlers = useRef([])
64
+ // Track the iframe contentWindow to reset readiness on remount
65
+ const lastContentWindowRef = useRef(null)
118
66
 
119
67
  // Reset ready state when iframe is unmounted/remounted
120
68
  useEffect(() => {
@@ -122,6 +70,15 @@ export function useSnapshotCapture({
122
70
  iframeReadyRef.current = false
123
71
  }, [widgetId])
124
72
 
73
+ // Reset readiness when iframe is torn down so remount waits for new snapshot-ready
74
+ useEffect(() => {
75
+ if (!showIframe) {
76
+ setIframeReady(false)
77
+ iframeReadyRef.current = false
78
+ lastContentWindowRef.current = null
79
+ }
80
+ }, [showIframe])
81
+
125
82
  // Listen for postMessage events from the embedded iframe
126
83
  useEffect(() => {
127
84
  if (!onUpdate) return
@@ -130,13 +87,19 @@ export function useSnapshotCapture({
130
87
  if (!iframeRef.current) return
131
88
  if (e.source !== iframeRef.current.contentWindow) return
132
89
 
90
+ // Detect new iframe instance → reset readiness
91
+ if (e.source !== lastContentWindowRef.current) {
92
+ lastContentWindowRef.current = e.source
93
+ setIframeReady(false)
94
+ iframeReadyRef.current = false
95
+ }
96
+
133
97
  if (e.data?.type === 'storyboard:embed:snapshot-ready') {
134
98
  setIframeReady(true)
135
99
  iframeReadyRef.current = true
136
100
  }
137
101
 
138
- if (e.data?.type === 'storyboard:embed:snapshot' ||
139
- e.data?.type === 'storyboard:embed:theme-applied') {
102
+ if (e.data?.type === 'storyboard:embed:snapshot') {
140
103
  for (const fn of responseHandlers.current) {
141
104
  fn(e.data)
142
105
  }
@@ -148,15 +111,8 @@ export function useSnapshotCapture({
148
111
  }, [iframeRef, onUpdate])
149
112
 
150
113
  /**
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
114
+ * Capture a single snapshot of the current iframe state.
115
+ * Uploads and saves as `snapshot` prop, overwriting any previous value.
160
116
  */
161
117
  const requestCapture = useCallback(async ({ force = false } = {}) => {
162
118
  if (!onUpdate) return {}
@@ -167,92 +123,35 @@ export function useSnapshotCapture({
167
123
  capturingRef.current = true
168
124
  const gen = ++captureGeneration.current
169
125
  const cw = iframeRef.current.contentWindow
170
- const iframe = iframeRef.current
171
126
  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
127
 
177
128
  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)
129
+ const reqId = ++requestIdCounter.current
130
+ const dataUrl = await captureOnce(cw, reqId, responseHandlers.current)
183
131
 
184
- // Bail if a newer capture started while we were waiting
185
132
  if (gen !== captureGeneration.current) return {}
133
+ if (!dataUrl) return {}
186
134
 
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)
135
+ const filename = `snapshot-${widgetId}.webp`
136
+ const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
208
137
 
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 || ''
138
+ if (gen !== captureGeneration.current) return {}
242
139
 
243
- if (gen === captureGeneration.current && Object.keys(updates).length > 0) {
140
+ if (result?.filename) {
141
+ const cacheBust = `?v=${Date.now()}`
142
+ const url = `${base}/_storyboard/canvas/images/${result.filename}${cacheBust}`
143
+ const updates = { snapshot: url }
244
144
  onUpdate?.(updates)
145
+ return updates
245
146
  }
246
- return updates
147
+ return {}
247
148
  } catch (err) {
248
- // Always restore visibility on error
249
- if (iframe) iframe.style.visibility = ''
250
149
  console.warn('[snapshot] Capture failed:', err)
251
150
  return {}
252
151
  } finally {
253
152
  capturingRef.current = false
254
153
  }
255
- }, [onUpdate, iframeRef, widgetId, canvasTheme])
154
+ }, [onUpdate, iframeRef, widgetId])
256
155
 
257
156
  return { iframeReady, requestCapture }
258
157
  }