@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,936 @@
1
+ import { useState, useEffect, useCallback, useMemo } from 'react'
2
+ import 'react-cmdk/dist/cmdk.css'
3
+ import * as ReactCmdk from 'react-cmdk'
4
+ const CommandPalette = ReactCmdk.default || ReactCmdk
5
+ const { filterItems, getItemIndex } = ReactCmdk
6
+ import {
7
+ buildPrototypeIndex,
8
+ listStories,
9
+ getStoryData,
10
+ getActionsForMode,
11
+ executeAction,
12
+ getActionChildren,
13
+ getToolbarToolState,
14
+ getCurrentMode,
15
+ getRecent,
16
+ trackRecent,
17
+ getCommandPaletteConfig,
18
+ getToolbarConfig,
19
+ setTheme,
20
+ getTheme,
21
+ isExcludedByRoute,
22
+ } from '@dfosco/storyboard-core'
23
+ import CreateDialog from './CreateDialog.jsx'
24
+ import BranchBar from '../BranchBar/BranchBar.jsx'
25
+ import AuthModal from '../AuthModal/AuthModal.jsx'
26
+ import './command-palette.css'
27
+
28
+ /**
29
+ * Check if a tool should be hidden from the command palette on the current route.
30
+ * Uses the same pattern-matching logic as excludeRoutes.
31
+ */
32
+ function isHiddenInPalette(tool, basePath) {
33
+ const val = tool.hideInCommandPalette
34
+ if (val === true) return true
35
+ if (!val || !Array.isArray(val) || val.length === 0) return false
36
+ if (typeof window === 'undefined') return false
37
+ let pathname = window.location.pathname
38
+ const base = (basePath || '/').replace(/\/+$/, '')
39
+ if (base && pathname.startsWith(base)) {
40
+ pathname = pathname.slice(base.length) || '/'
41
+ }
42
+ return val.some(pattern => new RegExp(pattern).test(pathname))
43
+ }
44
+
45
+ /**
46
+ * Build groups from commandPalette.sections config.
47
+ * Returns { groups, toolMenus } where toolMenus are entries with sub-pages.
48
+ *
49
+ * Section types:
50
+ * - Static items: { items: [...] }
51
+ * - Dynamic list: { source: "canvases"|"prototypes"|"stories"|"recent" }
52
+ * - Tool section: { source: "tools", toolIds: ["theme", "flows"] }
53
+ * - Tool-menu: { type: "tool-menu", options: [...] }
54
+ */
55
+ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
56
+ const config = getCommandPaletteConfig()
57
+ const sections = config?.sections || []
58
+ const groups = []
59
+ const toolMenus = []
60
+ const usedToolIds = new Set() // Track tools already listed by source:"tools" sections
61
+ const basePath = prefix || '/'
62
+
63
+ for (const section of sections) {
64
+ // Separator: id starts with "sep"
65
+ if (section.id?.startsWith('sep')) {
66
+ groups.push({ id: `cfg:${section.id}`, items: [{ id: `cfg:${section.id}:sep`, children: '', keywords: ['*'] }] })
67
+ continue
68
+ }
69
+
70
+ if (section.type === 'tool-menu') {
71
+ toolMenus.push(section)
72
+ continue
73
+ }
74
+
75
+ if (section.source) {
76
+ // Defer tool-subpages β€” needs usedToolIds from all other sections first
77
+ if (section.source === 'tool-subpages') continue
78
+ const result = buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
79
+ if (result?.group) groups.push(result.group)
80
+ if (result?.subPages) toolMenus.push(...result.subPages)
81
+ if (result?.usedToolIds) result.usedToolIds.forEach(id => usedToolIds.add(id))
82
+ continue
83
+ }
84
+
85
+ if (section.items && section.items.length > 0) {
86
+ groups.push({
87
+ heading: section.title,
88
+ id: `cfg:${section.id}`,
89
+ items: section.items.map((item, i) => {
90
+ const id = `cfg:${section.id}:${i}`
91
+ if (item.type === 'link') {
92
+ return {
93
+ id,
94
+ children: item.label,
95
+ keywords: item.keywords || [item.label],
96
+ onClick: () => {
97
+ const url = item.url?.startsWith('/') ? prefix + item.url : item.url
98
+ if (url) window.location.href = url
99
+ },
100
+ }
101
+ }
102
+ if (item.type === 'action') {
103
+ return {
104
+ id,
105
+ children: item.label,
106
+ keywords: item.keywords || [item.label],
107
+ onClick: () => { if (item.action) executeAction(item.action) },
108
+ }
109
+ }
110
+ return { id, children: item.label, keywords: [item.label] }
111
+ }),
112
+ })
113
+ }
114
+ }
115
+
116
+ // Resolve tool-subpages sections (deferred β€” needs complete usedToolIds)
117
+ for (const section of sections) {
118
+ if (section.source !== 'tool-subpages') continue
119
+
120
+ // Scan all toolbar tools for sub-page candidates not already listed
121
+ const toolbarConfig = getToolbarConfig()
122
+ const allTools = toolbarConfig?.tools || {}
123
+ const mode = getCurrentMode() || 'default'
124
+ const actions = getActionsForMode(mode)
125
+ const remainingItems = []
126
+
127
+ for (const [toolId, tool] of Object.entries(allTools)) {
128
+ if (usedToolIds.has(toolId)) continue
129
+ const state = getToolbarToolState(toolId)
130
+ if (state === 'disabled' || state === 'hidden') continue
131
+ if (tool.disabled) continue
132
+ if (isHiddenInPalette(tool, basePath)) continue
133
+
134
+ const label = tool.label || tool.ariaLabel || toolId
135
+ const excluded = isExcludedByRoute(tool)
136
+
137
+ // Route-excluded tools show as disabled with hint
138
+ if (excluded) {
139
+ remainingItems.push({
140
+ id: `cfg:${section.id}:${toolId}`,
141
+ children: <><span>{label}</span><span style={{ marginLeft: 'auto', fontSize: '12px', opacity: 0.5 }}>Not available on this page</span></>,
142
+ keywords: [label, toolId].filter(Boolean),
143
+ showType: false,
144
+ disabled: true,
145
+ })
146
+ continue
147
+ }
148
+
149
+ // Tools with submenu children
150
+ if (tool.render === 'submenu' || tool.render === 'menu') {
151
+ const action = actions.find(a => a.toolKey === toolId)
152
+ if (action?.type === 'submenu') {
153
+ const children = getActionChildren(action.id)
154
+ if (children.length > 0) {
155
+ const pageId = `tool:${toolId}`
156
+ toolMenus.push({
157
+ id: pageId, label, title: label,
158
+ keywords: [label, toolId].filter(Boolean),
159
+ options: children.map(child => ({ label: child.label, execute: child.execute })),
160
+ })
161
+ remainingItems.push({
162
+ id: `cfg:${section.id}:${toolId}`,
163
+ children: label,
164
+ keywords: [label, toolId].filter(Boolean),
165
+ showType: false,
166
+ onClick: () => onNavigateToPage?.(pageId),
167
+ closeOnSelect: false,
168
+ })
169
+ continue
170
+ }
171
+ }
172
+ // Declarative options
173
+ if (tool.options?.length > 0) {
174
+ const pageId = `tool:${toolId}`
175
+ toolMenus.push({
176
+ id: pageId, label, title: label,
177
+ keywords: [label, toolId].filter(Boolean),
178
+ options: tool.options.map(opt => ({ label: opt.label, toolHandler: tool.handler || `core:${toolId}`, value: opt.value })),
179
+ })
180
+ remainingItems.push({
181
+ id: `cfg:${section.id}:${toolId}`,
182
+ children: label,
183
+ keywords: [label, toolId].filter(Boolean),
184
+ showType: false,
185
+ onClick: () => onNavigateToPage?.(pageId),
186
+ closeOnSelect: false,
187
+ })
188
+ continue
189
+ }
190
+ }
191
+
192
+ // Inline actions (e.g. toggle-chrome for hide toolbars)
193
+ if (tool.inlineAction === 'toggle-chrome') {
194
+ remainingItems.push({
195
+ id: `cfg:${section.id}:${toolId}`,
196
+ children: label,
197
+ keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
198
+ showType: false,
199
+ onClick: () => {
200
+ document.documentElement.classList.toggle('storyboard-chrome-hidden')
201
+ },
202
+ })
203
+ continue
204
+ }
205
+
206
+ // Any remaining tools (all surfaces)
207
+ if (tool.render === 'link' && tool.url) {
208
+ remainingItems.push({
209
+ id: `cfg:${section.id}:${toolId}`,
210
+ children: label,
211
+ keywords: [label, toolId].filter(Boolean),
212
+ showType: false,
213
+ onClick: () => { window.location.href = tool.url },
214
+ })
215
+ } else {
216
+ // Menu tools: close palette and click the toolbar button to open the menu
217
+ if (tool.render === 'menu') {
218
+ const ariaLabel = tool.ariaLabel || tool.label || toolId
219
+ remainingItems.push({
220
+ id: `cfg:${section.id}:${toolId}`,
221
+ children: label,
222
+ keywords: [label, toolId].filter(Boolean),
223
+ showType: false,
224
+ onClick: () => {
225
+ // Find and click the toolbar button
226
+ setTimeout(() => {
227
+ const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
228
+ if (btn) btn.click()
229
+ }, 100)
230
+ },
231
+ })
232
+ } else {
233
+ // Fallback: click toolbar button or execute action
234
+ const action = actions.find(a => a.toolKey === toolId)
235
+ const ariaLabel = tool.ariaLabel || tool.label || toolId
236
+ remainingItems.push({
237
+ id: `cfg:${section.id}:${toolId}`,
238
+ children: label,
239
+ keywords: [label, toolId].filter(Boolean),
240
+ showType: false,
241
+ onClick: action
242
+ ? () => executeAction(action.id)
243
+ : () => {
244
+ setTimeout(() => {
245
+ const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
246
+ if (btn) btn.click()
247
+ }, 100)
248
+ },
249
+ })
250
+ }
251
+ }
252
+ }
253
+
254
+ // Also include any toolMenus sub-pages not yet listed
255
+ for (const menu of toolMenus) {
256
+ const menuToolId = menu.id?.replace('tool:', '')
257
+ if (usedToolIds.has(menuToolId)) continue
258
+ if (remainingItems.some(i => i.id === `cfg:${section.id}:${menuToolId}`)) continue
259
+ remainingItems.push({
260
+ id: `cfg:${section.id}:${menuToolId || menu.id}`,
261
+ children: menu.label || menu.id,
262
+ keywords: menu.keywords || [menu.label || menu.id],
263
+ showType: false,
264
+ onClick: () => onNavigateToPage?.(menu.id),
265
+ closeOnSelect: false,
266
+ })
267
+ }
268
+
269
+ if (remainingItems.length === 0) continue
270
+ groups.push({
271
+ heading: section.title,
272
+ id: `cfg:${section.id}`,
273
+ items: remainingItems,
274
+ })
275
+ }
276
+
277
+ return { groups, toolMenus }
278
+ }
279
+
280
+ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction) {
281
+ if (section.source === 'tools') {
282
+ return buildToolsSection(section, prefix, onNavigateToPage)
283
+ }
284
+
285
+ // --- Create source (dev-only workshop actions) ---
286
+ if (section.source === 'create') {
287
+ const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
288
+ if (!isLocalDev) return null
289
+ const createItems = [
290
+ { id: 'create:canvas', children: 'New Canvas', keywords: ['create', 'canvas', 'new', 'board'], showType: false, onClick: () => onCreateAction?.('Canvas') },
291
+ { id: 'create:prototype', children: 'New Prototype', keywords: ['create', 'prototype', 'new', 'page'], showType: false, onClick: () => onCreateAction?.('Prototype') },
292
+ { id: 'create:component', children: 'New Component', keywords: ['create', 'component', 'new', 'story'], showType: false, onClick: () => onCreateAction?.('Component') },
293
+ { id: 'create:flow', children: 'New Prototype Flow', keywords: ['create', 'flow', 'new', 'data'], showType: false, onClick: () => onCreateAction?.('Flow') },
294
+ { id: 'create:page', children: 'New Prototype Page', keywords: ['create', 'page', 'new'], showType: false, onClick: () => onCreateAction?.('Page') },
295
+ ]
296
+ return { group: { heading: section.title, id: `cfg:${section.id}`, items: createItems } }
297
+ }
298
+
299
+ // --- Commands source (all registered toolbar actions) ---
300
+ if (section.source === 'commands') {
301
+ const mode = getCurrentMode() || 'default'
302
+ const actions = getActionsForMode(mode)
303
+ const commandItems = []
304
+ for (const action of actions) {
305
+ if (action.type === 'header' || action.type === 'separator' || action.type === 'footer') continue
306
+ if (action.toolKey) {
307
+ const state = getToolbarToolState(action.toolKey)
308
+ if (state === 'disabled' || state === 'hidden') continue
309
+ }
310
+ if (action.type === 'submenu') {
311
+ const children = getActionChildren(action.id)
312
+ for (const child of children) {
313
+ commandItems.push({
314
+ id: `cmd:${action.id}/${child.id || child.label}`,
315
+ children: child.label,
316
+ keywords: [action.label, child.label],
317
+ onClick: () => { if (child.execute) child.execute() },
318
+ })
319
+ }
320
+ } else if (action.type === 'link' && action.url) {
321
+ commandItems.push({
322
+ id: `cmd:${action.id}`,
323
+ children: action.label,
324
+ keywords: [action.label],
325
+ onClick: () => {
326
+ const url = action.url.startsWith('/') && !action.url.startsWith('//') ? prefix + action.url : action.url
327
+ window.location.href = url
328
+ },
329
+ })
330
+ } else {
331
+ commandItems.push({
332
+ id: `cmd:${action.id}`,
333
+ children: action.label,
334
+ keywords: [action.label],
335
+ onClick: () => executeAction(action.id),
336
+ })
337
+ }
338
+ }
339
+ if (commandItems.length === 0) return null
340
+ return { group: { heading: section.title, id: `cfg:${section.id}`, items: commandItems } }
341
+ }
342
+
343
+ // --- Recent source: all artifact types from getRecent() ---
344
+ if (section.source === 'recent') {
345
+ const recent = getRecent()
346
+ if (recent.length === 0) return null
347
+ let items = recent
348
+ if (section.limit) items = items.slice(0, section.limit)
349
+ return {
350
+ group: {
351
+ heading: section.title,
352
+ id: `cfg:${section.id}`,
353
+ items: items.map(entry => ({
354
+ id: `cfg:${section.id}:${entry.type}:${entry.key}`,
355
+ children: entry.label,
356
+ keywords: [entry.type, entry.key, entry.label],
357
+ onClick: () => {
358
+ trackRecent(entry.type, entry.key, entry.label)
359
+ const route = resolveRecentRoute(entry, prefix)
360
+ if (route) window.location.href = route
361
+ },
362
+ })),
363
+ },
364
+ }
365
+ }
366
+
367
+ // --- Artifact sources: canvases, prototypes, stories ---
368
+ const index = buildPrototypeIndex()
369
+ let sourceItems = []
370
+
371
+ if (section.source === 'canvases') {
372
+ for (const c of index.canvases) sourceItems.push({ name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'canvas' })
373
+ for (const f of index.folders) {
374
+ if (f.canvases) for (const c of f.canvases) sourceItems.push({ name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'canvas' })
375
+ }
376
+ } else if (section.source === 'prototypes') {
377
+ for (const p of index.prototypes) sourceItems.push({ name: p.name, route: `${prefix}/${p.dirName}`, id: p.dirName, type: 'prototype' })
378
+ for (const f of index.folders) {
379
+ for (const p of f.prototypes) sourceItems.push({ name: p.name, route: `${prefix}/${p.dirName}`, id: p.dirName, type: 'prototype' })
380
+ }
381
+ } else if (section.source === 'stories') {
382
+ for (const name of listStories()) {
383
+ const data = getStoryData(name)
384
+ const route = data?._route || `/components/${name}`
385
+ sourceItems.push({ name, route: `${prefix}${route}`, id: name, type: 'story' })
386
+ }
387
+ }
388
+
389
+ if (sourceItems.length === 0) return null
390
+
391
+ if (section.order === 'recent') {
392
+ const recent = getRecent()
393
+ const recentKeys = recent.map(r => r.key)
394
+ sourceItems.sort((a, b) => {
395
+ const ai = recentKeys.indexOf(a.id)
396
+ const bi = recentKeys.indexOf(b.id)
397
+ if (ai === -1 && bi === -1) return 0
398
+ if (ai === -1) return 1
399
+ if (bi === -1) return -1
400
+ return ai - bi
401
+ })
402
+ } else if (section.order === 'alphabetical') {
403
+ sourceItems.sort((a, b) => a.name.localeCompare(b.name))
404
+ }
405
+
406
+ if (section.limit) sourceItems = sourceItems.slice(0, section.limit)
407
+
408
+ return {
409
+ group: {
410
+ heading: section.title,
411
+ id: `cfg:${section.id}`,
412
+ items: sourceItems.map(item => ({
413
+ id: `cfg:${section.id}:${item.id}`,
414
+ children: item.name,
415
+ keywords: [item.name, item.id, item.type],
416
+ onClick: () => {
417
+ trackRecent(item.type, item.id, item.name)
418
+ window.location.href = item.route
419
+ },
420
+ })),
421
+ },
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Build a section from toolbar.config.json tools.
427
+ * If toolIds is provided, only include those tools in that order (with optional custom labels).
428
+ * Otherwise include all command-list tools.
429
+ *
430
+ * toolIds format: ["theme", "flows"] or [{ id: "theme", label: "Change theme" }]
431
+ */
432
+ function buildToolsSection(section, prefix, onNavigateToPage) {
433
+ const toolbarConfig = getToolbarConfig()
434
+ const tools = toolbarConfig?.tools || {}
435
+ const mode = getCurrentMode() || 'default'
436
+ const actions = getActionsForMode(mode)
437
+ const basePath = prefix || '/'
438
+
439
+ let entries = []
440
+
441
+ if (section.toolIds && section.toolIds.length > 0) {
442
+ for (const entry of section.toolIds) {
443
+ const toolId = typeof entry === 'string' ? entry : entry.id
444
+ const customLabel = typeof entry === 'object' ? entry.label : null
445
+ const tool = tools[toolId]
446
+ if (!tool) continue
447
+ const state = getToolbarToolState(toolId)
448
+ if (state === 'disabled' || state === 'hidden') continue
449
+ if (isHiddenInPalette(tool, basePath)) continue
450
+ entries.push({ toolId, tool, label: customLabel || tool.label || toolId })
451
+ }
452
+ } else {
453
+ for (const [toolId, tool] of Object.entries(tools)) {
454
+ if (tool.surface !== 'command-list') continue
455
+ const state = getToolbarToolState(toolId)
456
+ if (state === 'disabled' || state === 'hidden') continue
457
+ if (isHiddenInPalette(tool, basePath)) continue
458
+ entries.push({ toolId, tool, label: tool.label || toolId })
459
+ }
460
+ }
461
+
462
+ if (entries.length === 0) return null
463
+
464
+ const items = []
465
+ const subPages = []
466
+
467
+ for (const { toolId, tool, label } of entries) {
468
+ // Inline actions
469
+ if (tool.inlineAction === 'toggle-chrome') {
470
+ items.push({
471
+ id: `cfg:${section.id}:${toolId}`,
472
+ children: label,
473
+ keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
474
+ showType: false,
475
+ onClick: () => {
476
+ document.documentElement.classList.toggle('storyboard-chrome-hidden')
477
+ },
478
+ })
479
+ continue
480
+ }
481
+
482
+ if (tool.render === 'link' && tool.url) {
483
+ items.push({
484
+ id: `cfg:${section.id}:${toolId}`,
485
+ children: label,
486
+ keywords: [label, toolId].filter(Boolean),
487
+ onClick: () => {
488
+ const url = tool.url.startsWith('/') ? prefix + tool.url : tool.url
489
+ window.location.href = url
490
+ },
491
+ })
492
+ continue
493
+ }
494
+
495
+ if (tool.render === 'submenu' || tool.render === 'menu') {
496
+ const action = actions.find(a => a.toolKey === toolId)
497
+ if (action?.type === 'submenu') {
498
+ const children = getActionChildren(action.id)
499
+ if (children.length > 0) {
500
+ const pageId = `tool:${toolId}`
501
+ subPages.push({
502
+ id: pageId,
503
+ label,
504
+ title: label,
505
+ keywords: [label, toolId].filter(Boolean),
506
+ options: children.map(child => ({
507
+ label: child.label,
508
+ execute: child.execute,
509
+ })),
510
+ })
511
+ items.push({
512
+ id: `cfg:${section.id}:${toolId}`,
513
+ children: label,
514
+ keywords: [label, toolId].filter(Boolean),
515
+ onClick: () => onNavigateToPage?.(pageId),
516
+ closeOnSelect: false,
517
+ showType: false,
518
+ })
519
+ continue
520
+ }
521
+ }
522
+
523
+ // Declarative options from toolbar.config.json (e.g. theme options)
524
+ if (tool.options && tool.options.length > 0) {
525
+ const pageId = `tool:${toolId}`
526
+ const handlerId = tool.handler || `core:${toolId}`
527
+ subPages.push({
528
+ id: pageId,
529
+ label,
530
+ title: label,
531
+ keywords: [label, toolId].filter(Boolean),
532
+ options: tool.options.map(opt => ({
533
+ label: opt.label,
534
+ // Lazy-execute via the handler's action system
535
+ toolHandler: handlerId,
536
+ value: opt.value,
537
+ })),
538
+ })
539
+ items.push({
540
+ id: `cfg:${section.id}:${toolId}`,
541
+ children: label,
542
+ keywords: [label, toolId].filter(Boolean),
543
+ onClick: () => onNavigateToPage?.(pageId),
544
+ closeOnSelect: false,
545
+ showType: false,
546
+ })
547
+ continue
548
+ }
549
+
550
+ // Menu tool without sub-items or options β€” click toolbar button
551
+ const ariaLabel = tool.ariaLabel || tool.label || toolId
552
+ items.push({
553
+ id: `cfg:${section.id}:${toolId}`,
554
+ children: label,
555
+ keywords: [label, toolId].filter(Boolean),
556
+ showType: false,
557
+ onClick: () => {
558
+ setTimeout(() => {
559
+ const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
560
+ if (btn) btn.click()
561
+ }, 100)
562
+ },
563
+ })
564
+ continue
565
+ }
566
+
567
+ if (tool.render === 'sidepanel' && tool.sidepanel) {
568
+ const action = actions.find(a => a.toolKey === toolId)
569
+ items.push({
570
+ id: `cfg:${section.id}:${toolId}`,
571
+ children: label,
572
+ keywords: [label, toolId].filter(Boolean),
573
+ onClick: () => { if (action) executeAction(action.id) },
574
+ })
575
+ continue
576
+ }
577
+
578
+ items.push({
579
+ id: `cfg:${section.id}:${toolId}`,
580
+ children: label,
581
+ keywords: [label, toolId].filter(Boolean),
582
+ onClick: () => executeAction(toolId),
583
+ })
584
+ }
585
+
586
+ return {
587
+ group: {
588
+ heading: section.title,
589
+ id: `cfg:${section.id}`,
590
+ items,
591
+ },
592
+ subPages,
593
+ usedToolIds: entries.map(e => e.toolId),
594
+ }
595
+ }
596
+
597
+ function resolveRecentRoute(entry, prefix) {
598
+ switch (entry.type) {
599
+ case 'prototype':
600
+ return `${prefix}/${entry.key}`
601
+ case 'canvas':
602
+ return `${prefix}/canvas/${entry.key}`
603
+ case 'story': {
604
+ const data = getStoryData(entry.key)
605
+ const route = data?._route || `/components/${entry.key}`
606
+ return `${prefix}${route}`
607
+ }
608
+ default:
609
+ return null
610
+ }
611
+ }
612
+
613
+ /**
614
+ * Build a map of author β†’ artifacts from the prototype index.
615
+ * Returns { authorIndex: Map<lowercase-author, { author, items[] }> }
616
+ */
617
+ function buildAuthorIndex(prefix) {
618
+ const index = buildPrototypeIndex()
619
+ const authorMap = new Map()
620
+
621
+ function addItem(author, item) {
622
+ const key = author.toLowerCase()
623
+ if (!authorMap.has(key)) authorMap.set(key, { author, items: [] })
624
+ authorMap.get(key).items.push(item)
625
+ }
626
+
627
+ function processAuthors(authors, item) {
628
+ if (!authors) return
629
+ const list = Array.isArray(authors) ? authors : [authors]
630
+ for (const a of list) if (a) addItem(a, item)
631
+ }
632
+
633
+ for (const p of index.prototypes) {
634
+ processAuthors(p.author, { name: p.name, route: `${prefix}/${p.dirName}`, id: p.dirName, type: 'Prototype' })
635
+ }
636
+ for (const f of index.folders) {
637
+ for (const p of f.prototypes) {
638
+ processAuthors(p.author, { name: p.name, route: `${prefix}/${p.dirName}`, id: p.dirName, type: 'Prototype' })
639
+ }
640
+ if (f.canvases) {
641
+ for (const c of f.canvases) {
642
+ processAuthors(c.author, { name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'Canvas' })
643
+ }
644
+ }
645
+ }
646
+ for (const c of index.canvases) {
647
+ processAuthors(c.author, { name: c.name, route: `${prefix}${c.route}`, id: c.dirName, type: 'Canvas' })
648
+ }
649
+
650
+ return authorMap
651
+ }
652
+
653
+ /**
654
+ * Build the JSON structure for react-cmdk from all data providers.
655
+ * Entirely config-driven β€” all sections come from commandPalette.sections.
656
+ */
657
+ function buildPaletteItems(basePath, onCreateAction, onNavigateToPage) {
658
+ const base = (basePath || '/').replace(/\/+$/, '')
659
+ const prefix = base === '/' ? '' : base
660
+
661
+ const { groups, toolMenus } = buildConfigSections(prefix, onNavigateToPage, onCreateAction)
662
+ const authorIndex = buildAuthorIndex(prefix)
663
+
664
+ return { groups, toolMenus, authorIndex }
665
+ }
666
+
667
+ /**
668
+ * StoryboardCommandPalette β€” React command palette using react-cmdk.
669
+ * Mounted at app root, listens for custom events from Svelte CoreUIBar.
670
+ */
671
+ export default function StoryboardCommandPalette({ basePath }) {
672
+ const [open, setOpen] = useState(false)
673
+ const [search, setSearch] = useState('')
674
+ const [items, setItems] = useState([])
675
+ const [toolMenus, setToolMenus] = useState([])
676
+ const [authorIndex, setAuthorIndex] = useState(new Map())
677
+ const [activePage, setActivePage] = useState('root')
678
+ const [createType, setCreateType] = useState(null)
679
+ const [currentTheme, setCurrentTheme] = useState(() => getTheme())
680
+
681
+ // Keep currentTheme in sync when theme changes
682
+ useEffect(() => {
683
+ const handler = (e) => setCurrentTheme(e.detail.theme)
684
+ document.addEventListener('storyboard:theme:changed', handler)
685
+ return () => document.removeEventListener('storyboard:theme:changed', handler)
686
+ }, [])
687
+
688
+ function handleCreateAction(type) {
689
+ setOpen(false)
690
+ requestAnimationFrame(() => setCreateType(type))
691
+ }
692
+
693
+ function handleNavigateToPage(pageId) {
694
+ setSearch('')
695
+ setActivePage(pageId)
696
+ }
697
+
698
+ // Listen for Cmd+K directly
699
+ // The Svelte CoreUIBar also handles Cmd+K by dispatching
700
+ // 'storyboard:toggle-palette'. We use rAF to detect if Svelte
701
+ // already fired the toggle event and skip to avoid double-toggle.
702
+ useEffect(() => {
703
+ let toggledByEvent = false
704
+
705
+ function handleToggleEvent() {
706
+ toggledByEvent = true
707
+ }
708
+
709
+ function handleKeyDown(e) {
710
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
711
+ e.preventDefault()
712
+ toggledByEvent = false
713
+ requestAnimationFrame(() => {
714
+ if (toggledByEvent) return
715
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
716
+ setItems(built.groups)
717
+ setToolMenus(built.toolMenus)
718
+ setAuthorIndex(built.authorIndex)
719
+ setSearch('')
720
+ setActivePage('root')
721
+ setOpen(prev => !prev)
722
+ })
723
+ }
724
+ }
725
+
726
+ document.addEventListener('storyboard:toggle-palette', handleToggleEvent)
727
+ document.addEventListener('keydown', handleKeyDown)
728
+ return () => {
729
+ document.removeEventListener('storyboard:toggle-palette', handleToggleEvent)
730
+ document.removeEventListener('keydown', handleKeyDown)
731
+ }
732
+ }, [basePath])
733
+
734
+ // Listen for toggle events from Svelte CoreUIBar
735
+ useEffect(() => {
736
+ function handleToggle() {
737
+ setOpen(prev => {
738
+ if (!prev) {
739
+ // Use setTimeout to set items after open state is committed
740
+ setTimeout(() => {
741
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
742
+ setItems(built.groups)
743
+ setToolMenus(built.toolMenus)
744
+ setAuthorIndex(built.authorIndex)
745
+ setSearch('')
746
+ setActivePage('root')
747
+ }, 0)
748
+ }
749
+ return !prev
750
+ })
751
+ }
752
+
753
+ function handleOpen() {
754
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
755
+ setItems(built.groups)
756
+ setToolMenus(built.toolMenus)
757
+ setAuthorIndex(built.authorIndex)
758
+ setSearch('')
759
+ setActivePage('root')
760
+ setOpen(true)
761
+ }
762
+
763
+ document.addEventListener('storyboard:toggle-palette', handleToggle)
764
+ document.addEventListener('storyboard:open-palette', handleOpen)
765
+ return () => {
766
+ document.removeEventListener('storyboard:toggle-palette', handleToggle)
767
+ document.removeEventListener('storyboard:open-palette', handleOpen)
768
+ }
769
+ }, [basePath])
770
+
771
+ const handleChangeOpen = useCallback((value) => {
772
+ if (!value) {
773
+ setOpen(false)
774
+ setActivePage('root')
775
+ }
776
+ }, [])
777
+
778
+ // Flatten sub-page options into searchable groups so they appear in root search
779
+ const subPageGroups = useMemo(() => {
780
+ return toolMenus.map(menu => ({
781
+ heading: menu.label || menu.title || menu.id,
782
+ id: `subpage:${menu.id}`,
783
+ items: (menu.options || []).map((opt, i) => ({
784
+ id: `subpage:${menu.id}:${i}`,
785
+ children: opt.toolHandler === 'core:theme' && opt.value === currentTheme
786
+ ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>βœ“</span></span>
787
+ : opt.label,
788
+ keywords: [opt.label, menu.label || menu.id],
789
+ showType: false,
790
+ onClick: () => {
791
+ if (opt.execute) {
792
+ opt.execute()
793
+ } else if (opt.toolHandler === 'core:theme' && opt.value) {
794
+ setTheme(opt.value)
795
+ } else if (opt.action) {
796
+ executeAction(opt.action, opt.value)
797
+ }
798
+ setOpen(false)
799
+ setActivePage('root')
800
+ },
801
+ })),
802
+ })).filter(g => g.items.length > 0)
803
+ }, [toolMenus, currentTheme])
804
+
805
+ const filteredItems = useMemo(() => {
806
+ const base = filterItems(items, search)
807
+ if (!search) return base
808
+ const matchingSub = filterItems(subPageGroups, search)
809
+ const result = [...base, ...matchingSub]
810
+
811
+ // Author search: match usernames against author index
812
+ const q = search.toLowerCase()
813
+ const authorQ = q.startsWith('@') ? q.slice(1) : q
814
+ for (const [key, { author, items: authorItems }] of authorIndex) {
815
+ if (!key.includes(authorQ)) continue
816
+ // Avoid duplicates with already-shown artifact items
817
+ const shownIds = new Set(result.flatMap(g => g.items.map(i => i.id)))
818
+ const uniqueItems = authorItems.filter(item => !shownIds.has(`author:${item.id}`))
819
+ if (uniqueItems.length === 0) continue
820
+ result.push({
821
+ heading: `Artifacts by @${author}`,
822
+ id: `author:${key}`,
823
+ items: uniqueItems.map(item => ({
824
+ id: `author:${item.id}`,
825
+ children: (
826
+ <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
827
+ <span>{item.name}</span>
828
+ <span style={{ fontSize: '12px', color: 'var(--fgColor-muted, #999)' }}>{item.type}</span>
829
+ </span>
830
+ ),
831
+ keywords: [item.name, item.id, item.type, author, `@${author}`],
832
+ showType: false,
833
+ onClick: () => {
834
+ trackRecent(item.type.toLowerCase(), item.id, item.name)
835
+ window.location.href = item.route
836
+ },
837
+ })),
838
+ })
839
+ }
840
+
841
+ return result
842
+ }, [items, search, subPageGroups, authorIndex])
843
+
844
+ // Items without separators β€” used for keyboard navigation indexing
845
+ const navigableItems = useMemo(
846
+ () => filteredItems.filter(list => !list.id?.startsWith('cfg:sep')),
847
+ [filteredItems]
848
+ )
849
+
850
+ const handleChangeSearch = useCallback((value) => {
851
+ setSearch(value)
852
+ }, [])
853
+
854
+ return (
855
+ <>
856
+ <CommandPalette
857
+ onChangeSearch={handleChangeSearch}
858
+ onChangeOpen={handleChangeOpen}
859
+ search={search}
860
+ isOpen={open}
861
+ page={activePage}
862
+ placeholder={activePage === 'root'
863
+ ? 'Search commands, prototypes, canvases, stories...'
864
+ : `Search ${toolMenus.find(m => m.id === activePage)?.label || ''}...`
865
+ }
866
+ >
867
+ <CommandPalette.Page id="root">
868
+ {filteredItems.length ? (
869
+ filteredItems.map((list) => (
870
+ list.id?.startsWith('cfg:sep') ? (
871
+ !search && <hr key={list.id} style={{ border: 'none', borderTop: '1px solid var(--borderColor-muted, #e5e5e5)', margin: '4px 14px' }} />
872
+ ) : (
873
+ <CommandPalette.List key={list.id} heading={list.heading}>
874
+ {list.items.map(({ id, ...rest }) => (
875
+ <CommandPalette.ListItem
876
+ key={id}
877
+ index={getItemIndex(navigableItems, id)}
878
+ {...rest}
879
+ />
880
+ ))}
881
+ </CommandPalette.List>
882
+ )
883
+ ))
884
+ ) : (
885
+ <div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280' }}>
886
+ No results for &ldquo;{search}&rdquo;
887
+ </div>
888
+ )}
889
+ </CommandPalette.Page>
890
+
891
+ {/* Tool-menu sub-pages */}
892
+ {toolMenus.map(menu => (
893
+ <CommandPalette.Page
894
+ key={menu.id}
895
+ id={menu.id}
896
+ onEscape={() => { setActivePage('root'); setSearch('') }}
897
+ searchPrefix={[menu.label || menu.id]}
898
+ >
899
+ <CommandPalette.List heading={menu.title || menu.label || menu.id}>
900
+ {(menu.options || []).map((opt, i) => (
901
+ <CommandPalette.ListItem
902
+ key={`${menu.id}:${i}`}
903
+ index={i}
904
+ showType={false}
905
+ onClick={() => {
906
+ if (opt.execute) {
907
+ opt.execute()
908
+ } else if (opt.toolHandler === 'core:theme' && opt.value) {
909
+ setTheme(opt.value)
910
+ } else if (opt.action) {
911
+ executeAction(opt.action, opt.value)
912
+ }
913
+ setOpen(false)
914
+ setActivePage('root')
915
+ }}
916
+ >
917
+ {opt.toolHandler === 'core:theme' && opt.value === currentTheme
918
+ ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>βœ“</span></span>
919
+ : opt.label}
920
+ </CommandPalette.ListItem>
921
+ ))}
922
+ </CommandPalette.List>
923
+ </CommandPalette.Page>
924
+ ))}
925
+ </CommandPalette>
926
+
927
+ <CreateDialog
928
+ type={createType}
929
+ basePath={basePath}
930
+ onClose={() => setCreateType(null)}
931
+ />
932
+ <BranchBar basePath={basePath} />
933
+ <AuthModal />
934
+ </>
935
+ )
936
+ }