@plannotator/ui 0.51.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.
@@ -6,7 +6,7 @@ import { AnnotationType, type Block, type Annotation, type EditorMode, type Inpu
6
6
  import { applyHighlight, codeBlockClassName, onCodeHighlightSwap } from '../utils/codeHighlight';
7
7
  import { paintCodeBlockMark } from '../utils/codeBlockMark';
8
8
  import { useFenceTheme } from '../hooks/useFenceTheme';
9
- import { computeListIndices, groupBlocks, type Frontmatter, type FrontmatterValue } from '../utils/parser';
9
+ import { computeListIndices, groupBlocks, selectionSourceLines, type Frontmatter, type FrontmatterValue } from '../utils/parser';
10
10
  import { buildHeadingSlugMap } from '../utils/slugify';
11
11
  import { copyTextToClipboard } from '../utils/clipboard';
12
12
  import { BlockRenderer } from './BlockRenderer';
@@ -218,6 +218,21 @@ export interface ViewerProps {
218
218
  * decision"), given the indexed question and its saved answer. Rendered in
219
219
  * read-only cards too; return null for none. */
220
220
  renderQuestionFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
221
+ /** Whether answering a `Decision: when answered` question records a
222
+ * decision, per question: return false to draw its "Records a decision"
223
+ * tag dimmed with a dotted outline and hide the "Answering this records a
224
+ * decision" row; true or undefined keeps today's card. Not consulted for a
225
+ * question whose decision is already recorded. */
226
+ questionDecisionRecording?: (question: IndexedQuestion) => boolean | undefined;
227
+ /** Makes a `Decision: when answered` question's "Records a decision" tag a
228
+ * toggle button (`aria-pressed`); a click calls this with the question's
229
+ * key and the next state, which the host stores and returns from
230
+ * `questionDecisionRecording`. Ignored when `readOnly`. */
231
+ onToggleQuestionDecisionRecording?: (key: string, next: boolean) => void;
232
+ /** `'none'` hides each question card's own status tag (Open / Answered /
233
+ * Settled / Skipped) so the host can draw its own; the decision tags stay.
234
+ * Default `'card'`. */
235
+ questionStatusTag?: 'card' | 'none';
221
236
  onAskAI?: CommentAskAIHandler;
222
237
  /** Whether comment popovers offer image attachments. Hosts without an
223
238
  * uploadTransport pass false so the attach affordance never dead-ends.
@@ -590,6 +605,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
590
605
  onSaveQuestionAnswer,
591
606
  saveQuestionAnswerLabel,
592
607
  renderQuestionFooter,
608
+ questionDecisionRecording,
609
+ onToggleQuestionDecisionRecording,
610
+ questionStatusTag,
593
611
  onAskAI,
594
612
  allowImages = true,
595
613
  readOnly = false,
@@ -1551,6 +1569,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1551
1569
  onSaveQuestionAnswer={readOnly ? undefined : onSaveQuestionAnswer}
1552
1570
  saveQuestionAnswerLabel={saveQuestionAnswerLabel}
1553
1571
  renderQuestionFooter={renderQuestionFooter}
1572
+ questionDecisionRecording={question ? questionDecisionRecording?.(question) : undefined}
1573
+ onToggleQuestionDecisionRecording={readOnly ? undefined : onToggleQuestionDecisionRecording}
1574
+ questionStatusTag={questionStatusTag}
1554
1575
  />
1555
1576
  );
1556
1577
  })()
@@ -1715,6 +1736,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1715
1736
  label: 'Selected text',
1716
1737
  text: hookCommentPopover.selectedText ?? hookCommentPopover.contextText,
1717
1738
  sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
1739
+ ...selectionSourceLines(blocks, hookCommentPopover.blockIds ?? []),
1718
1740
  }}
1719
1741
  />
1720
1742
  )}
@@ -1740,6 +1762,9 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
1740
1762
  label: viewerCommentPopover.isGlobal ? 'Document' : 'Code block',
1741
1763
  text: viewerCommentPopover.selectedText,
1742
1764
  sourcePath: linkedDocInfo?.filepath ?? sourceInfo,
1765
+ ...(!viewerCommentPopover.isGlobal && viewerCommentPopover.codeBlock
1766
+ ? selectionSourceLines(blocks, [viewerCommentPopover.codeBlock.block.id])
1767
+ : null),
1743
1768
  }}
1744
1769
  />
1745
1770
  )}
@@ -36,7 +36,10 @@ import { QuestionContext } from './QuestionContext';
36
36
  *
37
37
  * A host can add its own actions at the right of the footer
38
38
  * (`renderFooter`), and a question carrying `Decision: when answered` or a
39
- * `Decision: [statement](url)` link shows that beside the prompt.
39
+ * `Decision: [statement](url)` link shows that beside the prompt. A host can
40
+ * also let the reviewer switch a `Decision: when answered` question's
41
+ * recording off and on (`decisionRecording` + `onToggleDecisionRecording`),
42
+ * and hide the card's own status tag to draw its own (`statusTag: 'none'`).
40
43
  */
41
44
 
42
45
  /** Pointer travel (px) above which a press on a choice row is a drag. */
@@ -50,7 +53,7 @@ const now = (): number => (typeof performance !== 'undefined' ? performance.now(
50
53
 
51
54
  const cx = (...parts: Array<string | false | null | undefined>) => parts.filter(Boolean).join(' ');
52
55
 
53
- const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string }> = ({ tone, children, className }) => (
56
+ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.ReactNode; className?: string; style?: React.CSSProperties }> = ({ tone, children, className, style }) => (
54
57
  <span
55
58
  className={cx(
56
59
  'annotation-exclude select-none inline-flex items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em]',
@@ -60,6 +63,7 @@ const Tag: React.FC<{ tone: 'rec' | 'ok' | 'skip' | 'open'; children: React.Reac
60
63
  tone === 'open' && 'border border-dashed border-border text-muted-foreground',
61
64
  className,
62
65
  )}
66
+ style={style}
63
67
  data-pinpoint-ignore=""
64
68
  >
65
69
  {children}
@@ -78,6 +82,19 @@ const DiamondGlyph = () => (
78
82
  </svg>
79
83
  );
80
84
 
85
+ /** Draws a decision tag in its "off" state: the same words, dimmed, with a
86
+ * dotted outline instead of the tinted fill. Dimmed means the theme's
87
+ * muted-foreground, not opacity: 60% opacity on primary text dropped the
88
+ * default light theme under 3:1, while muted-foreground is the token every
89
+ * theme already tunes for secondary text. Inline so it adds no utility
90
+ * classes to the shared stylesheet and wins over the tag's `text-primary`. */
91
+ const DECISION_OFF_STYLE: React.CSSProperties = {
92
+ background: 'transparent',
93
+ color: 'var(--muted-foreground)',
94
+ outline: '1px dotted currentColor',
95
+ outlineOffset: '-1px',
96
+ };
97
+
81
98
  /** Two answers say the same thing (identity and line ignored). */
82
99
  const sameAnswer = (a: QuestionAnswer | null | undefined, b: QuestionAnswer | null | undefined): boolean => {
83
100
  const norm = (x: QuestionAnswer | null | undefined) =>
@@ -110,6 +127,21 @@ export interface QuestionBlockProps {
110
127
  * saved answer (never the unsaved draft). Rendered in read-only cards too;
111
128
  * return null for nothing. */
112
129
  renderFooter?: (question: IndexedQuestion, answer: QuestionAnswer | undefined) => React.ReactNode;
130
+ /** Whether answering a `Decision: when answered` question records a
131
+ * decision (default true). False draws the "Records a decision" tag dimmed
132
+ * with a dotted outline and hides the "Answering this records a decision"
133
+ * row. Has no effect on a question whose decision is already recorded
134
+ * (`Decision: [statement](url)`) or that carries no decision line. */
135
+ decisionRecording?: boolean;
136
+ /** Makes the "Records a decision" tag a toggle button (`aria-pressed`):
137
+ * a click calls this with the question's key and the next state. The host
138
+ * stores it and passes it back as `decisionRecording`. Not offered in a
139
+ * read-only card (no answer handler), which draws the tag as it stands. */
140
+ onToggleDecisionRecording?: (key: string, next: boolean) => void;
141
+ /** `'none'` hides the card's own status tag (Open / Answered / Settled /
142
+ * Skipped) so the host can draw its own; the decision tags stay. Default
143
+ * `'card'`. */
144
+ statusTag?: 'card' | 'none';
113
145
  onOpenLinkedDoc?: (path: string) => void;
114
146
  onOpenCodeFile?: (path: string) => void;
115
147
  imageBaseDir?: string;
@@ -128,6 +160,9 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
128
160
  onSaveAnswer,
129
161
  saveLabel,
130
162
  renderFooter,
163
+ decisionRecording,
164
+ onToggleDecisionRecording,
165
+ statusTag = 'card',
131
166
  onOpenLinkedDoc,
132
167
  onOpenCodeFile,
133
168
  imageBaseDir,
@@ -320,6 +355,17 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
320
355
  const other = answer?.other ?? '';
321
356
  const otherOn = other.trim() !== '';
322
357
 
358
+ // A `Decision: when answered` question the host may switch off. A recorded
359
+ // decision (`question.decision`) is never affected.
360
+ const decisionCapable = !question.decision && !!question.decisionOnAnswer;
361
+ const recordingOn = decisionRecording ?? true;
362
+ const decisionToggleable = decisionCapable && !readOnly && !!onToggleDecisionRecording;
363
+ // Present only when the host speaks to recording, so a card without these
364
+ // props carries exactly the attributes it always did.
365
+ const recordingAttr = decisionCapable && (decisionRecording !== undefined || !!onToggleDecisionRecording)
366
+ ? (recordingOn ? 'on' : 'off')
367
+ : undefined;
368
+
323
369
  const describedBy = [question.context ? contextId : null, question.kind === 'text' && question.suggestedText && !answered ? suggestId : null]
324
370
  .filter(Boolean)
325
371
  .join(' ') || undefined;
@@ -344,23 +390,44 @@ export const QuestionBlock: React.FC<QuestionBlockProps> = ({
344
390
  aria-describedby={describedBy}
345
391
  aria-disabled={readOnly || undefined}
346
392
  data-question-decision={question.decision ? 'recorded' : question.decisionOnAnswer ? 'on-answer' : undefined}
393
+ data-question-decision-recording={recordingAttr}
347
394
  >
348
395
  <div className="annotation-exclude select-none mb-1.5 flex items-center gap-2" data-pinpoint-ignore="">
349
396
  <span className="text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">{eyebrow}</span>
350
397
  <span className="ml-auto">
351
398
  {question.decision && <><Tag tone="rec"><DiamondGlyph />Decision</Tag>{' '}</>}
352
- {!question.decision && question.decisionOnAnswer && <><Tag tone="rec"><DiamondGlyph />Records a decision</Tag>{' '}</>}
353
- {status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
354
- {status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
355
- {status === 'skipped' && <Tag tone="skip">Skipped</Tag>}
356
- {status === 'open' && <Tag tone="open">Open</Tag>}
399
+ {decisionCapable && (decisionToggleable ? (
400
+ // Focus shows as a ring, not an outline: the dotted outline is
401
+ // what draws the off state.
402
+ <><button
403
+ type="button"
404
+ aria-pressed={recordingOn}
405
+ onClick={() => onToggleDecisionRecording!(question.key, !recordingOn)}
406
+ className="annotation-exclude select-none inline-flex cursor-pointer items-center gap-1 whitespace-nowrap rounded px-1.5 py-px align-[1px] text-[10.5px] font-semibold tracking-[0.03em] bg-primary/15 text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
407
+ style={recordingOn ? undefined : DECISION_OFF_STYLE}
408
+ data-pinpoint-ignore=""
409
+ data-question-decision-toggle=""
410
+ ><DiamondGlyph />Records a decision</button>{' '}</>
411
+ ) : recordingOn ? (
412
+ <><Tag tone="rec"><DiamondGlyph />Records a decision</Tag>{' '}</>
413
+ ) : (
414
+ <><Tag tone="rec" style={DECISION_OFF_STYLE}><DiamondGlyph />Records a decision</Tag>{' '}</>
415
+ ))}
416
+ {statusTag !== 'none' && (
417
+ <>
418
+ {status === 'answered' && <Tag tone="ok"><CheckGlyph />Answered</Tag>}
419
+ {status === 'settled' && <Tag tone="ok"><CheckGlyph />Settled</Tag>}
420
+ {status === 'skipped' && <Tag tone="skip">Skipped</Tag>}
421
+ {status === 'open' && <Tag tone="open">Open</Tag>}
422
+ </>
423
+ )}
357
424
  </span>
358
425
  </div>
359
426
 
360
427
  <p id={promptId} className="m-0 text-[15px] font-semibold leading-[1.45] text-foreground" data-question-part="prompt">
361
428
  {inline(question.prompt)}
362
429
  </p>
363
- {(question.decision || question.decisionOnAnswer) && (
430
+ {(question.decision || (question.decisionOnAnswer && recordingOn)) && (
364
431
  <div
365
432
  className="annotation-exclude mt-2 mb-0.5 flex items-center gap-2 rounded-lg bg-primary/8 px-[9px] py-1.5 text-[12.5px] leading-[18px] text-foreground [&>svg]:text-primary"
366
433
  data-pinpoint-ignore=""
@@ -2457,9 +2457,88 @@
2457
2457
 
2458
2458
  function anchorTextSnapshot(el) {
2459
2459
  var text = (el.textContent || '').replace(/\s+/g, ' ').trim();
2460
+ if (!text) return anchorSourceSnapshot(el);
2460
2461
  return text.length > 180 ? text.slice(0, 180) : text;
2461
2462
  }
2462
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
+
2463
2542
  function uniquelySelects(selector, el) {
2464
2543
  try {
2465
2544
  var matches = document.querySelectorAll(selector);
@@ -2558,12 +2637,13 @@
2558
2637
  if (!selector) return null;
2559
2638
  var snapshot = anchorTextSnapshot(el);
2560
2639
  // Text-less elements anchor ONLY through a stable-identity rung (#id or
2561
- // an author-controlled data-* identity attribute). A weak (positional /
2562
- // class) selector has nothing to verify against — identical siblings
2563
- // share every structural trait, so any derived signature would validate
2564
- // the WRONG element after a sibling is inserted or removed. A
2565
- // wrong-binding anchor is worse than no anchor: ship none and fail
2566
- // 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).
2567
2647
  if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
2568
2648
  return {
2569
2649
  selector: selector,
@@ -2596,6 +2676,26 @@
2596
2676
 
2597
2677
  function resolveAnchorElement(anchor) {
2598
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) {
2599
2699
  var matches;
2600
2700
  try {
2601
2701
  matches = document.querySelectorAll(anchor.selector);
@@ -2693,9 +2693,88 @@ export const BRIDGE_SCRIPT = `(function() {
2693
2693
 
2694
2694
  function anchorTextSnapshot(el) {
2695
2695
  var text = (el.textContent || '').replace(/\\s+/g, ' ').trim();
2696
+ if (!text) return anchorSourceSnapshot(el);
2696
2697
  return text.length > 180 ? text.slice(0, 180) : text;
2697
2698
  }
2698
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
+
2699
2778
  function uniquelySelects(selector, el) {
2700
2779
  try {
2701
2780
  var matches = document.querySelectorAll(selector);
@@ -2794,12 +2873,13 @@ export const BRIDGE_SCRIPT = `(function() {
2794
2873
  if (!selector) return null;
2795
2874
  var snapshot = anchorTextSnapshot(el);
2796
2875
  // Text-less elements anchor ONLY through a stable-identity rung (#id or
2797
- // an author-controlled data-* identity attribute). A weak (positional /
2798
- // class) selector has nothing to verify against — identical siblings
2799
- // share every structural trait, so any derived signature would validate
2800
- // the WRONG element after a sibling is inserted or removed. A
2801
- // wrong-binding anchor is worse than no anchor: ship none and fail
2802
- // 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).
2803
2883
  if (!snapshot && !anchorHasStableIdentity(selector, el)) return null;
2804
2884
  return {
2805
2885
  selector: selector,
@@ -2832,6 +2912,26 @@ export const BRIDGE_SCRIPT = `(function() {
2832
2912
 
2833
2913
  function resolveAnchorElement(anchor) {
2834
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) {
2835
2935
  var matches;
2836
2936
  try {
2837
2937
  matches = document.querySelectorAll(anchor.selector);