@plannotator/ui 0.50.0 → 0.52.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 (62) hide show
  1. package/HANDOFF.md +295 -11
  2. package/README.md +8 -0
  3. package/components/AISettingsTab.tsx +32 -0
  4. package/components/AgentToolAnnouncementDialog.tsx +347 -0
  5. package/components/AgentsTab.tsx +9 -9
  6. package/components/AnnotationToolbar.tsx +2 -2
  7. package/components/AskSessionAnnouncementDialog.tsx +329 -0
  8. package/components/BlockRenderer.tsx +10 -1
  9. package/components/CommentPopover.tsx +36 -18
  10. package/components/CompletionOverlay.tsx +12 -5
  11. package/components/DecisionControl.tsx +2 -7
  12. package/components/DiagramBlock.tsx +46 -4
  13. package/components/DocBadges.tsx +12 -8
  14. package/components/FloatingQuickLabelPicker.tsx +2 -2
  15. package/components/PlanHeaderMenu.tsx +1 -1
  16. package/components/QuickLabelDropdown.tsx +9 -4
  17. package/components/ServerSessionReplacedBanner.tsx +29 -0
  18. package/components/Settings.tsx +76 -192
  19. package/components/TerminalToolsAnnouncementDialog.tsx +1 -1
  20. package/components/Viewer.tsx +30 -1
  21. package/components/ai/AIProviderBar.tsx +18 -1
  22. package/components/ai/DocumentAIChatPanel.tsx +4 -9
  23. package/components/ai/SessionAskNotice.tsx +33 -21
  24. package/components/blocks/QuestionBlock.tsx +99 -27
  25. package/components/blocks/QuestionContext.tsx +81 -0
  26. package/components/diagram/DiagramComposer.tsx +54 -3
  27. package/components/diagram/DiagramViewer.tsx +31 -2
  28. package/components/diagram/index.ts +1 -1
  29. package/components/html-viewer/HtmlViewer.tsx +13 -3
  30. package/components/html-viewer/bridge-script.asset.js +230 -14
  31. package/components/html-viewer/bridge-script.ts +230 -14
  32. package/components/html-viewer/useHtmlAnnotation.ts +36 -55
  33. package/components/settings/QuickLabelsTab.tsx +378 -0
  34. package/hooks/useAIChat.ts +83 -6
  35. package/hooks/useAIProviderActivation.ts +13 -0
  36. package/hooks/useAIProviderConfig.ts +3 -3
  37. package/hooks/useActiveSection.ts +3 -2
  38. package/hooks/useAgentToolSetting.ts +33 -0
  39. package/hooks/useAnnotationDraft.ts +53 -7
  40. package/hooks/useAnnotationHighlighter.ts +129 -5
  41. package/hooks/useExternalAnnotations.ts +21 -1
  42. package/hooks/useFirstRunAnnouncementWindow.ts +114 -0
  43. package/hooks/useLatchedTrue.ts +15 -0
  44. package/hooks/useLinkedDoc.ts +64 -4
  45. package/hooks/useModelCatalogs.ts +9 -2
  46. package/package.json +2 -2
  47. package/styles.css +1 -1
  48. package/themes/gruvbox.css +6 -0
  49. package/types.ts +19 -0
  50. package/utils/agentClosed.ts +33 -0
  51. package/utils/agentToolAnnouncement.ts +109 -0
  52. package/utils/agentToolSetting.ts +108 -0
  53. package/utils/aiProvider.ts +13 -40
  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/parser.ts +382 -114
  60. package/utils/questionAnswers.ts +3 -1
  61. package/utils/quickLabels.ts +82 -2
  62. package/utils/serverSession.ts +85 -0
@@ -980,6 +980,10 @@
980
980
 
981
981
  // Floating label naming the element under the cursor (like the markdown overlay).
982
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' };
983
987
 
984
988
  var MAX_HOVER_LABEL = 40;
985
989
  function truncateLabel(text) {
@@ -1015,6 +1019,8 @@
1015
1019
  if (known) return known;
1016
1020
  var aria = el.getAttribute && el.getAttribute('aria-label');
1017
1021
  if (aria && aria.trim()) return truncateLabel(aria.trim());
1022
+ var media = MEDIA_PINPOINT_LABELS[el.tagName];
1023
+ if (media) return media;
1018
1024
  var role = el.getAttribute && el.getAttribute('role');
1019
1025
  if (role && role.trim()) return truncateLabel(role.trim());
1020
1026
  var tokens = meaningfulClassTokens(el);
@@ -2451,9 +2457,88 @@
2451
2457
 
2452
2458
  function anchorTextSnapshot(el) {
2453
2459
  var text = (el.textContent || '').replace(/\s+/g, ' ').trim();
2460
+ if (!text) return anchorSourceSnapshot(el);
2454
2461
  return text.length > 180 ? text.slice(0, 180) : text;
2455
2462
  }
2456
2463
 
2464
+ // Media snapshots. An image, video or frame has no text to verify a weak
2465
+ // selector against, but its source says what it shows the way text says
2466
+ // what a paragraph says, and unlike the structure-derived signatures D1
2467
+ // ruled out, two siblings showing different files never share it. The key
2468
+ // is the author's reference to the resource: relative to the document's
2469
+ // base when it lies under it (a host may re-root assets per session, through
2470
+ // a <base> on its own asset route or by rewriting attributes to a session
2471
+ // host), else its root-relative path on the document's own origin (a live
2472
+ // app's proxy port changes per session), else origin and path. The query
2473
+ // and the fragment are part of what a source shows (/_next/image?url=a.jpg,
2474
+ // proto.html?step=2, icons.svg#close), so they count, but only as a digest:
2475
+ // they can carry per-visit state (signed URLs, tokens) that must never be
2476
+ // stored in plain text in an annotation. A source whose query changes per
2477
+ // load therefore does not restore, which fails closed. Inline sources
2478
+ // (data:, blob:) name no resource and get no key.
2479
+ var ANCHOR_SOURCE_PREFIX = 'src:';
2480
+ var ANCHOR_SOURCE_MAX = 176;
2481
+
2482
+ // FNV-1a, 32 bits, as 8 hex digits: an identity check, not a secret.
2483
+ function anchorDigest(value) {
2484
+ var hash = 0x811c9dc5;
2485
+ for (var i = 0; i < value.length; i++) {
2486
+ hash ^= value.charCodeAt(i);
2487
+ hash = Math.imul(hash, 0x01000193) >>> 0;
2488
+ }
2489
+ return ('0000000' + hash.toString(16)).slice(-8);
2490
+ }
2491
+
2492
+ function anchorSourceKey(el) {
2493
+ var raw = descRawSource(el);
2494
+ if (!raw || /^(data|blob):/i.test(raw)) return '';
2495
+ var path = raw;
2496
+ var variant = '';
2497
+ var url = null;
2498
+ try { url = new URL(raw, document.baseURI); } catch (ex) {}
2499
+ if (!url || !/^https?:$/i.test(url.protocol)) {
2500
+ var mark = raw.search(/[?#]/);
2501
+ if (mark >= 0) {
2502
+ path = raw.slice(0, mark);
2503
+ variant = raw.slice(mark);
2504
+ }
2505
+ } else {
2506
+ variant = url.search + url.hash;
2507
+ var base = null;
2508
+ try { base = new URL('.', document.baseURI); } catch (ex2) {}
2509
+ if (base && url.origin === base.origin && url.pathname.indexOf(base.pathname) === 0) {
2510
+ path = url.pathname.slice(base.pathname.length);
2511
+ } else if (base && url.origin === base.origin) {
2512
+ path = url.pathname;
2513
+ } else {
2514
+ path = url.origin + url.pathname;
2515
+ }
2516
+ }
2517
+ if (!path) return '';
2518
+ return variant ? path + '?' + anchorDigest(variant) : path;
2519
+ }
2520
+
2521
+ function sourceSnapshotOf(el) {
2522
+ var key = anchorSourceKey(el);
2523
+ if (!key) return '';
2524
+ // A long key keeps its head for readability and a digest of the whole,
2525
+ // so two sources that differ only past the cut never compare equal.
2526
+ if (key.length > ANCHOR_SOURCE_MAX) key = key.slice(0, ANCHOR_SOURCE_MAX - 9) + '~' + anchorDigest(key);
2527
+ return ANCHOR_SOURCE_PREFIX + key;
2528
+ }
2529
+
2530
+ // A source that another element of the same kind shares cannot tell the two
2531
+ // apart, which is the D1 failure again: no snapshot, so no anchor.
2532
+ function anchorSourceSnapshot(el) {
2533
+ var snapshot = sourceSnapshotOf(el);
2534
+ if (!snapshot) return '';
2535
+ var sameKind = document.getElementsByTagName(el.tagName);
2536
+ for (var i = 0; i < sameKind.length; i++) {
2537
+ if (sameKind[i] !== el && sourceSnapshotOf(sameKind[i]) === snapshot) return '';
2538
+ }
2539
+ return snapshot;
2540
+ }
2541
+
2457
2542
  function uniquelySelects(selector, el) {
2458
2543
  try {
2459
2544
  var matches = document.querySelectorAll(selector);
@@ -2552,12 +2637,13 @@
2552
2637
  if (!selector) return null;
2553
2638
  var snapshot = anchorTextSnapshot(el);
2554
2639
  // Text-less elements anchor ONLY through a stable-identity rung (#id or
2555
- // an author-controlled data-* identity attribute). A weak (positional /
2556
- // class) selector has nothing to verify against — identical siblings
2557
- // share every structural trait, so any derived signature would validate
2558
- // the WRONG element after a sibling is inserted or removed. A
2559
- // wrong-binding anchor is worse than no anchor: ship none and fail
2560
- // closed (the pin simply doesn't restore).
2640
+ // an author-controlled data-* identity attribute), or, for media, through
2641
+ // a source no other element of its kind shows (anchorSourceSnapshot).
2642
+ // Otherwise a weak (positional / class) selector has nothing to verify
2643
+ // against — identical siblings share every structural trait, so any
2644
+ // derived signature would validate the WRONG element after a sibling is
2645
+ // inserted or removed. A wrong-binding anchor is worse than no anchor:
2646
+ // ship none and fail closed (the pin simply doesn't restore).
2561
2647
  if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
2562
2648
  return {
2563
2649
  selector: selector,
@@ -2590,6 +2676,26 @@
2590
2676
 
2591
2677
  function resolveAnchorElement(anchor) {
2592
2678
  if (!anchor || typeof anchor.selector !== 'string' || !anchor.selector || typeof anchor.tagName !== 'string') return null;
2679
+ return resolveAnchorSelector(anchor) || relocateBySource(anchor);
2680
+ }
2681
+
2682
+ // A media anchor whose selector no longer lands on its element (a sibling
2683
+ // was inserted or removed, a refresh reordered the page) finds it again by
2684
+ // its source, the way a text annotation falls back to searching for its
2685
+ // text. Exactly one element may match: two resolve nothing.
2686
+ function relocateBySource(anchor) {
2687
+ if (typeof anchor.text !== 'string' || anchor.text.indexOf(ANCHOR_SOURCE_PREFIX) !== 0) return null;
2688
+ var found = null;
2689
+ var candidates = document.getElementsByTagName(anchor.tagName);
2690
+ for (var i = 0; i < candidates.length; i++) {
2691
+ if (sourceSnapshotOf(candidates[i]) !== anchor.text) continue;
2692
+ if (found) return null;
2693
+ found = candidates[i];
2694
+ }
2695
+ return found;
2696
+ }
2697
+
2698
+ function resolveAnchorSelector(anchor) {
2593
2699
  var matches;
2594
2700
  try {
2595
2701
  matches = document.querySelectorAll(anchor.selector);
@@ -2680,6 +2786,25 @@
2680
2786
  function ctxScrubUrl(value) {
2681
2787
  var v = String(value).trim();
2682
2788
  if (/^javascript:/i.test(v)) return null;
2789
+ // Srcdoc sessions serve a document's relative assets through the
2790
+ // session's own asset route (/api/html-assets/<token>/<path>). The token
2791
+ // means nothing outside this session; the path after it is the author's
2792
+ // own, relative to the document, which is what an agent can find.
2793
+ if (!LIVE) {
2794
+ var asset = v.match(/^\/api\/html-assets\/[^\/?#]+\/([^?#]*)/);
2795
+ if (asset && asset[1]) {
2796
+ // The route percent-encodes each segment; show the name the author
2797
+ // wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
2798
+ // that decodes to ? or # (which the query scrub below would cut), keeps
2799
+ // the raw text.
2800
+ var authored = asset[1];
2801
+ try {
2802
+ var decoded = decodeURIComponent(authored);
2803
+ if (!/[?#]/.test(decoded)) authored = decoded;
2804
+ } catch (ex) {}
2805
+ v = authored + v.slice(asset[0].length);
2806
+ }
2807
+ }
2683
2808
  if (/^data:/i.test(v)) {
2684
2809
  var comma = v.indexOf(',');
2685
2810
  return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
@@ -2989,7 +3114,8 @@
2989
3114
  }
2990
3115
 
2991
3116
  // Shed order when over budget: the fields an agent can live without first.
2992
- var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
3117
+ // Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
3118
+ var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
2993
3119
 
2994
3120
  function buildElementContext(el, maxBytes) {
2995
3121
  if (!el || el.nodeType !== 1) return null;
@@ -3016,6 +3142,12 @@
3016
3142
  if (heading) context.heading = heading;
3017
3143
  var component = ctxComponent(el);
3018
3144
  if (component) context.component = component;
3145
+ // The media file this element shows, resolved exactly as the composer
3146
+ // quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
3147
+ // poster; see descRawSource), so the exported heading can name the file
3148
+ // even when the element has no usable src attribute.
3149
+ var sourceName = descSourceName(el);
3150
+ if (sourceName) context.sourceName = sourceName;
3019
3151
  if (LIVE) {
3020
3152
  context.page = { url: currentPageUrl() };
3021
3153
  var title = ctxCollapse(document.title, 200);
@@ -3128,11 +3260,95 @@
3128
3260
  }
3129
3261
  }
3130
3262
 
3131
- // Element text for an additional target: same capping and text-less
3132
- // description used by the primary element path in annotateElement.
3263
+ // --- Text-less element descriptions ---
3264
+ // A pinpoint on an element with no text quotes a description instead
3265
+ // ("[element: …]"). The hover label alone ("Image") cannot tell one image
3266
+ // from the next, so the description adds what identifies THIS element: its
3267
+ // accessible name (aria-label, alt, title, ...) and, for media, the source
3268
+ // file name. The source goes through the same scrub the element context
3269
+ // uses (no query, no fragment, data: reduced to its media type). Square
3270
+ // brackets become parentheses and double quotes single ones, so the whole
3271
+ // description stays one "[element: …]" token the export recognizes.
3272
+ var DESC_MAX_NAME = 80;
3273
+ var DESC_MAX_FILE = 60;
3274
+ var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
3275
+
3276
+ function descClean(value, max) {
3277
+ return ctxCollapse(String(value).replace(/\[/g, '(').replace(/\]/g, ')').replace(/"/g, "'"), max);
3278
+ }
3279
+
3280
+ // Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
3281
+ // first candidate's first word. A data: URL carries its own comma, so it is
3282
+ // taken up to whitespace instead.
3283
+ function descFirstSrcsetUrl(value) {
3284
+ var srcset = String(value || '').trim();
3285
+ if (!srcset) return '';
3286
+ return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\s+/)[0] || '';
3287
+ }
3288
+
3289
+ function descRawSource(el) {
3290
+ var tag = el.tagName;
3291
+ if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
3292
+ var attr = DESC_SRC_ATTR[tag];
3293
+ var src = '';
3294
+ if (attr) src = (el.getAttribute(attr) || '').trim();
3295
+ else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
3296
+ else return '';
3297
+ // Lazy loaders put a data: placeholder in src and the real file in data-src.
3298
+ var lazy = (el.getAttribute('data-src') || '').trim();
3299
+ if (lazy && (!src || /^data:/i.test(src))) src = lazy;
3300
+ if (!src && tag === 'IMG') {
3301
+ src = descFirstSrcsetUrl(el.getAttribute('srcset'));
3302
+ // <picture><source srcset="…"><img></picture>: the img itself may carry
3303
+ // no source at all; the first <source> candidate names the file.
3304
+ var picture = el.parentElement;
3305
+ if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
3306
+ var pictureSource = picture.querySelector('source[srcset]');
3307
+ if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
3308
+ }
3309
+ }
3310
+ if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
3311
+ var child = el.querySelector && el.querySelector('source[src]');
3312
+ if (child) src = (child.getAttribute('src') || '').trim();
3313
+ if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
3314
+ }
3315
+ return src;
3316
+ }
3317
+
3318
+ // The file name a source points at: "team.jpg" for
3319
+ // https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
3320
+ function descSourceName(el) {
3321
+ var src = descRawSource(el);
3322
+ if (!src) return '';
3323
+ var scrubbed = ctxScrubUrl(src);
3324
+ if (!scrubbed) return '';
3325
+ if (/^data:/i.test(scrubbed)) {
3326
+ var media = scrubbed.slice(5).split(/[;,]/)[0];
3327
+ return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
3328
+ }
3329
+ if (/^blob:/i.test(scrubbed)) return 'blob';
3330
+ var path = scrubbed.replace(/\?…$/, '').replace(/\/+$/, '');
3331
+ var name = path.slice(path.lastIndexOf('/') + 1);
3332
+ try { name = decodeURIComponent(name); } catch (ex) {}
3333
+ if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
3334
+ return descClean(name, DESC_MAX_FILE);
3335
+ }
3336
+
3337
+ function describeTextlessElement(el, label) {
3338
+ var out = descClean(label, DESC_MAX_NAME) || 'element';
3339
+ var name = ctxName(el);
3340
+ name = name ? descClean(name, DESC_MAX_NAME) : '';
3341
+ if (name && name !== out) out += ' "' + name + '"';
3342
+ var file = descSourceName(el);
3343
+ if (file && file !== name) out += ' (' + file + ')';
3344
+ return out;
3345
+ }
3346
+
3347
+ // Element text for a pinpoint target (primary or additional): the
3348
+ // element's own text, or for a text-less element its description.
3133
3349
  function elementTargetText(el, label) {
3134
3350
  var text = capSelectionText((el.textContent || '').trim());
3135
- if (!text) text = capSelectionText('[element: ' + label + ']');
3351
+ if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
3136
3352
  return text;
3137
3353
  }
3138
3354
 
@@ -3371,10 +3587,10 @@
3371
3587
  if (!posted) clearPendingPin();
3372
3588
  return posted;
3373
3589
  }
3374
- var elText = capSelectionText((el.textContent || '').trim());
3375
- // Text-less elements (icon buttons, decorative chips, empty containers)
3376
- // are still annotatable: describe the element instead of quoting it.
3377
- if (!elText) elText = capSelectionText('[element: ' + pinpointHoverLabel(el) + ']');
3590
+ // Text-less elements (images, icon buttons, decorative chips, empty
3591
+ // containers) are still annotatable: describe the element instead of
3592
+ // quoting it, naming WHICH one it is where the page says so.
3593
+ var elText = elementTargetText(el, pinpointHoverLabel(el));
3378
3594
  var r = el.getBoundingClientRect();
3379
3595
  pendingSelection = { element: true };
3380
3596
  pendingRange = null;
@@ -1216,6 +1216,10 @@ export const BRIDGE_SCRIPT = `(function() {
1216
1216
 
1217
1217
  // Floating label naming the element under the cursor (like the markdown overlay).
1218
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' };
1219
1223
 
1220
1224
  var MAX_HOVER_LABEL = 40;
1221
1225
  function truncateLabel(text) {
@@ -1251,6 +1255,8 @@ export const BRIDGE_SCRIPT = `(function() {
1251
1255
  if (known) return known;
1252
1256
  var aria = el.getAttribute && el.getAttribute('aria-label');
1253
1257
  if (aria && aria.trim()) return truncateLabel(aria.trim());
1258
+ var media = MEDIA_PINPOINT_LABELS[el.tagName];
1259
+ if (media) return media;
1254
1260
  var role = el.getAttribute && el.getAttribute('role');
1255
1261
  if (role && role.trim()) return truncateLabel(role.trim());
1256
1262
  var tokens = meaningfulClassTokens(el);
@@ -2687,9 +2693,88 @@ export const BRIDGE_SCRIPT = `(function() {
2687
2693
 
2688
2694
  function anchorTextSnapshot(el) {
2689
2695
  var text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
2696
+ if (!text) return anchorSourceSnapshot(el);
2690
2697
  return text.length > 180 ? text.slice(0, 180) : text;
2691
2698
  }
2692
2699
 
2700
+ // Media snapshots. An image, video or frame has no text to verify a weak
2701
+ // selector against, but its source says what it shows the way text says
2702
+ // what a paragraph says, and unlike the structure-derived signatures D1
2703
+ // ruled out, two siblings showing different files never share it. The key
2704
+ // is the author's reference to the resource: relative to the document's
2705
+ // base when it lies under it (a host may re-root assets per session, through
2706
+ // a <base> on its own asset route or by rewriting attributes to a session
2707
+ // host), else its root-relative path on the document's own origin (a live
2708
+ // app's proxy port changes per session), else origin and path. The query
2709
+ // and the fragment are part of what a source shows (/_next/image?url=a.jpg,
2710
+ // proto.html?step=2, icons.svg#close), so they count, but only as a digest:
2711
+ // they can carry per-visit state (signed URLs, tokens) that must never be
2712
+ // stored in plain text in an annotation. A source whose query changes per
2713
+ // load therefore does not restore, which fails closed. Inline sources
2714
+ // (data:, blob:) name no resource and get no key.
2715
+ var ANCHOR_SOURCE_PREFIX = 'src:';
2716
+ var ANCHOR_SOURCE_MAX = 176;
2717
+
2718
+ // FNV-1a, 32 bits, as 8 hex digits: an identity check, not a secret.
2719
+ function anchorDigest(value) {
2720
+ var hash = 0x811c9dc5;
2721
+ for (var i = 0; i < value.length; i++) {
2722
+ hash ^= value.charCodeAt(i);
2723
+ hash = Math.imul(hash, 0x01000193) >>> 0;
2724
+ }
2725
+ return ('0000000' + hash.toString(16)).slice(-8);
2726
+ }
2727
+
2728
+ function anchorSourceKey(el) {
2729
+ var raw = descRawSource(el);
2730
+ if (!raw || /^(data|blob):/i.test(raw)) return '';
2731
+ var path = raw;
2732
+ var variant = '';
2733
+ var url = null;
2734
+ try { url = new URL(raw, document.baseURI); } catch (ex) {}
2735
+ if (!url || !/^https?:$/i.test(url.protocol)) {
2736
+ var mark = raw.search(/[?#]/);
2737
+ if (mark >= 0) {
2738
+ path = raw.slice(0, mark);
2739
+ variant = raw.slice(mark);
2740
+ }
2741
+ } else {
2742
+ variant = url.search + url.hash;
2743
+ var base = null;
2744
+ try { base = new URL('.', document.baseURI); } catch (ex2) {}
2745
+ if (base && url.origin === base.origin && url.pathname.indexOf(base.pathname) === 0) {
2746
+ path = url.pathname.slice(base.pathname.length);
2747
+ } else if (base && url.origin === base.origin) {
2748
+ path = url.pathname;
2749
+ } else {
2750
+ path = url.origin + url.pathname;
2751
+ }
2752
+ }
2753
+ if (!path) return '';
2754
+ return variant ? path + '?' + anchorDigest(variant) : path;
2755
+ }
2756
+
2757
+ function sourceSnapshotOf(el) {
2758
+ var key = anchorSourceKey(el);
2759
+ if (!key) return '';
2760
+ // A long key keeps its head for readability and a digest of the whole,
2761
+ // so two sources that differ only past the cut never compare equal.
2762
+ if (key.length > ANCHOR_SOURCE_MAX) key = key.slice(0, ANCHOR_SOURCE_MAX - 9) + '~' + anchorDigest(key);
2763
+ return ANCHOR_SOURCE_PREFIX + key;
2764
+ }
2765
+
2766
+ // A source that another element of the same kind shares cannot tell the two
2767
+ // apart, which is the D1 failure again: no snapshot, so no anchor.
2768
+ function anchorSourceSnapshot(el) {
2769
+ var snapshot = sourceSnapshotOf(el);
2770
+ if (!snapshot) return '';
2771
+ var sameKind = document.getElementsByTagName(el.tagName);
2772
+ for (var i = 0; i < sameKind.length; i++) {
2773
+ if (sameKind[i] !== el && sourceSnapshotOf(sameKind[i]) === snapshot) return '';
2774
+ }
2775
+ return snapshot;
2776
+ }
2777
+
2693
2778
  function uniquelySelects(selector, el) {
2694
2779
  try {
2695
2780
  var matches = document.querySelectorAll(selector);
@@ -2788,12 +2873,13 @@ export const BRIDGE_SCRIPT = `(function() {
2788
2873
  if (!selector) return null;
2789
2874
  var snapshot = anchorTextSnapshot(el);
2790
2875
  // Text-less elements anchor ONLY through a stable-identity rung (#id or
2791
- // an author-controlled data-* identity attribute). A weak (positional /
2792
- // class) selector has nothing to verify against — identical siblings
2793
- // share every structural trait, so any derived signature would validate
2794
- // the WRONG element after a sibling is inserted or removed. A
2795
- // wrong-binding anchor is worse than no anchor: ship none and fail
2796
- // closed (the pin simply doesn't restore).
2876
+ // an author-controlled data-* identity attribute), or, for media, through
2877
+ // a source no other element of its kind shows (anchorSourceSnapshot).
2878
+ // Otherwise a weak (positional / class) selector has nothing to verify
2879
+ // against — identical siblings share every structural trait, so any
2880
+ // derived signature would validate the WRONG element after a sibling is
2881
+ // inserted or removed. A wrong-binding anchor is worse than no anchor:
2882
+ // ship none and fail closed (the pin simply doesn't restore).
2797
2883
  if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
2798
2884
  return {
2799
2885
  selector: selector,
@@ -2826,6 +2912,26 @@ export const BRIDGE_SCRIPT = `(function() {
2826
2912
 
2827
2913
  function resolveAnchorElement(anchor) {
2828
2914
  if (!anchor || typeof anchor.selector !== 'string' || !anchor.selector || typeof anchor.tagName !== 'string') return null;
2915
+ return resolveAnchorSelector(anchor) || relocateBySource(anchor);
2916
+ }
2917
+
2918
+ // A media anchor whose selector no longer lands on its element (a sibling
2919
+ // was inserted or removed, a refresh reordered the page) finds it again by
2920
+ // its source, the way a text annotation falls back to searching for its
2921
+ // text. Exactly one element may match: two resolve nothing.
2922
+ function relocateBySource(anchor) {
2923
+ if (typeof anchor.text !== 'string' || anchor.text.indexOf(ANCHOR_SOURCE_PREFIX) !== 0) return null;
2924
+ var found = null;
2925
+ var candidates = document.getElementsByTagName(anchor.tagName);
2926
+ for (var i = 0; i < candidates.length; i++) {
2927
+ if (sourceSnapshotOf(candidates[i]) !== anchor.text) continue;
2928
+ if (found) return null;
2929
+ found = candidates[i];
2930
+ }
2931
+ return found;
2932
+ }
2933
+
2934
+ function resolveAnchorSelector(anchor) {
2829
2935
  var matches;
2830
2936
  try {
2831
2937
  matches = document.querySelectorAll(anchor.selector);
@@ -2916,6 +3022,25 @@ export const BRIDGE_SCRIPT = `(function() {
2916
3022
  function ctxScrubUrl(value) {
2917
3023
  var v = String(value).trim();
2918
3024
  if (/^javascript:/i.test(v)) return null;
3025
+ // Srcdoc sessions serve a document's relative assets through the
3026
+ // session's own asset route (/api/html-assets/<token>/<path>). The token
3027
+ // means nothing outside this session; the path after it is the author's
3028
+ // own, relative to the document, which is what an agent can find.
3029
+ if (!LIVE) {
3030
+ var asset = v.match(/^\\/api\\/html-assets\\/[^\\/?#]+\\/([^?#]*)/);
3031
+ if (asset && asset[1]) {
3032
+ // The route percent-encodes each segment; show the name the author
3033
+ // wrote (jane doe.png, not jane%20doe.png). A malformed escape, or one
3034
+ // that decodes to ? or # (which the query scrub below would cut), keeps
3035
+ // the raw text.
3036
+ var authored = asset[1];
3037
+ try {
3038
+ var decoded = decodeURIComponent(authored);
3039
+ if (!/[?#]/.test(decoded)) authored = decoded;
3040
+ } catch (ex) {}
3041
+ v = authored + v.slice(asset[0].length);
3042
+ }
3043
+ }
2919
3044
  if (/^data:/i.test(v)) {
2920
3045
  var comma = v.indexOf(',');
2921
3046
  return (comma > 0 ? v.slice(0, Math.min(comma, 40)) : v.slice(0, 40)) + ',…';
@@ -3225,7 +3350,8 @@ export const BRIDGE_SCRIPT = `(function() {
3225
3350
  }
3226
3351
 
3227
3352
  // Shed order when over budget: the fields an agent can live without first.
3228
- var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component'];
3353
+ // Mirrors ELEMENT_CONTEXT_SHED_ORDER in @plannotator/core/html-anchor.
3354
+ var CTX_SHED_ORDER = ['outline', 'text', 'attrs', 'classes', 'path', 'heading', 'landmark', 'component', 'sourceName'];
3229
3355
 
3230
3356
  function buildElementContext(el, maxBytes) {
3231
3357
  if (!el || el.nodeType !== 1) return null;
@@ -3252,6 +3378,12 @@ export const BRIDGE_SCRIPT = `(function() {
3252
3378
  if (heading) context.heading = heading;
3253
3379
  var component = ctxComponent(el);
3254
3380
  if (component) context.component = component;
3381
+ // The media file this element shows, resolved exactly as the composer
3382
+ // quote resolves it (src, a lazy data-src, srcset, <picture>/<source>,
3383
+ // poster; see descRawSource), so the exported heading can name the file
3384
+ // even when the element has no usable src attribute.
3385
+ var sourceName = descSourceName(el);
3386
+ if (sourceName) context.sourceName = sourceName;
3255
3387
  if (LIVE) {
3256
3388
  context.page = { url: currentPageUrl() };
3257
3389
  var title = ctxCollapse(document.title, 200);
@@ -3364,11 +3496,95 @@ export const BRIDGE_SCRIPT = `(function() {
3364
3496
  }
3365
3497
  }
3366
3498
 
3367
- // Element text for an additional target: same capping and text-less
3368
- // description used by the primary element path in annotateElement.
3499
+ // --- Text-less element descriptions ---
3500
+ // A pinpoint on an element with no text quotes a description instead
3501
+ // ("[element: …]"). The hover label alone ("Image") cannot tell one image
3502
+ // from the next, so the description adds what identifies THIS element: its
3503
+ // accessible name (aria-label, alt, title, ...) and, for media, the source
3504
+ // file name. The source goes through the same scrub the element context
3505
+ // uses (no query, no fragment, data: reduced to its media type). Square
3506
+ // brackets become parentheses and double quotes single ones, so the whole
3507
+ // description stays one "[element: …]" token the export recognizes.
3508
+ var DESC_MAX_NAME = 80;
3509
+ var DESC_MAX_FILE = 60;
3510
+ var DESC_SRC_ATTR = { IMG: 'src', VIDEO: 'src', AUDIO: 'src', IFRAME: 'src', EMBED: 'src', SOURCE: 'src', TRACK: 'src', INPUT: 'src', OBJECT: 'data' };
3511
+
3512
+ function descClean(value, max) {
3513
+ return ctxCollapse(String(value).replace(/\\[/g, '(').replace(/\\]/g, ')').replace(/"/g, "'"), max);
3514
+ }
3515
+
3516
+ // Candidates are comma-separated ("a.png, b.png 2x"); the URL is the
3517
+ // first candidate's first word. A data: URL carries its own comma, so it is
3518
+ // taken up to whitespace instead.
3519
+ function descFirstSrcsetUrl(value) {
3520
+ var srcset = String(value || '').trim();
3521
+ if (!srcset) return '';
3522
+ return (/^data:/i.test(srcset) ? srcset : srcset.split(',')[0]).trim().split(/\\s+/)[0] || '';
3523
+ }
3524
+
3525
+ function descRawSource(el) {
3526
+ var tag = el.tagName;
3527
+ if (tag === 'INPUT' && String(el.getAttribute('type') || '').toLowerCase() !== 'image') return '';
3528
+ var attr = DESC_SRC_ATTR[tag];
3529
+ var src = '';
3530
+ if (attr) src = (el.getAttribute(attr) || '').trim();
3531
+ else if (el.ownerSVGElement && tag.toLowerCase() === 'image') src = (el.getAttribute('href') || el.getAttribute('xlink:href') || '').trim();
3532
+ else return '';
3533
+ // Lazy loaders put a data: placeholder in src and the real file in data-src.
3534
+ var lazy = (el.getAttribute('data-src') || '').trim();
3535
+ if (lazy && (!src || /^data:/i.test(src))) src = lazy;
3536
+ if (!src && tag === 'IMG') {
3537
+ src = descFirstSrcsetUrl(el.getAttribute('srcset'));
3538
+ // <picture><source srcset="…"><img></picture>: the img itself may carry
3539
+ // no source at all; the first <source> candidate names the file.
3540
+ var picture = el.parentElement;
3541
+ if (!src && picture && picture.tagName === 'PICTURE' && picture.querySelector) {
3542
+ var pictureSource = picture.querySelector('source[srcset]');
3543
+ if (pictureSource) src = descFirstSrcsetUrl(pictureSource.getAttribute('srcset'));
3544
+ }
3545
+ }
3546
+ if (!src && (tag === 'VIDEO' || tag === 'AUDIO')) {
3547
+ var child = el.querySelector && el.querySelector('source[src]');
3548
+ if (child) src = (child.getAttribute('src') || '').trim();
3549
+ if (!src && tag === 'VIDEO') src = (el.getAttribute('poster') || '').trim();
3550
+ }
3551
+ return src;
3552
+ }
3553
+
3554
+ // The file name a source points at: "team.jpg" for
3555
+ // https://cdn.test/img/team.jpg?sig=…, "data:image/png" for an inline image.
3556
+ function descSourceName(el) {
3557
+ var src = descRawSource(el);
3558
+ if (!src) return '';
3559
+ var scrubbed = ctxScrubUrl(src);
3560
+ if (!scrubbed) return '';
3561
+ if (/^data:/i.test(scrubbed)) {
3562
+ var media = scrubbed.slice(5).split(/[;,]/)[0];
3563
+ return descClean('data:' + (media || 'unknown'), DESC_MAX_FILE);
3564
+ }
3565
+ if (/^blob:/i.test(scrubbed)) return 'blob';
3566
+ var path = scrubbed.replace(/\\?…$/, '').replace(/\\/+$/, '');
3567
+ var name = path.slice(path.lastIndexOf('/') + 1);
3568
+ try { name = decodeURIComponent(name); } catch (ex) {}
3569
+ if (!name || /^[a-z][a-z0-9+.-]*:$/i.test(name)) name = path;
3570
+ return descClean(name, DESC_MAX_FILE);
3571
+ }
3572
+
3573
+ function describeTextlessElement(el, label) {
3574
+ var out = descClean(label, DESC_MAX_NAME) || 'element';
3575
+ var name = ctxName(el);
3576
+ name = name ? descClean(name, DESC_MAX_NAME) : '';
3577
+ if (name && name !== out) out += ' "' + name + '"';
3578
+ var file = descSourceName(el);
3579
+ if (file && file !== name) out += ' (' + file + ')';
3580
+ return out;
3581
+ }
3582
+
3583
+ // Element text for a pinpoint target (primary or additional): the
3584
+ // element's own text, or for a text-less element its description.
3369
3585
  function elementTargetText(el, label) {
3370
3586
  var text = capSelectionText((el.textContent || '').trim());
3371
- if (!text) text = capSelectionText('[element: ' + label + ']');
3587
+ if (!text) text = capSelectionText('[element: ' + describeTextlessElement(el, label) + ']');
3372
3588
  return text;
3373
3589
  }
3374
3590
 
@@ -3607,10 +3823,10 @@ export const BRIDGE_SCRIPT = `(function() {
3607
3823
  if (!posted) clearPendingPin();
3608
3824
  return posted;
3609
3825
  }
3610
- var elText = capSelectionText((el.textContent || '').trim());
3611
- // Text-less elements (icon buttons, decorative chips, empty containers)
3612
- // are still annotatable: describe the element instead of quoting it.
3613
- if (!elText) elText = capSelectionText('[element: ' + pinpointHoverLabel(el) + ']');
3826
+ // Text-less elements (images, icon buttons, decorative chips, empty
3827
+ // containers) are still annotatable: describe the element instead of
3828
+ // quoting it, naming WHICH one it is where the page says so.
3829
+ var elText = elementTargetText(el, pinpointHoverLabel(el));
3614
3830
  var r = el.getBoundingClientRect();
3615
3831
  pendingSelection = { element: true };
3616
3832
  pendingRange = null;