@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
@@ -3,7 +3,7 @@
3
3
  display: flex;
4
4
  align-items: center;
5
5
  justify-content: center;
6
- min-height: 100vh;
6
+ min-height: calc(100vh - var(--sb-branch-bar-height, 0px));
7
7
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
8
8
  color: var(--fgColor-muted, #656d76);
9
9
  font-size: 16px;
@@ -15,8 +15,9 @@
15
15
  }
16
16
 
17
17
  .canvasScroll {
18
+ position: relative;
18
19
  width: 100vw;
19
- height: 100vh;
20
+ height: calc(100vh - var(--sb-branch-bar-height, 0px));
20
21
  overflow: auto;
21
22
  background-color: var(--sb--canvas-bg, var(--bgColor-muted, #f6f8fa));
22
23
  }
@@ -32,11 +33,32 @@
32
33
  min-height: 100%;
33
34
  }
34
35
 
36
+ /* GPU compositing hint during active zoom gestures — applied imperatively
37
+ via data-zooming attribute and removed on zoom-end to avoid permanent
38
+ memory pressure on the large canvas surface. */
39
+ .canvasZoom[data-zooming] {
40
+ will-change: transform;
41
+ }
42
+
35
43
  /* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
36
44
 
45
+ .canvasLogo {
46
+ width: 32px;
47
+ height: 32px;
48
+ background: var(--bgColor-emphasis, #313131);
49
+ border-radius: 6px;
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: center;
53
+ color: var(--fgColor-onEmphasis, #fff);
54
+ flex-shrink: 0;
55
+ text-decoration: none;
56
+ transform: rotate(-1deg);
57
+ }
58
+
37
59
  .canvasTitle {
38
60
  position: fixed;
39
- top: 12px;
61
+ top: calc(12px + var(--sb-branch-bar-height, 0px));
40
62
  left: 16px;
41
63
  z-index: 10;
42
64
  display: flex;
@@ -44,58 +66,14 @@
44
66
  gap: 8px;
45
67
  }
46
68
 
47
- .canvasTitleWrap {
48
- display: inline-grid;
49
- }
50
-
51
- .canvasTitleWrap > * {
52
- grid-area: 1 / 1;
53
- }
54
-
55
- .canvasTitleMeasure {
56
- visibility: hidden;
57
- white-space: pre;
58
- font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
59
- font-size: 14px;
60
- font-weight: 600;
61
- padding: 4px 8px;
62
- border: 1px solid transparent;
63
- min-width: 80px;
64
- pointer-events: none;
65
- }
66
-
67
- .canvasTitleInput {
69
+ .canvasTitleStatic {
68
70
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
69
71
  font-size: 14px;
70
72
  font-weight: 600;
71
73
  color: var(--fgColor-muted, #656d76);
72
- background: transparent;
73
- border: 1px solid transparent;
74
- border-radius: 6px;
75
- padding: 4px 8px;
76
74
  margin: 0;
77
- outline: none;
78
- width: 100%;
79
- min-width: 0;
80
- transition: border-color 150ms, background-color 150ms, color 150ms;
81
- }
82
-
83
- .canvasTitleInput:hover {
84
- color: var(--fgColor-default, #1f2328);
85
- border-color: var(--borderColor-default, #d1d9e0);
86
- background: var(--bgColor-default, #ffffff);
87
- }
88
-
89
- .canvasTitleInput:focus {
90
- color: var(--fgColor-default, #1f2328);
91
- border-color: var(--bgColor-accent-emphasis, #2f81f7);
92
- background: var(--bgColor-default, #ffffff);
93
- }
94
-
95
- .canvasTitleStatic {
96
- composes: canvasTitleInput;
97
- cursor: default;
98
- pointer-events: none;
75
+ padding: 4px 8px;
76
+ white-space: nowrap;
99
77
  }
100
78
 
101
79
  /* Remove tiny-canvas wrapper clipping — widgets handle their own overflow/radius */
@@ -103,6 +81,12 @@
103
81
  overflow: visible;
104
82
  }
105
83
 
84
+ /* Elevate stacking context for hovered/selected widgets so their chrome
85
+ (toolbar, menus, selection outline) renders above sibling widgets. */
86
+ :global(.tc-drag:has([data-tc-elevated])) {
87
+ z-index: 1;
88
+ }
89
+
106
90
  .localEditingLabel {
107
91
  display: inline-flex;
108
92
  align-items: center;
@@ -117,3 +101,67 @@
117
101
  pointer-events: none;
118
102
  user-select: none;
119
103
  }
104
+
105
+ .ghInstallBanner {
106
+ position: fixed;
107
+ left: 50%;
108
+ bottom: 12px;
109
+ transform: translateX(-50%);
110
+ z-index: 15;
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 10px;
114
+ padding: 8px 12px;
115
+ border-radius: 8px;
116
+ border: 1px solid var(--borderColor-default, #d1d9e0);
117
+ background: color-mix(in srgb, var(--bgColor-default, #ffffff) 88%, transparent);
118
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.16);
119
+ backdrop-filter: blur(8px);
120
+ font-size: 12px;
121
+ color: var(--fgColor-default, #1f2328);
122
+ }
123
+
124
+ .ghInstallBannerText {
125
+ white-space: nowrap;
126
+ }
127
+
128
+ .ghInstallBannerText code {
129
+ font-size: 11px;
130
+ padding: 1px 4px;
131
+ border-radius: 4px;
132
+ background: var(--bgColor-muted, #f6f8fa);
133
+ }
134
+
135
+ .ghInstallBannerLink {
136
+ color: var(--fgColor-accent, #0969da);
137
+ font-weight: 600;
138
+ text-decoration: none;
139
+ }
140
+
141
+ .ghInstallBannerLink:hover {
142
+ text-decoration: underline;
143
+ }
144
+
145
+ .ghInstallBannerDismiss {
146
+ border: 1px solid var(--borderColor-default, #d1d9e0);
147
+ background: var(--bgColor-default, #ffffff);
148
+ color: var(--fgColor-default, #1f2328);
149
+ border-radius: 6px;
150
+ font-size: 11px;
151
+ padding: 4px 8px;
152
+ cursor: pointer;
153
+ }
154
+
155
+ .ghInstallBannerDismiss:hover {
156
+ background: var(--bgColor-muted, #f6f8fa);
157
+ }
158
+
159
+ /* Marquee selection rectangle */
160
+ .marqueeRect {
161
+ position: absolute;
162
+ background: rgba(56, 132, 255, 0.12);
163
+ border: 1.5px solid rgba(56, 132, 255, 0.6);
164
+ border-radius: 2px;
165
+ pointer-events: none;
166
+ z-index: 9999;
167
+ }
@@ -103,6 +103,8 @@ vi.mock('./widgets/figmaUrl.js', () => ({
103
103
 
104
104
  vi.mock('./canvasApi.js', () => ({
105
105
  addWidget: vi.fn(),
106
+ checkGitHubCliAvailable: vi.fn(() => Promise.resolve({ available: true })),
107
+ fetchGitHubEmbed: vi.fn(() => Promise.resolve({ success: false })),
106
108
  updateCanvas: vi.fn(() => Promise.resolve({ success: true })),
107
109
  removeWidget: vi.fn(() => Promise.resolve({ success: true })),
108
110
  uploadImage: vi.fn(),
@@ -123,7 +125,7 @@ describe('CanvasPage multi-select', () => {
123
125
  })
124
126
 
125
127
  it('shift+click on select handle adds widget to selection', async () => {
126
- render(<CanvasPage name="test-canvas" />)
128
+ render(<CanvasPage canvasId="test-canvas" />)
127
129
 
128
130
  // Select first widget
129
131
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -138,7 +140,7 @@ describe('CanvasPage multi-select', () => {
138
140
  })
139
141
 
140
142
  it('shift+click on already selected widget removes it from selection', async () => {
141
- render(<CanvasPage name="test-canvas" />)
143
+ render(<CanvasPage canvasId="test-canvas" />)
142
144
 
143
145
  // Select both
144
146
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -155,7 +157,7 @@ describe('CanvasPage multi-select', () => {
155
157
  })
156
158
 
157
159
  it('normal click replaces multi-selection with single', async () => {
158
- render(<CanvasPage name="test-canvas" />)
160
+ render(<CanvasPage canvasId="test-canvas" />)
159
161
 
160
162
  // Multi-select
161
163
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -172,7 +174,7 @@ describe('CanvasPage multi-select', () => {
172
174
  })
173
175
 
174
176
  it('sets multiSelected on all selected widgets when multiple are selected', async () => {
175
- render(<CanvasPage name="test-canvas" />)
177
+ render(<CanvasPage canvasId="test-canvas" />)
176
178
 
177
179
  fireEvent.click(screen.getByTestId('select-w1'))
178
180
  fireEvent.click(screen.getByTestId('shift-select-w2'))
@@ -185,7 +187,7 @@ describe('CanvasPage multi-select', () => {
185
187
  })
186
188
 
187
189
  it('Escape clears all selection', async () => {
188
- render(<CanvasPage name="test-canvas" />)
190
+ render(<CanvasPage canvasId="test-canvas" />)
189
191
 
190
192
  fireEvent.click(screen.getByTestId('select-w1'))
191
193
  fireEvent.click(screen.getByTestId('shift-select-w2'))
@@ -199,7 +201,7 @@ describe('CanvasPage multi-select', () => {
199
201
  })
200
202
 
201
203
  it('Delete removes all selected widgets and calls updateCanvas', async () => {
202
- render(<CanvasPage name="test-canvas" />)
204
+ render(<CanvasPage canvasId="test-canvas" />)
203
205
 
204
206
  // Multi-select w1 and w2
205
207
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -224,7 +226,7 @@ describe('CanvasPage multi-select', () => {
224
226
  })
225
227
 
226
228
  it('single-select Delete uses removeWidget API', async () => {
227
- render(<CanvasPage name="test-canvas" />)
229
+ render(<CanvasPage canvasId="test-canvas" />)
228
230
 
229
231
  fireEvent.click(screen.getByTestId('select-w1'))
230
232
  fireEvent.keyDown(document, { key: 'Backspace' })
@@ -234,7 +236,7 @@ describe('CanvasPage multi-select', () => {
234
236
  })
235
237
 
236
238
  it('multi-select move applies delta to all selected widgets', async () => {
237
- render(<CanvasPage name="test-canvas" />)
239
+ render(<CanvasPage canvasId="test-canvas" />)
238
240
 
239
241
  // Multi-select w1 (100,100) and w2 (300,100)
240
242
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -265,7 +267,7 @@ describe('CanvasPage multi-select', () => {
265
267
  })
266
268
 
267
269
  it('multi-select drag captures peer articles on drag start', async () => {
268
- render(<CanvasPage name="test-canvas" />)
270
+ render(<CanvasPage canvasId="test-canvas" />)
269
271
 
270
272
  // Multi-select w1 and w2
271
273
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -288,7 +290,7 @@ describe('CanvasPage multi-select', () => {
288
290
  })
289
291
 
290
292
  it('multi-select drag preserves selection after drag end', async () => {
291
- render(<CanvasPage name="test-canvas" />)
293
+ render(<CanvasPage canvasId="test-canvas" />)
292
294
 
293
295
  // Multi-select w1 and w2
294
296
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -313,7 +315,7 @@ describe('CanvasPage multi-select', () => {
313
315
  })
314
316
 
315
317
  it('any selected widget can serve as drag handler for the group', async () => {
316
- render(<CanvasPage name="test-canvas" />)
318
+ render(<CanvasPage canvasId="test-canvas" />)
317
319
 
318
320
  // Multi-select w1 (100,100), w2 (300,100), w3 (500,200)
319
321
  fireEvent.click(screen.getByTestId('select-w1'))
@@ -9,7 +9,7 @@ const WIDGET_TYPES = getMenuWidgetTypes()
9
9
  /**
10
10
  * Floating toolbar for adding widgets to a canvas.
11
11
  */
12
- export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
12
+ export default function CanvasToolbar({ canvasId, onWidgetAdded }) {
13
13
  const [open, setOpen] = useState(false)
14
14
  const [adding, setAdding] = useState(false)
15
15
 
@@ -18,7 +18,7 @@ export default function CanvasToolbar({ canvasName, onWidgetAdded }) {
18
18
  setAdding(true)
19
19
  try {
20
20
  const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
21
- const result = await addWidgetApi(canvasName, {
21
+ const result = await addWidgetApi(canvasId, {
22
22
  type,
23
23
  props: defaultProps,
24
24
  position: { x: 0, y: 0 },
@@ -0,0 +1,20 @@
1
+ import styles from './CanvasPage.module.css'
2
+
3
+ /**
4
+ * Renders the translucent selection rectangle during a marquee drag.
5
+ * Positioned relative to the scroll container.
6
+ */
7
+ export default function MarqueeOverlay({ rect }) {
8
+ if (!rect) return null
9
+ return (
10
+ <div
11
+ className={styles.marqueeRect}
12
+ style={{
13
+ left: rect.x,
14
+ top: rect.y,
15
+ width: rect.w,
16
+ height: rect.h,
17
+ }}
18
+ />
19
+ )
20
+ }
@@ -0,0 +1,239 @@
1
+ import { useCallback, useRef, useState, useEffect } from 'react'
2
+ import { createCanvas } from './canvasApi.js'
3
+ import styles from './PageSelector.module.css'
4
+
5
+ /**
6
+ * In-canvas page selector — shows sibling pages in the same canvas group.
7
+ * Only renders when 2+ sibling pages exist.
8
+ * Uses window.location for navigation to avoid requiring a Router context.
9
+ *
10
+ * @param {{ currentName: string, pages: Array<{ name: string, route: string, title: string }>, isLocalDev?: boolean }} props
11
+ */
12
+ export default function PageSelector({ currentName, pages: initialPages, isLocalDev = false }) {
13
+ const [open, setOpen] = useState(() => {
14
+ try {
15
+ if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('sb-open-page-selector')) {
16
+ sessionStorage.removeItem('sb-open-page-selector')
17
+ return true
18
+ }
19
+ } catch { /* ignore */ }
20
+ return false
21
+ })
22
+ const [adding, setAdding] = useState(false)
23
+ const [newName, setNewName] = useState('')
24
+ const [creating, setCreating] = useState(false)
25
+ const [pages, setPages] = useState(initialPages)
26
+ const [successMsg, setSuccessMsg] = useState(null)
27
+ const containerRef = useRef(null)
28
+ const inputRef = useRef(null)
29
+
30
+ // Sync pages when prop changes (e.g. HMR reload)
31
+ useEffect(() => { setPages(initialPages) }, [initialPages])
32
+
33
+ const currentPage = pages.find((p) => p.name === currentName)
34
+ const currentLabel = currentPage?.title || currentName.split('/').pop()
35
+ const currentIndex = pages.findIndex((p) => p.name === currentName)
36
+
37
+ // Derive folder from currentName (e.g. "Examples/Design Overview" → "Examples")
38
+ const folder = currentName.includes('/') ? currentName.split('/')[0] : ''
39
+
40
+ const handleSelect = useCallback(
41
+ (page) => {
42
+ if (page.name !== currentName) {
43
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
44
+ window.location.href = base + page.route
45
+ }
46
+ setOpen(false)
47
+ },
48
+ [currentName],
49
+ )
50
+
51
+ const handleAddPage = useCallback(async () => {
52
+ const trimmed = newName.trim()
53
+ if (!trimmed || creating) return
54
+ setCreating(true)
55
+ try {
56
+ // Single-page canvas (no folder) → convert to multi-page folder
57
+ const isSinglePage = !currentName.includes('/')
58
+ const createBody = isSinglePage
59
+ ? { name: trimmed, convertFrom: currentName }
60
+ : { name: trimmed, folder: folder || undefined }
61
+
62
+ const result = await createCanvas(createBody)
63
+ if (result.error) {
64
+ console.error('Failed to create canvas page:', result.error)
65
+ setCreating(false)
66
+ return
67
+ }
68
+ const route = result.route
69
+ const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
70
+ const targetUrl = base + route
71
+
72
+ // Optimistically add the new page to the bottom of the list
73
+ const pageName = result.name || trimmed
74
+ setPages(prev => [...prev, { name: pageName, route, title: trimmed }])
75
+
76
+ // Show success confirmation and reset form
77
+ setSuccessMsg(`"${trimmed}" created`)
78
+ setAdding(false)
79
+ setNewName('')
80
+ setCreating(false)
81
+
82
+ // Stash a flag so the page selector opens automatically on the new page
83
+ try { sessionStorage.setItem('sb-open-page-selector', '1') } catch { /* ignore */ }
84
+
85
+ // Navigate to the new page after Vite picks up the new file
86
+ if (import.meta.hot) {
87
+ const timer = setTimeout(() => {
88
+ window.location.href = targetUrl
89
+ }, 3000)
90
+ import.meta.hot.on('vite:beforeFullReload', () => {
91
+ clearTimeout(timer)
92
+ sessionStorage.setItem('sb-pending-navigate', targetUrl)
93
+ })
94
+ } else {
95
+ setTimeout(() => { window.location.href = targetUrl }, 1000)
96
+ }
97
+ } catch (err) {
98
+ console.error('Failed to create canvas page:', err)
99
+ setCreating(false)
100
+ }
101
+ }, [newName, currentName, folder, creating])
102
+
103
+ // Focus input when entering add mode
104
+ useEffect(() => {
105
+ if (adding && inputRef.current) inputRef.current.focus()
106
+ }, [adding])
107
+
108
+ // Close on outside click
109
+ useEffect(() => {
110
+ if (!open) return
111
+ function handleClick(e) {
112
+ if (containerRef.current && !containerRef.current.contains(e.target)) {
113
+ setOpen(false)
114
+ setAdding(false)
115
+ setNewName('')
116
+ setSuccessMsg(null)
117
+ }
118
+ }
119
+ document.addEventListener('mousedown', handleClick)
120
+ return () => document.removeEventListener('mousedown', handleClick)
121
+ }, [open])
122
+
123
+ // Close on Escape
124
+ useEffect(() => {
125
+ if (!open) return
126
+ function handleKey(e) {
127
+ if (e.key === 'Escape') {
128
+ if (adding) {
129
+ setAdding(false)
130
+ setNewName('')
131
+ } else {
132
+ setOpen(false)
133
+ }
134
+ }
135
+ }
136
+ document.addEventListener('keydown', handleKey)
137
+ return () => document.removeEventListener('keydown', handleKey)
138
+ }, [open, adding])
139
+
140
+ // Show selector when there are multiple pages, or in dev mode (to allow adding pages)
141
+ if (!pages || (pages.length < 2 && !isLocalDev)) return null
142
+
143
+ return (
144
+ <nav ref={containerRef} className={styles.container} aria-label="Canvas pages">
145
+ <button
146
+ className={styles.trigger}
147
+ onClick={() => setOpen((v) => !v)}
148
+ aria-expanded={open}
149
+ aria-haspopup="listbox"
150
+ title="Switch canvas page"
151
+ >
152
+ <span className={styles.label}>{currentLabel}</span>
153
+ <span className={styles.badge}>
154
+ {currentIndex + 1}/{pages.length}
155
+ </span>
156
+ <svg
157
+ className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
158
+ width="12"
159
+ height="12"
160
+ viewBox="0 0 12 12"
161
+ fill="none"
162
+ aria-hidden="true"
163
+ >
164
+ <path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
165
+ </svg>
166
+ </button>
167
+ {open && (
168
+ <ul className={styles.menu} role="listbox" aria-label="Canvas pages">
169
+ {pages.map((page) => (
170
+ <li
171
+ key={page.name}
172
+ role="option"
173
+ aria-selected={page.name === currentName}
174
+ className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''}`}
175
+ onClick={() => handleSelect(page)}
176
+ onKeyDown={(e) => {
177
+ if (e.key === 'Enter' || e.key === ' ') {
178
+ e.preventDefault()
179
+ handleSelect(page)
180
+ }
181
+ }}
182
+ tabIndex={0}
183
+ >
184
+ {page.title}
185
+ </li>
186
+ ))}
187
+ {isLocalDev && (
188
+ <>
189
+ <li className={styles.separator} role="separator" />
190
+ {adding ? (
191
+ <li className={styles.addForm}>
192
+ <input
193
+ ref={inputRef}
194
+ className={styles.addInput}
195
+ type="text"
196
+ placeholder="Page name"
197
+ value={newName}
198
+ onChange={(e) => setNewName(e.target.value)}
199
+ onKeyDown={(e) => {
200
+ if (e.key === 'Enter') {
201
+ e.preventDefault()
202
+ handleAddPage()
203
+ }
204
+ }}
205
+ disabled={creating}
206
+ />
207
+ <button
208
+ className={styles.addSubmit}
209
+ onClick={handleAddPage}
210
+ disabled={!newName.trim() || creating}
211
+ >
212
+ {creating ? '…' : 'Add'}
213
+ </button>
214
+ </li>
215
+ ) : (
216
+ <li
217
+ className={styles.addItem}
218
+ onClick={() => setAdding(true)}
219
+ tabIndex={0}
220
+ onKeyDown={(e) => {
221
+ if (e.key === 'Enter' || e.key === ' ') {
222
+ e.preventDefault()
223
+ setAdding(true)
224
+ }
225
+ }}
226
+ >
227
+ + Add new page
228
+ </li>
229
+ )}
230
+ {successMsg && (
231
+ <li className={styles.successMsg}>✓ {successMsg}</li>
232
+ )}
233
+ </>
234
+ )}
235
+ </ul>
236
+ )}
237
+ </nav>
238
+ )
239
+ }