@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
@@ -213,19 +213,83 @@ body[data-plannotator-vim-focus-owner]:focus {
213
213
  export const BRIDGE_SCRIPT = `(function() {
214
214
  var PREFIX = 'plannotator-bridge-';
215
215
 
216
+ // --- Live mode (proxied local app) ---
217
+ // Srcdoc sessions carry no config: LIVE stays null and every branch below is
218
+ // inert, keeping srcdoc behavior byte-for-byte identical. The proxy injects
219
+ // this script into EVERY HTML response, so a frame gate deactivates the
220
+ // bridge when the proxied page is opened directly (not framed) and inside
221
+ // nested same-origin subframes: only the frame whose parent IS the editor
222
+ // may run.
223
+ var LIVE = window.__plannotatorLiveConfig || null;
224
+ if (LIVE && (window === window.parent || window.parent !== window.top)) return;
225
+ // The server cannot know which origin form (localhost or 127.0.0.1) the
226
+ // editor tab was opened on, so live outbound messages are posted once per
227
+ // listed editor origin: the browser delivers only the post whose
228
+ // targetOrigin matches the parent document and silently drops the rest,
229
+ // so exactly one copy arrives. Inbound accepts any listed origin. Srcdoc
230
+ // keeps targetOrigin '*' and no token.
231
+ function isEditorOrigin(origin) {
232
+ if (!LIVE) return true;
233
+ var list = LIVE.editorOrigins || [];
234
+ for (var i = 0; i < list.length; i++) { if (list[i] === origin) return true; }
235
+ return false;
236
+ }
237
+ function postToParent(msg) {
238
+ if (LIVE) {
239
+ msg.token = LIVE.token;
240
+ var origins = LIVE.editorOrigins || [];
241
+ for (var o = 0; o < origins.length; o++) parent.postMessage(msg, origins[o]);
242
+ return;
243
+ }
244
+ parent.postMessage(msg, '*');
245
+ }
246
+ // Page identity for multi-page live sessions: annotations are stamped with
247
+ // the page they were made on, and restore filters to the current page.
248
+ function currentPageUrl() {
249
+ return (location.pathname + location.search).slice(0, 2048);
250
+ }
251
+ if (LIVE) {
252
+ // SPA navigation: report history changes so the parent can re-filter the
253
+ // restored set. Coalesced with a microtask flag so a pushState burst posts
254
+ // once. Full reloads need nothing: the proxy re-injects and the fresh
255
+ // document posts ready again.
256
+ var pageChangeQueued = false;
257
+ var postPageChange = function() {
258
+ if (pageChangeQueued) return;
259
+ pageChangeQueued = true;
260
+ Promise.resolve().then(function() {
261
+ pageChangeQueued = false;
262
+ postToParent({ type: PREFIX + 'page-change', pageUrl: currentPageUrl() });
263
+ });
264
+ };
265
+ var wrapHistory = function(name) {
266
+ var original = history[name];
267
+ if (typeof original !== 'function') return;
268
+ history[name] = function() {
269
+ var result = original.apply(this, arguments);
270
+ postPageChange();
271
+ return result;
272
+ };
273
+ };
274
+ wrapHistory('pushState');
275
+ wrapHistory('replaceState');
276
+ window.addEventListener('popstate', postPageChange);
277
+ }
278
+
216
279
  // --- Theme ---
217
280
  // The author owns this document. Unless it opted in to host theming
218
281
  // (hostTheme), only viewer-namespaced --pn-* properties may be written to its
219
282
  // root, and its class list is never touched.
220
283
  window.addEventListener('message', function(e) {
221
284
  if (e.source !== parent) return;
285
+ if (LIVE && (!isEditorOrigin(e.origin) || !e.data || e.data.token !== LIVE.token)) return;
222
286
  if (!e.data) return;
223
287
  if (e.data.type === PREFIX + 'set-vim-help') {
224
288
  vimHelpOpen = !!e.data.open;
225
- parent.postMessage({
289
+ postToParent({
226
290
  type: PREFIX + 'vim-help',
227
291
  open: vimHelpOpen
228
- }, '*');
292
+ });
229
293
  return;
230
294
  }
231
295
  if (e.data.type !== PREFIX + 'theme') return;
@@ -246,11 +310,12 @@ export const BRIDGE_SCRIPT = `(function() {
246
310
  // --- Resize ---
247
311
  var lastHeight = 0;
248
312
  function postResize() {
313
+ if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
249
314
  if (!document.body) return;
250
315
  var h = document.body.scrollHeight;
251
316
  if (h !== lastHeight) {
252
317
  lastHeight = h;
253
- parent.postMessage({ type: PREFIX + 'resize', height: h }, '*');
318
+ postToParent({ type: PREFIX + 'resize', height: h });
254
319
  }
255
320
  }
256
321
  window.addEventListener('load', postResize);
@@ -274,7 +339,39 @@ export const BRIDGE_SCRIPT = `(function() {
274
339
  var pendingMultiTargets = []; // { key, el, anchor, label, text, box }
275
340
  var multiTargetSeq = 0;
276
341
  var MAX_MULTI_TARGETS = 16;
277
- var currentInputMethod = 'drag'; // 'drag' = text selection, 'pinpoint' = click an element
342
+ // Per-draft cap on additional targets: the parent may lower it on
343
+ // arm-multi-select ({ max }) to its product cap so the toggle stops where
344
+ // the saved annotation would. Never above MAX_MULTI_TARGETS; reset with
345
+ // the arm on every draft.
346
+ var multiSelectMax = MAX_MULTI_TARGETS;
347
+ function clampMultiSelectMax(value) {
348
+ if (typeof value !== 'number' || !isFinite(value)) return MAX_MULTI_TARGETS;
349
+ var whole = Math.floor(value);
350
+ if (whole < 0) return 0;
351
+ return whole > MAX_MULTI_TARGETS ? MAX_MULTI_TARGETS : whole;
352
+ }
353
+ // Live mode clamps the INPUT METHOD to pinpoint (click = element). Text
354
+ // drag-selection is a separate, always-on channel — see the mouseup handler
355
+ // — so the clamp only decides what a plain click does, never whether text
356
+ // can be selected and commented.
357
+ var currentInputMethod = LIVE ? 'pinpoint' : 'drag'; // 'drag' = text selection, 'pinpoint' = click an element
358
+ // Interact/Annotate mode. While INACTIVE the bridge keeps clicks native: no
359
+ // pinpoint capture, no hover outline, no [data-annotate] click, no
360
+ // committed-highlight click interception — clicks, forms, and SPA
361
+ // navigation reach the page untouched. Text drag-selection commenting stays
362
+ // LIVE in both modes (a real drag opens the comment toolbar even in
363
+ // Interact), committed overlay artifacts stay visible in both modes, and
364
+ // marker buttons keep their own clicks. BOTH surface kinds start ARMED —
365
+ // Esc (or the header pen) drops to Interact.
366
+ var annotateModeActive = true;
367
+ function updatePinpointCursor() {
368
+ if (!document.body) return;
369
+ if (annotateModeActive && currentInputMethod === 'pinpoint') {
370
+ document.body.setAttribute('data-plannotator-pinpoint-cursor', '');
371
+ } else {
372
+ document.body.removeAttribute('data-plannotator-pinpoint-cursor');
373
+ }
374
+ }
278
375
  var pinpointHover = null;
279
376
  var vimEnabled = false;
280
377
  var vimHudEnabled = false;
@@ -295,10 +392,9 @@ export const BRIDGE_SCRIPT = `(function() {
295
392
  // and immediately clear it. This flag suppresses that one trailing clear.
296
393
  var skipNextClear = false;
297
394
 
298
- document.addEventListener('mouseup', function(e) {
299
- if (currentInputMethod === 'pinpoint') return; // pinpoint uses click, not drag-select
300
- setTimeout(handleSelection, 10);
301
- });
395
+ // Drag-selection commenting is handled by the merged capture-phase mouseup
396
+ // listener below (after the drag-yield state it reads) — it is ALWAYS live:
397
+ // both surfaces, armed or Interact, drag or pinpoint input method.
302
398
 
303
399
  // The page fully controls element text, so everything posted as a selection
304
400
  // is bounded here before it crosses the bridge (the parent enforces the same
@@ -322,7 +418,7 @@ export const BRIDGE_SCRIPT = `(function() {
322
418
  // Trailing clear from a plain-click element annotation — consume it once.
323
419
  if (skipNextClear) { skipNextClear = false; return; }
324
420
  if (pendingSelection) {
325
- parent.postMessage({ type: PREFIX + 'selection-clear' }, '*');
421
+ postToParent({ type: PREFIX + 'selection-clear' });
326
422
  pendingSelection = null;
327
423
  pendingRange = null;
328
424
  clearMultiTargets();
@@ -350,7 +446,7 @@ export const BRIDGE_SCRIPT = `(function() {
350
446
  endOffset: range.endOffset
351
447
  };
352
448
 
353
- parent.postMessage({
449
+ postToParent({
354
450
  type: PREFIX + 'selection',
355
451
  text: text,
356
452
  modeOverride: modeOverride || undefined,
@@ -359,7 +455,7 @@ export const BRIDGE_SCRIPT = `(function() {
359
455
  targetKey: (extras && extras.targetKey) || undefined,
360
456
  targetLabel: (extras && extras.targetLabel) || undefined,
361
457
  rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
362
- }, '*');
458
+ });
363
459
  renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
364
460
  return true;
365
461
  }
@@ -382,17 +478,17 @@ export const BRIDGE_SCRIPT = `(function() {
382
478
  // Drag selections keep the existing close-on-scroll-out behavior.
383
479
  if (pendingPinViaPinpoint || pendingMultiTargets.length > 0) return;
384
480
  // Selection scrolled out of view — close the toolbar (matches markdown).
385
- parent.postMessage({ type: PREFIX + 'selection-clear' }, '*');
481
+ postToParent({ type: PREFIX + 'selection-clear' });
386
482
  pendingSelection = null;
387
483
  pendingRange = null;
388
484
  clearPendingPin();
389
485
  renderAnnotationOverlay();
390
486
  return;
391
487
  }
392
- parent.postMessage({
488
+ postToParent({
393
489
  type: PREFIX + 'selection-rect',
394
490
  rect: { top: r.top, left: r.left, width: r.width, height: r.height }
395
- }, '*');
491
+ });
396
492
  }
397
493
  window.addEventListener('scroll', function() {
398
494
  if (!pendingSelection) return;
@@ -402,6 +498,7 @@ export const BRIDGE_SCRIPT = `(function() {
402
498
  // --- Mark Creation ---
403
499
  window.addEventListener('message', function(e) {
404
500
  if (e.source !== parent) return;
501
+ if (LIVE && (!isEditorOrigin(e.origin) || !e.data || e.data.token !== LIVE.token)) return;
405
502
  if (!e.data || !e.data.type) return;
406
503
  var type = e.data.type;
407
504
 
@@ -483,11 +580,11 @@ export const BRIDGE_SCRIPT = `(function() {
483
580
  e.data.anchor,
484
581
  e.data.additionalAnchors
485
582
  );
486
- parent.postMessage({
583
+ postToParent({
487
584
  type: PREFIX + 'mark-applied',
488
585
  id: e.data.id,
489
586
  success: found
490
- }, '*');
587
+ });
491
588
  }
492
589
 
493
590
  else if (type === PREFIX + 'remove-mark') {
@@ -551,6 +648,10 @@ export const BRIDGE_SCRIPT = `(function() {
551
648
  && e.data.key === pendingPinKey
552
649
  ) {
553
650
  multiSelectArmed = true;
651
+ // Optional product cap for THIS draft; absent keeps the bridge's own.
652
+ multiSelectMax = e.data.max === undefined
653
+ ? MAX_MULTI_TARGETS
654
+ : clampMultiSelectMax(e.data.max);
554
655
  }
555
656
  }
556
657
 
@@ -569,27 +670,64 @@ export const BRIDGE_SCRIPT = `(function() {
569
670
  // Selecting an annotation scrolls its first resolved target into view
570
671
  // and flashes the overlay focus highlight over EVERY rect of EVERY
571
672
  // target — never a class write on page elements, and never only the
572
- // first fragment of a multi-paragraph selection.
573
- scrollToAnnotation(e.data.id);
673
+ // first fragment of a multi-paragraph selection. The optional
674
+ // behavior lets the parent pass its reduced-motion preference across
675
+ // the boundary; absent means smooth, as before.
676
+ scrollToAnnotation(e.data.id, e.data.behavior === 'auto' ? 'auto' : 'smooth');
574
677
  }
575
678
 
576
679
  else if (type === PREFIX + 'focus-mark') {
577
680
  focusAnnotationRecord(typeof e.data.id === 'string' ? e.data.id : null, false);
578
681
  }
579
682
 
683
+ else if (type === PREFIX + 'report-unanchored') {
684
+ // The parent posted its restore batch and wants the complete set once
685
+ // the next complete overlay pass has run, even if the set is unchanged
686
+ // (empty included). Messages are processed in order, so the pass this
687
+ // schedules sees every find-and-mark posted before this request.
688
+ unanchoredReportRequested = true;
689
+ schedulePinpointReconcile();
690
+ }
691
+
580
692
  else if (type === PREFIX + 'set-input-method') {
581
- currentInputMethod = e.data.method === 'pinpoint' ? 'pinpoint' : 'drag';
693
+ // Live mode clamps the input method to pinpoint (what a plain click
694
+ // does); text drag-selection commenting stays live regardless.
695
+ currentInputMethod = (LIVE || e.data.method === 'pinpoint') ? 'pinpoint' : 'drag';
582
696
  if (currentInputMethod === 'pinpoint') {
583
697
  clearHoverHighlight(); // pinpoint owns clicks; drop the select affordance (and any pending hit test)
584
- if (document.body) document.body.setAttribute('data-plannotator-pinpoint-cursor', '');
585
698
  } else {
586
- if (document.body) document.body.removeAttribute('data-plannotator-pinpoint-cursor');
587
699
  clearPinpointHover();
588
700
  }
701
+ updatePinpointCursor(); // cursor affordance is mode-gated: never in Interact
589
702
  if (vimEnabled) updateVimUi();
590
703
  }
591
704
 
705
+ else if (type === PREFIX + 'set-annotate-mode') {
706
+ var nextAnnotateActive = e.data.active === true;
707
+ if (nextAnnotateActive !== annotateModeActive) {
708
+ annotateModeActive = nextAnnotateActive;
709
+ if (!annotateModeActive) {
710
+ // Disarm tears down every PENDING affordance; committed markers and
711
+ // highlights stay visible, and marker buttons keep their clicks.
712
+ if (pendingSelection) postToParent({ type: PREFIX + 'selection-clear' });
713
+ pendingSelection = null;
714
+ pendingRange = null;
715
+ skipNextClear = false;
716
+ clearMultiTargets();
717
+ clearPendingPin();
718
+ try { window.getSelection().removeAllRanges(); } catch (ex) {}
719
+ clearPinpointHover();
720
+ clearHoverHighlight();
721
+ renderAnnotationOverlay();
722
+ }
723
+ updatePinpointCursor();
724
+ }
725
+ }
726
+
592
727
  else if (type === PREFIX + 'set-vim-mode') {
728
+ // Vim is off in live mode: it writes classes onto author elements and
729
+ // captures keys the app needs.
730
+ if (LIVE) return;
593
731
  var wasVimEnabled = vimEnabled;
594
732
  var wasVimHudEnabled = vimHudEnabled;
595
733
  vimEnabled = e.data.enabled === true;
@@ -1096,6 +1234,7 @@ export const BRIDGE_SCRIPT = `(function() {
1096
1234
  document.addEventListener('mousemove', function(e) {
1097
1235
  lastPointer = { x: e.clientX, y: e.clientY };
1098
1236
  updateDragYield(e);
1237
+ if (!annotateModeActive) return; // Interact mode: no hover affordances
1099
1238
  if (currentInputMethod !== 'pinpoint') {
1100
1239
  // Click-to-select hover affordance (drag mode owns highlight clicks):
1101
1240
  // cheap cached-rect hit test, rAF-throttled, cleared while a draft or
@@ -1108,6 +1247,9 @@ export const BRIDGE_SCRIPT = `(function() {
1108
1247
  return;
1109
1248
  }
1110
1249
  if (vimEnabled && vimPhase !== 'inactive') return;
1250
+ // Mid-drag (text selection in progress) the element hover box is noise:
1251
+ // the drag owns the surface until mouseup resolves it.
1252
+ if (dragYieldActive) { clearPinpointHover(); return; }
1111
1253
  // Hit-test at the pointer (e.target only backstops engines without
1112
1254
  // elementFromPoint) so the same code path serves the scroll re-hit-test.
1113
1255
  updatePinpointHover(e.clientX, e.clientY, e.target);
@@ -1137,7 +1279,7 @@ export const BRIDGE_SCRIPT = `(function() {
1137
1279
  renderAnnotationOverlay();
1138
1280
  // Scroll under a stationary pointer moves the committed rects: re-run
1139
1281
  // the cached-rect hover test so the affordance tracks reality.
1140
- if (currentInputMethod !== 'pinpoint' && lastPointer && !pendingPinEl && !pendingSelection) {
1282
+ if (annotateModeActive && currentInputMethod !== 'pinpoint' && lastPointer && !pendingPinEl && !pendingSelection) {
1141
1283
  setHoverHighlight(committedHighlightIdAtCached(lastPointer.x, lastPointer.y));
1142
1284
  }
1143
1285
  positionMultiTargetBoxes();
@@ -1145,7 +1287,7 @@ export const BRIDGE_SCRIPT = `(function() {
1145
1287
  positionPinpointBox(pendingPinEl);
1146
1288
  return;
1147
1289
  }
1148
- if (currentInputMethod !== 'pinpoint') return;
1290
+ if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
1149
1291
  if (vimEnabled && vimPhase !== 'inactive') return;
1150
1292
  if (lastPointer) {
1151
1293
  updatePinpointHover(lastPointer.x, lastPointer.y, pinpointHover);
@@ -1259,15 +1401,21 @@ export const BRIDGE_SCRIPT = `(function() {
1259
1401
  }
1260
1402
  }
1261
1403
 
1262
- // Drag-selection marker yield: while a text drag is in progress in drag
1263
- // mode, placed markers drop pointer input (the same data-pn-hittest CSS the
1264
- // hit-test yield uses) so a 25px bubble sitting over the text cannot
1265
- // capture the selection mid-drag. Armed only by a >4px move with the
1266
- // primary button held from a non-overlay mousedown, so marker clicks
1267
- // (mousedown ON the marker) and plain click-to-select (no drag) are
1268
- // untouched; disarmed on mouseup or when the button is seen released.
1404
+ // Drag-selection marker yield: while a text drag is in progress, placed
1405
+ // markers drop pointer input (the same data-pn-hittest CSS the hit-test
1406
+ // yield uses) so a 25px bubble sitting over the text cannot capture the
1407
+ // selection mid-drag. Armed only by a >4px move with the primary button
1408
+ // held from a non-overlay mousedown, so marker clicks (mousedown ON the
1409
+ // marker) and plain click-to-select (no drag) are untouched; disarmed on
1410
+ // mouseup or when the button is seen released. The same >4px arming is
1411
+ // what tells the mouseup handler below that a REAL drag happened, which
1412
+ // is how drag-selection commenting stays live in pinpoint/armed mode
1413
+ // without a plain pinpoint click re-posting its own selection.
1269
1414
  var dragYieldStart = null;
1270
1415
  var dragYieldActive = false;
1416
+ // True between a drag's terminating mouseup and the next mousedown: the
1417
+ // click event that follows a completed drag must not pinpoint-annotate.
1418
+ var dragEndedClick = false;
1271
1419
  function endDragYield() {
1272
1420
  dragYieldStart = null;
1273
1421
  if (dragYieldActive) {
@@ -1292,13 +1440,37 @@ export const BRIDGE_SCRIPT = `(function() {
1292
1440
  }
1293
1441
  }
1294
1442
  document.addEventListener('mousedown', function(e) {
1295
- if (currentInputMethod === 'pinpoint') return;
1443
+ dragEndedClick = false;
1296
1444
  if (e.button !== 0) return;
1297
1445
  if (isViewerOverlayNode(e.target)) return;
1298
1446
  dragYieldStart = { x: e.clientX, y: e.clientY };
1299
1447
  }, true);
1300
1448
  document.addEventListener('mouseup', function() {
1449
+ // Text drag-selection commenting is ALWAYS live: both surfaces, armed or
1450
+ // Interact. In armed pinpoint a plain click belongs to the pinpoint
1451
+ // handler — only a drag that actually PRODUCED a text selection owns its
1452
+ // trailing click, so annotateElement's own selection is never re-posted
1453
+ // by its trailing mouseup. The >4px drift alone is NOT enough to arm the
1454
+ // click suppression: a drifted click (common on trackpads) that selected
1455
+ // nothing must stay a click, or armed pinpoint would silently swallow it
1456
+ // while the unprevented click leaks through to the page. Browsers clear
1457
+ // a prior selection on the mousedown that starts the drag, so the
1458
+ // selection observed here is the one THIS drag produced. Everywhere else
1459
+ // the classic always-schedule behavior stays: handleSelection only acts
1460
+ // on a real selection, posts the clear that dismisses a stale draft, and
1461
+ // never preventDefaults — a plain click is never swallowed.
1462
+ var dragged = dragYieldActive;
1301
1463
  endDragYield();
1464
+ var draggedSelection = false;
1465
+ if (dragged) {
1466
+ try {
1467
+ var s = window.getSelection();
1468
+ draggedSelection = !!(s && !s.isCollapsed && s.rangeCount && (s.toString() || '').trim());
1469
+ } catch (ex) {}
1470
+ }
1471
+ dragEndedClick = draggedSelection;
1472
+ if (annotateModeActive && currentInputMethod === 'pinpoint' && !draggedSelection) return;
1473
+ setTimeout(handleSelection, 10);
1302
1474
  }, true);
1303
1475
 
1304
1476
  // One record per committed annotation. Its targets are live projections;
@@ -1342,6 +1514,10 @@ export const BRIDGE_SCRIPT = `(function() {
1342
1514
  // whose records are removed and therefore invisible to the per-pass scan.
1343
1515
  var lastUnanchoredKey = '[]';
1344
1516
  var restoreFailedIds = new Set();
1517
+ // Set by report-unanchored: the parent asks for the complete set after
1518
+ // its restore batch, so the next COMPLETE pass emits even when the set
1519
+ // did not change (an all-restored document reports its empty set once).
1520
+ var unanchoredReportRequested = false;
1345
1521
  function emitUnanchored(deadRecordIds) {
1346
1522
  var seen = new Set();
1347
1523
  var combined = [];
@@ -1360,9 +1536,14 @@ export const BRIDGE_SCRIPT = `(function() {
1360
1536
  combined.sort();
1361
1537
  if (combined.length > 512) combined = combined.slice(0, 512);
1362
1538
  var key = JSON.stringify(combined);
1363
- if (key === lastUnanchoredKey) return;
1539
+ if (key === lastUnanchoredKey && !unanchoredReportRequested) return;
1540
+ unanchoredReportRequested = false;
1364
1541
  lastUnanchoredKey = key;
1365
- parent.postMessage({ type: PREFIX + 'unanchored', ids: combined }, '*');
1542
+ // postToParent, not a raw '*' post: live sessions stamp the session
1543
+ // token and post only to the listed editor origins, and the parent
1544
+ // drops untokened live messages — a raw post would silently disable
1545
+ // unanchored reporting exactly where restores fail most (live pages).
1546
+ postToParent({ type: PREFIX + 'unanchored', ids: combined });
1366
1547
  }
1367
1548
 
1368
1549
  function validNormalizedPoint(p) {
@@ -1502,9 +1683,47 @@ export const BRIDGE_SCRIPT = `(function() {
1502
1683
  }
1503
1684
  }
1504
1685
  }
1686
+ if (!record.targets.length && LIVE) {
1687
+ // Live pages render late (lazy routes, data-dependent trees): a
1688
+ // restore that resolves nothing keeps its record, seeded with
1689
+ // unresolved placeholder targets built from the durable params, so
1690
+ // the mutation-driven reconcile re-acquires them when the elements
1691
+ // appear. Srcdoc documents are static (nothing would ever
1692
+ // re-resolve), so the record is dropped below exactly as before.
1693
+ if (anchor) {
1694
+ record.targets.push({
1695
+ kind: 'element',
1696
+ element: null,
1697
+ anchor: anchor,
1698
+ point: normalizedPointOf(anchor, null)
1699
+ });
1700
+ }
1701
+ if (originalText) {
1702
+ record.targets.push({
1703
+ kind: 'range',
1704
+ range: null,
1705
+ text: originalText,
1706
+ markerless: !!anchor
1707
+ });
1708
+ }
1709
+ if (additionalAnchors && additionalAnchors.length) {
1710
+ var lateCount = Math.min(additionalAnchors.length, MAX_MULTI_TARGETS);
1711
+ for (var lateIndex = 0; lateIndex < lateCount; lateIndex++) {
1712
+ if (!additionalAnchors[lateIndex]) continue;
1713
+ record.targets.push({
1714
+ kind: 'element',
1715
+ element: null,
1716
+ anchor: additionalAnchors[lateIndex],
1717
+ point: normalizedPointOf(additionalAnchors[lateIndex], null)
1718
+ });
1719
+ }
1720
+ }
1721
+ }
1505
1722
  if (!record.targets.length) {
1506
1723
  // The record is removed (nothing to retry), so the per-pass dead scan
1507
1724
  // cannot see this id: track it separately for the unanchored report.
1725
+ // Live sessions only reach here when the durable params seeded no
1726
+ // placeholder targets at all (nothing will ever re-resolve).
1508
1727
  removeAnnRecord(id);
1509
1728
  restoreFailedIds.add(id);
1510
1729
  }
@@ -2067,7 +2286,7 @@ export const BRIDGE_SCRIPT = `(function() {
2067
2286
  btn.addEventListener('click', function(clickEvent) {
2068
2287
  clickEvent.preventDefault();
2069
2288
  clickEvent.stopPropagation();
2070
- parent.postMessage({ type: PREFIX + 'mark-click', id: annId }, '*');
2289
+ postToParent({ type: PREFIX + 'mark-click', id: annId });
2071
2290
  });
2072
2291
  overlayNodes.add(btn);
2073
2292
  return btn;
@@ -2307,7 +2526,7 @@ export const BRIDGE_SCRIPT = `(function() {
2307
2526
  }
2308
2527
  }
2309
2528
 
2310
- function scrollToAnnotation(id) {
2529
+ function scrollToAnnotation(id, behavior) {
2311
2530
  var record = findAnnRecord(id);
2312
2531
  if (!record) return;
2313
2532
  beginDeadSearchPass(Infinity); // user-initiated one-shot: never budget-starved
@@ -2323,7 +2542,7 @@ export const BRIDGE_SCRIPT = `(function() {
2323
2542
  }
2324
2543
  }
2325
2544
  if (scrollEl) {
2326
- try { scrollEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (ex) {}
2545
+ try { scrollEl.scrollIntoView({ behavior: behavior || 'smooth', block: 'center' }); } catch (ex) {}
2327
2546
  }
2328
2547
  focusAnnotationRecord(id, true);
2329
2548
  }
@@ -2505,6 +2724,7 @@ export const BRIDGE_SCRIPT = `(function() {
2505
2724
  pendingPinPoint = null;
2506
2725
  pendingPinViaPinpoint = false;
2507
2726
  multiSelectArmed = false;
2727
+ multiSelectMax = MAX_MULTI_TARGETS;
2508
2728
  hidePinpointBox();
2509
2729
  }
2510
2730
 
@@ -2608,7 +2828,7 @@ export const BRIDGE_SCRIPT = `(function() {
2608
2828
  clearPendingPin();
2609
2829
  try { window.getSelection().removeAllRanges(); } catch (ex) {}
2610
2830
  renderAnnotationOverlay();
2611
- if (echo) parent.postMessage({ type: PREFIX + 'multi-target-removed', key: key }, '*');
2831
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2612
2832
  return;
2613
2833
  }
2614
2834
  var next = pendingMultiTargets.shift();
@@ -2628,14 +2848,14 @@ export const BRIDGE_SCRIPT = `(function() {
2628
2848
  mainBox.classList.remove('pn-pin-enter');
2629
2849
  if (pendingPinEl && pendingPinEl.isConnected) positionPinpointBox(pendingPinEl);
2630
2850
  renderAnnotationOverlay();
2631
- if (echo) parent.postMessage({ type: PREFIX + 'multi-target-removed', key: key }, '*');
2851
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2632
2852
  return;
2633
2853
  }
2634
2854
  for (var i = 0; i < pendingMultiTargets.length; i++) {
2635
2855
  if (pendingMultiTargets[i].key === key) {
2636
2856
  destroyMultiTargetBox(pendingMultiTargets[i].box);
2637
2857
  pendingMultiTargets.splice(i, 1);
2638
- if (echo) parent.postMessage({ type: PREFIX + 'multi-target-removed', key: key }, '*');
2858
+ if (echo) postToParent({ type: PREFIX + 'multi-target-removed', key: key });
2639
2859
  return;
2640
2860
  }
2641
2861
  }
@@ -2669,8 +2889,9 @@ export const BRIDGE_SCRIPT = `(function() {
2669
2889
  }
2670
2890
  }
2671
2891
  }
2672
- // Cap at the source: never grow the draft past the parent-side DTO cap.
2673
- if (pendingMultiTargets.length >= MAX_MULTI_TARGETS) return;
2892
+ // Cap at the source: never grow the draft past the parent-side DTO cap
2893
+ // (or the lower product cap the parent armed this draft with).
2894
+ if (pendingMultiTargets.length >= multiSelectMax) return;
2674
2895
  var point = normalizePointInElement(el, clickPoint);
2675
2896
  if (anchor && point) anchor.point = point;
2676
2897
  var label = pinpointHoverLabel(el);
@@ -2678,13 +2899,13 @@ export const BRIDGE_SCRIPT = `(function() {
2678
2899
  var key = makeTargetKey();
2679
2900
  var box = createMultiTargetBox(el);
2680
2901
  pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, box: box });
2681
- parent.postMessage({
2902
+ postToParent({
2682
2903
  type: PREFIX + 'multi-target-added',
2683
2904
  key: key,
2684
2905
  label: label,
2685
2906
  text: text,
2686
2907
  anchor: anchor || undefined
2687
- }, '*');
2908
+ });
2688
2909
  }
2689
2910
 
2690
2911
  /** Chip hover in the composer: flash the corresponding pinned outline. */
@@ -2748,12 +2969,12 @@ export const BRIDGE_SCRIPT = `(function() {
2748
2969
  pointerRelayRaf = requestAnimationFrame(function() {
2749
2970
  pointerRelayRaf = 0;
2750
2971
  if (!pendingPinEl || !pointerRelayPos) return;
2751
- parent.postMessage({
2972
+ postToParent({
2752
2973
  type: PREFIX + 'pointer',
2753
2974
  x: pointerRelayPos.x,
2754
2975
  y: pointerRelayPos.y,
2755
2976
  shift: pointerRelayPos.shift
2756
- }, '*');
2977
+ });
2757
2978
  });
2758
2979
  }
2759
2980
 
@@ -2773,6 +2994,7 @@ export const BRIDGE_SCRIPT = `(function() {
2773
2994
  // drafts (comment -> quick label) leaves a stale arm and the bridge
2774
2995
  // accumulates pins the saved annotation will not carry.
2775
2996
  multiSelectArmed = false;
2997
+ multiSelectMax = MAX_MULTI_TARGETS;
2776
2998
  pendingPinEl = el;
2777
2999
  pendingPinAnchor = buildElementAnchor(el);
2778
3000
  pendingPinKey = makeTargetKey();
@@ -2823,22 +3045,31 @@ export const BRIDGE_SCRIPT = `(function() {
2823
3045
  pendingSelection = { element: true };
2824
3046
  pendingRange = null;
2825
3047
  skipNextClear = true; // don't let this click's mouseup clear the toolbar we just opened
2826
- parent.postMessage({ type: PREFIX + 'selection', text: elText,
3048
+ postToParent({ type: PREFIX + 'selection', text: elText,
2827
3049
  modeOverride: modeOverride || undefined,
2828
3050
  anchor: pendingPinAnchor || undefined,
2829
3051
  pinpoint: !!viaPinpoint || undefined,
2830
3052
  targetKey: pendingPinKey || undefined,
2831
3053
  targetLabel: pendingPinLabel || undefined,
2832
- rect: { top: r.top, left: r.left, width: r.width, height: r.height } }, '*');
3054
+ rect: { top: r.top, left: r.left, width: r.width, height: r.height } });
2833
3055
  return true;
2834
3056
  }
2835
3057
 
2836
3058
  document.addEventListener('click', function(e) {
2837
- if (currentInputMethod !== 'pinpoint') return;
3059
+ if (!annotateModeActive || currentInputMethod !== 'pinpoint') return;
2838
3060
  // Real placed markers (and any other viewer overlay) own their clicks —
2839
3061
  // checked by IDENTITY, not selector, so a page element spoofing
2840
3062
  // [data-plannotator-marker] stays an ordinary annotatable target.
2841
3063
  if (isViewerOverlayNode(e.target)) return;
3064
+ // A drag that ended in this click owns the surface: the drag-selection
3065
+ // pass is about to post the selected text, and pinpoint-annotating the
3066
+ // element under the pointer would clobber it (annotateElement rewrites
3067
+ // the selection). Armed at mouseup only when the drag actually produced
3068
+ // a selection — a drifted click arms nothing and pins normally below —
3069
+ // and mousedown clears a prior pin's leftover selection, so stale
3070
+ // selection state never blocks the next plain re-pin click. One-shot:
3071
+ // only the drag's own trailing click is suppressed.
3072
+ if (dragEndedClick) { dragEndedClick = false; return; }
2842
3073
  // Shift-click while an ARMED pinpoint draft is open: toggle the element
2843
3074
  // in/out of the SAME draft comment instead of replacing the selection.
2844
3075
  // Unarmed drafts (modes the parent does not mirror, e.g. quickLabel)
@@ -2865,24 +3096,51 @@ export const BRIDGE_SCRIPT = `(function() {
2865
3096
  annotateElement(el, undefined, true, { x: e.clientX, y: e.clientY });
2866
3097
  }, true);
2867
3098
 
2868
- // Escape while pinpointing (outside vim, which has its own ladder): cancel a
2869
- // pending pin, else just drop the hover outline.
3099
+ // Escape ladder (outside vim, which has its own): a pending draft closes
3100
+ // first, then the hover outline clears, then Esc EXITS Annotate back to
3101
+ // Interact — the parent owns the mode, so the final rung only posts
3102
+ // annotate-exit and waits for set-annotate-mode to come back down.
3103
+ function closePendingDraft() {
3104
+ postToParent({ type: PREFIX + 'selection-clear' });
3105
+ pendingSelection = null;
3106
+ pendingRange = null;
3107
+ skipNextClear = false;
3108
+ clearMultiTargets();
3109
+ clearPendingPin();
3110
+ window.getSelection().removeAllRanges();
3111
+ renderAnnotationOverlay();
3112
+ }
2870
3113
  document.addEventListener('keydown', function(e) {
2871
3114
  if (e.key !== 'Escape' || vimEnabled) return;
3115
+ if (!annotateModeActive) {
3116
+ // Interact mode: Esc belongs to the page — except an open drag-comment
3117
+ // draft (drag-selection stays live in Interact) still closes first.
3118
+ if (pendingSelection) closePendingDraft();
3119
+ return;
3120
+ }
2872
3121
  if (pendingSelection) {
2873
- parent.postMessage({ type: PREFIX + 'selection-clear' }, '*');
2874
- pendingSelection = null;
2875
- pendingRange = null;
2876
- skipNextClear = false;
2877
- clearMultiTargets();
2878
- clearPendingPin();
2879
- window.getSelection().removeAllRanges();
2880
- renderAnnotationOverlay();
2881
- } else if (currentInputMethod === 'pinpoint') {
2882
- clearPinpointHover();
3122
+ closePendingDraft();
3123
+ } else {
3124
+ // Hover-clear is NOT a rung: the hover outline is a pointer
3125
+ // affordance, not a state the user perceives as a step, so clearing
3126
+ // it and exiting to Interact happen on the SAME press. Only an open
3127
+ // draft earns its own press.
3128
+ if (currentInputMethod === 'pinpoint' && pinpointHover) clearPinpointHover();
3129
+ postToParent({ type: PREFIX + 'annotate-exit' });
2883
3130
  }
2884
3131
  });
2885
3132
 
3133
+ // Mod+Shift+A toggles Interact/Annotate from inside the iframe (the parent
3134
+ // registers the same chord, but focus usually lives in here on live apps).
3135
+ // Capture phase so the page cannot swallow the reserved chord; the parent
3136
+ // answers with set-annotate-mode.
3137
+ document.addEventListener('keydown', function(e) {
3138
+ if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return;
3139
+ if (e.key !== 'a' && e.key !== 'A') return;
3140
+ e.preventDefault();
3141
+ postToParent({ type: PREFIX + 'annotate-toggle' });
3142
+ }, true);
3143
+
2886
3144
  // Author opt-in: a plain click on any element tagged [data-annotate] pops the
2887
3145
  // toolbar — no pinpoint mode. Lets an HTML doc (e.g. a flow graph) wire its own
2888
3146
  // nodes to Plannotator's toolbar. Bubble phase so the page's own click handlers
@@ -2890,6 +3148,7 @@ export const BRIDGE_SCRIPT = `(function() {
2890
3148
  // a committed highlight selects the annotation instead (the pre-overlay
2891
3149
  // handler deferred to '.annotation-highlight' the same way).
2892
3150
  document.addEventListener('click', function(e) {
3151
+ if (!annotateModeActive) return; // Interact mode: [data-annotate] stays a page element
2893
3152
  if (currentInputMethod === 'pinpoint') return; // pinpoint handler covers this
2894
3153
  if (isViewerOverlayNode(e.target)) return; // placed markers own their clicks
2895
3154
  var t = e.target && e.target.closest && e.target.closest('[data-annotate]');
@@ -2933,6 +3192,10 @@ export const BRIDGE_SCRIPT = `(function() {
2933
3192
  }
2934
3193
 
2935
3194
  document.addEventListener('click', function(e) {
3195
+ // Interact mode: highlight rects are pointer-transparent projections, so a
3196
+ // page click landing on one goes to the page. Markers (real buttons) stay
3197
+ // the affordance for opening a committed comment in Interact.
3198
+ if (!annotateModeActive) return;
2936
3199
  if (e.shiftKey) return; // shift belongs to multi-select
2937
3200
  if (isViewerOverlayNode(e.target)) return; // markers own their clicks
2938
3201
  if (pendingPinEl) return; // an open pinpoint draft owns the surface
@@ -2941,7 +3204,7 @@ export const BRIDGE_SCRIPT = `(function() {
2941
3204
  var hitId = committedHighlightAt(e.clientX, e.clientY);
2942
3205
  if (!hitId) return;
2943
3206
  e.stopPropagation();
2944
- parent.postMessage({ type: PREFIX + 'mark-click', id: hitId }, '*');
3207
+ postToParent({ type: PREFIX + 'mark-click', id: hitId });
2945
3208
  });
2946
3209
 
2947
3210
  // --- Optional Vim navigation ---
@@ -3655,10 +3918,10 @@ export const BRIDGE_SCRIPT = `(function() {
3655
3918
  if (!vimEnabled) return;
3656
3919
  if (vimHudEnabled && vimLastPostedPhase !== vimPhase) {
3657
3920
  vimLastPostedPhase = vimPhase;
3658
- parent.postMessage({
3921
+ postToParent({
3659
3922
  type: PREFIX + 'vim-state',
3660
3923
  phase: vimPhase
3661
- }, '*');
3924
+ });
3662
3925
  }
3663
3926
  var badge = document.querySelector('[data-plannotator-vim-badge]');
3664
3927
  if (!vimHudEnabled && !badge) badge = getVimBadgeEl();
@@ -3720,10 +3983,10 @@ export const BRIDGE_SCRIPT = `(function() {
3720
3983
 
3721
3984
  function toggleVimHelp() {
3722
3985
  vimHelpOpen = !vimHelpOpen;
3723
- parent.postMessage({
3986
+ postToParent({
3724
3987
  type: PREFIX + 'vim-help',
3725
3988
  open: vimHelpOpen
3726
- }, '*');
3989
+ });
3727
3990
  }
3728
3991
 
3729
3992
  function clearVimUi() {
@@ -3743,10 +4006,10 @@ export const BRIDGE_SCRIPT = `(function() {
3743
4006
 
3744
4007
  function copyVimText(text) {
3745
4008
  if (!text) return;
3746
- parent.postMessage({
4009
+ postToParent({
3747
4010
  type: PREFIX + 'vim-copy',
3748
4011
  text: text
3749
- }, '*');
4012
+ });
3750
4013
  }
3751
4014
 
3752
4015
  function vimActionMode(key) {
@@ -3808,7 +4071,7 @@ export const BRIDGE_SCRIPT = `(function() {
3808
4071
  handled = true;
3809
4072
  } else if (key === 'Escape') {
3810
4073
  if (pendingSelection) {
3811
- parent.postMessage({ type: PREFIX + 'selection-clear' }, '*');
4074
+ postToParent({ type: PREFIX + 'selection-clear' });
3812
4075
  pendingSelection = null;
3813
4076
  pendingRange = null;
3814
4077
  restoreVimSemanticTarget();
@@ -4025,12 +4288,12 @@ export const BRIDGE_SCRIPT = `(function() {
4025
4288
  vimLastActionId = vimActionId;
4026
4289
  vimLastActionContext = vimCommandContext;
4027
4290
  updateVimReticle();
4028
- parent.postMessage({
4291
+ postToParent({
4029
4292
  type: PREFIX + 'vim-command',
4030
4293
  actionId: vimActionId,
4031
4294
  key: hudKey,
4032
4295
  context: vimCommandContext
4033
- }, '*');
4296
+ });
4034
4297
  }
4035
4298
  e.preventDefault();
4036
4299
  e.stopImmediatePropagation();
@@ -4064,7 +4327,7 @@ export const BRIDGE_SCRIPT = `(function() {
4064
4327
  if (e.metaKey || e.ctrlKey || e.altKey) return;
4065
4328
  if (!e.key || e.key.length !== 1) return; // single printable char only
4066
4329
  e.preventDefault();
4067
- parent.postMessage({ type: PREFIX + 'keytype', key: e.key }, '*');
4330
+ postToParent({ type: PREFIX + 'keytype', key: e.key });
4068
4331
  // Hand keyboard focus back to the parent window so the comment textarea can
4069
4332
  // take it. Blurring the <iframe> from the parent isn't enough — the inner
4070
4333
  // document keeps focus — so the iframe must relinquish it. parent.focus() is
@@ -4305,7 +4568,13 @@ export const BRIDGE_SCRIPT = `(function() {
4305
4568
  }).observe(document.body);
4306
4569
  }
4307
4570
  watchPageMutations();
4308
- parent.postMessage({ type: PREFIX + 'ready' }, '*');
4571
+ // Armed is the default on both surfaces, and live sessions default to
4572
+ // pinpoint: show the cursor affordance immediately instead of waiting for
4573
+ // the parent's first set-input-method/set-annotate-mode round trip.
4574
+ updatePinpointCursor();
4575
+ var readyMsg = { type: PREFIX + 'ready' };
4576
+ if (LIVE) readyMsg.pageUrl = currentPageUrl();
4577
+ postToParent(readyMsg);
4309
4578
  }
4310
4579
  if (document.readyState === 'loading') {
4311
4580
  document.addEventListener('DOMContentLoaded', onReady);
@@ -4333,3 +4602,22 @@ export const BRIDGE_SCRIPT = `(function() {
4333
4602
  }
4334
4603
  };
4335
4604
  })();`;
4605
+
4606
+ /**
4607
+ * Live-mode bootstrap, prepended to BRIDGE_SCRIPT by the annotate server when
4608
+ * composing the proxy-served bridge body. Reads the JSON config prelude
4609
+ * (window.__plannotatorLiveConfig) and installs the annotation CSS that srcdoc
4610
+ * mode splices as a <style> tag. Runs before the bridge IIFE and before its
4611
+ * MutationObserver exists, so this write never feeds the reconcile loop.
4612
+ * Same escaping rules as BRIDGE_SCRIPT: a dependency-free string constant.
4613
+ */
4614
+ export const LIVE_BRIDGE_BOOTSTRAP = `(function() {
4615
+ var config = window.__plannotatorLiveConfig;
4616
+ if (!config || typeof config.css !== 'string') return;
4617
+ try {
4618
+ var style = document.createElement('style');
4619
+ style.setAttribute('data-plannotator-live-css', '');
4620
+ style.appendChild(document.createTextNode(config.css));
4621
+ (document.head || document.documentElement).appendChild(style);
4622
+ } catch (ex) {}
4623
+ })();`;