@plannotator/ui 0.36.0 → 0.38.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 (37) hide show
  1. package/HANDOFF.md +6 -4
  2. package/README.md +57 -1
  3. package/components/ActionMenu.tsx +22 -24
  4. package/components/AnnotationToolbar.tsx +3 -1
  5. package/components/ApproveDropdown.tsx +15 -18
  6. package/components/CommentPopover.tsx +9 -4
  7. package/components/DecisionControl.tsx +626 -0
  8. package/components/DocBadges.tsx +9 -6
  9. package/components/FloatingQuickLabelPicker.tsx +4 -1
  10. package/components/KeyboardShortcuts.tsx +1 -0
  11. package/components/PlanHeaderMenu.tsx +1 -1
  12. package/components/Settings.tsx +67 -6
  13. package/components/StickyHeaderLane.tsx +16 -40
  14. package/components/ToolbarButtons.tsx +31 -9
  15. package/components/Viewer.tsx +277 -95
  16. package/components/VimTargetReticle.tsx +1 -1
  17. package/components/blocks/AlertBlock.tsx +51 -4
  18. package/components/compactHeaderLayout.ts +52 -0
  19. package/config/reviewView.ts +1 -0
  20. package/config/settings.ts +48 -3
  21. package/configure.ts +9 -0
  22. package/hooks/useAnnotationHighlighter.ts +31 -19
  23. package/hooks/useDismissablePopover.ts +65 -0
  24. package/hooks/useVimDocumentFocus.ts +6 -0
  25. package/package.json +22 -22
  26. package/shortcuts/decisionControl.shortcuts.ts +31 -0
  27. package/shortcuts/index.ts +1 -0
  28. package/shortcuts/plan-review/documentView.shortcuts.ts +13 -0
  29. package/styles.css +1 -1
  30. package/theme.css +11 -0
  31. package/utils/alertTitle.ts +69 -0
  32. package/utils/decisionSpec.ts +430 -0
  33. package/utils/platform.ts +16 -0
  34. package/utils/vimScroll.ts +1 -1
  35. package/sprite_package_additional/index.html +0 -34
  36. package/sprite_package_new/index.html +0 -34
  37. package/sprite_package_pulluphang/index.html +0 -34
@@ -15,6 +15,13 @@ import {
15
15
  } from '@plannotator/core/agent-terminal';
16
16
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
17
17
  import { isFaviconStyle, type FaviconStyle } from '@plannotator/core/favicon';
18
+ import {
19
+ DEFAULT_TOKEN_HOVER_DELAY_MS,
20
+ isTokenHoverDelay,
21
+ resolveStoredTokenHoverTrigger,
22
+ type TokenHoverDelay,
23
+ type TokenHoverTrigger,
24
+ } from '@plannotator/core/token-hover';
18
25
  import { storage } from '../utils/storage';
19
26
  import { generateIdentity } from '../utils/generateIdentity';
20
27
  import {
@@ -276,6 +283,44 @@ export const SETTINGS = {
276
283
  serverKey: undefined, fromServer: undefined, toServer: undefined,
277
284
  },
278
285
 
286
+ // Hovering a token in a code-review diff opens a card with what the search
287
+ // backend knows about that symbol. Cookie-only like the other review-chrome
288
+ // preferences: it is presentational, per-browser, and changes no review
289
+ // semantics — `off` simply means no listeners, no requests and no card.
290
+ //
291
+ // This one select REPLACED the original `tokenHoverCards` boolean rather
292
+ // than sitting beside it: a toggle plus a mode has an unreachable state
293
+ // (disabled + modifier) and asks one question with two controls. The legacy
294
+ // cookie is still read — on every load until the user touches this setting,
295
+ // since a migrating read returns a value and so never triggers the
296
+ // registry's default-seeding write — so an early adopter who turned cards
297
+ // off stays off. Resolution is pure and identical every time; see
298
+ // resolveStoredTokenHoverTrigger.
299
+ tokenHoverTrigger: {
300
+ defaultValue: 'hover' as TokenHoverTrigger,
301
+ fromCookie: () => resolveStoredTokenHoverTrigger(
302
+ storage.getItem('plannotator-token-hover-trigger'),
303
+ storage.getItem('plannotator-token-hover-cards'),
304
+ ),
305
+ toCookie: (value: TokenHoverTrigger) =>
306
+ storage.setItem('plannotator-token-hover-trigger', value),
307
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
308
+ },
309
+
310
+ // How long the pointer rests on a symbol before a card is requested. Three
311
+ // fixed steps, not a slider: "too eager" is a real complaint that neither
312
+ // `modifier` nor `off` answers, but nobody can tell 340ms from 360ms.
313
+ tokenHoverDelay: {
314
+ defaultValue: DEFAULT_TOKEN_HOVER_DELAY_MS as TokenHoverDelay,
315
+ fromCookie: () => {
316
+ const parsed = Number(storage.getItem('plannotator-token-hover-delay'));
317
+ return isTokenHoverDelay(parsed) ? parsed : undefined;
318
+ },
319
+ toCookie: (value: TokenHoverDelay) =>
320
+ storage.setItem('plannotator-token-hover-delay', String(value)),
321
+ serverKey: undefined, fromServer: undefined, toServer: undefined,
322
+ },
323
+
279
324
  reviewShowStageControls: {
280
325
  defaultValue: true as boolean,
281
326
  fromCookie: () => {
@@ -288,18 +333,18 @@ export const SETTINGS = {
288
333
  },
289
334
 
290
335
  defaultDiffType: {
291
- defaultValue: 'since-base' as 'since-base' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
336
+ defaultValue: 'since-base' as 'since-base' | 'local-vs-remote' | 'uncommitted' | 'unstaged' | 'staged' | 'merge-base' | 'all',
292
337
  fromCookie: () => {
293
338
  const v = storage.getItem('plannotator-default-diff-type');
294
339
  if (v === 'branch') return 'merge-base' as const;
295
- return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
340
+ return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
296
341
  },
297
342
  toCookie: (v: string) => storage.setItem('plannotator-default-diff-type', v),
298
343
  serverKey: 'diffOptions',
299
344
  fromServer: (sc: Record<string, unknown>) => {
300
345
  const v = (sc.diffOptions as Record<string, unknown> | undefined)?.defaultDiffType;
301
346
  if (v === 'branch') return 'merge-base' as const;
302
- return v === 'since-base' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
347
+ return v === 'since-base' || v === 'local-vs-remote' || v === 'uncommitted' || v === 'unstaged' || v === 'staged' || v === 'merge-base' || v === 'all' ? v : undefined;
303
348
  },
304
349
  toServer: (v: string) => ({ diffOptions: { defaultDiffType: v } }),
305
350
  },
package/configure.ts CHANGED
@@ -11,6 +11,7 @@ import { setSkillCatalogTransport, setSkillContentTransport, type SkillCatalogTr
11
11
  import { setWebMcpPolicy, type WebMcpPolicy } from './webmcp/policy';
12
12
  import { setMathRendererLoader, type MathRenderer, type MathRendererLoader } from './utils/math';
13
13
  import { setIdentityGenerator, type IdentityGenerator } from './utils/generateIdentity';
14
+ import { setAlertIconRenderer, type AlertIconRenderer } from './components/blocks/AlertBlock';
14
15
  import { configStore } from './config';
15
16
  import type { ServerSyncFn } from './config/configStore';
16
17
  import type { ExternalAnnotationEvent, VaultNode } from './types';
@@ -38,6 +39,7 @@ export type {
38
39
  MathRenderer,
39
40
  MathRendererLoader,
40
41
  IdentityGenerator,
42
+ AlertIconRenderer,
41
43
  };
42
44
 
43
45
  type ExternalAnnotationBase = { id: string; source?: string };
@@ -85,6 +87,12 @@ export interface PlannotatorUIConfig {
85
87
  * dictionary by importing `@plannotator/ui/utils/identity-tater`.
86
88
  */
87
89
  identityGenerator?: IdentityGenerator;
90
+ /**
91
+ * Resolve a GitHub alert title line's `<!-- icon: name -->` to a React node.
92
+ * Default: null for every name, so alerts keep the type's own icon exactly as
93
+ * today; the package bundles no icon set. A host with one registers a renderer.
94
+ */
95
+ alertIconRenderer?: AlertIconRenderer;
88
96
  /** Re-hydrate settings from the installed (SYNCHRONOUS) storageBackend after install. */
89
97
  loadSettingsFromBackend?: boolean;
90
98
  }
@@ -105,6 +113,7 @@ export function configurePlannotatorUI(config: PlannotatorUIConfig): void {
105
113
  if (config.webmcp) setWebMcpPolicy(config.webmcp);
106
114
  if (config.mathRendererLoader) setMathRendererLoader(config.mathRendererLoader);
107
115
  if (config.identityGenerator) setIdentityGenerator(config.identityGenerator);
116
+ if (config.alertIconRenderer) setAlertIconRenderer(config.alertIconRenderer);
108
117
  // Re-hydrate AFTER storageBackend is installed (load-bearing order — gated last).
109
118
  if (config.loadSettingsFromBackend) configStore.loadFromBackend();
110
119
  }
@@ -196,6 +196,12 @@ const escapeAttrValue = (value: string): string => {
196
196
  const normalizeForRestoreCompare = (value: string): string =>
197
197
  value.replace(/\s+/g, ' ').trim();
198
198
 
199
+ // web-highlighter 0.8.x accepts only class, ID, and tag exclusions.
200
+ const ANNOTATION_EXCLUDED_SELECTOR = '.annotation-exclude';
201
+
202
+ const isAnnotationExcludedTextNode = (node: Node): boolean =>
203
+ Boolean(node.parentElement?.closest(ANNOTATION_EXCLUDED_SELECTOR));
204
+
199
205
  const applyMathAnnotationClass = (
200
206
  element: HTMLElement,
201
207
  id: string,
@@ -361,21 +367,28 @@ export function useAnnotationHighlighter({
361
367
  const searchOnce = (needle: string): Range | null => {
362
368
  if (!needle || !containerRef.current) return null;
363
369
 
364
- const rangeFromTextOffsets = (startIndex: number, endIndex: number): Range | null => {
365
- const walker = document.createTreeWalker(
366
- containerRef.current!,
367
- NodeFilter.SHOW_TEXT,
368
- null
369
- );
370
+ const walker = document.createTreeWalker(
371
+ containerRef.current,
372
+ NodeFilter.SHOW_TEXT,
373
+ {
374
+ acceptNode: (node) => isAnnotationExcludedTextNode(node)
375
+ ? NodeFilter.FILTER_REJECT
376
+ : NodeFilter.FILTER_ACCEPT,
377
+ },
378
+ );
379
+ const textNodes: Text[] = [];
380
+ let currentNode: Text | null;
381
+ while ((currentNode = walker.nextNode() as Text | null)) {
382
+ textNodes.push(currentNode);
383
+ }
370
384
 
385
+ const rangeFromTextOffsets = (startIndex: number, endIndex: number): Range | null => {
371
386
  let charCount = 0;
372
387
  let startNode: Text | null = null;
373
388
  let startOffset = 0;
374
389
  let endNode: Text | null = null;
375
390
  let endOffset = 0;
376
- let node: Text | null;
377
-
378
- while ((node = walker.nextNode() as Text | null)) {
391
+ for (const node of textNodes) {
379
392
  const nodeLength = node.textContent?.length || 0;
380
393
 
381
394
  if (!startNode && charCount + nodeLength > startIndex) {
@@ -433,14 +446,7 @@ export function useAnnotationHighlighter({
433
446
  };
434
447
  };
435
448
 
436
- const walker = document.createTreeWalker(
437
- containerRef.current,
438
- NodeFilter.SHOW_TEXT,
439
- null
440
- );
441
-
442
- let node: Text | null;
443
- while ((node = walker.nextNode() as Text | null)) {
449
+ for (const node of textNodes) {
444
450
  const text = node.textContent || '';
445
451
  const index = text.indexOf(needle);
446
452
  if (index !== -1) {
@@ -451,7 +457,7 @@ export function useAnnotationHighlighter({
451
457
  }
452
458
  }
453
459
 
454
- const fullText = containerRef.current.textContent || '';
460
+ const fullText = textNodes.map((node) => node.textContent ?? '').join('');
455
461
  const searchIndex = fullText.indexOf(needle);
456
462
  if (searchIndex !== -1) {
457
463
  return rangeFromTextOffsets(searchIndex, searchIndex + needle.length);
@@ -840,7 +846,13 @@ export function useAnnotationHighlighter({
840
846
 
841
847
  const highlighter = new Highlighter({
842
848
  $root: containerRef.current,
843
- exceptSelectors: ['.annotation-toolbar', 'button', '.math-annotatable', '.katex'],
849
+ exceptSelectors: [
850
+ '.annotation-toolbar',
851
+ 'button',
852
+ '.math-annotatable',
853
+ '.katex',
854
+ ANNOTATION_EXCLUDED_SELECTOR,
855
+ ],
844
856
  wrapTag: 'mark',
845
857
  style: { className: 'annotation-highlight' },
846
858
  });
@@ -0,0 +1,65 @@
1
+ import { useEffect } from 'react';
2
+
3
+ /**
4
+ * Outside-`pointerdown` + Escape dismissal for anchored popovers — the one
5
+ * shared effect behind DecisionControl, ActionMenu, and ApproveDropdown.
6
+ * (FloatingQuickLabelPicker deliberately keeps its own dismissal: it needs
7
+ * deferred capture-phase registration so the click that opens it cannot
8
+ * dismiss it, and its Escape shares a listener with the digit-select keys.)
9
+ *
10
+ * `dismissOnIframeFocus` is the explicit strategy for framed surfaces
11
+ * (raw-HTML srcdoc, live-app proxy): a click inside the iframe never produces
12
+ * a `pointerdown` in the parent document, but it does move focus into the
13
+ * frame, which fires `blur` on the parent window. The check runs on the next
14
+ * task because `document.activeElement` is not yet updated inside the blur
15
+ * handler.
16
+ */
17
+ export function useDismissablePopover({
18
+ enabled,
19
+ ref,
20
+ onDismiss,
21
+ dismissOnIframeFocus,
22
+ }: {
23
+ enabled: boolean;
24
+ ref: React.RefObject<HTMLElement | null>;
25
+ onDismiss: () => void;
26
+ dismissOnIframeFocus?: boolean;
27
+ }) {
28
+ useEffect(() => {
29
+ if (!enabled) return;
30
+
31
+ const handlePointerDown = (event: PointerEvent) => {
32
+ const target = event.target as Node | null;
33
+ if (target && ref.current && ref.current.contains(target)) return;
34
+ onDismiss();
35
+ };
36
+
37
+ const handleKeyDown = (event: KeyboardEvent) => {
38
+ if (event.key !== 'Escape' || event.defaultPrevented) return;
39
+ // Fail closed: an Escape that dismisses the popover is consumed here
40
+ // (document bubbles before window), so the host apps' window-level
41
+ // Escape ladders never also act on it — one Escape, one rung.
42
+ event.preventDefault();
43
+ event.stopPropagation();
44
+ onDismiss();
45
+ };
46
+
47
+ let blurTimer: ReturnType<typeof setTimeout> | undefined;
48
+ const handleWindowBlur = () => {
49
+ blurTimer = setTimeout(() => {
50
+ if (document.activeElement?.tagName === 'IFRAME') onDismiss();
51
+ }, 0);
52
+ };
53
+
54
+ document.addEventListener('pointerdown', handlePointerDown);
55
+ document.addEventListener('keydown', handleKeyDown);
56
+ if (dismissOnIframeFocus) window.addEventListener('blur', handleWindowBlur);
57
+
58
+ return () => {
59
+ document.removeEventListener('pointerdown', handlePointerDown);
60
+ document.removeEventListener('keydown', handleKeyDown);
61
+ if (dismissOnIframeFocus) window.removeEventListener('blur', handleWindowBlur);
62
+ if (blurTimer !== undefined) clearTimeout(blurTimer);
63
+ };
64
+ }, [enabled, ref, onDismiss, dismissOnIframeFocus]);
65
+ }
@@ -18,6 +18,12 @@ const BLOCKING_OVERLAY_SELECTOR = [
18
18
  // Vim from focusing the obscured document underneath those full-screen
19
19
  // overlays while they are brought onto the shared dialog primitive.
20
20
  '.fixed.inset-0',
21
+ // An open dismissable popover (ActionMenu, ApproveDropdown, DecisionControl)
22
+ // owns Escape through useDismissablePopover. Without this, the vim handler
23
+ // runs first (it registered earlier on document), preventDefaults while
24
+ // reclaiming focus, and the popover's own Escape handler then skips the
25
+ // defaultPrevented event — the menu never closes.
26
+ '[data-pn-dismissable-popover]',
21
27
  ].join(',');
22
28
 
23
29
  /** Inputs for restoring keyboard ownership to a Vim-enabled document. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/ui",
3
- "version": "0.36.0",
3
+ "version": "0.38.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./components/*": "./components/*.tsx",
@@ -54,57 +54,57 @@
54
54
  "!test-setup"
55
55
  ],
56
56
  "dependencies": {
57
- "@base-ui/react": "^1.6.0",
57
+ "@base-ui/react": "^1.7.0",
58
58
  "@codemirror/autocomplete": "^6.20.3",
59
- "@codemirror/commands": "^6.10.3",
59
+ "@codemirror/commands": "^6.11.0",
60
60
  "@codemirror/lang-javascript": "^6.2.5",
61
61
  "@codemirror/lang-json": "^6.0.2",
62
- "@codemirror/lang-markdown": "^6.5.0",
62
+ "@codemirror/lang-markdown": "^6.5.2",
63
63
  "@codemirror/lang-python": "^6.2.1",
64
64
  "@codemirror/lang-yaml": "^6.1.3",
65
- "@codemirror/language": "^6.12.3",
65
+ "@codemirror/language": "^6.12.4",
66
66
  "@codemirror/legacy-modes": "^6.5.3",
67
67
  "@codemirror/merge": "^6.12.2",
68
- "@codemirror/search": "^6.7.0",
68
+ "@codemirror/search": "^6.7.2",
69
69
  "@codemirror/state": "^6.6.0",
70
- "@codemirror/view": "^6.43.0",
71
- "@fontsource-variable/geist-mono": "5.2.7",
72
- "@fontsource-variable/inter": "^5.2.8",
70
+ "@codemirror/view": "^6.43.10",
71
+ "@fontsource-variable/geist-mono": "5.3.0",
72
+ "@fontsource-variable/inter": "^5.3.0",
73
73
  "@lezer/common": "^1.5.2",
74
74
  "@lezer/highlight": "^1.2.3",
75
- "@pierre/diffs": "1.3.2",
75
+ "@pierre/diffs": "1.3.6",
76
76
  "@plannotator/atomic-editor": "^0.8.0",
77
77
  "@plannotator/core": "0.25.1",
78
78
  "@plannotator/markdown-editor": "^0.4.0",
79
79
  "@plannotator/web-highlighter": "^0.8.1",
80
80
  "@tanstack/react-table": "^8.21.3",
81
- "@viz-js/viz": "^3.25.0",
81
+ "@viz-js/viz": "^3.29.0",
82
82
  "class-variance-authority": "^0.7.1",
83
83
  "clsx": "^2.1.1",
84
84
  "diff": "^8.0.4",
85
- "dompurify": "^3.3.3",
85
+ "dompurify": "^3.4.14",
86
86
  "katex": "^0.16.47",
87
- "lucide-react": "^1.14.0",
87
+ "lucide-react": "^1.38.0",
88
88
  "marked": "^17.0.6",
89
- "mermaid": "^11.12.2",
89
+ "mermaid": "^11.17.2",
90
90
  "motion": "^12.38.0",
91
91
  "perfect-freehand": "^1.2.2",
92
92
  "tailwind-merge": "^3.6.0",
93
93
  "unique-username-generator": "^1.5.1"
94
94
  },
95
95
  "peerDependencies": {
96
- "react": "^19.2.3",
97
- "react-dom": "^19.2.3",
96
+ "react": "^19.2.8",
97
+ "react-dom": "^19.2.8",
98
98
  "tailwindcss": "^4.1.18"
99
99
  },
100
100
  "devDependencies": {
101
- "@happy-dom/global-registrator": "^20.10.1",
102
- "@tailwindcss/vite": "^4.1.18",
101
+ "@happy-dom/global-registrator": "^20.12.0",
102
+ "@tailwindcss/vite": "^4.3.3",
103
103
  "@types/bun": "^1.2.0",
104
- "@types/react": "^19.2.0",
105
- "@types/react-dom": "^19.2.0",
106
- "react": "^19.2.3",
107
- "react-dom": "^19.2.3",
104
+ "@types/react": "^19.2.18",
105
+ "@types/react-dom": "^19.2.5",
106
+ "react": "^19.2.8",
107
+ "react-dom": "^19.2.8",
108
108
  "tailwindcss": "^4.1.18",
109
109
  "typescript": "~5.8.2",
110
110
  "vite": "^6.2.0"
@@ -0,0 +1,31 @@
1
+ import { defineShortcutScope } from './core';
2
+
3
+ /**
4
+ * The header decision control's note composer (shortcuts root, not
5
+ * plan-review/ or code-review/: both apps mount the identical control with
6
+ * identical semantics).
7
+ *
8
+ * Documents ONLY the chords the control actually implements — bindings must
9
+ * match shipped behavior (`DecisionControl.tsx`). Enter is a newline in the
10
+ * note field and must never be documented as submit.
11
+ */
12
+ export const decisionControlShortcuts = defineShortcutScope({
13
+ id: 'decision-control',
14
+ title: 'Decision control',
15
+ shortcuts: {
16
+ submitNote: {
17
+ description: 'Send the note with the decision you picked',
18
+ bindings: ['Mod+Enter'],
19
+ section: 'Actions',
20
+ hint: "Available while the decision control's note field is open. Enter inserts a newline.",
21
+ displayOrder: 12,
22
+ },
23
+ closeNote: {
24
+ description: 'Step back to the decision menu, keeping the note',
25
+ bindings: ['Escape'],
26
+ section: 'Actions',
27
+ hint: "Available while the decision control's note field is open.",
28
+ displayOrder: 14,
29
+ },
30
+ },
31
+ });
@@ -1,6 +1,7 @@
1
1
  export * from './core';
2
2
  export * from './runtime';
3
3
  export { historyShortcuts, useHistoryShortcuts } from './history.shortcuts';
4
+ export { decisionControlShortcuts } from './decisionControl.shortcuts';
4
5
 
5
6
  // plan-review scopes
6
7
  export { annotationModeShortcuts, useAnnotationModeShortcuts } from './plan-review/annotationMode.shortcuts';
@@ -7,6 +7,11 @@ import { createShortcutScopeHook } from '../runtime';
7
7
  * Focus mode is the keyboard entry point to the same view state the document
8
8
  * card's `Focus` control drives: both side panels collapse in one press and the
9
9
  * previous arrangement comes back on the next one.
10
+ *
11
+ * Edit mode is its sibling: the same chord the card's `Edit` / `Done` control
12
+ * drives, opening the markdown source editor in place and committing back to
13
+ * the viewer — the scroll position survives both directions, so a mid-document
14
+ * fix never costs a scroll to the top and back.
10
15
  */
11
16
  export const documentViewShortcuts = defineShortcutScope({
12
17
  id: 'document-view',
@@ -20,6 +25,14 @@ export const documentViewShortcuts = defineShortcutScope({
20
25
  displayOrder: 10,
21
26
  preventDefault: true,
22
27
  },
28
+ toggleEditMode: {
29
+ description: 'Toggle edit mode',
30
+ bindings: ['Mod+E'],
31
+ section: 'View',
32
+ hint: 'Opens the markdown source editor in place, keeping your scroll position; press again to commit your edits and return to annotating.',
33
+ displayOrder: 20,
34
+ preventDefault: true,
35
+ },
23
36
  },
24
37
  });
25
38