@plannotator/ui 0.30.0 → 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 +62 -32
  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 +208 -39
  34. package/components/html-viewer/bridge-script.ts +319 -65
  35. package/components/html-viewer/composerYield.ts +1 -51
  36. package/components/html-viewer/useHtmlAnnotation.ts +146 -57
  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
@@ -4,7 +4,11 @@ import {
4
4
  useState,
5
5
  type RefObject,
6
6
  } from 'react';
7
- import { useScrollViewport } from '../hooks/useScrollViewport';
7
+ import {
8
+ addScrollViewportListener,
9
+ getScrollViewportRect,
10
+ useScrollViewport,
11
+ } from '../hooks/useScrollViewport';
8
12
  import type { SemanticTarget } from '../utils/blockTargeting';
9
13
  import {
10
14
  createRangeBetweenTextPositions,
@@ -186,7 +190,9 @@ export function VimTargetReticle({
186
190
  return;
187
191
  }
188
192
  const containerRect = container.getBoundingClientRect();
189
- const viewportTop = scrollViewport?.getBoundingClientRect().top ?? containerRect.top;
193
+ const viewportTop = scrollViewport
194
+ ? getScrollViewportRect(scrollViewport).top
195
+ : containerRect.top;
190
196
  const stickyBottom = container
191
197
  .querySelector<HTMLElement>('[data-sticky-actions]')
192
198
  ?.getBoundingClientRect()
@@ -205,7 +211,9 @@ export function VimTargetReticle({
205
211
 
206
212
  update();
207
213
  window.addEventListener('resize', scheduleUpdate, { passive: true });
208
- scrollViewport?.addEventListener('scroll', scheduleUpdate, { passive: true });
214
+ const removeScrollListener = scrollViewport
215
+ ? addScrollViewportListener(scrollViewport, scheduleUpdate)
216
+ : undefined;
209
217
  const resizeObserver = typeof ResizeObserver === 'undefined'
210
218
  ? null
211
219
  : new ResizeObserver(scheduleUpdate);
@@ -214,7 +222,7 @@ export function VimTargetReticle({
214
222
  return () => {
215
223
  cancelAnimationFrame(rafRef.current);
216
224
  window.removeEventListener('resize', scheduleUpdate);
217
- scrollViewport?.removeEventListener('scroll', scheduleUpdate);
225
+ removeScrollListener?.();
218
226
  resizeObserver?.disconnect();
219
227
  };
220
228
  }, [containerRef, restoredState, scrollViewport, target]);
@@ -268,6 +268,7 @@ const GeneralInput: React.FC<{
268
268
  <div className="border-t border-border/50 p-2">
269
269
  <div className="flex items-end gap-1.5">
270
270
  <textarea
271
+ data-pn-mobile-editable="true"
271
272
  ref={textareaRef}
272
273
  value={value}
273
274
  onChange={(event) => onChange(event.target.value)}
@@ -13,6 +13,12 @@ function cx(...classes: Array<string | false | null | undefined>): string {
13
13
  return classes.filter(Boolean).join(' ');
14
14
  }
15
15
 
16
+ /**
17
+ * Legacy shared button primitive.
18
+ *
19
+ * The visual sizes remain compact, while the shared touch marker expands the
20
+ * physical target only when a coarse pointer is present.
21
+ */
16
22
  export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
17
23
  className,
18
24
  variant = 'outline',
@@ -22,23 +28,25 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(({
22
28
  ...props
23
29
  }, ref) => (
24
30
  <button
31
+ {...props}
25
32
  ref={ref}
26
33
  type={type}
34
+ data-pn-touch-target="true"
35
+ data-pn-touch-target-icon={size === 'icon' || variant === 'icon' ? 'true' : undefined}
27
36
  className={cx(
28
37
  'inline-flex items-center justify-center gap-2 rounded-md text-xs font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50',
29
38
  size === 'sm' && 'h-8 px-2.5',
30
39
  size === 'md' && 'h-9 px-3',
31
40
  size === 'icon' && 'h-8 w-8',
32
- variant === 'primary' && 'bg-primary text-primary-foreground hover:opacity-90',
33
- variant === 'outline' && 'border border-border bg-card text-foreground hover:bg-muted',
34
- variant === 'ghost' && 'text-muted-foreground hover:bg-muted hover:text-foreground',
41
+ variant === 'primary' && 'bg-primary text-primary-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:opacity-90',
42
+ variant === 'outline' && 'border border-border bg-card text-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted',
43
+ variant === 'ghost' && 'text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted [@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground',
35
44
  variant === 'icon' && (active
36
45
  ? 'border border-primary/50 bg-primary/10 text-primary'
37
- : 'border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground'),
38
- variant === 'danger' && 'border border-border bg-background text-muted-foreground hover:border-destructive/50 hover:bg-destructive/10 hover:text-destructive',
46
+ : 'border border-border bg-background text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:bg-muted [@media(hover:hover)_and_(pointer:fine)]:hover:text-foreground'),
47
+ variant === 'danger' && 'border border-border bg-background text-muted-foreground [@media(hover:hover)_and_(pointer:fine)]:hover:border-destructive/50 [@media(hover:hover)_and_(pointer:fine)]:hover:bg-destructive/10 [@media(hover:hover)_and_(pointer:fine)]:hover:text-destructive',
39
48
  className
40
49
  )}
41
- {...props}
42
50
  />
43
51
  ));
44
52
  Button.displayName = 'Button';
@@ -29,7 +29,6 @@ import {
29
29
  type CommentAskAIHandler,
30
30
  type CommentTargetChip,
31
31
  } from "../CommentPopover";
32
- import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
33
32
  import { VimKeyHud } from "../VimKeyHud";
34
33
  import type { ViewerHandle } from "../Viewer";
35
34
  import {
@@ -38,7 +37,12 @@ import {
38
37
  type ComposerYieldState,
39
38
  } from "./composerYield";
40
39
  import { buildSyncNumbering } from "./annotationNumbering";
41
- import { useHtmlAnnotation } from "./useHtmlAnnotation";
40
+ import {
41
+ MAX_PAGE_URL_LENGTH,
42
+ rejectsLiveMessage,
43
+ useHtmlAnnotation,
44
+ type HtmlLiveSession,
45
+ } from "./useHtmlAnnotation";
42
46
  import {
43
47
  THEME_TOKENS,
44
48
  buildSrcdocInjection,
@@ -138,6 +142,17 @@ function parseVimBridgeCopy(value: unknown): string | null {
138
142
  /** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */
139
143
  export interface HtmlViewerProps {
140
144
  rawHtml: string;
145
+ /** Live proxied-app mode: render `src` (no sandbox, no srcdoc) instead of
146
+ * rawHtml. The caller must also set `fullViewport` and `liveSession`. */
147
+ src?: string;
148
+ /** Live session credentials paired with `src`: proxy origin + per-session
149
+ * token, validated on every inbound message and stamped on every post. */
150
+ liveSession?: HtmlLiveSession;
151
+ /** Current page (pathname + search) in a live multi-page session. Restore
152
+ * filters annotations to this page; changing it re-applies the filter. */
153
+ currentPageUrl?: string;
154
+ /** Live-mode page navigation reports (ready pageUrl + page-change). */
155
+ onPageChange?: (pageUrl: string) => void;
141
156
  annotations: Annotation[];
142
157
  onAddAnnotation: (ann: Annotation) => void;
143
158
  onSelectAnnotation: (id: string | null) => void;
@@ -145,6 +160,18 @@ export interface HtmlViewerProps {
145
160
  mode: EditorMode;
146
161
  /** Input method: 'drag' = text selection, 'pinpoint' = click an element. */
147
162
  inputMethod: InputMethod;
163
+ /** Interact/Annotate toggle for HTML and live-app surfaces. While false
164
+ * the bridge keeps clicks native (no pinpoint capture, no hover outline)
165
+ * and clicks/forms/navigation reach the page untouched. Text
166
+ * drag-selection commenting stays live in BOTH modes, and committed
167
+ * markers stay visible and clickable in BOTH modes. Default true (armed)
168
+ * on both surface kinds. */
169
+ annotateModeActive?: boolean;
170
+ /** Esc final rung (bridge-side or the parent-side listener here): the user
171
+ * asked to leave Annotate for Interact. The host owns the mode state. */
172
+ onAnnotateModeExit?: () => void;
173
+ /** Mod+Shift+A pressed while focus lived inside the iframe. */
174
+ onAnnotateModeToggle?: () => void;
148
175
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
149
176
  vimModeEnabled?: boolean;
150
177
  /** Replace the iframe-local compact badge with the shared live key HUD. */
@@ -189,12 +216,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
189
216
  (
190
217
  {
191
218
  rawHtml,
219
+ src,
220
+ liveSession,
221
+ currentPageUrl,
222
+ onPageChange,
192
223
  annotations,
193
224
  onAddAnnotation,
194
225
  onSelectAnnotation,
195
226
  selectedAnnotationId,
196
227
  mode,
197
228
  inputMethod,
229
+ annotateModeActive = true,
230
+ onAnnotateModeExit,
231
+ onAnnotateModeToggle,
198
232
  vimModeEnabled = false,
199
233
  vimHudEnabled = false,
200
234
  vimHudKeyPanelEnabled = true,
@@ -233,11 +267,44 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
233
267
  contextText: string;
234
268
  } | null>(null);
235
269
 
270
+ // Live proxied-app mode: the iframe navigates a real origin, so the
271
+ // srcdoc pipeline is skipped entirely and its messages carry credentials.
272
+ const liveMode = !!src;
273
+ const liveSessionRef = useRef<HtmlLiveSession | null>(liveSession ?? null);
274
+ liveSessionRef.current = liveSession ?? null;
275
+ const onPageChangeRef = useRef(onPageChange);
276
+ onPageChangeRef.current = onPageChange;
277
+ const onAnnotateModeExitRef = useRef(onAnnotateModeExit);
278
+ onAnnotateModeExitRef.current = onAnnotateModeExit;
279
+ const onAnnotateModeToggleRef = useRef(onAnnotateModeToggle);
280
+ onAnnotateModeToggleRef.current = onAnnotateModeToggle;
281
+
282
+ /** Single choke point for direct-to-bridge posts: live sessions get the
283
+ * token + concrete targetOrigin, srcdoc keeps "*" and no token. */
284
+ const postToBridge = useCallback((msg: Record<string, unknown>) => {
285
+ const win = iframeRef.current?.contentWindow;
286
+ if (!win) return;
287
+ const live = liveSessionRef.current;
288
+ if (live) {
289
+ // Browsers silently drop posts whose targetOrigin does not match the
290
+ // receiving window (mid-navigation frames); some DOM environments
291
+ // throw instead, so align with the browser semantics explicitly.
292
+ try {
293
+ win.postMessage({ ...msg, token: live.token }, live.origin);
294
+ } catch {
295
+ // Dropped, matching browser behavior for unmatched target origins.
296
+ }
297
+ } else {
298
+ win.postMessage(msg, "*");
299
+ }
300
+ }, []);
301
+
236
302
  // Host theming is opt-in per document (Plannotator-generated artifacts tag
237
303
  // themselves); arbitrary HTML renders untouched, like a standalone tab.
238
- const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
304
+ const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
239
305
 
240
306
  const srcdoc = useMemo(() => {
307
+ if (liveMode) return undefined; // src mode: the proxy injects the bridge
241
308
  const injection = buildSrcdocInjection({
242
309
  tokens: readThemeTokens(),
243
310
  isLight: isLightTheme(),
@@ -245,11 +312,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
245
312
  diffActive: !!diffActive,
246
313
  });
247
314
  return injectIntoHead(rawHtml, injection);
248
- }, [rawHtml, hostTheme, diffActive]);
315
+ }, [liveMode, rawHtml, hostTheme, diffActive]);
249
316
 
250
317
  const handleResize = useCallback((height: number) => {
318
+ if (liveMode) return; // live surfaces are full-viewport; height is ignored
251
319
  setIframeHeight(height);
252
- }, []);
320
+ }, [liveMode]);
253
321
 
254
322
  // Composer yield while shift-selecting (multi-target drafts): fade the
255
323
  // composer as the pointer approaches, click-through when over it. Pointer
@@ -299,6 +367,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
299
367
  selectedAnnotationId,
300
368
  mode,
301
369
  onResize: handleResize,
370
+ live: liveSession,
371
+ onPageChange,
302
372
  onBridgePointer: handleBridgePointer,
303
373
  onUnanchoredChange,
304
374
  });
@@ -353,11 +423,36 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
353
423
  useEffect(() => {
354
424
  function handler(e: MessageEvent<unknown>) {
355
425
  if (e.source !== iframeRef.current?.contentWindow) return;
426
+ // Live sessions verify origin + token before reading anything.
427
+ const live = liveSessionRef.current;
428
+ if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
356
429
  if (isBridgeReadyMessage(e.data)) {
357
430
  setIframeReadyVersion((version) => version + 1);
358
431
  setVimBridgePhase("inactive");
359
432
  setVimHudCommand(null);
360
433
  setVimHelpOpen(false);
434
+ // Live ready carries the page identity (validated like page-change)
435
+ // so reloads and cross-page navigations re-anchor the restore filter.
436
+ if (live && isRecord(e.data)) {
437
+ const pageUrl = e.data.pageUrl;
438
+ if (
439
+ typeof pageUrl === "string"
440
+ && pageUrl.length > 0
441
+ && pageUrl.length <= MAX_PAGE_URL_LENGTH
442
+ ) {
443
+ onPageChangeRef.current?.(pageUrl);
444
+ }
445
+ }
446
+ return;
447
+ }
448
+ // Interact/Annotate mode messages ride the same authenticated path:
449
+ // live sessions already rejected wrong-origin/tokenless data above.
450
+ if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-exit`) {
451
+ onAnnotateModeExitRef.current?.();
452
+ return;
453
+ }
454
+ if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-toggle`) {
455
+ onAnnotateModeToggleRef.current?.();
361
456
  return;
362
457
  }
363
458
  const vimCopy = parseVimBridgeCopy(e.data);
@@ -408,9 +503,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
408
503
 
409
504
  const handleVimHelpOpenChange = useCallback((open: boolean) => {
410
505
  setVimHelpOpen(open);
411
- iframeRef.current?.contentWindow?.postMessage(
506
+ postToBridge(
412
507
  { type: `${PREFIX}set-vim-help`, open },
413
- "*",
414
508
  );
415
509
  }, []);
416
510
 
@@ -425,9 +519,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
425
519
  if (document.activeElement === iframe) return false;
426
520
  iframe.focus({ preventScroll: true });
427
521
  if (document.activeElement !== iframe) return false;
428
- iframe.contentWindow?.postMessage(
522
+ postToBridge(
429
523
  { type: `${PREFIX}focus-vim` },
430
- "*",
431
524
  );
432
525
  return true;
433
526
  }, [readOnly, vimModeEnabled]);
@@ -438,13 +531,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
438
531
  focusDocument: focusVimDocument,
439
532
  });
440
533
 
534
+ // Restore filter for live multi-page sessions: only annotations made on
535
+ // the current page (or without page identity) are pushed for restoration.
536
+ // Numbering (sync-annotations) still ships the FULL list: numbers are
537
+ // parent-authoritative and global across pages, matching export.
538
+ const forCurrentPage = useCallback(
539
+ (anns: Annotation[]) =>
540
+ anns.filter((a) => !a.pageUrl || a.pageUrl === currentPageUrl),
541
+ [currentPageUrl],
542
+ );
543
+
441
544
  useEffect(() => {
442
545
  if (iframeReadyVersion === 0) return;
443
- if (annotations.length > 0) {
444
- hook.applyAnnotations(annotations);
546
+ const restorable = forCurrentPage(annotations);
547
+ if (restorable.length > 0) {
548
+ hook.applyAnnotations(restorable);
445
549
  }
446
550
  }, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
447
551
 
552
+ // Live page navigation with a ready iframe: explicitly clear the previous
553
+ // page's marks, then re-apply the filtered set. Relying on dead anchors to
554
+ // hide pins would risk cross-page text-search false matches and waste
555
+ // reconcile budget.
556
+ const lastAppliedPageRef = useRef<string | undefined>(currentPageUrl);
557
+ useEffect(() => {
558
+ if (lastAppliedPageRef.current === currentPageUrl) return;
559
+ lastAppliedPageRef.current = currentPageUrl;
560
+ if (iframeReadyVersion === 0) return;
561
+ postToBridge({ type: `${PREFIX}clear-marks` });
562
+ const restorable = forCurrentPage(annotations);
563
+ if (restorable.length > 0) {
564
+ hook.applyAnnotations(restorable);
565
+ }
566
+ // clear-marks drops the bridge's synced numbering; re-establish it so
567
+ // restored markers keep their export-matching global numbers.
568
+ postToBridge({
569
+ type: `${PREFIX}sync-annotations`,
570
+ annotations: buildSyncNumbering(annotations),
571
+ });
572
+ }, [currentPageUrl, iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
573
+
448
574
  // Placed-marker numbering is parent-authoritative and matches the
449
575
  // numbers exportAnnotations writes into the submitted feedback: the full
450
576
  // list INCLUDING globals is numbered by ARRAY position (the export's
@@ -454,9 +580,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
454
580
  // order is only a pre-sync fallback.
455
581
  useEffect(() => {
456
582
  if (iframeReadyVersion === 0) return;
457
- iframeRef.current?.contentWindow?.postMessage(
583
+ postToBridge(
458
584
  { type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
459
- "*",
460
585
  );
461
586
  }, [iframeReadyVersion, annotations]);
462
587
 
@@ -464,31 +589,67 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
464
589
  // ready (fresh iframe) and whenever the user switches it in the toolstrip.
465
590
  useEffect(() => {
466
591
  if (iframeReadyVersion === 0) return;
467
- iframeRef.current?.contentWindow?.postMessage(
592
+ postToBridge(
468
593
  { type: `${PREFIX}set-input-method`, method: inputMethod },
469
- "*",
470
594
  );
471
595
  }, [iframeReadyVersion, inputMethod]);
472
596
 
597
+ // Tell the bridge whether Annotate is armed. Same re-post pattern as
598
+ // set-input-method, so the mode survives live page changes / HMR reloads
599
+ // and bridge re-injection without ever reloading the iframe.
600
+ useEffect(() => {
601
+ if (iframeReadyVersion === 0) return;
602
+ postToBridge(
603
+ { type: `${PREFIX}set-annotate-mode`, active: annotateModeActive },
604
+ );
605
+ }, [iframeReadyVersion, annotateModeActive]);
606
+
607
+ // Parent-side Esc rung: with focus outside the iframe the bridge never
608
+ // sees the keydown. Any open composer/toolbar/picker still closes first —
609
+ // their state is read from this render's closure, so an Esc that closed
610
+ // one this same keydown is not double-consumed here.
611
+ useEffect(() => {
612
+ if (readOnly || !annotateModeActive || !onAnnotateModeExit) return;
613
+ const overlayOpen =
614
+ !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker || !!globalCommentPopover;
615
+ const onKeyDown = (e: KeyboardEvent) => {
616
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
617
+ if (overlayOpen) return;
618
+ // A text field or dialog owns its own Escape.
619
+ const target = e.target as HTMLElement | null;
620
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
621
+ if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return;
622
+ onAnnotateModeExit();
623
+ };
624
+ window.addEventListener('keydown', onKeyDown);
625
+ return () => window.removeEventListener('keydown', onKeyDown);
626
+ }, [
627
+ readOnly,
628
+ annotateModeActive,
629
+ onAnnotateModeExit,
630
+ hook.toolbarState,
631
+ hook.commentPopover,
632
+ hook.quickLabelPicker,
633
+ globalCommentPopover,
634
+ ]);
635
+
473
636
  useEffect(() => {
474
637
  if (iframeReadyVersion === 0) return;
475
638
  const iframe = iframeRef.current;
476
- iframe?.contentWindow?.postMessage(
639
+ postToBridge(
477
640
  {
478
641
  type: `${PREFIX}set-vim-mode`,
479
642
  enabled: !readOnly && vimModeEnabled,
480
643
  hudEnabled: vimHudEnabled,
481
644
  mode,
482
645
  },
483
- "*",
484
646
  );
485
647
  if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
486
648
  // The initial parent focus can land before the sandbox bridge is ready.
487
649
  // Reassert it after configuration so raw HTML enters BLOCK immediately,
488
650
  // matching the Markdown surface instead of waiting for the first key.
489
- iframe.contentWindow?.postMessage(
651
+ postToBridge(
490
652
  { type: `${PREFIX}focus-vim` },
491
- "*",
492
653
  );
493
654
  }
494
655
  }, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
@@ -506,9 +667,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
506
667
  && (document.activeElement === document.body || document.activeElement === null)
507
668
  ) {
508
669
  iframeRef.current?.focus({ preventScroll: true });
509
- iframeRef.current?.contentWindow?.postMessage(
670
+ postToBridge(
510
671
  { type: `${PREFIX}focus-vim` },
511
- "*",
512
672
  );
513
673
  }
514
674
  }, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
@@ -516,14 +676,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
516
676
  useEffect(() => {
517
677
  if (iframeReadyVersion === 0) return;
518
678
  function sendTheme() {
519
- iframeRef.current?.contentWindow?.postMessage(
679
+ postToBridge(
520
680
  {
521
681
  type: `${PREFIX}theme`,
522
682
  tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
523
683
  isLight: isLightTheme(),
524
684
  hostTheme,
525
685
  },
526
- "*",
527
686
  );
528
687
  }
529
688
  sendTheme();
@@ -538,7 +697,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
538
697
  useImperativeHandle(ref, () => ({
539
698
  removeHighlight: hook.removeHighlight,
540
699
  clearAllHighlights: hook.clearAllHighlights,
541
- applySharedAnnotations: hook.applyAnnotations,
700
+ // Shared/draft restores respect the live page filter too.
701
+ applySharedAnnotations: (anns: Annotation[]) =>
702
+ hook.applyAnnotations(forCurrentPage(anns)),
542
703
  }));
543
704
 
544
705
  const handleGlobalCommentSubmit = useCallback(
@@ -629,6 +790,20 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
629
790
  data-print-region="article"
630
791
  className={fullViewport ? "relative overflow-hidden w-full flex-1" : "relative bg-card rounded-xl shadow-xl overflow-hidden w-full"}
631
792
  >
793
+ {/* Armed affordance: a subtle accent ring floats over the iframe
794
+ while Annotate is armed (hosts that wire the toggle only).
795
+ Overlaid + pointer-transparent, so it never shifts layout and
796
+ never eats a click; an inset shadow on the article itself
797
+ would paint UNDER the covering iframe. */}
798
+ {!readOnly && annotateModeActive && (onAnnotateModeExit || onAnnotateModeToggle) && (
799
+ <div
800
+ aria-hidden
801
+ data-print-hide
802
+ data-annotate-armed-ring
803
+ className="pointer-events-none absolute inset-0 z-10"
804
+ style={{ boxShadow: "inset 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent)" }}
805
+ />
806
+ )}
632
807
  {/* Full-viewport mode has no card chrome, so float the same controls
633
808
  over the top-right of the iframe (with a backdrop so they read over
634
809
  any HTML). The selection toolbar is portaled separately. */}
@@ -640,10 +815,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
640
815
  {actionButtons}
641
816
  </div>
642
817
  )}
818
+ {/* Live proxied-app mode navigates a real loopback origin: no
819
+ sandbox (the user's own app needs cookies, storage, and
820
+ same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
643
821
  <iframe
644
822
  ref={iframeRef}
645
- srcDoc={srcdoc}
646
- sandbox="allow-scripts"
823
+ {...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
647
824
  style={{
648
825
  width: "100%",
649
826
  height: fullViewport ? "100%" : `${iframeHeight}px`,
@@ -700,9 +877,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
700
877
  positionMode="center-above"
701
878
  element={hook.toolbarState.element}
702
879
  copyText={hook.toolbarState.selectionText}
880
+ // HTML/live surfaces are comment-only: no Delete, no quick
881
+ // labels (onQuickLabel deliberately not passed). The markdown
882
+ // surface keeps the full toolbar.
883
+ commentOnly
703
884
  onAnnotate={hook.handleAnnotate}
704
885
  onRequestComment={hook.handleRequestComment}
705
- onQuickLabel={hook.handleQuickLabel}
706
886
  onClose={hook.handleToolbarClose}
707
887
  />,
708
888
  document.body,
@@ -716,6 +896,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
716
896
  contextText={hook.commentPopover.contextText}
717
897
  initialText={hook.commentPopover.initialText}
718
898
  isGlobal={false}
899
+ draftKey={`html:${hook.commentPopover.draftKey}`}
719
900
  onSubmit={hook.handleCommentSubmit}
720
901
  onClose={hook.handleCommentClose}
721
902
  skillReferences
@@ -735,18 +916,6 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
735
916
  document.body,
736
917
  )}
737
918
 
738
- {/* Quick label picker portal */}
739
- {!readOnly && hook.quickLabelPicker &&
740
- createPortal(
741
- <FloatingQuickLabelPicker
742
- anchorEl={hook.quickLabelPicker.anchorEl}
743
- cursorHint={hook.quickLabelPicker.cursorHint}
744
- onSelect={hook.handleFloatingQuickLabel}
745
- onDismiss={hook.handleQuickLabelPickerDismiss}
746
- />,
747
- document.body,
748
- )}
749
-
750
919
  {/* Global comment popover portal */}
751
920
  {!readOnly && globalCommentPopover &&
752
921
  createPortal(