@plannotator/ui 0.29.1 → 0.31.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 (74) hide show
  1. package/README.md +1 -0
  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 +68 -35
  6. package/components/AnnotationToolbar.tsx +28 -8
  7. package/components/AnnotationToolstrip.tsx +9 -0
  8. package/components/CommentPopover.tsx +212 -46
  9. package/components/ConfirmDialog.tsx +42 -28
  10. package/components/InlineMarkdown.tsx +3 -2
  11. package/components/KeyboardShortcuts.tsx +9 -0
  12. package/components/Landing.tsx +1 -1
  13. package/components/LookAndFeelAnnouncementDialog.tsx +147 -178
  14. package/components/MarkdownEditor/embedPicker.ts +349 -0
  15. package/components/MarkdownEditor.tsx +12 -0
  16. package/components/ModeToggle.tsx +2 -1
  17. package/components/PermissionModeSetup.tsx +24 -5
  18. package/components/PinpointOverlay.tsx +11 -6
  19. package/components/PlanHeaderMenu.tsx +140 -1
  20. package/components/SearchableSelect.tsx +2 -0
  21. package/components/Settings.tsx +136 -10
  22. package/components/SkillReferenceMenu.tsx +9 -0
  23. package/components/StickyHeaderLane.tsx +9 -2
  24. package/components/TableOfContents.tsx +9 -4
  25. package/components/TextShimmer.tsx +8 -5
  26. package/components/ThemeProvider.tsx +43 -1
  27. package/components/ThemeTab.tsx +52 -1
  28. package/components/Tooltip.tsx +3 -1
  29. package/components/Viewer.tsx +19 -5
  30. package/components/VimTargetReticle.tsx +12 -4
  31. package/components/ai/DocumentAIChatPanel.tsx +1 -0
  32. package/components/core/button.tsx +14 -6
  33. package/components/html-viewer/HtmlViewer.tsx +215 -39
  34. package/components/html-viewer/bridge-script.ts +384 -66
  35. package/components/html-viewer/composerYield.ts +1 -51
  36. package/components/html-viewer/useHtmlAnnotation.ts +179 -58
  37. package/components/plan-diff/PlanCleanDiffView.tsx +1 -0
  38. package/components/sidebar/FileBrowser.tsx +17 -5
  39. package/components/sidebar/SidebarContainer.tsx +124 -28
  40. package/components/ui/button.tsx +10 -8
  41. package/components/ui/dialog.tsx +35 -25
  42. package/config/index.ts +6 -1
  43. package/config/reviewView.ts +42 -9
  44. package/config/settings.ts +141 -0
  45. package/hooks/useAIProviderConfig.ts +8 -7
  46. package/hooks/useActiveSection.ts +6 -4
  47. package/hooks/useAgentJobs.ts +3 -0
  48. package/hooks/useAnnotationHighlighter.ts +20 -0
  49. package/hooks/useIsMobile.ts +37 -0
  50. package/hooks/useLinkedDoc.ts +7 -0
  51. package/hooks/useScrollViewport.ts +74 -0
  52. package/hooks/useViewportEnvironment.ts +350 -0
  53. package/package.json +3 -2
  54. package/shortcuts/index.ts +3 -0
  55. package/shortcuts/plan-review/annotationMode.shortcuts.ts +91 -0
  56. package/shortcuts/plan-review/documentView.shortcuts.ts +26 -0
  57. package/shortcuts/plan-review/htmlAnnotate.shortcuts.ts +24 -0
  58. package/styles.css +1 -1
  59. package/theme.css +229 -0
  60. package/types.ts +34 -0
  61. package/utils/annotateAgentTerminal.ts +36 -5
  62. package/utils/blockTargeting.ts +6 -3
  63. package/utils/composerYield.ts +45 -0
  64. package/utils/htmlChrome.ts +20 -16
  65. package/utils/lookAndFeelAnnouncement.ts +12 -8
  66. package/utils/markdownExtensions.ts +57 -0
  67. package/utils/parser.ts +37 -2
  68. package/utils/vimNavigation.ts +4 -1
  69. package/utils/vimScroll.ts +9 -4
  70. package/utils/wideMode.ts +20 -0
  71. package/components/PlanAIAnnouncementDialog.tsx +0 -187
  72. package/components/VimModeAnnouncementDialog.tsx +0 -557
  73. package/utils/planAIAnnouncement.ts +0 -17
  74. package/utils/vimModeAnnouncement.ts +0 -23
@@ -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';
@@ -99,19 +105,40 @@ function useCommentDraftSync(draftKey: string | undefined, text: string, images:
99
105
  }, [draftKey, text, images]);
100
106
  }
101
107
 
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);
108
+ interface CommentPopoverPosition {
109
+ top: number;
110
+ left: number;
111
+ flipAbove: boolean;
112
+ width: number;
113
+ maxHeight: number;
114
+ requiresExpanded: boolean;
115
+ }
116
+
117
+ export function computeCommentPopoverPosition(
118
+ anchorRect: Pick<DOMRect, 'top' | 'right' | 'bottom' | 'left' | 'width'>,
119
+ bounds: VisibleViewportBounds,
120
+ ): CommentPopoverPosition {
121
+ const spaceBelow = Math.max(0, bounds.bottom - anchorRect.bottom - GAP);
122
+ const spaceAbove = Math.max(0, anchorRect.top - bounds.top - GAP);
123
+ const flipAbove = spaceBelow < 280 && spaceAbove > spaceBelow;
124
+ const width = Math.min(MAX_POPOVER_WIDTH, bounds.width);
106
125
 
107
126
  const top = flipAbove
108
127
  ? anchorRect.top - GAP
109
128
  : anchorRect.bottom + GAP;
110
129
 
111
130
  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 };
131
+ left = Math.max(bounds.left, Math.min(left, bounds.right - width));
132
+ const maxHeight = flipAbove ? spaceAbove : spaceBelow;
133
+
134
+ return {
135
+ top,
136
+ left,
137
+ flipAbove,
138
+ width,
139
+ maxHeight,
140
+ requiresExpanded: maxHeight < 280,
141
+ };
115
142
  }
116
143
 
117
144
  export const CommentPopover: React.FC<CommentPopoverProps> = ({
@@ -137,11 +164,27 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
137
164
  captureStrayKeys = false,
138
165
  yieldState,
139
166
  }) => {
140
- const [mode, setMode] = useState<'popover' | 'dialog'>('popover');
167
+ const visibleBounds = useVisibleViewportBounds(16);
168
+ const coarsePointer = hasPrimaryCoarsePointer();
169
+ const prefersExpandedComposer = shouldUseExpandedComposer({
170
+ bounds: visibleBounds,
171
+ coarsePointer,
172
+ });
173
+ const [mode, setMode] = useState<'popover' | 'dialog'>(() =>
174
+ prefersExpandedComposer ? 'dialog' : 'popover'
175
+ );
176
+ // Dialog mode has two very different origins. Either the viewport PREFERS an
177
+ // expanded composer (phones, small windows), or the anchor simply has no room
178
+ // for a popover and the geometry FORCED it. Only the forced kind can bounce:
179
+ // collapsing recomputes the same geometry and immediately re-expands, which
180
+ // ate Escape and made the Collapse button a no-op. Track which one we are in.
181
+ const [dialogIsForced, setDialogIsForced] = useState(false);
182
+ // A preference-driven dialog is never the forced kind.
183
+ const forcedDialog = dialogIsForced && !prefersExpandedComposer;
141
184
  const initialDraft = draftKey ? draftStore.get(draftKey) : undefined;
142
185
  const [text, setText] = useState(initialDraft?.text ?? initialText);
143
186
  const [images, setImages] = useState<ImageAttachment[]>(allowImages ? initialDraft?.images ?? [] : []);
144
- const [position, setPosition] = useState<{ top: number; left: number; flipAbove: boolean; width: number } | null>(null);
187
+ const [position, setPosition] = useState<CommentPopoverPosition | null>(null);
145
188
  // Direction of an open popover that has scrolled out of view, or null when on-screen.
146
189
  const [offscreen, setOffscreen] = useState<'above' | 'below' | null>(null);
147
190
  const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -150,6 +193,18 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
150
193
  const hasUnsavedContentRef = useRef(hasUnsavedContent);
151
194
  hasUnsavedContentRef.current = hasUnsavedContent;
152
195
  const { dragPosition, dragHandleProps, wasDragged, reset: resetDrag } = useDraggable(popoverRef);
196
+ const openingFocusRef = useRef<HTMLElement | null>(
197
+ typeof document !== 'undefined'
198
+ && document.activeElement instanceof HTMLElement
199
+ && document.activeElement !== document.body
200
+ && document.activeElement !== document.documentElement
201
+ ? document.activeElement
202
+ : anchorEl ?? null,
203
+ );
204
+
205
+ useEffect(() => {
206
+ if (prefersExpandedComposer && mode === 'popover') setMode('dialog');
207
+ }, [mode, prefersExpandedComposer]);
153
208
 
154
209
  useEffect(() => {
155
210
  const nextDraft = draftKey ? draftStore.get(draftKey) : undefined;
@@ -167,23 +222,29 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
167
222
  useEffect(() => { resetDrag(); }, [anchorEl, anchorRect, resetDrag]);
168
223
  useEffect(() => { if (mode === 'popover') resetDrag(); }, [mode, resetDrag]);
169
224
 
170
- // Track anchor position on scroll/resize (popover mode only, not after user drag)
225
+ // Track anchor position on scroll and observed viewport changes (popover
226
+ // mode only, not after user drag).
171
227
  useEffect(() => {
172
228
  if (mode !== 'popover' || wasDragged) return;
173
229
 
174
230
  const update = () => {
175
231
  const rect = anchorEl?.getBoundingClientRect() ?? anchorRect;
176
- if (rect) setPosition(computePosition(rect));
232
+ if (!rect) return;
233
+ const nextPosition = computeCommentPopoverPosition(rect, visibleBounds);
234
+ if (nextPosition.requiresExpanded) {
235
+ setDialogIsForced(true);
236
+ setMode('dialog');
237
+ return;
238
+ }
239
+ setPosition(nextPosition);
177
240
  };
178
241
 
179
242
  update();
180
243
  window.addEventListener('scroll', update, true);
181
- window.addEventListener('resize', update);
182
244
  return () => {
183
245
  window.removeEventListener('scroll', update, true);
184
- window.removeEventListener('resize', update);
185
246
  };
186
- }, [anchorEl, anchorRect, mode, wasDragged]);
247
+ }, [anchorEl, anchorRect, mode, visibleBounds, wasDragged]);
187
248
 
188
249
  // Surface a "jump back" arrow when an open popover scrolls out of view.
189
250
  // Re-measures whenever the popover repositions (position updates every scroll
@@ -194,14 +255,12 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
194
255
  const el = popoverRef.current;
195
256
  if (!el) return;
196
257
  const rect = el.getBoundingClientRect();
197
- if (rect.bottom < 8) setOffscreen('above');
198
- else if (rect.top > window.innerHeight - 8) setOffscreen('below');
258
+ if (rect.bottom < visibleBounds.top) setOffscreen('above');
259
+ else if (rect.top > visibleBounds.bottom) setOffscreen('below');
199
260
  else setOffscreen(null);
200
261
  };
201
262
  measure();
202
- window.addEventListener('resize', measure);
203
- return () => window.removeEventListener('resize', measure);
204
- }, [position, dragPosition, mode]);
263
+ }, [position, dragPosition, mode, visibleBounds]);
205
264
 
206
265
  const scrollToPopover = useCallback(() => {
207
266
  anchorEl?.scrollIntoView({ block: 'center', behavior: 'smooth' });
@@ -212,19 +271,56 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
212
271
  // renders after `position` is measured, and WebKit fires 0ms timers ahead of
213
272
  // that commit, so an effect keyed on mode alone can run before the textarea
214
273
  // exists and never focus it (e.g. in WKWebView hosts like Glimpse).
274
+ const shouldAutoFocus = !coarsePointer || initialText.length > 0 || isGlobal;
215
275
  const focusOnMountRef = useCallback((el: HTMLTextAreaElement | null) => {
216
276
  textareaRef.current = el;
217
- if (!el) return;
277
+ if (!el || !shouldAutoFocus) return;
218
278
  setTimeout(() => {
219
279
  if (!el.isConnected) return;
220
280
  el.focus();
221
281
  el.selectionStart = el.selectionEnd = el.value.length;
222
282
  }, 0);
283
+ }, [shouldAutoFocus]);
284
+
285
+ // A touch-opened composer deliberately does not focus the textarea (and
286
+ // summon the software keyboard), but hardware-keyboard users still need a
287
+ // coherent focus target and Escape dismissal.
288
+ useEffect(() => {
289
+ if (mode !== 'dialog' || shouldAutoFocus) return;
290
+ const timer = setTimeout(() => {
291
+ if (!popoverRef.current?.isConnected) return;
292
+ popoverRef.current.focus({ preventScroll: true });
293
+ }, 0);
294
+ return () => clearTimeout(timer);
295
+ }, [mode, shouldAutoFocus]);
296
+
297
+ const restoreOpeningFocus = useCallback(() => {
298
+ const target = openingFocusRef.current;
299
+ requestAnimationFrame(() => {
300
+ if (!target?.isConnected) return;
301
+ target.focus({ preventScroll: true });
302
+ });
223
303
  }, []);
224
304
 
305
+ const handleClose = useCallback(
306
+ (focusDisposition: 'restore-opener' | 'preserve-pointer-target' = 'restore-opener') => {
307
+ if (draftKey) {
308
+ if (hasUnsavedCommentContent(text, allowImages ? images : [])) {
309
+ draftStore.set(draftKey, { text, images: allowImages ? images : [] });
310
+ } else {
311
+ draftStore.delete(draftKey);
312
+ }
313
+ }
314
+ onClose();
315
+ if (focusDisposition === 'restore-opener') restoreOpeningFocus();
316
+ },
317
+ [allowImages, draftKey, images, onClose, restoreOpeningFocus, text],
318
+ );
319
+
225
320
  // Click-outside for popover mode
226
321
  useEffect(() => {
227
322
  if (mode !== 'popover') return;
323
+ const shiftSelectionActive = Boolean(targetChips?.length);
228
324
 
229
325
  const handlePointerDown = (e: PointerEvent) => {
230
326
  const target = e.target as Node | null;
@@ -234,12 +330,16 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
234
330
  const el = target as HTMLElement;
235
331
  if (el.closest?.('[data-popover-layer]')) return;
236
332
  if (hasUnsavedContentRef.current) return;
237
- onClose();
333
+ // A same-document multi-select target receives pointerdown before click.
334
+ // Preserve the existing draft so the following Shift-click can extend
335
+ // it instead of silently replacing it with a new one.
336
+ if (shiftSelectionActive && e.shiftKey) return;
337
+ handleClose('preserve-pointer-target');
238
338
  };
239
339
 
240
340
  document.addEventListener('pointerdown', handlePointerDown, true);
241
341
  return () => document.removeEventListener('pointerdown', handlePointerDown, true);
242
- }, [mode, onClose]);
342
+ }, [handleClose, mode, targetChips?.length]);
243
343
 
244
344
  // Focus choreography (multi-select): after a shift-click adds/removes a
245
345
  // target, focus returns to the textarea so typing continues uninterrupted.
@@ -347,8 +447,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
347
447
  if (hasUnsavedContent || canSubmitEmpty) {
348
448
  if (draftKey) draftStore.delete(draftKey);
349
449
  onSubmit(text, allowImages && images.length > 0 ? images : undefined);
450
+ restoreOpeningFocus();
350
451
  }
351
- }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent]);
452
+ }, [text, images, onSubmit, draftKey, allowImages, allowEmptySubmit, initialText, hasUnsavedContent, restoreOpeningFocus]);
352
453
 
353
454
  const handleAskAI = useCallback(async () => {
354
455
  const question = text.trim();
@@ -382,15 +483,24 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
382
483
  textareaRef,
383
484
  enabled: skillReferences,
384
485
  });
486
+ const skillListboxId = `skill-reference-listbox-${useId().replace(/:/g, '')}`;
487
+ const activeSkillOptionId =
488
+ skillAc.menu?.activeIndex === null || skillAc.menu?.activeIndex === undefined
489
+ ? undefined
490
+ : `${skillListboxId}-option-${skillAc.menu.activeIndex}`;
385
491
 
386
492
  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
387
493
  if (skillAc.onKeyDown(e)) return;
388
494
  if (e.key === 'Escape') {
389
495
  e.stopPropagation();
390
496
  if (mode === 'dialog') {
391
- setMode('popover');
497
+ // Collapsing a forced dialog is geometrically impossible, so Escape
498
+ // closes it (draft-preserving) instead of being swallowed by the
499
+ // re-expand.
500
+ if (prefersExpandedComposer || forcedDialog) handleClose();
501
+ else setMode('popover');
392
502
  } else {
393
- onClose();
503
+ handleClose();
394
504
  }
395
505
  return;
396
506
  }
@@ -413,9 +523,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
413
523
 
414
524
  if (mode === 'dialog') {
415
525
  return createPortal(
416
- <div data-comment-popover="true" className="fixed inset-0 z-[100] flex items-center justify-center p-4">
526
+ <div
527
+ data-comment-popover="true"
528
+ className="pn-visible-viewport-overlay z-[100] flex items-center justify-center"
529
+ >
417
530
  {/* Backdrop */}
418
- <div className="absolute inset-0 bg-background/80 backdrop-blur-sm" />
531
+ <button
532
+ type="button"
533
+ aria-label="Dismiss comment"
534
+ className="absolute inset-0 bg-background/80 backdrop-blur-sm"
535
+ onClick={() => handleClose()}
536
+ />
419
537
 
420
538
  {/* Dialog card */}
421
539
  {/* The expanded dialog deliberately does not yield: it is an explicit
@@ -424,11 +542,21 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
424
542
  meaningless there. */}
425
543
  <div
426
544
  ref={popoverRef}
427
- className="relative w-full max-w-xl bg-popover border border-border rounded-xl shadow-2xl flex flex-col"
545
+ role="dialog"
546
+ aria-modal="true"
547
+ aria-label={isGlobal ? 'Global comment' : 'Comment'}
548
+ tabIndex={-1}
549
+ 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
550
  style={{
429
551
  animation: 'comment-dialog-in 0.15s ease-out',
430
552
  }}
431
553
  onPointerDown={(e) => e.stopPropagation()}
554
+ onKeyDown={(e) => {
555
+ if (e.key !== 'Escape') return;
556
+ e.preventDefault();
557
+ e.stopPropagation();
558
+ handleClose();
559
+ }}
432
560
  >
433
561
  <style>{`
434
562
  @keyframes comment-dialog-in {
@@ -443,15 +571,17 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
443
571
  {headerLabel}
444
572
  </span>
445
573
  <div className="flex items-center gap-1">
574
+ {!prefersExpandedComposer && !forcedDialog && (
575
+ <button
576
+ onClick={() => { setDialogIsForced(false); setMode('popover'); }}
577
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
578
+ title="Collapse"
579
+ >
580
+ <CollapseIcon />
581
+ </button>
582
+ )}
446
583
  <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}
584
+ onClick={() => handleClose()}
455
585
  className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
456
586
  title="Close"
457
587
  >
@@ -463,9 +593,10 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
463
593
  {chipsRow}
464
594
 
465
595
  {/* Textarea */}
466
- <div className="relative px-4 py-3 flex-1">
596
+ <div className="relative px-4 py-3 min-h-0 flex-1 overflow-y-auto">
467
597
  {skillAc.menu && (
468
598
  <SkillReferenceMenu
599
+ id={skillListboxId}
469
600
  items={skillAc.menu.items}
470
601
  activeIndex={skillAc.menu.activeIndex}
471
602
  onSelect={skillAc.select}
@@ -478,16 +609,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
478
609
  onKeyDown={handleKeyDown}
479
610
  onSelectCaret={skillAc.onSelect}
480
611
  placeholder={isGlobal ? 'Add a global comment...' : 'Add a comment...'}
481
- sizeClassName="min-h-48 max-h-96"
612
+ sizeClassName="min-h-32 max-h-full"
482
613
  skillReferences={skillReferences}
483
614
  tokens={skillAc.referenceTokens}
615
+ listboxId={skillListboxId}
616
+ listboxOpen={skillAc.menu !== null}
617
+ activeOptionId={activeSkillOptionId}
484
618
  />
485
619
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
486
620
  </div>
487
621
 
488
622
  {/* 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">
623
+ <div className="flex flex-row-reverse flex-wrap items-center justify-between gap-2 px-4 py-3 border-t border-border/50">
624
+ <div className="flex flex-row-reverse flex-wrap items-center gap-3">
491
625
  <button
492
626
  onClick={handleSubmit}
493
627
  disabled={!canSubmit}
@@ -495,7 +629,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
495
629
  >
496
630
  {isGlobal ? 'Add' : 'Save'}
497
631
  </button>
498
- <span className="text-[10px] text-muted-foreground">{submitHint}</span>
632
+ {!coarsePointer && (
633
+ <span className="text-[10px] text-muted-foreground">{submitHint}</span>
634
+ )}
499
635
  {onAskAI && (
500
636
  <button
501
637
  onClick={handleAskAI}
@@ -547,11 +683,19 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
547
683
  data-comment-popover="true"
548
684
  className={`fixed z-[100] bg-popover border border-border rounded-xl shadow-2xl flex flex-col${yieldClass}`}
549
685
  style={dragPosition
550
- ? { top: dragPosition.top, left: dragPosition.left, width: position.width }
686
+ ? {
687
+ top: dragPosition.top,
688
+ left: dragPosition.left,
689
+ width: position.width,
690
+ maxHeight: visibleBounds.height,
691
+ overflowY: 'auto',
692
+ }
551
693
  : {
552
694
  top: position.top,
553
695
  left: position.left,
554
696
  width: position.width,
697
+ maxHeight: position.maxHeight,
698
+ overflowY: 'auto',
555
699
  ...(position.flipAbove ? { transform: 'translateY(-100%)' } : {}),
556
700
  animation: position.flipAbove
557
701
  ? 'comment-popover-in-above 0.15s ease-out'
@@ -579,14 +723,14 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
579
723
  </span>
580
724
  <div className="flex items-center gap-1">
581
725
  <button
582
- onClick={() => setMode('dialog')}
726
+ onClick={() => { setDialogIsForced(false); setMode('dialog'); }}
583
727
  className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
584
728
  title="Expand"
585
729
  >
586
730
  <ExpandIcon />
587
731
  </button>
588
732
  <button
589
- onClick={onClose}
733
+ onClick={() => handleClose()}
590
734
  className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
591
735
  title="Close"
592
736
  >
@@ -601,6 +745,7 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
601
745
  <div className="relative px-3 py-2">
602
746
  {skillAc.menu && (
603
747
  <SkillReferenceMenu
748
+ id={skillListboxId}
604
749
  items={skillAc.menu.items}
605
750
  activeIndex={skillAc.menu.activeIndex}
606
751
  onSelect={skillAc.select}
@@ -616,6 +761,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
616
761
  sizeClassName="max-h-64 min-h-[4.5rem]"
617
762
  skillReferences={skillReferences}
618
763
  tokens={skillAc.referenceTokens}
764
+ listboxId={skillListboxId}
765
+ listboxOpen={skillAc.menu !== null}
766
+ activeOptionId={activeSkillOptionId}
619
767
  />
620
768
  <HumanOnlySkillNotice skills={skillAc.humanOnlyReferences} />
621
769
  </div>
@@ -630,7 +778,9 @@ export const CommentPopover: React.FC<CommentPopoverProps> = ({
630
778
  >
631
779
  {isGlobal ? 'Add' : 'Save'}
632
780
  </button>
633
- <span className="text-[10px] text-muted-foreground">{submitHint}</span>
781
+ {!coarsePointer && (
782
+ <span className="text-[10px] text-muted-foreground">{submitHint}</span>
783
+ )}
634
784
  {onAskAI && (
635
785
  <button
636
786
  onClick={handleAskAI}
@@ -682,6 +832,10 @@ interface ComposerTextareaProps {
682
832
  tokens: SkillReferenceToken[];
683
833
  /** Off → render the plain pre-feature textarea, byte-for-byte. */
684
834
  skillReferences: boolean;
835
+ /** ARIA relationship to the skill-reference listbox. */
836
+ listboxId: string;
837
+ listboxOpen: boolean;
838
+ activeOptionId?: string;
685
839
  }
686
840
 
687
841
  /**
@@ -706,6 +860,9 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
706
860
  textareaRef,
707
861
  tokens,
708
862
  skillReferences,
863
+ listboxId,
864
+ listboxOpen,
865
+ activeOptionId,
709
866
  }) => {
710
867
  const overlayRef = useRef<HTMLDivElement>(null);
711
868
  const [composing, setComposing] = useState(false);
@@ -735,6 +892,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
735
892
  if (!skillReferences) {
736
893
  return (
737
894
  <textarea
895
+ data-pn-mobile-editable="true"
738
896
  ref={attachRef}
739
897
  value={value}
740
898
  onChange={onChange}
@@ -780,6 +938,7 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
780
938
  ref={overlayRef}
781
939
  aria-hidden="true"
782
940
  data-skill-ref-overlay="true"
941
+ data-pn-mobile-editable-mirror="true"
783
942
  className={`${COMPOSER_TEXT_CLASSES} ${sizeClassName} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`}
784
943
  style={composing ? { visibility: 'hidden' } : undefined}
785
944
  >
@@ -787,6 +946,13 @@ const ComposerTextarea: React.FC<ComposerTextareaProps> = ({
787
946
  {'\n'}
788
947
  </div>
789
948
  <textarea
949
+ data-pn-mobile-editable="true"
950
+ aria-label={placeholder}
951
+ aria-autocomplete="list"
952
+ aria-haspopup="listbox"
953
+ aria-controls={listboxOpen ? listboxId : undefined}
954
+ aria-owns={listboxOpen ? listboxId : undefined}
955
+ aria-activedescendant={activeOptionId}
790
956
  ref={attachRef}
791
957
  value={value}
792
958
  onChange={onChange}
@@ -2,7 +2,8 @@
2
2
  * Reusable confirmation dialog component
3
3
  */
4
4
 
5
- import React, { useEffect } from 'react';
5
+ import React, { useEffect, useId, useRef } from 'react';
6
+ import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
6
7
 
7
8
  export interface ConfirmDialogProps {
8
9
  isOpen: boolean;
@@ -31,6 +32,10 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
31
32
  showCancel = false,
32
33
  wide = false,
33
34
  }) => {
35
+ const descriptionId = useId();
36
+ const cancelButtonRef = useRef<HTMLButtonElement>(null);
37
+ const confirmButtonRef = useRef<HTMLButtonElement>(null);
38
+
34
39
  useEffect(() => {
35
40
  if (!isOpen) return;
36
41
  const handleKey = (event: KeyboardEvent) => {
@@ -40,11 +45,6 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
40
45
  if (onConfirm) onConfirm();
41
46
  else onClose();
42
47
  }
43
- if (event.key === 'Escape') {
44
- event.preventDefault();
45
- event.stopPropagation();
46
- onClose();
47
- }
48
48
  };
49
49
  window.addEventListener('keydown', handleKey);
50
50
  return () => window.removeEventListener('keydown', handleKey);
@@ -58,8 +58,8 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
58
58
  };
59
59
 
60
60
  const buttonColors = {
61
- info: 'bg-primary text-primary-foreground hover:opacity-90',
62
- warning: 'bg-warning text-warning-foreground hover:opacity-90',
61
+ info: 'bg-primary text-primary-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
62
+ warning: 'bg-warning text-warning-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
63
63
  };
64
64
 
65
65
  const icons = {
@@ -76,40 +76,54 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
76
76
  };
77
77
 
78
78
  return (
79
- <div
80
- className="fixed inset-0 z-[100] flex items-center justify-center bg-background/80 backdrop-blur-sm p-4"
81
- data-plannotator-confirm-dialog="true"
79
+ <Dialog
80
+ open={isOpen}
81
+ disablePointerDismissal
82
+ onOpenChange={(open) => {
83
+ if (!open) onClose();
84
+ }}
82
85
  >
83
- <div
84
- role="dialog"
85
- aria-modal="true"
86
- className={`bg-card border border-border rounded-xl w-full shadow-2xl p-6 ${wide ? 'max-w-md' : 'max-w-sm'}`}
86
+ <DialogContent
87
+ hideClose
88
+ aria-describedby={descriptionId}
89
+ initialFocus={() => (showCancel ? cancelButtonRef.current : confirmButtonRef.current)}
90
+ backdropClassName="bg-background/80 backdrop-blur-sm"
91
+ className={`bg-card text-foreground rounded-xl shadow-2xl p-6 transition-none ${wide ? 'max-w-md' : 'max-w-sm'}`}
92
+ data-plannotator-confirm-dialog="true"
87
93
  >
88
94
  <div className="flex items-center gap-3 mb-4">
89
- <div className={`w-10 h-10 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
95
+ <div className={`w-10 h-10 shrink-0 rounded-full flex items-center justify-center ${iconColors[variant]}`}>
90
96
  {icons[variant]}
91
97
  </div>
92
- <h3 className="font-semibold">{title}</h3>
98
+ <DialogTitle className="font-semibold tracking-normal">{title}</DialogTitle>
93
99
  </div>
94
- <div className="text-sm text-muted-foreground mb-2">
95
- {message}
96
- </div>
97
- {subMessage && (
98
- <div className="text-xs text-muted-foreground mb-6">
99
- {subMessage}
100
+ <div id={descriptionId}>
101
+ <div className="text-sm text-muted-foreground mb-2">
102
+ {message}
100
103
  </div>
101
- )}
102
- {!subMessage && <div className="mb-4" />}
104
+ {subMessage && (
105
+ <div className="text-xs text-muted-foreground mb-6">
106
+ {subMessage}
107
+ </div>
108
+ )}
109
+ {!subMessage && <div className="mb-4" />}
110
+ </div>
103
111
  <div className="flex justify-end gap-2">
104
112
  {showCancel && (
105
113
  <button
114
+ ref={cancelButtonRef}
115
+ type="button"
116
+ data-pn-touch-target="true"
106
117
  onClick={onClose}
107
- className="px-4 py-2 rounded-md text-sm font-medium bg-muted text-muted-foreground hover:bg-muted/80 transition-opacity"
118
+ className="px-4 py-2 rounded-md text-sm font-medium bg-muted text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted/80 transition-opacity"
108
119
  >
109
120
  {cancelText}
110
121
  </button>
111
122
  )}
112
123
  <button
124
+ ref={confirmButtonRef}
125
+ type="button"
126
+ data-pn-touch-target="true"
113
127
  onClick={() => {
114
128
  if (onConfirm) {
115
129
  onConfirm();
@@ -122,7 +136,7 @@ export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
122
136
  {confirmText}
123
137
  </button>
124
138
  </div>
125
- </div>
126
- </div>
139
+ </DialogContent>
140
+ </Dialog>
127
141
  );
128
142
  };
@@ -4,6 +4,7 @@ import { isCodeFilePath, isCodeFilePathStrict, CODE_PATH_BARE_REGEX, parseCodePa
4
4
  import { ensureHighlight, highlightToHtml } from "../utils/codeHighlight";
5
5
  import { useFenceTheme } from "../hooks/useFenceTheme";
6
6
  import { transformPlainText } from "../utils/inlineTransforms";
7
+ import { hasLinkedDocExtension } from "../utils/markdownExtensions";
7
8
  import { getImageSrc } from "./ImageThumbnail";
8
9
  import { useCodePathValidation, type CodePathValidationContextValue } from "./CodePathValidationContext";
9
10
  import type { ValidationEntry } from "../hooks/useValidatedCodePaths";
@@ -877,7 +878,7 @@ export const InlineMarkdown: React.FC<{
877
878
  // targets are opaque doc ids, not paths. null → today's rendering.
878
879
  const resolution = resolveLinkedDoc?.(target) ?? null;
879
880
  const display = resolution?.label || storedLabel || target;
880
- const targetPath = /\.(mdx?|txt|html?)$/i.test(target)
881
+ const targetPath = hasLinkedDocExtension(target)
881
882
  ? target
882
883
  : `${target}.md`;
883
884
 
@@ -1009,7 +1010,7 @@ export const InlineMarkdown: React.FC<{
1009
1010
  // Fragment is stripped before handing to onOpenLinkedDoc (overlay has
1010
1011
  // no anchor-scroll support today).
1011
1012
  const isLocalDoc =
1012
- /\.(mdx?|txt|html?)(#.*)?$/i.test(linkUrl) &&
1013
+ hasLinkedDocExtension(linkUrl, { allowFragment: true }) &&
1013
1014
  !linkUrl.startsWith("http://") &&
1014
1015
  !linkUrl.startsWith("https://");
1015
1016
  const isCodeFile = !isLocalDoc && isCodeFilePath(linkUrl);