@plannotator/ui 0.28.0 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,557 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { Crosshair, Keyboard } from 'lucide-react';
4
+ import type { VimSelectionActionId, VimSelectionHudContext } from '../shortcuts';
5
+ import type { SemanticTarget } from '../utils/blockTargeting';
6
+ import {
7
+ createVimHudCommand,
8
+ getVimHudPhase,
9
+ } from '../utils/vimHud';
10
+ import type { VimRestorableState } from '../utils/vimNavigation';
11
+ import { getVimReticleLabel } from '../utils/vimReticle';
12
+
13
+ interface VimModeAnnouncementDialogProps {
14
+ readonly isOpen: boolean;
15
+ readonly vimModeEnabled: boolean;
16
+ readonly vimHudEnabled: boolean;
17
+ readonly onVimModeChange: (enabled: boolean) => void;
18
+ readonly onVimHudChange: (enabled: boolean) => void;
19
+ readonly onDismiss: () => void;
20
+ }
21
+
22
+ interface SwitchRowProps {
23
+ readonly checked: boolean;
24
+ readonly title: string;
25
+ readonly description: string;
26
+ readonly icon: React.ReactNode;
27
+ readonly recommended?: boolean;
28
+ readonly disabled?: boolean;
29
+ readonly onChange: (checked: boolean) => void;
30
+ }
31
+
32
+ type ShowcaseTarget = Pick<SemanticTarget, 'kind' | 'label'>;
33
+
34
+ interface ShowcaseStepSpec {
35
+ readonly actionId: VimSelectionActionId;
36
+ readonly rawKey: string;
37
+ readonly commandContext: VimSelectionHudContext;
38
+ readonly resultContext: VimSelectionHudContext;
39
+ readonly reticleState: VimRestorableState;
40
+ readonly target: ShowcaseTarget | null;
41
+ }
42
+
43
+ const DEMO_BLOCK_TARGET: ShowcaseTarget = {
44
+ kind: 'block',
45
+ label: 'paragraph: "Keep collaboration responsive"',
46
+ };
47
+ const DEMO_INLINE_TARGET: ShowcaseTarget = {
48
+ kind: 'inline',
49
+ label: 'code: "operationBatchSize: 32"',
50
+ };
51
+ const DEMO_TEXT_START = { blockId: 'demo-inline-code', textOffset: 0 };
52
+ const DEMO_VALUE_START = { blockId: 'demo-inline-code', textOffset: 20 };
53
+ const DEMO_VALUE_END = { blockId: 'demo-inline-code', textOffset: 22 };
54
+
55
+ const SHOWCASE_STEP_SPECS: readonly ShowcaseStepSpec[] = [
56
+ {
57
+ actionId: 'moveDown',
58
+ rawKey: 'j',
59
+ commandContext: 'block',
60
+ resultContext: 'block',
61
+ reticleState: { phase: 'block', targetKey: 'demo-block' },
62
+ target: DEMO_BLOCK_TARGET,
63
+ },
64
+ {
65
+ actionId: 'refine',
66
+ rawKey: 'l',
67
+ commandContext: 'block',
68
+ resultContext: 'inline',
69
+ reticleState: { phase: 'inline', targetKey: 'demo-inline-code' },
70
+ target: DEMO_INLINE_TARGET,
71
+ },
72
+ {
73
+ actionId: 'refine',
74
+ rawKey: 'l',
75
+ commandContext: 'inline',
76
+ resultContext: 'text',
77
+ reticleState: {
78
+ phase: 'text',
79
+ targetKey: 'demo-inline-code',
80
+ cursor: DEMO_TEXT_START,
81
+ },
82
+ target: null,
83
+ },
84
+ {
85
+ actionId: 'wordForward',
86
+ rawKey: 'w',
87
+ commandContext: 'text',
88
+ resultContext: 'text',
89
+ reticleState: {
90
+ phase: 'text',
91
+ targetKey: 'demo-inline-code',
92
+ cursor: DEMO_VALUE_START,
93
+ },
94
+ target: null,
95
+ },
96
+ {
97
+ actionId: 'visual',
98
+ rawKey: 'v',
99
+ commandContext: 'text',
100
+ resultContext: 'visual',
101
+ reticleState: {
102
+ phase: 'visual',
103
+ targetKey: 'demo-inline-code',
104
+ anchor: DEMO_VALUE_START,
105
+ cursor: DEMO_VALUE_START,
106
+ },
107
+ target: null,
108
+ },
109
+ {
110
+ actionId: 'wordEnd',
111
+ rawKey: 'e',
112
+ commandContext: 'visual',
113
+ resultContext: 'visual',
114
+ reticleState: {
115
+ phase: 'visual',
116
+ targetKey: 'demo-inline-code',
117
+ anchor: DEMO_VALUE_START,
118
+ cursor: DEMO_VALUE_END,
119
+ },
120
+ target: null,
121
+ },
122
+ {
123
+ actionId: 'comment',
124
+ rawKey: 'c',
125
+ commandContext: 'visual',
126
+ resultContext: 'action',
127
+ reticleState: {
128
+ phase: 'visual',
129
+ targetKey: 'demo-inline-code',
130
+ anchor: DEMO_VALUE_START,
131
+ cursor: DEMO_VALUE_END,
132
+ },
133
+ target: null,
134
+ },
135
+ ];
136
+
137
+ const SHOWCASE_STEPS = SHOWCASE_STEP_SPECS.map((spec, index) => {
138
+ const command = createVimHudCommand(
139
+ index + 1,
140
+ spec.actionId,
141
+ spec.rawKey,
142
+ spec.commandContext,
143
+ );
144
+ return {
145
+ ...command,
146
+ phase: getVimHudPhase(spec.resultContext, spec.actionId),
147
+ reticleLabel: getVimReticleLabel(
148
+ spec.reticleState,
149
+ spec.target,
150
+ command,
151
+ ),
152
+ };
153
+ });
154
+
155
+ function SwitchRow({
156
+ checked,
157
+ title,
158
+ description,
159
+ icon,
160
+ recommended = false,
161
+ disabled = false,
162
+ onChange,
163
+ }: SwitchRowProps) {
164
+ return (
165
+ <button
166
+ type="button"
167
+ role="switch"
168
+ aria-checked={checked}
169
+ aria-disabled={disabled || undefined}
170
+ onClick={() => {
171
+ if (disabled) return;
172
+ onChange(!checked);
173
+ }}
174
+ className={`vim-announcement-switch w-full min-h-24 rounded-xl border border-border bg-muted/25 p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card${
175
+ disabled ? ' cursor-not-allowed opacity-50' : ''
176
+ }`}
177
+ >
178
+ <span className="flex items-start gap-3">
179
+ <span
180
+ aria-hidden="true"
181
+ className={`mt-0.5 grid h-9 w-9 shrink-0 place-items-center rounded-lg border ${
182
+ checked
183
+ ? 'border-primary/40 bg-primary/15 text-primary'
184
+ : 'border-border bg-background/60 text-muted-foreground'
185
+ }`}
186
+ >
187
+ {icon}
188
+ </span>
189
+ <span className="min-w-0 flex-1">
190
+ <span className="flex flex-wrap items-center gap-2">
191
+ <span className="text-sm font-semibold text-foreground">{title}</span>
192
+ {recommended && (
193
+ <span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-primary">
194
+ Recommended
195
+ </span>
196
+ )}
197
+ </span>
198
+ <span className="mt-1 block text-xs leading-relaxed text-muted-foreground">
199
+ {description}
200
+ </span>
201
+ </span>
202
+ <span
203
+ aria-hidden="true"
204
+ className={`vim-announcement-switch__track mt-1 ${
205
+ checked ? 'vim-announcement-switch__track--checked' : ''
206
+ }`}
207
+ >
208
+ <span className="vim-announcement-switch__thumb" />
209
+ </span>
210
+ </span>
211
+ </button>
212
+ );
213
+ }
214
+
215
+ function ShowcaseReticle({
216
+ step,
217
+ label,
218
+ className,
219
+ }: {
220
+ readonly step: number;
221
+ readonly label: string;
222
+ readonly className: string;
223
+ }) {
224
+ return (
225
+ <span
226
+ className={`vim-announcement-step vim-announcement-step--${step} vim-announcement-reticle ${className}`}
227
+ >
228
+ <span className="vim-announcement-reticle__corner vim-announcement-reticle__corner--top-left" />
229
+ <span className="vim-announcement-reticle__corner vim-announcement-reticle__corner--top-right" />
230
+ <span className="vim-announcement-reticle__corner vim-announcement-reticle__corner--bottom-left" />
231
+ <span className="vim-announcement-reticle__corner vim-announcement-reticle__corner--bottom-right" />
232
+ <span className="vim-announcement-reticle__label">
233
+ <span className="vim-announcement-reticle__dot" />
234
+ {label}
235
+ </span>
236
+ </span>
237
+ );
238
+ }
239
+
240
+ function VimShowcase() {
241
+ const [isPlaying, setIsPlaying] = useState(() => (
242
+ typeof document === 'undefined' || document.visibilityState !== 'hidden'
243
+ ));
244
+
245
+ useEffect(() => {
246
+ const handleVisibilityChange = () => {
247
+ setIsPlaying(document.visibilityState !== 'hidden');
248
+ };
249
+ document.addEventListener('visibilitychange', handleVisibilityChange);
250
+ return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
251
+ }, []);
252
+
253
+ return (
254
+ <div
255
+ className="vim-announcement-showcase relative h-full min-h-[350px] overflow-hidden rounded-xl border border-white/10 bg-[#111019] min-[821px]:min-h-[430px]"
256
+ data-playing={isPlaying ? 'true' : 'false'}
257
+ aria-hidden="true"
258
+ >
259
+ <div className="vim-announcement-showcase__canvas absolute inset-0">
260
+ <div className="absolute inset-x-0 top-0 flex h-10 items-center gap-1.5 border-b border-white/[0.07] bg-white/[0.025] px-4">
261
+ <span className="h-2 w-2 rounded-full bg-white/10" />
262
+ <span className="h-2 w-2 rounded-full bg-white/10" />
263
+ <span className="h-2 w-2 rounded-full bg-white/10" />
264
+ <span className="ml-2 font-mono text-[9px] font-semibold uppercase tracking-[0.16em] text-white/35">
265
+ vim-mode-demo.md
266
+ </span>
267
+ </div>
268
+
269
+ <div className="absolute inset-x-0 bottom-0 top-10 px-7 pb-24 pt-6 text-[#cbc7d4]">
270
+ <div className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[#8b8398]">
271
+ Vim mode demo
272
+ </div>
273
+ <div className="mt-2 text-[21px] font-semibold tracking-[-0.02em] text-[#f3eff9]">
274
+ This is a demo
275
+ </div>
276
+ <p className="mt-4 max-w-[430px] text-[12px] leading-[1.75] text-[#aaa4b4]">
277
+ Watch Vim mode move through a sample document, refine into exact text,
278
+ select a value, and open a comment.
279
+ </p>
280
+
281
+ <p
282
+ data-vim-demo-target="block"
283
+ className="relative mt-5 rounded-lg border border-white/[0.07] bg-[#1b1923] px-4 py-4 text-[12px] leading-7 text-[#bdb6c9]"
284
+ >
285
+ <ShowcaseReticle
286
+ step={1}
287
+ label={SHOWCASE_STEPS[0].reticleLabel}
288
+ className="vim-announcement-reticle--block"
289
+ />
290
+ Keep collaboration responsive by setting{' '}
291
+ <code
292
+ data-vim-demo-target="inline"
293
+ className="relative inline-flex rounded bg-[#25222e] px-1.5 font-mono text-[11px] text-[#79c0ff]"
294
+ >
295
+ <ShowcaseReticle
296
+ step={2}
297
+ label={SHOWCASE_STEPS[1].reticleLabel}
298
+ className="vim-announcement-reticle--inline"
299
+ />
300
+ <span
301
+ data-vim-demo-target="word"
302
+ className="relative inline-block"
303
+ >
304
+ operationBatchSize
305
+ <ShowcaseReticle
306
+ step={3}
307
+ label={SHOWCASE_STEPS[2].reticleLabel}
308
+ className="vim-announcement-reticle--cursor-start"
309
+ />
310
+ </span>
311
+ <span className="text-[#bdb6c9]">: </span>
312
+ <span
313
+ data-vim-demo-target="selection"
314
+ className="relative inline-block"
315
+ >
316
+ <span className="vim-announcement-code-selection rounded-[3px] px-0.5 text-[#d2a8ff]">
317
+ 32
318
+ </span>
319
+ <ShowcaseReticle
320
+ step={4}
321
+ label={SHOWCASE_STEPS[3].reticleLabel}
322
+ className="vim-announcement-reticle--selection"
323
+ />
324
+ <ShowcaseReticle
325
+ step={5}
326
+ label={SHOWCASE_STEPS[4].reticleLabel}
327
+ className="vim-announcement-reticle--selection"
328
+ />
329
+ <ShowcaseReticle
330
+ step={6}
331
+ label={SHOWCASE_STEPS[5].reticleLabel}
332
+ className="vim-announcement-reticle--selection"
333
+ />
334
+ <ShowcaseReticle
335
+ step={7}
336
+ label={SHOWCASE_STEPS[6].reticleLabel}
337
+ className="vim-announcement-reticle--selection"
338
+ />
339
+ <span className="vim-announcement-step vim-announcement-step--7 vim-announcement-comment">
340
+ <span className="block text-[10px] font-semibold text-[#f3eff9]">
341
+ Comment on “32”
342
+ </span>
343
+ <span className="mt-1 block text-[9px] text-[#9e96aa]">
344
+ Explain why this batch size is safe…
345
+ </span>
346
+ </span>
347
+ </span>
348
+ </code>
349
+ . One <kbd className="font-mono text-[#c4b5fd]">w</kbd> jumps from the
350
+ variable to its value.
351
+ </p>
352
+ </div>
353
+
354
+ <div className="absolute bottom-5 right-5 z-20 flex h-[62px] w-[250px] items-center gap-3 rounded-xl border border-[#c4b5fd]/20 bg-[#16121f]/70 px-3.5 shadow-[0_16px_40px_rgba(0,0,0,0.42)] backdrop-blur-md">
355
+ <div className="relative h-10 w-10 shrink-0">
356
+ {SHOWCASE_STEPS.map((item, index) => (
357
+ <kbd
358
+ key={`${item.key}-${index}`}
359
+ className={`vim-announcement-step vim-announcement-step--${index + 1} absolute inset-0 grid place-items-center rounded-lg border border-[#ddd4ff]/70 border-b-[3px] border-b-[#6750a4] bg-gradient-to-b from-[#eee9ff] to-[#a78bfa] font-mono text-xl font-black text-[#1b1427] shadow-[0_0_24px_rgba(167,139,250,0.52)]`}
360
+ >
361
+ {item.key}
362
+ </kbd>
363
+ ))}
364
+ </div>
365
+ <div className="relative h-9 min-w-0 flex-1">
366
+ {SHOWCASE_STEPS.map((item, index) => (
367
+ <div
368
+ key={`${item.phase}-${index}`}
369
+ className={`vim-announcement-step vim-announcement-step--${index + 1} absolute inset-0 flex flex-col justify-center`}
370
+ >
371
+ <div className="font-mono text-[9px] font-black tracking-[0.16em] text-[#d8ccff]">
372
+ {item.phase}
373
+ </div>
374
+ <div className="mt-1 truncate text-[11px] font-semibold text-[#f5f0ff]">
375
+ {item.description}
376
+ </div>
377
+ </div>
378
+ ))}
379
+ </div>
380
+ <div className="font-mono text-[9px] font-bold text-[#817989]">1 / 1</div>
381
+ </div>
382
+ </div>
383
+ </div>
384
+ );
385
+ }
386
+
387
+ /**
388
+ * Introduces Vim navigation and lets the user persist the real Vim and HUD settings.
389
+ */
390
+ export function VimModeAnnouncementDialog({
391
+ isOpen,
392
+ vimModeEnabled,
393
+ vimHudEnabled,
394
+ onVimModeChange,
395
+ onVimHudChange,
396
+ onDismiss,
397
+ }: VimModeAnnouncementDialogProps) {
398
+ const primaryActionRef = useRef<HTMLButtonElement>(null);
399
+ const previousFocusRef = useRef<HTMLElement | null>(null);
400
+ const onDismissRef = useRef(onDismiss);
401
+
402
+ useEffect(() => {
403
+ onDismissRef.current = onDismiss;
404
+ }, [onDismiss]);
405
+
406
+ useEffect(() => {
407
+ if (!isOpen) return;
408
+
409
+ previousFocusRef.current = document.activeElement instanceof HTMLElement
410
+ ? document.activeElement
411
+ : null;
412
+ primaryActionRef.current?.focus();
413
+
414
+ const handleKeyDown = (event: KeyboardEvent) => {
415
+ if (event.key === 'Escape') {
416
+ event.preventDefault();
417
+ onDismissRef.current();
418
+ return;
419
+ }
420
+ if (event.key !== 'Tab') return;
421
+
422
+ const dialog = document.querySelector<HTMLElement>('[data-vim-announcement-dialog]');
423
+ const focusable = Array.from(
424
+ dialog?.querySelectorAll<HTMLElement>('button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])')
425
+ ?? [],
426
+ );
427
+ if (focusable.length === 0) return;
428
+
429
+ const first = focusable[0];
430
+ const last = focusable[focusable.length - 1];
431
+ if (event.shiftKey && document.activeElement === first) {
432
+ event.preventDefault();
433
+ last.focus();
434
+ } else if (!event.shiftKey && document.activeElement === last) {
435
+ event.preventDefault();
436
+ first.focus();
437
+ }
438
+ };
439
+
440
+ document.addEventListener('keydown', handleKeyDown);
441
+ return () => {
442
+ document.removeEventListener('keydown', handleKeyDown);
443
+ previousFocusRef.current?.focus();
444
+ previousFocusRef.current = null;
445
+ };
446
+ }, [isOpen]);
447
+
448
+ if (!isOpen) return null;
449
+
450
+ const primaryLabel = !vimModeEnabled
451
+ ? 'Close'
452
+ : vimHudEnabled
453
+ ? 'Start with Vim + HUD'
454
+ : 'Start with Vim';
455
+
456
+ return createPortal(
457
+ <div className="vim-announcement-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-background/90 p-4 backdrop-blur-sm">
458
+ <div
459
+ data-vim-announcement-dialog
460
+ role="dialog"
461
+ aria-modal="true"
462
+ aria-labelledby="vim-announcement-title"
463
+ aria-describedby="vim-announcement-description"
464
+ className="vim-announcement-dialog flex h-[760px] max-h-[calc(100dvh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl max-[820px]:h-[calc(100dvh-2rem)]"
465
+ >
466
+ <header className="border-b border-border px-7 py-6">
467
+ <div className="flex items-center gap-2.5">
468
+ <span className="inline-flex items-center rounded-full border border-primary/25 bg-primary/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.13em] text-primary">
469
+ New · Keyboard navigation
470
+ </span>
471
+ </div>
472
+ <h2 id="vim-announcement-title" className="mt-3 text-2xl font-semibold tracking-tight">
473
+ Vim keys, if you want them
474
+ </h2>
475
+ <p
476
+ id="vim-announcement-description"
477
+ className="mt-1.5 max-w-3xl text-sm leading-relaxed text-muted-foreground"
478
+ >
479
+ Plannotator now has optional Vim-style keyboard navigation for moving through a
480
+ document, selecting text, and annotating without the mouse. It stays off unless you
481
+ turn it on. If that's not how you work, close this and nothing changes.
482
+ </p>
483
+ </header>
484
+
485
+ <div className="grid min-h-0 flex-1 grid-cols-[1.35fr_0.85fr] gap-6 overflow-y-auto px-7 py-6 max-[820px]:grid-cols-1">
486
+ <section aria-label="Vim controls preview" className="min-h-[350px] min-[821px]:min-h-[430px]">
487
+ <VimShowcase />
488
+ </section>
489
+
490
+ <section aria-label="Vim controls settings" className="flex min-w-0 flex-col">
491
+ <div>
492
+ <div className="text-sm font-semibold">Turn it on now, or never</div>
493
+ <p className="mt-1 text-xs leading-relaxed text-muted-foreground">
494
+ These are the real saved settings. You can change them anytime.
495
+ </p>
496
+ </div>
497
+
498
+ <div className="mt-4 space-y-3">
499
+ <SwitchRow
500
+ checked={vimModeEnabled}
501
+ title="Vim controls"
502
+ description="Use J/K to move by block, L/H to move in or out, then W/B/E and V for precise text selection."
503
+ icon={<Keyboard className="h-[18px] w-[18px]" />}
504
+ onChange={onVimModeChange}
505
+ />
506
+ <SwitchRow
507
+ checked={vimModeEnabled && vimHudEnabled}
508
+ title="Vim HUD"
509
+ description={
510
+ vimModeEnabled
511
+ ? 'Adds the four-corner target reticle, live keypress feedback, and a complete ? key map.'
512
+ : 'Turn on Vim controls first.'
513
+ }
514
+ icon={<Crosshair className="h-[18px] w-[18px]" />}
515
+ recommended={vimModeEnabled}
516
+ disabled={!vimModeEnabled}
517
+ onChange={onVimHudChange}
518
+ />
519
+ </div>
520
+
521
+ <div className="mt-auto rounded-xl border border-primary/20 bg-primary/[0.06] p-4">
522
+ <div className="flex items-start gap-2.5">
523
+ <span className="mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-md bg-primary/15 text-primary">
524
+ <span className="font-mono text-xs font-black">?</span>
525
+ </span>
526
+ <div>
527
+ <div className="text-xs font-semibold text-foreground">Learn as you navigate</div>
528
+ <p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">
529
+ With HUD enabled, press <kbd className="rounded border border-border bg-background/70 px-1.5 py-0.5 font-mono text-[10px] text-foreground">?</kbd>{' '}
530
+ in the document to expand the complete command map.
531
+ </p>
532
+ </div>
533
+ </div>
534
+ </div>
535
+ </section>
536
+ </div>
537
+
538
+ <footer className="flex items-center justify-between gap-4 border-t border-border px-7 py-5">
539
+ <p className="text-xs text-muted-foreground">
540
+ Change this anytime in Settings → Vim.
541
+ </p>
542
+ <div className="flex items-center gap-2">
543
+ <button
544
+ ref={primaryActionRef}
545
+ type="button"
546
+ onClick={onDismiss}
547
+ className="vim-announcement-primary-action min-h-10 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-card"
548
+ >
549
+ {primaryLabel}
550
+ </button>
551
+ </div>
552
+ </footer>
553
+ </div>
554
+ </div>,
555
+ document.body,
556
+ );
557
+ }