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

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 (79) hide show
  1. package/package.json +3 -3
  2. package/src/BranchBar/BranchBar.jsx +3 -1
  3. package/src/BranchBar/BranchBar.module.css +2 -2
  4. package/src/BranchBar/useBranches.js +20 -6
  5. package/src/BranchBar/useBranches.test.js +68 -0
  6. package/src/CommandPalette/CommandPalette.jsx +250 -61
  7. package/src/CommandPalette/command-palette.css +12 -0
  8. package/src/Icon.jsx +46 -11
  9. package/src/Viewfinder.jsx +53 -133
  10. package/src/Viewfinder.module.css +20 -91
  11. package/src/Workspace.jsx +7 -0
  12. package/src/canvas/CanvasPage.jsx +601 -62
  13. package/src/canvas/CanvasPage.module.css +15 -2
  14. package/src/canvas/CanvasPage.multiselect.test.jsx +7 -0
  15. package/src/canvas/ConnectorLayer.jsx +120 -152
  16. package/src/canvas/ConnectorLayer.module.css +69 -0
  17. package/src/canvas/canvasApi.js +68 -2
  18. package/src/canvas/connectorGeometry.js +132 -0
  19. package/src/canvas/hotPoolDevLogs.js +25 -0
  20. package/src/canvas/useMarqueeSelect.js +30 -4
  21. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  22. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  23. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  24. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  25. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  26. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  27. package/src/canvas/widgets/ExpandedPane.jsx +472 -0
  28. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  29. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  30. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  31. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  32. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  33. package/src/canvas/widgets/FigmaEmbed.jsx +49 -102
  34. package/src/canvas/widgets/ImageWidget.jsx +129 -8
  35. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  36. package/src/canvas/widgets/LinkPreview.jsx +93 -44
  37. package/src/canvas/widgets/MarkdownBlock.jsx +141 -16
  38. package/src/canvas/widgets/MarkdownBlock.module.css +25 -0
  39. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  40. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  41. package/src/canvas/widgets/PrototypeEmbed.jsx +46 -170
  42. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  43. package/src/canvas/widgets/StoryWidget.jsx +65 -11
  44. package/src/canvas/widgets/TerminalReadWidget.jsx +11 -5
  45. package/src/canvas/widgets/TerminalReadWidget.module.css +3 -1
  46. package/src/canvas/widgets/TerminalWidget.jsx +301 -124
  47. package/src/canvas/widgets/TerminalWidget.module.css +121 -12
  48. package/src/canvas/widgets/TilesWidget.jsx +302 -0
  49. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  50. package/src/canvas/widgets/WidgetChrome.jsx +67 -152
  51. package/src/canvas/widgets/WidgetChrome.module.css +20 -1
  52. package/src/canvas/widgets/expandUtils.js +385 -16
  53. package/src/canvas/widgets/expandUtils.test.js +155 -0
  54. package/src/canvas/widgets/index.js +6 -2
  55. package/src/canvas/widgets/tilePool.js +23 -0
  56. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  57. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  58. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  59. package/src/canvas/widgets/tiles/leaf.png +0 -0
  60. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  61. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  62. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  63. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  64. package/src/canvas/widgets/widgetConfig.js +37 -4
  65. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  66. package/src/canvas/widgets/widgetProps.js +1 -0
  67. package/src/context.jsx +47 -19
  68. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  69. package/src/index.js +4 -2
  70. package/src/story/ComponentSetPage.jsx +186 -0
  71. package/src/story/ComponentSetPage.module.css +121 -0
  72. package/src/story/StoryPage.jsx +32 -2
  73. package/src/vite/data-plugin.js +79 -35
  74. package/src/canvas/widgets/ActionWidget.jsx +0 -200
  75. package/src/canvas/widgets/ActionWidget.module.css +0 -122
  76. package/src/canvas/widgets/SplitExpandModal.jsx +0 -234
  77. package/src/canvas/widgets/SplitExpandModal.module.css +0 -335
  78. package/src/canvas/widgets/SplitScreenTopBar.jsx +0 -30
  79. package/src/canvas/widgets/SplitScreenTopBar.module.css +0 -58
@@ -1,5 +1,7 @@
1
- import { useState, useEffect, useCallback, useMemo } from 'react'
1
+ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
2
2
  import { Command } from 'cmdk'
3
+ import * as VisuallyHidden from '@radix-ui/react-visually-hidden'
4
+ import * as DialogPrimitive from '@radix-ui/react-dialog'
3
5
  import Icon from '../Icon.jsx'
4
6
  import {
5
7
  buildPrototypeIndex,
@@ -14,9 +16,11 @@ import {
14
16
  trackRecent,
15
17
  getCommandPaletteConfig,
16
18
  getToolbarConfig,
19
+ getConfig,
17
20
  setTheme,
18
21
  getTheme,
19
22
  isExcludedByRoute,
23
+ scoreMatch,
20
24
  } from '@dfosco/storyboard-core'
21
25
  import { widgetTypes } from '../canvas/widgets/widgetConfig.js'
22
26
  import CreateDialog from './CreateDialog.jsx'
@@ -32,10 +36,12 @@ function getIconMap() {
32
36
  return config?.icons || {}
33
37
  }
34
38
 
35
- function ItemIcon({ type, toolIcon }) {
39
+ function ItemIcon({ type, toolIcon, toolMeta }) {
36
40
  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)" />
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 || {})} />
39
45
  }
40
46
 
41
47
  function AvatarIcon({ username }) {
@@ -116,13 +122,14 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
116
122
  items: section.items.map((item, i) => {
117
123
  const id = `cfg:${section.id}:${i}`
118
124
  if (item.type === 'link') {
125
+ const resolvedUrl = item.url?.startsWith('/') ? prefix + item.url : item.url
119
126
  return {
120
127
  id,
121
128
  children: item.label,
122
129
  keywords: item.keywords || [item.label],
130
+ url: resolvedUrl,
123
131
  onClick: () => {
124
- const url = item.url?.startsWith('/') ? prefix + item.url : item.url
125
- if (url) window.location.href = url
132
+ if (resolvedUrl) window.location.href = resolvedUrl
126
133
  },
127
134
  }
128
135
  }
@@ -237,12 +244,14 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
237
244
 
238
245
  // Any remaining tools (all surfaces)
239
246
  if (tool.render === 'link' && tool.url) {
247
+ const resolvedUrl = tool.url.startsWith('/') ? prefix + tool.url : tool.url
240
248
  remainingItems.push({
241
249
  id: `cfg:${section.id}:${toolId}`,
242
250
  children: label,
243
251
  keywords: [label, toolId].filter(Boolean),
244
252
  showType: false,
245
- onClick: () => { window.location.href = tool.url },
253
+ url: resolvedUrl,
254
+ onClick: () => { window.location.href = resolvedUrl },
246
255
  })
247
256
  } else {
248
257
  // Menu tools: close palette and click the toolbar button to open the menu
@@ -283,9 +292,10 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
283
292
  }
284
293
  }
285
294
 
286
- // 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)
287
296
  for (const menu of toolMenus) {
288
- const menuToolId = menu.id?.replace('tool:', '')
297
+ if (!menu.id?.startsWith('tool:')) continue
298
+ const menuToolId = menu.id.replace('tool:', '')
289
299
  if (usedToolIds.has(menuToolId)) continue
290
300
  if (remainingItems.some(i => i.id === `cfg:${section.id}:${menuToolId}`)) continue
291
301
  remainingItems.push({
@@ -299,6 +309,18 @@ function buildConfigSections(prefix, onNavigateToPage, onCreateAction) {
299
309
  }
300
310
 
301
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
+
302
324
  groups.push({
303
325
  heading: section.title,
304
326
  id: `cfg:${section.id}`,
@@ -334,22 +356,66 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
334
356
  if (!isLocalDev) return null
335
357
  const isCanvasRoute = typeof window !== 'undefined' && window.location.pathname.includes('/canvas/')
336
358
  if (!isCanvasRoute) return null
337
- const hiddenTypes = new Set(['link-preview', 'image', 'figma-embed', 'codepen-embed', 'story', 'terminal-read'])
359
+ const hiddenTypes = new Set(['link-preview', 'image', 'figma-embed', 'codepen-embed', 'story', 'terminal-read', 'agent'])
338
360
  const items = Object.entries(widgetTypes).filter(([type]) => !hiddenTypes.has(type)).map(([type, def]) => ({
339
361
  id: `create-widget:${type}`,
340
362
  children: def.label,
341
363
  keywords: ['add', 'widget', 'create', type, def.label.toLowerCase()],
342
- itemType: 'create',
364
+ itemType: type,
343
365
  onClick: () => {
344
366
  document.dispatchEvent(new CustomEvent('storyboard:canvas:add-widget', { detail: { type } }))
345
367
  },
346
368
  }))
347
- 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 }
348
414
  }
349
415
 
350
416
  // --- Starred source (reads from viewfinder localStorage) ---
351
417
  if (section.source === 'starred') {
352
- const STARRED_KEY = 'sb-viewfinder-starred'
418
+ const STARRED_KEY = 'sb-workspace-starred'
353
419
  let starredIds = []
354
420
  try { starredIds = JSON.parse(localStorage.getItem(STARRED_KEY)) || [] } catch {}
355
421
  if (starredIds.length === 0) return null
@@ -379,6 +445,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
379
445
  children: artifact.name,
380
446
  keywords: ['starred', 'star', artifact.name.toLowerCase()],
381
447
  itemType: artifact._type === 'canvas' ? 'canvas' : 'prototype',
448
+ url: route,
382
449
  onClick: () => {
383
450
  if (artifact.isExternal) {
384
451
  window.open(route, '_blank')
@@ -415,14 +482,15 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
415
482
  })
416
483
  }
417
484
  } else if (action.type === 'link' && action.url) {
485
+ const resolvedUrl = action.url.startsWith('/') && !action.url.startsWith('//') ? prefix + action.url : action.url
418
486
  commandItems.push({
419
487
  id: `cmd:${action.id}`,
420
488
  children: action.label,
421
489
  keywords: [action.label],
422
490
  itemType: 'link',
491
+ url: resolvedUrl,
423
492
  onClick: () => {
424
- const url = action.url.startsWith('/') && !action.url.startsWith('//') ? prefix + action.url : action.url
425
- window.location.href = url
493
+ window.location.href = resolvedUrl
426
494
  },
427
495
  })
428
496
  } else {
@@ -449,17 +517,20 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
449
517
  group: {
450
518
  heading: section.title,
451
519
  id: `cfg:${section.id}`,
452
- items: items.map(entry => ({
453
- id: `cfg:${section.id}:${entry.type}:${entry.key}`,
454
- children: entry.label,
455
- keywords: [entry.type, entry.key, entry.label],
456
- itemType: entry.type,
457
- onClick: () => {
458
- trackRecent(entry.type, entry.key, entry.label)
459
- const route = resolveRecentRoute(entry, prefix)
460
- if (route) window.location.href = route
461
- },
462
- })),
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
+ }),
463
534
  },
464
535
  }
465
536
  }
@@ -514,6 +585,7 @@ function buildDynamicSection(section, prefix, onNavigateToPage, onCreateAction)
514
585
  children: item.name,
515
586
  keywords: [item.name, item.id, item.type],
516
587
  itemType: item.type,
588
+ url: item.route,
517
589
  onClick: () => {
518
590
  trackRecent(item.type, item.id, item.name)
519
591
  window.location.href = item.route
@@ -543,12 +615,14 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
543
615
  for (const entry of section.toolIds) {
544
616
  const toolId = typeof entry === 'string' ? entry : entry.id
545
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
546
620
  const tool = tools[toolId]
547
621
  if (!tool) continue
548
622
  const state = getToolbarToolState(toolId)
549
623
  if (state === 'disabled' || state === 'hidden') continue
550
624
  if (isHiddenInPalette(tool, basePath)) continue
551
- entries.push({ toolId, tool, label: customLabel || tool.label || toolId, toolIcon: tool.icon })
625
+ entries.push({ toolId, tool, label: customLabel || tool.label || toolId, toolIcon: tool.icon, toolMeta: iconMeta, closeOnSelect: closeOnSelect ?? tool.closeOnSelect })
552
626
  }
553
627
  } else {
554
628
  for (const [toolId, tool] of Object.entries(tools)) {
@@ -556,7 +630,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
556
630
  const state = getToolbarToolState(toolId)
557
631
  if (state === 'disabled' || state === 'hidden') continue
558
632
  if (isHiddenInPalette(tool, basePath)) continue
559
- entries.push({ toolId, tool, label: tool.label || toolId, toolIcon: tool.icon })
633
+ entries.push({ toolId, tool, label: tool.label || toolId, toolIcon: tool.icon, toolMeta: undefined, closeOnSelect: tool.closeOnSelect })
560
634
  }
561
635
  }
562
636
 
@@ -565,22 +639,22 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
565
639
  const items = []
566
640
  const subPages = []
567
641
 
568
- for (const { toolId, tool, label, toolIcon } of entries) {
642
+ for (const { toolId, tool, label, toolIcon, toolMeta, closeOnSelect: entryCloseOnSelect } of entries) {
569
643
  // Inline actions
570
644
  if (tool.inlineAction === 'toggle-chrome') {
571
645
  const isHidden = document.documentElement.classList.contains('storyboard-chrome-hidden')
572
646
  items.push({
573
647
  id: `cfg:${section.id}:${toolId}`,
574
- toolIcon,
648
+ toolIcon: isHidden ? 'primer/light-bulb' : 'primer/light-bulb',
575
649
  children: <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
576
- <span>{label}</span>
650
+ <span>Hide toolbars</span>
577
651
  <span>{isHidden ? '✓' : ''}</span>
578
652
  </span>,
579
653
  keywords: [label, toolId, 'hide', 'show', 'toolbar'].filter(Boolean),
580
654
  showType: false,
655
+ closeOnSelect: entryCloseOnSelect,
581
656
  onClick: () => {
582
657
  document.documentElement.classList.toggle('storyboard-chrome-hidden')
583
- setRefreshKey(k => k + 1)
584
658
  },
585
659
  })
586
660
  continue
@@ -600,13 +674,15 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
600
674
  }
601
675
 
602
676
  if (tool.render === 'link' && tool.url) {
677
+ const resolvedUrl = tool.url.startsWith('/') ? prefix + tool.url : tool.url
603
678
  items.push({
604
679
  id: `cfg:${section.id}:${toolId}`,
605
680
  children: label,
606
681
  keywords: [label, toolId].filter(Boolean),
682
+ url: resolvedUrl,
683
+ closeOnSelect: entryCloseOnSelect,
607
684
  onClick: () => {
608
- const url = tool.url.startsWith('/') ? prefix + tool.url : tool.url
609
- window.location.href = url
685
+ window.location.href = resolvedUrl
610
686
  },
611
687
  })
612
688
  continue
@@ -669,7 +745,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
669
745
  continue
670
746
  }
671
747
 
672
- // 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
673
749
  const ariaLabel = tool.ariaLabel || tool.label || toolId
674
750
  items.push({
675
751
  id: `cfg:${section.id}:${toolId}`,
@@ -678,6 +754,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
678
754
  showType: false,
679
755
  onClick: () => {
680
756
  setTimeout(() => {
757
+ document.dispatchEvent(new CustomEvent(`storyboard:open-${toolId}`))
681
758
  const btn = document.querySelector(`[aria-label="${ariaLabel}"]`)
682
759
  if (btn) btn.click()
683
760
  }, 100)
@@ -692,6 +769,7 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
692
769
  id: `cfg:${section.id}:${toolId}`,
693
770
  children: label,
694
771
  keywords: [label, toolId].filter(Boolean),
772
+ closeOnSelect: entryCloseOnSelect,
695
773
  onClick: () => { if (action) executeAction(action.id) },
696
774
  })
697
775
  continue
@@ -701,16 +779,21 @@ function buildToolsSection(section, prefix, onNavigateToPage) {
701
779
  id: `cfg:${section.id}:${toolId}`,
702
780
  children: label,
703
781
  keywords: [label, toolId].filter(Boolean),
782
+ closeOnSelect: entryCloseOnSelect,
704
783
  onClick: () => executeAction(toolId),
705
784
  })
706
785
  }
707
786
 
708
- // Add toolIcon to all items from their entry
709
- const iconByToolId = new Map(entries.map(e => [e.toolId, e.toolIcon]))
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 }]))
710
789
  for (const item of items) {
711
790
  if (!item.toolIcon) {
712
791
  const match = item.id?.match(/cfg:[^:]+:(.+)/)
713
- if (match && iconByToolId.has(match[1])) item.toolIcon = iconByToolId.get(match[1])
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
+ }
714
797
  }
715
798
  }
716
799
 
@@ -811,6 +894,24 @@ export default function StoryboardCommandPalette({ basePath }) {
811
894
  const [currentTheme, setCurrentTheme] = useState(() => getTheme())
812
895
  const [refreshKey, setRefreshKey] = useState(0)
813
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
+ }, [])
914
+
814
915
  // Keep currentTheme in sync when theme changes
815
916
  useEffect(() => {
816
917
  const handler = (e) => setCurrentTheme(e.detail.theme)
@@ -899,10 +1000,16 @@ export default function StoryboardCommandPalette({ basePath }) {
899
1000
 
900
1001
  const handleChangeOpen = useCallback((value) => {
901
1002
  if (!value) {
1003
+ // Escape from a sub-page goes back to root instead of closing
1004
+ if (activePage !== 'root') {
1005
+ setActivePage('root')
1006
+ setSearch('')
1007
+ return
1008
+ }
902
1009
  setOpen(false)
903
1010
  setActivePage('root')
904
1011
  }
905
- }, [])
1012
+ }, [activePage])
906
1013
 
907
1014
  // Flatten sub-page options into searchable groups so they appear in root search
908
1015
  const subPageGroups = useMemo(() => {
@@ -912,6 +1019,7 @@ export default function StoryboardCommandPalette({ basePath }) {
912
1019
  items: (menu.options || []).map((opt, i) => ({
913
1020
  id: `subpage:${menu.id}:${i}`,
914
1021
  label: opt.label,
1022
+ icon: opt.icon,
915
1023
  isToggle: opt.type === 'toggle',
916
1024
  isActiveToggle: opt.type === 'toggle' && opt.active,
917
1025
  isActiveTheme: opt.toolHandler === 'core:theme' && opt.value === currentTheme,
@@ -947,9 +1055,12 @@ export default function StoryboardCommandPalette({ basePath }) {
947
1055
  id: `author:${item.id}`,
948
1056
  label: item.name,
949
1057
  type: item.type,
1058
+ url: item.route,
950
1059
  keywords: [item.name, item.id, item.type, author, `@${author}`],
951
1060
  onSelect: () => {
952
1061
  trackRecent(item.type.toLowerCase(), item.id, item.name)
1062
+ setOpen(false)
1063
+ setActivePage('root')
953
1064
  window.location.href = item.route
954
1065
  },
955
1066
  })),
@@ -979,6 +1090,49 @@ export default function StoryboardCommandPalette({ basePath }) {
979
1090
  return parts.filter(Boolean).join(' ')
980
1091
  }
981
1092
 
1093
+ // Custom filter using scoreMatch for better ranking.
1094
+ // scoreMatch tiers: prefix (100) > word-boundary (75) > substring (50) > fuzzy (5-25).
1095
+ // Normalizes to 0-1 for cmdk; weak fuzzy matches (score < 10) are hidden to
1096
+ // prevent garbage results from dominating above exact matches in other groups.
1097
+ const MAX_SCORE = 110
1098
+ const cmdkFilter = useCallback((value, search) => {
1099
+ if (!search) return 1
1100
+ const score = scoreMatch(value, search.toLowerCase().trim())
1101
+ if (score < 10) return 0
1102
+ return Math.min(1, score / MAX_SCORE)
1103
+ }, [])
1104
+
1105
+ function showCenterToast(message) {
1106
+ const el = document.createElement('div')
1107
+ Object.assign(el.style, {
1108
+ position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)',
1109
+ zIndex: '10000', padding: '0.625rem 1rem', borderRadius: '0.5rem',
1110
+ background: 'var(--bgColor-emphasis, #1f2328)', color: 'var(--fgColor-onEmphasis, #fff)',
1111
+ fontSize: '0.8125rem', fontFamily: 'var(--fontStack-sansSerif, system-ui)',
1112
+ opacity: '0', transition: 'opacity 0.15s ease',
1113
+ pointerEvents: 'none',
1114
+ })
1115
+ el.textContent = message
1116
+ document.body.appendChild(el)
1117
+ requestAnimationFrame(() => { el.style.opacity = '1' })
1118
+ setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 200) }, 1800)
1119
+ }
1120
+
1121
+ function copyLinkToClipboard(url, itemType) {
1122
+ const fullUrl = url.startsWith('/') ? window.location.origin + url : url
1123
+ const isCanvasRoute = typeof window !== 'undefined' && window.location.pathname.includes('/canvas/')
1124
+ const isPasteable = itemType === 'prototype' || itemType === 'story'
1125
+ const shouldPaste = isCanvasRoute && isPasteable
1126
+
1127
+ navigator.clipboard.writeText(fullUrl).then(() => {
1128
+ showCenterToast(shouldPaste ? 'Link copied and pasted' : 'Link copied to clipboard')
1129
+ })
1130
+
1131
+ if (shouldPaste) {
1132
+ document.dispatchEvent(new CustomEvent('storyboard:canvas:paste-url', { detail: { url: fullUrl } }))
1133
+ }
1134
+ }
1135
+
982
1136
  return (
983
1137
  <>
984
1138
  <Command.Dialog
@@ -987,6 +1141,8 @@ export default function StoryboardCommandPalette({ basePath }) {
987
1141
  label="Command Menu"
988
1142
  className="command-palette"
989
1143
  shouldFilter={activePage === 'root'}
1144
+ filter={cmdkFilter}
1145
+ aria-describedby={undefined}
990
1146
  onKeyDown={(e) => {
991
1147
  if (e.key === 'Escape' && activePage !== 'root') {
992
1148
  e.preventDefault()
@@ -996,6 +1152,10 @@ export default function StoryboardCommandPalette({ basePath }) {
996
1152
  }
997
1153
  }}
998
1154
  >
1155
+ <VisuallyHidden.Root asChild>
1156
+ <DialogPrimitive.Title>Command Menu</DialogPrimitive.Title>
1157
+ </VisuallyHidden.Root>
1158
+ <DialogPrimitive.Description className="sr-only" style={{ display: 'none' }} />
999
1159
  <Command.Input
1000
1160
  placeholder={activePage === 'root'
1001
1161
  ? 'Search commands, prototypes, canvases, stories...'
@@ -1009,13 +1169,35 @@ export default function StoryboardCommandPalette({ basePath }) {
1009
1169
 
1010
1170
  {activePage === 'root' ? (
1011
1171
  <>
1172
+ {/* Sub-page options flattened for root search — rendered first
1173
+ so high-scoring items (e.g. "Copilot CLI" for query "cop")
1174
+ appear above weaker matches in later groups. */}
1175
+ {search && subPageGroups.map(group => (
1176
+ <Command.Group key={group.id} heading={group.heading}>
1177
+ {group.items.map(item => (
1178
+ <Command.Item
1179
+ key={item.id}
1180
+ value={itemValue(item)}
1181
+ onSelect={item.onSelect}
1182
+ >
1183
+ {item.icon && <Icon name={item.icon} size={ICON_SIZE} color="var(--fgColor-muted, #656d76)" />}
1184
+ <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
1185
+ <span>{item.label}</span>
1186
+ {(item.isActiveToggle || item.isActiveTheme) && <span>✓</span>}
1187
+ </span>
1188
+ </Command.Item>
1189
+ ))}
1190
+ </Command.Group>
1191
+ ))}
1192
+
1012
1193
  {/* Main config-driven groups */}
1013
1194
  {cleanedItems.map((list) => (
1014
1195
  list.id?.startsWith('cfg:sep') ? (
1015
1196
  !search && <Command.Separator key={list.id} />
1016
1197
  ) : (
1017
1198
  <Command.Group key={list.id} heading={list.heading}>
1018
- {list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, ...rest }) => {
1199
+ {list.items.map(({ id, children, keywords, onClick, itemType, toolIcon, toolMeta, closeOnSelect, hideFromSearch, url, ...rest }) => {
1200
+ if (search && hideFromSearch) return null
1019
1201
  if (hiddenFromSearchIds.size > 0) {
1020
1202
  for (const toolId of hiddenFromSearchIds) {
1021
1203
  if (id?.includes(toolId)) return null
@@ -1025,9 +1207,21 @@ export default function StoryboardCommandPalette({ basePath }) {
1025
1207
  <Command.Item
1026
1208
  key={id}
1027
1209
  value={itemValue({ children, keywords })}
1028
- onSelect={() => onClick?.()}
1210
+ onSelect={() => {
1211
+ if (url && altHeldRef.current) {
1212
+ copyLinkToClipboard(url, itemType)
1213
+ } else if (url && modifierHeldRef.current) {
1214
+ window.open(url, '_blank')
1215
+ } else {
1216
+ onClick?.()
1217
+ }
1218
+ if (closeOnSelect !== false) {
1219
+ setOpen(false)
1220
+ setActivePage('root')
1221
+ }
1222
+ }}
1029
1223
  >
1030
- <ItemIcon type={itemType} toolIcon={toolIcon} />
1224
+ <ItemIcon type={itemType} toolIcon={toolIcon} toolMeta={toolMeta} />
1031
1225
  {children}
1032
1226
  </Command.Item>
1033
1227
  )
@@ -1036,24 +1230,6 @@ export default function StoryboardCommandPalette({ basePath }) {
1036
1230
  )
1037
1231
  ))}
1038
1232
 
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>
1053
- ))}
1054
- </Command.Group>
1055
- ))}
1056
-
1057
1233
  {/* Author groups */}
1058
1234
  {search && authorGroups.map(group => (
1059
1235
  <Command.Group key={group.id} heading={group.heading}>
@@ -1061,7 +1237,19 @@ export default function StoryboardCommandPalette({ basePath }) {
1061
1237
  <Command.Item
1062
1238
  key={item.id}
1063
1239
  value={itemValue(item)}
1064
- onSelect={item.onSelect}
1240
+ onSelect={() => {
1241
+ if (item.url && altHeldRef.current) {
1242
+ copyLinkToClipboard(item.url, item.type?.toLowerCase())
1243
+ setOpen(false)
1244
+ setActivePage('root')
1245
+ } else if (item.url && modifierHeldRef.current) {
1246
+ window.open(item.url, '_blank')
1247
+ setOpen(false)
1248
+ setActivePage('root')
1249
+ } else {
1250
+ item.onSelect()
1251
+ }
1252
+ }}
1065
1253
  >
1066
1254
  <AvatarIcon username={group.author} />
1067
1255
  <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}>
@@ -1093,6 +1281,7 @@ export default function StoryboardCommandPalette({ basePath }) {
1093
1281
  setActivePage('root')
1094
1282
  }}
1095
1283
  >
1284
+ {opt.icon && <Icon name={opt.icon} size={ICON_SIZE} color="var(--fgColor-muted, #656d76)" />}
1096
1285
  {opt.toolHandler === 'core:theme' && opt.value === currentTheme
1097
1286
  ? <span style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'center' }}><span>{opt.label}</span><span>✓</span></span>
1098
1287
  : opt.label}
@@ -10,6 +10,7 @@
10
10
  inset: 0;
11
11
  background: rgba(0, 0, 0, 0.5);
12
12
  z-index: 10000;
13
+ animation: cmdk-overlay-in 120ms ease-out;
13
14
  }
14
15
 
15
16
  /* ─── Dialog content (Radix Content wrapper) ─── */
@@ -29,6 +30,17 @@
29
30
  border: 1px solid var(--borderColor-muted, #d1d9e0);
30
31
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
31
32
  outline: none;
33
+ animation: cmdk-dialog-in 120ms ease-out;
34
+ }
35
+
36
+ @keyframes cmdk-overlay-in {
37
+ from { opacity: 0; }
38
+ to { opacity: 1; }
39
+ }
40
+
41
+ @keyframes cmdk-dialog-in {
42
+ from { opacity: 0; transform: translateX(-50%) scale(0.97); }
43
+ to { opacity: 1; transform: translateX(-50%) scale(1); }
32
44
  }
33
45
 
34
46
  /* ─── Input ─── */