@dfosco/storyboard-react 4.0.0-beta.43 → 4.0.0-beta.45

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