@plannotator/ui 0.30.0 → 0.32.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 (102) hide show
  1. package/README.md +46 -1
  2. package/components/ActionMenu.tsx +6 -1
  3. package/components/AgentsTab.tsx +8 -9
  4. package/components/AnalysisLayerToggle.tsx +48 -0
  5. package/components/AnnotationPanel.tsx +158 -36
  6. package/components/AnnotationToolbar.tsx +50 -30
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +238 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/GraphvizBlock.tsx +86 -7
  11. package/components/HtmlSurfaceControls.tsx +170 -0
  12. package/components/InlineMarkdown.tsx +25 -4
  13. package/components/KeyboardShortcuts.tsx +9 -0
  14. package/components/Landing.tsx +1 -1
  15. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  16. package/components/MarkdownEditor/embedPicker.ts +349 -0
  17. package/components/MarkdownEditor.tsx +12 -0
  18. package/components/MermaidBlock.tsx +60 -26
  19. package/components/ModeToggle.tsx +2 -1
  20. package/components/PermissionModeSetup.tsx +24 -5
  21. package/components/PinpointOverlay.tsx +11 -6
  22. package/components/PlanHeaderMenu.tsx +140 -1
  23. package/components/SearchableSelect.tsx +2 -0
  24. package/components/Settings.tsx +175 -10
  25. package/components/SkillReferenceMenu.tsx +9 -0
  26. package/components/StickyHeaderLane.tsx +9 -2
  27. package/components/TableOfContents.tsx +9 -4
  28. package/components/TextShimmer.tsx +8 -5
  29. package/components/ThemeProvider.tsx +43 -1
  30. package/components/ThemeTab.tsx +52 -1
  31. package/components/Tooltip.tsx +3 -1
  32. package/components/Viewer.tsx +19 -5
  33. package/components/VimTargetReticle.tsx +12 -4
  34. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  35. package/components/blocks/MathBlock.tsx +26 -14
  36. package/components/core/button.tsx +14 -6
  37. package/components/html-viewer/HtmlViewer.tsx +320 -41
  38. package/components/html-viewer/bridge-script.ts +360 -72
  39. package/components/html-viewer/composerYield.ts +1 -51
  40. package/components/html-viewer/hostThreads.ts +37 -0
  41. package/components/html-viewer/index.ts +9 -0
  42. package/components/html-viewer/unanchored.ts +47 -0
  43. package/components/html-viewer/useHtmlAnnotation.ts +240 -61
  44. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  45. package/components/sidebar/FileBrowser.tsx +17 -5
  46. package/components/sidebar/SidebarContainer.tsx +124 -28
  47. package/components/ui/button.tsx +10 -8
  48. package/components/ui/dialog.tsx +35 -25
  49. package/config/index.ts +6 -1
  50. package/config/reviewView.ts +42 -9
  51. package/config/settings.ts +141 -0
  52. package/configure.ts +32 -0
  53. package/hooks/useAIProviderConfig.ts +8 -7
  54. package/hooks/useActiveSection.ts +6 -4
  55. package/hooks/useAgentJobs.ts +3 -0
  56. package/hooks/useAnnotationHighlighter.ts +20 -0
  57. package/hooks/useHtmlRefresh.ts +149 -0
  58. package/hooks/useIsMobile.ts +37 -0
  59. package/hooks/useLinkedDoc.ts +7 -0
  60. package/hooks/useMathRenderer.ts +30 -0
  61. package/hooks/useScrollViewport.ts +74 -0
  62. package/hooks/useSharing.ts +31 -5
  63. package/hooks/useViewportEnvironment.ts +350 -0
  64. package/package.json +5 -2
  65. package/shortcuts/index.ts +3 -0
  66. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  67. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  68. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  69. package/styles.css +1 -1
  70. package/theme.css +229 -0
  71. package/types.ts +35 -0
  72. package/utils/annotateAgentTerminal.ts +36 -5
  73. package/utils/blockTargeting.ts +6 -3
  74. package/utils/composerYield.ts +45 -0
  75. package/utils/generateIdentity.ts +64 -14
  76. package/utils/htmlChrome.ts +20 -16
  77. package/utils/identity-tater.ts +36 -0
  78. package/utils/lookAndFeelAnnouncement.ts +12 -8
  79. package/utils/markdownExtensions.ts +57 -0
  80. package/utils/math-eager.ts +25 -0
  81. package/utils/math.ts +146 -0
  82. package/utils/mermaid-eager.ts +28 -0
  83. package/utils/mermaid.ts +132 -0
  84. package/utils/parser.ts +75 -2
  85. package/utils/quickLabels.ts +13 -0
  86. package/utils/vimNavigation.ts +4 -1
  87. package/utils/vimScroll.ts +9 -4
  88. package/utils/wideMode.ts +20 -0
  89. package/webmcp/activity.ts +46 -0
  90. package/webmcp/changes.ts +227 -0
  91. package/webmcp/index.ts +72 -0
  92. package/webmcp/modelContext.ts +103 -0
  93. package/webmcp/nudges.ts +174 -0
  94. package/webmcp/policy.ts +50 -0
  95. package/webmcp/preference.ts +50 -0
  96. package/webmcp/schema.ts +81 -0
  97. package/webmcp/toolset.ts +337 -0
  98. package/webmcp/useToolset.ts +74 -0
  99. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  100. package/components/VimModeAnnouncementDialog.tsx +0 -557
  101. package/utils/planAIAnnouncement.ts +0 -17
  102. package/utils/vimModeAnnouncement.ts +0 -23
@@ -25,6 +25,11 @@ interface AnnotationToolstripProps {
25
25
  * narrow and leaves room for the diff badges.
26
26
  */
27
27
  iconOnly?: boolean;
28
+ /**
29
+ * Hide the drag/pinpoint input group entirely. Live app sessions are
30
+ * pinpoint-only, so the switch would be a dead control there.
31
+ */
32
+ hideInputMethodSwitch?: boolean;
28
33
  }
29
34
 
30
35
  export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
@@ -36,6 +41,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
36
41
  compact = false,
37
42
  showHelpLink = true,
38
43
  iconOnly = false,
44
+ hideInputMethodSwitch = false,
39
45
  }) => {
40
46
  const [showHelp, setShowHelp] = useState(false);
41
47
  const [helpTab, setHelpTab] = useState<'selection' | 'plannotator'>('selection');
@@ -50,6 +56,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
50
56
  <>
51
57
  <div className={`flex items-center flex-wrap ${compact ? 'gap-1' : 'gap-1.5'}`}>
52
58
  {/* Input method group */}
59
+ {!hideInputMethodSwitch && (
53
60
  <div className="inline-flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5 border border-border/30">
54
61
  <ToolstripButton
55
62
  active={inputMethod === 'drag'}
@@ -89,6 +96,7 @@ export const AnnotationToolstrip: React.FC<AnnotationToolstripProps> = ({
89
96
  }
90
97
  />
91
98
  </div>
99
+ )}
92
100
 
93
101
  {/* Action mode group */}
94
102
  <div className="inline-flex items-center gap-0.5 bg-muted/50 rounded-lg p-0.5 border border-border/30">
@@ -321,6 +329,7 @@ const ToolstripButton: React.FC<{
321
329
  onClick={onClick}
322
330
  onMouseEnter={() => setHovered(true)}
323
331
  onMouseLeave={() => setHovered(false)}
332
+ aria-pressed={active}
324
333
  className={`relative flex items-center h-7 rounded-md overflow-hidden ${colorClass}`}
325
334
  style={{ width: currentWidth, transition }}
326
335
  >
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useRef, useCallback } from 'react';
1
+ import React, { useState, useEffect, useRef, useCallback, useId } from 'react';
2
2
  import { createPortal } from 'react-dom';
3
3
  import type { ImageAttachment } from '../types';
4
4
  import { AttachmentsButton } from './AttachmentsButton';
@@ -9,6 +9,12 @@ import { hasUnsavedCommentContent } from '../utils/commentContent';
9
9
  import { useSkillReferenceAutocomplete } from '../hooks/useSkillReferenceAutocomplete';
10
10
  import { HumanOnlySkillNotice, SkillReferenceMenu } from './SkillReferenceMenu';
11
11
  import type { SkillReferenceToken } from '../utils/skillReferences';
12
+ import {
13
+ hasPrimaryCoarsePointer,
14
+ shouldUseExpandedComposer,
15
+ useVisibleViewportBounds,
16
+ type VisibleViewportBounds,
17
+ } from '../hooks/useViewportEnvironment';
12
18
 
13
19
  export interface CommentAskAIContext {
14
20
  kind: 'general' | 'selection';
@@ -47,6 +53,14 @@ interface CommentPopoverProps {
47
53
  initialText?: string;
48
54
  /** Called on submit with comment text and optional images */
49
55
  onSubmit: (text: string, images?: ImageAttachment[]) => void;
56
+ /**
57
+ * One-click "Looks good" action (comment-only HTML/live surfaces, where
58
+ * pinpoint clicks open this composer directly and never see the selection
59
+ * toolbar's 👍). Renders a thumbs-up button in the footer; disabled once
60
+ * the user has typed or attached anything, so a click can never discard a
61
+ * draft. The parent owns annotation creation and closing.
62
+ */
63
+ onQuickLookGood?: () => void;
50
64
  /** Optional live draft observer for submit paths outside the popover. */
51
65
  onDraftChange?: (text: string, images?: ImageAttachment[]) => void;
52
66
  /** Called when popover is closed/cancelled */
@@ -99,19 +113,40 @@ function useCommentDraftSync(draftKey: string | undefined, text: string, images:
99
113
  }, [draftKey, text, images]);
100
114
  }
101
115
 
102
- function computePosition(anchorRect: DOMRect): { top: number; left: number; flipAbove: boolean; width: number } {
103
- const spaceBelow = window.innerHeight - anchorRect.bottom;
104
- const flipAbove = spaceBelow < 280;
105
- const width = Math.min(MAX_POPOVER_WIDTH, window.innerWidth - 32);
116
+ interface CommentPopoverPosition {
117
+ top: number;
118
+ left: number;
119
+ flipAbove: boolean;
120
+ width: number;
121
+ maxHeight: number;
122
+ requiresExpanded: boolean;
123
+ }
124
+
125
+ export function computeCommentPopoverPosition(
126
+ anchorRect: Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width'>,
127
+ bounds: VisibleViewportBounds,
128
+ ): CommentPopoverPosition {
129
+ const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP);
130
+ const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP);
131
+ const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
132
+ const width = Math.min(MAX_POPOVER_WIDTH, bounds.width);
106
133
 
107
134
  const top = flipAbove
108
135
  ? anchorRect.top - GAP
109
136
  : anchorRect.bottom + GAP;
110
137
 
111
138
  let left = anchorRect.left + anchorRect.width / 2 - width / 2;
112
- left = Math.max(16, Math.min(left, window.innerWidth - width - 16));
113
-
114
- return { top, left, flipAbove, width };
139
+ left = Math.max(bounds.left, Math.min(left, bounds.right - width));
140
+ const maxHeight = flipAbove ? spaceAbove : spaceBelow;
141
+
142
+ return {
143
+ top,
144
+ left,
145
+ flipAbove,
146
+ width,
147
+ maxHeight,
148
+ requiresExpanded: maxHeight < 280,
149
+ };
115
150
  }
116
151
 
117
152
  export const CommentPopover: React.FC<CommentPopoverProps> = ({
@@ -121,6 +156,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
121
156
  isGlobal,
122
157
  initialText = '',
123
158
  onSubmit,
159
+ onQuickLookGood,
124
160
  onDraftChange,
125
161
  onClose,
126
162
  draftKey,
@@ -137,11 +173,27 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
137
173
  captureStrayKeys = false,
138
174
  yieldState,
139
175
  }) => {
140
- const [mode, setMode] = useState<'popover' | 'dialog'>('popover');
176
+ const visibleBounds = useVisibleViewportBounds(16);
177
+ const coarsePointer = hasPrimaryCoarsePointer();
178
+ const prefersExpandedComposer = shouldUseExpandedComposer({
179
+ bounds: visibleBounds,
180
+ coarsePointer,
181
+ });
182
+ const [mode, setMode] = useState<'popover' | 'dialog'>(() =>
183
+ prefersExpandedComposer ? 'dialog' : 'popover'
184
+ );
185
+ // Dialog mode has two very different origins. Either the viewport PREFERS an
186
+ // expanded composer (phones, small windows), or the anchor simply has no room
187
+ // for a popover and the geometry FORCED it. Only the forced kind can bounce:
188
+ // collapsing recomputes the same geometry and immediately re-expands, which
189
+ // ate Escape and made the Collapse button a no-op. Track which one we are in.
190
+ const [dialogIsForced, setDialogIsForced] = useState(false);
191
+ // A preference-driven dialog is never the forced kind.
192
+ const forcedDialog = dialogIsForced && !prefersExpandedComposer;
141
193
  const initialDraft = draftKey ? draftStore.get(draftKey) : undefined;
142
194
  const [text, setText] = useState(initialDraft?.text ?? initialText);
143
195
  const [images, setImages] = useState<ImageAttachment[]>(allowImages ? initialDraft?.images ?? [] : []);
144
- const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean; width: number } | null>(null);
196
+ const [position, setPosition] = useState<CommentPopoverPosition | null>(null);
145
197
  // Direction of an open popover that has scrolled out of view, or null when on-screen.
146
198
  const [offscreen, setOffscreen] = useState<'above' | 'below' | null>(null);
147
199
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -150,6 +202,18 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
150
202
  const hasUnsavedContentRef = useRef(hasUnsavedContent);
151
203
  hasUnsavedContentRef.current = hasUnsavedContent;
152
204
  const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef);
205
+ const openingFocusRef = useRef<HTMLElement | null>(
206
+ typeof document !== 'undefined'
207
+ && document.activeElement instanceof HTMLElement
208
+ && document.activeElement !== document.body
209
+ && document.activeElement !== document.documentElement
210
+ ? document.activeElement
211
+ : anchorEl ?? null,
212
+ );
213
+
214
+ useEffect(() => {
215
+ if (prefersExpandedComposer && mode === 'popover') setMode('dialog');
216
+ }, [mode, prefersExpandedComposer]);
153
217
 
154
218
  useEffect(() => {
155
219
  const nextDraft = draftKey ? draftStore.get(draftKey) : undefined;
@@ -167,23 +231,29 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
167
231
  useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]);
168
232
  useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]);
169
233
 
170
- // Track anchor position on scroll/resize (popover mode only, not after user drag)
234
+ // Track anchor position on scroll and observed viewport changes (popover
235
+ // mode only, not after user drag).
171
236
  useEffect(() => {
172
237
  if (mode !== 'popover' || wasDragged) return;
173
238
 
174
239
  const update = () => {
175
240
  const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
176
- if (rect) setPosition(computePosition(rect));
241
+ if (!rect) return;
242
+ const nextPosition = computeCommentPopoverPosition(rect, visibleBounds);
243
+ if (nextPosition.requiresExpanded) {
244
+ setDialogIsForced(true);
245
+ setMode('dialog');
246
+ return;
247
+ }
248
+ setPosition(nextPosition);
177
249
  };
178
250
 
179
251
  update();
180
252
  window.addEventListener('scroll', update, true);
181
- window.addEventListener('resize', update);
182
253
  return () => {
183
254
  window.removeEventListener('scroll', update, true);
184
- window.removeEventListener('resize', update);
185
255
  };
186
- }, [anchorEl, anchorRect, mode, wasDragged]);
256
+ }, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]);
187
257
 
188
258
  // Surface a "jump back" arrow when an open popover scrolls out of view.
189
259
  // Re-measures whenever the popover repositions (position updates every scroll
@@ -194,14 +264,12 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
194
264
  const el = popoverRef.current;
195
265
  if (!el) return;
196
266
  const rect = el.getBoundingClientRect();
197
- if (rect.bottom < 8) setOffscreen('above');
198
- else if (rect.top > window.innerHeight - 8) setOffscreen('below');
267
+ if (rect.bottom < visibleBounds.top) setOffscreen('above');
268
+ else if (rect.top > visibleBounds.bottom) setOffscreen('below');
199
269
  else setOffscreen(null);
200
270
  };
201
271
  measure();
202
- window.addEventListener('resize', measure);
203
- return () => window.removeEventListener('resize', measure);
204
- }, [position, dragPosition, mode]);
272
+ }, [position, dragPosition, mode, visibleBounds]);
205
273
 
206
274
  const scrollToPopover = useCallback(() => {
207
275
  anchorEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
@@ -212,19 +280,56 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
212
280
  // renders after `position` is measured, and WebKit fires 0ms timers ahead of
213
281
  // that commit, so an effect keyed on mode alone can run before the textarea
214
282
  // exists and never focus it (e.g. in WKWebView hosts like Glimpse).
283
+ const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal;
215
284
  const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
216
285
  textareaRef.current = el;
217
- if (!el) return;
286
+ if (!el || !shouldAutoFocus) return;
218
287
  setTimeout(() => {
219
288
  if (!el.isConnected) return;
220
289
  el.focus();
221
290
  el.selectionStart = el.selectionEnd = el.value.length;
222
291
  }, 0);
292
+ }, [shouldAutoFocus]);
293
+
294
+ // A touch-opened composer deliberately does not focus the textarea (and
295
+ // summon the software keyboard), but hardware-keyboard users still need a
296
+ // coherent focus target and Escape dismissal.
297
+ useEffect(() => {
298
+ if (mode !== 'dialog' || shouldAutoFocus) return;
299
+ const timer = setTimeout(() => {
300
+ if (!popoverRef.current?.isConnected) return;
301
+ popoverRef.current.focus({ preventScroll: true });
302
+ }, 0);
303
+ return () => clearTimeout(timer);
304
+ }, [mode, shouldAutoFocus]);
305
+
306
+ const restoreOpeningFocus = useCallback(() => {
307
+ const target = openingFocusRef.current;
308
+ requestAnimationFrame(() => {
309
+ if (!target?.isConnected) return;
310
+ target.focus({ preventScroll: true });
311
+ });
223
312
  }, []);
224
313
 
314
+ const handleClose = useCallback(
315
+ (focusDisposition: 'restore-opener' | 'preserve-pointer-target' = 'restore-opener') => {
316
+ if (draftKey) {
317
+ if (hasUnsavedCommentContent(text, allowImages ? images : [])) {
318
+ draftStore.set(draftKey, { text, images: allowImages ? images : [] });
319
+ } else {
320
+ draftStore.delete(draftKey);
321
+ }
322
+ }
323
+ onClose();
324
+ if (focusDisposition === 'restore-opener') restoreOpeningFocus();
325
+ },
326
+ [allowImages, draftKey, images, onClose, restoreOpeningFocus, text],
327
+ );
328
+
225
329
  // Click-outside for popover mode
226
330
  useEffect(() => {
227
331
  if (mode !== 'popover') return;
332
+ const shiftSelectionActive = Boolean(targetChips?.length);
228
333
 
229
334
  const handlePointerDown = (e: PointerEvent) => {
230
335
  const target = e.target as Node | null;
@@ -234,12 +339,16 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
234
339
  const el = target as HTMLElement;
235
340
  if (el.closest?.('[data-popover-layer]')) return;
236
341
  if (hasUnsavedContentRef.current) return;
237
- onClose();
342
+ // A same-document multi-select target receives pointerdown before click.
343
+ // Preserve the existing draft so the following Shift-click can extend
344
+ // it instead of silently replacing it with a new one.
345
+ if (shiftSelectionActive && e.shiftKey) return;
346
+ handleClose('preserve-pointer-target');
238
347
  };
239
348
 
240
349
  document.addEventListener('pointerdown', handlePointerDown, true);
241
350
  return () => document.removeEventListener('pointerdown', handlePointerDown, true);
242
- }, [mode, onClose]);
351
+ }, [handleClose, mode, targetChips?.length]);
243
352
 
244
353
  // Focus choreography (multi-select): after a shift-click adds/removes a
245
354
  // target, focus returns to the textarea so typing continues uninterrupted.
@@ -347,8 +456,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
347
456
  if (hasUnsavedContent || canSubmitEmpty) {
348
457
  if (draftKey) draftStore.delete(draftKey);
349
458
  onSubmit(text, allowImages && images.length > 0 ? images : undefined);
459
+ restoreOpeningFocus();
350
460
  }
351
- }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent]);
461
+ }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus]);
352
462
 
353
463
  const handleAskAI = useCallback(async () => {
354
464
  const question = text.trim();
@@ -382,15 +492,24 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
382
492
  textareaRef,
383
493
  enabled: skillReferences,
384
494
  });
495
+ const skillListboxId = `skill-reference-listbox-${useId().replace(/:/g, '')}`;
496
+ const activeSkillOptionId =
497
+ skillAc.menu?.activeIndex === null || skillAc.menu?.activeIndex === undefined
498
+ ? undefined
499
+ : `${skillListboxId}-option-${skillAc.menu.activeIndex}`;
385
500
 
386
501
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
387
502
  if (skillAc.onKeyDown(e)) return;
388
503
  if (e.key === 'Escape') {
389
504
  e.stopPropagation();
390
505
  if (mode === 'dialog') {
391
- setMode('popover');
506
+ // Collapsing a forced dialog is geometrically impossible, so Escape
507
+ // closes it (draft-preserving) instead of being swallowed by the
508
+ // re-expand.
509
+ if (prefersExpandedComposer || forcedDialog) handleClose();
510
+ else setMode('popover');
392
511
  } else {
393
- onClose();
512
+ handleClose();
394
513
  }
395
514
  return;
396
515
  }
@@ -411,11 +530,34 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
411
530
  (allowEmptySubmit && initialText.trim().length > 0);
412
531
  const canAskAI = !!onAskAI && !askAIDisabled && text.trim().length > 0;
413
532
 
533
+ // Shared by both footers. Disabled once anything is typed or attached so a
534
+ // click can never discard a draft; with content present, Save is the path.
535
+ const quickLookGoodButton = onQuickLookGood ? (
536
+ <button
537
+ type="button"
538
+ onClick={onQuickLookGood}
539
+ disabled={hasUnsavedContent}
540
+ className="inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-md text-muted-foreground hover:text-foreground hover:bg-green-500/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
541
+ title={hasUnsavedContent ? 'Clear the comment to use Looks good' : 'Add "Looks good" without typing'}
542
+ >
543
+ <span aria-hidden="true">👍</span>
544
+ Looks good
545
+ </button>
546
+ ) : null;
547
+
414
548
  if (mode === 'dialog') {
415
549
  return createPortal(
416
- <div data-comment-popover="true" className="fixed inset-0 z-[100] flex items-center justify-center p-4">
550
+ <div
551
+ data-comment-popover="true"
552
+ className="pn-visible-viewport-overlay z-[100] flex items-center justify-center"
553
+ >
417
554
  {/* Backdrop */}
418
- <div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
555
+ <button
556
+ type="button"
557
+ aria-label="Dismiss comment"
558
+ className="absolute inset-0 bg-background/80 backdrop-blur-sm"
559
+ onClick={() => handleClose()}
560
+ />
419
561
 
420
562
  {/* Dialog card */}
421
563
  {/* The expanded dialog deliberately does not yield: it is an explicit
@@ -424,11 +566,21 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
424
566
  meaningless there. */}
425
567
  <div
426
568
  ref={popoverRef}
427
- className="relative w-full max-w-xl bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
569
+ role="dialog"
570
+ aria-modal="true"
571
+ aria-label={isGlobal ? 'Global comment' : 'Comment'}
572
+ tabIndex={-1}
573
+ className="relative w-full max-w-xl max-h-full min-h-0 bg-popover border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden"
428
574
  style={{
429
575
  animation: 'comment-dialog-in 0.15s ease-out',
430
576
  }}
431
577
  onPointerDown={(e) => e.stopPropagation()}
578
+ onKeyDown={(e) => {
579
+ if (e.key !== 'Escape') return;
580
+ e.preventDefault();
581
+ e.stopPropagation();
582
+ handleClose();
583
+ }}
432
584
  >
433
585
  <style>{`
434
586
  @keyframes comment-dialog-in {
@@ -443,15 +595,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
443
595
  {headerLabel}
444
596
  </span>
445
597
  <div className="flex items-center gap-1">
598
+ {!prefersExpandedComposer && !forcedDialog && (
599
+ <button
600
+ onClick={() => { setDialogIsForced(false); setMode('popover'); }}
601
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
602
+ title="Collapse"
603
+ >
604
+ <CollapseIcon />
605
+ </button>
606
+ )}
446
607
  <button
447
- onClick={() => setMode('popover')}
448
- className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
449
- title="Collapse"
450
- >
451
- <CollapseIcon />
452
- </button>
453
- <button
454
- onClick={onClose}
608
+ onClick={() => handleClose()}
455
609
  className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
456
610
  title="Close"
457
611
  >
@@ -463,9 +617,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
463
617
  {chipsRow}
464
618
 
465
619
  {/* Textarea */}
466
- <div className="relative px-4 py-3 flex-1">
620
+ <div className="relative px-4 py-3 min-h-0 flex-1 overflow-y-auto">
467
621
  {skillAc.menu && (
468
622
  <SkillReferenceMenu
623
+ id={skillListboxId}
469
624
  items={skillAc.menu.items}
470
625
  activeIndex={skillAc.menu.activeIndex}
471
626
  onSelect={skillAc.select}
@@ -478,16 +633,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
478
633
  onKeyDown={handleKeyDown}
479
634
  onSelectCaret={skillAc.onSelect}
480
635
  placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
481
- sizeClassName="min-h-48 max-h-96"
636
+ sizeClassName="min-h-32 max-h-full"
482
637
  skillReferences={skillReferences}
483
638
  tokens={skillAc.referenceTokens}
639
+ listboxId={skillListboxId}
640
+ listboxOpen={skillAc.menu !== null}
641
+ activeOptionId={activeSkillOptionId}
484
642
  />
485
643
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
486
644
  </div>
487
645
 
488
646
  {/* Footer — DOM order sets tab order (Save first); row-reverse keeps the visual layout unchanged */}
489
- <div className="flex flex-row-reverse items-center justify-between px-4 py-3 border-t border-border/50">
490
- <div className="flex flex-row-reverse items-center gap-3">
647
+ <div className="flex flex-row-reverse flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-border/50">
648
+ <div className="flex flex-row-reverse flex-wrap items-center gap-3">
491
649
  <button
492
650
  onClick={handleSubmit}
493
651
  disabled={!canSubmit}
@@ -495,7 +653,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
495
653
  >
496
654
  {isGlobal ? 'Add' : 'Save'}
497
655
  </button>
498
- <span className="text-[10px] text-muted-foreground">{submitHint}</span>
656
+ {!coarsePointer && (
657
+ <span className="text-[10px] text-muted-foreground">{submitHint}</span>
658
+ )}
659
+ {quickLookGoodButton}
499
660
  {onAskAI && (
500
661
  <button
501
662
  onClick={handleAskAI}
@@ -547,11 +708,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
547
708
  data-comment-popover="true"
548
709
  className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
549
710
  style={dragPosition
550
- ? { top: dragPosition.top, left: dragPosition.left, width: position.width }
711
+ ? {
712
+ top: dragPosition.top,
713
+ left: dragPosition.left,
714
+ width: position.width,
715
+ maxHeight: visibleBounds.height,
716
+ overflowY: 'auto',
717
+ }
551
718
  : {
552
719
  top: position.top,
553
720
  left: position.left,
554
721
  width: position.width,
722
+ maxHeight: position.maxHeight,
723
+ overflowY: 'auto',
555
724
  ...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
556
725
  animation: position.flipAbove
557
726
  ? 'comment-popover-in-above 0.15s ease-out'
@@ -579,14 +748,14 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
579
748
  </span>
580
749
  <div className="flex items-center gap-1">
581
750
  <button
582
- onClick={() => setMode('dialog')}
751
+ onClick={() => { setDialogIsForced(false); setMode('dialog'); }}
583
752
  className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
584
753
  title="Expand"
585
754
  >
586
755
  <ExpandIcon />
587
756
  </button>
588
757
  <button
589
- onClick={onClose}
758
+ onClick={() => handleClose()}
590
759
  className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
591
760
  title="Close"
592
761
  >
@@ -601,6 +770,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
601
770
  <div className="relative px-3 py-2">
602
771
  {skillAc.menu && (
603
772
  <SkillReferenceMenu
773
+ id={skillListboxId}
604
774
  items={skillAc.menu.items}
605
775
  activeIndex={skillAc.menu.activeIndex}
606
776
  onSelect={skillAc.select}
@@ -616,6 +786,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
616
786
  sizeClassName="max-h-64 min-h-[4.5rem]"
617
787
  skillReferences={skillReferences}
618
788
  tokens={skillAc.referenceTokens}
789
+ listboxId={skillListboxId}
790
+ listboxOpen={skillAc.menu !== null}
791
+ activeOptionId={activeSkillOptionId}
619
792
  />
620
793
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
621
794
  </div>
@@ -630,7 +803,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
630
803
  >
631
804
  {isGlobal ? 'Add' : 'Save'}
632
805
  </button>
633
- <span className="text-[10px] text-muted-foreground">{submitHint}</span>
806
+ {!coarsePointer && (
807
+ <span className="text-[10px] text-muted-foreground">{submitHint}</span>
808
+ )}
809
+ {quickLookGoodButton}
634
810
  {onAskAI && (
635
811
  <button
636
812
  onClick={handleAskAI}
@@ -682,6 +858,10 @@ interface ComposerTextareaProps {
682
858
  tokens: SkillReferenceToken[];
683
859
  /** Off → render the plain pre-feature textarea, byte-for-byte. */
684
860
  skillReferences: boolean;
861
+ /** ARIA relationship to the skill-reference listbox. */
862
+ listboxId: string;
863
+ listboxOpen: boolean;
864
+ activeOptionId?: string;
685
865
  }
686
866
 
687
867
  /**
@@ -706,6 +886,9 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
706
886
  textareaRef,
707
887
  tokens,
708
888
  skillReferences,
889
+ listboxId,
890
+ listboxOpen,
891
+ activeOptionId,
709
892
  }) => {
710
893
  const overlayRef = useRef<HTMLDivElement>(null);
711
894
  const [composing, setComposing] = useState(false);
@@ -735,6 +918,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
735
918
  if (!skillReferences) {
736
919
  return (
737
920
  <textarea
921
+ data-pn-mobile-editable="true"
738
922
  ref={attachRef}
739
923
  value={value}
740
924
  onChange={onChange}
@@ -780,6 +964,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
780
964
  ref={overlayRef}
781
965
  aria-hidden="true"
782
966
  data-skill-ref-overlay="true"
967
+ data-pn-mobile-editable-mirror="true"
783
968
  className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
784
969
  style={composing ? { visibility: 'hidden' } : undefined}
785
970
  >
@@ -787,6 +972,13 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
787
972
  {'\n'}
788
973
  </div>
789
974
  <textarea
975
+ data-pn-mobile-editable="true"
976
+ aria-label={placeholder}
977
+ aria-autocomplete="list"
978
+ aria-haspopup="listbox"
979
+ aria-controls={listboxOpen ? listboxId : undefined}
980
+ aria-owns={listboxOpen ? listboxId : undefined}
981
+ aria-activedescendant={activeOptionId}
790
982
  ref={attachRef}
791
983
  value={value}
792
984
  onChange={onChange}