@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,12 +1,12 @@
1
1
  /**
2
- * Tests for useSnapshotCapture hook — parent-side capture orchestration.
2
+ * Tests for useSnapshotCapture hook — single-capture orchestration.
3
3
  */
4
4
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
5
  import { renderHook, act } from '@testing-library/react'
6
6
  import { useSnapshotCapture } from './useSnapshotCapture.js'
7
7
 
8
8
  vi.mock('../canvasApi.js', () => ({
9
- uploadImage: vi.fn().mockResolvedValue({ filename: 'snapshot-test-widget--light.webp' }),
9
+ uploadImage: vi.fn().mockResolvedValue({ filename: 'snapshot-test-widget.webp' }),
10
10
  }))
11
11
 
12
12
  import { uploadImage } from '../canvasApi.js'
@@ -35,15 +35,6 @@ describe('useSnapshotCapture', () => {
35
35
  if (type === 'message') listeners = listeners.filter(l => l !== fn)
36
36
  origRemove.call(window, type, fn, opts)
37
37
  })
38
- // Mock Image so preload resolves immediately in tests
39
- vi.stubGlobal('Image', class MockImage {
40
- constructor() { this._onload = null }
41
- set onload(fn) { this._onload = fn }
42
- get onload() { return this._onload }
43
- set onerror(fn) { this._onerror = fn }
44
- set src(v) { this._src = v; Promise.resolve().then(() => this._onload?.()) }
45
- get src() { return this._src }
46
- })
47
38
  })
48
39
 
49
40
  afterEach(() => { vi.restoreAllMocks() })
@@ -56,7 +47,7 @@ describe('useSnapshotCapture', () => {
56
47
  it('returns iframeReady=false initially', () => {
57
48
  const iframeRef = createMockIframeRef()
58
49
  const { result } = renderHook(() =>
59
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
50
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
60
51
  )
61
52
  expect(result.current.iframeReady).toBe(false)
62
53
  })
@@ -65,7 +56,7 @@ describe('useSnapshotCapture', () => {
65
56
  const cw = createMockContentWindow()
66
57
  const iframeRef = createMockIframeRef(cw)
67
58
  const { result } = renderHook(() =>
68
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
59
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
69
60
  )
70
61
  act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
71
62
  expect(result.current.iframeReady).toBe(true)
@@ -75,7 +66,7 @@ describe('useSnapshotCapture', () => {
75
66
  const cw = createMockContentWindow()
76
67
  const iframeRef = createMockIframeRef(cw)
77
68
  const { result } = renderHook(() =>
78
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: null, canvasTheme: 'light' })
69
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: null })
79
70
  )
80
71
  await act(async () => { await result.current.requestCapture() })
81
72
  expect(cw.postMessage).not.toHaveBeenCalled()
@@ -85,141 +76,89 @@ describe('useSnapshotCapture', () => {
85
76
  const cw = createMockContentWindow()
86
77
  const iframeRef = createMockIframeRef(cw)
87
78
  const { result } = renderHook(() =>
88
- useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn(), canvasTheme: 'light' })
79
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate: vi.fn() })
89
80
  )
90
81
  await act(async () => { await result.current.requestCapture() })
91
82
  expect(cw.postMessage).not.toHaveBeenCalled()
92
83
  })
93
84
 
94
- it('sends capture + set-theme + capture + set-theme for dual-theme', async () => {
85
+ it('sends single capture and calls onUpdate with snapshot', async () => {
95
86
  const cw = createMockContentWindow()
96
- // Need a real iframe element for style.visibility
97
- const style = { visibility: "" }
98
-
99
- const iframeRef = { current: { contentWindow: cw, style } }
87
+ const iframeRef = createMockIframeRef(cw)
100
88
  const onUpdate = vi.fn()
101
89
  const { result } = renderHook(() =>
102
- useSnapshotCapture({ iframeRef, widgetId: 'test-widget', onUpdate, canvasTheme: 'light' })
90
+ useSnapshotCapture({ iframeRef, widgetId: 'test-widget', onUpdate })
103
91
  )
104
92
 
105
93
  act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
106
94
 
107
- uploadImage
108
- .mockResolvedValueOnce({ filename: 'snapshot-test-widget--light.webp' })
109
- .mockResolvedValueOnce({ filename: 'snapshot-test-widget--dark.webp' })
95
+ uploadImage.mockResolvedValueOnce({ filename: 'snapshot-test-widget.webp' })
110
96
 
111
97
  await act(async () => {
112
98
  const promise = result.current.requestCapture()
113
99
 
114
- // Respond to capture 1 (current=light)
115
- await new Promise(r => setTimeout(r, 10))
116
- dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,LIGHT' })
117
-
118
- // Respond to set-theme (switch to dark)
119
- await new Promise(r => setTimeout(r, 10))
120
- dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 2 })
121
-
122
- // Respond to capture 2 (alternate=dark)
123
100
  await new Promise(r => setTimeout(r, 10))
124
- dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 3, dataUrl: 'data:image/webp;base64,DARK' })
125
-
126
- // Respond to set-theme back (switch to light)
127
- await new Promise(r => setTimeout(r, 10))
128
- dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 4 })
101
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,IMG' })
129
102
 
130
103
  await promise
131
104
  })
132
105
 
133
- // 2 captures + 2 theme switches = 4 postMessages
134
- expect(cw.postMessage).toHaveBeenCalledTimes(4)
135
- expect(uploadImage).toHaveBeenCalledTimes(2)
136
- // Intermediate onUpdate for current theme + final with both
137
- expect(onUpdate).toHaveBeenCalledWith(
138
- expect.objectContaining({
139
- snapshotLight: expect.stringContaining('snapshot-test-widget--light.webp'),
140
- })
141
- )
106
+ // Single capture, single postMessage
107
+ expect(cw.postMessage).toHaveBeenCalledTimes(1)
108
+ expect(uploadImage).toHaveBeenCalledTimes(1)
142
109
  expect(onUpdate).toHaveBeenCalledWith(
143
110
  expect.objectContaining({
144
- snapshotLight: expect.stringContaining('snapshot-test-widget--light.webp'),
145
- snapshotDark: expect.stringContaining('snapshot-test-widget--dark.webp'),
111
+ snapshot: expect.stringContaining('snapshot-test-widget.webp'),
146
112
  })
147
113
  )
148
114
  })
149
115
 
150
- it('discards stale capture results via generation token', async () => {
116
+ it('guards against concurrent captures', async () => {
151
117
  const cw = createMockContentWindow()
152
- const style = { visibility: "" }
153
- const iframeRef = { current: { contentWindow: cw, style } }
118
+ const iframeRef = createMockIframeRef(cw)
154
119
  const onUpdate = vi.fn()
155
120
  const { result } = renderHook(() =>
156
- useSnapshotCapture({ iframeRef, widgetId: 'gen-widget', onUpdate, canvasTheme: 'light' })
121
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate })
157
122
  )
158
123
 
159
124
  act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
125
+ uploadImage.mockResolvedValue({ filename: 'snapshot-w1.webp' })
160
126
 
161
- uploadImage.mockResolvedValue({ filename: 'snapshot-gen-widget--light.webp' })
162
-
163
- // Start first capture — will be waiting for response
164
- let capture1Done = false
165
- let capture2Done = false
166
127
  await act(async () => {
167
- const promise1 = result.current.requestCapture().then(() => { capture1Done = true })
128
+ const p1 = result.current.requestCapture()
129
+ // Second call while first is in-flight should no-op
130
+ const p2 = result.current.requestCapture()
168
131
 
169
- // Respond to first capture
170
132
  await new Promise(r => setTimeout(r, 10))
171
- dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FIRST' })
133
+ dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,IMG' })
172
134
 
173
- // Start second capture before first completes its alternate-theme work
174
- // This won't start because capturingRef is still true, so it will return {}
175
- const promise2 = result.current.requestCapture().then(() => { capture2Done = true })
176
-
177
- // Complete first capture's theme switch and alternate capture
178
- await new Promise(r => setTimeout(r, 10))
179
- dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 2 })
180
- await new Promise(r => setTimeout(r, 10))
181
- dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 3, dataUrl: 'data:image/webp;base64,DARK' })
182
- await new Promise(r => setTimeout(r, 10))
183
- dispatchMessage(cw, { type: 'storyboard:embed:theme-applied', requestId: 4 })
184
-
185
- await promise1
186
- await promise2
135
+ await p1
136
+ await p2
187
137
  })
188
138
 
189
- // The second capture should have no-opped (capturingRef guard)
190
- expect(capture1Done).toBe(true)
191
- expect(capture2Done).toBe(true)
139
+ expect(cw.postMessage).toHaveBeenCalledTimes(1)
192
140
  })
193
141
 
194
- it('restores iframe visibility on error', async () => {
142
+ it('handles capture failure gracefully', async () => {
195
143
  const cw = createMockContentWindow()
196
- const style = { visibility: "visible" }
197
- const iframeRef = { current: { contentWindow: cw, style } }
144
+ const iframeRef = createMockIframeRef(cw)
198
145
  const onUpdate = vi.fn()
199
146
  const { result } = renderHook(() =>
200
- useSnapshotCapture({ iframeRef, widgetId: 'err-widget', onUpdate, canvasTheme: 'light' })
147
+ useSnapshotCapture({ iframeRef, widgetId: 'w1', onUpdate })
201
148
  )
202
149
 
203
150
  act(() => { dispatchMessage(cw, { type: 'storyboard:embed:snapshot-ready' }) })
204
-
205
- // Make uploadImage throw to trigger error path
206
151
  uploadImage.mockRejectedValueOnce(new Error('upload failed'))
207
152
 
208
153
  await act(async () => {
209
154
  const promise = result.current.requestCapture()
210
155
 
211
- // Respond to capture
212
156
  await new Promise(r => setTimeout(r, 10))
213
157
  dispatchMessage(cw, { type: 'storyboard:embed:snapshot', requestId: 1, dataUrl: 'data:image/webp;base64,FAIL' })
214
158
 
215
159
  await promise
216
160
  })
217
161
 
218
- // Visibility should be restored after error
219
- expect(style.visibility).toBe('')
220
- // onUpdate should not be called with failed data
221
- expect(onUpdate).not.toHaveBeenCalledWith(
222
- expect.objectContaining({ snapshotLight: expect.any(String) })
223
- )
162
+ expect(onUpdate).not.toHaveBeenCalled()
224
163
  })
225
164
  })
@@ -12,7 +12,7 @@ describe('isResizable', () => {
12
12
  expect(isResizable('component')).toBe(true)
13
13
  })
14
14
 
15
- it('returns false for widget types with resize disabled', () => {
15
+ it('returns false for link-preview (resize disabled)', () => {
16
16
  expect(isResizable('link-preview')).toBe(false)
17
17
  })
18
18
 
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * StoryPage — renders a .story.jsx module at its own route.
3
3
  *
4
- * When visited at e.g. /canvas/button-patterns, renders all named exports
5
- * from button-patterns.story.jsx in a gallery layout.
6
- *
7
- * When ?export=ExportName is present, renders only that single export
8
- * (used by iframe embeds from canvas StoryWidget).
4
+ * Renders only the bare component(s) with no layout chrome.
5
+ * When ?export=ExportName is present, renders that single export.
6
+ * Without ?export, renders all named exports stacked.
9
7
  */
10
8
  import { useState, useEffect, useMemo } from 'react'
11
9
  import { useLocation } from 'react-router-dom'
@@ -13,7 +11,7 @@ import { getStoryData } from '@dfosco/storyboard-core'
13
11
  import { ThemeProvider, BaseStyles } from '@primer/react'
14
12
  import styles from './StoryPage.module.css'
15
13
 
16
- function StoryErrorBoundaryFallback({ name, error }) {
14
+ function StoryErrorFallback({ name, error }) {
17
15
  return (
18
16
  <div className={styles.error}>
19
17
  <strong>{name}</strong>
@@ -60,8 +58,6 @@ export default function StoryPage({ name }) {
60
58
  }, [name, story])
61
59
 
62
60
  // Signal snapshot-ready after story renders in embed mode.
63
- // Stories have no data loading so they render fast — fire
64
- // the explicit ready signal instead of waiting for the 1.5s fallback.
65
61
  useEffect(() => {
66
62
  if (!isEmbed || !exports || window.parent === window) return
67
63
  document.fonts.ready.then(() => {
@@ -73,80 +69,49 @@ export default function StoryPage({ name }) {
73
69
 
74
70
  if (error) {
75
71
  return (
76
- <div className={styles.page}>
77
- <StoryErrorBoundaryFallback name={name} error={error} />
78
- </div>
72
+ <StoryErrorFallback name={name} error={error} />
79
73
  )
80
74
  }
81
75
 
82
76
  if (!exports) {
77
+ if (isEmbed) return null
83
78
  return (
84
- <div className={styles.page}>
85
- <div className={styles.loading}>Loading story…</div>
86
- </div>
79
+ <div className={styles.loading}>Loading story…</div>
87
80
  )
88
81
  }
89
82
 
90
- // Single export mode (for iframe embedding)
83
+ // Single export mode
91
84
  if (exportFilter) {
92
85
  const Component = exports[exportFilter]
93
86
  if (!Component) {
94
87
  return (
95
- <div className={styles.page}>
96
- <StoryErrorBoundaryFallback
97
- name={`${name}/${exportFilter}`}
98
- error={`Export "${exportFilter}" not found in story "${name}"`}
99
- />
100
- </div>
101
- )
102
- }
103
-
104
- // Minimal wrapper for embed mode
105
- if (isEmbed) {
106
- return (
107
- <ThemeProvider colorMode="day">
108
- <BaseStyles>
109
- <Component />
110
- </BaseStyles>
111
- </ThemeProvider>
88
+ <StoryErrorFallback
89
+ name={`${name}/${exportFilter}`}
90
+ error={`Export "${exportFilter}" not found in story "${name}"`}
91
+ />
112
92
  )
113
93
  }
114
94
 
115
95
  return (
116
- <div className={styles.page}>
117
- <header className={styles.header}>
118
- <h1 className={styles.title}>{name}</h1>
119
- <span className={styles.exportBadge}>{exportFilter}</span>
120
- </header>
121
- <section className={styles.storySection}>
96
+ <ThemeProvider colorMode="day">
97
+ <BaseStyles>
122
98
  <Component />
123
- </section>
124
- </div>
99
+ </BaseStyles>
100
+ </ThemeProvider>
125
101
  )
126
102
  }
127
103
 
128
- // Gallery mode — render all exports
104
+ // All exports — render each component bare
129
105
  const exportNames = Object.keys(exports)
130
106
 
131
107
  return (
132
- <div className={styles.page}>
133
- {!isEmbed && (
134
- <header className={styles.header}>
135
- <h1 className={styles.title}>{name}</h1>
136
- <span className={styles.count}>{exportNames.length} {exportNames.length === 1 ? 'export' : 'exports'}</span>
137
- </header>
138
- )}
139
- {exportNames.map((exportName) => {
140
- const Component = exports[exportName]
141
- return (
142
- <section key={exportName} className={styles.storySection}>
143
- {!isEmbed && <h2 className={styles.exportName}>{exportName}</h2>}
144
- <div className={styles.storyContent}>
145
- <Component />
146
- </div>
147
- </section>
148
- )
149
- })}
150
- </div>
108
+ <ThemeProvider colorMode="day">
109
+ <BaseStyles>
110
+ {exportNames.map((exportName) => {
111
+ const Component = exports[exportName]
112
+ return <Component key={exportName} />
113
+ })}
114
+ </BaseStyles>
115
+ </ThemeProvider>
151
116
  )
152
117
  }
@@ -1,58 +1,3 @@
1
- .page {
2
- max-width: 960px;
3
- margin: 0 auto;
4
- padding: 2rem;
5
- font-family: system-ui, -apple-system, sans-serif;
6
- }
7
-
8
- .header {
9
- display: flex;
10
- align-items: baseline;
11
- gap: 12px;
12
- margin-bottom: 2rem;
13
- padding-bottom: 1rem;
14
- border-bottom: 1px solid var(--borderColor-muted, #d0d7de);
15
- }
16
-
17
- .title {
18
- font-size: 1.5rem;
19
- font-weight: 600;
20
- margin: 0;
21
- color: var(--fgColor-default, #1f2328);
22
- }
23
-
24
- .exportBadge {
25
- font-size: 0.875rem;
26
- font-weight: 500;
27
- padding: 2px 8px;
28
- border-radius: 6px;
29
- background: var(--bgColor-accent-muted, #ddf4ff);
30
- color: var(--fgColor-accent, #0969da);
31
- }
32
-
33
- .count {
34
- font-size: 0.875rem;
35
- color: var(--fgColor-muted, #656d76);
36
- }
37
-
38
- .storySection {
39
- margin-bottom: 2rem;
40
- }
41
-
42
- .exportName {
43
- font-size: 1rem;
44
- font-weight: 500;
45
- margin: 0 0 0.75rem;
46
- color: var(--fgColor-default, #1f2328);
47
- }
48
-
49
- .storyContent {
50
- border: 1px solid var(--borderColor-muted, #d0d7de);
51
- border-radius: 8px;
52
- padding: 1.5rem;
53
- background: var(--bgColor-default, #ffffff);
54
- }
55
-
56
1
  .loading {
57
2
  display: flex;
58
3
  align-items: center;