cursedbelt 5.78.0 → 5.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/react/canvas-hud/HudControls.d.ts +8 -2
  2. package/dist/react/canvas-hud/HudControls.d.ts.map +1 -1
  3. package/dist/react/canvas-hud/HudControls.js +6 -3
  4. package/dist/react/canvas-hud/HudControls.js.map +1 -1
  5. package/dist/react/components/AppShell.d.ts +14 -0
  6. package/dist/react/components/AppShell.d.ts.map +1 -1
  7. package/dist/react/components/AppShell.js +7 -31
  8. package/dist/react/components/AppShell.js.map +1 -1
  9. package/dist/react/components/ChipFilterStrip.d.ts.map +1 -1
  10. package/dist/react/components/ChipFilterStrip.js +2 -18
  11. package/dist/react/components/ChipFilterStrip.js.map +1 -1
  12. package/dist/react/components/KanbanBoard.d.ts +55 -0
  13. package/dist/react/components/KanbanBoard.d.ts.map +1 -0
  14. package/dist/react/components/KanbanBoard.js +89 -0
  15. package/dist/react/components/KanbanBoard.js.map +1 -0
  16. package/dist/react/components/ResponsiveWorkbench.d.ts +14 -1
  17. package/dist/react/components/ResponsiveWorkbench.d.ts.map +1 -1
  18. package/dist/react/components/ResponsiveWorkbench.js +33 -4
  19. package/dist/react/components/ResponsiveWorkbench.js.map +1 -1
  20. package/dist/react/components/SelectField.d.ts +18 -48
  21. package/dist/react/components/SelectField.d.ts.map +1 -1
  22. package/dist/react/components/SelectField.js +88 -11
  23. package/dist/react/components/SelectField.js.map +1 -1
  24. package/dist/react/components/SelectFieldImpl.d.ts +7 -0
  25. package/dist/react/components/SelectFieldImpl.d.ts.map +1 -0
  26. package/dist/react/components/SelectFieldImpl.js +26 -0
  27. package/dist/react/components/SelectFieldImpl.js.map +1 -0
  28. package/dist/react/components/filterChip.d.ts +8 -0
  29. package/dist/react/components/filterChip.d.ts.map +1 -0
  30. package/dist/react/components/filterChip.js +20 -0
  31. package/dist/react/components/filterChip.js.map +1 -0
  32. package/dist/react/hooks/pointerDnd.d.ts +37 -3
  33. package/dist/react/hooks/pointerDnd.d.ts.map +1 -1
  34. package/dist/react/hooks/pointerDnd.js +101 -9
  35. package/dist/react/hooks/pointerDnd.js.map +1 -1
  36. package/dist/react/hooks/pragmaticDnd.d.ts +4 -1
  37. package/dist/react/hooks/pragmaticDnd.d.ts.map +1 -1
  38. package/dist/react/hooks/pragmaticDnd.js +9 -3
  39. package/dist/react/hooks/pragmaticDnd.js.map +1 -1
  40. package/dist/react/hooks/usePublishedBottomChrome.d.ts +12 -0
  41. package/dist/react/hooks/usePublishedBottomChrome.d.ts.map +1 -0
  42. package/dist/react/hooks/usePublishedBottomChrome.js +31 -0
  43. package/dist/react/hooks/usePublishedBottomChrome.js.map +1 -0
  44. package/dist/react/index.d.ts +3 -0
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +3 -0
  47. package/dist/react/index.js.map +1 -1
  48. package/dist/react/lib/selectField.d.ts +72 -0
  49. package/dist/react/lib/selectField.d.ts.map +1 -0
  50. package/dist/react/lib/selectField.js +23 -0
  51. package/dist/react/lib/selectField.js.map +1 -0
  52. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  53. package/dist/react/media/VideoChapterEditor.js +2 -1
  54. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  55. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  56. package/dist/react/media/VideoCutEditor.js +2 -1
  57. package/dist/react/media/VideoCutEditor.js.map +1 -1
  58. package/dist/react/media/denseGallery.css +2 -0
  59. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  60. package/dist/react/media-gallery/MediaGallery.js +6 -7
  61. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  62. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -1
  63. package/dist/react/media-gallery/MediaMetaEditor.js +2 -1
  64. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -1
  65. package/dist/react/nav/CommandBar.d.ts.map +1 -1
  66. package/dist/react/nav/CommandBar.js +2 -1
  67. package/dist/react/nav/CommandBar.js.map +1 -1
  68. package/dist/react/palette/CommandPalette.d.ts +15 -1
  69. package/dist/react/palette/CommandPalette.d.ts.map +1 -1
  70. package/dist/react/palette/CommandPalette.js +58 -8
  71. package/dist/react/palette/CommandPalette.js.map +1 -1
  72. package/dist/react/palette/Kbd.d.ts +15 -0
  73. package/dist/react/palette/Kbd.d.ts.map +1 -0
  74. package/dist/react/palette/Kbd.js +17 -0
  75. package/dist/react/palette/Kbd.js.map +1 -0
  76. package/dist/react/palette/ShortcutSheet.d.ts +30 -0
  77. package/dist/react/palette/ShortcutSheet.d.ts.map +1 -0
  78. package/dist/react/palette/ShortcutSheet.js +15 -0
  79. package/dist/react/palette/ShortcutSheet.js.map +1 -0
  80. package/dist/react/palette/commandSources.d.ts +14 -0
  81. package/dist/react/palette/commandSources.d.ts.map +1 -1
  82. package/dist/react/palette/commandSources.js +11 -1
  83. package/dist/react/palette/commandSources.js.map +1 -1
  84. package/dist/react/palette/index.d.ts +4 -2
  85. package/dist/react/palette/index.d.ts.map +1 -1
  86. package/dist/react/palette/index.js +2 -0
  87. package/dist/react/palette/index.js.map +1 -1
  88. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  89. package/dist/styles-areas/canvas-hud.css +2 -2
  90. package/dist/styles-areas/kanban.css +31 -0
  91. package/dist/styles-areas/palette.css +3 -3
  92. package/dist/styles-areas/workbench.css +15 -13
  93. package/dist/styles-utilities.css +42 -0
  94. package/package.json +3 -2
  95. package/scripts/guardrails-baseline.json +2 -1
  96. package/scripts/guardrailsEnforce.spec.ts +62 -0
  97. package/scripts/guardrailsEnforce.ts +77 -6
  98. package/scripts/styleAreas.ts +2 -0
  99. package/src/react/canvas-hud/HudControls.spec.tsx +11 -0
  100. package/src/react/canvas-hud/HudControls.tsx +33 -9
  101. package/src/react/components/AppShell.spec.tsx +40 -0
  102. package/src/react/components/AppShell.tsx +25 -27
  103. package/src/react/components/ChipFilterStrip.tsx +2 -38
  104. package/src/react/components/KanbanBoard.spec.tsx +89 -0
  105. package/src/react/components/KanbanBoard.tsx +270 -0
  106. package/src/react/components/ResponsiveWorkbench.spec.tsx +57 -0
  107. package/src/react/components/ResponsiveWorkbench.tsx +69 -4
  108. package/src/react/components/SelectField.spec.tsx +117 -0
  109. package/src/react/components/SelectField.tsx +131 -166
  110. package/src/react/components/SelectFieldImpl.tsx +140 -0
  111. package/src/react/components/filterChip.tsx +43 -0
  112. package/src/react/hooks/pointerDnd.spec.ts +89 -3
  113. package/src/react/hooks/pointerDnd.ts +103 -8
  114. package/src/react/hooks/pragmaticDnd.ts +13 -2
  115. package/src/react/hooks/usePublishedBottomChrome.ts +30 -0
  116. package/src/react/hooks/useScrollMemory.spec.tsx +7 -5
  117. package/src/react/index.ts +3 -0
  118. package/src/react/lib/floatingStyles.spec.ts +1 -1
  119. package/src/react/lib/selectField.ts +96 -0
  120. package/src/react/media/VideoChapterEditor.tsx +3 -2
  121. package/src/react/media/VideoCutEditor.tsx +3 -2
  122. package/src/react/media/denseGallery.css +2 -0
  123. package/src/react/media-gallery/MediaGallery.spec.tsx +15 -11
  124. package/src/react/media-gallery/MediaGallery.tsx +16 -18
  125. package/src/react/media-gallery/MediaMetaEditor.tsx +2 -1
  126. package/src/react/nav/CommandBar.tsx +2 -3
  127. package/src/react/palette/CommandPalette.spec.tsx +73 -0
  128. package/src/react/palette/CommandPalette.tsx +153 -13
  129. package/src/react/palette/Kbd.tsx +46 -0
  130. package/src/react/palette/ShortcutSheet.spec.tsx +58 -0
  131. package/src/react/palette/ShortcutSheet.tsx +110 -0
  132. package/src/react/palette/commandSources.tsx +30 -1
  133. package/src/react/palette/index.ts +9 -0
  134. package/src/styles-areas/canvas-hud.css +2 -2
  135. package/src/styles-areas/kanban.css +31 -0
  136. package/src/styles-areas/palette.css +3 -3
  137. package/src/styles-areas/workbench.css +15 -13
  138. package/src/styles-utilities.css +42 -0
@@ -6,6 +6,7 @@ import { FieldLabel } from '../components/FieldLabel.js';
6
6
  import { InfoHint } from '../components/InfoHint.js';
7
7
  import { Modal } from '../components/Modal.js';
8
8
  import { TagsInput } from '../components/TagsInput.js';
9
+ import { Kbd } from '../palette/Kbd.js';
9
10
  import {
10
11
  type MediaMetaChange,
11
12
  cleanTags,
@@ -156,7 +157,7 @@ export function MediaMetaEditor({
156
157
  <>
157
158
  Tags are how this file is found again: each one becomes a chip above the
158
159
  grid that filters the album down to the things wearing it. Type and press{' '}
159
- <kbd>Enter</kbd>, or pick one already in use.
160
+ <Kbd>Enter</Kbd>, or pick one already in use.
160
161
  </>
161
162
  }
162
163
  >
@@ -2,6 +2,7 @@ import { Plus, Search } from 'lucide-react';
2
2
  import type { ReactNode } from 'react';
3
3
  import { Button } from '../components/Button.js';
4
4
  import { cn } from '../lib/cn.js';
5
+ import { Kbd } from '../palette/Kbd.js';
5
6
 
6
7
  export interface CommandBarProps {
7
8
  /**
@@ -93,9 +94,7 @@ export function CommandBar({
93
94
  {commandPlaceholder}
94
95
  </span>
95
96
  {commandHint != null && !compact && (
96
- <kbd className='hidden shrink-0 items-center rounded border border-border bg-background px-1.5 py-0.5 font-sans text-xs text-muted-foreground sm:inline-flex'>
97
- {commandHint}
98
- </kbd>
97
+ <Kbd className='hidden shrink-0 items-center sm:inline-flex'>{commandHint}</Kbd>
99
98
  )}
100
99
  </Button>
101
100
  )}
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, mock, test } from 'bun:test';
2
2
  import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
3
3
  import type { GateFn, NavGate, NavigationModel } from 'cursedbelt-core/navigation';
4
+ import { formatHotkey, isApplePlatform } from 'cursedbelt-core/palette/hotkeys';
4
5
  import { shortcutRegistry } from 'cursedbelt-core/palette/shortcut-registry';
5
6
  import type { SearchResultGroup } from '../nav/SearchResults.js';
6
7
  import { CommandPalette } from './CommandPalette.js';
@@ -451,3 +452,75 @@ describe('contributed sources — recent items first (OVL-8)', () => {
451
452
  localStorage.removeItem(PALETTE_RECENT_STORAGE_KEY);
452
453
  });
453
454
  });
455
+
456
+ describe('a row chord and the hint footer (the vault omnibar, forge task 3031)', () => {
457
+ const mod = { metaKey: isApplePlatform(), ctrlKey: !isApplePlatform() };
458
+ const setup = (overrides: { hints?: { keys: string; label: string; onSelect?: () => void }[] } = {}) => {
459
+ const copied: string[] = [];
460
+ const onOpenChange = mock((_open: boolean) => {});
461
+ const sources = [
462
+ {
463
+ group: 'Entries',
464
+ items: [
465
+ { id: 'a', label: 'Alpha', onSelect: () => {}, actions: [{ keys: '$mod+c', label: 'Copy secret', run: () => copied.push('a') }] },
466
+ { id: 'b', label: 'Beta', onSelect: () => {} },
467
+ { id: 'c', label: 'Gamma', onSelect: () => {}, actions: [{ keys: '$mod+c', label: 'Copy secret', run: () => copied.push('c') }] },
468
+ ],
469
+ },
470
+ ];
471
+ render(
472
+ <CommandPalette
473
+ model={model}
474
+ onNavigate={() => {}}
475
+ shortcuts={[]}
476
+ sources={sources}
477
+ open
478
+ onOpenChange={onOpenChange}
479
+ hints={'hints' in overrides ? overrides.hints : []}
480
+ />,
481
+ );
482
+ const input = screen.getByRole('combobox');
483
+ return { copied, onOpenChange, input };
484
+ };
485
+ const footerActions = () =>
486
+ Array.from(document.querySelectorAll('[data-cb-palette-action]')).map((el) => el.textContent);
487
+
488
+ test('the chord runs on the HIGHLIGHTED row only, and closes the palette', () => {
489
+ localStorage.removeItem(PALETTE_RECENT_STORAGE_KEY);
490
+ const { copied, onOpenChange, input } = setup();
491
+ // Alpha is highlighted first: the footer names its chord.
492
+ expect(footerActions()).toEqual([`${formatHotkey('$mod+c')}Copy secret`]);
493
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
494
+ // Beta has no chord: the footer drops it, and the chord does nothing.
495
+ expect(footerActions()).toEqual([]);
496
+ fireEvent.keyDown(input, { key: 'c', ...mod });
497
+ expect(copied).toEqual([]);
498
+ expect(onOpenChange).not.toHaveBeenCalled();
499
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
500
+ fireEvent.keyDown(input, { key: 'c', ...mod });
501
+ expect(copied).toEqual(['c']);
502
+ expect(onOpenChange).toHaveBeenCalledWith(false);
503
+ localStorage.removeItem(PALETTE_RECENT_STORAGE_KEY);
504
+ });
505
+
506
+ test('⌘C over SELECTED query text is the input’s copy, never the row’s', () => {
507
+ const { copied, input } = setup();
508
+ fireEvent.change(input, { target: { value: 'al' } });
509
+ (input as HTMLInputElement).setSelectionRange(0, 2);
510
+ fireEvent.keyDown(input, { key: 'c', ...mod });
511
+ expect(copied).toEqual([]);
512
+ });
513
+
514
+ test('a hint with onSelect is a button that closes the palette and runs', () => {
515
+ const opened: string[] = [];
516
+ const { onOpenChange } = setup({ hints: [{ keys: '?', label: 'Shortcuts', onSelect: () => opened.push('sheet') }] });
517
+ fireEvent.click(screen.getByRole('button', { name: /Shortcuts/ }));
518
+ expect(onOpenChange).toHaveBeenCalledWith(false);
519
+ expect(opened).toEqual(['sheet']);
520
+ });
521
+
522
+ test('no hints, no footer — the default every other palette keeps', () => {
523
+ setup({ hints: undefined });
524
+ expect(document.querySelector('[data-cb-palette-hints]')).toBeNull();
525
+ });
526
+ });
@@ -13,7 +13,7 @@
13
13
 
14
14
  import * as Dialog from '@radix-ui/react-dialog';
15
15
  import { Pin, Plus } from 'lucide-react';
16
- import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
+ import { type KeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
17
17
  import {
18
18
  allowAllGate,
19
19
  deriveSearchCatalogue,
@@ -24,7 +24,7 @@ import {
24
24
  type SearchCatalogueEntry,
25
25
  } from 'cursedbelt-core/navigation';
26
26
  import { fuzzyRankItems } from 'cursedbelt-core/palette/fuzzy-rank';
27
- import { formatHotkey } from 'cursedbelt-core/palette/hotkeys';
27
+ import { matchHotkey } from 'cursedbelt-core/palette/hotkeys';
28
28
  import type { ShortcutEntry } from 'cursedbelt-core/palette/shortcut-registry';
29
29
  import { Button } from '../components/Button.js';
30
30
  import { ScrollArea } from '../components/ScrollArea.js';
@@ -43,7 +43,14 @@ import {
43
43
  CommandItem,
44
44
  CommandList,
45
45
  } from './Command.js';
46
- import { type CommandSource, type CommandSourceItem, recentFirst, rememberCommandItem } from './commandSources.js';
46
+ import {
47
+ type CommandItemAction,
48
+ type CommandSource,
49
+ type CommandSourceItem,
50
+ recentFirst,
51
+ rememberCommandItem,
52
+ } from './commandSources.js';
53
+ import { Kbd } from './Kbd.js';
47
54
  import { useShortcutRegistry } from './useShortcutRegistry.js';
48
55
  import { TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
49
56
 
@@ -106,6 +113,13 @@ export interface CommandPaletteProps {
106
113
  */
107
114
  sources?: readonly CommandSource[];
108
115
 
116
+ /**
117
+ * A key-hint row along the palette's foot: ↑↓ Navigate · ↵ Open, the highlighted row's own
118
+ * chords (its `actions` — "⌘C Copy secret"), then these. A hint with `onSelect` is a button
119
+ * (the vault's "? Shortcuts" opens its sheet). Omit for no footer, the default.
120
+ */
121
+ hints?: readonly CommandPaletteHint[];
122
+
109
123
  /** Resolve model icon *names* to nodes (the app wires its icon set, e.g. lucide). */
110
124
  resolveIcon?: IconResolver;
111
125
  /** Max fuzzy page results. Default 30. */
@@ -116,6 +130,24 @@ export interface CommandPaletteProps {
116
130
  className?: string;
117
131
  }
118
132
 
133
+ /** One entry in the palette's foot (see {@link CommandPaletteProps.hints}). */
134
+ export interface CommandPaletteHint {
135
+ /** A binding (`'?'`, `'$mod+/'`), drawn as a key cap. */
136
+ keys: string;
137
+ label: string;
138
+ /** Makes the hint a button. The palette closes first. */
139
+ onSelect?: () => void;
140
+ }
141
+
142
+ /** The row a chord acts on: its actions, and what to remember it as when one runs. */
143
+ interface RowActions {
144
+ group: string;
145
+ id: string;
146
+ actions: readonly CommandItemAction[];
147
+ }
148
+
149
+ const NO_ROW: RowActions | undefined = undefined;
150
+
119
151
  /** A page hit grouped under its owning domain, preserving uFuzzy rank order. */
120
152
  interface PageGroup {
121
153
  id: string;
@@ -152,6 +184,7 @@ export function CommandPalette({
152
184
  searchDebounceMs = 300,
153
185
  places = false,
154
186
  sources,
187
+ hints,
155
188
  resolveIcon,
156
189
  pagesLimit = 30,
157
190
  placeholder = 'Search pages, run a command…',
@@ -178,10 +211,17 @@ export function CommandPalette({
178
211
  onQueryChangeRef.current?.(debouncedQuery);
179
212
  }, [debouncedQuery]);
180
213
 
214
+ // The highlighted row's cmdk value — controlled, so a row's chords (`actions`) and the footer's
215
+ // hints can follow the highlight.
216
+ const [selected, setSelected] = useState('');
217
+
181
218
  // Reset the query each time the palette closes so it reopens cold (the Radix Dialog
182
219
  // unmounts its content, but this component stays mounted and holds the state).
183
220
  useEffect(() => {
184
- if (!open) setQuery('');
221
+ if (!open) {
222
+ setQuery('');
223
+ setSelected('');
224
+ }
185
225
  }, [open]);
186
226
 
187
227
  // ── Derivations off the registry ──────────────────────────────────────────
@@ -274,6 +314,36 @@ export function CommandPalette({
274
314
  .filter((group) => group.items.length > 0);
275
315
  }, [sources, isCold, trimmed, open]);
276
316
 
317
+ /** Every contributed row with chords, by its cmdk value (see `SourceGroups`). */
318
+ const rowActions = useMemo(() => {
319
+ const map = new Map<string, RowActions>();
320
+ for (const group of sourceGroups) {
321
+ for (const item of group.items) {
322
+ if (item.actions?.length) {
323
+ map.set(sourceValue(group.key, item.id), { group: group.label, id: item.id, actions: item.actions });
324
+ }
325
+ }
326
+ }
327
+ return map;
328
+ }, [sourceGroups]);
329
+ const selectedRow = rowActions.get(selected) ?? NO_ROW;
330
+
331
+ /**
332
+ * A highlighted row's chord runs its action and closes the palette. 🔴 A chord the input itself
333
+ * answers stays the input's: ⌘C over SELECTED query text copies that text, never the row.
334
+ */
335
+ const onPaletteKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
336
+ if (!selectedRow || event.defaultPrevented) return;
337
+ const action = selectedRow.actions.find((a) => matchHotkey(event.nativeEvent, a.keys));
338
+ if (!action) return;
339
+ const field = event.target as Partial<HTMLInputElement>;
340
+ if (typeof field.selectionStart === 'number' && field.selectionStart !== field.selectionEnd) return;
341
+ event.preventDefault();
342
+ rememberCommandItem(selectedRow.group, selectedRow.id);
343
+ action.run();
344
+ onOpenChange(false);
345
+ };
346
+
277
347
  // ── Actions ───────────────────────────────────────────────────────────────
278
348
  const runSource = (group: string, item: CommandSourceItem) => {
279
349
  rememberCommandItem(group, item.id);
@@ -325,6 +395,9 @@ export function CommandPalette({
325
395
  <Command
326
396
  shouldFilter={false}
327
397
  loop
398
+ value={selected}
399
+ onValueChange={setSelected}
400
+ onKeyDown={onPaletteKeyDown}
328
401
  label='Command palette'
329
402
  className='rounded-xl border border-border shadow-[var(--shadow-overlay)]'
330
403
  >
@@ -337,9 +410,7 @@ export function CommandPalette({
337
410
  trailing={
338
411
  <>
339
412
  {showSpinner && <Spinner size='sm' />}
340
- <kbd className='hidden rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-caption font-medium text-muted-foreground sm:inline-block'>
341
- {formatHotkey(TOGGLE_BINDING)}
342
- </kbd>
413
+ <Kbd keys={TOGGLE_BINDING} className='hidden sm:inline-block' />
343
414
  </>
344
415
  }
345
416
  />
@@ -440,6 +511,16 @@ export function CommandPalette({
440
511
  </>
441
512
  )}
442
513
  </CommandList>
514
+ {hints && (
515
+ <PaletteHints
516
+ actions={selectedRow?.actions}
517
+ hints={hints}
518
+ onHint={(hint) => {
519
+ onOpenChange(false);
520
+ hint.onSelect?.();
521
+ }}
522
+ />
523
+ )}
443
524
  </Command>
444
525
  </Dialog.Content>
445
526
  </Dialog.Portal>
@@ -541,7 +622,7 @@ function SourceGroups({
541
622
  {groups.map((group) => (
542
623
  <CommandGroup key={`source-${group.key}`} heading={group.label}>
543
624
  {group.items.map((item) => (
544
- <CommandItem key={`${group.key}:${item.id}`} value={`source:${group.key}:${item.id}`} onSelect={() => onRun(group.label, item)}>
625
+ <CommandItem key={`${group.key}:${item.id}`} value={sourceValue(group.key, item.id)} onSelect={() => onRun(group.label, item)}>
545
626
  <ItemBody
546
627
  icon={item.icon}
547
628
  label={item.label}
@@ -556,6 +637,69 @@ function SourceGroups({
556
637
  );
557
638
  }
558
639
 
640
+ /** A contributed row's cmdk value — the key its chords are found by. */
641
+ const sourceValue = (groupKey: string, id: string) => `source:${groupKey}:${id}`;
642
+
643
+ /**
644
+ * The foot: how to move and open, the highlighted row's chords, then the app's hints. Keys are a
645
+ * pointer's business, so a phone does not get the row (the ⌘K badge above hides the same way).
646
+ */
647
+ function PaletteHints({
648
+ actions,
649
+ hints,
650
+ onHint,
651
+ }: {
652
+ actions: readonly CommandItemAction[] | undefined;
653
+ hints: readonly CommandPaletteHint[];
654
+ onHint: (hint: CommandPaletteHint) => void;
655
+ }) {
656
+ return (
657
+ <div
658
+ data-cb-palette-hints=''
659
+ className='hidden w-full shrink-0 items-center gap-4 border-t border-border px-3 py-2 text-caption text-muted-foreground sm:inline-flex'
660
+ >
661
+ <span className='flex items-center gap-1.5'>
662
+ <Kbd>↑↓</Kbd>
663
+ Navigate
664
+ </span>
665
+ <span className='flex items-center gap-1.5'>
666
+ <Kbd>↵</Kbd>
667
+ Open
668
+ </span>
669
+ {actions?.map((action) => (
670
+ <span key={action.keys} data-cb-palette-action='' className='flex items-center gap-1.5'>
671
+ <Kbd keys={action.keys} />
672
+ {action.label}
673
+ </span>
674
+ ))}
675
+ {hints.length > 0 && (
676
+ <span className='ml-auto flex items-center gap-2'>
677
+ {hints.map((hint) =>
678
+ hint.onSelect ? (
679
+ <Button
680
+ key={hint.keys}
681
+ variant='ghost'
682
+ size='sm'
683
+ tooltip={hint.label}
684
+ onClick={() => onHint(hint)}
685
+ className='h-7 gap-1.5 px-1.5 text-caption font-normal text-muted-foreground'
686
+ >
687
+ <Kbd keys={hint.keys} />
688
+ {hint.label}
689
+ </Button>
690
+ ) : (
691
+ <span key={hint.keys} className='flex items-center gap-1.5'>
692
+ <Kbd keys={hint.keys} />
693
+ {hint.label}
694
+ </span>
695
+ ),
696
+ )}
697
+ </span>
698
+ )}
699
+ </div>
700
+ );
701
+ }
702
+
559
703
  /** Shared row layout: optional leading icon, a label + sub stack, optional trailing node. */
560
704
  function ItemBody({
561
705
  icon,
@@ -591,11 +735,7 @@ function ItemBody({
591
735
 
592
736
  /** A keyboard-shortcut hint, formatted for the current platform (⌘K / Ctrl K). */
593
737
  function KeyHint({ keys }: { keys: string }) {
594
- return (
595
- <kbd className='rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-caption font-medium text-muted-foreground'>
596
- {formatHotkey(keys)}
597
- </kbd>
598
- );
738
+ return <Kbd keys={keys} />;
599
739
  }
600
740
 
601
741
  /**
@@ -0,0 +1,46 @@
1
+ // A key cap — the one drawing of a keyboard key in the kit. The palette's ⌘K badge, its rows'
2
+ // shortcut hints, its footer and the `ShortcutSheet` all use it, so a key reads the same
3
+ // everywhere. Pass `keys` for a binding (`'$mod+k'` → ⌘K on a Mac, Ctrl K elsewhere) or
4
+ // children for a literal (`↑↓`). `platform` pins the notation when the reader chose one (family's
5
+ // Mac / Windows keys setting) instead of the browser's guess; 'both' draws the Mac cap with the
6
+ // Windows one beside it, in parentheses, where the two differ.
7
+
8
+ import type { ReactNode } from 'react';
9
+ import { formatHotkey } from 'cursedbelt-core/palette/hotkeys';
10
+ import { cn } from '../lib/cn.js';
11
+
12
+ /** Whose notation a binding is drawn in: ⌘⌥⇧ glyphs, or Ctrl / Alt / Shift words. */
13
+ export type KbdPlatform = 'mac' | 'windows';
14
+
15
+ export interface KbdProps {
16
+ /** A binding in the `useHotkeys` format, formatted for this platform. */
17
+ keys?: string;
18
+ /** Draw `keys` in this platform's notation; omitted, the browser's own. */
19
+ platform?: KbdPlatform | 'both';
20
+ /** A literal cap, when there is no binding to format. */
21
+ children?: ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ const CAP =
26
+ 'rounded border border-border bg-muted px-1.5 py-0.5 font-sans text-caption font-medium text-muted-foreground';
27
+
28
+ /** One key cap, in the platform's notation. */
29
+ export function Kbd({ keys, platform, children, className }: KbdProps) {
30
+ if (keys !== undefined && platform === 'both') {
31
+ const mac = formatHotkey(keys, true);
32
+ const windows = formatHotkey(keys, false);
33
+ if (mac !== windows) {
34
+ return (
35
+ <span className={cn('inline-flex items-center gap-1', className)}>
36
+ <kbd className={CAP}>{mac}</kbd>
37
+ <span className='text-caption text-muted-foreground'>
38
+ (<kbd className={CAP}>{windows}</kbd>)
39
+ </span>
40
+ </span>
41
+ );
42
+ }
43
+ }
44
+ const apple = platform === 'mac' ? true : platform === 'windows' ? false : undefined;
45
+ return <kbd className={cn(CAP, className)}>{keys !== undefined ? formatHotkey(keys, apple) : children}</kbd>;
46
+ }
@@ -0,0 +1,58 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react';
3
+ import { formatHotkey } from 'cursedbelt-core/palette/hotkeys';
4
+ import { ShortcutSheet } from './ShortcutSheet.js';
5
+
6
+ afterEach(cleanup);
7
+
8
+ describe('ShortcutSheet', () => {
9
+ test('draws each group, each action, and every binding that reaches it in the platform notation', () => {
10
+ render(
11
+ <ShortcutSheet
12
+ open
13
+ onClose={() => {}}
14
+ groups={[
15
+ { heading: 'Anywhere', items: [{ label: 'Search', keys: ['/', '$mod+k'] }] },
16
+ { heading: 'An open entry', items: [{ label: 'Tags', keys: 'shift+t', where: 'in an open entry' }] },
17
+ ]}
18
+ />,
19
+ );
20
+ expect(screen.getByRole('region', { name: 'Anywhere' })).toBeTruthy();
21
+ const rows = Array.from(document.querySelectorAll('[data-cb-shortcut]'));
22
+ expect(rows).toHaveLength(2);
23
+ const caps = (row: Element) => Array.from(row.querySelectorAll('kbd')).map((k) => k.textContent);
24
+ expect(caps(rows[0]!)).toEqual(['/', formatHotkey('$mod+k')]);
25
+ expect(rows[0]!.textContent).toContain('or');
26
+ expect(caps(rows[1]!)).toEqual([formatHotkey('shift+t')]);
27
+ expect(rows[1]!.textContent).toContain('in an open entry');
28
+ });
29
+
30
+ test('platform pins the notation: mac, windows, or both — the Windows chord in parentheses only where it differs', () => {
31
+ const groups = [{ heading: 'Keys', items: [{ label: 'Find', keys: '$mod+k' }, { label: 'Center', keys: 'c' }] }];
32
+ const caps = () =>
33
+ Array.from(document.querySelectorAll('[data-cb-shortcut]')).map((row) =>
34
+ Array.from(row.querySelectorAll('kbd')).map((k) => k.textContent),
35
+ );
36
+ const { rerender } = render(<ShortcutSheet open onClose={() => {}} groups={groups} platform='mac' />);
37
+ expect(caps()).toEqual([['⌘K'], ['C']]);
38
+ rerender(<ShortcutSheet open onClose={() => {}} groups={groups} platform='windows' />);
39
+ expect(caps()).toEqual([['Ctrl K'], ['C']]);
40
+ rerender(<ShortcutSheet open onClose={() => {}} groups={groups} platform='both' />);
41
+ expect(caps()).toEqual([['⌘K', 'Ctrl K'], ['C']]);
42
+ expect(document.querySelector('[data-cb-shortcut]')!.textContent).toContain('(Ctrl K)');
43
+ });
44
+
45
+ test('literal caps draw as written, for a key the grammar cannot spell', () => {
46
+ render(
47
+ <ShortcutSheet open onClose={() => {}} groups={[{ heading: 'Move', items: [{ label: 'Up and down', caps: ['↑', '↓'] }] }]} />,
48
+ );
49
+ expect(Array.from(document.querySelectorAll('[data-cb-shortcut] kbd')).map((k) => k.textContent)).toEqual(['↑', '↓']);
50
+ });
51
+
52
+ test('Done closes it', () => {
53
+ const onClose = mock(() => {});
54
+ render(<ShortcutSheet open onClose={onClose} groups={[]} />);
55
+ fireEvent.click(screen.getByRole('button', { name: 'Done' }));
56
+ expect(onClose).toHaveBeenCalled();
57
+ });
58
+ });
@@ -0,0 +1,110 @@
1
+ // The `?` sheet — every key an app answers, on one dialog (Linear's and GitHub's `?`). A key that
2
+ // is not written down is a key nobody finds, so an app lists its map here once and the sheet
3
+ // draws it: grouped, the action on the left, its caps on the right in the platform's notation.
4
+ //
5
+ // `platform` is for an app that lets its reader choose the notation (family's Mac / Windows / both
6
+ // setting) — `Kbd`'s own, so 'both' draws the Mac caps with the Windows chord beside them, in
7
+ // parentheses, where the two differ. A key the binding grammar cannot spell ("↑ ↓", "Space (hold)") is `caps`.
8
+ //
9
+ // The app owns `open` and binds `?` itself (outside a field), because only the app knows what
10
+ // counts as typing on its screens. The list is data, so a test can hold it to the handlers.
11
+
12
+ import type { ReactNode } from 'react';
13
+ import { ActionBar } from '../components/ActionBar.js';
14
+ import { Button } from '../components/Button.js';
15
+ import { Modal } from '../components/Modal.js';
16
+ import { Kbd, type KbdProps } from './Kbd.js';
17
+
18
+ export interface ShortcutSheetItem {
19
+ /** What the key does ("Copy the secret"). */
20
+ label: string;
21
+ /** One binding (`'$mod+c'`), or several that do the same thing (`['/', '$mod+k']`). */
22
+ keys?: string | readonly string[];
23
+ /** Literal caps, drawn as written, for a key the binding grammar cannot spell (`['↑', '↓']`).
24
+ * Wins over `keys`. */
25
+ caps?: readonly string[];
26
+ /** Where it works, when that is narrower than its group ("in an open entry"). */
27
+ where?: string;
28
+ }
29
+
30
+ export interface ShortcutSheetGroup {
31
+ heading: string;
32
+ items: readonly ShortcutSheetItem[];
33
+ }
34
+
35
+ export interface ShortcutSheetProps {
36
+ open: boolean;
37
+ onClose: () => void;
38
+ groups: readonly ShortcutSheetGroup[];
39
+ title?: ReactNode;
40
+ subtitle?: ReactNode;
41
+ /** The notation every binding is drawn in; omitted, the browser's own. 'both' adds the
42
+ * Windows chord in parentheses wherever it differs from the Mac's. */
43
+ platform?: KbdProps['platform'];
44
+ }
45
+
46
+ /** The keyboard map as a dialog. */
47
+ export function ShortcutSheet({
48
+ open,
49
+ onClose,
50
+ groups,
51
+ title = 'Keyboard shortcuts',
52
+ subtitle = 'Keys work anywhere outside a text field.',
53
+ platform,
54
+ }: ShortcutSheetProps) {
55
+ return (
56
+ <Modal
57
+ open={open}
58
+ onClose={onClose}
59
+ size='md'
60
+ title={title}
61
+ subtitle={subtitle}
62
+ footer={
63
+ <ActionBar
64
+ primary={
65
+ <Button variant='primary' tooltip='Close the shortcut list (Esc)' onClick={onClose}>
66
+ Done
67
+ </Button>
68
+ }
69
+ />
70
+ }
71
+ >
72
+ <div data-cb-shortcut-sheet='' className='flex flex-col gap-6'>
73
+ {groups.map((group) => (
74
+ <section key={group.heading} aria-label={group.heading} className='flex flex-col gap-1'>
75
+ <h3 className='m-0 text-xs font-semibold uppercase tracking-wide text-muted-foreground'>
76
+ {group.heading}
77
+ </h3>
78
+ <ul className='m-0 flex flex-col p-0'>
79
+ {group.items.map((item) => {
80
+ const bindings = item.keys === undefined ? [] : typeof item.keys === 'string' ? [item.keys] : item.keys;
81
+ return (
82
+ <li
83
+ key={item.label}
84
+ data-cb-shortcut=''
85
+ className='flex items-center justify-between gap-4 border-b border-border py-2 last:border-b-0'
86
+ >
87
+ <span className='min-w-0 text-sm'>
88
+ {item.label}
89
+ {item.where && <span className='text-muted-foreground'> · {item.where}</span>}
90
+ </span>
91
+ <span className='flex shrink-0 items-center gap-1.5 text-caption text-muted-foreground'>
92
+ {item.caps
93
+ ? item.caps.map((cap) => <Kbd key={cap}>{cap}</Kbd>)
94
+ : bindings.map((binding, index) => (
95
+ <span key={binding} className='flex items-center gap-1.5'>
96
+ {index > 0 && <span>or</span>}
97
+ <Kbd keys={binding} platform={platform} />
98
+ </span>
99
+ ))}
100
+ </span>
101
+ </li>
102
+ );
103
+ })}
104
+ </ul>
105
+ </section>
106
+ ))}
107
+ </div>
108
+ </Modal>
109
+ );
110
+ }
@@ -21,6 +21,19 @@ import {
21
21
  } from 'react';
22
22
  import { devWarnOnce } from '../lib/devWarn.js';
23
23
 
24
+ /**
25
+ * A second verb on a row, run by a chord while the row is highlighted — the vault's ⌘C copies an
26
+ * entry's secret without opening it. The palette closes after it runs, and its footer names the
27
+ * highlighted row's actions so the chord is discoverable where it works.
28
+ */
29
+ export interface CommandItemAction {
30
+ /** The chord, in the `useHotkeys` format (`'$mod+c'`). */
31
+ keys: string;
32
+ /** What it does, in the footer ("Copy secret"). */
33
+ label: string;
34
+ run: () => void;
35
+ }
36
+
24
37
  export interface CommandSourceItem {
25
38
  /** Stable within its source. */
26
39
  id: string;
@@ -36,6 +49,8 @@ export interface CommandSourceItem {
36
49
  onSelect?: () => void;
37
50
  /** Or go somewhere — handed to the palette's `onNavigate`. */
38
51
  path?: string;
52
+ /** Chords that act on this row without selecting it (see {@link CommandItemAction}). */
53
+ actions?: readonly CommandItemAction[];
39
54
  }
40
55
 
41
56
  export interface CommandSource {
@@ -97,7 +112,13 @@ export function useCommandSources(): CommandSource[] {
97
112
 
98
113
  /** What a row SHOWS — an entry is replaced only when this changes. */
99
114
  const signature = (source: CommandSource): string =>
100
- [source.group, ...source.items.map((i) => `${i.id}\u0001${i.label}\u0001${i.sub ?? ''}\u0001${i.path ?? ''}`)].join(
115
+ [
116
+ source.group,
117
+ ...source.items.map(
118
+ (i) =>
119
+ `${i.id}\u0001${i.label}\u0001${i.sub ?? ''}\u0001${i.path ?? ''}\u0001${(i.actions ?? []).map((a) => `${a.keys}:${a.label}`).join(',')}`,
120
+ ),
121
+ ].join(
101
122
  '\u0002',
102
123
  );
103
124
 
@@ -135,6 +156,14 @@ export function useCommandSource(source: CommandSource): void {
135
156
  onSelect: item.onSelect
136
157
  ? () => latest.current.items.find((i) => i.id === item.id)?.onSelect?.()
137
158
  : undefined,
159
+ actions: item.actions?.map((action) => ({
160
+ ...action,
161
+ run: () =>
162
+ latest.current.items
163
+ .find((i) => i.id === item.id)
164
+ ?.actions?.find((a) => a.keys === action.keys)
165
+ ?.run(),
166
+ })),
138
167
  })),
139
168
  });
140
169
  }, [store, key, sig]);