@plannotator/ui 0.38.1 → 0.39.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.
@@ -340,6 +340,7 @@ export const BRIDGE_SCRIPT = `(function() {
340
340
  var pendingPinKey = null; // target key for the primary pinpoint target (multi-select)
341
341
  var pendingPinLabel = null; // semantic label captured for the primary target
342
342
  var pendingPinPoint = null; // normalized {x,y} click point inside the pinned element's rect
343
+ var pendingPinContext = null; // agent-facing element context for the primary target (see buildElementContext)
343
344
  var pendingPinViaPinpoint = false; // pinpoint drafts survive scroll-out (see postSelectionRect)
344
345
  // Multi-select is ARMED EXPLICITLY by the parent (arm-multi-select), and only
345
346
  // when the comment composer owns the draft. The bridge must never accept a
@@ -466,6 +467,7 @@ export const BRIDGE_SCRIPT = `(function() {
466
467
  pinpoint: (extras && extras.pinpoint) || undefined,
467
468
  targetKey: (extras && extras.targetKey) || undefined,
468
469
  targetLabel: (extras && extras.targetLabel) || undefined,
470
+ context: (extras && extras.context) || undefined,
469
471
  rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
470
472
  });
471
473
  renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
@@ -2728,8 +2730,435 @@ export const BRIDGE_SCRIPT = `(function() {
2728
2730
  return el;
2729
2731
  }
2730
2732
 
2733
+ // --- Element context: a bounded, agent-facing description of a pinpointed
2734
+ // element, captured at click time (only the bridge can see the DOM). It is
2735
+ // purely descriptive and NEVER consulted by restore (that is the anchor's
2736
+ // job): the exported feedback prints it so an agent working in the app's
2737
+ // source can find the element without having the page. Everything here is
2738
+ // page-controlled, so the parent re-validates and re-caps it all at the
2739
+ // trust boundary; this side keeps the wire payload honest.
2740
+ //
2741
+ // Constraints (the "smart" part is that they adapt to the element):
2742
+ // - attributes are an ALLOWLIST (a page cannot add a key); no form values,
2743
+ // no on* handlers, no style, no script/style/template contents ever;
2744
+ // - absolute http(s) URLs lose their query and fragment (tokens live
2745
+ // there), data: URIs keep only their media-type prefix;
2746
+ // - the outline tries two levels of children, falls back to one, then to a
2747
+ // per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
2748
+ // whole <main> costs the same bytes as a click on a chip;
2749
+ // - text is innerText (rendered words, not the DOM soup), word-boundary
2750
+ // truncated;
2751
+ // - the serialized context stays under a byte budget by shedding the
2752
+ // expendable fields in a fixed order; tag/id/role/name/page never shed.
2753
+ var CTX_MAX_BYTES = 2048; // primary target
2754
+ var CTX_MAX_BYTES_EXTRA = 1024; // each shift-click additional target
2755
+ var CTX_MAX_TEXT = 300;
2756
+ var CTX_MAX_OUTLINE = 600;
2757
+ var CTX_MAX_PATH_SEGMENTS = 8;
2758
+ var CTX_MAX_CLASSES = 8;
2759
+ var CTX_MAX_ATTRS = 10;
2760
+ var CTX_MAX_ATTR_VALUE = 120;
2761
+ var CTX_MAX_CHILDREN = 6;
2762
+ var CONTEXT_ATTRS = ['href', 'src', 'alt', 'title', 'type', 'name', 'role', 'placeholder', 'for', 'target', 'rel',
2763
+ 'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-current', 'aria-expanded', 'aria-hidden', 'aria-controls',
2764
+ 'data-annotate', 'data-testid', 'data-test', 'data-test-id', 'data-cy', 'data-qa', 'data-component', 'data-id'];
2765
+ var CONTEXT_SKIP_TAGS = { SCRIPT: 1, STYLE: 1, TEMPLATE: 1, NOSCRIPT: 1 };
2766
+ var CONTEXT_IMPLICIT_ROLE = { NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo', ASIDE: 'complementary',
2767
+ ARTICLE: 'article', SECTION: 'region', FORM: 'form', BUTTON: 'button', A: 'link', IMG: 'img', TABLE: 'table', UL: 'list',
2768
+ OL: 'list', LI: 'listitem', H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading', H6: 'heading',
2769
+ DIALOG: 'dialog', INPUT: 'textbox', TEXTAREA: 'textbox', SELECT: 'combobox', SUMMARY: 'button', PROGRESS: 'progressbar' };
2770
+ var CONTEXT_LANDMARK_SELECTOR = 'main,nav,header,footer,aside,article,section,form,dialog,[role]';
2771
+
2772
+ // Collapse control characters and whitespace runs; the parent does it again.
2773
+ function ctxCollapse(value, max) {
2774
+ var s = String(value == null ? '' : value).replace(/[\\x00-\\x1f\\x7f]+/g, ' ').replace(/\\s+/g, ' ').trim();
2775
+ return max ? ctxTruncate(s, max) : s;
2776
+ }
2777
+
2778
+ // Word-boundary truncation with an ellipsis, never splitting a surrogate pair.
2779
+ function ctxTruncate(s, max) {
2780
+ if (s.length <= max) return s;
2781
+ var cut = s.lastIndexOf(' ', max - 1);
2782
+ if (cut < max * 0.6) cut = max - 1;
2783
+ var last = s.charCodeAt(cut - 1);
2784
+ if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
2785
+ return s.slice(0, cut) + '…';
2786
+ }
2787
+
2788
+ function ctxBytes(value) {
2789
+ var json = JSON.stringify(value);
2790
+ try { return new TextEncoder().encode(json).length; } catch (ex) {}
2791
+ try { return unescape(encodeURIComponent(json)).length; } catch (ex2) { return json.length * 2; }
2792
+ }
2793
+
2794
+ // URL attribute values: keep what locates the element in source, drop
2795
+ // what identifies the user. Relative URLs are route state and stay whole.
2796
+ function ctxScrubUrl(value) {
2797
+ var v = String(value).trim();
2798
+ if (/^javascript:/i.test(v)) return null;
2799
+ if (/^data:/i.test(v)) {
2800
+ var comma = v.indexOf(',');
2801
+ return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
2802
+ }
2803
+ if (/^https?:\\/\\//i.test(v)) {
2804
+ try {
2805
+ var u = new URL(v);
2806
+ return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
2807
+ } catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
2808
+ }
2809
+ return v;
2810
+ }
2811
+
2812
+ function ctxAttrs(el) {
2813
+ var out = [];
2814
+ if (!el.getAttribute) return out;
2815
+ for (var i = 0; i < CONTEXT_ATTRS.length && out.length < CTX_MAX_ATTRS; i++) {
2816
+ var name = CONTEXT_ATTRS[i];
2817
+ var value = el.getAttribute(name);
2818
+ if (value == null) continue;
2819
+ if (name === 'href' || name === 'src') {
2820
+ value = ctxScrubUrl(value);
2821
+ if (value == null) continue;
2822
+ }
2823
+ out.push([name, ctxCollapse(value, CTX_MAX_ATTR_VALUE)]);
2824
+ }
2825
+ return out;
2826
+ }
2827
+
2828
+ function ctxClasses(el) {
2829
+ var out = [];
2830
+ if (!el.classList) return out;
2831
+ for (var i = 0; i < el.classList.length && out.length < CTX_MAX_CLASSES; i++) {
2832
+ var cls = String(el.classList[i]);
2833
+ if (!cls || isLikelyGeneratedClass(cls)) continue;
2834
+ out.push(ctxTruncate(cls, 48));
2835
+ }
2836
+ if (el.classList.length > out.length && out.length === CTX_MAX_CLASSES) out.push('+' + (el.classList.length - out.length) + ' more');
2837
+ return out;
2838
+ }
2839
+
2840
+ function ctxFirstClass(el) {
2841
+ if (!el.classList) return null;
2842
+ for (var i = 0; i < el.classList.length; i++) {
2843
+ var cls = String(el.classList[i]);
2844
+ if (cls && !isLikelyGeneratedClass(cls)) return ctxTruncate(cls, 48);
2845
+ }
2846
+ return null;
2847
+ }
2848
+
2849
+ function ctxRole(el) {
2850
+ var explicit = el.getAttribute && el.getAttribute('role');
2851
+ if (explicit && explicit.trim()) return ctxCollapse(explicit, 32);
2852
+ if (el.tagName === 'A' && !(el.getAttribute && el.getAttribute('href'))) return null;
2853
+ return CONTEXT_IMPLICIT_ROLE[el.tagName] || null;
2854
+ }
2855
+
2856
+ function ctxText(el) {
2857
+ var raw = typeof el.innerText === 'string' && el.innerText ? el.innerText : (el.textContent || '');
2858
+ return ctxCollapse(raw, CTX_MAX_TEXT);
2859
+ }
2860
+
2861
+ // Accessible name, in the practical order: aria-label, aria-labelledby,
2862
+ // alt, title, a <label for>, then the element's own short text.
2863
+ function ctxName(el) {
2864
+ if (!el.getAttribute) return null;
2865
+ var aria = el.getAttribute('aria-label');
2866
+ if (aria && aria.trim()) return ctxCollapse(aria, 120);
2867
+ var by = el.getAttribute('aria-labelledby');
2868
+ if (by && by.trim()) {
2869
+ var ids = by.trim().split(/\\s+/);
2870
+ var parts = [];
2871
+ for (var i = 0; i < ids.length && i < 4; i++) {
2872
+ var ref = document.getElementById(ids[i]);
2873
+ if (ref) parts.push(ref.textContent || '');
2874
+ }
2875
+ var joined = ctxCollapse(parts.join(' '), 120);
2876
+ if (joined) return joined;
2877
+ }
2878
+ var alt = el.getAttribute('alt');
2879
+ if (alt && alt.trim()) return ctxCollapse(alt, 120);
2880
+ var title = el.getAttribute('title');
2881
+ if (title && title.trim()) return ctxCollapse(title, 120);
2882
+ if (el.id && typeof CSS !== 'undefined' && CSS.escape) {
2883
+ var label = null;
2884
+ try { label = document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); } catch (ex) {}
2885
+ if (label) {
2886
+ var labelText = ctxCollapse(label.textContent, 120);
2887
+ if (labelText) return labelText;
2888
+ }
2889
+ }
2890
+ var own = ctxCollapse(el.textContent, 120);
2891
+ return own && own.length <= 80 ? own : null;
2892
+ }
2893
+
2894
+ // One path segment: tag, then the strongest cheap identity it carries, and
2895
+ // a position only when a same-tag sibling would otherwise read the same.
2896
+ function ctxSegment(el) {
2897
+ var seg = el.tagName.toLowerCase();
2898
+ if (el.id) return seg + '#' + ctxCollapse(el.id, 60);
2899
+ var cls = ctxFirstClass(el);
2900
+ if (cls) seg += '.' + cls;
2901
+ var hook = el.getAttribute && (el.getAttribute('data-testid') || el.getAttribute('data-component'));
2902
+ if (hook) seg += '[' + (el.getAttribute('data-testid') ? 'data-testid' : 'data-component') + '="' + ctxCollapse(hook, 40) + '"]';
2903
+ // A position only when a same-tag sibling would read identically (same
2904
+ // first class, same hook): p.beta among p.alpha / p.gamma needs none,
2905
+ // the fourth of forty identical <li class="row"> does.
2906
+ var parent = el.parentElement;
2907
+ if (parent) {
2908
+ var same = 0;
2909
+ var index = 0;
2910
+ var typeIndex = 0;
2911
+ var typeCount = 0;
2912
+ for (var i = 0; i < parent.children.length; i++) {
2913
+ var sibling = parent.children[i];
2914
+ if (sibling.tagName !== el.tagName) continue;
2915
+ typeCount++;
2916
+ if (sibling === el) typeIndex = typeCount;
2917
+ if (sibling.id) continue;
2918
+ if (ctxFirstClass(sibling) === cls && !!(sibling.getAttribute && (sibling.getAttribute('data-testid') || sibling.getAttribute('data-component'))) === !!hook) {
2919
+ same++;
2920
+ if (sibling === el) index = same;
2921
+ }
2922
+ }
2923
+ if (same > 1) seg += ':nth-of-type(' + typeIndex + ')';
2924
+ }
2925
+ return seg;
2926
+ }
2927
+
2928
+ function ctxPath(el) {
2929
+ var parts = [];
2930
+ var current = el;
2931
+ while (current && current.nodeType === 1 && current !== document.documentElement) {
2932
+ if (parts.length >= CTX_MAX_PATH_SEGMENTS) { parts.unshift('…'); break; }
2933
+ parts.unshift(ctxSegment(current));
2934
+ if (current === document.body) break;
2935
+ current = current.parentElement;
2936
+ }
2937
+ return parts.join(' > ');
2938
+ }
2939
+
2940
+ function ctxIsSkipped(node) {
2941
+ return !node || node.nodeType !== 1 || CONTEXT_SKIP_TAGS[node.tagName] || isViewerOverlayNode(node);
2942
+ }
2943
+
2944
+ function ctxElementChildren(el) {
2945
+ var kids = [];
2946
+ for (var i = 0; i < el.children.length; i++) {
2947
+ if (!ctxIsSkipped(el.children[i])) kids.push(el.children[i]);
2948
+ }
2949
+ return kids;
2950
+ }
2951
+
2952
+ function ctxTagCounts(nodes) {
2953
+ var counts = {};
2954
+ var order = [];
2955
+ for (var i = 0; i < nodes.length; i++) {
2956
+ var tag = nodes[i].tagName.toLowerCase();
2957
+ if (!counts[tag]) { counts[tag] = 0; order.push(tag); }
2958
+ counts[tag]++;
2959
+ }
2960
+ var out = [];
2961
+ for (var j = 0; j < order.length && j < 4; j++) out.push(order[j] + '×' + counts[order[j]]);
2962
+ if (order.length > 4) out.push('…');
2963
+ return out.join(', ');
2964
+ }
2965
+
2966
+ function ctxAttrString(pairs) {
2967
+ var s = '';
2968
+ for (var i = 0; i < pairs.length; i++) s += ' ' + pairs[i][0] + '="' + pairs[i][1].replace(/"/g, '&quot;') + '"';
2969
+ return s;
2970
+ }
2971
+
2972
+ // id and up to four author classes on an opening tag: the grep keys, first.
2973
+ function ctxIdentityString(el) {
2974
+ var s = '';
2975
+ if (el.id) s += ' id="' + ctxCollapse(el.id, 60) + '"';
2976
+ var classes = [];
2977
+ if (el.classList) {
2978
+ for (var i = 0; i < el.classList.length && classes.length < 4; i++) {
2979
+ var cls = String(el.classList[i]);
2980
+ if (cls && !isLikelyGeneratedClass(cls)) classes.push(ctxTruncate(cls, 48));
2981
+ }
2982
+ }
2983
+ if (classes.length) s += ' class="' + classes.join(' ') + '"';
2984
+ return s;
2985
+ }
2986
+
2987
+ // A child rendered inside the outline: its tag with one identity hook, its
2988
+ // own short text when it has no element children, an ellipsis otherwise.
2989
+ function ctxChildTag(child, depth) {
2990
+ var tag = child.tagName.toLowerCase();
2991
+ var open = '<' + tag;
2992
+ if (child.id) open += ' id="' + ctxCollapse(child.id, 60) + '"';
2993
+ else {
2994
+ var cls = ctxFirstClass(child);
2995
+ if (cls) open += ' class="' + cls + '"';
2996
+ }
2997
+ var hook = child.getAttribute && child.getAttribute('data-testid');
2998
+ if (hook) open += ' data-testid="' + ctxCollapse(hook, 40) + '"';
2999
+ if (tag === 'a' && child.getAttribute('href')) {
3000
+ var href = ctxScrubUrl(child.getAttribute('href'));
3001
+ if (href) open += ' href="' + ctxCollapse(href, 60) + '"';
3002
+ }
3003
+ open += '>';
3004
+ var kids = ctxElementChildren(child);
3005
+ if (!kids.length) {
3006
+ var own = ctxCollapse(child.textContent, 40);
3007
+ return open + own + '</' + tag + '>';
3008
+ }
3009
+ if (depth <= 1) {
3010
+ var leafText = ctxCollapse(child.textContent, 40);
3011
+ return open + (leafText || '…') + '</' + tag + '>';
3012
+ }
3013
+ var lines = [open];
3014
+ var shown = kids.slice(0, CTX_MAX_CHILDREN);
3015
+ for (var i = 0; i < shown.length; i++) lines.push(' ' + ctxChildTag(shown[i], depth - 1));
3016
+ if (kids.length > shown.length) lines.push(' <!-- +' + (kids.length - shown.length) + ' more: ' + ctxTagCounts(kids.slice(shown.length)) + ' -->');
3017
+ lines.push('</' + tag + '>');
3018
+ return lines.join('\\n');
3019
+ }
3020
+
3021
+ function ctxOutlineAtDepth(el, attrs, kids, depth) {
3022
+ var tag = el.tagName.toLowerCase();
3023
+ var open = '<' + tag + ctxIdentityString(el) + ctxAttrString(attrs) + '>';
3024
+ if (!kids.length) {
3025
+ var own = ctxCollapse(el.textContent, 80);
3026
+ return open + own + '</' + tag + '>';
3027
+ }
3028
+ if (depth <= 0) return open + '<!-- ' + kids.length + ' children: ' + ctxTagCounts(kids) + ' -->' + '</' + tag + '>';
3029
+ var lines = [open];
3030
+ var shown = kids.slice(0, CTX_MAX_CHILDREN);
3031
+ for (var i = 0; i < shown.length; i++) {
3032
+ var rendered = ctxChildTag(shown[i], depth);
3033
+ var childLines = rendered.split('\\n');
3034
+ for (var j = 0; j < childLines.length; j++) lines.push(' ' + childLines[j]);
3035
+ }
3036
+ if (kids.length > shown.length) lines.push(' <!-- +' + (kids.length - shown.length) + ' more: ' + ctxTagCounts(kids.slice(shown.length)) + ' -->');
3037
+ lines.push('</' + tag + '>');
3038
+ return lines.join('\\n');
3039
+ }
3040
+
3041
+ // Adaptive depth: two levels, then one, then a per-tag count — the first
3042
+ // that fits the outline cap. A whole-page container never costs more than a chip.
3043
+ function ctxOutline(el, attrs) {
3044
+ var kids = ctxElementChildren(el);
3045
+ var depths = [2, 1, 0];
3046
+ for (var i = 0; i < depths.length; i++) {
3047
+ var out = ctxOutlineAtDepth(el, attrs, kids, depths[i]);
3048
+ if (out.length <= CTX_MAX_OUTLINE) return out;
3049
+ }
3050
+ return ctxTruncate(ctxOutlineAtDepth(el, attrs, kids, 0), CTX_MAX_OUTLINE);
3051
+ }
3052
+
3053
+ function ctxLandmark(el) {
3054
+ var parent = el.parentElement;
3055
+ if (!parent || !parent.closest) return null;
3056
+ var region = null;
3057
+ try { region = parent.closest(CONTEXT_LANDMARK_SELECTOR); } catch (ex) { return null; }
3058
+ if (!region || region === document.body) return null;
3059
+ var seg = ctxSegment(region);
3060
+ var name = region.getAttribute('aria-label');
3061
+ if (name && name.trim()) seg += ' "' + ctxCollapse(name, 60) + '"';
3062
+ return ctxTruncate(seg, 80);
3063
+ }
3064
+
3065
+ // The nearest heading ABOVE the element in document order (or one that
3066
+ // contains it): the section it sits in, which often names the source file.
3067
+ function ctxHeading(el) {
3068
+ var headings = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
3069
+ var best = null;
3070
+ for (var i = 0; i < headings.length && i < 500; i++) {
3071
+ var h = headings[i];
3072
+ var pos = el.compareDocumentPosition(h);
3073
+ if (pos & 2 || pos & 8) best = h; // PRECEDING or CONTAINS
3074
+ else if (pos & 4) break; // FOLLOWING: every later heading follows too
3075
+ }
3076
+ if (!best) return null;
3077
+ var text = ctxCollapse(best.textContent, 120);
3078
+ return text ? best.tagName.toLowerCase() + ' "' + text + '"' : null;
3079
+ }
3080
+
3081
+ // The cheapest safe framework hint: an author-placed component marker on
3082
+ // the element or a near ancestor. Deliberately no React fiber reads.
3083
+ function ctxComponent(el) {
3084
+ var current = el;
3085
+ for (var depth = 0; current && current.nodeType === 1 && depth < 6; depth++) {
3086
+ var attrs = ['data-component', 'data-testid', 'data-qa'];
3087
+ for (var i = 0; i < attrs.length; i++) {
3088
+ var value = current.getAttribute && current.getAttribute(attrs[i]);
3089
+ if (value && value.trim()) return attrs[i] + '=' + ctxCollapse(value, 60);
3090
+ }
3091
+ current = current.parentElement;
3092
+ }
3093
+ return null;
3094
+ }
3095
+
3096
+ function ctxRect(el) {
3097
+ var r = el.getBoundingClientRect();
3098
+ return {
3099
+ x: Math.round(r.left), y: Math.round(r.top),
3100
+ w: Math.round(r.width), h: Math.round(r.height),
3101
+ vw: Math.round(window.innerWidth || 0), vh: Math.round(window.innerHeight || 0)
3102
+ };
3103
+ }
3104
+
3105
+ // Shed order when over budget: the fields an agent can live without first.
3106
+ var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
3107
+
3108
+ function buildElementContext(el, maxBytes) {
3109
+ if (!el || el.nodeType !== 1) return null;
3110
+ try {
3111
+ var attrs = ctxAttrs(el);
3112
+ var context = { tag: el.tagName.toLowerCase() };
3113
+ if (el.id) context.id = ctxCollapse(el.id, 100);
3114
+ var classes = ctxClasses(el);
3115
+ if (classes.length) context.classes = classes;
3116
+ context.path = ctxPath(el);
3117
+ var role = ctxRole(el);
3118
+ if (role) context.role = role;
3119
+ var name = ctxName(el);
3120
+ if (name) context.name = name;
3121
+ if (attrs.length) context.attrs = attrs;
3122
+ var text = ctxText(el);
3123
+ if (text) context.text = text;
3124
+ context.outline = ctxOutline(el, attrs);
3125
+ context.children = ctxElementChildren(el).length;
3126
+ context.rect = ctxRect(el);
3127
+ var landmark = ctxLandmark(el);
3128
+ if (landmark) context.landmark = landmark;
3129
+ var heading = ctxHeading(el);
3130
+ if (heading) context.heading = heading;
3131
+ var component = ctxComponent(el);
3132
+ if (component) context.component = component;
3133
+ if (LIVE) {
3134
+ context.page = { url: currentPageUrl() };
3135
+ var title = ctxCollapse(document.title, 200);
3136
+ if (title) context.page.title = title;
3137
+ }
3138
+ // Byte budget: shed whole fields in order; a shed attrs/classes array
3139
+ // first loses entries from the end before the field goes entirely.
3140
+ for (var s = 0; s < CTX_SHED_ORDER.length && ctxBytes(context) > maxBytes; s++) {
3141
+ var field = CTX_SHED_ORDER[s];
3142
+ if (context[field] === undefined) continue;
3143
+ if ((field === 'attrs' || field === 'classes') && context[field].length > 1) {
3144
+ while (context[field].length > 1 && ctxBytes(context) > maxBytes) context[field].pop();
3145
+ if (ctxBytes(context) <= maxBytes) break;
3146
+ }
3147
+ if (field === 'text' && context.text.length > 80) {
3148
+ context.text = ctxTruncate(context.text, 80);
3149
+ if (ctxBytes(context) <= maxBytes) break;
3150
+ }
3151
+ delete context[field];
3152
+ }
3153
+ return context;
3154
+ } catch (ex) {
3155
+ return null;
3156
+ }
3157
+ }
3158
+
2731
3159
  function clearPendingPin() {
2732
3160
  pendingPinEl = null;
3161
+ pendingPinContext = null;
2733
3162
  pendingPinAnchor = null;
2734
3163
  pendingPinKey = null;
2735
3164
  pendingPinLabel = null;
@@ -2850,6 +3279,7 @@ export const BRIDGE_SCRIPT = `(function() {
2850
3279
  pendingPinKey = next.key;
2851
3280
  pendingPinLabel = next.label;
2852
3281
  pendingPinPoint = next.point || null;
3282
+ pendingPinContext = next.context || null;
2853
3283
  // A promoted primary commits as an element pin: the original text
2854
3284
  // selection belonged to the removed element and no longer applies.
2855
3285
  pendingSelection = { element: true };
@@ -2908,15 +3338,17 @@ export const BRIDGE_SCRIPT = `(function() {
2908
3338
  if (anchor && point) anchor.point = point;
2909
3339
  var label = pinpointHoverLabel(el);
2910
3340
  var text = elementTargetText(el, label);
3341
+ var context = buildElementContext(el, CTX_MAX_BYTES_EXTRA);
2911
3342
  var key = makeTargetKey();
2912
3343
  var box = createMultiTargetBox(el);
2913
- pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, box: box });
3344
+ pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, context: context, box: box });
2914
3345
  postToParent({
2915
3346
  type: PREFIX + 'multi-target-added',
2916
3347
  key: key,
2917
3348
  label: label,
2918
3349
  text: text,
2919
- anchor: anchor || undefined
3350
+ anchor: anchor || undefined,
3351
+ context: context || undefined
2920
3352
  });
2921
3353
  }
2922
3354
 
@@ -3017,11 +3449,15 @@ export const BRIDGE_SCRIPT = `(function() {
3017
3449
  pendingPinPoint = normalizePointInElement(el, clickPoint);
3018
3450
  if (pendingPinAnchor && pendingPinPoint) pendingPinAnchor.point = pendingPinPoint;
3019
3451
  pendingPinViaPinpoint = !!viaPinpoint;
3452
+ // Agent-facing description, captured now while the element is in hand
3453
+ // (an SPA re-render or navigation may have replaced it by submit time).
3454
+ pendingPinContext = buildElementContext(el, CTX_MAX_BYTES);
3020
3455
  var extras = {
3021
3456
  anchor: pendingPinAnchor,
3022
3457
  pinpoint: !!viaPinpoint,
3023
3458
  targetKey: pendingPinKey,
3024
- targetLabel: pendingPinLabel
3459
+ targetLabel: pendingPinLabel,
3460
+ context: pendingPinContext
3025
3461
  };
3026
3462
  // Pinned outline: stronger accent box that tracks the element until the
3027
3463
  // composer resolves (create-mark or cancel-selection).
@@ -3063,6 +3499,7 @@ export const BRIDGE_SCRIPT = `(function() {
3063
3499
  pinpoint: !!viaPinpoint || undefined,
3064
3500
  targetKey: pendingPinKey || undefined,
3065
3501
  targetLabel: pendingPinLabel || undefined,
3502
+ context: pendingPinContext || undefined,
3066
3503
  rect: { top: r.top, left: r.left, width: r.width, height: r.height } });
3067
3504
  return true;
3068
3505
  }