@dfosco/storyboard-react 4.0.0-beta.9 → 4.0.0

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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/AuthModal/AuthModal.jsx +134 -0
  3. package/src/AuthModal/AuthModal.module.css +221 -0
  4. package/src/BranchBar/BranchBar.jsx +56 -0
  5. package/src/BranchBar/BranchBar.module.css +230 -0
  6. package/src/BranchBar/useBranches.js +79 -0
  7. package/src/CommandPalette/CommandPalette.jsx +936 -0
  8. package/src/CommandPalette/CreateDialog.jsx +219 -0
  9. package/src/CommandPalette/command-palette.css +111 -0
  10. package/src/Icon.jsx +180 -0
  11. package/src/Viewfinder.jsx +1104 -57
  12. package/src/Viewfinder.module.css +1107 -149
  13. package/src/canvas/CanvasControls.jsx +51 -2
  14. package/src/canvas/CanvasControls.module.css +31 -0
  15. package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
  16. package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
  17. package/src/canvas/CanvasPage.jsx +807 -251
  18. package/src/canvas/CanvasPage.module.css +98 -50
  19. package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
  20. package/src/canvas/CanvasToolbar.jsx +2 -2
  21. package/src/canvas/MarqueeOverlay.jsx +20 -0
  22. package/src/canvas/PageSelector.jsx +239 -0
  23. package/src/canvas/PageSelector.module.css +165 -0
  24. package/src/canvas/PageSelector.test.jsx +104 -0
  25. package/src/canvas/canvasApi.js +22 -8
  26. package/src/canvas/canvasTheme.js +96 -52
  27. package/src/canvas/componentIsolate.jsx +33 -7
  28. package/src/canvas/useCanvas.js +9 -8
  29. package/src/canvas/useCanvas.test.js +4 -4
  30. package/src/canvas/useMarqueeSelect.js +187 -0
  31. package/src/canvas/useMarqueeSelect.test.js +78 -0
  32. package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
  33. package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
  34. package/src/canvas/widgets/ComponentWidget.jsx +42 -10
  35. package/src/canvas/widgets/ComponentWidget.module.css +6 -5
  36. package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
  37. package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
  38. package/src/canvas/widgets/LinkPreview.jsx +297 -11
  39. package/src/canvas/widgets/LinkPreview.module.css +386 -18
  40. package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
  41. package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
  42. package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
  43. package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
  44. package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
  45. package/src/canvas/widgets/StickyNote.module.css +5 -0
  46. package/src/canvas/widgets/StickyNote.test.jsx +9 -9
  47. package/src/canvas/widgets/StoryWidget.jsx +277 -0
  48. package/src/canvas/widgets/StoryWidget.module.css +211 -0
  49. package/src/canvas/widgets/WidgetChrome.jsx +76 -20
  50. package/src/canvas/widgets/WidgetChrome.module.css +2 -6
  51. package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
  52. package/src/canvas/widgets/codepenUrl.js +75 -0
  53. package/src/canvas/widgets/codepenUrl.test.js +76 -0
  54. package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
  55. package/src/canvas/widgets/embedOverlay.module.css +35 -0
  56. package/src/canvas/widgets/embedTheme.js +138 -39
  57. package/src/canvas/widgets/githubUrl.js +82 -0
  58. package/src/canvas/widgets/githubUrl.test.js +74 -0
  59. package/src/canvas/widgets/iframeDevLogs.js +49 -0
  60. package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
  61. package/src/canvas/widgets/index.js +4 -0
  62. package/src/canvas/widgets/pasteRules.js +295 -0
  63. package/src/canvas/widgets/pasteRules.test.js +474 -0
  64. package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
  65. package/src/canvas/widgets/widgetConfig.js +16 -5
  66. package/src/canvas/widgets/widgetConfig.test.js +34 -12
  67. package/src/context.jsx +145 -16
  68. package/src/hooks/useSceneData.js +4 -2
  69. package/src/hooks/useThemeState.js +61 -0
  70. package/src/hooks/useThemeState.test.js +66 -0
  71. package/src/index.js +10 -0
  72. package/src/story/StoryPage.jsx +117 -0
  73. package/src/story/StoryPage.module.css +18 -0
  74. package/src/vite/data-plugin.js +348 -66
  75. 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.story.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,49 +1,148 @@
1
- export function getEmbedChromeVars(theme) {
2
- const value = String(theme || 'light')
3
- if (value === 'dark_dimmed') {
4
- return {
5
- '--bgColor-default': '#22272e',
6
- '--bgColor-muted': '#2d333b',
7
- '--bgColor-neutral-muted': 'rgba(99, 110, 123, 0.3)',
8
- '--fgColor-default': '#adbac7',
9
- '--fgColor-muted': '#768390',
10
- '--fgColor-onEmphasis': '#ffffff',
11
- '--borderColor-default': '#444c56',
12
- '--borderColor-muted': '#545d68',
13
- '--bgColor-accent-emphasis': '#316dca',
14
- '--trigger-bg': '#2d333b',
15
- '--trigger-bg-hover': '#373e47',
16
- '--trigger-border': '#444c56',
1
+ /**
2
+ * Resolve the effective canvas theme from the core theme store.
3
+ * Respects the canvas-specific theme sync toggle.
4
+ */
5
+ import { getTheme, getThemeSyncTargets } from '@dfosco/storyboard-core'
6
+
7
+ function resolveSystem() {
8
+ if (typeof window === 'undefined') return 'light'
9
+ return window.matchMedia?.('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light'
10
+ }
11
+
12
+ export function resolveCanvasTheme() {
13
+ const sync = getThemeSyncTargets()
14
+ if (!sync.canvas) return 'light'
15
+ const theme = getTheme()
16
+ return theme === 'system' ? resolveSystem() : theme
17
+ }
18
+
19
+ /**
20
+ * Subscribe to canvas theme changes for embed widget snapshot switching.
21
+ *
22
+ * Reads `data-sb-canvas-theme` from the nearest ancestor set by CanvasPage.
23
+ * With canvas sync ON (the default), this attribute follows the global theme.
24
+ * Uses MutationObserver for immediate reaction + event backup.
25
+ */
26
+ export function subscribeCanvasTheme({ anchorRef, onTheme }) {
27
+ if (typeof onTheme !== 'function') return () => {}
28
+
29
+ let observed = null
30
+ let observer = null
31
+
32
+ function readAndEmit() {
33
+ const el = anchorRef?.current?.closest?.('[data-sb-canvas-theme]') || null
34
+ if (el !== observed) {
35
+ if (observer) observer.disconnect()
36
+ observer = null
37
+ observed = el
38
+ if (el && typeof MutationObserver !== 'undefined') {
39
+ observer = new MutationObserver(readAndEmit)
40
+ observer.observe(el, { attributes: true, attributeFilter: ['data-sb-canvas-theme'] })
41
+ }
17
42
  }
43
+ onTheme(el?.getAttribute('data-sb-canvas-theme') || 'light')
18
44
  }
19
- if (value.startsWith('dark')) {
20
- return {
21
- '--bgColor-default': '#161b22',
22
- '--bgColor-muted': '#21262d',
23
- '--bgColor-neutral-muted': 'rgba(110, 118, 129, 0.2)',
24
- '--fgColor-default': '#e6edf3',
25
- '--fgColor-muted': '#8b949e',
26
- '--fgColor-onEmphasis': '#ffffff',
27
- '--borderColor-default': '#30363d',
28
- '--borderColor-muted': '#30363d',
29
- '--bgColor-accent-emphasis': '#2f81f7',
30
- '--trigger-bg': '#21262d',
31
- '--trigger-bg-hover': '#30363d',
32
- '--trigger-border': '#30363d',
33
- }
45
+
46
+ readAndEmit()
47
+ document.addEventListener('storyboard:theme:changed', readAndEmit)
48
+
49
+ return () => {
50
+ document.removeEventListener('storyboard:theme:changed', readAndEmit)
51
+ if (observer) observer.disconnect()
34
52
  }
35
- return {
53
+ }
54
+
55
+ /**
56
+ * Per-theme embed chrome CSS custom properties sourced from @primer/primitives.
57
+ */
58
+ const EMBED_CHROME_VARS = {
59
+ light: {
36
60
  '--bgColor-default': '#ffffff',
37
61
  '--bgColor-muted': '#f6f8fa',
38
- '--bgColor-neutral-muted': '#eaeef2',
62
+ '--bgColor-neutral-muted': '#818b981f',
39
63
  '--fgColor-default': '#1f2328',
40
- '--fgColor-muted': '#656d76',
64
+ '--fgColor-muted': '#59636e',
41
65
  '--fgColor-onEmphasis': '#ffffff',
42
- '--borderColor-default': '#d0d7de',
43
- '--borderColor-muted': '#d8dee4',
44
- '--bgColor-accent-emphasis': '#2f81f7',
66
+ '--borderColor-default': '#d1d9e0',
67
+ '--borderColor-muted': '#d1d9e0b3',
68
+ '--bgColor-accent-emphasis': '#0969da',
45
69
  '--trigger-bg': '#f6f8fa',
46
70
  '--trigger-bg-hover': '#eaeef2',
47
- '--trigger-border': '#d0d7de',
48
- }
71
+ '--trigger-border': '#d1d9e0',
72
+ },
73
+ light_colorblind: {
74
+ '--bgColor-default': '#ffffff',
75
+ '--bgColor-muted': '#f6f8fa',
76
+ '--bgColor-neutral-muted': '#818b981f',
77
+ '--fgColor-default': '#1f2328',
78
+ '--fgColor-muted': '#59636e',
79
+ '--fgColor-onEmphasis': '#ffffff',
80
+ '--borderColor-default': '#d1d9e0',
81
+ '--borderColor-muted': '#d1d9e0b3',
82
+ '--bgColor-accent-emphasis': '#0969da',
83
+ '--trigger-bg': '#f6f8fa',
84
+ '--trigger-bg-hover': '#eaeef2',
85
+ '--trigger-border': '#d1d9e0',
86
+ },
87
+ dark: {
88
+ '--bgColor-default': '#0d1117',
89
+ '--bgColor-muted': '#151b23',
90
+ '--bgColor-neutral-muted': '#656c7633',
91
+ '--fgColor-default': '#f0f6fc',
92
+ '--fgColor-muted': '#9198a1',
93
+ '--fgColor-onEmphasis': '#ffffff',
94
+ '--borderColor-default': '#3d444d',
95
+ '--borderColor-muted': '#3d444db3',
96
+ '--bgColor-accent-emphasis': '#1f6feb',
97
+ '--trigger-bg': '#151b23',
98
+ '--trigger-bg-hover': '#212830',
99
+ '--trigger-border': '#3d444d',
100
+ },
101
+ dark_dimmed: {
102
+ '--bgColor-default': '#212830',
103
+ '--bgColor-muted': '#262c36',
104
+ '--bgColor-neutral-muted': '#656c7633',
105
+ '--fgColor-default': '#d1d7e0',
106
+ '--fgColor-muted': '#9198a1',
107
+ '--fgColor-onEmphasis': '#f0f6fc',
108
+ '--borderColor-default': '#3d444d',
109
+ '--borderColor-muted': '#3d444db3',
110
+ '--bgColor-accent-emphasis': '#316dca',
111
+ '--trigger-bg': '#262c36',
112
+ '--trigger-bg-hover': '#2d333b',
113
+ '--trigger-border': '#3d444d',
114
+ },
115
+ dark_colorblind: {
116
+ '--bgColor-default': '#0d1117',
117
+ '--bgColor-muted': '#151b23',
118
+ '--bgColor-neutral-muted': '#656c7633',
119
+ '--fgColor-default': '#f0f6fc',
120
+ '--fgColor-muted': '#9198a1',
121
+ '--fgColor-onEmphasis': '#ffffff',
122
+ '--borderColor-default': '#3d444d',
123
+ '--borderColor-muted': '#3d444db3',
124
+ '--bgColor-accent-emphasis': '#1f6feb',
125
+ '--trigger-bg': '#151b23',
126
+ '--trigger-bg-hover': '#212830',
127
+ '--trigger-border': '#3d444d',
128
+ },
129
+ dark_high_contrast: {
130
+ '--bgColor-default': '#010409',
131
+ '--bgColor-muted': '#151b23',
132
+ '--bgColor-neutral-muted': '#212830',
133
+ '--fgColor-default': '#ffffff',
134
+ '--fgColor-muted': '#b7bdc8',
135
+ '--fgColor-onEmphasis': '#ffffff',
136
+ '--borderColor-default': '#b7bdc8',
137
+ '--borderColor-muted': '#b7bdc8',
138
+ '--bgColor-accent-emphasis': '#194fb1',
139
+ '--trigger-bg': '#151b23',
140
+ '--trigger-bg-hover': '#212830',
141
+ '--trigger-border': '#b7bdc8',
142
+ },
143
+ }
144
+
145
+ export function getEmbedChromeVars(theme) {
146
+ const value = String(theme || 'light')
147
+ return EMBED_CHROME_VARS[value] || EMBED_CHROME_VARS.light
49
148
  }