@plannotator/ui 0.38.2 → 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.
- package/HANDOFF.md +3 -2
- package/README.md +1 -1
- package/components/DecisionControl.tsx +0 -10
- package/components/html-viewer/bridge-script.asset.js +440 -3
- package/components/html-viewer/bridge-script.ts +440 -3
- package/components/html-viewer/useHtmlAnnotation.ts +175 -1
- package/package.json +1 -1
- package/styles.css +1 -1
- package/types.ts +45 -0
- package/utils/decisionSpec.ts +1 -4
- package/utils/parser.ts +135 -4
package/HANDOFF.md
CHANGED
|
@@ -678,8 +678,9 @@ Additive only, but required: `@plannotator/ui` 0.32.0 imports the new `@plannota
|
|
|
678
678
|
|
|
679
679
|
## Publishing & versioning
|
|
680
680
|
|
|
681
|
-
- The current pair is `@plannotator/ui` `0.
|
|
682
|
-
-
|
|
681
|
+
- The current pair is `@plannotator/ui` `0.39.0` on `@plannotator/core` `0.25.2` (core unchanged; nothing under `packages/core` moved). UI 0.39.0 adds **element context** to raw-HTML and live-app pinpoint annotations (#1517, #1520): a new optional `Annotation.elementContext` (`HtmlElementContext` in `@plannotator/ui/types`) and `HtmlAnnotationTarget.context`, captured by the bridge at click time (tag, id, author classes, ancestor `path`, `role`, accessible `name`, an allowlisted `attrs` set with href/src scrubbed of query and fragment, rendered `text`, an adaptive collapsed HTML `outline`, child count, viewport `rect`, nearest `landmark` and `heading`, a `component` hint, and in live-app sessions `page`), hard-capped at 2 KiB serialized per primary and 1 KiB per extra target, and re-validated at the parent trust boundary by the new `parseHtmlElementContext` export of `@plannotator/ui/components/html-viewer`. New helpers on `@plannotator/ui/utils/parser`: `elementContextExportBlock(ann, { includeRoute })` (the fenced skeleton plus selector/path/role/name/attrs/text/box/near lines the full export now prints under a context-bearing comment) and `exportAnnotationEntry(ann, { includeRoute })` (one annotation as a standalone feedback entry, a pure helper for hosts; `AnnotationPanel`'s card chrome is unchanged from 0.38.2). The field is purely descriptive: `HtmlElementAnchor` and restore are untouched, no `BRIDGE_PROTOCOL_VERSION` bump, share links drop it like anchors, annotations without it export byte-identically, and the repaint path posts only anchors to the bridge. **Host persistence gap, tracked as #1521**: `@plannotator/core/html-anchor` (`buildPersistedHtmlAnchor`, `projectHostThreads`) does not carry `elementContext` yet, so a host persisting through those helpers drops it on save; until #1521 lands, hosts that want it must persist and project the field themselves. Peer ranges are unchanged from 0.38.2: `react` / `react-dom` `^19.2.3`, `tailwindcss` as before, and `@codemirror/state ^6.7.2` beside `@codemirror/view ^6.43.10`. Decision-control change in the same window (#1516): the header primary reads `Send Feedback` / `Post Comments` with no inline count (`DecisionPrimary.count` removed; internal, not host-supported surface).
|
|
682
|
+
- The previous pair was `@plannotator/ui` `0.38.2` on `@plannotator/core` `0.25.2`. UI 0.38.2 keeps the type word in a titled alert's accessible name through a visually hidden `sr-only` span before the title instead of an `aria-label` on the title row (naming a generic `div` is prohibited by ARIA and WebKit drops it, so VoiceOver on Safari read only the bold title in 0.38.1), and loosens the React peer back to `^19.2.3` (0.38.1 declared `^19.2.8` only because the dependency batch moved it; nothing in the package needs a newer API). **Do not consume ui 0.38.0**: it imports `@plannotator/core/token-hover` (the hover-card trigger settings, #1462) but pins core 0.25.1, which never exported that subpath, so it fails to compile in any consumer; 0.38.1 is the same UI pinning core 0.25.2, which publishes `./token-hover`, the rotated `guide-viewer-manifest` pin, and the `config-types` hover fields (core 0.25.2 is the first core publish since 0.25.1 even though those changes landed over several releases; the package smoke now diffs the UI's core imports against the registry so an unpublished core subpath fails preflight instead of the consumer). UI 0.38.1 also aligns `@codemirror/state` to `^6.7.2` beside `@codemirror/view ^6.43.10`, so a consumer can no longer resolve two state copies. UI 0.38.0 also renders a GitHub alert's bold-only first body line as its title on the icon row (an emoji on that line becomes the icon; `<!-- icon: name -->` is stripped and resolved through the new `alertIconRenderer` seam, null by default; grammar in `utils/alertTitle`, importable by a host editor so it writes the bytes the reader parses; a fenced code block inside an alert body still renders as text, deferred because nesting a `CodeBlock` inside a block interacts with the positional annotation anchors and needs its own design). UI 0.38.0 carries the whole unified decision-control stack: the internal primitives (`DecisionControl`, `utils/decisionSpec`, `hooks/useDismissablePopover` — not host-supported surface, see the unsupported list; `useDismissablePopover` also replaced the hand-rolled dismissal inside `ActionMenu`/`ApproveDropdown`, both likewise unsupported) plus one blessed-barrel addition: `decisionControlShortcuts` on `@plannotator/ui/shortcuts` (pure scope data, fetch-free, same contract as the other scopes). The removal of `ToolbarButtons`' platform-mode `muted` prop is internal — `ToolbarButtons` is not host-supported surface. UI 0.37.0 added the Viewer-owned document-header seam (a new public API, hence the minor bump; 0.36.1 was reserved for it but never published) while retaining the `hideQuickLabel` and `StickyHeaderLane` seams from the 0.35.x and 0.36.0 releases; core 0.25.1 publishes the `annotation-threads` subpath already used by `AnnotationPanel` and `utils/parser`, and UI pins that corrected core exactly.
|
|
683
|
+
- Recent pairs, for the consumer's install matrix: ui 0.32.0 on core 0.25.0 (lockstep, `html-anchor`), ui 0.33.0 and ui 0.34.0 on core 0.25.0 (ui only), and ui 0.35.2, ui 0.36.0, and ui 0.37.0 on core 0.25.1 (0.36.1 was never published), and ui 0.38.1, ui 0.38.2, and ui 0.39.0 on core 0.25.2. Do not consume ui 0.35.0 externally because its published manifest contains `workspace:*`; do not consume ui 0.35.1 because its exact core 0.25.0 dependency lacks the `annotation-threads` export. Do not consume ui 0.38.0 because its exact core 0.25.1 dependency lacks the `token-hover` export.
|
|
683
684
|
- When both packages change, **publish `core` first**: ui 0.32.0 imports the `@plannotator/core/html-anchor` subpath, which no earlier published core (0.24.0 and before) has, just as ui 0.29.0 needed core 0.23.0 for `@plannotator/core/annotatable`. Bump core, update UI's exact core dependency to the same new version, and run `bun install` so `bun.lock` records the new workspace versions before packing either package.
|
|
684
685
|
- The HTML annotation seams also changed the guides.show viewer **stylesheet** (five utility rules from `HtmlSurfaceControls`; the viewer JS is unchanged), so `packages/core/guide-viewer-manifest.ts` now pins a CSS hash that exists on guides.show only after the deploy workflow has published this build's `/v1/` assets. A guide exported from this build before that deploy would pin a stylesheet the host does not serve yet: **deploy guides.show before any release that ships this manifest.**
|
|
685
686
|
- UI declares the already published core version exactly in its source manifest. Do not replace it with `workspace:*`: direct publication can preserve that protocol and make the package impossible to install outside this repository. Bun links the local core workspace whenever its version matches the exact dependency. Before publishing, run `bun run --cwd packages/ui smoke:package`; it checks the source and packed manifests, required tarball subpaths, local Bun linking, and a real pnpm install in an external temporary consumer. When both packages change, publish **`core` first, then `ui`**.
|
package/README.md
CHANGED
|
@@ -251,7 +251,7 @@ npm install @plannotator/ui @plannotator/core
|
|
|
251
251
|
- `@plannotator/core` — pure utils + types, zero deps, browser-safe (CI enforces no `node:` imports). Published.
|
|
252
252
|
- `@plannotator/ui` — React components/hooks + theme + `configure()`. Depends on an exact published `@plannotator/core` version. Published.
|
|
253
253
|
- `@plannotator/shared`, `@plannotator/ai` — stay private to the monorepo; `shared` re-exports `core`'s modules via shims so Plannotator's internals are untouched.
|
|
254
|
-
- Currently `@plannotator/ui` 0.
|
|
254
|
+
- Currently `@plannotator/ui` 0.39.0 depends exactly on `@plannotator/core` 0.25.2. `core` is bumped only when something under `packages/core` changes, so `ui` can advance alone. Keep the published core version exact in `packages/ui/package.json`; do not use a `workspace:` protocol there, because a directly published manifest must remain installable outside this monorepo. Bun still links the matching local workspace during development. When both packages change, publish `core` first, then build and publish the UI tarball. See HANDOFF.md "Publishing & versioning" for the verification command.
|
|
255
255
|
|
|
256
256
|
## The one rule
|
|
257
257
|
|
|
@@ -475,16 +475,6 @@ export const DecisionControl: React.FC<DecisionControlProps> = ({
|
|
|
475
475
|
{spec.primary.label}
|
|
476
476
|
</span>
|
|
477
477
|
)}
|
|
478
|
-
{typeof spec.primary.count === 'number' && (
|
|
479
|
-
// The count is the state indicator, not decoration: present at every
|
|
480
|
-
// breakpoint, unlike the labels.
|
|
481
|
-
<span
|
|
482
|
-
data-decision-count="true"
|
|
483
|
-
className="rounded-full bg-white/25 px-1.5 text-[10px] font-bold leading-4"
|
|
484
|
-
>
|
|
485
|
-
{spec.primary.count}
|
|
486
|
-
</span>
|
|
487
|
-
)}
|
|
488
478
|
</Button>
|
|
489
479
|
);
|
|
490
480
|
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
var pendingPinKey = null; // target key for the primary pinpoint target (multi-select)
|
|
117
117
|
var pendingPinLabel = null; // semantic label captured for the primary target
|
|
118
118
|
var pendingPinPoint = null; // normalized {x,y} click point inside the pinned element's rect
|
|
119
|
+
var pendingPinContext = null; // agent-facing element context for the primary target (see buildElementContext)
|
|
119
120
|
var pendingPinViaPinpoint = false; // pinpoint drafts survive scroll-out (see postSelectionRect)
|
|
120
121
|
// Multi-select is ARMED EXPLICITLY by the parent (arm-multi-select), and only
|
|
121
122
|
// when the comment composer owns the draft. The bridge must never accept a
|
|
@@ -242,6 +243,7 @@
|
|
|
242
243
|
pinpoint: (extras && extras.pinpoint) || undefined,
|
|
243
244
|
targetKey: (extras && extras.targetKey) || undefined,
|
|
244
245
|
targetLabel: (extras && extras.targetLabel) || undefined,
|
|
246
|
+
context: (extras && extras.context) || undefined,
|
|
245
247
|
rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
|
|
246
248
|
});
|
|
247
249
|
renderAnnotationOverlay(); // draft selection highlight (overlay-projected)
|
|
@@ -2504,8 +2506,435 @@
|
|
|
2504
2506
|
return el;
|
|
2505
2507
|
}
|
|
2506
2508
|
|
|
2509
|
+
// --- Element context: a bounded, agent-facing description of a pinpointed
|
|
2510
|
+
// element, captured at click time (only the bridge can see the DOM). It is
|
|
2511
|
+
// purely descriptive and NEVER consulted by restore (that is the anchor's
|
|
2512
|
+
// job): the exported feedback prints it so an agent working in the app's
|
|
2513
|
+
// source can find the element without having the page. Everything here is
|
|
2514
|
+
// page-controlled, so the parent re-validates and re-caps it all at the
|
|
2515
|
+
// trust boundary; this side keeps the wire payload honest.
|
|
2516
|
+
//
|
|
2517
|
+
// Constraints (the "smart" part is that they adapt to the element):
|
|
2518
|
+
// - attributes are an ALLOWLIST (a page cannot add a key); no form values,
|
|
2519
|
+
// no on* handlers, no style, no script/style/template contents ever;
|
|
2520
|
+
// - absolute http(s) URLs lose their query and fragment (tokens live
|
|
2521
|
+
// there), data: URIs keep only their media-type prefix;
|
|
2522
|
+
// - the outline tries two levels of children, falls back to one, then to a
|
|
2523
|
+
// per-tag count, whichever first fits CTX_MAX_OUTLINE, so a click on a
|
|
2524
|
+
// whole <main> costs the same bytes as a click on a chip;
|
|
2525
|
+
// - text is innerText (rendered words, not the DOM soup), word-boundary
|
|
2526
|
+
// truncated;
|
|
2527
|
+
// - the serialized context stays under a byte budget by shedding the
|
|
2528
|
+
// expendable fields in a fixed order; tag/id/role/name/page never shed.
|
|
2529
|
+
var CTX_MAX_BYTES = 2048; // primary target
|
|
2530
|
+
var CTX_MAX_BYTES_EXTRA = 1024; // each shift-click additional target
|
|
2531
|
+
var CTX_MAX_TEXT = 300;
|
|
2532
|
+
var CTX_MAX_OUTLINE = 600;
|
|
2533
|
+
var CTX_MAX_PATH_SEGMENTS = 8;
|
|
2534
|
+
var CTX_MAX_CLASSES = 8;
|
|
2535
|
+
var CTX_MAX_ATTRS = 10;
|
|
2536
|
+
var CTX_MAX_ATTR_VALUE = 120;
|
|
2537
|
+
var CTX_MAX_CHILDREN = 6;
|
|
2538
|
+
var CONTEXT_ATTRS = ['href', 'src', 'alt', 'title', 'type', 'name', 'role', 'placeholder', 'for', 'target', 'rel',
|
|
2539
|
+
'aria-label', 'aria-labelledby', 'aria-describedby', 'aria-current', 'aria-expanded', 'aria-hidden', 'aria-controls',
|
|
2540
|
+
'data-annotate', 'data-testid', 'data-test', 'data-test-id', 'data-cy', 'data-qa', 'data-component', 'data-id'];
|
|
2541
|
+
var CONTEXT_SKIP_TAGS = { SCRIPT: 1, STYLE: 1, TEMPLATE: 1, NOSCRIPT: 1 };
|
|
2542
|
+
var CONTEXT_IMPLICIT_ROLE = { NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo', ASIDE: 'complementary',
|
|
2543
|
+
ARTICLE: 'article', SECTION: 'region', FORM: 'form', BUTTON: 'button', A: 'link', IMG: 'img', TABLE: 'table', UL: 'list',
|
|
2544
|
+
OL: 'list', LI: 'listitem', H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading', H6: 'heading',
|
|
2545
|
+
DIALOG: 'dialog', INPUT: 'textbox', TEXTAREA: 'textbox', SELECT: 'combobox', SUMMARY: 'button', PROGRESS: 'progressbar' };
|
|
2546
|
+
var CONTEXT_LANDMARK_SELECTOR = 'main,nav,header,footer,aside,article,section,form,dialog,[role]';
|
|
2547
|
+
|
|
2548
|
+
// Collapse control characters and whitespace runs; the parent does it again.
|
|
2549
|
+
function ctxCollapse(value, max) {
|
|
2550
|
+
var s = String(value == null ? '' : value).replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
2551
|
+
return max ? ctxTruncate(s, max) : s;
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
// Word-boundary truncation with an ellipsis, never splitting a surrogate pair.
|
|
2555
|
+
function ctxTruncate(s, max) {
|
|
2556
|
+
if (s.length <= max) return s;
|
|
2557
|
+
var cut = s.lastIndexOf(' ', max - 1);
|
|
2558
|
+
if (cut < max * 0.6) cut = max - 1;
|
|
2559
|
+
var last = s.charCodeAt(cut - 1);
|
|
2560
|
+
if (last >= 0xd800 && last <= 0xdbff) cut -= 1;
|
|
2561
|
+
return s.slice(0, cut) + '…';
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
function ctxBytes(value) {
|
|
2565
|
+
var json = JSON.stringify(value);
|
|
2566
|
+
try { return new TextEncoder().encode(json).length; } catch (ex) {}
|
|
2567
|
+
try { return unescape(encodeURIComponent(json)).length; } catch (ex2) { return json.length * 2; }
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
// URL attribute values: keep what locates the element in source, drop
|
|
2571
|
+
// what identifies the user. Relative URLs are route state and stay whole.
|
|
2572
|
+
function ctxScrubUrl(value) {
|
|
2573
|
+
var v = String(value).trim();
|
|
2574
|
+
if (/^javascript:/i.test(v)) return null;
|
|
2575
|
+
if (/^data:/i.test(v)) {
|
|
2576
|
+
var comma = v.indexOf(',');
|
|
2577
|
+
return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
|
|
2578
|
+
}
|
|
2579
|
+
if (/^https?:\/\//i.test(v)) {
|
|
2580
|
+
try {
|
|
2581
|
+
var u = new URL(v);
|
|
2582
|
+
return u.origin + u.pathname + (u.search || u.hash ? '?…' : '');
|
|
2583
|
+
} catch (ex) { return ctxTruncate(v, CTX_MAX_ATTR_VALUE); }
|
|
2584
|
+
}
|
|
2585
|
+
return v;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
function ctxAttrs(el) {
|
|
2589
|
+
var out = [];
|
|
2590
|
+
if (!el.getAttribute) return out;
|
|
2591
|
+
for (var i = 0; i < CONTEXT_ATTRS.length && out.length < CTX_MAX_ATTRS; i++) {
|
|
2592
|
+
var name = CONTEXT_ATTRS[i];
|
|
2593
|
+
var value = el.getAttribute(name);
|
|
2594
|
+
if (value == null) continue;
|
|
2595
|
+
if (name === 'href' || name === 'src') {
|
|
2596
|
+
value = ctxScrubUrl(value);
|
|
2597
|
+
if (value == null) continue;
|
|
2598
|
+
}
|
|
2599
|
+
out.push([name, ctxCollapse(value, CTX_MAX_ATTR_VALUE)]);
|
|
2600
|
+
}
|
|
2601
|
+
return out;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
function ctxClasses(el) {
|
|
2605
|
+
var out = [];
|
|
2606
|
+
if (!el.classList) return out;
|
|
2607
|
+
for (var i = 0; i < el.classList.length && out.length < CTX_MAX_CLASSES; i++) {
|
|
2608
|
+
var cls = String(el.classList[i]);
|
|
2609
|
+
if (!cls || isLikelyGeneratedClass(cls)) continue;
|
|
2610
|
+
out.push(ctxTruncate(cls, 48));
|
|
2611
|
+
}
|
|
2612
|
+
if (el.classList.length > out.length && out.length === CTX_MAX_CLASSES) out.push('+' + (el.classList.length - out.length) + ' more');
|
|
2613
|
+
return out;
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
function ctxFirstClass(el) {
|
|
2617
|
+
if (!el.classList) return null;
|
|
2618
|
+
for (var i = 0; i < el.classList.length; i++) {
|
|
2619
|
+
var cls = String(el.classList[i]);
|
|
2620
|
+
if (cls && !isLikelyGeneratedClass(cls)) return ctxTruncate(cls, 48);
|
|
2621
|
+
}
|
|
2622
|
+
return null;
|
|
2623
|
+
}
|
|
2624
|
+
|
|
2625
|
+
function ctxRole(el) {
|
|
2626
|
+
var explicit = el.getAttribute && el.getAttribute('role');
|
|
2627
|
+
if (explicit && explicit.trim()) return ctxCollapse(explicit, 32);
|
|
2628
|
+
if (el.tagName === 'A' && !(el.getAttribute && el.getAttribute('href'))) return null;
|
|
2629
|
+
return CONTEXT_IMPLICIT_ROLE[el.tagName] || null;
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
function ctxText(el) {
|
|
2633
|
+
var raw = typeof el.innerText === 'string' && el.innerText ? el.innerText : (el.textContent || '');
|
|
2634
|
+
return ctxCollapse(raw, CTX_MAX_TEXT);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2637
|
+
// Accessible name, in the practical order: aria-label, aria-labelledby,
|
|
2638
|
+
// alt, title, a <label for>, then the element's own short text.
|
|
2639
|
+
function ctxName(el) {
|
|
2640
|
+
if (!el.getAttribute) return null;
|
|
2641
|
+
var aria = el.getAttribute('aria-label');
|
|
2642
|
+
if (aria && aria.trim()) return ctxCollapse(aria, 120);
|
|
2643
|
+
var by = el.getAttribute('aria-labelledby');
|
|
2644
|
+
if (by && by.trim()) {
|
|
2645
|
+
var ids = by.trim().split(/\s+/);
|
|
2646
|
+
var parts = [];
|
|
2647
|
+
for (var i = 0; i < ids.length && i < 4; i++) {
|
|
2648
|
+
var ref = document.getElementById(ids[i]);
|
|
2649
|
+
if (ref) parts.push(ref.textContent || '');
|
|
2650
|
+
}
|
|
2651
|
+
var joined = ctxCollapse(parts.join(' '), 120);
|
|
2652
|
+
if (joined) return joined;
|
|
2653
|
+
}
|
|
2654
|
+
var alt = el.getAttribute('alt');
|
|
2655
|
+
if (alt && alt.trim()) return ctxCollapse(alt, 120);
|
|
2656
|
+
var title = el.getAttribute('title');
|
|
2657
|
+
if (title && title.trim()) return ctxCollapse(title, 120);
|
|
2658
|
+
if (el.id && typeof CSS !== 'undefined' && CSS.escape) {
|
|
2659
|
+
var label = null;
|
|
2660
|
+
try { label = document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); } catch (ex) {}
|
|
2661
|
+
if (label) {
|
|
2662
|
+
var labelText = ctxCollapse(label.textContent, 120);
|
|
2663
|
+
if (labelText) return labelText;
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
var own = ctxCollapse(el.textContent, 120);
|
|
2667
|
+
return own && own.length <= 80 ? own : null;
|
|
2668
|
+
}
|
|
2669
|
+
|
|
2670
|
+
// One path segment: tag, then the strongest cheap identity it carries, and
|
|
2671
|
+
// a position only when a same-tag sibling would otherwise read the same.
|
|
2672
|
+
function ctxSegment(el) {
|
|
2673
|
+
var seg = el.tagName.toLowerCase();
|
|
2674
|
+
if (el.id) return seg + '#' + ctxCollapse(el.id, 60);
|
|
2675
|
+
var cls = ctxFirstClass(el);
|
|
2676
|
+
if (cls) seg += '.' + cls;
|
|
2677
|
+
var hook = el.getAttribute && (el.getAttribute('data-testid') || el.getAttribute('data-component'));
|
|
2678
|
+
if (hook) seg += '[' + (el.getAttribute('data-testid') ? 'data-testid' : 'data-component') + '="' + ctxCollapse(hook, 40) + '"]';
|
|
2679
|
+
// A position only when a same-tag sibling would read identically (same
|
|
2680
|
+
// first class, same hook): p.beta among p.alpha / p.gamma needs none,
|
|
2681
|
+
// the fourth of forty identical <li class="row"> does.
|
|
2682
|
+
var parent = el.parentElement;
|
|
2683
|
+
if (parent) {
|
|
2684
|
+
var same = 0;
|
|
2685
|
+
var index = 0;
|
|
2686
|
+
var typeIndex = 0;
|
|
2687
|
+
var typeCount = 0;
|
|
2688
|
+
for (var i = 0; i < parent.children.length; i++) {
|
|
2689
|
+
var sibling = parent.children[i];
|
|
2690
|
+
if (sibling.tagName !== el.tagName) continue;
|
|
2691
|
+
typeCount++;
|
|
2692
|
+
if (sibling === el) typeIndex = typeCount;
|
|
2693
|
+
if (sibling.id) continue;
|
|
2694
|
+
if (ctxFirstClass(sibling) === cls && !!(sibling.getAttribute && (sibling.getAttribute('data-testid') || sibling.getAttribute('data-component'))) === !!hook) {
|
|
2695
|
+
same++;
|
|
2696
|
+
if (sibling === el) index = same;
|
|
2697
|
+
}
|
|
2698
|
+
}
|
|
2699
|
+
if (same > 1) seg += ':nth-of-type(' + typeIndex + ')';
|
|
2700
|
+
}
|
|
2701
|
+
return seg;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
function ctxPath(el) {
|
|
2705
|
+
var parts = [];
|
|
2706
|
+
var current = el;
|
|
2707
|
+
while (current && current.nodeType === 1 && current !== document.documentElement) {
|
|
2708
|
+
if (parts.length >= CTX_MAX_PATH_SEGMENTS) { parts.unshift('…'); break; }
|
|
2709
|
+
parts.unshift(ctxSegment(current));
|
|
2710
|
+
if (current === document.body) break;
|
|
2711
|
+
current = current.parentElement;
|
|
2712
|
+
}
|
|
2713
|
+
return parts.join(' > ');
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
function ctxIsSkipped(node) {
|
|
2717
|
+
return !node || node.nodeType !== 1 || CONTEXT_SKIP_TAGS[node.tagName] || isViewerOverlayNode(node);
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
function ctxElementChildren(el) {
|
|
2721
|
+
var kids = [];
|
|
2722
|
+
for (var i = 0; i < el.children.length; i++) {
|
|
2723
|
+
if (!ctxIsSkipped(el.children[i])) kids.push(el.children[i]);
|
|
2724
|
+
}
|
|
2725
|
+
return kids;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
function ctxTagCounts(nodes) {
|
|
2729
|
+
var counts = {};
|
|
2730
|
+
var order = [];
|
|
2731
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
2732
|
+
var tag = nodes[i].tagName.toLowerCase();
|
|
2733
|
+
if (!counts[tag]) { counts[tag] = 0; order.push(tag); }
|
|
2734
|
+
counts[tag]++;
|
|
2735
|
+
}
|
|
2736
|
+
var out = [];
|
|
2737
|
+
for (var j = 0; j < order.length && j < 4; j++) out.push(order[j] + '×' + counts[order[j]]);
|
|
2738
|
+
if (order.length > 4) out.push('…');
|
|
2739
|
+
return out.join(', ');
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
function ctxAttrString(pairs) {
|
|
2743
|
+
var s = '';
|
|
2744
|
+
for (var i = 0; i < pairs.length; i++) s += ' ' + pairs[i][0] + '="' + pairs[i][1].replace(/"/g, '"') + '"';
|
|
2745
|
+
return s;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
// id and up to four author classes on an opening tag: the grep keys, first.
|
|
2749
|
+
function ctxIdentityString(el) {
|
|
2750
|
+
var s = '';
|
|
2751
|
+
if (el.id) s += ' id="' + ctxCollapse(el.id, 60) + '"';
|
|
2752
|
+
var classes = [];
|
|
2753
|
+
if (el.classList) {
|
|
2754
|
+
for (var i = 0; i < el.classList.length && classes.length < 4; i++) {
|
|
2755
|
+
var cls = String(el.classList[i]);
|
|
2756
|
+
if (cls && !isLikelyGeneratedClass(cls)) classes.push(ctxTruncate(cls, 48));
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
if (classes.length) s += ' class="' + classes.join(' ') + '"';
|
|
2760
|
+
return s;
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
// A child rendered inside the outline: its tag with one identity hook, its
|
|
2764
|
+
// own short text when it has no element children, an ellipsis otherwise.
|
|
2765
|
+
function ctxChildTag(child, depth) {
|
|
2766
|
+
var tag = child.tagName.toLowerCase();
|
|
2767
|
+
var open = '<' + tag;
|
|
2768
|
+
if (child.id) open += ' id="' + ctxCollapse(child.id, 60) + '"';
|
|
2769
|
+
else {
|
|
2770
|
+
var cls = ctxFirstClass(child);
|
|
2771
|
+
if (cls) open += ' class="' + cls + '"';
|
|
2772
|
+
}
|
|
2773
|
+
var hook = child.getAttribute && child.getAttribute('data-testid');
|
|
2774
|
+
if (hook) open += ' data-testid="' + ctxCollapse(hook, 40) + '"';
|
|
2775
|
+
if (tag === 'a' && child.getAttribute('href')) {
|
|
2776
|
+
var href = ctxScrubUrl(child.getAttribute('href'));
|
|
2777
|
+
if (href) open += ' href="' + ctxCollapse(href, 60) + '"';
|
|
2778
|
+
}
|
|
2779
|
+
open += '>';
|
|
2780
|
+
var kids = ctxElementChildren(child);
|
|
2781
|
+
if (!kids.length) {
|
|
2782
|
+
var own = ctxCollapse(child.textContent, 40);
|
|
2783
|
+
return open + own + '</' + tag + '>';
|
|
2784
|
+
}
|
|
2785
|
+
if (depth <= 1) {
|
|
2786
|
+
var leafText = ctxCollapse(child.textContent, 40);
|
|
2787
|
+
return open + (leafText || '…') + '</' + tag + '>';
|
|
2788
|
+
}
|
|
2789
|
+
var lines = [open];
|
|
2790
|
+
var shown = kids.slice(0, CTX_MAX_CHILDREN);
|
|
2791
|
+
for (var i = 0; i < shown.length; i++) lines.push(' ' + ctxChildTag(shown[i], depth - 1));
|
|
2792
|
+
if (kids.length > shown.length) lines.push(' <!-- +' + (kids.length - shown.length) + ' more: ' + ctxTagCounts(kids.slice(shown.length)) + ' -->');
|
|
2793
|
+
lines.push('</' + tag + '>');
|
|
2794
|
+
return lines.join('\n');
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
function ctxOutlineAtDepth(el, attrs, kids, depth) {
|
|
2798
|
+
var tag = el.tagName.toLowerCase();
|
|
2799
|
+
var open = '<' + tag + ctxIdentityString(el) + ctxAttrString(attrs) + '>';
|
|
2800
|
+
if (!kids.length) {
|
|
2801
|
+
var own = ctxCollapse(el.textContent, 80);
|
|
2802
|
+
return open + own + '</' + tag + '>';
|
|
2803
|
+
}
|
|
2804
|
+
if (depth <= 0) return open + '<!-- ' + kids.length + ' children: ' + ctxTagCounts(kids) + ' -->' + '</' + tag + '>';
|
|
2805
|
+
var lines = [open];
|
|
2806
|
+
var shown = kids.slice(0, CTX_MAX_CHILDREN);
|
|
2807
|
+
for (var i = 0; i < shown.length; i++) {
|
|
2808
|
+
var rendered = ctxChildTag(shown[i], depth);
|
|
2809
|
+
var childLines = rendered.split('\n');
|
|
2810
|
+
for (var j = 0; j < childLines.length; j++) lines.push(' ' + childLines[j]);
|
|
2811
|
+
}
|
|
2812
|
+
if (kids.length > shown.length) lines.push(' <!-- +' + (kids.length - shown.length) + ' more: ' + ctxTagCounts(kids.slice(shown.length)) + ' -->');
|
|
2813
|
+
lines.push('</' + tag + '>');
|
|
2814
|
+
return lines.join('\n');
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
// Adaptive depth: two levels, then one, then a per-tag count — the first
|
|
2818
|
+
// that fits the outline cap. A whole-page container never costs more than a chip.
|
|
2819
|
+
function ctxOutline(el, attrs) {
|
|
2820
|
+
var kids = ctxElementChildren(el);
|
|
2821
|
+
var depths = [2, 1, 0];
|
|
2822
|
+
for (var i = 0; i < depths.length; i++) {
|
|
2823
|
+
var out = ctxOutlineAtDepth(el, attrs, kids, depths[i]);
|
|
2824
|
+
if (out.length <= CTX_MAX_OUTLINE) return out;
|
|
2825
|
+
}
|
|
2826
|
+
return ctxTruncate(ctxOutlineAtDepth(el, attrs, kids, 0), CTX_MAX_OUTLINE);
|
|
2827
|
+
}
|
|
2828
|
+
|
|
2829
|
+
function ctxLandmark(el) {
|
|
2830
|
+
var parent = el.parentElement;
|
|
2831
|
+
if (!parent || !parent.closest) return null;
|
|
2832
|
+
var region = null;
|
|
2833
|
+
try { region = parent.closest(CONTEXT_LANDMARK_SELECTOR); } catch (ex) { return null; }
|
|
2834
|
+
if (!region || region === document.body) return null;
|
|
2835
|
+
var seg = ctxSegment(region);
|
|
2836
|
+
var name = region.getAttribute('aria-label');
|
|
2837
|
+
if (name && name.trim()) seg += ' "' + ctxCollapse(name, 60) + '"';
|
|
2838
|
+
return ctxTruncate(seg, 80);
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
// The nearest heading ABOVE the element in document order (or one that
|
|
2842
|
+
// contains it): the section it sits in, which often names the source file.
|
|
2843
|
+
function ctxHeading(el) {
|
|
2844
|
+
var headings = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
|
2845
|
+
var best = null;
|
|
2846
|
+
for (var i = 0; i < headings.length && i < 500; i++) {
|
|
2847
|
+
var h = headings[i];
|
|
2848
|
+
var pos = el.compareDocumentPosition(h);
|
|
2849
|
+
if (pos & 2 || pos & 8) best = h; // PRECEDING or CONTAINS
|
|
2850
|
+
else if (pos & 4) break; // FOLLOWING: every later heading follows too
|
|
2851
|
+
}
|
|
2852
|
+
if (!best) return null;
|
|
2853
|
+
var text = ctxCollapse(best.textContent, 120);
|
|
2854
|
+
return text ? best.tagName.toLowerCase() + ' "' + text + '"' : null;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
// The cheapest safe framework hint: an author-placed component marker on
|
|
2858
|
+
// the element or a near ancestor. Deliberately no React fiber reads.
|
|
2859
|
+
function ctxComponent(el) {
|
|
2860
|
+
var current = el;
|
|
2861
|
+
for (var depth = 0; current && current.nodeType === 1 && depth < 6; depth++) {
|
|
2862
|
+
var attrs = ['data-component', 'data-testid', 'data-qa'];
|
|
2863
|
+
for (var i = 0; i < attrs.length; i++) {
|
|
2864
|
+
var value = current.getAttribute && current.getAttribute(attrs[i]);
|
|
2865
|
+
if (value && value.trim()) return attrs[i] + '=' + ctxCollapse(value, 60);
|
|
2866
|
+
}
|
|
2867
|
+
current = current.parentElement;
|
|
2868
|
+
}
|
|
2869
|
+
return null;
|
|
2870
|
+
}
|
|
2871
|
+
|
|
2872
|
+
function ctxRect(el) {
|
|
2873
|
+
var r = el.getBoundingClientRect();
|
|
2874
|
+
return {
|
|
2875
|
+
x: Math.round(r.left), y: Math.round(r.top),
|
|
2876
|
+
w: Math.round(r.width), h: Math.round(r.height),
|
|
2877
|
+
vw: Math.round(window.innerWidth || 0), vh: Math.round(window.innerHeight || 0)
|
|
2878
|
+
};
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
// Shed order when over budget: the fields an agent can live without first.
|
|
2882
|
+
var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
|
|
2883
|
+
|
|
2884
|
+
function buildElementContext(el, maxBytes) {
|
|
2885
|
+
if (!el || el.nodeType !== 1) return null;
|
|
2886
|
+
try {
|
|
2887
|
+
var attrs = ctxAttrs(el);
|
|
2888
|
+
var context = { tag: el.tagName.toLowerCase() };
|
|
2889
|
+
if (el.id) context.id = ctxCollapse(el.id, 100);
|
|
2890
|
+
var classes = ctxClasses(el);
|
|
2891
|
+
if (classes.length) context.classes = classes;
|
|
2892
|
+
context.path = ctxPath(el);
|
|
2893
|
+
var role = ctxRole(el);
|
|
2894
|
+
if (role) context.role = role;
|
|
2895
|
+
var name = ctxName(el);
|
|
2896
|
+
if (name) context.name = name;
|
|
2897
|
+
if (attrs.length) context.attrs = attrs;
|
|
2898
|
+
var text = ctxText(el);
|
|
2899
|
+
if (text) context.text = text;
|
|
2900
|
+
context.outline = ctxOutline(el, attrs);
|
|
2901
|
+
context.children = ctxElementChildren(el).length;
|
|
2902
|
+
context.rect = ctxRect(el);
|
|
2903
|
+
var landmark = ctxLandmark(el);
|
|
2904
|
+
if (landmark) context.landmark = landmark;
|
|
2905
|
+
var heading = ctxHeading(el);
|
|
2906
|
+
if (heading) context.heading = heading;
|
|
2907
|
+
var component = ctxComponent(el);
|
|
2908
|
+
if (component) context.component = component;
|
|
2909
|
+
if (LIVE) {
|
|
2910
|
+
context.page = { url: currentPageUrl() };
|
|
2911
|
+
var title = ctxCollapse(document.title, 200);
|
|
2912
|
+
if (title) context.page.title = title;
|
|
2913
|
+
}
|
|
2914
|
+
// Byte budget: shed whole fields in order; a shed attrs/classes array
|
|
2915
|
+
// first loses entries from the end before the field goes entirely.
|
|
2916
|
+
for (var s = 0; s < CTX_SHED_ORDER.length && ctxBytes(context) > maxBytes; s++) {
|
|
2917
|
+
var field = CTX_SHED_ORDER[s];
|
|
2918
|
+
if (context[field] === undefined) continue;
|
|
2919
|
+
if ((field === 'attrs' || field === 'classes') && context[field].length > 1) {
|
|
2920
|
+
while (context[field].length > 1 && ctxBytes(context) > maxBytes) context[field].pop();
|
|
2921
|
+
if (ctxBytes(context) <= maxBytes) break;
|
|
2922
|
+
}
|
|
2923
|
+
if (field === 'text' && context.text.length > 80) {
|
|
2924
|
+
context.text = ctxTruncate(context.text, 80);
|
|
2925
|
+
if (ctxBytes(context) <= maxBytes) break;
|
|
2926
|
+
}
|
|
2927
|
+
delete context[field];
|
|
2928
|
+
}
|
|
2929
|
+
return context;
|
|
2930
|
+
} catch (ex) {
|
|
2931
|
+
return null;
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2507
2935
|
function clearPendingPin() {
|
|
2508
2936
|
pendingPinEl = null;
|
|
2937
|
+
pendingPinContext = null;
|
|
2509
2938
|
pendingPinAnchor = null;
|
|
2510
2939
|
pendingPinKey = null;
|
|
2511
2940
|
pendingPinLabel = null;
|
|
@@ -2626,6 +3055,7 @@
|
|
|
2626
3055
|
pendingPinKey = next.key;
|
|
2627
3056
|
pendingPinLabel = next.label;
|
|
2628
3057
|
pendingPinPoint = next.point || null;
|
|
3058
|
+
pendingPinContext = next.context || null;
|
|
2629
3059
|
// A promoted primary commits as an element pin: the original text
|
|
2630
3060
|
// selection belonged to the removed element and no longer applies.
|
|
2631
3061
|
pendingSelection = { element: true };
|
|
@@ -2684,15 +3114,17 @@
|
|
|
2684
3114
|
if (anchor && point) anchor.point = point;
|
|
2685
3115
|
var label = pinpointHoverLabel(el);
|
|
2686
3116
|
var text = elementTargetText(el, label);
|
|
3117
|
+
var context = buildElementContext(el, CTX_MAX_BYTES_EXTRA);
|
|
2687
3118
|
var key = makeTargetKey();
|
|
2688
3119
|
var box = createMultiTargetBox(el);
|
|
2689
|
-
pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, box: box });
|
|
3120
|
+
pendingMultiTargets.push({ key: key, el: el, anchor: anchor, label: label, text: text, point: point, context: context, box: box });
|
|
2690
3121
|
postToParent({
|
|
2691
3122
|
type: PREFIX + 'multi-target-added',
|
|
2692
3123
|
key: key,
|
|
2693
3124
|
label: label,
|
|
2694
3125
|
text: text,
|
|
2695
|
-
anchor: anchor || undefined
|
|
3126
|
+
anchor: anchor || undefined,
|
|
3127
|
+
context: context || undefined
|
|
2696
3128
|
});
|
|
2697
3129
|
}
|
|
2698
3130
|
|
|
@@ -2793,11 +3225,15 @@
|
|
|
2793
3225
|
pendingPinPoint = normalizePointInElement(el, clickPoint);
|
|
2794
3226
|
if (pendingPinAnchor && pendingPinPoint) pendingPinAnchor.point = pendingPinPoint;
|
|
2795
3227
|
pendingPinViaPinpoint = !!viaPinpoint;
|
|
3228
|
+
// Agent-facing description, captured now while the element is in hand
|
|
3229
|
+
// (an SPA re-render or navigation may have replaced it by submit time).
|
|
3230
|
+
pendingPinContext = buildElementContext(el, CTX_MAX_BYTES);
|
|
2796
3231
|
var extras = {
|
|
2797
3232
|
anchor: pendingPinAnchor,
|
|
2798
3233
|
pinpoint: !!viaPinpoint,
|
|
2799
3234
|
targetKey: pendingPinKey,
|
|
2800
|
-
targetLabel: pendingPinLabel
|
|
3235
|
+
targetLabel: pendingPinLabel,
|
|
3236
|
+
context: pendingPinContext
|
|
2801
3237
|
};
|
|
2802
3238
|
// Pinned outline: stronger accent box that tracks the element until the
|
|
2803
3239
|
// composer resolves (create-mark or cancel-selection).
|
|
@@ -2839,6 +3275,7 @@
|
|
|
2839
3275
|
pinpoint: !!viaPinpoint || undefined,
|
|
2840
3276
|
targetKey: pendingPinKey || undefined,
|
|
2841
3277
|
targetLabel: pendingPinLabel || undefined,
|
|
3278
|
+
context: pendingPinContext || undefined,
|
|
2842
3279
|
rect: { top: r.top, left: r.left, width: r.width, height: r.height } });
|
|
2843
3280
|
return true;
|
|
2844
3281
|
}
|