@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
@@ -17,6 +17,7 @@ import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../..
17
17
  import { AnnotationType } from "../../types";
18
18
  import { copyTextPreservingFocus } from "../../utils/clipboard";
19
19
  import { getIdentity } from "../../utils/identity";
20
+ import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
20
21
  import {
21
22
  createVimHudCommand,
22
23
  getVimHudPhase,
@@ -29,7 +30,6 @@ import {
29
30
  type CommentAskAIHandler,
30
31
  type CommentTargetChip,
31
32
  } from "../CommentPopover";
32
- import { FloatingQuickLabelPicker } from "../FloatingQuickLabelPicker";
33
33
  import { VimKeyHud } from "../VimKeyHud";
34
34
  import type { ViewerHandle } from "../Viewer";
35
35
  import {
@@ -38,7 +38,13 @@ import {
38
38
  type ComposerYieldState,
39
39
  } from "./composerYield";
40
40
  import { buildSyncNumbering } from "./annotationNumbering";
41
- import { useHtmlAnnotation } from "./useHtmlAnnotation";
41
+ import { mergeUnanchoredIds } from "./unanchored";
42
+ import {
43
+ MAX_PAGE_URL_LENGTH,
44
+ rejectsLiveMessage,
45
+ useHtmlAnnotation,
46
+ type HtmlLiveSession,
47
+ } from "./useHtmlAnnotation";
42
48
  import {
43
49
  THEME_TOKENS,
44
50
  buildSrcdocInjection,
@@ -138,6 +144,17 @@ function parseVimBridgeCopy(value: unknown): string | null {
138
144
  /** Inputs for the sandboxed raw-HTML viewer and its parent-side annotation UI. */
139
145
  export interface HtmlViewerProps {
140
146
  rawHtml: string;
147
+ /** Live proxied-app mode: render `src` (no sandbox, no srcdoc) instead of
148
+ * rawHtml. The caller must also set `fullViewport` and `liveSession`. */
149
+ src?: string;
150
+ /** Live session credentials paired with `src`: proxy origin + per-session
151
+ * token, validated on every inbound message and stamped on every post. */
152
+ liveSession?: HtmlLiveSession;
153
+ /** Current page (pathname + search) in a live multi-page session. Restore
154
+ * filters annotations to this page; changing it re-applies the filter. */
155
+ currentPageUrl?: string;
156
+ /** Live-mode page navigation reports (ready pageUrl + page-change). */
157
+ onPageChange?: (pageUrl: string) => void;
141
158
  annotations: Annotation[];
142
159
  onAddAnnotation: (ann: Annotation) => void;
143
160
  onSelectAnnotation: (id: string | null) => void;
@@ -145,6 +162,18 @@ export interface HtmlViewerProps {
145
162
  mode: EditorMode;
146
163
  /** Input method: 'drag' = text selection, 'pinpoint' = click an element. */
147
164
  inputMethod: InputMethod;
165
+ /** Interact/Annotate toggle for HTML and live-app surfaces. While false
166
+ * the bridge keeps clicks native (no pinpoint capture, no hover outline)
167
+ * and clicks/forms/navigation reach the page untouched. Text
168
+ * drag-selection commenting stays live in BOTH modes, and committed
169
+ * markers stay visible and clickable in BOTH modes. Default true (armed)
170
+ * on both surface kinds. */
171
+ annotateModeActive?: boolean;
172
+ /** Esc final rung (bridge-side or the parent-side listener here): the user
173
+ * asked to leave Annotate for Interact. The host owns the mode state. */
174
+ onAnnotateModeExit?: () => void;
175
+ /** Mod+Shift+A pressed while focus lived inside the iframe. */
176
+ onAnnotateModeToggle?: () => void;
148
177
  /** Opt-in Vim-style keyboard selection. Default false for compatibility. */
149
178
  vimModeEnabled?: boolean;
150
179
  /** Replace the iframe-local compact badge with the shared live key HUD. */
@@ -175,8 +204,21 @@ export interface HtmlViewerProps {
175
204
  /** Reports the full set of annotation ids with no live representation on
176
205
  * the page (fail-closed anchors hide markers rather than guess). Called
177
206
  * with the complete current set whenever it changes, including back to
178
- * empty on recovery. Fires in readOnly mode too. */
207
+ * empty on recovery. Fires in readOnly mode too. Complete over the
208
+ * `annotations` prop: page rows with nothing to restore by (no quoted
209
+ * text, no element anchor) are reported even though the bridge never
210
+ * sees them, and an id this viewer minted for a local comment that the
211
+ * host swapped out of `annotations` for its own id is not reported. */
179
212
  onUnanchoredChange?: (ids: string[]) => void;
213
+ /** Product cap on additional (shift-click) targets per comment, 0..16.
214
+ * Enforced at the trust boundary, on submit and on restore, and carried
215
+ * to the bridge so the in-page toggle stops at the same number. Default
216
+ * 16 (the package cap); absent leaves every message unchanged. */
217
+ maxAdditionalTargets?: number;
218
+ /** scrollIntoView behavior when a selected annotation is scrolled into
219
+ * view inside the page. Default 'smooth'; pass 'auto' to carry the
220
+ * parent's reduced-motion preference across the iframe boundary. */
221
+ scrollBehavior?: 'smooth' | 'auto';
180
222
  /** Accessible iframe title. */
181
223
  title?: string;
182
224
  }
@@ -189,12 +231,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
189
231
  (
190
232
  {
191
233
  rawHtml,
234
+ src,
235
+ liveSession,
236
+ currentPageUrl,
237
+ onPageChange,
192
238
  annotations,
193
239
  onAddAnnotation,
194
240
  onSelectAnnotation,
195
241
  selectedAnnotationId,
196
242
  mode,
197
243
  inputMethod,
244
+ annotateModeActive = true,
245
+ onAnnotateModeExit,
246
+ onAnnotateModeToggle,
198
247
  vimModeEnabled = false,
199
248
  vimHudEnabled = false,
200
249
  vimHudKeyPanelEnabled = true,
@@ -211,6 +260,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
211
260
  onAskAI,
212
261
  readOnly = false,
213
262
  onUnanchoredChange,
263
+ maxAdditionalTargets,
264
+ scrollBehavior,
214
265
  title = "HTML Plan Viewer",
215
266
  },
216
267
  ref,
@@ -233,11 +284,44 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
233
284
  contextText: string;
234
285
  } | null>(null);
235
286
 
287
+ // Live proxied-app mode: the iframe navigates a real origin, so the
288
+ // srcdoc pipeline is skipped entirely and its messages carry credentials.
289
+ const liveMode = !!src;
290
+ const liveSessionRef = useRef<HtmlLiveSession | null>(liveSession ?? null);
291
+ liveSessionRef.current = liveSession ?? null;
292
+ const onPageChangeRef = useRef(onPageChange);
293
+ onPageChangeRef.current = onPageChange;
294
+ const onAnnotateModeExitRef = useRef(onAnnotateModeExit);
295
+ onAnnotateModeExitRef.current = onAnnotateModeExit;
296
+ const onAnnotateModeToggleRef = useRef(onAnnotateModeToggle);
297
+ onAnnotateModeToggleRef.current = onAnnotateModeToggle;
298
+
299
+ /** Single choke point for direct-to-bridge posts: live sessions get the
300
+ * token + concrete targetOrigin, srcdoc keeps "*" and no token. */
301
+ const postToBridge = useCallback((msg: Record<string, unknown>) => {
302
+ const win = iframeRef.current?.contentWindow;
303
+ if (!win) return;
304
+ const live = liveSessionRef.current;
305
+ if (live) {
306
+ // Browsers silently drop posts whose targetOrigin does not match the
307
+ // receiving window (mid-navigation frames); some DOM environments
308
+ // throw instead, so align with the browser semantics explicitly.
309
+ try {
310
+ win.postMessage({ ...msg, token: live.token }, live.origin);
311
+ } catch {
312
+ // Dropped, matching browser behavior for unmatched target origins.
313
+ }
314
+ } else {
315
+ win.postMessage(msg, "*");
316
+ }
317
+ }, []);
318
+
236
319
  // Host theming is opt-in per document (Plannotator-generated artifacts tag
237
320
  // themselves); arbitrary HTML renders untouched, like a standalone tab.
238
- const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
321
+ const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
239
322
 
240
323
  const srcdoc = useMemo(() => {
324
+ if (liveMode) return undefined; // src mode: the proxy injects the bridge
241
325
  const injection = buildSrcdocInjection({
242
326
  tokens: readThemeTokens(),
243
327
  isLight: isLightTheme(),
@@ -245,11 +329,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
245
329
  diffActive: !!diffActive,
246
330
  });
247
331
  return injectIntoHead(rawHtml, injection);
248
- }, [rawHtml, hostTheme, diffActive]);
332
+ }, [liveMode, rawHtml, hostTheme, diffActive]);
249
333
 
250
334
  const handleResize = useCallback((height: number) => {
335
+ if (liveMode) return; // live surfaces are full-viewport; height is ignored
251
336
  setIframeHeight(height);
252
- }, []);
337
+ }, [liveMode]);
253
338
 
254
339
  // Composer yield while shift-selecting (multi-target drafts): fade the
255
340
  // composer as the pointer approaches, click-through when over it. Pointer
@@ -290,6 +375,63 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
290
375
  [handleYieldPointer],
291
376
  );
292
377
 
378
+ // Unanchored union: the bridge reports ids with no live representation,
379
+ // completed here with what the bridge cannot see (textless page rows it
380
+ // was never asked to restore) and minus locally minted ids the host
381
+ // swapped out of `annotations`. Bridge reports deliver as they arrive
382
+ // (pass-through timing); a prop-side change delivers only when the union
383
+ // actually changes, so a viewer with nothing to complete delivers exactly
384
+ // the bridge list, exactly when the bridge posts it.
385
+ const onUnanchoredChangeRef = useRef(onUnanchoredChange);
386
+ onUnanchoredChangeRef.current = onUnanchoredChange;
387
+ const annotationsRef = useRef(annotations);
388
+ annotationsRef.current = annotations;
389
+ const bridgeUnanchoredRef = useRef<readonly string[]>([]);
390
+ const lastDeliveredUnanchoredRef = useRef("[]");
391
+ const createdIdsRef = useRef<ReadonlySet<string>>(new Set());
392
+ // The bridge's first report for the current document is the one that
393
+ // follows the restore batch (the parent asks for it with
394
+ // report-unanchored, and the bridge answers after its next complete
395
+ // pass, empty set included). Nothing is delivered before it: a host
396
+ // that acknowledges "the first report after a reload" must get the
397
+ // post-restore set, never a prop-side set computed at mount.
398
+ const bridgeReportedRef = useRef(false);
399
+ // An in-place document swap (rawHtml or src changes on one instance):
400
+ // the old document may still emit through the same contentWindow before
401
+ // the new one is ready. From the swap until the next ready, bridge
402
+ // reports belong to the old document and are dropped, and the
403
+ // last-delivered key is reset so the new document's first answer is
404
+ // delivered even when it equals the old one. First mount is not a swap.
405
+ const awaitingReadyRef = useRef(false);
406
+ const documentIdentityRef = useRef<{ rawHtml: string; src: string | undefined } | null>(null);
407
+ const previousIdentity = documentIdentityRef.current;
408
+ if (previousIdentity && (previousIdentity.rawHtml !== rawHtml || previousIdentity.src !== src)) {
409
+ awaitingReadyRef.current = true;
410
+ bridgeReportedRef.current = false;
411
+ lastDeliveredUnanchoredRef.current = "[]";
412
+ bridgeUnanchoredRef.current = [];
413
+ }
414
+ documentIdentityRef.current = { rawHtml, src };
415
+ const deliverUnanchored = useCallback((ids: string[], onlyIfChanged: boolean) => {
416
+ const key = JSON.stringify(ids);
417
+ if (onlyIfChanged && key === lastDeliveredUnanchoredRef.current) return;
418
+ lastDeliveredUnanchoredRef.current = key;
419
+ onUnanchoredChangeRef.current?.(ids);
420
+ }, []);
421
+ const handleBridgeUnanchored = useCallback((ids: string[]) => {
422
+ if (awaitingReadyRef.current) return;
423
+ bridgeUnanchoredRef.current = ids;
424
+ bridgeReportedRef.current = true;
425
+ deliverUnanchored(
426
+ mergeUnanchoredIds({
427
+ bridgeIds: ids,
428
+ annotations: annotationsRef.current,
429
+ createdIds: createdIdsRef.current,
430
+ }),
431
+ false,
432
+ );
433
+ }, [deliverUnanchored]);
434
+
293
435
  const hook = useHtmlAnnotation({
294
436
  iframeRef,
295
437
  enabled: !readOnly,
@@ -299,9 +441,26 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
299
441
  selectedAnnotationId,
300
442
  mode,
301
443
  onResize: handleResize,
444
+ live: liveSession,
445
+ onPageChange,
302
446
  onBridgePointer: handleBridgePointer,
303
- onUnanchoredChange,
447
+ onUnanchoredChange: handleBridgeUnanchored,
448
+ maxAdditionalTargets,
449
+ scrollBehavior,
304
450
  });
451
+ createdIdsRef.current = hook.createdAnnotationIds;
452
+
453
+ useEffect(() => {
454
+ if (!bridgeReportedRef.current) return;
455
+ deliverUnanchored(
456
+ mergeUnanchoredIds({
457
+ bridgeIds: bridgeUnanchoredRef.current,
458
+ annotations,
459
+ createdIds: hook.createdAnnotationIds,
460
+ }),
461
+ true,
462
+ );
463
+ }, [annotations, hook.createdAnnotationIds, deliverUnanchored]);
305
464
 
306
465
  const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
307
466
 
@@ -353,11 +512,36 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
353
512
  useEffect(() => {
354
513
  function handler(e: MessageEvent<unknown>) {
355
514
  if (e.source !== iframeRef.current?.contentWindow) return;
515
+ // Live sessions verify origin + token before reading anything.
516
+ const live = liveSessionRef.current;
517
+ if (live && rejectsLiveMessage(live, e.origin, e.data)) return;
356
518
  if (isBridgeReadyMessage(e.data)) {
357
519
  setIframeReadyVersion((version) => version + 1);
358
520
  setVimBridgePhase("inactive");
359
521
  setVimHudCommand(null);
360
522
  setVimHelpOpen(false);
523
+ // Live ready carries the page identity (validated like page-change)
524
+ // so reloads and cross-page navigations re-anchor the restore filter.
525
+ if (live && isRecord(e.data)) {
526
+ const pageUrl = e.data.pageUrl;
527
+ if (
528
+ typeof pageUrl === "string"
529
+ && pageUrl.length > 0
530
+ && pageUrl.length <= MAX_PAGE_URL_LENGTH
531
+ ) {
532
+ onPageChangeRef.current?.(pageUrl);
533
+ }
534
+ }
535
+ return;
536
+ }
537
+ // Interact/Annotate mode messages ride the same authenticated path:
538
+ // live sessions already rejected wrong-origin/tokenless data above.
539
+ if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-exit`) {
540
+ onAnnotateModeExitRef.current?.();
541
+ return;
542
+ }
543
+ if (isRecord(e.data) && e.data.type === `${PREFIX}annotate-toggle`) {
544
+ onAnnotateModeToggleRef.current?.();
361
545
  return;
362
546
  }
363
547
  const vimCopy = parseVimBridgeCopy(e.data);
@@ -408,9 +592,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
408
592
 
409
593
  const handleVimHelpOpenChange = useCallback((open: boolean) => {
410
594
  setVimHelpOpen(open);
411
- iframeRef.current?.contentWindow?.postMessage(
595
+ postToBridge(
412
596
  { type: `${PREFIX}set-vim-help`, open },
413
- "*",
414
597
  );
415
598
  }, []);
416
599
 
@@ -425,9 +608,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
425
608
  if (document.activeElement === iframe) return false;
426
609
  iframe.focus({ preventScroll: true });
427
610
  if (document.activeElement !== iframe) return false;
428
- iframe.contentWindow?.postMessage(
611
+ postToBridge(
429
612
  { type: `${PREFIX}focus-vim` },
430
- "*",
431
613
  );
432
614
  return true;
433
615
  }, [readOnly, vimModeEnabled]);
@@ -438,13 +620,58 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
438
620
  focusDocument: focusVimDocument,
439
621
  });
440
622
 
623
+ // Restore filter for live multi-page sessions: only annotations made on
624
+ // the current page (or without page identity) are pushed for restoration.
625
+ // Numbering (sync-annotations) still ships the FULL list: numbers are
626
+ // parent-authoritative and global across pages, matching export.
627
+ const forCurrentPage = useCallback(
628
+ (anns: Annotation[]) =>
629
+ anns.filter((a) => !a.pageUrl || a.pageUrl === currentPageUrl),
630
+ [currentPageUrl],
631
+ );
632
+
441
633
  useEffect(() => {
442
634
  if (iframeReadyVersion === 0) return;
443
- if (annotations.length > 0) {
444
- hook.applyAnnotations(annotations);
635
+ const restorable = forCurrentPage(annotations);
636
+ if (restorable.length > 0) {
637
+ hook.applyAnnotations(restorable);
445
638
  }
639
+ // A fresh document: the bridge starts from an empty set and would stay
640
+ // silent when everything restores. Ask for one complete report after
641
+ // this restore batch (posted after it, so the answering pass sees it),
642
+ // which becomes this document's first delivery, empty set included.
643
+ awaitingReadyRef.current = false;
644
+ bridgeReportedRef.current = false;
645
+ postToBridge({ type: `${PREFIX}report-unanchored` });
446
646
  }, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
447
647
 
648
+ // Live page navigation with a ready iframe: explicitly clear the previous
649
+ // page's marks, then re-apply the filtered set. Relying on dead anchors to
650
+ // hide pins would risk cross-page text-search false matches and waste
651
+ // reconcile budget.
652
+ const lastAppliedPageRef = useRef<string | undefined>(currentPageUrl);
653
+ useEffect(() => {
654
+ if (lastAppliedPageRef.current === currentPageUrl) return;
655
+ lastAppliedPageRef.current = currentPageUrl;
656
+ if (iframeReadyVersion === 0) return;
657
+ postToBridge({ type: `${PREFIX}clear-marks` });
658
+ const restorable = forCurrentPage(annotations);
659
+ if (restorable.length > 0) {
660
+ hook.applyAnnotations(restorable);
661
+ }
662
+ // clear-marks drops the bridge's synced numbering; re-establish it so
663
+ // restored markers keep their export-matching global numbers.
664
+ postToBridge({
665
+ type: `${PREFIX}sync-annotations`,
666
+ annotations: buildSyncNumbering(annotations),
667
+ });
668
+ // A new page is a new restore batch: report its complete set once, and
669
+ // deliver nothing computed against the previous page's report until
670
+ // that answer arrives.
671
+ bridgeReportedRef.current = false;
672
+ postToBridge({ type: `${PREFIX}report-unanchored` });
673
+ }, [currentPageUrl, iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
674
+
448
675
  // Placed-marker numbering is parent-authoritative and matches the
449
676
  // numbers exportAnnotations writes into the submitted feedback: the full
450
677
  // list INCLUDING globals is numbered by ARRAY position (the export's
@@ -454,9 +681,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
454
681
  // order is only a pre-sync fallback.
455
682
  useEffect(() => {
456
683
  if (iframeReadyVersion === 0) return;
457
- iframeRef.current?.contentWindow?.postMessage(
684
+ postToBridge(
458
685
  { type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
459
- "*",
460
686
  );
461
687
  }, [iframeReadyVersion, annotations]);
462
688
 
@@ -464,31 +690,67 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
464
690
  // ready (fresh iframe) and whenever the user switches it in the toolstrip.
465
691
  useEffect(() => {
466
692
  if (iframeReadyVersion === 0) return;
467
- iframeRef.current?.contentWindow?.postMessage(
693
+ postToBridge(
468
694
  { type: `${PREFIX}set-input-method`, method: inputMethod },
469
- "*",
470
695
  );
471
696
  }, [iframeReadyVersion, inputMethod]);
472
697
 
698
+ // Tell the bridge whether Annotate is armed. Same re-post pattern as
699
+ // set-input-method, so the mode survives live page changes / HMR reloads
700
+ // and bridge re-injection without ever reloading the iframe.
701
+ useEffect(() => {
702
+ if (iframeReadyVersion === 0) return;
703
+ postToBridge(
704
+ { type: `${PREFIX}set-annotate-mode`, active: annotateModeActive },
705
+ );
706
+ }, [iframeReadyVersion, annotateModeActive]);
707
+
708
+ // Parent-side Esc rung: with focus outside the iframe the bridge never
709
+ // sees the keydown. Any open composer/toolbar/picker still closes first —
710
+ // their state is read from this render's closure, so an Esc that closed
711
+ // one this same keydown is not double-consumed here.
712
+ useEffect(() => {
713
+ if (readOnly || !annotateModeActive || !onAnnotateModeExit) return;
714
+ const overlayOpen =
715
+ !!hook.toolbarState || !!hook.commentPopover || !!hook.quickLabelPicker || !!globalCommentPopover;
716
+ const onKeyDown = (e: KeyboardEvent) => {
717
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
718
+ if (overlayOpen) return;
719
+ // A text field or dialog owns its own Escape.
720
+ const target = e.target as HTMLElement | null;
721
+ if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
722
+ if (document.querySelector('[data-plannotator-confirm-dialog="true"]')) return;
723
+ onAnnotateModeExit();
724
+ };
725
+ window.addEventListener('keydown', onKeyDown);
726
+ return () => window.removeEventListener('keydown', onKeyDown);
727
+ }, [
728
+ readOnly,
729
+ annotateModeActive,
730
+ onAnnotateModeExit,
731
+ hook.toolbarState,
732
+ hook.commentPopover,
733
+ hook.quickLabelPicker,
734
+ globalCommentPopover,
735
+ ]);
736
+
473
737
  useEffect(() => {
474
738
  if (iframeReadyVersion === 0) return;
475
739
  const iframe = iframeRef.current;
476
- iframe?.contentWindow?.postMessage(
740
+ postToBridge(
477
741
  {
478
742
  type: `${PREFIX}set-vim-mode`,
479
743
  enabled: !readOnly && vimModeEnabled,
480
744
  hudEnabled: vimHudEnabled,
481
745
  mode,
482
746
  },
483
- "*",
484
747
  );
485
748
  if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
486
749
  // The initial parent focus can land before the sandbox bridge is ready.
487
750
  // Reassert it after configuration so raw HTML enters BLOCK immediately,
488
751
  // matching the Markdown surface instead of waiting for the first key.
489
- iframe.contentWindow?.postMessage(
752
+ postToBridge(
490
753
  { type: `${PREFIX}focus-vim` },
491
- "*",
492
754
  );
493
755
  }
494
756
  }, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
@@ -506,9 +768,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
506
768
  && (document.activeElement === document.body || document.activeElement === null)
507
769
  ) {
508
770
  iframeRef.current?.focus({ preventScroll: true });
509
- iframeRef.current?.contentWindow?.postMessage(
771
+ postToBridge(
510
772
  { type: `${PREFIX}focus-vim` },
511
- "*",
512
773
  );
513
774
  }
514
775
  }, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
@@ -516,14 +777,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
516
777
  useEffect(() => {
517
778
  if (iframeReadyVersion === 0) return;
518
779
  function sendTheme() {
519
- iframeRef.current?.contentWindow?.postMessage(
780
+ postToBridge(
520
781
  {
521
782
  type: `${PREFIX}theme`,
522
783
  tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
523
784
  isLight: isLightTheme(),
524
785
  hostTheme,
525
786
  },
526
- "*",
527
787
  );
528
788
  }
529
789
  sendTheme();
@@ -538,7 +798,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
538
798
  useImperativeHandle(ref, () => ({
539
799
  removeHighlight: hook.removeHighlight,
540
800
  clearAllHighlights: hook.clearAllHighlights,
541
- applySharedAnnotations: hook.applyAnnotations,
801
+ // Shared/draft restores respect the live page filter too.
802
+ applySharedAnnotations: (anns: Annotation[]) =>
803
+ hook.applyAnnotations(forCurrentPage(anns)),
542
804
  }));
543
805
 
544
806
  const handleGlobalCommentSubmit = useCallback(
@@ -629,6 +891,20 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
629
891
  data-print-region="article"
630
892
  className={fullViewport ? "relative overflow-hidden w-full flex-1" : "relative bg-card rounded-xl shadow-xl overflow-hidden w-full"}
631
893
  >
894
+ {/* Armed affordance: a subtle accent ring floats over the iframe
895
+ while Annotate is armed (hosts that wire the toggle only).
896
+ Overlaid + pointer-transparent, so it never shifts layout and
897
+ never eats a click; an inset shadow on the article itself
898
+ would paint UNDER the covering iframe. */}
899
+ {!readOnly && annotateModeActive && (onAnnotateModeExit || onAnnotateModeToggle) && (
900
+ <div
901
+ aria-hidden
902
+ data-print-hide
903
+ data-annotate-armed-ring
904
+ className="pointer-events-none absolute inset-0 z-10"
905
+ style={{ boxShadow: "inset 0 0 0 2px color-mix(in srgb, var(--primary) 45%, transparent)" }}
906
+ />
907
+ )}
632
908
  {/* Full-viewport mode has no card chrome, so float the same controls
633
909
  over the top-right of the iframe (with a backdrop so they read over
634
910
  any HTML). The selection toolbar is portaled separately. */}
@@ -640,10 +916,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
640
916
  {actionButtons}
641
917
  </div>
642
918
  )}
919
+ {/* Live proxied-app mode navigates a real loopback origin: no
920
+ sandbox (the user's own app needs cookies, storage, and
921
+ same-origin XHR) and no srcdoc. Srcdoc mode is unchanged. */}
643
922
  <iframe
644
923
  ref={iframeRef}
645
- srcDoc={srcdoc}
646
- sandbox="allow-scripts"
924
+ {...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
647
925
  style={{
648
926
  width: "100%",
649
927
  height: fullViewport ? "100%" : `${iframeHeight}px`,
@@ -700,9 +978,17 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
700
978
  positionMode="center-above"
701
979
  element={hook.toolbarState.element}
702
980
  copyText={hook.toolbarState.selectionText}
981
+ // HTML/live surfaces are comment-only: no Delete, no label
982
+ // picker, no Alt+digit labels (commentOnly). Exactly ONE label
983
+ // affordance is restored: the hardcoded 👍 "Looks good". The
984
+ // wrapper filters by id as defense in depth, so no present or
985
+ // future toolbar path can emit an arbitrary label here.
986
+ commentOnly
987
+ onQuickLabel={(label) => {
988
+ if (label.id === THUMBS_UP_LABEL.id) hook.handleQuickLabel(label);
989
+ }}
703
990
  onAnnotate={hook.handleAnnotate}
704
991
  onRequestComment={hook.handleRequestComment}
705
- onQuickLabel={hook.handleQuickLabel}
706
992
  onClose={hook.handleToolbarClose}
707
993
  />,
708
994
  document.body,
@@ -716,7 +1002,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
716
1002
  contextText={hook.commentPopover.contextText}
717
1003
  initialText={hook.commentPopover.initialText}
718
1004
  isGlobal={false}
1005
+ draftKey={`html:${hook.commentPopover.draftKey}`}
719
1006
  onSubmit={hook.handleCommentSubmit}
1007
+ // Pinpoint clicks open this composer directly, so it carries
1008
+ // the surface's one-click "Looks good" (the global composer
1009
+ // does not: a document-wide thumbs-up is not a thing).
1010
+ onQuickLookGood={hook.handleCommentLooksGood}
720
1011
  onClose={hook.handleCommentClose}
721
1012
  skillReferences
722
1013
  onAskAI={onAskAI}
@@ -735,18 +1026,6 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
735
1026
  document.body,
736
1027
  )}
737
1028
 
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
1029
  {/* Global comment popover portal */}
751
1030
  {!readOnly && globalCommentPopover &&
752
1031
  createPortal(