@plannotator/ui 0.28.0 → 0.29.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 (105) hide show
  1. package/README.md +6 -0
  2. package/components/AISettingsTab.tsx +5 -4
  3. package/components/ActionMenu.tsx +5 -1
  4. package/components/AgentsTab.tsx +10 -23
  5. package/components/AnnotationPanel.tsx +9 -5
  6. package/components/AnnotationToolbar.tsx +5 -13
  7. package/components/AnnotationToolstrip.tsx +2 -2
  8. package/components/ApproveDropdown.tsx +1 -1
  9. package/components/BlockRenderer.tsx +1 -1
  10. package/components/CodeFilePopout.tsx +5 -4
  11. package/components/CommentPopover.tsx +391 -65
  12. package/components/DocBadges.tsx +29 -11
  13. package/components/ExportModal.tsx +16 -8
  14. package/components/GraphvizBlock.tsx +1 -1
  15. package/components/InlineMarkdown.tsx +30 -13
  16. package/components/KeyboardShortcuts.tsx +37 -2
  17. package/components/Landing.tsx +7 -7
  18. package/components/MenuVersionSection.tsx +4 -4
  19. package/components/MermaidBlock.tsx +1 -1
  20. package/components/ModeToggle.tsx +7 -6
  21. package/components/OpenInAppButton.tsx +2 -5
  22. package/components/PinpointOverlay.tsx +9 -7
  23. package/components/PlanHeaderMenu.tsx +8 -8
  24. package/components/PopoutDialog.tsx +6 -1
  25. package/components/ResizeHandle.tsx +1 -0
  26. package/components/Settings.tsx +172 -12
  27. package/components/SkillReferenceMenu.tsx +260 -0
  28. package/components/StickyHeaderLane.tsx +7 -0
  29. package/components/ThemeProvider.tsx +131 -32
  30. package/components/ThemeTab.tsx +123 -77
  31. package/components/ToolbarButtons.tsx +29 -8
  32. package/components/Viewer.tsx +396 -130
  33. package/components/VimKeyHud.tsx +695 -0
  34. package/components/VimModeAnnouncementDialog.tsx +557 -0
  35. package/components/VimModeOverlay.tsx +235 -0
  36. package/components/VimTargetReticle.tsx +284 -0
  37. package/components/ai/DocumentAIChatPanel.tsx +1 -1
  38. package/components/blocks/CodeBlock.tsx +18 -18
  39. package/components/blocks/TablePopout.tsx +7 -8
  40. package/components/blocks/TableToolbar.tsx +7 -8
  41. package/components/goal-setup/GoalSetupSurface.tsx +16 -3
  42. package/components/html-viewer/HtmlViewer.tsx +450 -47
  43. package/components/html-viewer/annotationNumbering.ts +37 -0
  44. package/components/html-viewer/bridge-script.ts +4051 -298
  45. package/components/html-viewer/composerYield.ts +51 -0
  46. package/components/html-viewer/srcdoc.ts +18 -3
  47. package/components/html-viewer/useHtmlAnnotation.ts +457 -32
  48. package/components/icons/themeIcons.tsx +1 -1
  49. package/components/plan-diff/PlanCleanDiffView.tsx +9 -9
  50. package/components/plan-diff/PlanDiffBadge.tsx +22 -1
  51. package/components/settings/HooksTab.tsx +12 -8
  52. package/components/sidebar/FileBrowser.tsx +4 -1
  53. package/components/themeModes.tsx +28 -0
  54. package/config/configStore.ts +76 -1
  55. package/config/settings.ts +152 -0
  56. package/configure.ts +9 -0
  57. package/globals.d.ts +7 -1
  58. package/hooks/useAIChat.ts +5 -2
  59. package/hooks/useAIProviderActivation.ts +47 -0
  60. package/hooks/useAIProviderConfig.ts +5 -1
  61. package/hooks/useAgentSettings.ts +64 -23
  62. package/hooks/useAgents.ts +4 -4
  63. package/hooks/useAnnotationHighlighter.ts +100 -3
  64. package/hooks/useArchive.ts +2 -1
  65. package/hooks/useFenceTheme.ts +17 -0
  66. package/hooks/useLinkedDoc.ts +68 -1
  67. package/hooks/usePinpoint.ts +76 -75
  68. package/hooks/usePlanDiff.ts +73 -2
  69. package/hooks/useSkillReferenceAutocomplete.ts +239 -0
  70. package/hooks/useUpdateCheck.ts +1 -2
  71. package/hooks/useVimDocumentFocus.ts +116 -0
  72. package/hooks/useVimSelection.ts +1063 -0
  73. package/package.json +4 -4
  74. package/print.css +14 -13
  75. package/shortcuts/core.ts +38 -13
  76. package/shortcuts/index.ts +10 -0
  77. package/shortcuts/plan-review/commentPopover.shortcuts.ts +7 -0
  78. package/shortcuts/plan-review/vimSelection.shortcuts.ts +251 -0
  79. package/shortcuts/runtime.ts +111 -12
  80. package/styles.css +1 -1
  81. package/theme.css +504 -0
  82. package/themes/colorblind.css +89 -0
  83. package/themes/plannotator.css +2 -2
  84. package/types.ts +93 -10
  85. package/utils/agentSwitch.ts +33 -7
  86. package/utils/blockTargeting.ts +462 -178
  87. package/utils/clipboard.ts +110 -0
  88. package/utils/codeBlockMark.ts +50 -0
  89. package/utils/codeHighlight.ts +293 -0
  90. package/utils/codexModels.ts +79 -0
  91. package/utils/domSelection.ts +84 -0
  92. package/utils/htmlChrome.ts +73 -0
  93. package/utils/inputMethod.ts +79 -6
  94. package/utils/parser.ts +517 -21
  95. package/utils/preferenceTtl.ts +15 -0
  96. package/utils/sharing.ts +0 -1
  97. package/utils/skillCatalog.ts +269 -0
  98. package/utils/skillReferences.ts +475 -0
  99. package/utils/syntaxTheme.ts +83 -0
  100. package/utils/themeRegistry.ts +154 -0
  101. package/utils/vimHud.ts +263 -0
  102. package/utils/vimModeAnnouncement.ts +23 -0
  103. package/utils/vimNavigation.ts +417 -0
  104. package/utils/vimReticle.ts +88 -0
  105. package/utils/vimScroll.ts +162 -0
@@ -0,0 +1,695 @@
1
+ import { EyeOff } from 'lucide-react';
2
+ import { useLayoutEffect, useRef, useState } from 'react';
3
+ import type { InputMethod } from '../types';
4
+ import {
5
+ getVimHudLegendGroups,
6
+ isVimHudLegendGroupActive,
7
+ type VimHudCommand,
8
+ type VimHudLegendGroup,
9
+ type VimHudLegendGroupId,
10
+ type VimHudPhase,
11
+ } from '../utils/vimHud';
12
+
13
+ const HUD_MONO_FONT = '"SFMono-Regular", "JetBrains Mono", Menlo, var(--font-mono), monospace';
14
+ // Match the seven-frame pressed state in the 30fps product-demo HUD.
15
+ const PRESSED_DURATION_MS = (7 / 30) * 1000;
16
+ const KEY_MAP_COLUMNS: readonly (readonly VimHudLegendGroupId[])[] = [
17
+ ['structure', 'control'],
18
+ ['text'],
19
+ ['selection', 'annotation'],
20
+ ];
21
+ const KEY_MAP_GROUPS = getVimHudLegendGroups();
22
+
23
+ interface PreviousKeyProps {
24
+ readonly keyLabel: string;
25
+ readonly opacity: number;
26
+ }
27
+
28
+ function PreviousKey({ keyLabel, opacity }: PreviousKeyProps) {
29
+ return (
30
+ <div
31
+ data-vim-hud-previous-key={keyLabel}
32
+ style={{
33
+ width: 34,
34
+ height: 34,
35
+ display: 'grid',
36
+ flex: '0 0 auto',
37
+ placeItems: 'center',
38
+ borderRadius: 9,
39
+ color: '#cfc8dc',
40
+ background: 'rgba(26,24,34,0.9)',
41
+ border: '1px solid rgba(255,255,255,0.09)',
42
+ borderBottom: '3px solid rgba(0,0,0,0.72)',
43
+ opacity,
44
+ fontFamily: HUD_MONO_FONT,
45
+ fontSize: keyLabel.length > 1 ? 9 : 14,
46
+ fontWeight: 850,
47
+ }}
48
+ >
49
+ {keyLabel}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ interface ActiveKeyProps {
55
+ readonly keyLabel: string;
56
+ readonly pressed: boolean;
57
+ }
58
+
59
+ function ActiveKey({ keyLabel, pressed }: ActiveKeyProps) {
60
+ return (
61
+ <div
62
+ data-vim-hud-active-key={keyLabel}
63
+ data-pressed={pressed ? 'true' : 'false'}
64
+ style={{
65
+ width: 58,
66
+ height: 58,
67
+ display: 'grid',
68
+ flex: '0 0 auto',
69
+ placeItems: 'center',
70
+ borderRadius: 14,
71
+ color: pressed ? '#1b1427' : '#f5f0ff',
72
+ background: pressed
73
+ ? 'linear-gradient(180deg, #eee9ff, #a78bfa)'
74
+ : 'linear-gradient(180deg, #292535, #15121d)',
75
+ border: pressed
76
+ ? '1px solid rgba(255,255,255,0.92)'
77
+ : '1px solid rgba(196,181,253,0.34)',
78
+ borderBottom: pressed
79
+ ? '4px solid #6750a4'
80
+ : '4px solid rgba(0,0,0,0.82)',
81
+ boxShadow: pressed
82
+ ? '0 0 32px rgba(167,139,250,0.72), 0 8px 24px rgba(0,0,0,0.42)'
83
+ : '0 0 20px rgba(167,139,250,0.17), 0 8px 24px rgba(0,0,0,0.42)',
84
+ fontFamily: HUD_MONO_FONT,
85
+ fontSize: keyLabel.length > 1 ? 13 : 29,
86
+ fontWeight: 900,
87
+ }}
88
+ >
89
+ {keyLabel}
90
+ </div>
91
+ );
92
+ }
93
+
94
+ interface VimKeyMapGroupProps {
95
+ readonly group: VimHudLegendGroup;
96
+ readonly phase: VimHudPhase;
97
+ readonly activeActionId: VimHudCommand['actionId'] | null;
98
+ }
99
+
100
+ function VimKeyMapGroup({
101
+ group,
102
+ phase,
103
+ activeActionId,
104
+ }: VimKeyMapGroupProps) {
105
+ const active = isVimHudLegendGroupActive(group.id, phase);
106
+
107
+ return (
108
+ <section
109
+ data-vim-key-map-group={group.id}
110
+ data-current={active ? 'true' : 'false'}
111
+ aria-current={active ? 'true' : undefined}
112
+ style={{
113
+ padding: '11px 12px 9px',
114
+ borderRadius: 13,
115
+ border: active
116
+ ? '1px solid rgba(196,181,253,0.34)'
117
+ : '1px solid rgba(255,255,255,0.075)',
118
+ color: '#f5f0ff',
119
+ background: active
120
+ ? 'linear-gradient(180deg, rgba(97,71,148,0.25), rgba(24,19,34,0.24))'
121
+ : 'rgba(13,11,20,0.22)',
122
+ boxShadow: active
123
+ ? 'inset 0 1px 0 rgba(255,255,255,0.06), 0 0 24px rgba(139,92,246,0.08)'
124
+ : 'inset 0 1px 0 rgba(255,255,255,0.035)',
125
+ }}
126
+ >
127
+ <div
128
+ style={{
129
+ display: 'flex',
130
+ alignItems: 'flex-start',
131
+ justifyContent: 'space-between',
132
+ gap: 8,
133
+ marginBottom: 7,
134
+ }}
135
+ >
136
+ <div style={{ minWidth: 0 }}>
137
+ <div
138
+ style={{
139
+ color: active ? '#d8ccff' : '#b4acc1',
140
+ fontFamily: HUD_MONO_FONT,
141
+ fontSize: 10,
142
+ fontWeight: 900,
143
+ letterSpacing: '0.14em',
144
+ lineHeight: 1.1,
145
+ textTransform: 'uppercase',
146
+ }}
147
+ >
148
+ {group.title}
149
+ </div>
150
+ <div
151
+ style={{
152
+ marginTop: 4,
153
+ color: '#817989',
154
+ fontSize: 10,
155
+ fontWeight: 590,
156
+ lineHeight: 1.25,
157
+ }}
158
+ >
159
+ {group.description}
160
+ </div>
161
+ </div>
162
+ {active && (
163
+ <span
164
+ style={{
165
+ flex: '0 0 auto',
166
+ padding: '3px 5px',
167
+ borderRadius: 5,
168
+ color: '#d8ccff',
169
+ background: 'rgba(167,139,250,0.13)',
170
+ fontFamily: HUD_MONO_FONT,
171
+ fontSize: 7,
172
+ fontWeight: 900,
173
+ letterSpacing: '0.11em',
174
+ }}
175
+ >
176
+ NOW
177
+ </span>
178
+ )}
179
+ </div>
180
+
181
+ <div style={{ display: 'grid', gap: 3 }}>
182
+ {group.items.map((item) => {
183
+ const isNavigationGroup = group.id === 'structure' || group.id === 'text';
184
+ const isLatestAction = item.actionId === activeActionId
185
+ && (!isNavigationGroup || active);
186
+ return (
187
+ <div
188
+ key={`${group.id}-${item.actionId}`}
189
+ data-vim-key-map-action={item.actionId}
190
+ data-latest={isLatestAction ? 'true' : 'false'}
191
+ style={{
192
+ display: 'grid',
193
+ gridTemplateColumns: '48px minmax(0, 1fr)',
194
+ alignItems: 'center',
195
+ minHeight: 23,
196
+ gap: 7,
197
+ borderRadius: 7,
198
+ color: isLatestAction ? '#f7f3ff' : '#bbb4c4',
199
+ background: isLatestAction
200
+ ? 'rgba(167,139,250,0.095)'
201
+ : 'transparent',
202
+ }}
203
+ >
204
+ <kbd
205
+ style={{
206
+ minWidth: 29,
207
+ width: 'fit-content',
208
+ maxWidth: 48,
209
+ height: 21,
210
+ display: 'inline-grid',
211
+ placeItems: 'center',
212
+ padding: '0 6px',
213
+ overflow: 'hidden',
214
+ border: isLatestAction
215
+ ? '1px solid rgba(216,204,255,0.52)'
216
+ : '1px solid rgba(255,255,255,0.11)',
217
+ borderBottom: isLatestAction
218
+ ? '3px solid rgba(103,80,164,0.92)'
219
+ : '3px solid rgba(0,0,0,0.64)',
220
+ borderRadius: 6,
221
+ color: isLatestAction ? '#f6f0ff' : '#d5cedf',
222
+ background: isLatestAction
223
+ ? 'linear-gradient(180deg, rgba(107,82,158,0.72), rgba(44,34,63,0.75))'
224
+ : 'linear-gradient(180deg, rgba(43,39,51,0.84), rgba(23,20,29,0.84))',
225
+ fontFamily: HUD_MONO_FONT,
226
+ fontSize: item.key.length > 3 ? 8 : 10,
227
+ fontWeight: 850,
228
+ lineHeight: 1,
229
+ whiteSpace: 'nowrap',
230
+ }}
231
+ >
232
+ {item.key}
233
+ </kbd>
234
+ <span
235
+ style={{
236
+ minWidth: 0,
237
+ fontSize: 10,
238
+ fontWeight: isLatestAction ? 680 : 580,
239
+ lineHeight: 1.15,
240
+ }}
241
+ >
242
+ {item.description}
243
+ </span>
244
+ </div>
245
+ );
246
+ })}
247
+ </div>
248
+ </section>
249
+ );
250
+ }
251
+
252
+ /** Props for the pixel-faithful, live Vim HUD used by both document renderers. */
253
+ export interface VimKeyHudProps {
254
+ readonly command: VimHudCommand | null;
255
+ readonly phase: VimHudPhase;
256
+ readonly inputMethod: InputMethod;
257
+ readonly expanded: boolean;
258
+ readonly onExpandedChange: (expanded: boolean) => void;
259
+ /** Hide the persistent key panel while leaving the targeting HUD enabled. */
260
+ readonly onHide?: () => void;
261
+ /** Notify the owner when keyboard focus leaves the HUD for another surface. */
262
+ readonly onFocusLeave?: () => void;
263
+ }
264
+
265
+ /**
266
+ * Render the video HUD against real handled Vim commands.
267
+ *
268
+ * Key feedback changes instantly rather than animating navigation, preserving
269
+ * the responsiveness expected by high-frequency keyboard users.
270
+ */
271
+ export function VimKeyHud({
272
+ command,
273
+ phase,
274
+ inputMethod,
275
+ expanded,
276
+ onExpandedChange,
277
+ onHide,
278
+ onFocusLeave,
279
+ }: VimKeyHudProps) {
280
+ const [history, setHistory] = useState<readonly VimHudCommand[]>([]);
281
+ const [pressedSequence, setPressedSequence] = useState<number | null>(null);
282
+ const releaseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
283
+
284
+ useLayoutEffect(() => {
285
+ if (!command) {
286
+ if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current);
287
+ releaseTimerRef.current = null;
288
+ setHistory((current) => (current.length === 0 ? current : []));
289
+ setPressedSequence(null);
290
+ return;
291
+ }
292
+
293
+ setHistory((current) => {
294
+ if (current.at(-1)?.sequence === command.sequence) return current;
295
+ return [...current, command].slice(-4);
296
+ });
297
+ setPressedSequence(command.sequence);
298
+ if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current);
299
+ releaseTimerRef.current = setTimeout(() => {
300
+ setPressedSequence((current) => (
301
+ current === command.sequence ? null : current
302
+ ));
303
+ }, PRESSED_DURATION_MS);
304
+
305
+ return () => {
306
+ if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current);
307
+ releaseTimerRef.current = null;
308
+ };
309
+ }, [command]);
310
+
311
+ const previous = history
312
+ .filter((entry) => entry.sequence !== command?.sequence)
313
+ .slice(-3);
314
+ const keyLabel = command?.key ?? '·';
315
+ const description = command?.description ?? 'Move by document meaning';
316
+ const inputLabel = inputMethod === 'pinpoint' ? 'PINPOINT' : 'SELECT';
317
+
318
+ return (
319
+ <div
320
+ data-vim-key-hud
321
+ data-expanded={expanded ? 'true' : 'false'}
322
+ role="region"
323
+ aria-label="Vim keyboard HUD"
324
+ onBlur={(event) => {
325
+ if (
326
+ event.relatedTarget instanceof Node
327
+ && event.currentTarget.contains(event.relatedTarget)
328
+ ) {
329
+ return;
330
+ }
331
+ onFocusLeave?.();
332
+ }}
333
+ style={{
334
+ position: 'fixed',
335
+ right: 'min(48px, 4vw)',
336
+ bottom: 150,
337
+ zIndex: 120,
338
+ width: expanded
339
+ ? 'min(860px, calc(100vw - 32px))'
340
+ : 'min(650px, calc(100vw - 32px))',
341
+ height: expanded
342
+ ? 'min(560px, calc(100dvh - 174px))'
343
+ : 88,
344
+ display: 'flex',
345
+ flexDirection: 'column',
346
+ alignItems: 'stretch',
347
+ overflow: 'hidden',
348
+ padding: '12px 16px',
349
+ borderRadius: 20,
350
+ color: '#f7f4ff',
351
+ background: 'linear-gradient(180deg, rgba(43,35,59,0.46), rgba(13,11,20,0.34))',
352
+ border: '1px solid rgba(196,181,253,0.25)',
353
+ boxShadow: '0 22px 58px rgba(0,0,0,0.52), 0 0 32px rgba(167,139,250,0.1), inset 0 1px 0 rgba(255,255,255,0.07)',
354
+ backdropFilter: 'blur(10px)',
355
+ WebkitBackdropFilter: 'blur(10px)',
356
+ pointerEvents: 'none',
357
+ }}
358
+ >
359
+ <div
360
+ data-vim-hud-summary
361
+ style={{
362
+ width: '100%',
363
+ height: 63,
364
+ display: 'flex',
365
+ flex: '0 0 auto',
366
+ alignItems: 'center',
367
+ }}
368
+ >
369
+ <div
370
+ data-vim-hud-history
371
+ aria-hidden="true"
372
+ style={{
373
+ width: 112,
374
+ display: 'flex',
375
+ flex: '0 0 auto',
376
+ alignItems: 'center',
377
+ justifyContent: 'flex-end',
378
+ gap: 5,
379
+ }}
380
+ >
381
+ {previous.map((entry, index) => (
382
+ <PreviousKey
383
+ key={entry.sequence}
384
+ keyLabel={entry.key}
385
+ opacity={0.28 + index * 0.24}
386
+ />
387
+ ))}
388
+ </div>
389
+
390
+ <div
391
+ aria-hidden="true"
392
+ style={{
393
+ width: 1,
394
+ height: 46,
395
+ flex: '0 0 auto',
396
+ margin: '0 15px',
397
+ background: 'linear-gradient(180deg, transparent, rgba(255,255,255,0.14), transparent)',
398
+ }}
399
+ />
400
+
401
+ <ActiveKey
402
+ keyLabel={keyLabel}
403
+ pressed={command?.sequence === pressedSequence}
404
+ />
405
+
406
+ <div
407
+ style={{
408
+ minWidth: 0,
409
+ flex: 1,
410
+ marginLeft: 16,
411
+ }}
412
+ >
413
+ <div
414
+ style={{
415
+ display: 'flex',
416
+ alignItems: 'center',
417
+ gap: 8,
418
+ color: '#a99fba',
419
+ fontFamily: HUD_MONO_FONT,
420
+ fontSize: 10,
421
+ fontWeight: 850,
422
+ letterSpacing: '0.16em',
423
+ }}
424
+ >
425
+ <span
426
+ aria-hidden="true"
427
+ style={{
428
+ width: 6,
429
+ height: 6,
430
+ flex: '0 0 auto',
431
+ borderRadius: 99,
432
+ background: '#a78bfa',
433
+ boxShadow: '0 0 13px rgba(167,139,250,0.88)',
434
+ }}
435
+ />
436
+ <span data-vim-hud-phase>{phase} / {inputLabel}</span>
437
+ </div>
438
+ <div
439
+ data-vim-hud-command
440
+ style={{
441
+ marginTop: 5,
442
+ overflow: 'hidden',
443
+ color: '#f4f0f8',
444
+ fontSize: 19,
445
+ fontWeight: 690,
446
+ lineHeight: 1.08,
447
+ whiteSpace: 'nowrap',
448
+ textOverflow: 'ellipsis',
449
+ }}
450
+ >
451
+ {description}
452
+ </div>
453
+ <span
454
+ role="status"
455
+ aria-live="polite"
456
+ className="sr-only"
457
+ >
458
+ {phase} {inputLabel}: {keyLabel}, {description}
459
+ </span>
460
+ </div>
461
+
462
+ {onHide && (
463
+ <button
464
+ type="button"
465
+ data-vim-key-panel-hide
466
+ aria-label="Hide Vim key panel"
467
+ title="Hide key panel (keep target reticle)"
468
+ onPointerDown={(event) => {
469
+ // Preserve document ownership for pointer users. Keyboard
470
+ // activation is handed back by the owner after hiding.
471
+ event.preventDefault();
472
+ }}
473
+ onClick={onHide}
474
+ style={{
475
+ width: 40,
476
+ height: 40,
477
+ display: 'grid',
478
+ flex: '0 0 auto',
479
+ placeItems: 'center',
480
+ marginLeft: 10,
481
+ padding: 0,
482
+ border: '1px solid rgba(196,181,253,0.16)',
483
+ borderRadius: 10,
484
+ color: '#aaa0ba',
485
+ background: 'rgba(17,14,24,0.2)',
486
+ cursor: 'pointer',
487
+ pointerEvents: 'auto',
488
+ }}
489
+ >
490
+ <EyeOff size={15} strokeWidth={1.8} aria-hidden="true" />
491
+ </button>
492
+ )}
493
+
494
+ <button
495
+ type="button"
496
+ data-vim-key-map-toggle
497
+ aria-expanded={expanded}
498
+ aria-controls="vim-hud-key-map"
499
+ aria-label={expanded ? 'Collapse Vim key map' : 'Expand Vim key map'}
500
+ onPointerDown={(event) => {
501
+ // Keep keyboard ownership on the document/iframe while the pointer
502
+ // toggles this fixed HUD control.
503
+ event.preventDefault();
504
+ }}
505
+ onClick={() => onExpandedChange(!expanded)}
506
+ className="vim-key-hud__map-toggle"
507
+ style={{
508
+ width: 92,
509
+ minWidth: 44,
510
+ minHeight: 44,
511
+ display: 'grid',
512
+ flex: '0 0 auto',
513
+ gridTemplateColumns: '1fr auto',
514
+ alignItems: 'center',
515
+ gap: 7,
516
+ marginLeft: 12,
517
+ padding: '7px 8px 7px 10px',
518
+ border: '1px solid rgba(196,181,253,0.18)',
519
+ borderRadius: 11,
520
+ color: '#afa5bf',
521
+ background: expanded
522
+ ? 'rgba(167,139,250,0.13)'
523
+ : 'rgba(17,14,24,0.22)',
524
+ fontFamily: HUD_MONO_FONT,
525
+ cursor: 'pointer',
526
+ pointerEvents: 'auto',
527
+ }}
528
+ >
529
+ <span
530
+ style={{
531
+ display: 'grid',
532
+ justifyItems: 'start',
533
+ fontSize: 8,
534
+ fontWeight: 850,
535
+ letterSpacing: '0.1em',
536
+ lineHeight: 1.35,
537
+ }}
538
+ >
539
+ <span>VIM</span>
540
+ <span style={{ color: '#b39ce7' }}>
541
+ {expanded ? 'CLOSE' : 'KEY MAP'}
542
+ </span>
543
+ </span>
544
+ <kbd
545
+ aria-hidden="true"
546
+ style={{
547
+ width: 25,
548
+ height: 25,
549
+ display: 'grid',
550
+ placeItems: 'center',
551
+ border: '1px solid rgba(216,204,255,0.34)',
552
+ borderBottom: '3px solid rgba(0,0,0,0.66)',
553
+ borderRadius: 7,
554
+ color: '#eee8ff',
555
+ background: 'linear-gradient(180deg, rgba(64,55,78,0.9), rgba(27,23,35,0.9))',
556
+ fontSize: 12,
557
+ fontWeight: 900,
558
+ }}
559
+ >
560
+ ?
561
+ </kbd>
562
+ </button>
563
+ </div>
564
+
565
+ {expanded && (
566
+ <div
567
+ id="vim-hud-key-map"
568
+ data-vim-key-map
569
+ style={{
570
+ minHeight: 0,
571
+ display: 'flex',
572
+ flex: 1,
573
+ flexDirection: 'column',
574
+ paddingTop: 10,
575
+ borderTop: '1px solid rgba(255,255,255,0.085)',
576
+ pointerEvents: 'auto',
577
+ }}
578
+ >
579
+ <div
580
+ style={{
581
+ display: 'flex',
582
+ flex: '0 0 auto',
583
+ alignItems: 'center',
584
+ justifyContent: 'space-between',
585
+ gap: 16,
586
+ padding: '2px 2px 10px',
587
+ }}
588
+ >
589
+ <div>
590
+ <div
591
+ style={{
592
+ color: '#f4efff',
593
+ fontSize: 14,
594
+ fontWeight: 760,
595
+ lineHeight: 1.15,
596
+ }}
597
+ >
598
+ Vim key map
599
+ </div>
600
+ <div
601
+ style={{
602
+ marginTop: 3,
603
+ color: '#92899e',
604
+ fontSize: 10,
605
+ fontWeight: 570,
606
+ }}
607
+ >
608
+ The highlighted section matches your current navigation level.
609
+ </div>
610
+ </div>
611
+ <div
612
+ style={{
613
+ flex: '0 0 auto',
614
+ display: 'flex',
615
+ alignItems: 'center',
616
+ gap: 7,
617
+ color: '#a99fba',
618
+ fontFamily: HUD_MONO_FONT,
619
+ fontSize: 9,
620
+ fontWeight: 850,
621
+ letterSpacing: '0.11em',
622
+ }}
623
+ >
624
+ <span
625
+ aria-hidden="true"
626
+ style={{
627
+ width: 6,
628
+ height: 6,
629
+ borderRadius: 99,
630
+ background: '#a78bfa',
631
+ boxShadow: '0 0 12px rgba(167,139,250,0.82)',
632
+ }}
633
+ />
634
+ {phase} MODE
635
+ </div>
636
+ </div>
637
+
638
+ <div
639
+ className="vim-key-hud__map-scroll"
640
+ style={{
641
+ minHeight: 0,
642
+ flex: 1,
643
+ overflow: 'auto',
644
+ overscrollBehavior: 'contain',
645
+ padding: '0 2px 4px',
646
+ scrollbarGutter: 'stable',
647
+ }}
648
+ >
649
+ <div className="vim-key-hud__map-columns">
650
+ {KEY_MAP_COLUMNS.map((groupIds, columnIndex) => (
651
+ <div
652
+ key={columnIndex}
653
+ style={{
654
+ display: 'grid',
655
+ alignContent: 'start',
656
+ gap: 9,
657
+ }}
658
+ >
659
+ {KEY_MAP_GROUPS
660
+ .filter((group) => groupIds.includes(group.id))
661
+ .map((group) => (
662
+ <VimKeyMapGroup
663
+ key={group.id}
664
+ group={group}
665
+ phase={phase}
666
+ activeActionId={command?.actionId ?? null}
667
+ />
668
+ ))}
669
+ </div>
670
+ ))}
671
+ </div>
672
+ </div>
673
+
674
+ <div
675
+ style={{
676
+ display: 'flex',
677
+ flex: '0 0 auto',
678
+ justifyContent: 'space-between',
679
+ gap: 12,
680
+ padding: '9px 2px 0',
681
+ color: '#777080',
682
+ fontFamily: HUD_MONO_FONT,
683
+ fontSize: 8,
684
+ fontWeight: 780,
685
+ letterSpacing: '0.08em',
686
+ }}
687
+ >
688
+ <span>PRESS ? TO CLOSE</span>
689
+ <span>ESC BACKS OUT ONE LEVEL</span>
690
+ </div>
691
+ </div>
692
+ )}
693
+ </div>
694
+ );
695
+ }