@dfosco/storyboard-react 4.2.0-beta.1 → 4.2.0-beta.17

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 (48) hide show
  1. package/package.json +5 -4
  2. package/src/AuthModal/AuthModal.jsx +6 -2
  3. package/src/BranchBar/BranchBar.jsx +17 -5
  4. package/src/BranchBar/BranchBar.module.css +11 -2
  5. package/src/CommandPalette/CommandPalette.jsx +267 -164
  6. package/src/CommandPalette/command-palette.css +130 -78
  7. package/src/Icon.jsx +112 -48
  8. package/src/Viewfinder.jsx +511 -61
  9. package/src/Viewfinder.module.css +414 -2
  10. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  11. package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
  12. package/src/canvas/CanvasPage.jsx +157 -174
  13. package/src/canvas/CanvasPage.module.css +0 -15
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
  15. package/src/canvas/ConnectorLayer.jsx +5 -5
  16. package/src/canvas/PageSelector.test.jsx +15 -6
  17. package/src/canvas/useCanvas.js +1 -1
  18. package/src/canvas/widgets/ActionWidget.jsx +200 -0
  19. package/src/canvas/widgets/ActionWidget.module.css +122 -0
  20. package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
  21. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  22. package/src/canvas/widgets/ImageWidget.jsx +1 -1
  23. package/src/canvas/widgets/LinkPreview.jsx +64 -5
  24. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  25. package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
  26. package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
  27. package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
  28. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  29. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  30. package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
  31. package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
  32. package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
  33. package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
  34. package/src/canvas/widgets/StoryWidget.jsx +7 -4
  35. package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
  36. package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
  37. package/src/canvas/widgets/TerminalWidget.jsx +299 -49
  38. package/src/canvas/widgets/TerminalWidget.module.css +155 -1
  39. package/src/canvas/widgets/WidgetChrome.jsx +19 -14
  40. package/src/canvas/widgets/WidgetChrome.module.css +10 -0
  41. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  42. package/src/canvas/widgets/expandUtils.js +188 -0
  43. package/src/canvas/widgets/index.js +5 -0
  44. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  45. package/src/canvas/widgets/widgetConfig.js +19 -1
  46. package/src/hooks/useConfig.js +14 -0
  47. package/src/index.js +4 -0
  48. package/src/vite/data-plugin.js +264 -14
@@ -1,8 +1,6 @@
1
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
2
+ import { Command } from 'cmdk'
3
+ import Icon from '../Icon.jsx'
6
4
  import {
7
5
  buildPrototypeIndex,
8
6
  listStories,
@@ -26,6 +24,32 @@ import BranchBar from '../BranchBar/BranchBar.jsx'
26
24
  import AuthModal from '../AuthModal/AuthModal.jsx'
27
25
  import './command-palette.css'
28
26
 
27
+ // Icon size for all palette items
28
+ const ICON_SIZE = 16
29
+
30
+ function getIconMap() {
31
+ const config = getCommandPaletteConfig()
32
+ return config?.icons || {}
33
+ }
34
+
35
+ function ItemIcon({ type, toolIcon }) {
36
+ const icons = getIconMap()
37
+ const iconName = toolIcon || icons[type] || icons.fallback || 'feather/hexagon'
38
+ return <Icon name={iconName} size={ICON_SIZE} color="var(--fgColor-muted, #656d76)" />
39
+ }
40
+
41
+ function AvatarIcon({ username }) {
42
+ return (
43
+ <img
44
+ src={`https://github.com/${username}.png?size=32`}
45
+ alt={username}
46
+ width={ICON_SIZE}
47
+ height={ICON_SIZE}
48
+ style={{ flexShrink: 0, borderRadius: '50%' }}
49
+ />
50
+ )
51
+ }
52
+
29
53
  /**
30
54
  * Check if a tool should be hidden from the command palette on the current route.
31
55
  * Uses the same pattern-matching logic as excludeRoutes.
@@ -58,7 +82,8 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
58
82
  const sections = config?.sections || []
59
83
  const groups = []
60
84
  const toolMenus = []
61
- const usedToolIds = new Set() // Track tools already listed by source:"tools" sections
85
+ const usedToolIds = new Set()
86
+ const hiddenFromSearchIds = new Set()
62
87
  const basePath = prefix || '/'
63
88
 
64
89
  for (const section of sections) {
@@ -80,6 +105,7 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
80
105
  if (result?.group) groups.push(result.group)
81
106
  if (result?.subPages) toolMenus.push(...result.subPages)
82
107
  if (result?.usedToolIds) result.usedToolIds.forEach(id => usedToolIds.add(id))
108
+ if (result?.hiddenFromSearchIds) result.hiddenFromSearchIds.forEach(id => hiddenFromSearchIds.add(id))
83
109
  continue
84
110
  }
85
111
 
@@ -204,6 +230,11 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
204
230
  continue
205
231
  }
206
232
 
233
+ if (tool.inlineAction === 'open-palette') {
234
+ // Skip — no point opening the palette from within itself
235
+ continue
236
+ }
237
+
207
238
  // Any remaining tools (all surfaces)
208
239
  if (tool.render === 'link' && tool.url) {
209
240
  remainingItems.push({
@@ -275,7 +306,7 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
275
306
  })
276
307
  }
277
308
 
278
- return { groups, toolMenus }
309
+ return { groups, toolMenus, hiddenFromSearchIds }
279
310
  }
280
311
 
281
312
  function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction) {
@@ -288,11 +319,11 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
288
319
  const isLocalDev = typeof window !== 'undefined' && window.__SB_LOCAL_DEV__ === true
289
320
  if (!isLocalDev) return null
290
321
  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') },
322
+ { id: 'create:canvas', children: 'Canvas', keywords: ['create', 'canvas', 'new', 'board'], itemType: 'create', onClick: () => onCreateAction?.('Canvas') },
323
+ { id: 'create:prototype', children: 'Prototype', keywords: ['create', 'prototype', 'new', 'page'], itemType: 'create', onClick: () => onCreateAction?.('Prototype') },
324
+ { id: 'create:component', children: 'Component', keywords: ['create', 'component', 'new', 'story'], itemType: 'create', onClick: () => onCreateAction?.('Component') },
325
+ { id: 'create:flow', children: 'Prototype Flow', keywords: ['create', 'flow', 'new', 'data'], itemType: 'create', onClick: () => onCreateAction?.('Flow') },
326
+ { id: 'create:page', children: 'Prototype Page', keywords: ['create', 'page', 'new'], itemType: 'create', onClick: () => onCreateAction?.('Page') },
296
327
  ]
297
328
  return { group: { heading: section.title, id: `cfg:${section.id}`, items: createItems } }
298
329
  }
@@ -303,11 +334,12 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
303
334
  if (!isLocalDev) return null
304
335
  const isCanvasRoute = typeof window !== 'undefined' && window.location.pathname.includes('/canvas/')
305
336
  if (!isCanvasRoute) return null
306
- const items = Object.entries(widgetTypes).map(([type, def]) => ({
337
+ const hiddenTypes = new Set(['link-preview', 'image', 'figma-embed', 'codepen-embed', 'story', 'terminal-read'])
338
+ const items = Object.entries(widgetTypes).filter(([type]) => !hiddenTypes.has(type)).map(([type, def]) => ({
307
339
  id: `create-widget:${type}`,
308
340
  children: def.label,
309
341
  keywords: ['add', 'widget', 'create', type, def.label.toLowerCase()],
310
- showType: false,
342
+ itemType: 'create',
311
343
  onClick: () => {
312
344
  document.dispatchEvent(new CustomEvent('storyboard:canvas:add-widget', { detail: { type } }))
313
345
  },
@@ -346,7 +378,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
346
378
  id: `starred:${id}`,
347
379
  children: artifact.name,
348
380
  keywords: ['starred', 'star', artifact.name.toLowerCase()],
349
- showType: false,
381
+ itemType: artifact._type === 'canvas' ? 'canvas' : 'prototype',
350
382
  onClick: () => {
351
383
  if (artifact.isExternal) {
352
384
  window.open(route, '_blank')
@@ -378,6 +410,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
378
410
  id: `cmd:${action.id}/${child.id || child.label}`,
379
411
  children: child.label,
380
412
  keywords: [action.label, child.label],
413
+ itemType: 'command',
381
414
  onClick: () => { if (child.execute) child.execute() },
382
415
  })
383
416
  }
@@ -386,6 +419,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
386
419
  id: `cmd:${action.id}`,
387
420
  children: action.label,
388
421
  keywords: [action.label],
422
+ itemType: 'link',
389
423
  onClick: () => {
390
424
  const url = action.url.startsWith('/') && !action.url.startsWith('//') ? prefix + action.url : action.url
391
425
  window.location.href = url
@@ -396,6 +430,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
396
430
  id: `cmd:${action.id}`,
397
431
  children: action.label,
398
432
  keywords: [action.label],
433
+ itemType: 'command',
399
434
  onClick: () => executeAction(action.id),
400
435
  })
401
436
  }
@@ -418,6 +453,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
418
453
  id: `cfg:${section.id}:${entry.type}:${entry.key}`,
419
454
  children: entry.label,
420
455
  keywords: [entry.type, entry.key, entry.label],
456
+ itemType: entry.type,
421
457
  onClick: () => {
422
458
  trackRecent(entry.type, entry.key, entry.label)
423
459
  const route = resolveRecentRoute(entry, prefix)
@@ -477,6 +513,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
477
513
  id: `cfg:${section.id}:${item.id}`,
478
514
  children: item.name,
479
515
  keywords: [item.name, item.id, item.type],
516
+ itemType: item.type,
480
517
  onClick: () => {
481
518
  trackRecent(item.type, item.id, item.name)
482
519
  window.location.href = item.route
@@ -489,7 +526,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
489
526
  /**
490
527
  * Build a section from toolbar.config.json tools.
491
528
  * If toolIds is provided, only include those tools in that order (with optional custom labels).
492
- * Otherwise include all command-list tools.
529
+ * Otherwise include all command-palette tools.
493
530
  *
494
531
  * toolIds format: ["theme", "flows"] or [{ id: "theme", label: "Change theme" }]
495
532
  */
@@ -511,15 +548,15 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
511
548
  const state = getToolbarToolState(toolId)
512
549
  if (state === 'disabled' || state === 'hidden') continue
513
550
  if (isHiddenInPalette(tool, basePath)) continue
514
- entries.push({ toolId, tool, label: customLabel || tool.label || toolId })
551
+ entries.push({ toolId, tool, label: customLabel || tool.label || toolId, toolIcon: tool.icon })
515
552
  }
516
553
  } else {
517
554
  for (const [toolId, tool] of Object.entries(tools)) {
518
- if (tool.surface !== 'command-list') continue
555
+ if (tool.surface !== 'command-palette') continue
519
556
  const state = getToolbarToolState(toolId)
520
557
  if (state === 'disabled' || state === 'hidden') continue
521
558
  if (isHiddenInPalette(tool, basePath)) continue
522
- entries.push({ toolId, tool, label: tool.label || toolId })
559
+ entries.push({ toolId, tool, label: tool.label || toolId, toolIcon: tool.icon })
523
560
  }
524
561
  }
525
562
 
@@ -528,16 +565,35 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
528
565
  const items = []
529
566
  const subPages = []
530
567
 
531
- for (const { toolId, tool, label } of entries) {
568
+ for (const { toolId, tool, label, toolIcon } of entries) {
532
569
  // Inline actions
533
570
  if (tool.inlineAction === 'toggle-chrome') {
571
+ const isHidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
534
572
  items.push({
535
573
  id: `cfg:${section.id}:${toolId}`,
536
- children: label,
574
+ toolIcon,
575
+ children: <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
576
+ <span>{label}</span>
577
+ <span>{isHidden ? '✓' : ''}</span>
578
+ </span>,
537
579
  keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
538
580
  showType: false,
539
581
  onClick: () => {
540
582
  document.documentElement.classList.toggle('storyboard-chrome-hidden')
583
+ setRefreshKey(k => k + 1)
584
+ },
585
+ })
586
+ continue
587
+ }
588
+
589
+ if (tool.inlineAction === 'open-palette') {
590
+ items.push({
591
+ id: `cfg:${section.id}:${toolId}`,
592
+ children: label,
593
+ keywords: [label, toolId, 'command', 'palette', 'search'].filter(Boolean),
594
+ showType: false,
595
+ onClick: () => {
596
+ document.dispatchEvent(new CustomEvent('storyboard:open-palette'))
541
597
  },
542
598
  })
543
599
  continue
@@ -570,6 +626,8 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
570
626
  options: children.map(child => ({
571
627
  label: child.label,
572
628
  execute: child.execute,
629
+ type: child.type,
630
+ active: child.active,
573
631
  })),
574
632
  })
575
633
  items.push({
@@ -647,6 +705,15 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
647
705
  })
648
706
  }
649
707
 
708
+ // Add toolIcon to all items from their entry
709
+ const iconByToolId = new Map(entries.map(e => [e.toolId, e.toolIcon]))
710
+ for (const item of items) {
711
+ if (!item.toolIcon) {
712
+ const match = item.id?.match(/cfg:[^:]+:(.+)/)
713
+ if (match && iconByToolId.has(match[1])) item.toolIcon = iconByToolId.get(match[1])
714
+ }
715
+ }
716
+
650
717
  return {
651
718
  group: {
652
719
  heading: section.title,
@@ -722,10 +789,10 @@ function buildPaletteItems(basePath, onCreateAction, onNavigateToPage) {
722
789
  const base = (basePath || '/').replace(/\/+$/, '')
723
790
  const prefix = base === '/' ? '' : base
724
791
 
725
- const { groups, toolMenus } = buildConfigSections(prefix, onNavigateToPage, onCreateAction)
792
+ const { groups, toolMenus, hiddenFromSearchIds } = buildConfigSections(prefix, onNavigateToPage, onCreateAction)
726
793
  const authorIndex = buildAuthorIndex(prefix)
727
794
 
728
- return { groups, toolMenus, authorIndex }
795
+ return { groups, toolMenus, authorIndex, hiddenFromSearchIds }
729
796
  }
730
797
 
731
798
  /**
@@ -738,9 +805,11 @@ export default function StoryboardCommandPalette({ basePath }) {
738
805
  const [items, setItems] = useState([])
739
806
  const [toolMenus, setToolMenus] = useState([])
740
807
  const [authorIndex, setAuthorIndex] = useState(new Map())
808
+ const [hiddenFromSearchIds, setHiddenFromSearchIds] = useState(new Set())
741
809
  const [activePage, setActivePage] = useState('root')
742
810
  const [createType, setCreateType] = useState(null)
743
811
  const [currentTheme, setCurrentTheme] = useState(() => getTheme())
812
+ const [refreshKey, setRefreshKey] = useState(0)
744
813
 
745
814
  // Keep currentTheme in sync when theme changes
746
815
  useEffect(() => {
@@ -759,43 +828,29 @@ export default function StoryboardCommandPalette({ basePath }) {
759
828
  setActivePage(pageId)
760
829
  }
761
830
 
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.
831
+ // Listen for Cmd+K directly to toggle the palette
766
832
  useEffect(() => {
767
- let toggledByEvent = false
768
-
769
- function handleToggleEvent() {
770
- toggledByEvent = true
771
- }
772
-
773
833
  function handleKeyDown(e) {
774
834
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
775
835
  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
- })
836
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
837
+ setItems(built.groups)
838
+ setToolMenus(built.toolMenus)
839
+ setAuthorIndex(built.authorIndex)
840
+ setHiddenFromSearchIds(built.hiddenFromSearchIds || new Set())
841
+ setSearch('')
842
+ setActivePage('root')
843
+ setOpen(prev => !prev)
787
844
  }
788
845
  }
789
846
 
790
- document.addEventListener('storyboard:toggle-palette', handleToggleEvent)
791
847
  document.addEventListener('keydown', handleKeyDown)
792
848
  return () => {
793
- document.removeEventListener('storyboard:toggle-palette', handleToggleEvent)
794
849
  document.removeEventListener('keydown', handleKeyDown)
795
850
  }
796
851
  }, [basePath])
797
852
 
798
- // Listen for toggle events from Svelte CoreUIBar
853
+ // Listen for toggle/open events from toolbar buttons (e.g. CommandPaletteTrigger)
799
854
  useEffect(() => {
800
855
  function handleToggle() {
801
856
  setOpen(prev => {
@@ -806,6 +861,7 @@ export default function StoryboardCommandPalette({ basePath }) {
806
861
  setItems(built.groups)
807
862
  setToolMenus(built.toolMenus)
808
863
  setAuthorIndex(built.authorIndex)
864
+ setHiddenFromSearchIds(built.hiddenFromSearchIds || new Set())
809
865
  setSearch('')
810
866
  setActivePage('root')
811
867
  }, 0)
@@ -819,6 +875,7 @@ export default function StoryboardCommandPalette({ basePath }) {
819
875
  setItems(built.groups)
820
876
  setToolMenus(built.toolMenus)
821
877
  setAuthorIndex(built.authorIndex)
878
+ setHiddenFromSearchIds(built.hiddenFromSearchIds || new Set())
822
879
  setSearch('')
823
880
  setActivePage('root')
824
881
  setOpen(true)
@@ -832,6 +889,14 @@ export default function StoryboardCommandPalette({ basePath }) {
832
889
  }
833
890
  }, [basePath])
834
891
 
892
+ // Rebuild palette items when a toggle is clicked (refreshKey changes)
893
+ useEffect(() => {
894
+ if (refreshKey === 0) return
895
+ const built = buildPaletteItems(basePath, handleCreateAction, handleNavigateToPage)
896
+ setItems(built.groups)
897
+ setToolMenus(built.toolMenus)
898
+ }, [refreshKey, basePath])
899
+
835
900
  const handleChangeOpen = useCallback((value) => {
836
901
  if (!value) {
837
902
  setOpen(false)
@@ -846,12 +911,12 @@ export default function StoryboardCommandPalette({ basePath }) {
846
911
  id: `subpage:${menu.id}`,
847
912
  items: (menu.options || []).map((opt, i) => ({
848
913
  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,
914
+ label: opt.label,
915
+ isToggle: opt.type === 'toggle',
916
+ isActiveToggle: opt.type === 'toggle' && opt.active,
917
+ isActiveTheme: opt.toolHandler === 'core:theme' && opt.value === currentTheme,
852
918
  keywords: [opt.label, menu.label || menu.id],
853
- showType: false,
854
- onClick: () => {
919
+ onSelect: () => {
855
920
  if (opt.execute) {
856
921
  opt.execute()
857
922
  } else if (opt.toolHandler === 'core:theme' && opt.value) {
@@ -859,147 +924,185 @@ export default function StoryboardCommandPalette({ basePath }) {
859
924
  } else if (opt.action) {
860
925
  executeAction(opt.action, opt.value)
861
926
  }
862
- setOpen(false)
863
- setActivePage('root')
927
+ if (opt.type === 'toggle') {
928
+ setRefreshKey(k => k + 1)
929
+ } else {
930
+ setOpen(false)
931
+ setActivePage('root')
932
+ }
864
933
  },
865
934
  })),
866
935
  })).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({
936
+ }, [toolMenus, currentTheme, refreshKey])
937
+
938
+ // Build author groups from the index
939
+ const authorGroups = useMemo(() => {
940
+ const groups = []
941
+ for (const [, { author, items: authorItems }] of authorIndex) {
942
+ groups.push({
885
943
  heading: `Artifacts by @${author}`,
886
- id: `author:${key}`,
887
- items: uniqueItems.map(item => ({
944
+ id: `author:${author.toLowerCase()}`,
945
+ author,
946
+ items: authorItems.map(item => ({
888
947
  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
- ),
948
+ label: item.name,
949
+ type: item.type,
895
950
  keywords: [item.name, item.id, item.type, author, `@${author}`],
896
- showType: false,
897
- onClick: () => {
951
+ onSelect: () => {
898
952
  trackRecent(item.type.toLowerCase(), item.id, item.name)
899
953
  window.location.href = item.route
900
954
  },
901
955
  })),
902
956
  })
903
957
  }
904
-
905
- return result
906
- }, [items, search, subPageGroups, authorIndex])
958
+ return groups
959
+ }, [authorIndex])
907
960
 
908
961
  // Remove consecutive separators and leading/trailing separators
909
- const deduplicatedItems = useMemo(() => {
962
+ const cleanedItems = useMemo(() => {
910
963
  const result = []
911
- for (const item of filteredItems) {
964
+ for (const item of items) {
912
965
  const isSep = item.id?.startsWith('cfg:sep')
913
966
  if (isSep && (result.length === 0 || result[result.length - 1].id?.startsWith('cfg:sep'))) continue
914
967
  result.push(item)
915
968
  }
916
- // Remove trailing separator
917
969
  while (result.length > 0 && result[result.length - 1].id?.startsWith('cfg:sep')) result.pop()
918
970
  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
- )
926
-
927
- const handleChangeSearch = useCallback((value) => {
928
- setSearch(value)
929
- }, [])
971
+ }, [items])
972
+
973
+ // Build search value string from keywords array
974
+ function itemValue(item) {
975
+ const parts = []
976
+ if (typeof item.children === 'string') parts.push(item.children)
977
+ if (item.label) parts.push(item.label)
978
+ if (item.keywords) parts.push(...item.keywords)
979
+ return parts.filter(Boolean).join(' ')
980
+ }
930
981
 
931
982
  return (
932
983
  <>
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
- }
984
+ <Command.Dialog
985
+ open={open}
986
+ onOpenChange={handleChangeOpen}
987
+ label="Command Menu"
988
+ className="command-palette"
989
+ shouldFilter={activePage === 'root'}
990
+ onKeyDown={(e) => {
991
+ if (e.key === 'Escape' && activePage !== 'root') {
992
+ e.preventDefault()
993
+ e.stopPropagation()
994
+ setActivePage('root')
995
+ setSearch('')
996
+ }
997
+ }}
943
998
  >
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
- />
999
+ <Command.Input
1000
+ placeholder={activePage === 'root'
1001
+ ? 'Search commands, prototypes, canvases, stories...'
1002
+ : `Search ${toolMenus.find(m => m.id === activePage)?.label || ''}...`
1003
+ }
1004
+ value={search}
1005
+ onValueChange={setSearch}
1006
+ />
1007
+ <Command.List>
1008
+ <Command.Empty>No results found.</Command.Empty>
1009
+
1010
+ {activePage === 'root' ? (
1011
+ <>
1012
+ {/* Main config-driven groups */}
1013
+ {cleanedItems.map((list) => (
1014
+ list.id?.startsWith('cfg:sep') ? (
1015
+ !search && <Command.Separator key={list.id} />
1016
+ ) : (
1017
+ <Command.Group key={list.id} heading={list.heading}>
1018
+ {list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, ...rest }) => {
1019
+ if (hiddenFromSearchIds.size > 0) {
1020
+ for (const toolId of hiddenFromSearchIds) {
1021
+ if (id?.includes(toolId)) return null
1022
+ }
1023
+ }
1024
+ return (
1025
+ <Command.Item
1026
+ key={id}
1027
+ value={itemValue({ children, keywords })}
1028
+ onSelect={() => onClick?.()}
1029
+ >
1030
+ <ItemIcon type={itemType} toolIcon={toolIcon} />
1031
+ {children}
1032
+ </Command.Item>
1033
+ )
1034
+ })}
1035
+ </Command.Group>
1036
+ )
1037
+ ))}
1038
+
1039
+ {/* Sub-page options flattened for root search */}
1040
+ {search && subPageGroups.map(group => (
1041
+ <Command.Group key={group.id} heading={group.heading}>
1042
+ {group.items.map(item => (
1043
+ <Command.Item
1044
+ key={item.id}
1045
+ value={itemValue(item)}
1046
+ onSelect={item.onSelect}
1047
+ >
1048
+ <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
1049
+ <span>{item.label}</span>
1050
+ {(item.isActiveToggle || item.isActiveTheme) && <span>✓</span>}
1051
+ </span>
1052
+ </Command.Item>
957
1053
  ))}
958
- </CommandPalette.List>
959
- )
960
- ))
1054
+ </Command.Group>
1055
+ ))}
1056
+
1057
+ {/* Author groups */}
1058
+ {search && authorGroups.map(group => (
1059
+ <Command.Group key={group.id} heading={group.heading}>
1060
+ {group.items.map(item => (
1061
+ <Command.Item
1062
+ key={item.id}
1063
+ value={itemValue(item)}
1064
+ onSelect={item.onSelect}
1065
+ >
1066
+ <AvatarIcon username={group.author} />
1067
+ <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
1068
+ <span>{item.label}</span>
1069
+ <span style={{ fontSize: '12px', color: 'var(--fgColor-muted, #999)' }}>{item.type}</span>
1070
+ </span>
1071
+ </Command.Item>
1072
+ ))}
1073
+ </Command.Group>
1074
+ ))}
1075
+ </>
961
1076
  ) : (
962
- <div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280' }}>
963
- No results for &ldquo;{search}&rdquo;
964
- </div>
1077
+ /* Tool-menu sub-pages */
1078
+ toolMenus.filter(menu => menu.id === activePage).map(menu => (
1079
+ <Command.Group key={menu.id} heading={menu.title || menu.label || menu.id}>
1080
+ {(menu.options || []).map((opt, i) => (
1081
+ <Command.Item
1082
+ key={`${menu.id}:${i}`}
1083
+ value={opt.label}
1084
+ onSelect={() => {
1085
+ if (opt.execute) {
1086
+ opt.execute()
1087
+ } else if (opt.toolHandler === 'core:theme' && opt.value) {
1088
+ setTheme(opt.value)
1089
+ } else if (opt.action) {
1090
+ executeAction(opt.action, opt.value)
1091
+ }
1092
+ setOpen(false)
1093
+ setActivePage('root')
1094
+ }}
1095
+ >
1096
+ {opt.toolHandler === 'core:theme' && opt.value === currentTheme
1097
+ ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>✓</span></span>
1098
+ : opt.label}
1099
+ </Command.Item>
1100
+ ))}
1101
+ </Command.Group>
1102
+ ))
965
1103
  )}
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>
1104
+ </Command.List>
1105
+ </Command.Dialog>
1003
1106
 
1004
1107
  <CreateDialog
1005
1108
  type={createType}