@dfosco/storyboard-react 4.2.0-beta.4 → 4.2.1

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 (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -165
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +407 -67
  89. package/src/vite/data-plugin.test.js +1 -1
@@ -1,8 +1,8 @@
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
1
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
+ import { Command } from 'cmdk'
3
+ import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
4
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
5
+ import Icon from '../Icon.jsx'
6
6
  import {
7
7
  buildPrototypeIndex,
8
8
  listStories,
@@ -16,9 +16,11 @@ import {
16
16
  trackRecent,
17
17
  getCommandPaletteConfig,
18
18
  getToolbarConfig,
19
+ getConfig,
19
20
  setTheme,
20
21
  getTheme,
21
22
  isExcludedByRoute,
23
+ scoreMatch,
22
24
  } from '@dfosco/storyboard-core'
23
25
  import { widgetTypes } from '../canvas/widgets/widgetConfig.js'
24
26
  import CreateDialog from './CreateDialog.jsx'
@@ -26,6 +28,34 @@ import BranchBar from '../BranchBar/BranchBar.jsx'
26
28
  import AuthModal from '../AuthModal/AuthModal.jsx'
27
29
  import './command-palette.css'
28
30
 
31
+ // Icon size for all palette items
32
+ const ICON_SIZE = 16
33
+
34
+ function getIconMap() {
35
+ const config = getCommandPaletteConfig()
36
+ return config?.icons || {}
37
+ }
38
+
39
+ function ItemIcon({ type, toolIcon, toolMeta }) {
40
+ const icons = getIconMap()
41
+ const entry = toolIcon || icons[type] || icons.fallback || 'feather/hexagon'
42
+ const iconName = typeof entry === 'object' ? entry.name : entry
43
+ const meta = toolMeta || (typeof entry === 'object' ? entry.meta : undefined)
44
+ return <Icon name={iconName} size={ICON_SIZE} color="var(--fgColor-muted, #656d76)" {...(meta || {})} />
45
+ }
46
+
47
+ function AvatarIcon({ username }) {
48
+ return (
49
+ <img
50
+ src={`https://github.com/${username}.png?size=32`}
51
+ alt={username}
52
+ width={ICON_SIZE}
53
+ height={ICON_SIZE}
54
+ style={{ flexShrink: 0, borderRadius: '50%' }}
55
+ />
56
+ )
57
+ }
58
+
29
59
  /**
30
60
  * Check if a tool should be hidden from the command palette on the current route.
31
61
  * Uses the same pattern-matching logic as excludeRoutes.
@@ -58,7 +88,8 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
58
88
  const sections = config?.sections || []
59
89
  const groups = []
60
90
  const toolMenus = []
61
- const usedToolIds = new Set() // Track tools already listed by source:"tools" sections
91
+ const usedToolIds = new Set()
92
+ const hiddenFromSearchIds = new Set()
62
93
  const basePath = prefix || '/'
63
94
 
64
95
  for (const section of sections) {
@@ -80,6 +111,7 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
80
111
  if (result?.group) groups.push(result.group)
81
112
  if (result?.subPages) toolMenus.push(...result.subPages)
82
113
  if (result?.usedToolIds) result.usedToolIds.forEach(id => usedToolIds.add(id))
114
+ if (result?.hiddenFromSearchIds) result.hiddenFromSearchIds.forEach(id => hiddenFromSearchIds.add(id))
83
115
  continue
84
116
  }
85
117
 
@@ -90,13 +122,14 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
90
122
  items: section.items.map((item, i) => {
91
123
  const id = `cfg:${section.id}:${i}`
92
124
  if (item.type === 'link') {
125
+ const resolvedUrl = item.url?.startsWith('/') ? prefix + item.url : item.url
93
126
  return {
94
127
  id,
95
128
  children: item.label,
96
129
  keywords: item.keywords || [item.label],
130
+ url: resolvedUrl,
97
131
  onClick: () => {
98
- const url = item.url?.startsWith('/') ? prefix + item.url : item.url
99
- if (url) window.location.href = url
132
+ if (resolvedUrl) window.location.href = resolvedUrl
100
133
  },
101
134
  }
102
135
  }
@@ -204,14 +237,21 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
204
237
  continue
205
238
  }
206
239
 
240
+ if (tool.inlineAction === 'open-palette') {
241
+ // Skip — no point opening the palette from within itself
242
+ continue
243
+ }
244
+
207
245
  // Any remaining tools (all surfaces)
208
246
  if (tool.render === 'link' && tool.url) {
247
+ const resolvedUrl = tool.url.startsWith('/') ? prefix + tool.url : tool.url
209
248
  remainingItems.push({
210
249
  id: `cfg:${section.id}:${toolId}`,
211
250
  children: label,
212
251
  keywords: [label, toolId].filter(Boolean),
213
252
  showType: false,
214
- onClick: () => { window.location.href = tool.url },
253
+ url: resolvedUrl,
254
+ onClick: () => { window.location.href = resolvedUrl },
215
255
  })
216
256
  } else {
217
257
  // Menu tools: close palette and click the toolbar button to open the menu
@@ -252,9 +292,10 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
252
292
  }
253
293
  }
254
294
 
255
- // Also include any toolMenus sub-pages not yet listed
295
+ // Also include any tool sub-pages not yet listed (skip non-tool sub-pages like create-widget)
256
296
  for (const menu of toolMenus) {
257
- const menuToolId = menu.id?.replace('tool:', '')
297
+ if (!menu.id?.startsWith('tool:')) continue
298
+ const menuToolId = menu.id.replace('tool:', '')
258
299
  if (usedToolIds.has(menuToolId)) continue
259
300
  if (remainingItems.some(i => i.id === `cfg:${section.id}:${menuToolId}`)) continue
260
301
  remainingItems.push({
@@ -268,6 +309,18 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
268
309
  }
269
310
 
270
311
  if (remainingItems.length === 0) continue
312
+
313
+ // Stamp toolIcon from toolbar config onto remaining items
314
+ for (const item of remainingItems) {
315
+ if (!item.toolIcon) {
316
+ const match = item.id?.match(/cfg:[^:]+:(.+)/)
317
+ if (match) {
318
+ const t = allTools[match[1]]
319
+ if (t?.icon) item.toolIcon = t.icon
320
+ }
321
+ }
322
+ }
323
+
271
324
  groups.push({
272
325
  heading: section.title,
273
326
  id: `cfg:${section.id}`,
@@ -275,7 +328,7 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
275
328
  })
276
329
  }
277
330
 
278
- return { groups, toolMenus }
331
+ return { groups, toolMenus, hiddenFromSearchIds }
279
332
  }
280
333
 
281
334
  function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction) {
@@ -288,11 +341,11 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
288
341
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
289
342
  if (!isLocalDev) return null
290
343
  const createItems = [
291
- { id: 'create:canvas', children: 'Canvas', keywords: ['create', 'canvas', 'new', 'board'], showType: false, onClick: () => onCreateAction?.('Canvas') },
292
- { id: 'create:prototype', children: 'Prototype', keywords: ['create', 'prototype', 'new', 'page'], showType: false, onClick: () => onCreateAction?.('Prototype') },
293
- { id: 'create:component', children: 'Component', keywords: ['create', 'component', 'new', 'story'], showType: false, onClick: () => onCreateAction?.('Component') },
294
- { id: 'create:flow', children: 'Prototype Flow', keywords: ['create', 'flow', 'new', 'data'], showType: false, onClick: () => onCreateAction?.('Flow') },
295
- { id: 'create:page', children: 'Prototype Page', keywords: ['create', 'page', 'new'], showType: false, onClick: () => onCreateAction?.('Page') },
344
+ { id: 'create:canvas', children: 'Canvas', keywords: ['create', 'canvas', 'new', 'board'], itemType: 'create', onClick: () => onCreateAction?.('Canvas') },
345
+ { id: 'create:prototype', children: 'Prototype', keywords: ['create', 'prototype', 'new', 'page'], itemType: 'create', onClick: () => onCreateAction?.('Prototype') },
346
+ { id: 'create:component', children: 'Component', keywords: ['create', 'component', 'new', 'story'], itemType: 'create', onClick: () => onCreateAction?.('Component') },
347
+ { id: 'create:flow', children: 'Prototype Flow', keywords: ['create', 'flow', 'new', 'data'], itemType: 'create', onClick: () => onCreateAction?.('Flow') },
348
+ { id: 'create:page', children: 'Prototype Page', keywords: ['create', 'page', 'new'], itemType: 'create', onClick: () => onCreateAction?.('Page') },
296
349
  ]
297
350
  return { group: { heading: section.title, id: `cfg:${section.id}`, items: createItems } }
298
351
  }
@@ -303,23 +356,68 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
303
356
  if (!isLocalDev) return null
304
357
  const isCanvasRoute = typeof window !== 'undefined' && window.location.pathname.includes('/canvas/')
305
358
  if (!isCanvasRoute) return null
306
- const items = Object.entries(widgetTypes).map(([type, def]) => ({
359
+ const hiddenTypes = new Set(['link-preview', 'image', 'figma-embed', 'codepen-embed', 'story', 'terminal-read', 'agent'])
360
+ const items = Object.entries(widgetTypes).filter(([type]) => !hiddenTypes.has(type)).map(([type, def]) => ({
307
361
  id: `create-widget:${type}`,
308
362
  children: def.label,
309
363
  keywords: ['add', 'widget', 'create', type, def.label.toLowerCase()],
310
- showType: false,
364
+ itemType: type,
311
365
  onClick: () => {
312
366
  document.dispatchEvent(new CustomEvent('storyboard:canvas:add-widget', { detail: { type } }))
313
367
  },
314
368
  }))
315
- return { group: { heading: section.title, id: `cfg:${section.id}`, items } }
369
+
370
+ // Build agent submenu from canvas.agents config
371
+ const subPages = []
372
+ const canvasConfig = getConfig('canvas')
373
+ const agentsConfig = canvasConfig?.agents
374
+ if (agentsConfig && typeof agentsConfig === 'object') {
375
+ const agentEntries = Object.entries(agentsConfig)
376
+ if (agentEntries.length > 0) {
377
+ const pageId = 'create-widget:agents'
378
+ subPages.push({
379
+ id: pageId,
380
+ label: 'Add agent to canvas',
381
+ title: 'Add agent to canvas',
382
+ keywords: ['agent', 'add', 'widget', 'copilot', 'claude', 'codex'],
383
+ options: agentEntries.map(([id, cfg]) => ({
384
+ label: cfg.label || id,
385
+ icon: cfg.icon,
386
+ execute: () => {
387
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:add-widget', {
388
+ detail: {
389
+ type: 'agent',
390
+ props: {
391
+ agentId: id,
392
+ startupCommand: cfg.startupCommand || id,
393
+ ...(cfg.defaultWidth ? { width: cfg.defaultWidth } : {}),
394
+ ...(cfg.defaultHeight ? { height: cfg.defaultHeight } : {}),
395
+ },
396
+ },
397
+ }))
398
+ },
399
+ })),
400
+ })
401
+ items.push({
402
+ id: 'create-widget:agent',
403
+ children: 'Agent',
404
+ keywords: ['add', 'widget', 'create', 'agent'],
405
+ itemType: 'agent',
406
+ hideFromSearch: true,
407
+ onClick: () => onNavigateToPage?.(pageId),
408
+ closeOnSelect: false,
409
+ })
410
+ }
411
+ }
412
+
413
+ return { group: { heading: section.title, id: `cfg:${section.id}`, items }, subPages }
316
414
  }
317
415
 
318
416
  // --- Starred source (reads from viewfinder localStorage) ---
319
417
  if (section.source === 'starred') {
320
- const STARRED_KEY = 'sb-viewfinder-starred'
418
+ const STARRED_KEY = 'sb-workspace-starred'
321
419
  let starredIds = []
322
- try { starredIds = JSON.parse(localStorage.getItem(STARRED_KEY)) || [] } catch {}
420
+ try { starredIds = JSON.parse(localStorage.getItem(STARRED_KEY)) || [] } catch { /* empty */ }
323
421
  if (starredIds.length === 0) return null
324
422
 
325
423
  const index = buildPrototypeIndex()
@@ -346,7 +444,8 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
346
444
  id: `starred:${id}`,
347
445
  children: artifact.name,
348
446
  keywords: ['starred', 'star', artifact.name.toLowerCase()],
349
- showType: false,
447
+ itemType: artifact._type === 'canvas' ? 'canvas' : 'prototype',
448
+ url: route,
350
449
  onClick: () => {
351
450
  if (artifact.isExternal) {
352
451
  window.open(route, '_blank')
@@ -378,17 +477,20 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
378
477
  id: `cmd:${action.id}/${child.id || child.label}`,
379
478
  children: child.label,
380
479
  keywords: [action.label, child.label],
480
+ itemType: 'command',
381
481
  onClick: () => { if (child.execute) child.execute() },
382
482
  })
383
483
  }
384
484
  } else if (action.type === 'link' && action.url) {
485
+ const resolvedUrl = action.url.startsWith('/') && !action.url.startsWith('//') ? prefix + action.url : action.url
385
486
  commandItems.push({
386
487
  id: `cmd:${action.id}`,
387
488
  children: action.label,
388
489
  keywords: [action.label],
490
+ itemType: 'link',
491
+ url: resolvedUrl,
389
492
  onClick: () => {
390
- const url = action.url.startsWith('/') && !action.url.startsWith('//') ? prefix + action.url : action.url
391
- window.location.href = url
493
+ window.location.href = resolvedUrl
392
494
  },
393
495
  })
394
496
  } else {
@@ -396,6 +498,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
396
498
  id: `cmd:${action.id}`,
397
499
  children: action.label,
398
500
  keywords: [action.label],
501
+ itemType: 'command',
399
502
  onClick: () => executeAction(action.id),
400
503
  })
401
504
  }
@@ -414,16 +517,20 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
414
517
  group: {
415
518
  heading: section.title,
416
519
  id: `cfg:${section.id}`,
417
- items: items.map(entry => ({
418
- id: `cfg:${section.id}:${entry.type}:${entry.key}`,
419
- children: entry.label,
420
- keywords: [entry.type, entry.key, entry.label],
421
- onClick: () => {
422
- trackRecent(entry.type, entry.key, entry.label)
423
- const route = resolveRecentRoute(entry, prefix)
424
- if (route) window.location.href = route
425
- },
426
- })),
520
+ items: items.map(entry => {
521
+ const route = resolveRecentRoute(entry, prefix)
522
+ return {
523
+ id: `cfg:${section.id}:${entry.type}:${entry.key}`,
524
+ children: entry.label,
525
+ keywords: [entry.type, entry.key, entry.label],
526
+ itemType: entry.type,
527
+ url: route || undefined,
528
+ onClick: () => {
529
+ trackRecent(entry.type, entry.key, entry.label)
530
+ if (route) window.location.href = route
531
+ },
532
+ }
533
+ }),
427
534
  },
428
535
  }
429
536
  }
@@ -477,6 +584,8 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
477
584
  id: `cfg:${section.id}:${item.id}`,
478
585
  children: item.name,
479
586
  keywords: [item.name, item.id, item.type],
587
+ itemType: item.type,
588
+ url: item.route,
480
589
  onClick: () => {
481
590
  trackRecent(item.type, item.id, item.name)
482
591
  window.location.href = item.route
@@ -489,7 +598,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
489
598
  /**
490
599
  * Build a section from toolbar.config.json tools.
491
600
  * If toolIds is provided, only include those tools in that order (with optional custom labels).
492
- * Otherwise include all command-list tools.
601
+ * Otherwise include all command-palette tools.
493
602
  *
494
603
  * toolIds format: ["theme", "flows"] or [{ id: "theme", label: "Change theme" }]
495
604
  */
@@ -506,20 +615,22 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
506
615
  for (const entry of section.toolIds) {
507
616
  const toolId = typeof entry === 'string' ? entry : entry.id
508
617
  const customLabel = typeof entry === 'object' ? entry.label : null
618
+ const closeOnSelect = typeof entry === 'object' ? entry.closeOnSelect : undefined
619
+ const iconMeta = typeof entry === 'object' ? entry.meta : undefined
509
620
  const tool = tools[toolId]
510
621
  if (!tool) continue
511
622
  const state = getToolbarToolState(toolId)
512
623
  if (state === 'disabled' || state === 'hidden') continue
513
624
  if (isHiddenInPalette(tool, basePath)) continue
514
- entries.push({ toolId, tool, label: customLabel || tool.label || toolId })
625
+ entries.push({ toolId, tool, label: customLabel || tool.label || toolId, toolIcon: tool.icon, toolMeta: iconMeta, closeOnSelect: closeOnSelect ?? tool.closeOnSelect })
515
626
  }
516
627
  } else {
517
628
  for (const [toolId, tool] of Object.entries(tools)) {
518
- if (tool.surface !== 'command-list') continue
629
+ if (tool.surface !== 'command-palette') continue
519
630
  const state = getToolbarToolState(toolId)
520
631
  if (state === 'disabled' || state === 'hidden') continue
521
632
  if (isHiddenInPalette(tool, basePath)) continue
522
- entries.push({ toolId, tool, label: tool.label || toolId })
633
+ entries.push({ toolId, tool, label: tool.label || toolId, toolIcon: tool.icon, toolMeta: undefined, closeOnSelect: tool.closeOnSelect })
523
634
  }
524
635
  }
525
636
 
@@ -528,14 +639,20 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
528
639
  const items = []
529
640
  const subPages = []
530
641
 
531
- for (const { toolId, tool, label } of entries) {
642
+ for (const { toolId, tool, label, closeOnSelect: entryCloseOnSelect } of entries) {
532
643
  // Inline actions
533
644
  if (tool.inlineAction === 'toggle-chrome') {
645
+ const isHidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
534
646
  items.push({
535
647
  id: `cfg:${section.id}:${toolId}`,
536
- children: label,
648
+ toolIcon: isHidden ? 'primer/light-bulb' : 'primer/light-bulb',
649
+ children: <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
650
+ <span>Hide toolbars</span>
651
+ <span>{isHidden ? '✓' : ''}</span>
652
+ </span>,
537
653
  keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
538
654
  showType: false,
655
+ closeOnSelect: entryCloseOnSelect,
539
656
  onClick: () => {
540
657
  document.documentElement.classList.toggle('storyboard-chrome-hidden')
541
658
  },
@@ -543,14 +660,29 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
543
660
  continue
544
661
  }
545
662
 
663
+ if (tool.inlineAction === 'open-palette') {
664
+ items.push({
665
+ id: `cfg:${section.id}:${toolId}`,
666
+ children: label,
667
+ keywords: [label, toolId, 'command', 'palette', 'search'].filter(Boolean),
668
+ showType: false,
669
+ onClick: () => {
670
+ document.dispatchEvent(new CustomEvent('storyboard:open-palette'))
671
+ },
672
+ })
673
+ continue
674
+ }
675
+
546
676
  if (tool.render === 'link' && tool.url) {
677
+ const resolvedUrl = tool.url.startsWith('/') ? prefix + tool.url : tool.url
547
678
  items.push({
548
679
  id: `cfg:${section.id}:${toolId}`,
549
680
  children: label,
550
681
  keywords: [label, toolId].filter(Boolean),
682
+ url: resolvedUrl,
683
+ closeOnSelect: entryCloseOnSelect,
551
684
  onClick: () => {
552
- const url = tool.url.startsWith('/') ? prefix + tool.url : tool.url
553
- window.location.href = url
685
+ window.location.href = resolvedUrl
554
686
  },
555
687
  })
556
688
  continue
@@ -570,6 +702,8 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
570
702
  options: children.map(child => ({
571
703
  label: child.label,
572
704
  execute: child.execute,
705
+ type: child.type,
706
+ active: child.active,
573
707
  })),
574
708
  })
575
709
  items.push({
@@ -611,7 +745,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
611
745
  continue
612
746
  }
613
747
 
614
- // Menu tool without sub-items or options — click toolbar button
748
+ // Menu tool without sub-items or options — dispatch open event, fall back to clicking toolbar button
615
749
  const ariaLabel = tool.ariaLabel || tool.label || toolId
616
750
  items.push({
617
751
  id: `cfg:${section.id}:${toolId}`,
@@ -620,6 +754,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
620
754
  showType: false,
621
755
  onClick: () => {
622
756
  setTimeout(() => {
757
+ document.dispatchEvent(new CustomEvent(`storyboard:open-${toolId}`))
623
758
  const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
624
759
  if (btn) btn.click()
625
760
  }, 100)
@@ -634,6 +769,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
634
769
  id: `cfg:${section.id}:${toolId}`,
635
770
  children: label,
636
771
  keywords: [label, toolId].filter(Boolean),
772
+ closeOnSelect: entryCloseOnSelect,
637
773
  onClick: () => { if (action) executeAction(action.id) },
638
774
  })
639
775
  continue
@@ -643,10 +779,24 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
643
779
  id: `cfg:${section.id}:${toolId}`,
644
780
  children: label,
645
781
  keywords: [label, toolId].filter(Boolean),
782
+ closeOnSelect: entryCloseOnSelect,
646
783
  onClick: () => executeAction(toolId),
647
784
  })
648
785
  }
649
786
 
787
+ // Add toolIcon and toolMeta to all items from their entry
788
+ const iconByToolId = new Map(entries.map(e => [e.toolId, { icon: e.toolIcon, meta: e.toolMeta }]))
789
+ for (const item of items) {
790
+ if (!item.toolIcon) {
791
+ const match = item.id?.match(/cfg:[^:]+:(.+)/)
792
+ if (match && iconByToolId.has(match[1])) {
793
+ const entry = iconByToolId.get(match[1])
794
+ item.toolIcon = entry.icon
795
+ if (!item.toolMeta && entry.meta) item.toolMeta = entry.meta
796
+ }
797
+ }
798
+ }
799
+
650
800
  return {
651
801
  group: {
652
802
  heading: section.title,
@@ -722,10 +872,10 @@ function buildPaletteItems(basePath, onCreateAction, onNavigateToPage) {
722
872
  const base = (basePath || '/').replace(/\/+$/, '')
723
873
  const prefix = base === '/' ? '' : base
724
874
 
725
- const { groups, toolMenus } = buildConfigSections(prefix, onNavigateToPage, onCreateAction)
875
+ const { groups, toolMenus, hiddenFromSearchIds } = buildConfigSections(prefix, onNavigateToPage, onCreateAction)
726
876
  const authorIndex = buildAuthorIndex(prefix)
727
877
 
728
- return { groups, toolMenus, authorIndex }
878
+ return { groups, toolMenus, authorIndex, hiddenFromSearchIds }
729
879
  }
730
880
 
731
881
  /**
@@ -738,9 +888,29 @@ export default function StoryboardCommandPalette({ basePath }) {
738
888
  const [items, setItems] = useState([])
739
889
  const [toolMenus, setToolMenus] = useState([])
740
890
  const [authorIndex, setAuthorIndex] = useState(new Map())
891
+ const [hiddenFromSearchIds, setHiddenFromSearchIds] = useState(new Set())
741
892
  const [activePage, setActivePage] = useState('root')
742
893
  const [createType, setCreateType] = useState(null)
743
894
  const [currentTheme, setCurrentTheme] = useState(() => getTheme())
895
+ const [refreshKey, setRefreshKey] = useState(0)
896
+
897
+ // Track modifier keys for link items (cmd/ctrl → new tab, alt → copy link).
898
+ // Updated from the most recent keyboard/mouse event via a capturing listener
899
+ // on the document so it fires before cmdk's own handlers.
900
+ const modifierHeldRef = useRef(false)
901
+ const altHeldRef = useRef(false)
902
+ useEffect(() => {
903
+ const track = (e) => { modifierHeldRef.current = e.metaKey || e.ctrlKey; altHeldRef.current = e.altKey }
904
+ const reset = () => { modifierHeldRef.current = false; altHeldRef.current = false }
905
+ document.addEventListener('keydown', track, true)
906
+ document.addEventListener('keyup', reset, true)
907
+ document.addEventListener('mousedown', track, true)
908
+ return () => {
909
+ document.removeEventListener('keydown', track, true)
910
+ document.removeEventListener('keyup', reset, true)
911
+ document.removeEventListener('mousedown', track, true)
912
+ }
913
+ }, [])
744
914
 
745
915
  // Keep currentTheme in sync when theme changes
746
916
  useEffect(() => {
@@ -759,43 +929,29 @@ export default function StoryboardCommandPalette({ basePath }) {
759
929
  setActivePage(pageId)
760
930
  }
761
931
 
762
- // Listen for Cmd+K directly
763
- // The Svelte CoreUIBar also handles Cmd+K by dispatching
764
- // 'storyboard:toggle-palette'. We use rAF to detect if Svelte
765
- // already fired the toggle event and skip to avoid double-toggle.
932
+ // Listen for Cmd+K directly to toggle the palette
766
933
  useEffect(() => {
767
- let toggledByEvent = false
768
-
769
- function handleToggleEvent() {
770
- toggledByEvent = true
771
- }
772
-
773
934
  function handleKeyDown(e) {
774
935
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
775
936
  e.preventDefault()
776
- toggledByEvent = false
777
- requestAnimationFrame(() => {
778
- if (toggledByEvent) return
779
- const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
780
- setItems(built.groups)
781
- setToolMenus(built.toolMenus)
782
- setAuthorIndex(built.authorIndex)
783
- setSearch('')
784
- setActivePage('root')
785
- setOpen(prev => !prev)
786
- })
937
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
938
+ setItems(built.groups)
939
+ setToolMenus(built.toolMenus)
940
+ setAuthorIndex(built.authorIndex)
941
+ setHiddenFromSearchIds(built.hiddenFromSearchIds || new Set())
942
+ setSearch('')
943
+ setActivePage('root')
944
+ setOpen(prev => !prev)
787
945
  }
788
946
  }
789
947
 
790
- document.addEventListener('storyboard:toggle-palette', handleToggleEvent)
791
948
  document.addEventListener('keydown', handleKeyDown)
792
949
  return () => {
793
- document.removeEventListener('storyboard:toggle-palette', handleToggleEvent)
794
950
  document.removeEventListener('keydown', handleKeyDown)
795
951
  }
796
952
  }, [basePath])
797
953
 
798
- // Listen for toggle events from Svelte CoreUIBar
954
+ // Listen for toggle/open events from toolbar buttons (e.g. CommandPaletteTrigger)
799
955
  useEffect(() => {
800
956
  function handleToggle() {
801
957
  setOpen(prev => {
@@ -806,6 +962,7 @@ export default function StoryboardCommandPalette({ basePath }) {
806
962
  setItems(built.groups)
807
963
  setToolMenus(built.toolMenus)
808
964
  setAuthorIndex(built.authorIndex)
965
+ setHiddenFromSearchIds(built.hiddenFromSearchIds || new Set())
809
966
  setSearch('')
810
967
  setActivePage('root')
811
968
  }, 0)
@@ -819,6 +976,7 @@ export default function StoryboardCommandPalette({ basePath }) {
819
976
  setItems(built.groups)
820
977
  setToolMenus(built.toolMenus)
821
978
  setAuthorIndex(built.authorIndex)
979
+ setHiddenFromSearchIds(built.hiddenFromSearchIds || new Set())
822
980
  setSearch('')
823
981
  setActivePage('root')
824
982
  setOpen(true)
@@ -832,12 +990,27 @@ export default function StoryboardCommandPalette({ basePath }) {
832
990
  }
833
991
  }, [basePath])
834
992
 
993
+ // Rebuild palette items when a toggle is clicked (refreshKey changes)
994
+ useEffect(() => {
995
+ if (refreshKey === 0) return
996
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
997
+ // eslint-disable-next-line react-hooks/set-state-in-effect
998
+ setItems(built.groups)
999
+ setToolMenus(built.toolMenus)
1000
+ }, [refreshKey, basePath])
1001
+
835
1002
  const handleChangeOpen = useCallback((value) => {
836
1003
  if (!value) {
1004
+ // Escape from a sub-page goes back to root instead of closing
1005
+ if (activePage !== 'root') {
1006
+ setActivePage('root')
1007
+ setSearch('')
1008
+ return
1009
+ }
837
1010
  setOpen(false)
838
1011
  setActivePage('root')
839
1012
  }
840
- }, [])
1013
+ }, [activePage])
841
1014
 
842
1015
  // Flatten sub-page options into searchable groups so they appear in root search
843
1016
  const subPageGroups = useMemo(() => {
@@ -846,12 +1019,13 @@ export default function StoryboardCommandPalette({ basePath }) {
846
1019
  id: `subpage:${menu.id}`,
847
1020
  items: (menu.options || []).map((opt, i) => ({
848
1021
  id: `subpage:${menu.id}:${i}`,
849
- children: opt.toolHandler === 'core:theme' && opt.value === currentTheme
850
- ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>✓</span></span>
851
- : opt.label,
1022
+ label: opt.label,
1023
+ icon: opt.icon,
1024
+ isToggle: opt.type === 'toggle',
1025
+ isActiveToggle: opt.type === 'toggle' && opt.active,
1026
+ isActiveTheme: opt.toolHandler === 'core:theme' && opt.value === currentTheme,
852
1027
  keywords: [opt.label, menu.label || menu.id],
853
- showType: false,
854
- onClick: () => {
1028
+ onSelect: () => {
855
1029
  if (opt.execute) {
856
1030
  opt.execute()
857
1031
  } else if (opt.toolHandler === 'core:theme' && opt.value) {
@@ -859,147 +1033,266 @@ export default function StoryboardCommandPalette({ basePath }) {
859
1033
  } else if (opt.action) {
860
1034
  executeAction(opt.action, opt.value)
861
1035
  }
862
- setOpen(false)
863
- setActivePage('root')
1036
+ if (opt.type === 'toggle') {
1037
+ setRefreshKey(k => k + 1)
1038
+ } else {
1039
+ setOpen(false)
1040
+ setActivePage('root')
1041
+ }
864
1042
  },
865
1043
  })),
866
1044
  })).filter(g => g.items.length > 0)
867
- }, [toolMenus, currentTheme])
868
-
869
- const filteredItems = useMemo(() => {
870
- const base = filterItems(items, search)
871
- if (!search) return base
872
- const matchingSub = filterItems(subPageGroups, search)
873
- const result = [...base, ...matchingSub]
874
-
875
- // Author search: match usernames against author index
876
- const q = search.toLowerCase()
877
- const authorQ = q.startsWith('@') ? q.slice(1) : q
878
- for (const [key, { author, items: authorItems }] of authorIndex) {
879
- if (!key.includes(authorQ)) continue
880
- // Avoid duplicates with already-shown artifact items
881
- const shownIds = new Set(result.flatMap(g => g.items.map(i => i.id)))
882
- const uniqueItems = authorItems.filter(item => !shownIds.has(`author:${item.id}`))
883
- if (uniqueItems.length === 0) continue
884
- result.push({
1045
+ }, [toolMenus, currentTheme, refreshKey])
1046
+
1047
+ // Build author groups from the index
1048
+ const authorGroups = useMemo(() => {
1049
+ const groups = []
1050
+ for (const [, { author, items: authorItems }] of authorIndex) {
1051
+ groups.push({
885
1052
  heading: `Artifacts by @${author}`,
886
- id: `author:${key}`,
887
- items: uniqueItems.map(item => ({
1053
+ id: `author:${author.toLowerCase()}`,
1054
+ author,
1055
+ items: authorItems.map(item => ({
888
1056
  id: `author:${item.id}`,
889
- children: (
890
- <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
891
- <span>{item.name}</span>
892
- <span style={{ fontSize: '12px', color: 'var(--fgColor-muted, #999)' }}>{item.type}</span>
893
- </span>
894
- ),
1057
+ label: item.name,
1058
+ type: item.type,
1059
+ url: item.route,
895
1060
  keywords: [item.name, item.id, item.type, author, `@${author}`],
896
- showType: false,
897
- onClick: () => {
1061
+ onSelect: () => {
898
1062
  trackRecent(item.type.toLowerCase(), item.id, item.name)
1063
+ setOpen(false)
1064
+ setActivePage('root')
899
1065
  window.location.href = item.route
900
1066
  },
901
1067
  })),
902
1068
  })
903
1069
  }
904
-
905
- return result
906
- }, [items, search, subPageGroups, authorIndex])
1070
+ return groups
1071
+ }, [authorIndex])
907
1072
 
908
1073
  // Remove consecutive separators and leading/trailing separators
909
- const deduplicatedItems = useMemo(() => {
1074
+ const cleanedItems = useMemo(() => {
910
1075
  const result = []
911
- for (const item of filteredItems) {
1076
+ for (const item of items) {
912
1077
  const isSep = item.id?.startsWith('cfg:sep')
913
1078
  if (isSep && (result.length === 0 || result[result.length - 1].id?.startsWith('cfg:sep'))) continue
914
1079
  result.push(item)
915
1080
  }
916
- // Remove trailing separator
917
1081
  while (result.length > 0 && result[result.length - 1].id?.startsWith('cfg:sep')) result.pop()
918
1082
  return result
919
- }, [filteredItems])
920
-
921
- // Items without separators used for keyboard navigation indexing
922
- const navigableItems = useMemo(
923
- () => deduplicatedItems.filter(list => !list.id?.startsWith('cfg:sep')),
924
- [deduplicatedItems]
925
- )
1083
+ }, [items])
1084
+
1085
+ // Build search value string from keywords array
1086
+ function itemValue(item) {
1087
+ const parts = []
1088
+ if (typeof item.children === 'string') parts.push(item.children)
1089
+ if (item.label) parts.push(item.label)
1090
+ if (item.keywords) parts.push(...item.keywords)
1091
+ return parts.filter(Boolean).join(' ')
1092
+ }
926
1093
 
927
- const handleChangeSearch = useCallback((value) => {
928
- setSearch(value)
1094
+ // Custom filter using scoreMatch for better ranking.
1095
+ // scoreMatch tiers: prefix (100) > word-boundary (75) > substring (50) > fuzzy (5-25).
1096
+ // Normalizes to 0-1 for cmdk; weak fuzzy matches (score < 10) are hidden to
1097
+ // prevent garbage results from dominating above exact matches in other groups.
1098
+ const MAX_SCORE = 110
1099
+ const cmdkFilter = useCallback((value, search) => {
1100
+ if (!search) return 1
1101
+ const score = scoreMatch(value, search.toLowerCase().trim())
1102
+ if (score < 10) return 0
1103
+ return Math.min(1, score / MAX_SCORE)
929
1104
  }, [])
930
1105
 
1106
+ function showCenterToast(message) {
1107
+ const el = document.createElement('div')
1108
+ Object.assign(el.style, {
1109
+ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
1110
+ zIndex: '10000', padding: '0.625rem 1rem', borderRadius: '0.5rem',
1111
+ background: 'var(--bgColor-emphasis, #1f2328)', color: 'var(--fgColor-onEmphasis, #fff)',
1112
+ fontSize: '0.8125rem', fontFamily: 'var(--fontStack-sansSerif, system-ui)',
1113
+ opacity: '0', transition: 'opacity 0.15s ease',
1114
+ pointerEvents: 'none',
1115
+ })
1116
+ el.textContent = message
1117
+ document.body.appendChild(el)
1118
+ requestAnimationFrame(() => { el.style.opacity = '1' })
1119
+ setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 200) }, 1800)
1120
+ }
1121
+
1122
+ function copyLinkToClipboard(url, itemType) {
1123
+ const fullUrl = url.startsWith('/') ? window.location.origin + url : url
1124
+ const isCanvasRoute = typeof window !== 'undefined' && window.location.pathname.includes('/canvas/')
1125
+ const isPasteable = itemType === 'prototype' || itemType === 'story'
1126
+ const shouldPaste = isCanvasRoute && isPasteable
1127
+
1128
+ navigator.clipboard.writeText(fullUrl).then(() => {
1129
+ showCenterToast(shouldPaste ? 'Link copied and pasted' : 'Link copied to clipboard')
1130
+ })
1131
+
1132
+ if (shouldPaste) {
1133
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:paste-url', { detail: { url: fullUrl } }))
1134
+ }
1135
+ }
1136
+
931
1137
  return (
932
1138
  <>
933
- <CommandPalette
934
- onChangeSearch={handleChangeSearch}
935
- onChangeOpen={handleChangeOpen}
936
- search={search}
937
- isOpen={open}
938
- page={activePage}
939
- placeholder={activePage === 'root'
940
- ? 'Search commands, prototypes, canvases, stories...'
941
- : `Search ${toolMenus.find(m => m.id === activePage)?.label || ''}...`
942
- }
1139
+ <Command.Dialog
1140
+ open={open}
1141
+ onOpenChange={handleChangeOpen}
1142
+ label="Command Menu"
1143
+ className="command-palette"
1144
+ shouldFilter={activePage === 'root'}
1145
+ filter={cmdkFilter}
1146
+ aria-describedby={undefined}
1147
+ onKeyDown={(e) => {
1148
+ if (e.key === 'Escape' && activePage !== 'root') {
1149
+ e.preventDefault()
1150
+ e.stopPropagation()
1151
+ setActivePage('root')
1152
+ setSearch('')
1153
+ }
1154
+ }}
943
1155
  >
944
- <CommandPalette.Page id="root">
945
- {deduplicatedItems.length ? (
946
- deduplicatedItems.map((list) => (
947
- list.id?.startsWith('cfg:sep') ? (
948
- !search && <hr key={list.id} style={{ border: 'none', borderTop: '1px solid var(--borderColor-muted, #e5e5e5)', margin: '4px 14px' }} />
949
- ) : (
950
- <CommandPalette.List key={list.id} heading={list.heading}>
951
- {list.items.map(({ id, ...rest }) => (
952
- <CommandPalette.ListItem
953
- key={id}
954
- index={getItemIndex(navigableItems, id)}
955
- {...rest}
956
- />
1156
+ <VisuallyHidden.Root asChild>
1157
+ <DialogPrimitive.Title>Command Menu</DialogPrimitive.Title>
1158
+ </VisuallyHidden.Root>
1159
+ <DialogPrimitive.Description className="sr-only" style={{ display: 'none' }} />
1160
+ <Command.Input
1161
+ placeholder={activePage === 'root'
1162
+ ? 'Search commands, prototypes, canvases, stories...'
1163
+ : `Search ${toolMenus.find(m => m.id === activePage)?.label || ''}...`
1164
+ }
1165
+ value={search}
1166
+ onValueChange={setSearch}
1167
+ />
1168
+ <Command.List>
1169
+ <Command.Empty>No results found.</Command.Empty>
1170
+
1171
+ {activePage === 'root' ? (
1172
+ <>
1173
+ {/* Sub-page options flattened for root search — rendered first
1174
+ so high-scoring items (e.g. "Copilot CLI" for query "cop")
1175
+ appear above weaker matches in later groups. */}
1176
+ {search && subPageGroups.map(group => (
1177
+ <Command.Group key={group.id} heading={group.heading}>
1178
+ {group.items.map(item => (
1179
+ <Command.Item
1180
+ key={item.id}
1181
+ value={itemValue(item)}
1182
+ onSelect={item.onSelect}
1183
+ >
1184
+ {item.icon && <Icon name={item.icon} size={ICON_SIZE} color="var(--fgColor-muted, #656d76)" />}
1185
+ <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
1186
+ <span>{item.label}</span>
1187
+ {(item.isActiveToggle || item.isActiveTheme) && <span>✓</span>}
1188
+ </span>
1189
+ </Command.Item>
957
1190
  ))}
958
- </CommandPalette.List>
959
- )
960
- ))
1191
+ </Command.Group>
1192
+ ))}
1193
+
1194
+ {/* Main config-driven groups */}
1195
+ {cleanedItems.map((list) => (
1196
+ list.id?.startsWith('cfg:sep') ? (
1197
+ !search && <Command.Separator key={list.id} />
1198
+ ) : (
1199
+ <Command.Group key={list.id} heading={list.heading}>
1200
+ {list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, toolMeta, closeOnSelect, hideFromSearch, url }) => {
1201
+ if (search && hideFromSearch) return null
1202
+ if (hiddenFromSearchIds.size > 0) {
1203
+ for (const toolId of hiddenFromSearchIds) {
1204
+ if (id?.includes(toolId)) return null
1205
+ }
1206
+ }
1207
+ return (
1208
+ <Command.Item
1209
+ key={id}
1210
+ value={itemValue({ children, keywords })}
1211
+ onSelect={() => {
1212
+ if (url && altHeldRef.current) {
1213
+ copyLinkToClipboard(url, itemType)
1214
+ } else if (url && modifierHeldRef.current) {
1215
+ window.open(url, '_blank')
1216
+ } else {
1217
+ onClick?.()
1218
+ }
1219
+ if (closeOnSelect !== false) {
1220
+ setOpen(false)
1221
+ setActivePage('root')
1222
+ }
1223
+ }}
1224
+ >
1225
+ <ItemIcon type={itemType} toolIcon={toolIcon} toolMeta={toolMeta} />
1226
+ {children}
1227
+ </Command.Item>
1228
+ )
1229
+ })}
1230
+ </Command.Group>
1231
+ )
1232
+ ))}
1233
+
1234
+ {/* Author groups */}
1235
+ {search && authorGroups.map(group => (
1236
+ <Command.Group key={group.id} heading={group.heading}>
1237
+ {group.items.map(item => (
1238
+ <Command.Item
1239
+ key={item.id}
1240
+ value={itemValue(item)}
1241
+ onSelect={() => {
1242
+ if (item.url && altHeldRef.current) {
1243
+ copyLinkToClipboard(item.url, item.type?.toLowerCase())
1244
+ setOpen(false)
1245
+ setActivePage('root')
1246
+ } else if (item.url && modifierHeldRef.current) {
1247
+ window.open(item.url, '_blank')
1248
+ setOpen(false)
1249
+ setActivePage('root')
1250
+ } else {
1251
+ item.onSelect()
1252
+ }
1253
+ }}
1254
+ >
1255
+ <AvatarIcon username={group.author} />
1256
+ <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
1257
+ <span>{item.label}</span>
1258
+ <span style={{ fontSize: '12px', color: 'var(--fgColor-muted, #999)' }}>{item.type}</span>
1259
+ </span>
1260
+ </Command.Item>
1261
+ ))}
1262
+ </Command.Group>
1263
+ ))}
1264
+ </>
961
1265
  ) : (
962
- <div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280' }}>
963
- No results for &ldquo;{search}&rdquo;
964
- </div>
1266
+ /* Tool-menu sub-pages */
1267
+ toolMenus.filter(menu => menu.id === activePage).map(menu => (
1268
+ <Command.Group key={menu.id} heading={menu.title || menu.label || menu.id}>
1269
+ {(menu.options || []).map((opt, i) => (
1270
+ <Command.Item
1271
+ key={`${menu.id}:${i}`}
1272
+ value={opt.label}
1273
+ onSelect={() => {
1274
+ if (opt.execute) {
1275
+ opt.execute()
1276
+ } else if (opt.toolHandler === 'core:theme' && opt.value) {
1277
+ setTheme(opt.value)
1278
+ } else if (opt.action) {
1279
+ executeAction(opt.action, opt.value)
1280
+ }
1281
+ setOpen(false)
1282
+ setActivePage('root')
1283
+ }}
1284
+ >
1285
+ {opt.icon && <Icon name={opt.icon} size={ICON_SIZE} color="var(--fgColor-muted, #656d76)" />}
1286
+ {opt.toolHandler === 'core:theme' && opt.value === currentTheme
1287
+ ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>✓</span></span>
1288
+ : opt.label}
1289
+ </Command.Item>
1290
+ ))}
1291
+ </Command.Group>
1292
+ ))
965
1293
  )}
966
- </CommandPalette.Page>
967
-
968
- {/* Tool-menu sub-pages */}
969
- {toolMenus.map(menu => (
970
- <CommandPalette.Page
971
- key={menu.id}
972
- id={menu.id}
973
- onEscape={() => { setActivePage('root'); setSearch('') }}
974
- searchPrefix={[menu.label || menu.id]}
975
- >
976
- <CommandPalette.List heading={menu.title || menu.label || menu.id}>
977
- {(menu.options || []).map((opt, i) => (
978
- <CommandPalette.ListItem
979
- key={`${menu.id}:${i}`}
980
- index={i}
981
- showType={false}
982
- onClick={() => {
983
- if (opt.execute) {
984
- opt.execute()
985
- } else if (opt.toolHandler === 'core:theme' && opt.value) {
986
- setTheme(opt.value)
987
- } else if (opt.action) {
988
- executeAction(opt.action, opt.value)
989
- }
990
- setOpen(false)
991
- setActivePage('root')
992
- }}
993
- >
994
- {opt.toolHandler === 'core:theme' && opt.value === currentTheme
995
- ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>✓</span></span>
996
- : opt.label}
997
- </CommandPalette.ListItem>
998
- ))}
999
- </CommandPalette.List>
1000
- </CommandPalette.Page>
1001
- ))}
1002
- </CommandPalette>
1294
+ </Command.List>
1295
+ </Command.Dialog>
1003
1296
 
1004
1297
  <CreateDialog
1005
1298
  type={createType}