@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
@@ -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. */
@@ -172,6 +199,11 @@ export interface HtmlViewerProps {
172
199
  onAskAI?: CommentAskAIHandler;
173
200
  /** Disable every annotation mutation entry point while preserving reading and navigation. */
174
201
  readOnly?: boolean;
202
+ /** Reports the full set of annotation ids with no live representation on
203
+ * the page (fail-closed anchors hide markers rather than guess). Called
204
+ * with the complete current set whenever it changes, including back to
205
+ * empty on recovery. Fires in readOnly mode too. */
206
+ onUnanchoredChange?: (ids: string[]) => void;
175
207
  /** Accessible iframe title. */
176
208
  title?: string;
177
209
  }
@@ -184,12 +216,19 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
184
216
  (
185
217
  {
186
218
  rawHtml,
219
+ src,
220
+ liveSession,
221
+ currentPageUrl,
222
+ onPageChange,
187
223
  annotations,
188
224
  onAddAnnotation,
189
225
  onSelectAnnotation,
190
226
  selectedAnnotationId,
191
227
  mode,
192
228
  inputMethod,
229
+ annotateModeActive = true,
230
+ onAnnotateModeExit,
231
+ onAnnotateModeToggle,
193
232
  vimModeEnabled = false,
194
233
  vimHudEnabled = false,
195
234
  vimHudKeyPanelEnabled = true,
@@ -205,6 +244,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
205
244
  onToggleDiff,
206
245
  onAskAI,
207
246
  readOnly = false,
247
+ onUnanchoredChange,
208
248
  title = "HTML Plan Viewer",
209
249
  },
210
250
  ref,
@@ -227,11 +267,44 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
227
267
  contextText: string;
228
268
  } | null>(null);
229
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
+
230
302
  // Host theming is opt-in per document (Plannotator-generated artifacts tag
231
303
  // themselves); arbitrary HTML renders untouched, like a standalone tab.
232
- const hostTheme = useMemo(() => hasHostThemeOptIn(rawHtml), [rawHtml]);
304
+ const hostTheme = useMemo(() => !liveMode && hasHostThemeOptIn(rawHtml), [liveMode, rawHtml]);
233
305
 
234
306
  const srcdoc = useMemo(() => {
307
+ if (liveMode) return undefined; // src mode: the proxy injects the bridge
235
308
  const injection = buildSrcdocInjection({
236
309
  tokens: readThemeTokens(),
237
310
  isLight: isLightTheme(),
@@ -239,11 +312,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
239
312
  diffActive: !!diffActive,
240
313
  });
241
314
  return injectIntoHead(rawHtml, injection);
242
- }, [rawHtml, hostTheme, diffActive]);
315
+ }, [liveMode, rawHtml, hostTheme, diffActive]);
243
316
 
244
317
  const handleResize = useCallback((height: number) => {
318
+ if (liveMode) return; // live surfaces are full-viewport; height is ignored
245
319
  setIframeHeight(height);
246
- }, []);
320
+ }, [liveMode]);
247
321
 
248
322
  // Composer yield while shift-selecting (multi-target drafts): fade the
249
323
  // composer as the pointer approaches, click-through when over it. Pointer
@@ -293,7 +367,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
293
367
  selectedAnnotationId,
294
368
  mode,
295
369
  onResize: handleResize,
370
+ live: liveSession,
371
+ onPageChange,
296
372
  onBridgePointer: handleBridgePointer,
373
+ onUnanchoredChange,
297
374
  });
298
375
 
299
376
  const multiSelectActive = !readOnly && !!hook.commentPopover && hook.draftTargets.length > 0;
@@ -346,11 +423,36 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
346
423
  useEffect(() => {
347
424
  function handler(e: MessageEvent<unknown>) {
348
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;
349
429
  if (isBridgeReadyMessage(e.data)) {
350
430
  setIframeReadyVersion((version) => version + 1);
351
431
  setVimBridgePhase("inactive");
352
432
  setVimHudCommand(null);
353
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?.();
354
456
  return;
355
457
  }
356
458
  const vimCopy = parseVimBridgeCopy(e.data);
@@ -401,9 +503,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
401
503
 
402
504
  const handleVimHelpOpenChange = useCallback((open: boolean) => {
403
505
  setVimHelpOpen(open);
404
- iframeRef.current?.contentWindow?.postMessage(
506
+ postToBridge(
405
507
  { type: `${PREFIX}set-vim-help`, open },
406
- "*",
407
508
  );
408
509
  }, []);
409
510
 
@@ -418,9 +519,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
418
519
  if (document.activeElement === iframe) return false;
419
520
  iframe.focus({ preventScroll: true });
420
521
  if (document.activeElement !== iframe) return false;
421
- iframe.contentWindow?.postMessage(
522
+ postToBridge(
422
523
  { type: `${PREFIX}focus-vim` },
423
- "*",
424
524
  );
425
525
  return true;
426
526
  }, [readOnly, vimModeEnabled]);
@@ -431,13 +531,46 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
431
531
  focusDocument: focusVimDocument,
432
532
  });
433
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
+
434
544
  useEffect(() => {
435
545
  if (iframeReadyVersion === 0) return;
436
- if (annotations.length > 0) {
437
- hook.applyAnnotations(annotations);
546
+ const restorable = forCurrentPage(annotations);
547
+ if (restorable.length > 0) {
548
+ hook.applyAnnotations(restorable);
438
549
  }
439
550
  }, [iframeReadyVersion]); // eslint-disable-line react-hooks/exhaustive-deps
440
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
+
441
574
  // Placed-marker numbering is parent-authoritative and matches the
442
575
  // numbers exportAnnotations writes into the submitted feedback: the full
443
576
  // list INCLUDING globals is numbered by ARRAY position (the export's
@@ -447,9 +580,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
447
580
  // order is only a pre-sync fallback.
448
581
  useEffect(() => {
449
582
  if (iframeReadyVersion === 0) return;
450
- iframeRef.current?.contentWindow?.postMessage(
583
+ postToBridge(
451
584
  { type: `${PREFIX}sync-annotations`, annotations: buildSyncNumbering(annotations) },
452
- "*",
453
585
  );
454
586
  }, [iframeReadyVersion, annotations]);
455
587
 
@@ -457,31 +589,67 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
457
589
  // ready (fresh iframe) and whenever the user switches it in the toolstrip.
458
590
  useEffect(() => {
459
591
  if (iframeReadyVersion === 0) return;
460
- iframeRef.current?.contentWindow?.postMessage(
592
+ postToBridge(
461
593
  { type: `${PREFIX}set-input-method`, method: inputMethod },
462
- "*",
463
594
  );
464
595
  }, [iframeReadyVersion, inputMethod]);
465
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
+
466
636
  useEffect(() => {
467
637
  if (iframeReadyVersion === 0) return;
468
638
  const iframe = iframeRef.current;
469
- iframe?.contentWindow?.postMessage(
639
+ postToBridge(
470
640
  {
471
641
  type: `${PREFIX}set-vim-mode`,
472
642
  enabled: !readOnly && vimModeEnabled,
473
643
  hudEnabled: vimHudEnabled,
474
644
  mode,
475
645
  },
476
- "*",
477
646
  );
478
647
  if (!readOnly && vimModeEnabled && iframe && iframe === document.activeElement) {
479
648
  // The initial parent focus can land before the sandbox bridge is ready.
480
649
  // Reassert it after configuration so raw HTML enters BLOCK immediately,
481
650
  // matching the Markdown surface instead of waiting for the first key.
482
- iframe.contentWindow?.postMessage(
651
+ postToBridge(
483
652
  { type: `${PREFIX}focus-vim` },
484
- "*",
485
653
  );
486
654
  }
487
655
  }, [iframeReadyVersion, mode, readOnly, vimHudEnabled, vimModeEnabled]);
@@ -499,9 +667,8 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
499
667
  && (document.activeElement === document.body || document.activeElement === null)
500
668
  ) {
501
669
  iframeRef.current?.focus({ preventScroll: true });
502
- iframeRef.current?.contentWindow?.postMessage(
670
+ postToBridge(
503
671
  { type: `${PREFIX}focus-vim` },
504
- "*",
505
672
  );
506
673
  }
507
674
  }, [hook.commentPopover, hook.quickLabelPicker, hook.toolbarState, readOnly, vimModeEnabled]);
@@ -509,14 +676,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
509
676
  useEffect(() => {
510
677
  if (iframeReadyVersion === 0) return;
511
678
  function sendTheme() {
512
- iframeRef.current?.contentWindow?.postMessage(
679
+ postToBridge(
513
680
  {
514
681
  type: `${PREFIX}theme`,
515
682
  tokens: buildThemeTokenPayload(readThemeTokens(), hostTheme),
516
683
  isLight: isLightTheme(),
517
684
  hostTheme,
518
685
  },
519
- "*",
520
686
  );
521
687
  }
522
688
  sendTheme();
@@ -531,7 +697,9 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
531
697
  useImperativeHandle(ref, () => ({
532
698
  removeHighlight: hook.removeHighlight,
533
699
  clearAllHighlights: hook.clearAllHighlights,
534
- applySharedAnnotations: hook.applyAnnotations,
700
+ // Shared/draft restores respect the live page filter too.
701
+ applySharedAnnotations: (anns: Annotation[]) =>
702
+ hook.applyAnnotations(forCurrentPage(anns)),
535
703
  }));
536
704
 
537
705
  const handleGlobalCommentSubmit = useCallback(
@@ -622,6 +790,20 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
622
790
  data-print-region="article"
623
791
  className={fullViewport ? "relative overflow-hidden w-full flex-1" : "relative bg-card rounded-xl shadow-xl overflow-hidden w-full"}
624
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
+ )}
625
807
  {/* Full-viewport mode has no card chrome, so float the same controls
626
808
  over the top-right of the iframe (with a backdrop so they read over
627
809
  any HTML). The selection toolbar is portaled separately. */}
@@ -633,10 +815,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
633
815
  {actionButtons}
634
816
  </div>
635
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. */}
636
821
  <iframe
637
822
  ref={iframeRef}
638
- srcDoc={srcdoc}
639
- sandbox="allow-scripts"
823
+ {...(src ? { src } : { srcDoc: srcdoc, sandbox: "allow-scripts" })}
640
824
  style={{
641
825
  width: "100%",
642
826
  height: fullViewport ? "100%" : `${iframeHeight}px`,
@@ -693,9 +877,12 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
693
877
  positionMode="center-above"
694
878
  element={hook.toolbarState.element}
695
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
696
884
  onAnnotate={hook.handleAnnotate}
697
885
  onRequestComment={hook.handleRequestComment}
698
- onQuickLabel={hook.handleQuickLabel}
699
886
  onClose={hook.handleToolbarClose}
700
887
  />,
701
888
  document.body,
@@ -709,6 +896,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
709
896
  contextText={hook.commentPopover.contextText}
710
897
  initialText={hook.commentPopover.initialText}
711
898
  isGlobal={false}
899
+ draftKey={`html:${hook.commentPopover.draftKey}`}
712
900
  onSubmit={hook.handleCommentSubmit}
713
901
  onClose={hook.handleCommentClose}
714
902
  skillReferences
@@ -728,18 +916,6 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
728
916
  document.body,
729
917
  )}
730
918
 
731
- {/* Quick label picker portal */}
732
- {!readOnly && hook.quickLabelPicker &&
733
- createPortal(
734
- <FloatingQuickLabelPicker
735
- anchorEl={hook.quickLabelPicker.anchorEl}
736
- cursorHint={hook.quickLabelPicker.cursorHint}
737
- onSelect={hook.handleFloatingQuickLabel}
738
- onDismiss={hook.handleQuickLabelPickerDismiss}
739
- />,
740
- document.body,
741
- )}
742
-
743
919
  {/* Global comment popover portal */}
744
920
  {!readOnly && globalCommentPopover &&
745
921
  createPortal(