@plannotator/ui 0.37.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.
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
3
3
  import type { AnnotateAgentTerminalSide } from '@plannotator/core/agent-terminal';
4
4
  import type { Origin } from '@plannotator/core/agents';
5
5
  import type { DiffLineBgIntensity } from '@plannotator/core/config-types';
6
+ import type { TokenHoverDelay } from '@plannotator/core/token-hover';
6
7
  import { configStore, useConfigValue, setReviewPanelView, setReviewDefaultDiffType, setReviewAutoViewed } from '../config';
7
8
  import { setWebMcpToolsEnabled, useWebMcpToolsEnabled } from '../webmcp/preference';
8
9
  import { loadDiffFont } from '../utils/diffFonts';
@@ -64,7 +65,7 @@ import { useAgents } from '../hooks/useAgents';
64
65
  import { KeyboardShortcuts } from './KeyboardShortcuts';
65
66
  import { type QuickLabel, getQuickLabels, saveQuickLabels, resetQuickLabels, DEFAULT_QUICK_LABELS, getLabelColors, LABEL_COLOR_MAP } from '../utils/quickLabels';
66
67
  import { ThemeTab } from './ThemeTab';
67
- import { isMac, modKey, altKey } from '../utils/platform';
68
+ import { isMac, modKey, modKeyWord, altKey } from '../utils/platform';
68
69
  import { getAIProviderSettings, resolveAIProviderSelection } from '../utils/aiProvider';
69
70
  import { AISettingsTab } from './AISettingsTab';
70
71
  import { HooksTab } from './settings/HooksTab';
@@ -133,6 +134,23 @@ export const DIFF_STYLE_OPTIONS = [
133
134
  { value: 'split' as const, label: 'Split' },
134
135
  { value: 'unified' as const, label: 'Unified' },
135
136
  ];
137
+ /**
138
+ * The modifier gate is the platform's primary modifier: Cmd on macOS, Ctrl on
139
+ * Windows and Linux. Labeled through modKeyWord so the control names the key
140
+ * the reader actually has.
141
+ */
142
+ export const TOKEN_HOVER_TRIGGER_OPTIONS = [
143
+ { value: 'hover' as const, label: 'On hover' },
144
+ { value: 'modifier' as const, label: `Hold ${modKeyWord}` },
145
+ { value: 'off' as const, label: 'Off' },
146
+ ];
147
+ /** SegmentedControl keys on strings, so the ms values ride as their digits. */
148
+ export type TokenHoverDelayOption = '150' | '300' | '700';
149
+ export const TOKEN_HOVER_DELAY_OPTIONS = [
150
+ { value: '150' as const, label: 'Fast' },
151
+ { value: '300' as const, label: 'Default' },
152
+ { value: '700' as const, label: 'Relaxed' },
153
+ ];
136
154
  export const OVERFLOW_OPTIONS = [
137
155
  { value: 'scroll' as const, label: 'Scroll' },
138
156
  { value: 'wrap' as const, label: 'Wrap' },
@@ -157,6 +175,7 @@ const DEFAULT_DIFF_TYPE_OPTIONS = [
157
175
  // "All Changes" belongs to since-base (the flagship composite); uncommitted
158
176
  // reverts to its plain name so the two stay distinguishable side by side.
159
177
  { value: 'since-base' as const, label: 'All Changes (Recommended)', description: "Everything since your branch split from main — committed, uncommitted, and untracked" },
178
+ { value: 'local-vs-remote' as const, label: 'Local vs Remote Branch', description: "Your local branch and working tree compared with its last-fetched remote-tracking branch" },
160
179
  { value: 'uncommitted' as const, label: 'Uncommitted', description: "Everything you've changed since your last commit" },
161
180
  { value: 'unstaged' as const, label: 'Unstaged', description: "Only changes you haven't staged yet" },
162
181
  { value: 'staged' as const, label: 'Staged', description: "Only changes you've staged for commit" },
@@ -170,22 +189,27 @@ const AGENT_TERMINAL_SIDE_OPTIONS: { value: AnnotateAgentTerminalSide; label: st
170
189
  { value: 'hidden', label: 'Hidden' },
171
190
  ];
172
191
 
173
- function SegmentedControl<T extends string>({ options, value, onChange }: {
192
+ function SegmentedControl<T extends string>({ options, value, onChange, disabled = false }: {
174
193
  options: { value: T; label: string }[];
175
194
  value: T;
176
195
  onChange: (v: T) => void;
196
+ /** Visible but inert, for a control whose axis does not apply right now. */
197
+ disabled?: boolean;
177
198
  }) {
178
199
  return (
179
- <div className="flex items-center gap-1 bg-muted/50 rounded-lg p-0.5">
200
+ <div className={['flex items-center gap-1 bg-muted/50 rounded-lg p-0.5', disabled && 'opacity-50'].filter(Boolean).join(' ')}>
180
201
  {options.map((opt) => (
181
202
  <button
182
203
  key={opt.value}
204
+ {...(disabled ? { disabled: true } : {})}
183
205
  onClick={() => onChange(opt.value)}
184
- className={`flex-1 px-3 py-1.5 text-xs rounded-md transition-colors ${
206
+ className={[
207
+ 'flex-1 px-3 py-1.5 text-xs rounded-md transition-colors',
208
+ disabled && 'cursor-not-allowed',
185
209
  value === opt.value
186
210
  ? 'bg-background text-foreground shadow-sm font-medium'
187
- : 'text-muted-foreground hover:text-foreground'
188
- }`}
211
+ : 'text-muted-foreground hover:text-foreground',
212
+ ].filter(Boolean).join(' ')}
189
213
  >
190
214
  {opt.label}
191
215
  </button>
@@ -470,6 +494,8 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
470
494
  const diffExpandUnchanged = useConfigValue('diffExpandUnchanged');
471
495
  const diffFontFamily = useConfigValue('diffFontFamily');
472
496
  const diffFontSize = useConfigValue('diffFontSize');
497
+ const tokenHoverTrigger = useConfigValue('tokenHoverTrigger');
498
+ const tokenHoverDelay = useConfigValue('tokenHoverDelay');
473
499
 
474
500
  // Load font for the preview swatch
475
501
  useEffect(() => {
@@ -493,6 +519,41 @@ const ReviewDisplayTab: React.FC<{ isCompactTouchLayout?: boolean }> = ({ isComp
493
519
 
494
520
  <div className="border-t border-border" />
495
521
 
522
+ {/* Hover cards (internally tokenHover*; the label is what changed, not
523
+ the ids). One trigger select rather than a toggle plus a mode: `Off`
524
+ is a value of the same question, so there is no unreachable
525
+ enabled-but-off state to reason about. The delay stays a separate
526
+ axis because "too eager" is a complaint neither the hold-modifier option
527
+ nor Off answers. */}
528
+ <div className="space-y-3">
529
+ <div>
530
+ <div className="text-sm font-medium">Hover cards</div>
531
+ <div className="text-xs text-muted-foreground">
532
+ Rest the pointer on a symbol in a diff to see where it is defined and who
533
+ references it. Needs ripgrep and a local checkout; nothing appears when the
534
+ search comes back empty. {modKeyWord}+click still opens the References panel either way.
535
+ </div>
536
+ </div>
537
+ <SegmentedControl
538
+ options={TOKEN_HOVER_TRIGGER_OPTIONS}
539
+ value={tokenHoverTrigger}
540
+ onChange={(v) => configStore.set('tokenHoverTrigger', v)}
541
+ />
542
+ <div className="space-y-2">
543
+ <div className="text-xs text-muted-foreground">
544
+ How long the pointer rests before a card is requested
545
+ </div>
546
+ <SegmentedControl
547
+ options={TOKEN_HOVER_DELAY_OPTIONS}
548
+ value={String(tokenHoverDelay) as TokenHoverDelayOption}
549
+ onChange={(v) => configStore.set('tokenHoverDelay', Number(v) as TokenHoverDelay)}
550
+ disabled={tokenHoverTrigger === 'off'}
551
+ />
552
+ </div>
553
+ </div>
554
+
555
+ <div className="border-t border-border" />
556
+
496
557
  {/* Font Family */}
497
558
  <div className="space-y-2">
498
559
  <div>
@@ -14,7 +14,6 @@ interface FeedbackButtonProps {
14
14
  loadingLabel?: string;
15
15
  shortLoadingLabel?: string;
16
16
  title?: string;
17
- muted?: boolean;
18
17
  labelBreakpoint?: ToolbarLabelBreakpoint;
19
18
  }
20
19
 
@@ -27,7 +26,6 @@ export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
27
26
  loadingLabel = 'Sending...',
28
27
  shortLoadingLabel,
29
28
  title = 'Send Feedback',
30
- muted = false,
31
29
  labelBreakpoint = 'md',
32
30
  }) => (
33
31
  <Button
@@ -37,7 +35,6 @@ export const FeedbackButton: React.FC<FeedbackButtonProps> = ({
37
35
  disabled={disabled}
38
36
  title={title}
39
37
  iconLeft={<Send className="size-3.5" />}
40
- className={cn(muted && 'opacity-50 cursor-not-allowed')}
41
38
  >
42
39
  {shortLabel ? (
43
40
  <>
@@ -65,8 +62,10 @@ export interface ApproveButtonProps {
65
62
  mobileLabel?: string;
66
63
  mobileLoadingLabel?: string;
67
64
  title?: string;
65
+ /** Kept for plan mode (packages/editor AppHeader) — the softened "you have
66
+ * annotations" treatment. The old platform-mode `muted` prop died with
67
+ * PR6 (DecisionControl owns the self-approval mute now). */
68
68
  dimmed?: boolean;
69
- muted?: boolean;
70
69
  labelBreakpoint?: ToolbarLabelBreakpoint;
71
70
  }
72
71
 
@@ -80,7 +79,6 @@ export const ApproveButton: React.FC<ApproveButtonProps> = ({
80
79
  mobileLoadingLabel = '...',
81
80
  title,
82
81
  dimmed = false,
83
- muted = false,
84
82
  labelBreakpoint = 'md',
85
83
  }) => (
86
84
  <Button
@@ -91,9 +89,8 @@ export const ApproveButton: React.FC<ApproveButtonProps> = ({
91
89
  title={title}
92
90
  iconLeft={<Check className="size-3.5" />}
93
91
  className={cn(
94
- muted && 'opacity-40 cursor-not-allowed bg-muted text-muted-foreground hover:bg-muted',
95
- disabled && !muted && 'bg-muted text-muted-foreground hover:bg-muted',
96
- dimmed && !muted && !disabled && 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground',
92
+ disabled && 'bg-muted text-muted-foreground hover:bg-muted',
93
+ dimmed && !disabled && 'bg-success/50 text-success-foreground/70 hover:bg-success hover:text-success-foreground',
97
94
  )}
98
95
  >
99
96
  <span className={labelBreakpoint === 'lg' ? 'lg:hidden' : 'md:hidden'}>
@@ -111,6 +108,10 @@ interface ExitButtonProps {
111
108
  isLoading?: boolean;
112
109
  title?: string;
113
110
  labelBreakpoint?: ToolbarLabelBreakpoint;
111
+ /** `'ghost'` renders the icon-only ghost-X form that sits beside the
112
+ * decision control. Default `'pill'` keeps every existing caller
113
+ * byte-identical. */
114
+ appearance?: 'pill' | 'ghost';
114
115
  }
115
116
 
116
117
  export const ExitButton: React.FC<ExitButtonProps> = ({
@@ -119,7 +120,28 @@ export const ExitButton: React.FC<ExitButtonProps> = ({
119
120
  isLoading = false,
120
121
  title = 'Close session without sending feedback',
121
122
  labelBreakpoint = 'md',
122
- }) => (
123
+ appearance = 'pill',
124
+ }) => appearance === 'ghost' ? (
125
+ <Button
126
+ variant="ghost"
127
+ size="xs"
128
+ onClick={onClick}
129
+ disabled={disabled || isLoading}
130
+ title={title}
131
+ aria-label={title}
132
+ // Maintainer ruling (post-demo): a bare X carries "close without
133
+ // sending" semantics an unlabeled icon should not carry alone — the
134
+ // word renders at every breakpoint.
135
+ className="px-2 text-muted-foreground gap-1.5"
136
+ >
137
+ {isLoading ? '…' : (
138
+ <>
139
+ <X className="size-3.5" aria-hidden="true" />
140
+ <span>Close</span>
141
+ </>
142
+ )}
143
+ </Button>
144
+ ) : (
123
145
  <Button
124
146
  variant="secondary"
125
147
  size="xs"
@@ -1,3 +1,4 @@
1
+ import { generateId } from '../utils/generateId';
1
2
  import React, { useRef, useState, useEffect, useMemo, forwardRef, useImperativeHandle, useCallback } from 'react';
2
3
  import { createPortal } from 'react-dom';
3
4
  import { AnnotationType, type Block, type Annotation, type EditorMode, type InputMethod, type ImageAttachment, type ActionsLabelMode } from '../types';
@@ -908,7 +909,10 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
908
909
 
909
910
  if (viewerCommentPopover.isGlobal) {
910
911
  const newAnnotation: Annotation = {
911
- id: `global-${Date.now()}`,
912
+ // randomUUID, not Date.now(): two comments minted in the same
913
+ // millisecond (paste + submit, or a concurrent external write)
914
+ // would collide on a timestamp id.
915
+ id: generateId('global'),
912
916
  blockId: '',
913
917
  startOffset: 0,
914
918
  endOffset: 0,
@@ -1,6 +1,8 @@
1
1
  import React from 'react';
2
2
  import type { AlertKind } from '../../types';
3
3
  import { renderProseBody } from './proseBody';
4
+ import { InlineMarkdown } from '../InlineMarkdown';
5
+ import { parseAlertTitleLine } from '../../utils/alertTitle';
4
6
 
5
7
  interface AlertBlockProps {
6
8
  blockId: string;
@@ -22,6 +24,25 @@ const TITLE: Record<AlertKind, string> = {
22
24
  important: 'Important',
23
25
  };
24
26
 
27
+ /**
28
+ * Host seam: resolve a named icon from a title line's `<!-- icon: name -->`
29
+ * comment to a React node. Default: null for every name, so the alert keeps
30
+ * the type's own icon exactly as today. A host that ships an icon set (the
31
+ * package deliberately bundles none) registers a renderer once at startup.
32
+ * Returning null for an unknown name falls back to the type's icon.
33
+ */
34
+ export type AlertIconRenderer = (name: string) => React.ReactNode | null;
35
+
36
+ let alertIconRenderer: AlertIconRenderer | null = null;
37
+
38
+ export function setAlertIconRenderer(renderer: AlertIconRenderer | null): void {
39
+ alertIconRenderer = renderer;
40
+ }
41
+
42
+ export function resetAlertIconRenderer(): void {
43
+ alertIconRenderer = null;
44
+ }
45
+
25
46
  const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
26
47
  const common = { viewBox: '0 0 16 16', width: '16', height: '16', fill: 'currentColor', 'aria-hidden': true as const };
27
48
  switch (kind) {
@@ -41,6 +62,19 @@ const Icon: React.FC<{ kind: AlertKind }> = ({ kind }) => {
41
62
  export const AlertBlock: React.FC<AlertBlockProps> = ({
42
63
  blockId, kind, body, onOpenLinkedDoc, onOpenCodeFile, imageBaseDir, onImageClick, githubRepo, onNavigateAnchor,
43
64
  }) => {
65
+ const proseProps = { imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo };
66
+
67
+ // A bold-only first line (optionally led by one emoji, optionally trailed by
68
+ // an `<!-- icon: name -->` comment) is the alert's title and rides the icon
69
+ // row in place of the type word; the emoji, or a host-resolved named icon,
70
+ // takes the icon slot. See utils/alertTitle for the exact grammar. A body
71
+ // with no such line renders exactly as before.
72
+ const titleLine = parseAlertTitleLine(body);
73
+ const hostIcon = titleLine?.icon && alertIconRenderer ? alertIconRenderer(titleLine.icon) : null;
74
+ const icon = titleLine?.emoji
75
+ ? <span aria-hidden="true" className="alert-emoji text-base leading-none">{titleLine.emoji}</span>
76
+ : hostIcon ?? <Icon kind={kind} />;
77
+
44
78
  return (
45
79
  <div
46
80
  className={`alert alert-${kind} my-4 pl-4 pr-3 py-2 border-l-[3px]`}
@@ -48,11 +82,24 @@ export const AlertBlock: React.FC<AlertBlockProps> = ({
48
82
  data-block-type="alert"
49
83
  data-alert-kind={kind}
50
84
  >
51
- <div className="alert-title flex items-center gap-2 font-semibold mb-1">
52
- <Icon kind={kind} />
53
- <span>{TITLE[kind]}</span>
85
+ <div
86
+ className="alert-title flex items-center gap-2 font-semibold mb-1"
87
+ aria-label={titleLine?.title ? `${TITLE[kind]}: ${titleLine.title}` : undefined}
88
+ >
89
+ {icon}
90
+ <span>
91
+ {titleLine?.title
92
+ ? <InlineMarkdown text={titleLine.title} {...proseProps} />
93
+ : TITLE[kind]}
94
+ </span>
54
95
  </div>
55
- {renderProseBody({ body, imageBaseDir, onImageClick, onOpenLinkedDoc, onOpenCodeFile, onNavigateAnchor, githubRepo })}
96
+ {titleLine
97
+ ? (titleLine.rest.trim()
98
+ // Indent the body by the icon's width plus the row gap so it starts
99
+ // at the title's left edge (16px icon + 8px gap = pl-6).
100
+ ? <div className="alert-body pl-6">{renderProseBody({ body: titleLine.rest, ...proseProps })}</div>
101
+ : null)
102
+ : renderProseBody({ body, ...proseProps })}
56
103
  </div>
57
104
  );
58
105
  };
@@ -56,6 +56,7 @@ export function getPersistedReviewPanelView(): 'sections' | 'tree' | undefined {
56
56
 
57
57
  export type ReviewDefaultDiffType =
58
58
  | 'since-base'
59
+ | 'local-vs-remote'
59
60
  | 'uncommitted'
60
61
  | 'unstaged'
61
62
  | 'staged'
@@ -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
  }
@@ -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.37.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';