@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.
- package/package.json +3 -3
- package/src/canvas/CanvasPage.bridge.test.jsx +8 -8
- package/src/canvas/CanvasPage.dragdrop.test.jsx +8 -8
- package/src/canvas/CanvasPage.jsx +170 -103
- package/src/canvas/CanvasPage.module.css +7 -0
- package/src/canvas/CanvasPage.multiselect.test.jsx +11 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/canvasApi.js +12 -10
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/widgets/ComponentWidget.jsx +21 -6
- package/src/canvas/widgets/ComponentWidget.module.css +5 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +44 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +210 -208
- package/src/canvas/widgets/PrototypeEmbed.module.css +61 -19
- package/src/canvas/widgets/StoryWidget.jsx +135 -171
- package/src/canvas/widgets/StoryWidget.module.css +38 -28
- package/src/canvas/widgets/WidgetChrome.jsx +3 -2
- package/src/canvas/widgets/embedInteraction.test.jsx +86 -4
- package/src/canvas/widgets/embedTheme.js +20 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +289 -0
- package/src/canvas/widgets/useSnapshotCapture.js +258 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +225 -0
- package/src/context.jsx +14 -14
- package/src/story/StoryPage.jsx +7 -7
- package/src/vite/data-plugin.js +25 -20
- package/src/vite/data-plugin.test.js +4 -4
- package/src/canvas/widgets/useViewportEntry.js +0 -93
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tests for embed interaction UX (click-to-interact overlay).
|
|
3
3
|
*/
|
|
4
|
-
import { describe, it, expect, vi
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
5
5
|
import { render, fireEvent, screen } from '@testing-library/react'
|
|
6
6
|
import PrototypeEmbed from './PrototypeEmbed.jsx'
|
|
7
7
|
import FigmaEmbed from './FigmaEmbed.jsx'
|
|
8
8
|
import ComponentWidget from './ComponentWidget.jsx'
|
|
9
|
+
import StoryWidget from './StoryWidget.jsx'
|
|
9
10
|
|
|
10
11
|
// Mock buildPrototypeIndex for PrototypeEmbed
|
|
11
12
|
vi.mock('@dfosco/storyboard-core', () => ({
|
|
12
|
-
buildPrototypeIndex: () => ({
|
|
13
|
+
buildPrototypeIndex: () => ({
|
|
14
|
+
folders: [],
|
|
15
|
+
prototypes: [
|
|
16
|
+
{
|
|
17
|
+
name: 'Design Overview',
|
|
18
|
+
dirName: 'examples',
|
|
19
|
+
isExternal: false,
|
|
20
|
+
hideFlows: true,
|
|
21
|
+
flows: [{ route: '/test', name: 'default', meta: { title: 'Design Overview' } }],
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
globalFlows: [],
|
|
25
|
+
sorted: { title: { prototypes: [], folders: [] } },
|
|
26
|
+
}),
|
|
27
|
+
getStoryData: (storyId) => ({ _route: `/components/${storyId}` }),
|
|
13
28
|
}))
|
|
14
29
|
|
|
15
30
|
// Simple mock wrapper for WidgetWrapper
|
|
@@ -17,6 +32,12 @@ vi.mock('./WidgetWrapper.jsx', () => ({
|
|
|
17
32
|
default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
|
|
18
33
|
}))
|
|
19
34
|
|
|
35
|
+
vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
|
|
36
|
+
createInspectorHighlighter: async () => ({
|
|
37
|
+
codeToHtml: () => '<pre><code></code></pre>',
|
|
38
|
+
}),
|
|
39
|
+
}))
|
|
40
|
+
|
|
20
41
|
// Mock ResizeHandle
|
|
21
42
|
vi.mock('./ResizeHandle.jsx', () => ({
|
|
22
43
|
default: () => <div data-testid="resize-handle" />,
|
|
@@ -44,17 +65,26 @@ describe('Embed interaction overlay', () => {
|
|
|
44
65
|
})
|
|
45
66
|
|
|
46
67
|
it('enters interactive mode on single click (not double-click)', async () => {
|
|
47
|
-
render(<PrototypeEmbed {...defaultProps} />)
|
|
68
|
+
const { container } = render(<PrototypeEmbed {...defaultProps} />)
|
|
48
69
|
|
|
49
70
|
// Overlay should exist before interaction
|
|
50
71
|
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
51
72
|
expect(overlay).toBeInTheDocument()
|
|
73
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
74
|
+
expect(screen.getByText('Design Overview')).toBeInTheDocument()
|
|
75
|
+
expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
|
|
52
76
|
|
|
53
77
|
// Single click should remove the overlay (enter interactive mode)
|
|
54
78
|
fireEvent.click(overlay)
|
|
55
79
|
|
|
56
80
|
// Overlay should no longer exist
|
|
57
81
|
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
82
|
+
expect(screen.queryByText('Design Overview prototype')).not.toBeInTheDocument()
|
|
83
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
84
|
+
|
|
85
|
+
fireEvent.pointerDown(document.body)
|
|
86
|
+
expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
|
|
87
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
58
88
|
})
|
|
59
89
|
|
|
60
90
|
it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
|
|
@@ -112,12 +142,42 @@ describe('Embed interaction overlay', () => {
|
|
|
112
142
|
})
|
|
113
143
|
|
|
114
144
|
it('enters interactive mode on single click', () => {
|
|
115
|
-
render(<FigmaEmbed {...defaultProps} />)
|
|
145
|
+
const { container } = render(<FigmaEmbed {...defaultProps} />)
|
|
116
146
|
|
|
117
147
|
const overlay = screen.getByRole('button', { name: /click to interact/i })
|
|
148
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
118
149
|
fireEvent.click(overlay)
|
|
119
150
|
|
|
120
151
|
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
152
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
153
|
+
|
|
154
|
+
fireEvent.pointerDown(document.body)
|
|
155
|
+
expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
|
|
156
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('StoryWidget', () => {
|
|
161
|
+
const defaultProps = {
|
|
162
|
+
props: { storyId: 'button-patterns', exportName: 'Primary', width: 400, height: 300 },
|
|
163
|
+
onUpdate: vi.fn(),
|
|
164
|
+
resizable: false,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
it('mounts iframe only after user activation', () => {
|
|
168
|
+
const { container } = render(<StoryWidget {...defaultProps} />)
|
|
169
|
+
|
|
170
|
+
const overlay = screen.getByRole('button', { name: /click to interact with story component/i })
|
|
171
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
172
|
+
|
|
173
|
+
fireEvent.click(overlay)
|
|
174
|
+
|
|
175
|
+
expect(screen.queryByRole('button', { name: /click to interact with story component/i })).not.toBeInTheDocument()
|
|
176
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
177
|
+
|
|
178
|
+
fireEvent.pointerDown(document.body)
|
|
179
|
+
expect(screen.getByRole('button', { name: /click to interact with story component/i })).toBeInTheDocument()
|
|
180
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
121
181
|
})
|
|
122
182
|
})
|
|
123
183
|
|
|
@@ -151,5 +211,27 @@ describe('Embed interaction overlay', () => {
|
|
|
151
211
|
|
|
152
212
|
expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
|
|
153
213
|
})
|
|
214
|
+
|
|
215
|
+
it('mounts dev iframe only after user activation', () => {
|
|
216
|
+
const { container } = render(
|
|
217
|
+
<ComponentWidget
|
|
218
|
+
{...defaultProps}
|
|
219
|
+
isLocalDev
|
|
220
|
+
jsxModule="/src/canvas/mock.canvas.jsx"
|
|
221
|
+
exportName="MockComponent"
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
const overlay = screen.getByRole('button', { name: /click to interact with component/i })
|
|
226
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
227
|
+
|
|
228
|
+
fireEvent.click(overlay)
|
|
229
|
+
|
|
230
|
+
expect(container.querySelector('iframe')).toBeInTheDocument()
|
|
231
|
+
|
|
232
|
+
fireEvent.pointerDown(document.body)
|
|
233
|
+
expect(screen.getByRole('button', { name: /click to interact with component/i })).toBeInTheDocument()
|
|
234
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
235
|
+
})
|
|
154
236
|
})
|
|
155
237
|
})
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the effective canvas theme from localStorage + sync settings.
|
|
3
|
+
* Respects the canvas-specific theme sync toggle.
|
|
4
|
+
*/
|
|
5
|
+
export function resolveCanvasTheme() {
|
|
6
|
+
if (typeof localStorage === 'undefined') return 'light'
|
|
7
|
+
let sync = { prototype: true, toolbar: false, codeBoxes: true, canvas: false }
|
|
8
|
+
try {
|
|
9
|
+
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
10
|
+
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
11
|
+
} catch { /* ignore */ }
|
|
12
|
+
if (!sync.canvas) return 'light'
|
|
13
|
+
const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
|
|
14
|
+
if (attrTheme) return attrTheme
|
|
15
|
+
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
16
|
+
if (stored !== 'system') return stored
|
|
17
|
+
return typeof window.matchMedia === 'function' &&
|
|
18
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
19
|
+
}
|
|
20
|
+
|
|
1
21
|
export function getEmbedChromeVars(theme) {
|
|
2
22
|
const value = String(theme || 'light')
|
|
3
23
|
if (value === 'dark_dimmed') {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
let loadedIframeCount = 0
|
|
4
|
+
|
|
5
|
+
function isDevRuntime() {
|
|
6
|
+
return typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function toText(value) {
|
|
10
|
+
return value ? String(value) : '(no src)'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function logIframeEvent(event, count, meta) {
|
|
14
|
+
console.log(`[storyboard][iframe] ${event} | count=${count} | ${meta.widget}`, {
|
|
15
|
+
event,
|
|
16
|
+
count,
|
|
17
|
+
widget: meta.widget,
|
|
18
|
+
src: toText(meta.src),
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Dev-only iframe load/unload logging with a live count of mounted iframes.
|
|
24
|
+
*/
|
|
25
|
+
export function useIframeDevLogs({ widget, loaded, src }) {
|
|
26
|
+
const metaRef = useRef({ widget, src })
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
metaRef.current = { widget, src }
|
|
30
|
+
}, [widget, src])
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!loaded) return
|
|
34
|
+
|
|
35
|
+
loadedIframeCount += 1
|
|
36
|
+
if (isDevRuntime()) {
|
|
37
|
+
const meta = metaRef.current
|
|
38
|
+
logIframeEvent('loaded', loadedIframeCount, meta)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return () => {
|
|
42
|
+
loadedIframeCount = Math.max(0, loadedIframeCount - 1)
|
|
43
|
+
if (isDevRuntime()) {
|
|
44
|
+
const meta = metaRef.current
|
|
45
|
+
logIframeEvent('unloaded', loadedIframeCount, meta)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}, [loaded])
|
|
49
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
4
|
+
|
|
5
|
+
function Probe({ widget = 'Probe', loaded = false, src = '/test' }) {
|
|
6
|
+
useIframeDevLogs({ widget, loaded, src })
|
|
7
|
+
return null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('useIframeDevLogs', () => {
|
|
11
|
+
let logSpy
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
window.__SB_LOCAL_DEV__ = true
|
|
15
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
logSpy.mockRestore()
|
|
20
|
+
delete window.__SB_LOCAL_DEV__
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('logs iframe load and unload with tally', () => {
|
|
24
|
+
const { rerender, unmount } = render(<Probe loaded={false} src="/alpha" />)
|
|
25
|
+
rerender(<Probe loaded src="/alpha" />)
|
|
26
|
+
rerender(<Probe loaded={false} src="/alpha" />)
|
|
27
|
+
|
|
28
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
29
|
+
1,
|
|
30
|
+
'[storyboard][iframe] loaded | count=1 | Probe',
|
|
31
|
+
{ event: 'loaded', count: 1, widget: 'Probe', src: '/alpha' },
|
|
32
|
+
)
|
|
33
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
34
|
+
2,
|
|
35
|
+
'[storyboard][iframe] unloaded | count=0 | Probe',
|
|
36
|
+
{ event: 'unloaded', count: 0, widget: 'Probe', src: '/alpha' },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
unmount()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('tracks tally across multiple loaded iframes', () => {
|
|
43
|
+
const first = render(<Probe widget="PrototypeEmbed" loaded src="/proto" />)
|
|
44
|
+
const second = render(<Probe widget="FigmaEmbed" loaded src="/figma" />)
|
|
45
|
+
|
|
46
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
47
|
+
1,
|
|
48
|
+
'[storyboard][iframe] loaded | count=1 | PrototypeEmbed',
|
|
49
|
+
{ event: 'loaded', count: 1, widget: 'PrototypeEmbed', src: '/proto' },
|
|
50
|
+
)
|
|
51
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
52
|
+
2,
|
|
53
|
+
'[storyboard][iframe] loaded | count=2 | FigmaEmbed',
|
|
54
|
+
{ event: 'loaded', count: 2, widget: 'FigmaEmbed', src: '/figma' },
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
first.unmount()
|
|
58
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
59
|
+
3,
|
|
60
|
+
'[storyboard][iframe] unloaded | count=1 | PrototypeEmbed',
|
|
61
|
+
{ event: 'unloaded', count: 1, widget: 'PrototypeEmbed', src: '/proto' },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
second.unmount()
|
|
65
|
+
expect(logSpy).toHaveBeenNthCalledWith(
|
|
66
|
+
4,
|
|
67
|
+
'[storyboard][iframe] unloaded | count=0 | FigmaEmbed',
|
|
68
|
+
{ event: 'unloaded', count: 0, widget: 'FigmaEmbed', src: '/figma' },
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not log outside local dev runtime', () => {
|
|
73
|
+
window.__SB_LOCAL_DEV__ = false
|
|
74
|
+
const { rerender, unmount } = render(<Probe loaded={false} src="/off" />)
|
|
75
|
+
rerender(<Probe loaded src="/off" />)
|
|
76
|
+
rerender(<Probe loaded={false} src="/off" />)
|
|
77
|
+
|
|
78
|
+
expect(logSpy).not.toHaveBeenCalled()
|
|
79
|
+
unmount()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for iframe snapshot display — layered dual-theme rendering.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
5
|
+
import { render, screen, fireEvent } 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
|
+
}))
|
|
36
|
+
|
|
37
|
+
vi.mock('./ResizeHandle.jsx', () => ({
|
|
38
|
+
default: () => <div data-testid="resize-handle" />,
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
describe('Snapshot display', () => {
|
|
42
|
+
describe('PrototypeEmbed', () => {
|
|
43
|
+
it('shows snapshot image when valid snapshot prop exists', () => {
|
|
44
|
+
const { container } = render(
|
|
45
|
+
<PrototypeEmbed
|
|
46
|
+
id="proto-abc123"
|
|
47
|
+
props={{
|
|
48
|
+
src: '/test',
|
|
49
|
+
width: 400,
|
|
50
|
+
height: 300,
|
|
51
|
+
zoom: 100,
|
|
52
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=123',
|
|
53
|
+
}}
|
|
54
|
+
onUpdate={vi.fn()}
|
|
55
|
+
resizable={false}
|
|
56
|
+
/>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const img = container.querySelector('img')
|
|
60
|
+
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()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('renders both themed snapshots with correct visibility', () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<PrototypeEmbed
|
|
69
|
+
id="proto-abc123"
|
|
70
|
+
props={{
|
|
71
|
+
src: '/test',
|
|
72
|
+
width: 400,
|
|
73
|
+
height: 300,
|
|
74
|
+
zoom: 100,
|
|
75
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=1',
|
|
76
|
+
snapshotDark: '/_storyboard/canvas/images/snapshot-proto-abc123--dark.webp?v=1',
|
|
77
|
+
}}
|
|
78
|
+
onUpdate={vi.fn()}
|
|
79
|
+
resizable={false}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
|
|
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')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('shows placeholder when no snapshot exists', () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<PrototypeEmbed
|
|
95
|
+
id="proto-xyz"
|
|
96
|
+
props={{ src: '/test', width: 400, height: 300, zoom: 100 }}
|
|
97
|
+
onUpdate={vi.fn()}
|
|
98
|
+
resizable={false}
|
|
99
|
+
/>
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(container.querySelector('img')).not.toBeInTheDocument()
|
|
103
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
104
|
+
expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
108
|
+
const { container } = render(
|
|
109
|
+
<PrototypeEmbed
|
|
110
|
+
id="proto-abc123"
|
|
111
|
+
props={{
|
|
112
|
+
src: '/test',
|
|
113
|
+
width: 400,
|
|
114
|
+
height: 300,
|
|
115
|
+
zoom: 100,
|
|
116
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-proto-abc123--light.webp?v=123',
|
|
117
|
+
}}
|
|
118
|
+
onUpdate={vi.fn()}
|
|
119
|
+
resizable={false}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const img = container.querySelector('img')
|
|
124
|
+
expect(img).toBeInTheDocument()
|
|
125
|
+
|
|
126
|
+
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()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('ignores snapshot that does not match widget ID', () => {
|
|
134
|
+
const { container } = render(
|
|
135
|
+
<PrototypeEmbed
|
|
136
|
+
id="proto-abc123"
|
|
137
|
+
props={{
|
|
138
|
+
src: '/test',
|
|
139
|
+
width: 400,
|
|
140
|
+
height: 300,
|
|
141
|
+
zoom: 100,
|
|
142
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-other-widget--light.webp?v=123',
|
|
143
|
+
}}
|
|
144
|
+
onUpdate={vi.fn()}
|
|
145
|
+
resizable={false}
|
|
146
|
+
/>
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
// Should show placeholder, not the mismatched snapshot
|
|
150
|
+
expect(container.querySelector('img')).not.toBeInTheDocument()
|
|
151
|
+
expect(screen.getByText('Design Overview prototype')).toBeInTheDocument()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('does not show snapshot for external URLs', () => {
|
|
155
|
+
const { container } = render(
|
|
156
|
+
<PrototypeEmbed
|
|
157
|
+
id="proto-ext"
|
|
158
|
+
props={{
|
|
159
|
+
src: 'https://example.com',
|
|
160
|
+
width: 400,
|
|
161
|
+
height: 300,
|
|
162
|
+
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
|
|
185
|
+
}}
|
|
186
|
+
onUpdate={vi.fn()}
|
|
187
|
+
resizable={false}
|
|
188
|
+
/>
|
|
189
|
+
)
|
|
190
|
+
|
|
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')
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('StoryWidget', () => {
|
|
200
|
+
it('shows snapshot image when valid snapshot prop exists', () => {
|
|
201
|
+
const { container } = render(
|
|
202
|
+
<StoryWidget
|
|
203
|
+
id="story-abc123"
|
|
204
|
+
props={{
|
|
205
|
+
storyId: 'button-patterns',
|
|
206
|
+
exportName: 'Primary',
|
|
207
|
+
width: 400,
|
|
208
|
+
height: 300,
|
|
209
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-story-abc123--light.webp?v=456',
|
|
210
|
+
}}
|
|
211
|
+
onUpdate={vi.fn()}
|
|
212
|
+
resizable={false}
|
|
213
|
+
/>
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
const img = container.querySelector('img')
|
|
217
|
+
expect(img).toBeInTheDocument()
|
|
218
|
+
expect(img.src).toContain('snapshot-story-abc123--light.webp')
|
|
219
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('renders both themed snapshots with correct visibility', () => {
|
|
223
|
+
const { container } = render(
|
|
224
|
+
<StoryWidget
|
|
225
|
+
id="story-abc123"
|
|
226
|
+
props={{
|
|
227
|
+
storyId: 'button-patterns',
|
|
228
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-story-abc123--light.webp?v=1',
|
|
229
|
+
snapshotDark: '/_storyboard/canvas/images/snapshot-story-abc123--dark.webp?v=1',
|
|
230
|
+
}}
|
|
231
|
+
onUpdate={vi.fn()}
|
|
232
|
+
resizable={false}
|
|
233
|
+
/>
|
|
234
|
+
)
|
|
235
|
+
|
|
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')
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('shows placeholder when no snapshot exists', () => {
|
|
246
|
+
const { container } = render(
|
|
247
|
+
<StoryWidget
|
|
248
|
+
id="story-xyz"
|
|
249
|
+
props={{
|
|
250
|
+
storyId: 'button-patterns',
|
|
251
|
+
exportName: 'Primary',
|
|
252
|
+
width: 400,
|
|
253
|
+
height: 300,
|
|
254
|
+
}}
|
|
255
|
+
onUpdate={vi.fn()}
|
|
256
|
+
resizable={false}
|
|
257
|
+
/>
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
expect(container.querySelector('img')).not.toBeInTheDocument()
|
|
261
|
+
expect(container.querySelector('iframe')).not.toBeInTheDocument()
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('falls back to placeholder when snapshot image fails to load', () => {
|
|
265
|
+
const { container } = render(
|
|
266
|
+
<StoryWidget
|
|
267
|
+
id="story-abc123"
|
|
268
|
+
props={{
|
|
269
|
+
storyId: 'button-patterns',
|
|
270
|
+
exportName: 'Primary',
|
|
271
|
+
width: 400,
|
|
272
|
+
height: 300,
|
|
273
|
+
snapshotLight: '/_storyboard/canvas/images/snapshot-story-abc123--light.webp?v=456',
|
|
274
|
+
}}
|
|
275
|
+
onUpdate={vi.fn()}
|
|
276
|
+
resizable={false}
|
|
277
|
+
/>
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const img = container.querySelector('img')
|
|
281
|
+
expect(img).toBeInTheDocument()
|
|
282
|
+
|
|
283
|
+
fireEvent.error(img)
|
|
284
|
+
|
|
285
|
+
// After error, img should be gone and placeholder shown
|
|
286
|
+
expect(container.querySelector('img')).not.toBeInTheDocument()
|
|
287
|
+
})
|
|
288
|
+
})
|
|
289
|
+
})
|