@plannotator/ui 0.53.0 → 0.54.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 (145) hide show
  1. package/HANDOFF.md +284 -27
  2. package/README.md +84 -14
  3. package/components/AgentToolAnnouncementDialog.tsx +1 -1
  4. package/components/AgentsTab.tsx +1 -1
  5. package/components/AnnotationPanel.tsx +177 -34
  6. package/components/AnnotationSidebar.tsx +1 -1
  7. package/components/AnnotationToolbar.tsx +64 -9
  8. package/components/AnnotationToolstrip.tsx +45 -40
  9. package/components/ApproveDropdown.tsx +7 -9
  10. package/components/AskSessionAnnouncementDialog.tsx +1 -1
  11. package/components/AttachmentsButton.tsx +1 -1
  12. package/components/BlockRenderer.tsx +19 -5
  13. package/components/CodeFilePopout.tsx +140 -5
  14. package/components/CommentPopover.tsx +46 -8
  15. package/components/DecisionControl.tsx +31 -5
  16. package/components/DiagramBlock.tsx +17 -12
  17. package/components/EditorAnnotationCard.tsx +1 -1
  18. package/components/ExportModal.tsx +77 -7
  19. package/components/HostTitle.tsx +0 -4
  20. package/components/HtmlSurfaceControls.tsx +2 -1
  21. package/components/ImageAnnotator/Toolbar.tsx +25 -1
  22. package/components/ImageAnnotator/index.tsx +24 -4
  23. package/components/ImageLightbox.tsx +658 -120
  24. package/components/ImagePeek.tsx +38 -4
  25. package/components/ImageRegions.tsx +369 -0
  26. package/components/ImageThumbnail.tsx +1 -1
  27. package/components/ImageZoomStage.tsx +796 -0
  28. package/components/InboxAnnouncementDialog.tsx +202 -0
  29. package/components/InlineMarkdown.tsx +152 -24
  30. package/components/KeyboardShortcuts.tsx +23 -0
  31. package/components/LazyBlockBoundary.tsx +100 -0
  32. package/components/ListMarker.tsx +3 -3
  33. package/components/LookAndFeelAnnouncementDialog.tsx +12 -2
  34. package/components/MentionPicker.tsx +51 -0
  35. package/components/MenuVersionSection.tsx +31 -9
  36. package/components/PlanDecisionMenuExtras.tsx +129 -0
  37. package/components/PlanHeaderMenu.tsx +15 -1
  38. package/components/ProviderIcons.tsx +8 -0
  39. package/components/RefHoverCard.tsx +913 -0
  40. package/components/SendFailureToast.tsx +126 -0
  41. package/components/Settings.tsx +617 -126
  42. package/components/SkillReferenceMenu.tsx +39 -12
  43. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  44. package/components/ThemeProvider.tsx +14 -1
  45. package/components/ThemeTab.tsx +16 -24
  46. package/components/Tooltip.tsx +49 -18
  47. package/components/Viewer.tsx +316 -47
  48. package/components/ai/DocumentAIChatPanel.tsx +29 -3
  49. package/components/ai/NewChatButton.tsx +74 -0
  50. package/components/blocks/CodeBlock.tsx +1 -1
  51. package/components/blocks/HtmlBlock.tsx +7 -1
  52. package/components/blocks/QuestionBlock.tsx +522 -62
  53. package/components/blocks/QuestionContext.tsx +3 -0
  54. package/components/diagram/DiagramComposer.tsx +5 -1
  55. package/components/diagram/DiagramPending.tsx +12 -2
  56. package/components/diagram/DiagramViewer.tsx +19 -3
  57. package/components/html-embed/HtmlEmbedBlock.tsx +374 -0
  58. package/components/html-embed/SvgFenceBlock.tsx +46 -0
  59. package/components/html-embed/index.ts +3 -0
  60. package/components/html-embed/languages.ts +15 -0
  61. package/components/html-embed/loadGate.ts +107 -0
  62. package/components/html-viewer/HtmlViewer.tsx +69 -6
  63. package/components/html-viewer/bridge-script.asset.js +504 -12
  64. package/components/html-viewer/bridge-script.lite.ts +1 -1
  65. package/components/html-viewer/bridge-script.ts +545 -12
  66. package/components/html-viewer/useHtmlAnnotation.ts +125 -0
  67. package/components/imageZoom.ts +265 -0
  68. package/components/plan-diff/PlanCleanDiffView.tsx +1 -1
  69. package/components/plan-diff/PlanDiffViewer.tsx +13 -2
  70. package/components/plan-diff/PlanRawDiffView.tsx +1 -1
  71. package/components/sidebar/FileBrowser.tsx +16 -2
  72. package/components/sidebar/SidebarTabs.tsx +3 -0
  73. package/config/index.ts +3 -0
  74. package/config/reviewView.ts +41 -1
  75. package/config/settings.ts +155 -4
  76. package/configure.ts +28 -0
  77. package/hooks/pfm/useCodeFilePopout.ts +7 -1
  78. package/hooks/useAIChat.ts +72 -25
  79. package/hooks/useAnnotationDraft.ts +71 -6
  80. package/hooks/useAnnotationHighlighter.ts +57 -3
  81. package/hooks/useCodeAnnotationDraft.ts +113 -17
  82. package/hooks/useCookieHostSetting.ts +96 -0
  83. package/hooks/useDocumentFont.ts +17 -0
  84. package/hooks/useDocumentTextSize.ts +20 -0
  85. package/hooks/useFileBrowser.ts +10 -1
  86. package/hooks/useLinkedDoc.ts +226 -13
  87. package/hooks/useMentionAutocomplete.ts +1 -1
  88. package/hooks/usePackageChangesSetting.ts +90 -0
  89. package/hooks/usePlanLookDialog.ts +67 -0
  90. package/hooks/useSharing.ts +11 -7
  91. package/hooks/useSkillReferenceAutocomplete.ts +7 -3
  92. package/hooks/useThemePaletteHalves.ts +37 -0
  93. package/hooks/useUpdateCheck.ts +22 -4
  94. package/hooks/useUpdateToast.ts +1 -1
  95. package/hooks/useVimSelection.ts +9 -0
  96. package/package.json +23 -22
  97. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +1 -0
  98. package/shortcuts/code-review/explorer.shortcuts.ts +82 -0
  99. package/shortcuts/core.ts +63 -4
  100. package/shortcuts/index.ts +1 -0
  101. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +1 -1
  102. package/shortcuts/plan-review/commentPopover.shortcuts.ts +1 -0
  103. package/shortcuts/runtime.ts +14 -6
  104. package/styles.css +1 -1
  105. package/theme.css +179 -1
  106. package/themes/plannotator-graphite.css +66 -0
  107. package/types.ts +26 -9
  108. package/utils/agentSwitch.ts +41 -20
  109. package/utils/agentToolSetting.ts +1 -1
  110. package/utils/aiChatFormat.ts +23 -7
  111. package/utils/annotationOwnsHighlight.ts +4 -2
  112. package/utils/asciidocParser.ts +570 -0
  113. package/utils/autoUpdateNotice.ts +24 -0
  114. package/utils/belowTitleSlot.ts +174 -0
  115. package/utils/codeLineScroll.ts +100 -0
  116. package/utils/commentSubmitKey.ts +48 -0
  117. package/utils/cssFontFamily.ts +65 -0
  118. package/utils/decisionSpec.ts +190 -2
  119. package/utils/defaultNotesApp.ts +1 -1
  120. package/utils/diffFonts.ts +4 -1
  121. package/utils/documentFont.ts +68 -0
  122. package/utils/documentTextSize.ts +54 -0
  123. package/utils/fontDetect.ts +63 -0
  124. package/utils/inboxAnnouncement.ts +78 -0
  125. package/utils/lookAndFeelAnnouncement.ts +82 -5
  126. package/utils/markdownExtensions.ts +2 -2
  127. package/utils/mentions.ts +34 -5
  128. package/utils/mermaid.ts +20 -3
  129. package/utils/mermaidTheme.ts +3 -51
  130. package/utils/notion.ts +58 -0
  131. package/utils/parser.ts +28 -1
  132. package/utils/platform.ts +46 -0
  133. package/utils/refCard.ts +82 -0
  134. package/utils/richClipboard.ts +380 -0
  135. package/utils/richClipboardLists.ts +57 -0
  136. package/utils/richCopyPolicy.ts +22 -0
  137. package/utils/scrollAnchor.ts +82 -0
  138. package/utils/sectionRefs.ts +123 -0
  139. package/utils/selectionBoundary.ts +40 -0
  140. package/utils/sendFailure.ts +85 -0
  141. package/utils/sharing.ts +5 -2
  142. package/utils/themeRegistry.ts +10 -0
  143. package/utils/themeTokens.ts +74 -0
  144. package/utils/updateNotice.ts +30 -8
  145. package/utils/vscodePanel.ts +9 -0
@@ -5,6 +5,8 @@ import { AttachmentsButton } from './AttachmentsButton';
5
5
  import { useDraggable } from '../hooks/useDraggable';
6
6
  import { SparklesIcon } from './SparklesIcon';
7
7
  import { hasUnsavedCommentContent } from '../utils/commentContent';
8
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
9
+ import { useConfigValue } from '../config/useConfig';
8
10
  import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
9
11
  import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
10
12
  import { ComposerTokenTextarea, type ComposerMentionChips } from './ComposerTokenTextarea';
@@ -119,6 +121,20 @@ interface CommentPopoverProps {
119
121
  /** Opt-in composer yield while shift-selecting: 'near' fades the composer,
120
122
  * 'over' makes it near-invisible and click-through. Undefined → no-op. */
121
123
  yieldState?: CommentPopoverYieldState;
124
+ /** Opt-in: the header's text in place of the quoted `contextText` (an image
125
+ * region's "Region 3 · 356 × 486 px"). Absent → the quote, as always. */
126
+ headerText?: string;
127
+ /** Opt-in: `side` opens the popover beside the anchor (right, else left),
128
+ * falling back to below/above when neither side has room (an image region
129
+ * in the image viewer). Absent → below/above, as always. */
130
+ placement?: 'below' | 'side';
131
+ /** Opt-in: the stacking level, for a composer over a surface that is
132
+ * itself above the page (the image viewer is z-200). Absent → 100. */
133
+ zIndex?: number;
134
+ /** Opt-out: `false` mounts the composer without taking focus (an HTML
135
+ * pin's composer shown again when its hidden target returns, so focus
136
+ * stays where the person is). Absent → focuses on mount, as always. */
137
+ autoFocus?: boolean;
122
138
  }
123
139
 
124
140
  const MAX_POPOVER_WIDTH = 384;
@@ -151,7 +167,19 @@ interface CommentPopoverPosition {
151
167
  export function computeCommentPopoverPosition(
152
168
  anchorRect: Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width'>,
153
169
  bounds: VisibleViewportBounds,
170
+ placement: 'below' | 'side' = 'below',
154
171
  ): CommentPopoverPosition {
172
+ if (placement === 'side') {
173
+ const width = Math.min(MAX_POPOVER_WIDTH, bounds.width);
174
+ const right = anchorRect.right + GAP * 1.75;
175
+ const left = anchorRect.left - GAP * 1.75 - width;
176
+ const sideLeft = right + width <= bounds.right ? right : left >= bounds.left ? left : null;
177
+ if (sideLeft !== null) {
178
+ const top = Math.max(bounds.top, Math.min(anchorRect.top, bounds.bottom - 280));
179
+ const maxHeight = Math.max(0, bounds.bottom - top);
180
+ if (maxHeight >= 280) return { top, left: sideLeft, flipAbove: false, width, maxHeight, requiresExpanded: false };
181
+ }
182
+ }
155
183
  const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP);
156
184
  const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP);
157
185
  const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
@@ -199,6 +227,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
199
227
  refocusToken,
200
228
  captureStrayKeys = false,
201
229
  yieldState,
230
+ headerText,
231
+ placement = 'below',
232
+ zIndex,
233
+ autoFocus = true,
202
234
  }) => {
203
235
  const visibleBounds = useVisibleViewportBounds(16);
204
236
  const coarsePointer = hasPrimaryCoarsePointer();
@@ -266,7 +298,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
266
298
  const update = () => {
267
299
  const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
268
300
  if (!rect) return;
269
- const nextPosition = computeCommentPopoverPosition(rect, visibleBounds);
301
+ const nextPosition = computeCommentPopoverPosition(rect, visibleBounds, placement);
270
302
  if (nextPosition.requiresExpanded) {
271
303
  setDialogIsForced(true);
272
304
  setMode('dialog');
@@ -280,7 +312,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
280
312
  return () => {
281
313
  window.removeEventListener('scroll', update, true);
282
314
  };
283
- }, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]);
315
+ }, [anchorEl, anchorRect, mode, visibleBounds, wasDragged, placement]);
284
316
 
285
317
  // Surface a "jump back" arrow when an open popover scrolls out of view.
286
318
  // Re-measures whenever the popover repositions (position updates every scroll
@@ -307,7 +339,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
307
339
  // renders after `position` is measured, and WebKit fires 0ms timers ahead of
308
340
  // that commit, so an effect keyed on mode alone can run before the textarea
309
341
  // exists and never focus it (e.g. in WKWebView hosts like Glimpse).
310
- const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal;
342
+ const shouldAutoFocus = autoFocus && (!coarsePointer || initialText.length > 0 || isGlobal);
311
343
  const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
312
344
  textareaRef.current = el;
313
345
  if (!el || !shouldAutoFocus) return;
@@ -322,13 +354,13 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
322
354
  // summon the software keyboard), but hardware-keyboard users still need a
323
355
  // coherent focus target and Escape dismissal.
324
356
  useEffect(() => {
325
- if (mode !== 'dialog' || shouldAutoFocus) return;
357
+ if (mode !== 'dialog' || shouldAutoFocus || !autoFocus) return;
326
358
  const timer = setTimeout(() => {
327
359
  if (!popoverRef.current?.isConnected) return;
328
360
  popoverRef.current.focus({ preventScroll: true });
329
361
  }, 0);
330
362
  return () => clearTimeout(timer);
331
- }, [mode, shouldAutoFocus]);
363
+ }, [autoFocus, mode, shouldAutoFocus]);
332
364
 
333
365
  const restoreOpeningFocus = useCallback(() => {
334
366
  const target = openingFocusRef.current;
@@ -561,6 +593,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
561
593
  const composerListboxOpen = skillAc.menu !== null || mentionAc.menu !== null;
562
594
  const activeComposerOptionId = activeSkillOptionId ?? activeMentionOptionId;
563
595
 
596
+ const enterSavesComment = useConfigValue('enterSavesComment');
564
597
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
565
598
  if (skillAc.onKeyDown(e)) return;
566
599
  if (mentionAc.onKeyDown(e)) return;
@@ -577,17 +610,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
577
610
  }
578
611
  return;
579
612
  }
580
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
613
+ if (isCommentSubmitKey(e, enterSavesComment)) {
581
614
  e.preventDefault();
582
615
  handleSubmit();
583
616
  }
584
617
  };
585
618
 
586
- const headerLabel = isGlobal
619
+ const headerLabel = headerText ?? (isGlobal
587
620
  ? 'Global Comment'
588
621
  : contextText
589
622
  ? `"${contextText.length > 50 ? contextText.slice(0, 50) + '...' : contextText}"`
590
- : 'Comment';
623
+ : 'Comment');
591
624
 
592
625
  const canSubmit =
593
626
  hasUnsavedContent ||
@@ -630,6 +663,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
630
663
  <div
631
664
  data-comment-popover="true"
632
665
  className="pn-visible-viewport-overlay z-[100] flex items-center justify-center"
666
+ style={zIndex !== undefined ? { zIndex } : undefined}
633
667
  >
634
668
  {/* Backdrop */}
635
669
  <button
@@ -726,6 +760,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
726
760
  activeOptionId={activeComposerOptionId}
727
761
  />
728
762
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
763
+ {mentionSource?.renderNotice?.(mentionChipPeople)}
729
764
  <MentionAutocompleteMenu
730
765
  id={mentionListboxId}
731
766
  menu={mentionAc.menu}
@@ -796,6 +831,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
796
831
  className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
797
832
  style={dragPosition
798
833
  ? {
834
+ ...(zIndex !== undefined ? { zIndex } : {}),
799
835
  top: dragPosition.top,
800
836
  left: dragPosition.left,
801
837
  width: position.width,
@@ -803,6 +839,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
803
839
  overflowY: showsSkillMenu ? 'visible' : 'auto',
804
840
  }
805
841
  : {
842
+ ...(zIndex !== undefined ? { zIndex } : {}),
806
843
  top: position.top,
807
844
  left: position.left,
808
845
  width: position.width,
@@ -879,6 +916,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
879
916
  activeOptionId={activeComposerOptionId}
880
917
  />
881
918
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
919
+ {mentionSource?.renderNotice?.(mentionChipPeople)}
882
920
  <MentionAutocompleteMenu
883
921
  id={mentionListboxId}
884
922
  menu={mentionAc.menu}
@@ -8,6 +8,7 @@ import { ConfirmDialog, type ConfirmDialogProps } from './ConfirmDialog';
8
8
  import { useDismissablePopover } from '../hooks/useDismissablePopover';
9
9
  import { useCompactTouchLayout } from '../hooks/useIsMobile';
10
10
  import { cn } from '../lib/utils';
11
+ import { isCommentSubmitKey } from '../utils/commentSubmitKey';
11
12
  import type {
12
13
  DecisionActionId,
13
14
  DecisionComposer,
@@ -57,6 +58,16 @@ export interface DecisionControlProps {
57
58
  dismissOnIframeFocus?: boolean;
58
59
  /** Confirm surface override; defaults to the ui ConfirmDialog. */
59
60
  confirmDialog?: React.ComponentType<ConfirmDialogProps>;
61
+ /**
62
+ * Rows rendered under the decisions, inside the same menu (#1057: plan
63
+ * review's "After approval, switch to" agent picker and Claude Code's
64
+ * permission-mode line). Not decisions: they never close the menu by
65
+ * themselves. Interactive rows use `role="menuitem"` or
66
+ * `role="menuitemradio"` so the arrow keys reach them. Absent: the menu is
67
+ * the decisions alone, exactly as before. A function receives
68
+ * `closeMenu`, for a row that leaves the menu (e.g. one opening Settings).
69
+ */
70
+ menuFooter?: React.ReactNode | ((api: { closeMenu: () => void }) => React.ReactNode);
60
71
  }
61
72
 
62
73
  const NOTE_MAX_HEIGHT_PX = 144;
@@ -98,6 +109,14 @@ export interface DecisionNoteFieldProps {
98
109
  placeholder?: string;
99
110
  disabled?: boolean;
100
111
  autoFocus?: boolean;
112
+ /**
113
+ * The field is a COMMENT composer (the review sidebar's "+ General
114
+ * comment"), so the "Enter saves a comment" setting applies: pass its value
115
+ * and a plain Enter also submits (Shift+Enter stays a new line). Default
116
+ * false: in the decision control's own composer a note rides a decision,
117
+ * and only Mod+Enter fires it.
118
+ */
119
+ enterSubmits?: boolean;
101
120
  }
102
121
 
103
122
  /**
@@ -114,6 +133,7 @@ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
114
133
  placeholder = 'Add a note...',
115
134
  disabled = false,
116
135
  autoFocus = true,
136
+ enterSubmits = false,
117
137
  }) => {
118
138
  const ref = useAutoGrow(text);
119
139
 
@@ -132,15 +152,15 @@ export const DecisionNoteField: React.FC<DecisionNoteFieldProps> = ({
132
152
  onCancel();
133
153
  return;
134
154
  }
135
- if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
155
+ if (enterSubmits ? isCommentSubmitKey(event, true) : event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
136
156
  event.preventDefault();
137
157
  event.stopPropagation();
138
158
  if (!disabled) onSubmit();
139
159
  }
140
- // Plain Enter falls through: the field is multi-line and Enter's job is
141
- // a newline.
160
+ // Otherwise plain Enter falls through: the field is multi-line and
161
+ // Enter's job is a newline.
142
162
  },
143
- [disabled, onCancel, onSubmit],
163
+ [disabled, enterSubmits, onCancel, onSubmit],
144
164
  );
145
165
 
146
166
  return (
@@ -255,6 +275,7 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
255
275
  labelBreakpoint = 'md',
256
276
  dismissOnIframeFocus = false,
257
277
  confirmDialog,
278
+ menuFooter,
258
279
  }) => {
259
280
  const rootRef = useRef<HTMLDivElement>(null);
260
281
  const popoverRef = useRef<HTMLDivElement>(null);
@@ -402,7 +423,9 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
402
423
  const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
403
424
  if (!keys.includes(event.key)) return;
404
425
  const items = Array.from(
405
- popoverRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled)') ?? [],
426
+ popoverRef.current?.querySelectorAll<HTMLButtonElement>(
427
+ '[role="menuitem"]:not(:disabled), [role="menuitemradio"]:not(:disabled)',
428
+ ) ?? [],
406
429
  );
407
430
  if (items.length === 0) return;
408
431
  event.preventDefault();
@@ -548,6 +571,9 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
548
571
  />
549
572
  </React.Fragment>
550
573
  ))}
574
+ {typeof menuFooter === 'function'
575
+ ? menuFooter({ closeMenu: () => closePopover() })
576
+ : menuFooter}
551
577
  </div>
552
578
  ) : activeItem?.composer ? (
553
579
  <div className="p-2">
@@ -16,6 +16,7 @@ import { svgContentSize } from './diagram/svgContentSize';
16
16
  import type { DiagramComment, DiagramCreateComment } from './diagram/useDiagramComments';
17
17
  import type { DiagramRenderState } from './diagram/useDiagramRender';
18
18
  import { useTheme } from './ThemeProvider';
19
+ import { LazyBlockBoundary } from './LazyBlockBoundary';
19
20
 
20
21
  /**
21
22
  * A diagram fence in the document: the fence's language picks the engine
@@ -345,7 +346,7 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
345
346
  appears in a node label) can never wrap a <mark> inside the svg. */}
346
347
  <div ref={rootRef} className="annotation-exclude my-5 group relative" data-block-id={block.id} data-pinpoint-ignore="" data-diagram-block={kind}>
347
348
  {svgReady && !showSource && (
348
- <div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
349
+ <div data-print-hide="" className="absolute top-2 right-2 z-10 flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 [@media(hover:none)]:opacity-100 transition-opacity">
349
350
  <button
350
351
  type="button"
351
352
  onClick={() => setShowSource(true)}
@@ -404,17 +405,21 @@ export const DiagramBlock: React.FC<DiagramBlockProps & { kind: DiagramKind }> =
404
405
  )}
405
406
  </div>
406
407
  {isExpanded && svgReady && typeof document !== 'undefined' && (
407
- <Suspense fallback={null}>
408
- <DiagramPopout
409
- {...viewerProps}
410
- open
411
- onClose={() => setIsExpanded(false)}
412
- title={`${label} diagram`}
413
- renderId={`${kind}-${block.id}-popout`}
414
- onAskAI={handlePopoutAskAI}
415
- dataAttributes={{ 'data-block-id': block.id }}
416
- />
417
- </Suspense>
408
+ // A popout chunk that cannot load leaves the inline diagram as it is
409
+ // and says so under it.
410
+ <LazyBlockBoundary fallback={(notice) => <div className="mb-5">{notice}</div>}>
411
+ <Suspense fallback={null}>
412
+ <DiagramPopout
413
+ {...viewerProps}
414
+ open
415
+ onClose={() => setIsExpanded(false)}
416
+ title={`${label} diagram`}
417
+ renderId={`${kind}-${block.id}-popout`}
418
+ onAskAI={handlePopoutAskAI}
419
+ dataAttributes={{ 'data-block-id': block.id }}
420
+ />
421
+ </Suspense>
422
+ </LazyBlockBoundary>
418
423
  )}
419
424
  </>
420
425
  );
@@ -42,7 +42,7 @@ export const EditorAnnotationCard: React.FC<EditorAnnotationCardProps> = ({ anno
42
42
  {onDelete && (
43
43
  <button
44
44
  onClick={(e) => { e.stopPropagation(); onDelete(); }}
45
- className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
45
+ className="ml-auto relative rounded-md p-1.5 text-muted-foreground transition-colors before:absolute before:-inset-1.5 before:content-[''] opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 [@media(hover:none)]:opacity-100 hover:text-destructive"
46
46
  title="Delete annotation"
47
47
  >
48
48
  <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -10,6 +10,7 @@ import React, { useState, useEffect } from 'react';
10
10
  import { getObsidianSettings, getEffectiveVaultPath } from '../utils/obsidian';
11
11
  import { getBearSettings } from '../utils/bear';
12
12
  import { getOctarineSettings } from '../utils/octarine';
13
+ import { getNotionParentPageId, isNotionAvailable } from '../utils/notion';
13
14
  import { wrapFeedbackForAgent } from '../utils/parser';
14
15
  import { copyTextToClipboard } from '../utils/clipboard';
15
16
  import { noteServerSessionMismatch, withServerSession } from '../utils/serverSession';
@@ -20,11 +21,12 @@ interface SaveToNotesPayload {
20
21
  obsidian?: object;
21
22
  bear?: object;
22
23
  octarine?: object;
24
+ notion?: object;
23
25
  }
24
26
 
25
27
  /** Parsed response from the notes endpoint. */
26
28
  interface SaveToNotesResult {
27
- results?: Record<string, { success?: boolean; error?: string }>;
29
+ results?: Record<string, { success?: boolean; error?: string; url?: string }>;
28
30
  }
29
31
 
30
32
  /** Default save-to-notes wire: today's literal POST to /api/save-notes. */
@@ -74,7 +76,7 @@ interface ExportModalProps {
74
76
 
75
77
  type Tab = 'share' | 'annotations' | 'notes';
76
78
 
77
- type SaveTarget = 'obsidian' | 'bear' | 'octarine';
79
+ type SaveTarget = 'obsidian' | 'bear' | 'octarine' | 'notion';
78
80
  type SaveStatus = 'idle' | 'saving' | 'success' | 'error';
79
81
 
80
82
  export const ExportModal: React.FC<ExportModalProps> = ({
@@ -99,8 +101,9 @@ export const ExportModal: React.FC<ExportModalProps> = ({
99
101
  const defaultTab = initialTab || (sharingEnabled ? 'share' : 'annotations');
100
102
  const [activeTab, setActiveTab] = useState<Tab>(defaultTab);
101
103
  const [copied, setCopied] = useState<'short' | 'full' | 'annotations' | false>(false);
102
- const [saveStatus, setSaveStatus] = useState<Record<SaveTarget, SaveStatus>>({ obsidian: 'idle', bear: 'idle', octarine: 'idle' });
104
+ const [saveStatus, setSaveStatus] = useState<Record<SaveTarget, SaveStatus>>({ obsidian: 'idle', bear: 'idle', octarine: 'idle', notion: 'idle' });
103
105
  const [saveErrors, setSaveErrors] = useState<Record<string, string>>({});
106
+ const [savedUrls, setSavedUrls] = useState<Partial<Record<SaveTarget, string>>>({});
104
107
 
105
108
  // Reset tab when modal opens
106
109
  useEffect(() => {
@@ -112,8 +115,9 @@ export const ExportModal: React.FC<ExportModalProps> = ({
112
115
  // Reset save status when modal opens
113
116
  useEffect(() => {
114
117
  if (isOpen) {
115
- setSaveStatus({ obsidian: 'idle', bear: 'idle', octarine: 'idle' });
118
+ setSaveStatus({ obsidian: 'idle', bear: 'idle', octarine: 'idle', notion: 'idle' });
116
119
  setSaveErrors({});
120
+ setSavedUrls({});
117
121
  }
118
122
  }, [isOpen]);
119
123
 
@@ -127,6 +131,10 @@ export const ExportModal: React.FC<ExportModalProps> = ({
127
131
  const isObsidianReady = obsidianSettings.enabled && effectiveVaultPath.trim().length > 0;
128
132
  const isBearReady = bearSettings.enabled;
129
133
  const isOctarineReady = octarineSettings.enabled && octarineSettings.workspace.trim().length > 0;
134
+ // Notion shows only when this server offers it (NOTION_TOKEN set, local session).
135
+ const notionAvailable = isNotionAvailable();
136
+ const notionParentPageId = getNotionParentPageId();
137
+ const isNotionReady = notionParentPageId !== null;
130
138
 
131
139
  const handleCopy = async (text: string, which: 'short' | 'full' | 'annotations') => {
132
140
  if (await copyTextToClipboard(text)) {
@@ -162,7 +170,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
162
170
  setSaveStatus(prev => ({ ...prev, [target]: 'saving' }));
163
171
  setSaveErrors(prev => { const next = { ...prev }; delete next[target]; return next; });
164
172
 
165
- const body: { obsidian?: object; bear?: object; octarine?: object } = {};
173
+ const body: { obsidian?: object; bear?: object; octarine?: object; notion?: object } = {};
166
174
 
167
175
  if (target === 'obsidian') {
168
176
  body.obsidian = {
@@ -183,6 +191,9 @@ export const ExportModal: React.FC<ExportModalProps> = ({
183
191
  folder: octarineSettings.folder || 'plannotator',
184
192
  };
185
193
  }
194
+ if (target === 'notion' && notionParentPageId) {
195
+ body.notion = { plan: markdown, parentPageId: notionParentPageId };
196
+ }
186
197
 
187
198
  try {
188
199
  const data = await onSaveToNotes(body);
@@ -190,6 +201,7 @@ export const ExportModal: React.FC<ExportModalProps> = ({
190
201
 
191
202
  if (result?.success) {
192
203
  setSaveStatus(prev => ({ ...prev, [target]: 'success' }));
204
+ if (result.url) setSavedUrls(prev => ({ ...prev, [target]: result.url! }));
193
205
  } else {
194
206
  setSaveStatus(prev => ({ ...prev, [target]: 'error' }));
195
207
  setSaveErrors(prev => ({ ...prev, [target]: result?.error || 'Save failed' }));
@@ -205,10 +217,11 @@ export const ExportModal: React.FC<ExportModalProps> = ({
205
217
  if (isObsidianReady) targets.push('obsidian');
206
218
  if (isBearReady) targets.push('bear');
207
219
  if (isOctarineReady) targets.push('octarine');
220
+ if (isNotionReady) targets.push('notion');
208
221
  await Promise.all(targets.map(t => handleSaveToNotes(t)));
209
222
  };
210
223
 
211
- const readyCount = [isObsidianReady, isBearReady, isOctarineReady].filter(Boolean).length;
224
+ const readyCount = [isObsidianReady, isBearReady, isOctarineReady, isNotionReady].filter(Boolean).length;
212
225
 
213
226
  // Determine which tabs to show
214
227
  const showTabs = sharingEnabled || showNotesTab;
@@ -538,12 +551,69 @@ export const ExportModal: React.FC<ExportModalProps> = ({
538
551
  )}
539
552
  </div>
540
553
 
554
+ {/* Notion */}
555
+ {notionAvailable && (
556
+ <div className="border border-border rounded-lg p-3 space-y-2">
557
+ <div className="flex items-center justify-between">
558
+ <div className="flex items-center gap-2">
559
+ <span className={`w-2 h-2 rounded-full ${isNotionReady ? 'bg-success' : 'bg-muted-foreground/30'}`} />
560
+ <span className="text-sm font-medium">Notion</span>
561
+ </div>
562
+ {isNotionReady ? (
563
+ <button
564
+ onClick={() => handleSaveToNotes('notion')}
565
+ disabled={saveStatus.notion === 'saving'}
566
+ className={`px-3 py-1 rounded-md text-xs font-medium transition-all ${
567
+ saveStatus.notion === 'success'
568
+ ? 'bg-success/15 text-success'
569
+ : saveStatus.notion === 'error'
570
+ ? 'bg-destructive/15 text-destructive'
571
+ : saveStatus.notion === 'saving'
572
+ ? 'bg-muted text-muted-foreground opacity-50'
573
+ : 'bg-primary text-primary-foreground hover:opacity-90'
574
+ }`}
575
+ >
576
+ {saveStatus.notion === 'saving' ? 'Saving...'
577
+ : saveStatus.notion === 'success' ? 'Saved'
578
+ : saveStatus.notion === 'error' ? 'Failed'
579
+ : 'Save'}
580
+ </button>
581
+ ) : (
582
+ <span className="text-xs text-muted-foreground">Not configured</span>
583
+ )}
584
+ </div>
585
+ {isNotionReady && (
586
+ <div className="text-[10px] text-muted-foreground/70">
587
+ Parent page: {notionParentPageId}
588
+ </div>
589
+ )}
590
+ {savedUrls.notion?.startsWith('https://') && (
591
+ <a
592
+ href={savedUrls.notion}
593
+ target="_blank"
594
+ rel="noreferrer"
595
+ className="block text-[10px] text-primary hover:underline truncate"
596
+ >
597
+ Open saved page
598
+ </a>
599
+ )}
600
+ {!isNotionReady && (
601
+ <div className="text-[10px] text-muted-foreground/70">
602
+ Enable in Settings &gt; Saving &gt; Notion
603
+ </div>
604
+ )}
605
+ {saveErrors.notion && (
606
+ <div className="text-[10px] text-destructive">{saveErrors.notion}</div>
607
+ )}
608
+ </div>
609
+ )}
610
+
541
611
  {/* Save All button */}
542
612
  {readyCount >= 2 && (
543
613
  <div className="flex justify-end">
544
614
  <button
545
615
  onClick={handleSaveAll}
546
- disabled={saveStatus.obsidian === 'saving' || saveStatus.bear === 'saving' || saveStatus.octarine === 'saving'}
616
+ disabled={saveStatus.obsidian === 'saving' || saveStatus.bear === 'saving' || saveStatus.octarine === 'saving' || saveStatus.notion === 'saving'}
547
617
  className="px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
548
618
  >
549
619
  Save All
@@ -33,9 +33,6 @@ export function getComponentTooltips(): boolean {
33
33
  return componentTooltips;
34
34
  }
35
35
 
36
- /** How long the pointer rests before a component tooltip opens. */
37
- const HOST_TITLE_DELAY_MS = 150;
38
-
39
36
  /**
40
37
  * Where a tooltip for `trigger` is portaled: the open native `<dialog>` it
41
38
  * sits in (a popup in the body is drawn under the dialog's top layer), else
@@ -83,7 +80,6 @@ export function HostTitle({
83
80
  content={title}
84
81
  side={side}
85
82
  wide
86
- delayDuration={HOST_TITLE_DELAY_MS}
87
83
  escapePassesThrough
88
84
  shouldOpen={(trigger) => {
89
85
  if (noHover() || (cut && !isTextCut(trigger))) return false;
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import React from 'react';
33
33
  import { Tooltip } from './Tooltip';
34
+ import { modKey } from '../utils/platform';
34
35
  import { formatShortcutBindingText, formatShortcutBindingTokens } from '../shortcuts/core';
35
36
  import { htmlAnnotateShortcuts } from '../shortcuts/plan-review/htmlAnnotate.shortcuts';
36
37
 
@@ -135,7 +136,7 @@ export interface HtmlSurfaceControlLabels {
135
136
  export const DEFAULT_HTML_SURFACE_CONTROL_LABELS: Required<
136
137
  Omit<HtmlSurfaceControlLabels, 'annotateLabel' | 'interactLabel'>
137
138
  > = {
138
- annotateTitle: 'Annotate mode: click an element or select text to comment. Esc to interact',
139
+ annotateTitle: `Annotate mode: click an element or select text to comment; links open (${modKey}-click one to comment on it). Esc to interact`,
139
140
  interactTitle: 'Interact mode: clicks reach the page (text selection still comments). Click to annotate',
140
141
  hideTools: 'Hide tools',
141
142
  showTools: 'Show tools',
@@ -15,6 +15,8 @@ interface ToolbarProps {
15
15
  onRedo?: () => void;
16
16
  onClear: () => void;
17
17
  onSave: () => void;
18
+ /** The key named in the ✓'s title: Escape by default; Enter where Escape cancels (`escapeCancels`). */
19
+ saveKey?: 'Escape' | 'Enter';
18
20
  }
19
21
 
20
22
  const PenIcon = () => (
@@ -85,6 +87,15 @@ const TOOLS: { id: Tool; icon: React.FC; label: string }[] = [
85
87
 
86
88
  const STROKE_SIZES = [3, 6, 10, 16, 24];
87
89
 
90
+ /** The colours' names, for a screen reader (the swatch shows only the colour). */
91
+ const COLOR_NAMES: Record<string, string> = {
92
+ '#ef4444': 'Red',
93
+ '#eab308': 'Yellow',
94
+ '#22c55e': 'Green',
95
+ '#3b82f6': 'Blue',
96
+ '#ffffff': 'White',
97
+ };
98
+
88
99
  export const Toolbar: React.FC<ToolbarProps> = ({
89
100
  tool,
90
101
  color,
@@ -98,6 +109,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
98
109
  onRedo,
99
110
  onClear,
100
111
  onSave,
112
+ saveKey = 'Escape',
101
113
  }) => {
102
114
  const currentSizeIndex = STROKE_SIZES.indexOf(strokeSize);
103
115
  const canDecrease = currentSizeIndex > 0;
@@ -121,6 +133,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
121
133
  type="button"
122
134
  onClick={() => onToolChange(id)}
123
135
  title={label}
136
+ aria-label={label}
137
+ aria-pressed={tool === id}
124
138
  className={`p-1.5 rounded transition-colors ${
125
139
  tool === id
126
140
  ? 'bg-primary text-primary-foreground'
@@ -141,6 +155,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
141
155
  onClick={() => canDecrease && onStrokeSizeChange(STROKE_SIZES[currentSizeIndex - 1])}
142
156
  disabled={!canDecrease}
143
157
  title="Smaller stroke"
158
+ aria-label="Smaller stroke"
144
159
  className={`p-1 rounded transition-colors ${
145
160
  canDecrease
146
161
  ? 'hover:bg-muted text-muted-foreground hover:text-foreground'
@@ -152,6 +167,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
152
167
  <div
153
168
  className="w-5 h-5 flex items-center justify-center"
154
169
  title={`Stroke size: ${strokeSize}`}
170
+ role="img"
171
+ aria-label={`Stroke size: ${strokeSize}`}
155
172
  >
156
173
  <div
157
174
  className="rounded-full"
@@ -167,6 +184,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
167
184
  onClick={() => canIncrease && onStrokeSizeChange(STROKE_SIZES[currentSizeIndex + 1])}
168
185
  disabled={!canIncrease}
169
186
  title="Larger stroke"
187
+ aria-label="Larger stroke"
170
188
  className={`p-1 rounded transition-colors ${
171
189
  canIncrease
172
190
  ? 'hover:bg-muted text-muted-foreground hover:text-foreground'
@@ -186,6 +204,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
186
204
  key={c}
187
205
  type="button"
188
206
  onClick={() => onColorChange(c)}
207
+ aria-label={`Colour: ${COLOR_NAMES[c] ?? c}`}
208
+ aria-pressed={color === c}
189
209
  className={`w-5 h-5 rounded-full border-2 transition-transform ${
190
210
  color === c ? 'scale-110 border-foreground' : 'border-transparent hover:scale-105'
191
211
  }`}
@@ -202,6 +222,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
202
222
  onClick={onUndo}
203
223
  disabled={!canUndo}
204
224
  title="Undo (Cmd+Z)"
225
+ aria-label="Undo"
205
226
  className={`p-1.5 rounded transition-colors ${
206
227
  canUndo
207
228
  ? 'hover:bg-muted text-muted-foreground hover:text-foreground'
@@ -217,6 +238,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
217
238
  onClick={onRedo}
218
239
  disabled={!canRedo}
219
240
  title="Redo (Cmd+Shift+Z)"
241
+ aria-label="Redo"
220
242
  className={`p-1.5 rounded transition-colors ${
221
243
  canRedo
222
244
  ? 'hover:bg-muted text-muted-foreground hover:text-foreground'
@@ -232,6 +254,7 @@ export const Toolbar: React.FC<ToolbarProps> = ({
232
254
  type="button"
233
255
  onClick={onClear}
234
256
  title="Clear all"
257
+ aria-label="Clear all"
235
258
  className="p-1.5 rounded transition-colors hover:bg-muted text-muted-foreground hover:text-destructive"
236
259
  >
237
260
  <ClearIcon />
@@ -245,7 +268,8 @@ export const Toolbar: React.FC<ToolbarProps> = ({
245
268
  <button
246
269
  type="button"
247
270
  onClick={onSave}
248
- title="Save (Esc)"
271
+ title={saveKey === 'Enter' ? 'Save (Enter)' : 'Save (Esc)'}
272
+ aria-label="Save"
249
273
  className="shrink-0 p-1.5 rounded transition-colors bg-success text-success-foreground hover:opacity-90"
250
274
  >
251
275
  <CheckIcon />