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

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
@@ -0,0 +1,259 @@
1
+ /**
2
+ * Tests for iframe snapshot display — single snapshot prop.
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
+ import { render, fireEvent, waitFor, act } from '@testing-library/react'
6
+ import PrototypeEmbed from './PrototypeEmbed.jsx'
7
+ import StoryWidget from './StoryWidget.jsx'
8
+
9
+ vi.mock('@dfosco/storyboard-core', () => ({
10
+ buildPrototypeIndex: () => ({
11
+ folders: [],
12
+ prototypes: [
13
+ {
14
+ name: 'Design Overview',
15
+ dirName: 'examples',
16
+ isExternal: false,
17
+ hideFlows: true,
18
+ flows: [{ route: '/test', name: 'default', meta: { title: 'Design Overview' } }],
19
+ },
20
+ ],
21
+ globalFlows: [],
22
+ sorted: { title: { prototypes: [], folders: [] } },
23
+ }),
24
+ getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
25
+ }))
26
+
27
+ vi.mock('./WidgetWrapper.jsx', () => ({
28
+ default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
29
+ }))
30
+
31
+ vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
32
+ createInspectorHighlighter: async () => ({
33
+ codeToHtml: () => '<pre><code></code></pre>',
34
+ }),
35
+ }), { virtual: true })
36
+
37
+ vi.mock('./ResizeHandle.jsx', () => ({
38
+ default: () => <div data-testid="resize-handle" />,
39
+ }))
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
+
57
+ describe('Snapshot display', () => {
58
+ describe('PrototypeEmbed', () => {
59
+ it('shows snapshot image when valid snapshot prop exists', () => {
60
+ const { wrapper } = renderInCanvas(
61
+ <PrototypeEmbed
62
+ id="proto-abc123"
63
+ props={{
64
+ src: '/test',
65
+ width: 400,
66
+ height: 300,
67
+ zoom: 100,
68
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
69
+ }}
70
+ onUpdate={vi.fn()}
71
+ resizable={false}
72
+ />
73
+ )
74
+
75
+ const img = wrapper.querySelector('img')
76
+ expect(img).toBeInTheDocument()
77
+ expect(img.src).toContain('snapshot-proto-abc123.webp')
78
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
79
+ })
80
+
81
+ it('falls back to snapshotLight for backward compat', () => {
82
+ const { wrapper } = renderInCanvas(
83
+ <PrototypeEmbed
84
+ id="proto-abc123"
85
+ props={{
86
+ src: '/test',
87
+ width: 400,
88
+ height: 300,
89
+ zoom: 100,
90
+ snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
91
+ }}
92
+ onUpdate={vi.fn()}
93
+ resizable={false}
94
+ />
95
+ )
96
+
97
+ const img = wrapper.querySelector('img')
98
+ expect(img).toBeInTheDocument()
99
+ expect(img.src).toContain('snapshot-proto-abc123--light.webp')
100
+ })
101
+
102
+ it('shows placeholder when no snapshot exists', () => {
103
+ const { wrapper } = renderInCanvas(
104
+ <PrototypeEmbed
105
+ id="proto-xyz"
106
+ props={{ src: '/test', width: 400, height: 300, zoom: 100 }}
107
+ onUpdate={vi.fn()}
108
+ resizable={false}
109
+ />
110
+ )
111
+
112
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
113
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
114
+ })
115
+
116
+ it('falls back to placeholder when snapshot image fails to load', () => {
117
+ const { wrapper } = renderInCanvas(
118
+ <PrototypeEmbed
119
+ id="proto-abc123"
120
+ props={{
121
+ src: '/test',
122
+ width: 400,
123
+ height: 300,
124
+ zoom: 100,
125
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
126
+ }}
127
+ onUpdate={vi.fn()}
128
+ resizable={false}
129
+ />
130
+ )
131
+
132
+ const img = wrapper.querySelector('img')
133
+ expect(img).toBeInTheDocument()
134
+ fireEvent.error(img)
135
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
136
+ })
137
+
138
+ it('ignores snapshot that does not match widget ID', () => {
139
+ const { wrapper } = renderInCanvas(
140
+ <PrototypeEmbed
141
+ id="proto-abc123"
142
+ props={{
143
+ src: '/test',
144
+ width: 400,
145
+ height: 300,
146
+ zoom: 100,
147
+ snapshot: '/_storyboard/canvas/images/snapshot-other-widget.webp?v=123',
148
+ }}
149
+ onUpdate={vi.fn()}
150
+ resizable={false}
151
+ />
152
+ )
153
+
154
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
155
+ })
156
+
157
+ it('does not show snapshot for external URLs', () => {
158
+ const { wrapper } = renderInCanvas(
159
+ <PrototypeEmbed
160
+ id="proto-ext"
161
+ props={{
162
+ src: 'https://example.com',
163
+ width: 400,
164
+ height: 300,
165
+ zoom: 100,
166
+ snapshot: '/_storyboard/canvas/images/snapshot-proto-ext.webp?v=123',
167
+ }}
168
+ onUpdate={vi.fn()}
169
+ resizable={false}
170
+ />
171
+ )
172
+
173
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
174
+ })
175
+ })
176
+
177
+ describe('StoryWidget', () => {
178
+ it('shows snapshot image when valid snapshot prop exists', () => {
179
+ const { wrapper } = renderInCanvas(
180
+ <StoryWidget
181
+ id="story-abc123"
182
+ props={{
183
+ storyId: 'button-patterns',
184
+ exportName: 'Primary',
185
+ width: 400,
186
+ height: 300,
187
+ snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
188
+ }}
189
+ onUpdate={vi.fn()}
190
+ resizable={false}
191
+ />
192
+ )
193
+
194
+ const img = wrapper.querySelector('img')
195
+ expect(img).toBeInTheDocument()
196
+ expect(img.src).toContain('snapshot-story-abc123.webp')
197
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
198
+ })
199
+
200
+ it('falls back to snapshotDark for backward compat', () => {
201
+ const { wrapper } = renderInCanvas(
202
+ <StoryWidget
203
+ id="story-abc123"
204
+ props={{
205
+ storyId: 'button-patterns',
206
+ snapshotDark: '/_storyboard/canvas/images/snapshot-story-abc123--dark.webp?v=1',
207
+ }}
208
+ onUpdate={vi.fn()}
209
+ resizable={false}
210
+ />
211
+ )
212
+
213
+ const img = wrapper.querySelector('img')
214
+ expect(img).toBeInTheDocument()
215
+ expect(img.src).toContain('snapshot-story-abc123--dark.webp')
216
+ })
217
+
218
+ it('shows placeholder when no snapshot exists', () => {
219
+ const { wrapper } = renderInCanvas(
220
+ <StoryWidget
221
+ id="story-xyz"
222
+ props={{
223
+ storyId: 'button-patterns',
224
+ exportName: 'Primary',
225
+ width: 400,
226
+ height: 300,
227
+ }}
228
+ onUpdate={vi.fn()}
229
+ resizable={false}
230
+ />
231
+ )
232
+
233
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
234
+ expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
235
+ })
236
+
237
+ it('falls back to placeholder when snapshot image fails to load', () => {
238
+ const { wrapper } = renderInCanvas(
239
+ <StoryWidget
240
+ id="story-abc123"
241
+ props={{
242
+ storyId: 'button-patterns',
243
+ exportName: 'Primary',
244
+ width: 400,
245
+ height: 300,
246
+ snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
247
+ }}
248
+ onUpdate={vi.fn()}
249
+ resizable={false}
250
+ />
251
+ )
252
+
253
+ const img = wrapper.querySelector('img')
254
+ expect(img).toBeInTheDocument()
255
+ fireEvent.error(img)
256
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
257
+ })
258
+ })
259
+ })
@@ -0,0 +1,157 @@
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 single capture
6
+ * of whatever the iframe is currently showing.
7
+ *
8
+ * Saves a single `snapshot` prop — overwritten every time.
9
+ * Only active in dev mode (when onUpdate is provided).
10
+ */
11
+ import { useState, useEffect, useCallback, useRef } from 'react'
12
+ import { uploadImage } from '../canvasApi.js'
13
+
14
+ const CAPTURE_TIMEOUT = 3000
15
+
16
+ /**
17
+ * Run a single capture request against the iframe.
18
+ * Returns the dataUrl or null on failure.
19
+ */
20
+ function captureOnce(iframeContentWindow, requestId, listeners) {
21
+ return new Promise((resolve) => {
22
+ const timer = setTimeout(() => {
23
+ cleanup()
24
+ resolve(null)
25
+ }, CAPTURE_TIMEOUT)
26
+
27
+ function cleanup() {
28
+ clearTimeout(timer)
29
+ const idx = listeners.indexOf(handler)
30
+ if (idx !== -1) listeners.splice(idx, 1)
31
+ }
32
+
33
+ function handler(data) {
34
+ if (data.requestId !== requestId) return
35
+ cleanup()
36
+ if (data.error || !data.dataUrl) {
37
+ if (data.error) console.warn('[snapshot] Capture failed:', data.error)
38
+ resolve(null)
39
+ } else {
40
+ resolve(data.dataUrl)
41
+ }
42
+ }
43
+
44
+ listeners.push(handler)
45
+ iframeContentWindow.postMessage({
46
+ type: 'storyboard:embed:capture',
47
+ requestId,
48
+ }, '*')
49
+ })
50
+ }
51
+
52
+ export function useSnapshotCapture({
53
+ iframeRef,
54
+ widgetId,
55
+ onUpdate,
56
+ showIframe,
57
+ }) {
58
+ const [iframeReady, setIframeReady] = useState(false)
59
+ const iframeReadyRef = useRef(false)
60
+ const capturingRef = useRef(false)
61
+ const requestIdCounter = useRef(0)
62
+ const captureGeneration = useRef(0)
63
+ const responseHandlers = useRef([])
64
+ // Track the iframe contentWindow to reset readiness on remount
65
+ const lastContentWindowRef = useRef(null)
66
+
67
+ // Reset ready state when iframe is unmounted/remounted
68
+ useEffect(() => {
69
+ setIframeReady(false)
70
+ iframeReadyRef.current = false
71
+ }, [widgetId])
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
+
82
+ // Listen for postMessage events from the embedded iframe
83
+ useEffect(() => {
84
+ if (!onUpdate) return
85
+
86
+ function handler(e) {
87
+ if (!iframeRef.current) return
88
+ if (e.source !== iframeRef.current.contentWindow) return
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
+
97
+ if (e.data?.type === 'storyboard:embed:snapshot-ready') {
98
+ setIframeReady(true)
99
+ iframeReadyRef.current = true
100
+ }
101
+
102
+ if (e.data?.type === 'storyboard:embed:snapshot') {
103
+ for (const fn of responseHandlers.current) {
104
+ fn(e.data)
105
+ }
106
+ }
107
+ }
108
+
109
+ window.addEventListener('message', handler)
110
+ return () => window.removeEventListener('message', handler)
111
+ }, [iframeRef, onUpdate])
112
+
113
+ /**
114
+ * Capture a single snapshot of the current iframe state.
115
+ * Uploads and saves as `snapshot` prop, overwriting any previous value.
116
+ */
117
+ const requestCapture = useCallback(async ({ force = false } = {}) => {
118
+ if (!onUpdate) return {}
119
+ if (!iframeRef.current?.contentWindow) return {}
120
+ if (capturingRef.current) return {}
121
+ if (!force && !iframeReadyRef.current) return {}
122
+
123
+ capturingRef.current = true
124
+ const gen = ++captureGeneration.current
125
+ const cw = iframeRef.current.contentWindow
126
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
127
+
128
+ try {
129
+ const reqId = ++requestIdCounter.current
130
+ const dataUrl = await captureOnce(cw, reqId, responseHandlers.current)
131
+
132
+ if (gen !== captureGeneration.current) return {}
133
+ if (!dataUrl) return {}
134
+
135
+ const filename = `snapshot-${widgetId}.webp`
136
+ const result = await uploadImage(dataUrl, `snapshot-${widgetId}`, filename)
137
+
138
+ if (gen !== captureGeneration.current) return {}
139
+
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 }
144
+ onUpdate?.(updates)
145
+ return updates
146
+ }
147
+ return {}
148
+ } catch (err) {
149
+ console.warn('[snapshot] Capture failed:', err)
150
+ return {}
151
+ } finally {
152
+ capturingRef.current = false
153
+ }
154
+ }, [onUpdate, iframeRef, widgetId])
155
+
156
+ return { iframeReady, requestCapture }
157
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Tests for useSnapshotCapture hook — single-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.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
+ })
39
+
40
+ afterEach(() => { vi.restoreAllMocks() })
41
+
42
+ function dispatchMessage(source, data) {
43
+ const event = new MessageEvent('message', { source, data })
44
+ listeners.forEach(fn => fn(event))
45
+ }
46
+
47
+ it('returns iframeReady=false initially', () => {
48
+ const iframeRef = createMockIframeRef()
49
+ const { result } = renderHook(() =>
50
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
51
+ )
52
+ expect(result.current.iframeReady).toBe(false)
53
+ })
54
+
55
+ it('sets iframeReady=true on snapshot-ready message', () => {
56
+ const cw = createMockContentWindow()
57
+ const iframeRef = createMockIframeRef(cw)
58
+ const { result } = renderHook(() =>
59
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
60
+ )
61
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
62
+ expect(result.current.iframeReady).toBe(true)
63
+ })
64
+
65
+ it('no-ops when onUpdate is null', async () => {
66
+ const cw = createMockContentWindow()
67
+ const iframeRef = createMockIframeRef(cw)
68
+ const { result } = renderHook(() =>
69
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: null })
70
+ )
71
+ await act(async () => { await result.current.requestCapture() })
72
+ expect(cw.postMessage).not.toHaveBeenCalled()
73
+ })
74
+
75
+ it('no-ops when iframe not ready', async () => {
76
+ const cw = createMockContentWindow()
77
+ const iframeRef = createMockIframeRef(cw)
78
+ const { result } = renderHook(() =>
79
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
80
+ )
81
+ await act(async () => { await result.current.requestCapture() })
82
+ expect(cw.postMessage).not.toHaveBeenCalled()
83
+ })
84
+
85
+ it('sends single capture and calls onUpdate with snapshot', async () => {
86
+ const cw = createMockContentWindow()
87
+ const iframeRef = createMockIframeRef(cw)
88
+ const onUpdate = vi.fn()
89
+ const { result } = renderHook(() =>
90
+ useSnapshotCapture({ iframeRef, widgetId: 'test-widget', onUpdate })
91
+ )
92
+
93
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
94
+
95
+ uploadImage.mockResolvedValueOnce({ filename: 'snapshot-test-widget.webp' })
96
+
97
+ await act(async () => {
98
+ const promise = result.current.requestCapture()
99
+
100
+ await new Promise(r => setTimeout(r, 10))
101
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,IMG' })
102
+
103
+ await promise
104
+ })
105
+
106
+ // Single capture, single postMessage
107
+ expect(cw.postMessage).toHaveBeenCalledTimes(1)
108
+ expect(uploadImage).toHaveBeenCalledTimes(1)
109
+ expect(onUpdate).toHaveBeenCalledWith(
110
+ expect.objectContaining({
111
+ snapshot: expect.stringContaining('snapshot-test-widget.webp'),
112
+ })
113
+ )
114
+ })
115
+
116
+ it('guards against concurrent captures', async () => {
117
+ const cw = createMockContentWindow()
118
+ const iframeRef = createMockIframeRef(cw)
119
+ const onUpdate = vi.fn()
120
+ const { result } = renderHook(() =>
121
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate })
122
+ )
123
+
124
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
125
+ uploadImage.mockResolvedValue({ filename: 'snapshot-w1.webp' })
126
+
127
+ await act(async () => {
128
+ const p1 = result.current.requestCapture()
129
+ // Second call while first is in-flight should no-op
130
+ const p2 = result.current.requestCapture()
131
+
132
+ await new Promise(r => setTimeout(r, 10))
133
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,IMG' })
134
+
135
+ await p1
136
+ await p2
137
+ })
138
+
139
+ expect(cw.postMessage).toHaveBeenCalledTimes(1)
140
+ })
141
+
142
+ it('handles capture failure gracefully', async () => {
143
+ const cw = createMockContentWindow()
144
+ const iframeRef = createMockIframeRef(cw)
145
+ const onUpdate = vi.fn()
146
+ const { result } = renderHook(() =>
147
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate })
148
+ )
149
+
150
+ act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
151
+ uploadImage.mockRejectedValueOnce(new Error('upload failed'))
152
+
153
+ await act(async () => {
154
+ const promise = result.current.requestCapture()
155
+
156
+ await new Promise(r => setTimeout(r, 10))
157
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FAIL' })
158
+
159
+ await promise
160
+ })
161
+
162
+ expect(onUpdate).not.toHaveBeenCalled()
163
+ })
164
+ })
@@ -34,7 +34,16 @@ function resolveFeature(feature) {
34
34
  if (key === 'items' && Array.isArray(val)) {
35
35
  resolved[key] = val.map((item) => {
36
36
  const r = {}
37
- for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
37
+ for (const [k, v] of Object.entries(item)) {
38
+ // Resolve nested alt object inside items
39
+ if (k === 'alt' && v && typeof v === 'object') {
40
+ const altResolved = {}
41
+ for (const [ak, av] of Object.entries(v)) altResolved[ak] = resolveVar(av)
42
+ r[k] = altResolved
43
+ } else {
44
+ r[k] = resolveVar(v)
45
+ }
46
+ }
38
47
  return r
39
48
  })
40
49
  } else if (key === 'alt' && val && typeof val === 'object') {
@@ -103,14 +112,16 @@ export const widgetTypes = buildWidgetTypes()
103
112
 
104
113
  /**
105
114
  * Get the feature list for a widget type.
106
- * In production, only features with `prod: true` are returned.
115
+ * In production (or when isLocalDev is false, e.g. ?prodMode simulation),
116
+ * only features with `prod: true` are returned.
107
117
  * In dev, all features are returned.
108
118
  * @param {string} type — widget type string
119
+ * @param {{ isLocalDev?: boolean }} [options]
109
120
  * @returns {Array} features array from config (variables resolved), or empty array
110
121
  */
111
- export function getFeatures(type) {
122
+ export function getFeatures(type, { isLocalDev = true } = {}) {
112
123
  const features = widgetTypes[type]?.features ?? []
113
- if (import.meta.env?.PROD) {
124
+ if (import.meta.env?.PROD || !isLocalDev) {
114
125
  return features.filter(f => f.prod)
115
126
  }
116
127
  return features
@@ -146,6 +157,6 @@ export function getWidgetMeta(type) {
146
157
  */
147
158
  export function getMenuWidgetTypes() {
148
159
  return Object.entries(widgetTypes)
149
- .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed')
160
+ .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
150
161
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
151
162
  }
@@ -2,21 +2,24 @@ import { describe, expect, it } from 'vitest'
2
2
  import { isResizable, getFeatures, getWidgetMeta } from './widgetConfig.js'
3
3
 
4
4
  describe('isResizable', () => {
5
- // Vitest runs with import.meta.env.PROD = true, so prod: false widgets
6
- // correctly return false. This tests the production behavior.
7
- it('returns false for resize-enabled widgets when prod is false (production env)', () => {
8
- expect(isResizable('sticky-note')).toBe(false)
9
- expect(isResizable('prototype')).toBe(false)
10
- expect(isResizable('figma-embed')).toBe(false)
11
- expect(isResizable('image')).toBe(false)
12
- expect(isResizable('component')).toBe(false)
13
- })
14
-
15
- it('returns false for widget types with resize disabled', () => {
16
- expect(isResizable('markdown')).toBe(false)
5
+ // Vitest runs in dev mode by default (import.meta.env.PROD = false)
6
+ // In dev mode, all resize-enabled widgets are resizable
7
+ it('returns true for resize-enabled widgets in dev mode', () => {
8
+ expect(isResizable('sticky-note')).toBe(true)
9
+ expect(isResizable('prototype')).toBe(true)
10
+ expect(isResizable('figma-embed')).toBe(true)
11
+ expect(isResizable('image')).toBe(true)
12
+ expect(isResizable('component')).toBe(true)
13
+ })
14
+
15
+ it('returns false for link-preview (resize disabled)', () => {
17
16
  expect(isResizable('link-preview')).toBe(false)
18
17
  })
19
18
 
19
+ it('returns true for markdown (resize enabled, dev only)', () => {
20
+ expect(isResizable('markdown')).toBe(true)
21
+ })
22
+
20
23
  it('returns false for unknown widget types', () => {
21
24
  expect(isResizable('nonexistent')).toBe(false)
22
25
  })
@@ -32,6 +35,25 @@ describe('getFeatures', () => {
32
35
  it('returns empty array for unknown widget types', () => {
33
36
  expect(getFeatures('nonexistent')).toEqual([])
34
37
  })
38
+
39
+ it('returns only prod features when isLocalDev is false', () => {
40
+ const features = getFeatures('figma-embed', { isLocalDev: false })
41
+ expect(features.length).toBeGreaterThan(0)
42
+ expect(features.every(f => f.prod === true)).toBe(true)
43
+ })
44
+
45
+ it('returns all features when isLocalDev is true (default)', () => {
46
+ const allFeatures = getFeatures('figma-embed')
47
+ const prodFeatures = getFeatures('figma-embed', { isLocalDev: false })
48
+ expect(allFeatures.length).toBeGreaterThan(prodFeatures.length)
49
+ })
50
+
51
+ it('includes menu-only prod features when isLocalDev is false', () => {
52
+ const features = getFeatures('figma-embed', { isLocalDev: false })
53
+ const menuFeature = features.find(f => f.menu)
54
+ expect(menuFeature).toBeDefined()
55
+ expect(menuFeature.prod).toBe(true)
56
+ })
35
57
  })
36
58
 
37
59
  describe('getWidgetMeta', () => {