@dfosco/storyboard-react 4.0.0-beta.8 → 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,165 @@
1
+ .container {
2
+ position: relative;
3
+ font-size: 13px;
4
+ }
5
+
6
+ .trigger {
7
+ display: inline-flex;
8
+ align-items: center;
9
+ gap: 6px;
10
+ padding: 5px 10px;
11
+ border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
12
+ border-radius: 6px;
13
+ background: var(--bgColor-default, #fff);
14
+ color: var(--fgColor-default, #1f2328);
15
+ cursor: pointer;
16
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
17
+ transition: border-color 0.15s, box-shadow 0.15s;
18
+ line-height: 1;
19
+ }
20
+
21
+ .trigger:hover {
22
+ border-color: var(--borderColor-emphasis, rgba(0, 0, 0, 0.3));
23
+ box-shadow: 0 1px 5px rgba(0, 0, 0, 0.12);
24
+ }
25
+
26
+ .label {
27
+ font-weight: 600;
28
+ max-width: 200px;
29
+ overflow: hidden;
30
+ text-overflow: ellipsis;
31
+ white-space: nowrap;
32
+ }
33
+
34
+ .badge {
35
+ font-size: 11px;
36
+ color: var(--fgColor-muted, #656d76);
37
+ font-variant-numeric: tabular-nums;
38
+ }
39
+
40
+ .chevron {
41
+ color: var(--fgColor-muted, #656d76);
42
+ transition: transform 0.15s;
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ .chevronOpen {
47
+ transform: rotate(180deg);
48
+ }
49
+
50
+ .menu {
51
+ position: absolute;
52
+ top: calc(100% + 4px);
53
+ left: 0;
54
+ min-width: 180px;
55
+ max-width: 300px;
56
+ max-height: 320px;
57
+ overflow-y: auto;
58
+ margin: 0;
59
+ padding: 4px;
60
+ list-style: none;
61
+ background: var(--bgColor-default, #fff);
62
+ border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
63
+ border-radius: 8px;
64
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
65
+ }
66
+
67
+ .item {
68
+ padding: 6px 10px;
69
+ border-radius: 4px;
70
+ cursor: pointer;
71
+ white-space: nowrap;
72
+ overflow: hidden;
73
+ text-overflow: ellipsis;
74
+ color: var(--fgColor-default, #1f2328);
75
+ }
76
+
77
+ .item:hover {
78
+ background: var(--bgColor-muted, #f6f8fa);
79
+ }
80
+
81
+ .item:focus-visible {
82
+ outline: 2px solid var(--focus-outlineColor, #0969da);
83
+ outline-offset: -2px;
84
+ }
85
+
86
+ .itemActive {
87
+ font-weight: 600;
88
+ background: var(--bgColor-accent-muted, #ddf4ff);
89
+ }
90
+
91
+ .itemActive:hover {
92
+ background: var(--bgColor-accent-muted, #ddf4ff);
93
+ }
94
+
95
+ .separator {
96
+ height: 1px;
97
+ background: var(--borderColor-default, rgba(0, 0, 0, 0.15));
98
+ margin: 4px 8px;
99
+ list-style: none;
100
+ }
101
+
102
+ .addItem {
103
+ padding: 6px 10px;
104
+ border-radius: 4px;
105
+ cursor: pointer;
106
+ white-space: nowrap;
107
+ color: var(--fgColor-muted, #656d76);
108
+ font-size: 12px;
109
+ }
110
+
111
+ .addItem:hover {
112
+ background: var(--bgColor-muted, #f6f8fa);
113
+ color: var(--fgColor-default, #1f2328);
114
+ }
115
+
116
+ .addForm {
117
+ display: flex;
118
+ gap: 4px;
119
+ padding: 4px 6px;
120
+ list-style: none;
121
+ }
122
+
123
+ .addInput {
124
+ flex: 1;
125
+ min-width: 0;
126
+ padding: 4px 8px;
127
+ border: 1px solid var(--borderColor-default, rgba(0, 0, 0, 0.15));
128
+ border-radius: 4px;
129
+ font-size: 12px;
130
+ font-family: inherit;
131
+ outline: none;
132
+ }
133
+
134
+ .addInput:focus {
135
+ border-color: var(--focus-outlineColor, #0969da);
136
+ box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
137
+ }
138
+
139
+ .addSubmit {
140
+ padding: 4px 10px;
141
+ border: none;
142
+ border-radius: 4px;
143
+ background: var(--bgColor-accent-emphasis, #0969da);
144
+ color: #fff;
145
+ font-size: 12px;
146
+ font-family: inherit;
147
+ cursor: pointer;
148
+ white-space: nowrap;
149
+ }
150
+
151
+ .addSubmit:hover {
152
+ opacity: 0.9;
153
+ }
154
+
155
+ .addSubmit:disabled {
156
+ opacity: 0.5;
157
+ cursor: default;
158
+ }
159
+
160
+ .successMsg {
161
+ padding: 4px 12px 6px;
162
+ font-size: 11px;
163
+ color: #1a7f37;
164
+ font-weight: 500;
165
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import PageSelector from './PageSelector.jsx'
4
+
5
+ const PAGES = [
6
+ { name: 'research/interviews', route: '/canvas/research/interviews', title: 'Interviews' },
7
+ { name: 'research/surveys', route: '/canvas/research/surveys', title: 'Surveys' },
8
+ { name: 'research/analysis', route: '/canvas/research/analysis', title: 'Analysis' },
9
+ ]
10
+
11
+ describe('PageSelector', () => {
12
+ beforeEach(() => {
13
+ // Reset location mock
14
+ delete window.location
15
+ window.location = { href: '' }
16
+ })
17
+
18
+ it('renders nothing when fewer than 2 pages', () => {
19
+ const { container } = render(<PageSelector currentName="solo" pages={[{ name: 'solo', route: '/canvas/solo', title: 'Solo' }]} />)
20
+ expect(container.innerHTML).toBe('')
21
+ })
22
+
23
+ it('renders nothing when pages is empty', () => {
24
+ const { container } = render(<PageSelector currentName="foo" pages={[]} />)
25
+ expect(container.innerHTML).toBe('')
26
+ })
27
+
28
+ it('shows current page label and page count', () => {
29
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
30
+ expect(screen.getByText('Interviews')).toBeTruthy()
31
+ expect(screen.getByText('1/3')).toBeTruthy()
32
+ })
33
+
34
+ it('shows correct index for non-first page', () => {
35
+ render(<PageSelector currentName="research/surveys" pages={PAGES} />)
36
+ expect(screen.getByText('Surveys')).toBeTruthy()
37
+ expect(screen.getByText('2/3')).toBeTruthy()
38
+ })
39
+
40
+ it('opens dropdown on click and shows all pages', () => {
41
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
42
+ const trigger = screen.getByTitle('Switch canvas page')
43
+ fireEvent.click(trigger)
44
+
45
+ const options = screen.getAllByRole('option')
46
+ expect(options).toHaveLength(3)
47
+ expect(options[0].textContent).toBe('Interviews')
48
+ expect(options[1].textContent).toBe('Surveys')
49
+ expect(options[2].textContent).toBe('Analysis')
50
+ })
51
+
52
+ it('marks the current page as active', () => {
53
+ render(<PageSelector currentName="research/surveys" pages={PAGES} />)
54
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
55
+
56
+ const options = screen.getAllByRole('option')
57
+ expect(options[1].getAttribute('aria-selected')).toBe('true')
58
+ expect(options[0].getAttribute('aria-selected')).toBe('false')
59
+ })
60
+
61
+ it('navigates to selected page', () => {
62
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
63
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
64
+ // Click the option in the menu (not the trigger label)
65
+ const options = screen.getAllByRole('option')
66
+ fireEvent.click(options[1]) // Surveys
67
+
68
+ expect(window.location.href).toContain('/canvas/research/surveys')
69
+ })
70
+
71
+ it('closes dropdown on Escape', () => {
72
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
73
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
74
+ expect(screen.queryByRole('listbox')).toBeTruthy()
75
+
76
+ fireEvent.keyDown(document, { key: 'Escape' })
77
+ expect(screen.queryByRole('listbox')).toBeNull()
78
+ })
79
+
80
+ it('closes dropdown on outside click', () => {
81
+ render(
82
+ <div>
83
+ <PageSelector currentName="research/interviews" pages={PAGES} />
84
+ <span data-testid="outside">Outside</span>
85
+ </div>
86
+ )
87
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
88
+ expect(screen.queryByRole('listbox')).toBeTruthy()
89
+
90
+ fireEvent.mouseDown(screen.getByTestId('outside'))
91
+ expect(screen.queryByRole('listbox')).toBeNull()
92
+ })
93
+
94
+ it('does not navigate when selecting the current page', () => {
95
+ render(<PageSelector currentName="research/interviews" pages={PAGES} />)
96
+ fireEvent.click(screen.getByTitle('Switch canvas page'))
97
+ // Click the current page option
98
+ const options = screen.getAllByRole('option')
99
+ fireEvent.click(options[0]) // Interviews (current)
100
+
101
+ // location.href was set to '' initially, should remain unchanged
102
+ expect(window.location.href).toBe('')
103
+ })
104
+ })
@@ -28,22 +28,36 @@ export function createCanvas(data) {
28
28
  return request('/create', 'POST', data)
29
29
  }
30
30
 
31
- export function updateCanvas(name, { widgets, sources, settings }) {
32
- return request('/update', 'PUT', { name, widgets, sources, settings })
31
+ export function updateCanvas(canvasId, { widgets, sources, settings }) {
32
+ return request('/update', 'PUT', { name: canvasId, widgets, sources, settings })
33
33
  }
34
34
 
35
- export function addWidget(name, { type, props, position }) {
36
- return request('/widget', 'POST', { name, type, props, position })
35
+ export function addWidget(canvasId, { type, props, position }) {
36
+ return request('/widget', 'POST', { name: canvasId, type, props, position })
37
37
  }
38
38
 
39
- export function removeWidget(name, widgetId) {
40
- return request('/widget', 'DELETE', { name, widgetId })
39
+ export function removeWidget(canvasId, widgetId) {
40
+ return request('/widget', 'DELETE', { name: canvasId, widgetId })
41
41
  }
42
42
 
43
- export function uploadImage(dataUrl, canvasName) {
44
- return request('/image', 'POST', { dataUrl, canvasName })
43
+ export function uploadImage(dataUrl, canvasId, filename) {
44
+ const body = { dataUrl, canvasName: canvasId }
45
+ if (filename) body.filename = filename
46
+ return request('/image', 'POST', body)
45
47
  }
46
48
 
47
49
  export function toggleImagePrivacy(filename) {
48
50
  return request('/image/toggle-private', 'POST', { filename })
49
51
  }
52
+
53
+ export function getCanvas(canvasId) {
54
+ return request(`/read?name=${encodeURIComponent(canvasId)}`, 'GET')
55
+ }
56
+
57
+ export function checkGitHubCliAvailable() {
58
+ return request('/github/available', 'GET')
59
+ }
60
+
61
+ export function fetchGitHubEmbed(url) {
62
+ return request('/github/embed', 'POST', { url })
63
+ }
@@ -1,74 +1,118 @@
1
1
  export function getCanvasPrimerAttrs(theme) {
2
- if (String(theme || 'light') === 'dark_dimmed') {
3
- return {
4
- 'data-color-mode': 'dark',
5
- 'data-dark-theme': 'dark_dimmed',
6
- 'data-light-theme': 'light',
7
- }
8
- }
9
- if (String(theme || 'light').startsWith('dark')) {
2
+ const value = String(theme || 'light')
3
+ if (value.startsWith('dark')) {
10
4
  return {
11
5
  'data-color-mode': 'dark',
12
- 'data-dark-theme': 'dark',
6
+ 'data-dark-theme': value,
13
7
  'data-light-theme': 'light',
14
8
  }
15
9
  }
16
10
  return {
17
11
  'data-color-mode': 'light',
18
12
  'data-dark-theme': 'dark',
19
- 'data-light-theme': 'light',
13
+ 'data-light-theme': value.startsWith('light') ? value : 'light',
20
14
  }
21
15
  }
22
16
 
23
- export function getCanvasThemeVars(theme) {
24
- const value = String(theme || 'light')
25
- if (value === 'dark_dimmed') {
26
- return {
27
- '--sb--canvas-bg': '#22272e',
28
- '--bgColor-default': '#22272e',
29
- '--bgColor-muted': '#22272e',
30
- '--bgColor-neutral-muted': 'rgba(99, 110, 123, 0.3)',
31
- '--bgColor-accent-emphasis': '#316dca',
32
- '--tc-bg-muted': '#22272e',
33
- '--tc-dot-color': 'rgba(205, 217, 229, 0.22)',
34
- '--overlay-backdrop-bgColor': 'rgba(205, 217, 229, 0.22)',
35
- '--fgColor-muted': '#768390',
36
- '--fgColor-default': '#adbac7',
37
- '--fgColor-onEmphasis': '#ffffff',
38
- '--borderColor-default': '#444c56',
39
- '--borderColor-muted': '#545d68',
40
- }
41
- }
42
- if (value.startsWith('dark')) {
43
- return {
44
- '--sb--canvas-bg': '#161b22',
45
- '--bgColor-default': '#161b22',
46
- '--bgColor-muted': '#161b22',
47
- '--bgColor-neutral-muted': 'rgba(110, 118, 129, 0.2)',
48
- '--bgColor-accent-emphasis': '#2f81f7',
49
- '--tc-bg-muted': '#161b22',
50
- '--tc-dot-color': 'rgba(255, 255, 255, 0.1)',
51
- '--overlay-backdrop-bgColor': 'rgba(255, 255, 255, 0.1)',
52
- '--fgColor-muted': '#8b949e',
53
- '--fgColor-default': '#e6edf3',
54
- '--fgColor-onEmphasis': '#ffffff',
55
- '--borderColor-default': '#30363d',
56
- '--borderColor-muted': '#30363d',
57
- }
58
- }
59
- return {
17
+ /**
18
+ * Per-theme canvas CSS custom properties sourced from @primer/primitives.
19
+ * Each theme gets its own entry so high-contrast, colorblind, and dimmed
20
+ * variants all render with the correct background, dot, and text colors.
21
+ */
22
+ const THEME_VARS = {
23
+ light: {
60
24
  '--sb--canvas-bg': '#f6f8fa',
61
25
  '--bgColor-default': '#ffffff',
26
+ '--bgColor-muted': '#f6f8fa',
27
+ '--bgColor-neutral-muted': '#818b981f',
28
+ '--bgColor-accent-emphasis': '#0969da',
62
29
  '--tc-bg-muted': '#f6f8fa',
63
30
  '--tc-dot-color': 'rgba(0, 0, 0, 0.08)',
64
31
  '--overlay-backdrop-bgColor': 'rgba(0, 0, 0, 0.08)',
32
+ '--fgColor-muted': '#59636e',
33
+ '--fgColor-default': '#1f2328',
34
+ '--fgColor-onEmphasis': '#ffffff',
35
+ '--borderColor-default': '#d1d9e0',
36
+ '--borderColor-muted': '#d1d9e0b3',
37
+ },
38
+ light_colorblind: {
39
+ '--sb--canvas-bg': '#f6f8fa',
40
+ '--bgColor-default': '#ffffff',
65
41
  '--bgColor-muted': '#f6f8fa',
66
- '--bgColor-neutral-muted': '#eaeef2',
67
- '--bgColor-accent-emphasis': '#2f81f7',
68
- '--fgColor-muted': '#656d76',
42
+ '--bgColor-neutral-muted': '#818b981f',
43
+ '--bgColor-accent-emphasis': '#0969da',
44
+ '--tc-bg-muted': '#f6f8fa',
45
+ '--tc-dot-color': 'rgba(0, 0, 0, 0.08)',
46
+ '--overlay-backdrop-bgColor': 'rgba(0, 0, 0, 0.08)',
47
+ '--fgColor-muted': '#59636e',
69
48
  '--fgColor-default': '#1f2328',
70
49
  '--fgColor-onEmphasis': '#ffffff',
71
50
  '--borderColor-default': '#d1d9e0',
72
- '--borderColor-muted': '#d8dee4',
73
- }
51
+ '--borderColor-muted': '#d1d9e0b3',
52
+ },
53
+ dark: {
54
+ '--sb--canvas-bg': '#151b23',
55
+ '--bgColor-default': '#0d1117',
56
+ '--bgColor-muted': '#151b23',
57
+ '--bgColor-neutral-muted': '#656c7633',
58
+ '--bgColor-accent-emphasis': '#1f6feb',
59
+ '--tc-bg-muted': '#151b23',
60
+ '--tc-dot-color': 'rgba(255, 255, 255, 0.1)',
61
+ '--overlay-backdrop-bgColor': 'rgba(255, 255, 255, 0.1)',
62
+ '--fgColor-muted': '#9198a1',
63
+ '--fgColor-default': '#f0f6fc',
64
+ '--fgColor-onEmphasis': '#ffffff',
65
+ '--borderColor-default': '#3d444d',
66
+ '--borderColor-muted': '#3d444db3',
67
+ },
68
+ dark_dimmed: {
69
+ '--sb--canvas-bg': '#262c36',
70
+ '--bgColor-default': '#212830',
71
+ '--bgColor-muted': '#262c36',
72
+ '--bgColor-neutral-muted': '#656c7633',
73
+ '--bgColor-accent-emphasis': '#316dca',
74
+ '--tc-bg-muted': '#262c36',
75
+ '--tc-dot-color': 'rgba(209, 215, 224, 0.18)',
76
+ '--overlay-backdrop-bgColor': 'rgba(209, 215, 224, 0.18)',
77
+ '--fgColor-muted': '#9198a1',
78
+ '--fgColor-default': '#d1d7e0',
79
+ '--fgColor-onEmphasis': '#f0f6fc',
80
+ '--borderColor-default': '#3d444d',
81
+ '--borderColor-muted': '#3d444db3',
82
+ },
83
+ dark_colorblind: {
84
+ '--sb--canvas-bg': '#151b23',
85
+ '--bgColor-default': '#0d1117',
86
+ '--bgColor-muted': '#151b23',
87
+ '--bgColor-neutral-muted': '#656c7633',
88
+ '--bgColor-accent-emphasis': '#1f6feb',
89
+ '--tc-bg-muted': '#151b23',
90
+ '--tc-dot-color': 'rgba(255, 255, 255, 0.1)',
91
+ '--overlay-backdrop-bgColor': 'rgba(255, 255, 255, 0.1)',
92
+ '--fgColor-muted': '#9198a1',
93
+ '--fgColor-default': '#f0f6fc',
94
+ '--fgColor-onEmphasis': '#ffffff',
95
+ '--borderColor-default': '#3d444d',
96
+ '--borderColor-muted': '#3d444db3',
97
+ },
98
+ dark_high_contrast: {
99
+ '--sb--canvas-bg': '#151b23',
100
+ '--bgColor-default': '#010409',
101
+ '--bgColor-muted': '#151b23',
102
+ '--bgColor-neutral-muted': '#212830',
103
+ '--bgColor-accent-emphasis': '#194fb1',
104
+ '--tc-bg-muted': '#151b23',
105
+ '--tc-dot-color': 'rgba(183, 189, 200, 0.25)',
106
+ '--overlay-backdrop-bgColor': 'rgba(183, 189, 200, 0.25)',
107
+ '--fgColor-muted': '#b7bdc8',
108
+ '--fgColor-default': '#ffffff',
109
+ '--fgColor-onEmphasis': '#ffffff',
110
+ '--borderColor-default': '#b7bdc8',
111
+ '--borderColor-muted': '#b7bdc8',
112
+ },
113
+ }
114
+
115
+ export function getCanvasThemeVars(theme) {
116
+ const value = String(theme || 'light')
117
+ return THEME_VARS[value] || THEME_VARS.light
74
118
  }
@@ -1,17 +1,36 @@
1
1
  /**
2
2
  * Canvas Component Isolate — iframe entry point.
3
3
  *
4
- * Renders a single named export from a .canvas.jsx module inside an
4
+ * Renders a single named export from a .story.jsx module inside an
5
5
  * isolated document. The parent CanvasPage embeds this via an iframe
6
6
  * so a broken component cannot crash the entire canvas.
7
7
  *
8
8
  * Query params:
9
- * module — absolute or base-relative path to the .canvas.jsx file
9
+ * module — absolute or base-relative path to the .story.jsx file
10
10
  * export — the named export to render
11
11
  * theme — canvas theme (light / dark / dark_dimmed)
12
12
  */
13
13
  import { createElement, Component as ReactComponent } from 'react'
14
14
  import { createRoot } from 'react-dom/client'
15
+ import { ThemeProvider, BaseStyles } from '@primer/react'
16
+
17
+ // ── Primer Primitives CSS (required for CSS variables) ──────────────
18
+ import '@primer/primitives/dist/css/base/size/size.css'
19
+ import '@primer/primitives/dist/css/base/typography/typography.css'
20
+ import '@primer/primitives/dist/css/base/motion/motion.css'
21
+ import '@primer/primitives/dist/css/functional/size/border.css'
22
+ import '@primer/primitives/dist/css/functional/size/breakpoints.css'
23
+ import '@primer/primitives/dist/css/functional/size/size-coarse.css'
24
+ import '@primer/primitives/dist/css/functional/size/size-fine.css'
25
+ import '@primer/primitives/dist/css/functional/size/size.css'
26
+ import '@primer/primitives/dist/css/functional/size/viewport.css'
27
+ import '@primer/primitives/dist/css/functional/typography/typography.css'
28
+ import '@primer/primitives/dist/css/functional/themes/light.css'
29
+ import '@primer/primitives/dist/css/functional/themes/light-colorblind.css'
30
+ import '@primer/primitives/dist/css/functional/themes/dark.css'
31
+ import '@primer/primitives/dist/css/functional/themes/dark-colorblind.css'
32
+ import '@primer/primitives/dist/css/functional/themes/dark-high-contrast.css'
33
+ import '@primer/primitives/dist/css/functional/themes/dark-dimmed.css'
15
34
 
16
35
  // ── Error Boundary ──────────────────────────────────────────────────
17
36
  class IsolateErrorBoundary extends ReactComponent {
@@ -62,6 +81,9 @@ const modulePath = params.get('module')
62
81
  const exportName = params.get('export')
63
82
  const theme = params.get('theme') || 'light'
64
83
 
84
+ // Map theme to Primer colorMode
85
+ const colorMode = theme.startsWith('dark') ? 'night' : 'day'
86
+
65
87
  // Apply theme to document for Primer / CSS-var inheritance
66
88
  document.documentElement.setAttribute('data-color-mode', theme.startsWith('dark') ? 'dark' : 'light')
67
89
  document.documentElement.setAttribute('data-dark-theme', theme.startsWith('dark') ? theme : '')
@@ -75,9 +97,9 @@ async function mount() {
75
97
  return
76
98
  }
77
99
 
78
- // Validate: only allow .canvas.jsx modules
79
- if (!modulePath.endsWith('.canvas.jsx')) {
80
- root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .canvas.jsx files are allowed'))
100
+ // Validate: only allow .story.{jsx,tsx} modules
101
+ if (!modulePath.match(/\.story\.(jsx|tsx)$/)) {
102
+ root.render(createElement('div', { style: errorStyle }, 'Invalid module path — only .story.jsx/.tsx files are allowed'))
81
103
  return
82
104
  }
83
105
 
@@ -91,8 +113,12 @@ async function mount() {
91
113
  }
92
114
 
93
115
  root.render(
94
- createElement(IsolateErrorBoundary, { name: exportName },
95
- createElement(Component),
116
+ createElement(ThemeProvider, { colorMode },
117
+ createElement(BaseStyles, null,
118
+ createElement(IsolateErrorBoundary, { name: exportName },
119
+ createElement(Component),
120
+ ),
121
+ ),
96
122
  ),
97
123
  )
98
124
  } catch (err) {
@@ -31,11 +31,11 @@ export function resolveCanvasModuleImport(modulePath, baseUrl = import.meta.env?
31
31
  * Uses build-time data for static config (routes, JSX path), but fetches
32
32
  * fresh widget data from the server to pick up persisted edits.
33
33
  *
34
- * @param {string} name - Canvas name as indexed by the data plugin
34
+ * @param {string} canvasId - Canonical canvas ID as indexed by the data plugin
35
35
  * @returns {{ canvas: object|null, jsxExports: object|null, jsxError: boolean, loading: boolean }}
36
36
  */
37
- export function useCanvas(name) {
38
- const buildTimeCanvas = useMemo(() => getCanvasData(name), [name])
37
+ export function useCanvas(canvasId) {
38
+ const buildTimeCanvas = useMemo(() => getCanvasData(canvasId), [canvasId])
39
39
  const [canvas, setCanvas] = useState(buildTimeCanvas)
40
40
  const [jsxExports, setJsxExports] = useState(null)
41
41
  const [jsxError, setJsxError] = useState(false)
@@ -50,7 +50,7 @@ export function useCanvas(name) {
50
50
  }
51
51
 
52
52
  setLoading(true)
53
- fetchCanvasFromServer(name).then((fresh) => {
53
+ fetchCanvasFromServer(canvasId).then((fresh) => {
54
54
  if (fresh) {
55
55
  // Merge: use server data for widgets/sources, keep build-time for _route/_jsxModule
56
56
  setCanvas({ ...buildTimeCanvas, ...fresh })
@@ -59,7 +59,7 @@ export function useCanvas(name) {
59
59
  }
60
60
  setLoading(false)
61
61
  })
62
- }, [name, buildTimeCanvas])
62
+ }, [canvasId, buildTimeCanvas])
63
63
 
64
64
  const jsxModule = canvas?._jsxModule
65
65
  const jsxImport = canvas?._jsxImport
@@ -99,8 +99,9 @@ export function useCanvas(name) {
99
99
  if (!import.meta.hot || !buildTimeCanvas) return
100
100
 
101
101
  const handleCanvasFileChanged = ({ data }) => {
102
- if (!data || data.name !== name) return
103
- fetchCanvasFromServer(name).then((fresh) => {
102
+ const eventId = data?.canvasId || data?.name
103
+ if (!data || eventId !== canvasId) return
104
+ fetchCanvasFromServer(canvasId).then((fresh) => {
104
105
  if (fresh) {
105
106
  setCanvas((prev) => ({ ...(prev || buildTimeCanvas), ...fresh }))
106
107
  }
@@ -111,7 +112,7 @@ export function useCanvas(name) {
111
112
  return () => {
112
113
  import.meta.hot.off('storyboard:canvas-file-changed', handleCanvasFileChanged)
113
114
  }
114
- }, [name, buildTimeCanvas])
115
+ }, [canvasId, buildTimeCanvas])
115
116
 
116
117
  return { canvas, jsxExports, jsxError, loading }
117
118
  }
@@ -3,14 +3,14 @@ import { resolveCanvasModuleImport } from './useCanvas.js'
3
3
 
4
4
  describe('resolveCanvasModuleImport', () => {
5
5
  it('prefixes root-relative module paths with BASE_URL', () => {
6
- expect(resolveCanvasModuleImport('/src/canvas/button-patterns.canvas.jsx', '/feature-branch/')).toBe(
7
- '/feature-branch/src/canvas/button-patterns.canvas.jsx',
6
+ expect(resolveCanvasModuleImport('/src/canvas/button-patterns.story.jsx', '/feature-branch/')).toBe(
7
+ '/feature-branch/src/canvas/button-patterns.story.jsx',
8
8
  )
9
9
  })
10
10
 
11
11
  it('keeps root-relative paths unchanged when BASE_URL is root', () => {
12
- expect(resolveCanvasModuleImport('/src/canvas/button-patterns.canvas.jsx', '/')).toBe(
13
- '/src/canvas/button-patterns.canvas.jsx',
12
+ expect(resolveCanvasModuleImport('/src/canvas/button-patterns.story.jsx', '/')).toBe(
13
+ '/src/canvas/button-patterns.story.jsx',
14
14
  )
15
15
  })
16
16