@plannotator/ui 0.49.0 → 0.51.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 (64) hide show
  1. package/HANDOFF.md +202 -1
  2. package/LICENSE +191 -0
  3. package/README.md +23 -0
  4. package/components/AISettingsTab.tsx +33 -1
  5. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  6. package/components/AgentsTab.tsx +9 -9
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/CommentPopover.tsx +32 -18
  9. package/components/DecisionControl.tsx +2 -7
  10. package/components/DiagramBlock.tsx +46 -4
  11. package/components/DiffFileTree.tsx +363 -0
  12. package/components/DocBadges.tsx +12 -8
  13. package/components/ImageLightbox.tsx +66 -0
  14. package/components/PlanHeaderMenu.tsx +1 -1
  15. package/components/ProviderIcons.tsx +15 -3
  16. package/components/Settings.tsx +73 -1
  17. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  18. package/components/Viewer.tsx +5 -28
  19. package/components/ai/AIProviderBar.tsx +20 -3
  20. package/components/ai/DocumentAIChatPanel.tsx +23 -3
  21. package/components/ai/SessionAskNotice.tsx +101 -0
  22. package/components/blocks/QuestionBlock.tsx +24 -19
  23. package/components/blocks/QuestionContext.tsx +81 -0
  24. package/components/diagram/DiagramComposer.tsx +54 -3
  25. package/components/diagram/DiagramViewer.tsx +31 -2
  26. package/components/diagram/index.ts +1 -1
  27. package/components/html-viewer/HtmlViewer.tsx +13 -3
  28. package/components/html-viewer/bridge-script.asset.js +209 -16
  29. package/components/html-viewer/bridge-script.ts +209 -16
  30. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  31. package/configure.ts +12 -0
  32. package/hooks/useAIChat.ts +96 -10
  33. package/hooks/useAIProviderActivation.ts +13 -0
  34. package/hooks/useAIProviderConfig.ts +32 -14
  35. package/hooks/useActiveSection.ts +3 -2
  36. package/hooks/useAgentToolSetting.ts +33 -0
  37. package/hooks/useAnnotationDraft.ts +53 -7
  38. package/hooks/useAnnotationHighlighter.ts +101 -5
  39. package/hooks/useDiffFileTreeExpansion.ts +92 -0
  40. package/hooks/useExternalAnnotations.ts +21 -1
  41. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  42. package/hooks/useLatchedTrue.ts +15 -0
  43. package/hooks/useLinkedDoc.ts +64 -4
  44. package/hooks/useModelCatalogs.ts +9 -2
  45. package/package.json +3 -2
  46. package/styles.css +1 -1
  47. package/themes/gruvbox.css +6 -0
  48. package/types.ts +17 -0
  49. package/utils/agentClosed.ts +33 -0
  50. package/utils/agentToolAnnouncement.ts +109 -0
  51. package/utils/agentToolSetting.ts +108 -0
  52. package/utils/aiPrompt.ts +31 -0
  53. package/utils/aiProvider.ts +41 -0
  54. package/utils/annotationOwnsHighlight.ts +27 -0
  55. package/utils/annotationScope.ts +15 -0
  56. package/utils/askSessionAnnouncement.ts +107 -0
  57. package/utils/blockTargeting.ts +61 -2
  58. package/utils/diagramAskAI.ts +161 -0
  59. package/utils/diffFileTree.ts +203 -0
  60. package/utils/htmlLinkNavigation.ts +87 -3
  61. package/utils/parser.ts +236 -110
  62. package/utils/questionAnswers.ts +3 -1
  63. package/utils/quickLabels.ts +2 -2
  64. package/utils/syntaxTheme.ts +33 -0
@@ -17,6 +17,7 @@ import {
17
17
  import type { Annotation, EditorMode, ImageAttachment, InputMethod } from "../../types";
18
18
  import { AnnotationType } from "../../types";
19
19
  import { copyTextPreservingFocus } from "../../utils/clipboard";
20
+ import { elementIdentityForAskAI } from "../../utils/parser";
20
21
  import { getIdentity } from "../../utils/identity";
21
22
  import { THUMBS_UP_LABEL } from "../../utils/quickLabels";
22
23
  import {
@@ -674,6 +675,13 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
674
675
  }));
675
676
  }, [hook.draftTargets]);
676
677
 
678
+ // Ask AI from a pinpoint draft: which element(s) the question is about,
679
+ // in the identity lines the feedback export prints (no outline).
680
+ const askAIElementDetail = useMemo(
681
+ () => elementIdentityForAskAI(hook.draftTargets) || undefined,
682
+ [hook.draftTargets],
683
+ );
684
+
677
685
  useEffect(() => {
678
686
  function handler(e: MessageEvent<unknown>) {
679
687
  if (e.source !== iframeRef.current?.contentWindow) return;
@@ -1244,9 +1252,10 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1244
1252
  draftKey={`html:${hook.commentPopover.draftKey}`}
1245
1253
  onSubmit={hook.handleCommentSubmit}
1246
1254
  mentionSource={mentionSource}
1247
- // Pinpoint clicks open this composer directly, so it carries
1248
- // the surface's one-click "Looks good" (the global composer
1249
- // does not: a document-wide thumbs-up is not a thing).
1255
+ // A pinpoint click opens this composer directly (never the
1256
+ // selection toolbar), so it carries the surface's one-click 👍.
1257
+ // It also shows when the composer opens from a text selection's
1258
+ // Comment button: a harmless duplicate of the toolbar 👍.
1250
1259
  onQuickLookGood={hook.handleCommentLooksGood}
1251
1260
  onClose={hook.handleCommentClose}
1252
1261
  skillReferences
@@ -1255,6 +1264,7 @@ export const HtmlViewer = forwardRef<ViewerHandle, HtmlViewerProps>(
1255
1264
  kind: "selection",
1256
1265
  label: "Selected HTML",
1257
1266
  text: hook.commentPopover.selectedText ?? hook.commentPopover.contextText,
1267
+ ...(askAIElementDetail ? { detail: askAIElementDetail } : {}),
1258
1268
  }}
1259
1269
  targetChips={targetChips}
1260
1270
  onRemoveTargetChip={targetChips ? hook.removeDraftTarget : undefined}
@@ -96,17 +96,89 @@
96
96
  });
97
97
 
98
98
  // --- Resize ---
99
- var lastHeight = 0;
99
+ // The last child of el whose bottom margin could collapse through el's
100
+ // bottom edge: out-of-flow children (display:none, absolute/fixed, floats)
101
+ // never do, which also skips the bridge's own fixed overlay elements and a
102
+ // trailing script element.
103
+ function lastInFlowChild(el) {
104
+ for (var child = el.lastElementChild; child; child = child.previousElementSibling) {
105
+ var cs = window.getComputedStyle(child);
106
+ if (cs.display === 'none' || cs.display === 'contents') continue;
107
+ if (cs.position === 'absolute' || cs.position === 'fixed') continue;
108
+ if (cs.float && cs.float !== 'none') continue;
109
+ return child;
110
+ }
111
+ return null;
112
+ }
113
+
114
+ // Height the auto-sized frame needs to show the whole document.
115
+ // Not body.scrollHeight alone: when body has no top padding or border, a
116
+ // first child's top margin collapses THROUGH body and moves body's box down
117
+ // without making it taller (the same holds at the bottom), so the page was
118
+ // cut short by those margins. Not documentElement.scrollHeight either: it
119
+ // never reports less than the frame's own viewport, so the frame could grow
120
+ // but never shrink back, and an html{height:100%} page would pin it at
121
+ // whatever height it had. Instead body's box is placed in document
122
+ // coordinates (its top already includes a collapsed top margin), and the
123
+ // bottom margins that can collapse through body's bottom edge are added by
124
+ // walking the last in-flow child chain. Nothing here reads the frame's own
125
+ // height, so the measure cannot feed back on itself.
126
+ function measureContentHeight() {
127
+ var body = document.body;
128
+ var scrollY = window.pageYOffset || 0;
129
+ var bodyRect = body.getBoundingClientRect();
130
+ var bottom = Math.max(bodyRect.bottom, bodyRect.top + (body.clientTop || 0) + body.scrollHeight) + scrollY;
131
+ var el = body;
132
+ for (var depth = 0; el && depth < 64; depth++) {
133
+ var cs = window.getComputedStyle(el);
134
+ var marginBottom = parseFloat(cs.marginBottom) || 0;
135
+ if (marginBottom > 0) bottom = Math.max(bottom, el.getBoundingClientRect().bottom + scrollY + marginBottom);
136
+ // A child's bottom margin only collapses through a plain block parent
137
+ // with no bottom padding or border that does not clip (flex/grid
138
+ // containers, flow-root and scroll containers keep it inside).
139
+ if (cs.display !== 'block' && cs.display !== 'list-item') break;
140
+ if (cs.overflowY && cs.overflowY !== 'visible') break;
141
+ if ((parseFloat(cs.paddingBottom) || 0) > 0 || (parseFloat(cs.borderBottomWidth) || 0) > 0) break;
142
+ el = lastInFlowChild(el);
143
+ }
144
+ var rootStyle = window.getComputedStyle(document.documentElement);
145
+ bottom += (parseFloat(rootStyle.paddingBottom) || 0) + (parseFloat(rootStyle.borderBottomWidth) || 0);
146
+ return Math.max(0, Math.ceil(bottom));
147
+ }
148
+
149
+ // Feedback guard. Content sized by the frame itself (html,body{height:100%},
150
+ // min-height:100vh, a 100vh section) plus anything around it (a collapsed
151
+ // margin, body's own margin) always measures taller than the frame, so every
152
+ // grow would ask for another: the frame would grow forever. The first
153
+ // measure after the parent applied a GROW we asked for decides: if the page
154
+ // still overflows by at least as much as it did before the grow, it grew
155
+ // with the frame, and the frame stays where it is. Later content changes
156
+ // post as usual (and re-run the check on their own grow). Pure, so the
157
+ // test can drive it: returns the height to post, or null for none.
158
+ function nextResizeHeight(state, h, viewport) {
159
+ var applied = state.awaiting && Math.abs(viewport - state.last) <= 1;
160
+ if (applied) state.awaiting = false;
161
+ if (h === state.last) return null;
162
+ if (applied && state.excess > 0 && h > viewport && h - viewport >= state.excess - 1) {
163
+ state.last = h; // held: report nothing until the content measures differently
164
+ return null;
165
+ }
166
+ state.excess = h - viewport;
167
+ state.awaiting = true;
168
+ state.last = h;
169
+ return h;
170
+ }
171
+
172
+ var resizeState = { last: 0, excess: 0, awaiting: false };
100
173
  function postResize() {
101
174
  if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
102
175
  if (!document.body) return;
103
- var h = document.body.scrollHeight;
104
- if (h !== lastHeight) {
105
- lastHeight = h;
106
- postToParent({ type: PREFIX + 'resize', height: h });
107
- }
176
+ var h = nextResizeHeight(resizeState, measureContentHeight(), window.innerHeight);
177
+ if (h !== null) postToParent({ type: PREFIX + 'resize', height: h });
108
178
  }
109
179
  window.addEventListener('load', postResize);
180
+ // The frame resizing is what tells the guard its last grow was applied.
181
+ window.addEventListener('resize', postResize, { passive: true });
110
182
 
111
183
  // --- Selection ---
112
184
  var pendingSelection = null;
@@ -908,6 +980,10 @@
908
980
 
909
981
  // Floating label naming the element under the cursor (like the markdown overlay).
910
982
  var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
983
+ // Media kinds name themselves only when the page gives no accessible name:
984
+ // unlike the semantic tags above, an aria-label on a <video> is a better
985
+ // label than "Video", and it keeps winning exactly as it did before.
986
+ var MEDIA_PINPOINT_LABELS = { VIDEO:'Video', AUDIO:'Audio', IFRAME:'Frame', CANVAS:'Canvas' };
911
987
 
912
988
  var MAX_HOVER_LABEL = 40;
913
989
  function truncateLabel(text) {
@@ -943,6 +1019,8 @@
943
1019
  if (known) return known;
944
1020
  var aria = el.getAttribute && el.getAttribute('aria-label');
945
1021
  if (aria && aria.trim()) return truncateLabel(aria.trim());
1022
+ var media = MEDIA_PINPOINT_LABELS[el.tagName];
1023
+ if (media) return media;
946
1024
  var role = el.getAttribute && el.getAttribute('role');
947
1025
  if (role && role.trim()) return truncateLabel(role.trim());
948
1026
  var tokens = meaningfulClassTokens(el);
@@ -2608,6 +2686,25 @@
2608
2686
  function ctxScrubUrl(value) {
2609
2687
  var v = String(value).trim();
2610
2688
  if (/^javascript:/i.test(v)) return null;
2689
+ // Srcdoc sessions serve a document's relative assets through the
2690
+ // session's own asset route (/api/html-assets/<token>/<path>). The token
2691
+ // means nothing outside this session; the path after it is the author's
2692
+ // own, relative to the document, which is what an agent can find.
2693
+ if (!LIVE) {
2694
+ var asset = v.match(/^\/api\/html-assets\/[^\/?#]+\/([^?#]*)/);
2695
+ if (asset && asset[1]) {
2696
+ // The route percent-encodes each segment; show the name the author
2697
+ // wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
2698
+ // that decodes to ? or # (which the query scrub below would cut), keeps
2699
+ // the raw text.
2700
+ var authored = asset[1];
2701
+ try {
2702
+ var decoded = decodeURIComponent(authored);
2703
+ if (!/[?#]/.test(decoded)) authored = decoded;
2704
+ } catch (ex) {}
2705
+ v = authored + v.slice(asset[0].length);
2706
+ }
2707
+ }
2611
2708
  if (/^data:/i.test(v)) {
2612
2709
  var comma = v.indexOf(',');
2613
2710
  return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
@@ -2917,7 +3014,8 @@
2917
3014
  }
2918
3015
 
2919
3016
  // Shed order when over budget: the fields an agent can live without first.
2920
- var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
3017
+ // Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
3018
+ var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
2921
3019
 
2922
3020
  function buildElementContext(el, maxBytes) {
2923
3021
  if (!el || el.nodeType !== 1) return null;
@@ -2944,6 +3042,12 @@
2944
3042
  if (heading) context.heading = heading;
2945
3043
  var component = ctxComponent(el);
2946
3044
  if (component) context.component = component;
3045
+ // The media file this element shows, resolved exactly as the composer
3046
+ // quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
3047
+ // poster; see descRawSource), so the exported heading can name the file
3048
+ // even when the element has no usable src attribute.
3049
+ var sourceName = descSourceName(el);
3050
+ if (sourceName) context.sourceName = sourceName;
2947
3051
  if (LIVE) {
2948
3052
  context.page = { url: currentPageUrl() };
2949
3053
  var title = ctxCollapse(document.title, 200);
@@ -3056,11 +3160,95 @@
3056
3160
  }
3057
3161
  }
3058
3162
 
3059
- // Element text for an additional target: same capping and text-less
3060
- // description used by the primary element path in annotateElement.
3163
+ // --- Text-less element descriptions ---
3164
+ // A pinpoint on an element with no text quotes a description instead
3165
+ // ("[element: …]"). The hover label alone ("Image") cannot tell one image
3166
+ // from the next, so the description adds what identifies THIS element: its
3167
+ // accessible name (aria-label, alt, title, ...) and, for media, the source
3168
+ // file name. The source goes through the same scrub the element context
3169
+ // uses (no query, no fragment, data: reduced to its media type). Square
3170
+ // brackets become parentheses and double quotes single ones, so the whole
3171
+ // description stays one "[element: …]" token the export recognizes.
3172
+ var DESC_MAX_NAME = 80;
3173
+ var DESC_MAX_FILE = 60;
3174
+ var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
3175
+
3176
+ function descClean(value, max) {
3177
+ return ctxCollapse(String(value).replace(/\[/g, '(').replace(/\]/g, ')').replace(/"/g, "'"), max);
3178
+ }
3179
+
3180
+ // Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
3181
+ // first candidate's first word. A data: URL carries its own comma, so it is
3182
+ // taken up to whitespace instead.
3183
+ function descFirstSrcsetUrl(value) {
3184
+ var srcset = String(value || '').trim();
3185
+ if (!srcset) return '';
3186
+ return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\s+/)[0] || '';
3187
+ }
3188
+
3189
+ function descRawSource(el) {
3190
+ var tag = el.tagName;
3191
+ if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
3192
+ var attr = DESC_SRC_ATTR[tag];
3193
+ var src = '';
3194
+ if (attr) src = (el.getAttribute(attr) || '').trim();
3195
+ else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
3196
+ else return '';
3197
+ // Lazy loaders put a data: placeholder in src and the real file in data-src.
3198
+ var lazy = (el.getAttribute('data-src') || '').trim();
3199
+ if (lazy && (!src || /^data:/i.test(src))) src = lazy;
3200
+ if (!src && tag === 'IMG') {
3201
+ src = descFirstSrcsetUrl(el.getAttribute('srcset'));
3202
+ // <picture><source srcset="…"><img></picture>: the img itself may carry
3203
+ // no source at all; the first <source> candidate names the file.
3204
+ var picture = el.parentElement;
3205
+ if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
3206
+ var pictureSource = picture.querySelector('source[srcset]');
3207
+ if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
3208
+ }
3209
+ }
3210
+ if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
3211
+ var child = el.querySelector && el.querySelector('source[src]');
3212
+ if (child) src = (child.getAttribute('src') || '').trim();
3213
+ if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
3214
+ }
3215
+ return src;
3216
+ }
3217
+
3218
+ // The file name a source points at: "team.jpg" for
3219
+ // https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
3220
+ function descSourceName(el) {
3221
+ var src = descRawSource(el);
3222
+ if (!src) return '';
3223
+ var scrubbed = ctxScrubUrl(src);
3224
+ if (!scrubbed) return '';
3225
+ if (/^data:/i.test(scrubbed)) {
3226
+ var media = scrubbed.slice(5).split(/[;,]/)[0];
3227
+ return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
3228
+ }
3229
+ if (/^blob:/i.test(scrubbed)) return 'blob';
3230
+ var path = scrubbed.replace(/\?…$/, '').replace(/\/+$/, '');
3231
+ var name = path.slice(path.lastIndexOf('/') + 1);
3232
+ try { name = decodeURIComponent(name); } catch (ex) {}
3233
+ if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
3234
+ return descClean(name, DESC_MAX_FILE);
3235
+ }
3236
+
3237
+ function describeTextlessElement(el, label) {
3238
+ var out = descClean(label, DESC_MAX_NAME) || 'element';
3239
+ var name = ctxName(el);
3240
+ name = name ? descClean(name, DESC_MAX_NAME) : '';
3241
+ if (name && name !== out) out += ' "' + name + '"';
3242
+ var file = descSourceName(el);
3243
+ if (file && file !== name) out += ' (' + file + ')';
3244
+ return out;
3245
+ }
3246
+
3247
+ // Element text for a pinpoint target (primary or additional): the
3248
+ // element's own text, or for a text-less element its description.
3061
3249
  function elementTargetText(el, label) {
3062
3250
  var text = capSelectionText((el.textContent || '').trim());
3063
- if (!text) text = capSelectionText('[element: ' + label + ']');
3251
+ if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
3064
3252
  return text;
3065
3253
  }
3066
3254
 
@@ -3299,10 +3487,10 @@
3299
3487
  if (!posted) clearPendingPin();
3300
3488
  return posted;
3301
3489
  }
3302
- var elText = capSelectionText((el.textContent || '').trim());
3303
- // Text-less elements (icon buttons, decorative chips, empty containers)
3304
- // are still annotatable: describe the element instead of quoting it.
3305
- if (!elText) elText = capSelectionText('[element: ' + pinpointHoverLabel(el) + ']');
3490
+ // Text-less elements (images, icon buttons, decorative chips, empty
3491
+ // containers) are still annotatable: describe the element instead of
3492
+ // quoting it, naming WHICH one it is where the page says so.
3493
+ var elText = elementTargetText(el, pinpointHoverLabel(el));
3306
3494
  var r = el.getBoundingClientRect();
3307
3495
  pendingSelection = { element: true };
3308
3496
  pendingRange = null;
@@ -4898,11 +5086,16 @@
4898
5086
 
4899
5087
  function onReady() {
4900
5088
  if (typeof ResizeObserver !== 'undefined' && document.body) {
4901
- new ResizeObserver(function() {
5089
+ var resizeObserver = new ResizeObserver(function() {
4902
5090
  postResize();
4903
5091
  scheduleVimUiUpdate();
4904
5092
  schedulePinpointReconcile();
4905
- }).observe(document.body);
5093
+ });
5094
+ resizeObserver.observe(document.body);
5095
+ // A collapsed margin changing moves body without resizing it; the root
5096
+ // box does resize then. (An html{height:100%} root resizes with the
5097
+ // frame instead, which re-measures the same height and posts nothing.)
5098
+ resizeObserver.observe(document.documentElement);
4906
5099
  }
4907
5100
  watchPageMutations();
4908
5101
  // Armed is the default on both surfaces, and live sessions default to
@@ -332,17 +332,89 @@ export const BRIDGE_SCRIPT = `(function() {
332
332
  });
333
333
 
334
334
  // --- Resize ---
335
- var lastHeight = 0;
335
+ // The last child of el whose bottom margin could collapse through el's
336
+ // bottom edge: out-of-flow children (display:none, absolute/fixed, floats)
337
+ // never do, which also skips the bridge's own fixed overlay elements and a
338
+ // trailing script element.
339
+ function lastInFlowChild(el) {
340
+ for (var child = el.lastElementChild; child; child = child.previousElementSibling) {
341
+ var cs = window.getComputedStyle(child);
342
+ if (cs.display === 'none' || cs.display === 'contents') continue;
343
+ if (cs.position === 'absolute' || cs.position === 'fixed') continue;
344
+ if (cs.float && cs.float !== 'none') continue;
345
+ return child;
346
+ }
347
+ return null;
348
+ }
349
+
350
+ // Height the auto-sized frame needs to show the whole document.
351
+ // Not body.scrollHeight alone: when body has no top padding or border, a
352
+ // first child's top margin collapses THROUGH body and moves body's box down
353
+ // without making it taller (the same holds at the bottom), so the page was
354
+ // cut short by those margins. Not documentElement.scrollHeight either: it
355
+ // never reports less than the frame's own viewport, so the frame could grow
356
+ // but never shrink back, and an html{height:100%} page would pin it at
357
+ // whatever height it had. Instead body's box is placed in document
358
+ // coordinates (its top already includes a collapsed top margin), and the
359
+ // bottom margins that can collapse through body's bottom edge are added by
360
+ // walking the last in-flow child chain. Nothing here reads the frame's own
361
+ // height, so the measure cannot feed back on itself.
362
+ function measureContentHeight() {
363
+ var body = document.body;
364
+ var scrollY = window.pageYOffset || 0;
365
+ var bodyRect = body.getBoundingClientRect();
366
+ var bottom = Math.max(bodyRect.bottom, bodyRect.top + (body.clientTop || 0) + body.scrollHeight) + scrollY;
367
+ var el = body;
368
+ for (var depth = 0; el && depth < 64; depth++) {
369
+ var cs = window.getComputedStyle(el);
370
+ var marginBottom = parseFloat(cs.marginBottom) || 0;
371
+ if (marginBottom > 0) bottom = Math.max(bottom, el.getBoundingClientRect().bottom + scrollY + marginBottom);
372
+ // A child's bottom margin only collapses through a plain block parent
373
+ // with no bottom padding or border that does not clip (flex/grid
374
+ // containers, flow-root and scroll containers keep it inside).
375
+ if (cs.display !== 'block' && cs.display !== 'list-item') break;
376
+ if (cs.overflowY && cs.overflowY !== 'visible') break;
377
+ if ((parseFloat(cs.paddingBottom) || 0) > 0 || (parseFloat(cs.borderBottomWidth) || 0) > 0) break;
378
+ el = lastInFlowChild(el);
379
+ }
380
+ var rootStyle = window.getComputedStyle(document.documentElement);
381
+ bottom += (parseFloat(rootStyle.paddingBottom) || 0) + (parseFloat(rootStyle.borderBottomWidth) || 0);
382
+ return Math.max(0, Math.ceil(bottom));
383
+ }
384
+
385
+ // Feedback guard. Content sized by the frame itself (html,body{height:100%},
386
+ // min-height:100vh, a 100vh section) plus anything around it (a collapsed
387
+ // margin, body's own margin) always measures taller than the frame, so every
388
+ // grow would ask for another: the frame would grow forever. The first
389
+ // measure after the parent applied a GROW we asked for decides: if the page
390
+ // still overflows by at least as much as it did before the grow, it grew
391
+ // with the frame, and the frame stays where it is. Later content changes
392
+ // post as usual (and re-run the check on their own grow). Pure, so the
393
+ // test can drive it: returns the height to post, or null for none.
394
+ function nextResizeHeight(state, h, viewport) {
395
+ var applied = state.awaiting && Math.abs(viewport - state.last) <= 1;
396
+ if (applied) state.awaiting = false;
397
+ if (h === state.last) return null;
398
+ if (applied && state.excess > 0 && h > viewport && h - viewport >= state.excess - 1) {
399
+ state.last = h; // held: report nothing until the content measures differently
400
+ return null;
401
+ }
402
+ state.excess = h - viewport;
403
+ state.awaiting = true;
404
+ state.last = h;
405
+ return h;
406
+ }
407
+
408
+ var resizeState = { last: 0, excess: 0, awaiting: false };
336
409
  function postResize() {
337
410
  if (LIVE) return; // live surfaces render full-viewport; the parent ignores height
338
411
  if (!document.body) return;
339
- var h = document.body.scrollHeight;
340
- if (h !== lastHeight) {
341
- lastHeight = h;
342
- postToParent({ type: PREFIX + 'resize', height: h });
343
- }
412
+ var h = nextResizeHeight(resizeState, measureContentHeight(), window.innerHeight);
413
+ if (h !== null) postToParent({ type: PREFIX + 'resize', height: h });
344
414
  }
345
415
  window.addEventListener('load', postResize);
416
+ // The frame resizing is what tells the guard its last grow was applied.
417
+ window.addEventListener('resize', postResize, { passive: true });
346
418
 
347
419
  // --- Selection ---
348
420
  var pendingSelection = null;
@@ -1144,6 +1216,10 @@ export const BRIDGE_SCRIPT = `(function() {
1144
1216
 
1145
1217
  // Floating label naming the element under the cursor (like the markdown overlay).
1146
1218
  var PINPOINT_LABELS = { H1:'Heading', H2:'Heading', H3:'Heading', H4:'Heading', H5:'Heading', H6:'Heading', P:'Paragraph', UL:'List', OL:'List', LI:'List item', A:'Link', BUTTON:'Button', IMG:'Image', TABLE:'Table', THEAD:'Table', TBODY:'Table', TR:'Row', TD:'Cell', TH:'Header cell', SECTION:'Section', NAV:'Navigation', HEADER:'Header', FOOTER:'Footer', ARTICLE:'Article', ASIDE:'Sidebar', BLOCKQUOTE:'Quote', PRE:'Code', CODE:'Code', FIGURE:'Figure', FIGCAPTION:'Caption', MAIN:'Main', FORM:'Form', INPUT:'Input', LABEL:'Label' };
1219
+ // Media kinds name themselves only when the page gives no accessible name:
1220
+ // unlike the semantic tags above, an aria-label on a <video> is a better
1221
+ // label than "Video", and it keeps winning exactly as it did before.
1222
+ var MEDIA_PINPOINT_LABELS = { VIDEO:'Video', AUDIO:'Audio', IFRAME:'Frame', CANVAS:'Canvas' };
1147
1223
 
1148
1224
  var MAX_HOVER_LABEL = 40;
1149
1225
  function truncateLabel(text) {
@@ -1179,6 +1255,8 @@ export const BRIDGE_SCRIPT = `(function() {
1179
1255
  if (known) return known;
1180
1256
  var aria = el.getAttribute && el.getAttribute('aria-label');
1181
1257
  if (aria && aria.trim()) return truncateLabel(aria.trim());
1258
+ var media = MEDIA_PINPOINT_LABELS[el.tagName];
1259
+ if (media) return media;
1182
1260
  var role = el.getAttribute && el.getAttribute('role');
1183
1261
  if (role && role.trim()) return truncateLabel(role.trim());
1184
1262
  var tokens = meaningfulClassTokens(el);
@@ -2844,6 +2922,25 @@ export const BRIDGE_SCRIPT = `(function() {
2844
2922
  function ctxScrubUrl(value) {
2845
2923
  var v = String(value).trim();
2846
2924
  if (/^javascript:/i.test(v)) return null;
2925
+ // Srcdoc sessions serve a document's relative assets through the
2926
+ // session's own asset route (/api/html-assets/<token>/<path>). The token
2927
+ // means nothing outside this session; the path after it is the author's
2928
+ // own, relative to the document, which is what an agent can find.
2929
+ if (!LIVE) {
2930
+ var asset = v.match(/^\\/api\\/html-assets\\/[^\\/?#]+\\/([^?#]*)/);
2931
+ if (asset && asset[1]) {
2932
+ // The route percent-encodes each segment; show the name the author
2933
+ // wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
2934
+ // that decodes to ? or # (which the query scrub below would cut), keeps
2935
+ // the raw text.
2936
+ var authored = asset[1];
2937
+ try {
2938
+ var decoded = decodeURIComponent(authored);
2939
+ if (!/[?#]/.test(decoded)) authored = decoded;
2940
+ } catch (ex) {}
2941
+ v = authored + v.slice(asset[0].length);
2942
+ }
2943
+ }
2847
2944
  if (/^data:/i.test(v)) {
2848
2945
  var comma = v.indexOf(',');
2849
2946
  return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
@@ -3153,7 +3250,8 @@ export const BRIDGE_SCRIPT = `(function() {
3153
3250
  }
3154
3251
 
3155
3252
  // Shed order when over budget: the fields an agent can live without first.
3156
- var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
3253
+ // Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
3254
+ var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
3157
3255
 
3158
3256
  function buildElementContext(el, maxBytes) {
3159
3257
  if (!el || el.nodeType !== 1) return null;
@@ -3180,6 +3278,12 @@ export const BRIDGE_SCRIPT = `(function() {
3180
3278
  if (heading) context.heading = heading;
3181
3279
  var component = ctxComponent(el);
3182
3280
  if (component) context.component = component;
3281
+ // The media file this element shows, resolved exactly as the composer
3282
+ // quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
3283
+ // poster; see descRawSource), so the exported heading can name the file
3284
+ // even when the element has no usable src attribute.
3285
+ var sourceName = descSourceName(el);
3286
+ if (sourceName) context.sourceName = sourceName;
3183
3287
  if (LIVE) {
3184
3288
  context.page = { url: currentPageUrl() };
3185
3289
  var title = ctxCollapse(document.title, 200);
@@ -3292,11 +3396,95 @@ export const BRIDGE_SCRIPT = `(function() {
3292
3396
  }
3293
3397
  }
3294
3398
 
3295
- // Element text for an additional target: same capping and text-less
3296
- // description used by the primary element path in annotateElement.
3399
+ // --- Text-less element descriptions ---
3400
+ // A pinpoint on an element with no text quotes a description instead
3401
+ // ("[element: …]"). The hover label alone ("Image") cannot tell one image
3402
+ // from the next, so the description adds what identifies THIS element: its
3403
+ // accessible name (aria-label, alt, title, ...) and, for media, the source
3404
+ // file name. The source goes through the same scrub the element context
3405
+ // uses (no query, no fragment, data: reduced to its media type). Square
3406
+ // brackets become parentheses and double quotes single ones, so the whole
3407
+ // description stays one "[element: …]" token the export recognizes.
3408
+ var DESC_MAX_NAME = 80;
3409
+ var DESC_MAX_FILE = 60;
3410
+ var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
3411
+
3412
+ function descClean(value, max) {
3413
+ return ctxCollapse(String(value).replace(/\\[/g, '(').replace(/\\]/g, ')').replace(/"/g, "'"), max);
3414
+ }
3415
+
3416
+ // Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
3417
+ // first candidate's first word. A data: URL carries its own comma, so it is
3418
+ // taken up to whitespace instead.
3419
+ function descFirstSrcsetUrl(value) {
3420
+ var srcset = String(value || '').trim();
3421
+ if (!srcset) return '';
3422
+ return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\\s+/)[0] || '';
3423
+ }
3424
+
3425
+ function descRawSource(el) {
3426
+ var tag = el.tagName;
3427
+ if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
3428
+ var attr = DESC_SRC_ATTR[tag];
3429
+ var src = '';
3430
+ if (attr) src = (el.getAttribute(attr) || '').trim();
3431
+ else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
3432
+ else return '';
3433
+ // Lazy loaders put a data: placeholder in src and the real file in data-src.
3434
+ var lazy = (el.getAttribute('data-src') || '').trim();
3435
+ if (lazy && (!src || /^data:/i.test(src))) src = lazy;
3436
+ if (!src && tag === 'IMG') {
3437
+ src = descFirstSrcsetUrl(el.getAttribute('srcset'));
3438
+ // <picture><source srcset="…"><img></picture>: the img itself may carry
3439
+ // no source at all; the first <source> candidate names the file.
3440
+ var picture = el.parentElement;
3441
+ if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
3442
+ var pictureSource = picture.querySelector('source[srcset]');
3443
+ if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
3444
+ }
3445
+ }
3446
+ if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
3447
+ var child = el.querySelector && el.querySelector('source[src]');
3448
+ if (child) src = (child.getAttribute('src') || '').trim();
3449
+ if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
3450
+ }
3451
+ return src;
3452
+ }
3453
+
3454
+ // The file name a source points at: "team.jpg" for
3455
+ // https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
3456
+ function descSourceName(el) {
3457
+ var src = descRawSource(el);
3458
+ if (!src) return '';
3459
+ var scrubbed = ctxScrubUrl(src);
3460
+ if (!scrubbed) return '';
3461
+ if (/^data:/i.test(scrubbed)) {
3462
+ var media = scrubbed.slice(5).split(/[;,]/)[0];
3463
+ return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
3464
+ }
3465
+ if (/^blob:/i.test(scrubbed)) return 'blob';
3466
+ var path = scrubbed.replace(/\\?…$/, '').replace(/\\/+$/, '');
3467
+ var name = path.slice(path.lastIndexOf('/') + 1);
3468
+ try { name = decodeURIComponent(name); } catch (ex) {}
3469
+ if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
3470
+ return descClean(name, DESC_MAX_FILE);
3471
+ }
3472
+
3473
+ function describeTextlessElement(el, label) {
3474
+ var out = descClean(label, DESC_MAX_NAME) || 'element';
3475
+ var name = ctxName(el);
3476
+ name = name ? descClean(name, DESC_MAX_NAME) : '';
3477
+ if (name && name !== out) out += ' "' + name + '"';
3478
+ var file = descSourceName(el);
3479
+ if (file && file !== name) out += ' (' + file + ')';
3480
+ return out;
3481
+ }
3482
+
3483
+ // Element text for a pinpoint target (primary or additional): the
3484
+ // element's own text, or for a text-less element its description.
3297
3485
  function elementTargetText(el, label) {
3298
3486
  var text = capSelectionText((el.textContent || '').trim());
3299
- if (!text) text = capSelectionText('[element: ' + label + ']');
3487
+ if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
3300
3488
  return text;
3301
3489
  }
3302
3490
 
@@ -3535,10 +3723,10 @@ export const BRIDGE_SCRIPT = `(function() {
3535
3723
  if (!posted) clearPendingPin();
3536
3724
  return posted;
3537
3725
  }
3538
- var elText = capSelectionText((el.textContent || '').trim());
3539
- // Text-less elements (icon buttons, decorative chips, empty containers)
3540
- // are still annotatable: describe the element instead of quoting it.
3541
- if (!elText) elText = capSelectionText('[element: ' + pinpointHoverLabel(el) + ']');
3726
+ // Text-less elements (images, icon buttons, decorative chips, empty
3727
+ // containers) are still annotatable: describe the element instead of
3728
+ // quoting it, naming WHICH one it is where the page says so.
3729
+ var elText = elementTargetText(el, pinpointHoverLabel(el));
3542
3730
  var r = el.getBoundingClientRect();
3543
3731
  pendingSelection = { element: true };
3544
3732
  pendingRange = null;
@@ -5134,11 +5322,16 @@ export const BRIDGE_SCRIPT = `(function() {
5134
5322
 
5135
5323
  function onReady() {
5136
5324
  if (typeof ResizeObserver !== 'undefined' && document.body) {
5137
- new ResizeObserver(function() {
5325
+ var resizeObserver = new ResizeObserver(function() {
5138
5326
  postResize();
5139
5327
  scheduleVimUiUpdate();
5140
5328
  schedulePinpointReconcile();
5141
- }).observe(document.body);
5329
+ });
5330
+ resizeObserver.observe(document.body);
5331
+ // A collapsed margin changing moves body without resizing it; the root
5332
+ // box does resize then. (An html{height:100%} root resizes with the
5333
+ // frame instead, which re-measures the same height and posts nothing.)
5334
+ resizeObserver.observe(document.documentElement);
5142
5335
  }
5143
5336
  watchPageMutations();
5144
5337
  // Armed is the default on both surfaces, and live sessions default to