@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.
@@ -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, beforeEach } from 'vitest'
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: () => ({ folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }),
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
+ })