@dfosco/storyboard-react 4.0.0-beta.4 → 4.0.0-beta.41

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 +9 -4
  2. package/src/Icon.jsx +179 -0
  3. package/src/Viewfinder.jsx +1030 -57
  4. package/src/Viewfinder.module.css +1524 -155
  5. package/src/canvas/CanvasControls.jsx +51 -2
  6. package/src/canvas/CanvasControls.module.css +31 -0
  7. package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
  8. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  9. package/src/canvas/CanvasPage.jsx +843 -301
  10. package/src/canvas/CanvasPage.module.css +73 -50
  11. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  12. package/src/canvas/CanvasToolbar.jsx +2 -2
  13. package/src/canvas/ComponentErrorBoundary.jsx +50 -0
  14. package/src/canvas/PageSelector.jsx +198 -0
  15. package/src/canvas/PageSelector.module.css +158 -0
  16. package/src/canvas/PageSelector.test.jsx +104 -0
  17. package/src/canvas/canvasApi.js +22 -8
  18. package/src/canvas/canvasReloadGuard.js +37 -0
  19. package/src/canvas/canvasReloadGuard.test.js +27 -0
  20. package/src/canvas/componentIsolate.jsx +135 -0
  21. package/src/canvas/useCanvas.js +15 -10
  22. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  23. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +82 -9
  25. package/src/canvas/widgets/ComponentWidget.module.css +14 -6
  26. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  27. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  28. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  29. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  30. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  31. package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
  32. package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
  33. package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
  34. package/src/canvas/widgets/PrototypeEmbed.jsx +95 -144
  35. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  36. package/src/canvas/widgets/StickyNote.module.css +5 -0
  37. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  38. package/src/canvas/widgets/StoryWidget.jsx +276 -0
  39. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  40. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  41. package/src/canvas/widgets/WidgetChrome.module.css +4 -7
  42. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  43. package/src/canvas/widgets/codepenUrl.js +75 -0
  44. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  45. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  46. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  47. package/src/canvas/widgets/embedTheme.js +56 -0
  48. package/src/canvas/widgets/githubUrl.js +82 -0
  49. package/src/canvas/widgets/githubUrl.test.js +74 -0
  50. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  51. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  52. package/src/canvas/widgets/index.js +4 -0
  53. package/src/canvas/widgets/pasteRules.js +295 -0
  54. package/src/canvas/widgets/pasteRules.test.js +474 -0
  55. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -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 +375 -57
  63. package/src/vite/data-plugin.test.js +405 -5
@@ -0,0 +1,75 @@
1
+ /**
2
+ * CodePen URL utilities — parse, validate, and convert CodePen URLs
3
+ * to their embeddable format.
4
+ *
5
+ * Supported URL formats:
6
+ * https://codepen.io/{user}/pen/{penId}
7
+ * https://codepen.io/{user}/full/{penId}
8
+ * https://codepen.io/{user}/details/{penId}
9
+ * https://codepen.io/{user}/embed/{penId}
10
+ */
11
+
12
+ const CODEPEN_RE = /^https?:\/\/codepen\.io\/([^/]+)\/(pen|full|details|embed)\/([A-Za-z0-9]+)/
13
+
14
+ /**
15
+ * Check if a URL is a valid CodePen pen URL.
16
+ */
17
+ export function isCodePenUrl(url) {
18
+ if (!url) return false
19
+ return CODEPEN_RE.test(url)
20
+ }
21
+
22
+ /**
23
+ * Convert any CodePen pen URL to the embed format.
24
+ * Defaults to showing the result tab with a dark theme.
25
+ */
26
+ export function toCodePenEmbedUrl(url) {
27
+ const m = url?.match(CODEPEN_RE)
28
+ if (!m) return ''
29
+ const [, user, , penId] = m
30
+ return `https://codepen.io/${user}/embed/${penId}?default-tab=result&editable=true`
31
+ }
32
+
33
+ /**
34
+ * Extract a fallback title from a CodePen URL (user/penId).
35
+ */
36
+ export function getCodePenTitle(url) {
37
+ const m = url?.match(CODEPEN_RE)
38
+ if (!m) return 'CodePen'
39
+ return `${m[1]}/${m[3]}`
40
+ }
41
+
42
+ /**
43
+ * Extract the username from a CodePen URL.
44
+ */
45
+ export function getCodePenUser(url) {
46
+ const m = url?.match(CODEPEN_RE)
47
+ return m?.[1] || ''
48
+ }
49
+
50
+ /** In-memory cache for oEmbed results keyed by pen URL. */
51
+ const _oembedCache = new Map()
52
+
53
+ /**
54
+ * Fetch pen metadata (title, author_name) via CodePen's oEmbed API.
55
+ * Returns `{ title, author }` or null on failure. Results are cached.
56
+ */
57
+ export async function fetchCodePenMeta(url) {
58
+ if (!url || !isCodePenUrl(url)) return null
59
+ if (_oembedCache.has(url)) return _oembedCache.get(url)
60
+
61
+ try {
62
+ const endpoint = `https://codepen.io/api/oembed?url=${encodeURIComponent(url)}&format=json`
63
+ const res = await fetch(endpoint)
64
+ if (!res.ok) return null
65
+ const data = await res.json()
66
+ const meta = {
67
+ title: data.title || '',
68
+ author: data.author_name || '',
69
+ }
70
+ _oembedCache.set(url, meta)
71
+ return meta
72
+ } catch {
73
+ return null
74
+ }
75
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Tests for CodePen URL utilities.
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import { isCodePenUrl, toCodePenEmbedUrl, getCodePenTitle, getCodePenUser } from './codepenUrl.js'
6
+
7
+ describe('isCodePenUrl', () => {
8
+ it('returns true for pen URLs', () => {
9
+ expect(isCodePenUrl('https://codepen.io/Calleb/pen/jEMXgvq')).toBe(true)
10
+ })
11
+
12
+ it('returns true for full view URLs', () => {
13
+ expect(isCodePenUrl('https://codepen.io/Calleb/full/jEMXgvq')).toBe(true)
14
+ })
15
+
16
+ it('returns true for details URLs', () => {
17
+ expect(isCodePenUrl('https://codepen.io/Calleb/details/jEMXgvq')).toBe(true)
18
+ })
19
+
20
+ it('returns true for embed URLs', () => {
21
+ expect(isCodePenUrl('https://codepen.io/Calleb/embed/jEMXgvq')).toBe(true)
22
+ })
23
+
24
+ it('returns false for non-CodePen URLs', () => {
25
+ expect(isCodePenUrl('https://example.com')).toBe(false)
26
+ expect(isCodePenUrl('https://figma.com/design/abc')).toBe(false)
27
+ })
28
+
29
+ it('returns false for CodePen homepage', () => {
30
+ expect(isCodePenUrl('https://codepen.io')).toBe(false)
31
+ expect(isCodePenUrl('https://codepen.io/Calleb')).toBe(false)
32
+ })
33
+
34
+ it('returns false for null/empty', () => {
35
+ expect(isCodePenUrl(null)).toBe(false)
36
+ expect(isCodePenUrl('')).toBe(false)
37
+ })
38
+ })
39
+
40
+ describe('toCodePenEmbedUrl', () => {
41
+ it('converts pen URL to embed format', () => {
42
+ const result = toCodePenEmbedUrl('https://codepen.io/Calleb/pen/jEMXgvq')
43
+ expect(result).toBe('https://codepen.io/Calleb/embed/jEMXgvq?default-tab=result&editable=true')
44
+ })
45
+
46
+ it('converts full URL to embed format', () => {
47
+ const result = toCodePenEmbedUrl('https://codepen.io/Calleb/full/jEMXgvq')
48
+ expect(result).toBe('https://codepen.io/Calleb/embed/jEMXgvq?default-tab=result&editable=true')
49
+ })
50
+
51
+ it('returns empty string for invalid URL', () => {
52
+ expect(toCodePenEmbedUrl('https://example.com')).toBe('')
53
+ expect(toCodePenEmbedUrl(null)).toBe('')
54
+ })
55
+ })
56
+
57
+ describe('getCodePenTitle', () => {
58
+ it('extracts user/penId from URL', () => {
59
+ expect(getCodePenTitle('https://codepen.io/Calleb/pen/jEMXgvq')).toBe('Calleb/jEMXgvq')
60
+ })
61
+
62
+ it('returns "CodePen" for invalid URL', () => {
63
+ expect(getCodePenTitle('https://example.com')).toBe('CodePen')
64
+ expect(getCodePenTitle(null)).toBe('CodePen')
65
+ })
66
+ })
67
+
68
+ describe('getCodePenUser', () => {
69
+ it('extracts username', () => {
70
+ expect(getCodePenUser('https://codepen.io/Calleb/pen/jEMXgvq')).toBe('Calleb')
71
+ })
72
+
73
+ it('returns empty for invalid URL', () => {
74
+ expect(getCodePenUser('https://example.com')).toBe('')
75
+ })
76
+ })
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Tests for embed interaction UX (click-to-interact overlay).
3
+ */
4
+ import { describe, it, expect, vi } from 'vitest'
5
+ import { render, fireEvent, screen } from '@testing-library/react'
6
+ import PrototypeEmbed from './PrototypeEmbed.jsx'
7
+ import FigmaEmbed from './FigmaEmbed.jsx'
8
+ import ComponentWidget from './ComponentWidget.jsx'
9
+ import StoryWidget from './StoryWidget.jsx'
10
+
11
+ // Mock buildPrototypeIndex for PrototypeEmbed
12
+ vi.mock('@dfosco/storyboard-core', () => ({
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}` }),
28
+ }))
29
+
30
+ // Simple mock wrapper for WidgetWrapper
31
+ vi.mock('./WidgetWrapper.jsx', () => ({
32
+ default: ({ children }) => <div data-testid="widget-wrapper">{children}</div>,
33
+ }))
34
+
35
+ vi.mock('@dfosco/storyboard-core/inspector/highlighter', () => ({
36
+ createInspectorHighlighter: async () => ({
37
+ codeToHtml: () => '<pre><code></code></pre>',
38
+ }),
39
+ }))
40
+
41
+ // Mock ResizeHandle
42
+ vi.mock('./ResizeHandle.jsx', () => ({
43
+ default: () => <div data-testid="resize-handle" />,
44
+ }))
45
+
46
+ // Mock ComponentErrorBoundary
47
+ vi.mock('../ComponentErrorBoundary.jsx', () => ({
48
+ default: ({ children }) => <div data-testid="error-boundary">{children}</div>,
49
+ }))
50
+
51
+ describe('Embed interaction overlay', () => {
52
+ describe('PrototypeEmbed', () => {
53
+ const defaultProps = {
54
+ props: { src: '/test', width: 400, height: 300, zoom: 100 },
55
+ onUpdate: vi.fn(),
56
+ resizable: false,
57
+ }
58
+
59
+ it('renders "Click to open" hint when no snapshot exists', () => {
60
+ render(<PrototypeEmbed {...defaultProps} />)
61
+
62
+ const hint = screen.getByText('Click to open')
63
+ expect(hint).toBeInTheDocument()
64
+ // CSS modules mangle class names, just check the element exists
65
+ })
66
+
67
+ it('enters interactive mode on single click (not double-click)', async () => {
68
+ const { container } = render(<PrototypeEmbed {...defaultProps} />)
69
+
70
+ // Overlay should exist before interaction
71
+ const overlay = screen.getByRole('button', { name: /click to open/i })
72
+ expect(overlay).toBeInTheDocument()
73
+ expect(container.querySelector('iframe')).not.toBeInTheDocument()
74
+ expect(screen.getByText('Design Overview')).toBeInTheDocument()
75
+
76
+ // Single click should remove the overlay (enter interactive mode)
77
+ fireEvent.click(overlay)
78
+
79
+ // Overlay should no longer exist
80
+ expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
81
+ expect(container.querySelector('iframe')).toBeInTheDocument()
82
+
83
+ fireEvent.pointerDown(document.body)
84
+ expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
85
+ expect(container.querySelector('iframe')).not.toBeInTheDocument()
86
+ })
87
+
88
+ it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
89
+ render(<PrototypeEmbed {...defaultProps} />)
90
+
91
+ const overlay = screen.getByRole('button', { name: /click to open/i })
92
+ fireEvent.click(overlay, { shiftKey: true })
93
+
94
+ // Overlay should still exist (did not enter interactive mode)
95
+ expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
96
+ })
97
+
98
+ it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
99
+ render(<PrototypeEmbed {...defaultProps} />)
100
+
101
+ const overlay = screen.getByRole('button', { name: /click to open/i })
102
+ fireEvent.click(overlay, { metaKey: true })
103
+
104
+ expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
105
+ })
106
+
107
+ it('supports keyboard interaction (Enter key) with event prevention', () => {
108
+ render(<PrototypeEmbed {...defaultProps} />)
109
+
110
+ const overlay = screen.getByRole('button', { name: /click to open/i })
111
+ const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
112
+ fireEvent.keyDown(overlay, event)
113
+
114
+ expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
115
+ })
116
+
117
+ it('supports keyboard interaction (Space key) with event prevention', () => {
118
+ render(<PrototypeEmbed {...defaultProps} />)
119
+
120
+ const overlay = screen.getByRole('button', { name: /click to open/i })
121
+ const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
122
+ fireEvent.keyDown(overlay, event)
123
+
124
+ expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
125
+ })
126
+ })
127
+
128
+ describe('FigmaEmbed', () => {
129
+ const defaultProps = {
130
+ props: { url: 'https://www.figma.com/design/abc123/Test', width: 400, height: 300 },
131
+ onUpdate: vi.fn(),
132
+ resizable: false,
133
+ }
134
+
135
+ it('renders "Click to interact" hint', () => {
136
+ render(<FigmaEmbed {...defaultProps} />)
137
+
138
+ const hint = screen.getByText('Click to interact')
139
+ expect(hint).toBeInTheDocument()
140
+ })
141
+
142
+ it('enters interactive mode on single click', () => {
143
+ const { container } = render(<FigmaEmbed {...defaultProps} />)
144
+
145
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
146
+ expect(container.querySelector('iframe')).not.toBeInTheDocument()
147
+ fireEvent.click(overlay)
148
+
149
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
150
+ expect(container.querySelector('iframe')).toBeInTheDocument()
151
+
152
+ fireEvent.pointerDown(document.body)
153
+ expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
154
+ expect(container.querySelector('iframe')).not.toBeInTheDocument()
155
+ })
156
+ })
157
+
158
+ describe('StoryWidget', () => {
159
+ const defaultProps = {
160
+ props: { storyId: 'button-patterns', exportName: 'Primary', width: 400, height: 300 },
161
+ onUpdate: vi.fn(),
162
+ resizable: false,
163
+ }
164
+
165
+ it('mounts iframe only after user activation', () => {
166
+ const { container } = render(<StoryWidget {...defaultProps} />)
167
+
168
+ const overlay = screen.getByRole('button', { name: /click to open story component/i })
169
+ expect(container.querySelector('iframe')).not.toBeInTheDocument()
170
+
171
+ fireEvent.click(overlay)
172
+
173
+ expect(screen.queryByRole('button', { name: /click to open story component/i })).not.toBeInTheDocument()
174
+ expect(container.querySelector('iframe')).toBeInTheDocument()
175
+
176
+ fireEvent.pointerDown(document.body)
177
+ expect(screen.getByRole('button', { name: /click to open story component/i })).toBeInTheDocument()
178
+ expect(container.querySelector('iframe')).not.toBeInTheDocument()
179
+ })
180
+ })
181
+
182
+ describe('ComponentWidget', () => {
183
+ const MockComponent = () => <div>Mock Component</div>
184
+
185
+ const defaultProps = {
186
+ component: MockComponent,
187
+ jsxModule: null,
188
+ exportName: 'MockComponent',
189
+ canvasTheme: 'light',
190
+ isLocalDev: false,
191
+ width: 200,
192
+ height: 150,
193
+ onUpdate: vi.fn(),
194
+ resizable: false,
195
+ }
196
+
197
+ it('renders "Click to interact" hint', () => {
198
+ render(<ComponentWidget {...defaultProps} />)
199
+
200
+ const hint = screen.getByText('Click to interact')
201
+ expect(hint).toBeInTheDocument()
202
+ })
203
+
204
+ it('enters interactive mode on single click', () => {
205
+ render(<ComponentWidget {...defaultProps} />)
206
+
207
+ const overlay = screen.getByRole('button', { name: /click to interact/i })
208
+ fireEvent.click(overlay)
209
+
210
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
211
+ })
212
+
213
+ it('mounts dev iframe only after user activation', () => {
214
+ const { container } = render(
215
+ <ComponentWidget
216
+ {...defaultProps}
217
+ isLocalDev
218
+ jsxModule="/src/canvas/mock.canvas.jsx"
219
+ exportName="MockComponent"
220
+ />
221
+ )
222
+
223
+ const overlay = screen.getByRole('button', { name: /click to interact with component/i })
224
+ expect(container.querySelector('iframe')).not.toBeInTheDocument()
225
+
226
+ fireEvent.click(overlay)
227
+
228
+ expect(container.querySelector('iframe')).toBeInTheDocument()
229
+
230
+ fireEvent.pointerDown(document.body)
231
+ expect(screen.getByRole('button', { name: /click to interact with component/i })).toBeInTheDocument()
232
+ expect(container.querySelector('iframe')).not.toBeInTheDocument()
233
+ })
234
+ })
235
+ })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Shared styles for embed interaction overlays.
3
+ * Used by PrototypeEmbed, FigmaEmbed, and ComponentWidget.
4
+ */
5
+
6
+ .interactOverlay {
7
+ position: absolute;
8
+ inset: 0;
9
+ z-index: 1;
10
+ cursor: pointer;
11
+ display: flex;
12
+ align-items: center;
13
+ justify-content: center;
14
+ transition: background-color 150ms ease;
15
+ }
16
+
17
+ .interactOverlay:hover {
18
+ background-color: rgba(0, 0, 0, 0.15);
19
+ }
20
+
21
+ .interactHint {
22
+ opacity: 0;
23
+ color: var(--fgColor-onInverse);
24
+ background-color: var(--bgColor-inverse);
25
+ padding: var(--base-size-12) var(--base-size-16);
26
+ border-radius: var(--base-size-6);
27
+ font-size: 14px;
28
+ font-weight: 600;
29
+ pointer-events: none;
30
+ transition: opacity 150ms ease;
31
+ }
32
+
33
+ .interactOverlay:hover .interactHint {
34
+ opacity: 1;
35
+ }
@@ -1,3 +1,59 @@
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: true }
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
+
21
+ /**
22
+ * Subscribe to canvas theme changes for embed widget snapshot switching.
23
+ *
24
+ * Reads `data-sb-canvas-theme` from the nearest ancestor set by CanvasPage.
25
+ * With canvas sync ON (the default), this attribute follows the global theme.
26
+ * Uses MutationObserver for immediate reaction + event backup.
27
+ */
28
+ export function subscribeCanvasTheme({ anchorRef, onTheme }) {
29
+ if (typeof onTheme !== 'function') return () => {}
30
+
31
+ let observed = null
32
+ let observer = null
33
+
34
+ function readAndEmit() {
35
+ const el = anchorRef?.current?.closest?.('[data-sb-canvas-theme]') || null
36
+ if (el !== observed) {
37
+ if (observer) observer.disconnect()
38
+ observer = null
39
+ observed = el
40
+ if (el && typeof MutationObserver !== 'undefined') {
41
+ observer = new MutationObserver(readAndEmit)
42
+ observer.observe(el, { attributes: true, attributeFilter: ['data-sb-canvas-theme'] })
43
+ }
44
+ }
45
+ onTheme(el?.getAttribute('data-sb-canvas-theme') || 'light')
46
+ }
47
+
48
+ readAndEmit()
49
+ document.addEventListener('storyboard:theme:changed', readAndEmit)
50
+
51
+ return () => {
52
+ document.removeEventListener('storyboard:theme:changed', readAndEmit)
53
+ if (observer) observer.disconnect()
54
+ }
55
+ }
56
+
1
57
  export function getEmbedChromeVars(theme) {
2
58
  const value = String(theme || 'light')
3
59
  if (value === 'dark_dimmed') {
@@ -0,0 +1,82 @@
1
+ const GH_HOST_RE = /^(www\.)?github\.com$/i
2
+ const ISSUE_PATH_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/?$/
3
+ const DISCUSSION_PATH_RE = /^\/([^/]+)\/([^/]+)\/discussions\/(\d+)\/?$/
4
+ const PULL_REQUEST_PATH_RE = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/
5
+ const ISSUE_COMMENT_HASH_RE = /^#issuecomment-(\d+)$/i
6
+ const DISCUSSION_COMMENT_HASH_RE = /^#discussioncomment-(\d+)$/i
7
+
8
+ function toNumber(raw) {
9
+ const value = Number.parseInt(raw, 10)
10
+ return Number.isFinite(value) ? value : null
11
+ }
12
+
13
+ /**
14
+ * Parse supported GitHub embed URLs (issues, discussions, comments).
15
+ * @param {string} rawUrl
16
+ * @returns {null | {
17
+ * kind: 'issue' | 'discussion' | 'comment',
18
+ * parentKind: 'issue' | 'discussion',
19
+ * owner: string,
20
+ * repo: string,
21
+ * number: number,
22
+ * commentId?: number
23
+ * }}
24
+ */
25
+ export function parseGitHubUrl(rawUrl) {
26
+ try {
27
+ const parsed = new URL(rawUrl)
28
+ if (!GH_HOST_RE.test(parsed.hostname)) return null
29
+
30
+ const issueMatch = parsed.pathname.match(ISSUE_PATH_RE)
31
+ if (issueMatch) {
32
+ const [, owner, repo, numberRaw] = issueMatch
33
+ const number = toNumber(numberRaw)
34
+ if (!number) return null
35
+
36
+ const commentMatch = parsed.hash.match(ISSUE_COMMENT_HASH_RE)
37
+ if (commentMatch) {
38
+ const commentId = toNumber(commentMatch[1])
39
+ if (!commentId) return null
40
+ return { kind: 'comment', parentKind: 'issue', owner, repo, number, commentId }
41
+ }
42
+
43
+ if (parsed.hash) return null
44
+ return { kind: 'issue', parentKind: 'issue', owner, repo, number }
45
+ }
46
+
47
+ const discussionMatch = parsed.pathname.match(DISCUSSION_PATH_RE)
48
+ if (discussionMatch) {
49
+ const [, owner, repo, numberRaw] = discussionMatch
50
+ const number = toNumber(numberRaw)
51
+ if (!number) return null
52
+
53
+ const commentMatch = parsed.hash.match(DISCUSSION_COMMENT_HASH_RE)
54
+ if (commentMatch) {
55
+ const commentId = toNumber(commentMatch[1])
56
+ if (!commentId) return null
57
+ return { kind: 'comment', parentKind: 'discussion', owner, repo, number, commentId }
58
+ }
59
+
60
+ if (parsed.hash) return null
61
+ return { kind: 'discussion', parentKind: 'discussion', owner, repo, number }
62
+ }
63
+
64
+ const prMatch = parsed.pathname.match(PULL_REQUEST_PATH_RE)
65
+ if (prMatch) {
66
+ const [, owner, repo, numberRaw] = prMatch
67
+ const number = toNumber(numberRaw)
68
+ if (!number) return null
69
+
70
+ if (parsed.hash) return null
71
+ return { kind: 'pull_request', parentKind: 'pull_request', owner, repo, number }
72
+ }
73
+ } catch {
74
+ return null
75
+ }
76
+
77
+ return null
78
+ }
79
+
80
+ export function isGitHubEmbedUrl(rawUrl) {
81
+ return parseGitHubUrl(rawUrl) !== null
82
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isGitHubEmbedUrl, parseGitHubUrl } from './githubUrl.js'
3
+
4
+ describe('parseGitHubUrl', () => {
5
+ it('classifies issue URLs', () => {
6
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12')).toEqual({
7
+ kind: 'issue',
8
+ parentKind: 'issue',
9
+ owner: 'dfosco',
10
+ repo: 'storyboard',
11
+ number: 12,
12
+ })
13
+ })
14
+
15
+ it('classifies discussion URLs', () => {
16
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99')).toEqual({
17
+ kind: 'discussion',
18
+ parentKind: 'discussion',
19
+ owner: 'dfosco',
20
+ repo: 'storyboard',
21
+ number: 99,
22
+ })
23
+ })
24
+
25
+ it('classifies issue comment URLs', () => {
26
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#issuecomment-345')).toEqual({
27
+ kind: 'comment',
28
+ parentKind: 'issue',
29
+ owner: 'dfosco',
30
+ repo: 'storyboard',
31
+ number: 12,
32
+ commentId: 345,
33
+ })
34
+ })
35
+
36
+ it('classifies discussion comment URLs', () => {
37
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toEqual({
38
+ kind: 'comment',
39
+ parentKind: 'discussion',
40
+ owner: 'dfosco',
41
+ repo: 'storyboard',
42
+ number: 99,
43
+ commentId: 888,
44
+ })
45
+ })
46
+
47
+ it('classifies pull request URLs', () => {
48
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/pull/12')).toEqual({
49
+ kind: 'pull_request',
50
+ parentKind: 'pull_request',
51
+ owner: 'dfosco',
52
+ repo: 'storyboard',
53
+ number: 12,
54
+ })
55
+ })
56
+
57
+ it('rejects unsupported paths and hashes', () => {
58
+ expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#random')).toBeNull()
59
+ expect(parseGitHubUrl('https://example.com/dfosco/storyboard/issues/12')).toBeNull()
60
+ expect(parseGitHubUrl('not a url')).toBeNull()
61
+ })
62
+ })
63
+
64
+ describe('isGitHubEmbedUrl', () => {
65
+ it('returns true for supported GitHub URLs', () => {
66
+ expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/issues/12')).toBe(true)
67
+ expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/discussions/99#discussioncomment-888')).toBe(true)
68
+ expect(isGitHubEmbedUrl('https://github.com/dfosco/storyboard/pull/1')).toBe(true)
69
+ })
70
+
71
+ it('returns false for unsupported URLs', () => {
72
+ expect(isGitHubEmbedUrl('https://example.com')).toBe(false)
73
+ })
74
+ })
@@ -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
+ }