vite-plugin-specter 0.7.0 → 0.7.1

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/dist/index.js CHANGED
@@ -70,6 +70,45 @@ function getClientScript(options) {
70
70
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
71
71
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
72
72
 
73
+ // \u2500\u2500\u2500 Hydration-proof mounting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
74
+ // We inject at document_end, but frameworks that hydrate <body> (Next.js App
75
+ // Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
76
+ // didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
77
+ // vanish; badges added later survive because they're created post-hydration).
78
+ // Track our persistent singletons and re-attach any that gets detached, so the
79
+ // UI survives the hydration pass and later SPA re-renders. The parent is resolved
80
+ // fresh on each remount so a wholesale body/head element swap is handled too.
81
+ var _mounted = [];
82
+ function mount(node, where) {
83
+ var parent = where === 'head' ? document.head : document.body;
84
+ if (parent) parent.appendChild(node);
85
+ _mounted.push({ node: node, where: where });
86
+ return node;
87
+ }
88
+ var _remountQueued = false;
89
+ function remountDetached() {
90
+ _remountQueued = false;
91
+ for (var i = 0; i < _mounted.length; i++) {
92
+ var m = _mounted[i];
93
+ if (m.node.isConnected) continue;
94
+ var p = m.where === 'head' ? document.head : document.body;
95
+ if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
96
+ }
97
+ }
98
+ try {
99
+ var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
100
+ var _mo = new MutationObserver(function () {
101
+ if (_remountQueued) return;
102
+ _remountQueued = true;
103
+ _defer(remountDetached); // coalesce a burst of mutations into one restore pass
104
+ });
105
+ // childList on the roots is enough \u2014 every singleton is a direct child of
106
+ // body/head; watching documentElement too catches a body/head element swap.
107
+ _mo.observe(document.documentElement, { childList: true });
108
+ _mo.observe(document.body, { childList: true });
109
+ _mo.observe(document.head, { childList: true });
110
+ } catch (e) {}
111
+
73
112
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
74
113
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
75
114
  function hoverFx(el, on, off) {
@@ -96,7 +135,7 @@ function getClientScript(options) {
96
135
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
97
136
  display: 'none',
98
137
  });
99
- document.body.appendChild(tooltip);
138
+ mount(tooltip);
100
139
 
101
140
  // Measure overlay
102
141
  var measureOverlay = document.createElement('div');
@@ -108,7 +147,7 @@ function getClientScript(options) {
108
147
  pointerEvents: 'none',
109
148
  display: 'none',
110
149
  });
111
- document.body.appendChild(measureOverlay);
150
+ mount(measureOverlay);
112
151
 
113
152
  // \u2500\u2500\u2500 Pill \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
114
153
  var pillWrap = document.createElement('div');
@@ -184,41 +223,34 @@ function getClientScript(options) {
184
223
  var pillText = document.createElement('span');
185
224
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
186
225
 
226
+ // Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
227
+ // (Open/Close) and always names the shortcut.
187
228
  var listBtn = document.createElement('span');
188
- listBtn.innerHTML = LIST;
189
- listBtn.title = 'Show Specs panel (L)';
229
+ listBtn.title = 'Toggle Specs panel (L)';
190
230
  Object.assign(listBtn.style, {
191
231
  display: 'none',
192
232
  alignItems: 'center',
233
+ gap: '6px',
193
234
  cursor: 'pointer',
194
235
  color: '#fff',
236
+ fontSize: '11px',
195
237
  flexShrink: '0',
196
- padding: '4px 6px',
238
+ padding: '4px 10px',
197
239
  marginLeft: '2px',
198
240
  borderRadius: '999px',
199
241
  background: 'rgba(255,255,255,0.16)',
200
242
  });
243
+ var listIcon = document.createElement('span');
244
+ listIcon.innerHTML = LIST;
245
+ Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
246
+ var listLabel = document.createElement('span');
247
+ listBtn.appendChild(listIcon);
248
+ listBtn.appendChild(listLabel);
249
+ function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
250
+ updateListBtn();
201
251
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
202
252
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
203
253
 
204
- var clearBtn = document.createElement('span');
205
- clearBtn.textContent = '\u2715 Delete all';
206
- clearBtn.title = 'Delete all annotations';
207
- Object.assign(clearBtn.style, {
208
- display: 'none',
209
- cursor: 'pointer',
210
- color: '#fff',
211
- fontSize: '11px',
212
- fontWeight: '600',
213
- flexShrink: '0',
214
- padding: '2px 8px',
215
- marginLeft: '2px',
216
- borderRadius: '999px',
217
- background: 'rgba(255,255,255,0.16)',
218
- });
219
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
220
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
221
-
222
254
  var chevron = document.createElement('span');
223
255
  chevron.textContent = '\u203A';
224
256
  chevron.title = 'Move to other side';
@@ -248,16 +280,15 @@ function getClientScript(options) {
248
280
  pill.appendChild(pillSync);
249
281
  pill.appendChild(pillText);
250
282
  pill.appendChild(listBtn);
251
- pill.appendChild(clearBtn);
252
283
  pill.appendChild(chevron);
253
284
  pillWrap.appendChild(pill);
254
- document.body.appendChild(pillWrap);
285
+ mount(pillWrap);
255
286
 
256
287
  // Keyframes for the sync spinner (injected once).
257
288
  var spinStyle = document.createElement('style');
258
289
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
259
290
  markUI(spinStyle);
260
- document.head.appendChild(spinStyle);
291
+ mount(spinStyle, 'head');
261
292
 
262
293
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
263
294
  // always match: spinner while syncing, green when synced, red on error.
@@ -309,7 +340,7 @@ function getClientScript(options) {
309
340
  // The mode is shown at all times (persistent prefix), so you always know whether
310
341
  // hovering shows properties, measurements, or nothing (Comment).
311
342
  function modeLabel() {
312
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
343
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
313
344
  }
314
345
 
315
346
  function expandPill(text) {
@@ -317,7 +348,7 @@ function getClientScript(options) {
317
348
  pillText.style.display = 'inline';
318
349
  chevron.style.display = 'inline';
319
350
  listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
320
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
351
+ updateListBtn();
321
352
  pillExpanded = true;
322
353
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
323
354
  // side panel now, so the pill stays short.
@@ -332,14 +363,12 @@ function getClientScript(options) {
332
363
  pill.style.maxWidth = '220px';
333
364
  chevron.style.display = 'none';
334
365
  listBtn.style.display = 'none';
335
- clearBtn.style.display = 'none';
336
366
  pillExpanded = false;
337
367
  }
338
368
 
339
369
  function flashMode() {
340
370
  if (pillExpanded && pillWrap.matches(':hover')) return;
341
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
342
- expandPill(text);
371
+ expandPill(modeLabel());
343
372
  clearTimeout(flashTimer);
344
373
  flashTimer = setTimeout(function () {
345
374
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -465,10 +494,13 @@ function getClientScript(options) {
465
494
  var cur = el;
466
495
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
467
496
  var part = cur.tagName.toLowerCase();
468
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
497
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
469
498
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
470
499
  if (cls.length) {
471
- part += '.' + cls.slice(0, 2).join('.');
500
+ // Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
501
+ // invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
502
+ // throw on query, so they must be CSS.escape-d first.
503
+ part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
472
504
  } else if (cur.parentElement) {
473
505
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
474
506
  if (sameTag.length > 1) {
@@ -548,13 +580,13 @@ function getClientScript(options) {
548
580
  var i, v;
549
581
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
550
582
  for (i = 0; i < testAttrs.length; i++) { v = el.getAttribute(testAttrs[i]); if (v) { var ts = '[' + testAttrs[i] + '="' + cssEsc(v) + '"]'; if (uniqueSel(ts)) return ts; } }
551
- if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
583
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
552
584
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
553
585
  for (i = 0; i < attrs.length; i++) { v = el.getAttribute(attrs[i]); if (v && v.trim()) { v = v.trim(); if (uniqueSel('[' + attrs[i] + '="' + cssEsc(v) + '"]')) return attrs[i] + ' "' + v.slice(0, 60) + '"'; } }
554
586
  var txt = ownText(el);
555
587
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
556
588
  var cls = ownClass(el);
557
- if (cls) return '.' + cls;
589
+ if (cls) return '.' + cssEsc(cls);
558
590
  return getSelector(el); // fallback: the CSS path
559
591
  }
560
592
 
@@ -728,11 +760,20 @@ function getClientScript(options) {
728
760
  function elementPath(el) {
729
761
  if (!el || el.nodeType !== 1) return '';
730
762
  var parts = [], cur = el;
731
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
732
- if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
763
+ // Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
764
+ // nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
765
+ // child index (the old div:nth-child(11) bug \u2014 matched a different node, or
766
+ // none, on the recipient). nth-of-type counts only same-tag siblings, so it
767
+ // survives that. Deeper cap (12) so the chain reaches a rooting anchor.
768
+ while (cur && cur.nodeType === 1 && parts.length < 12) {
769
+ if (cur === document.body) { parts.unshift('body'); break; }
770
+ if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
733
771
  var seg = cur.tagName.toLowerCase();
734
772
  var parent = cur.parentElement;
735
- if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
773
+ if (parent) {
774
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
775
+ if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
776
+ }
736
777
  parts.unshift(seg);
737
778
  cur = cur.parentElement;
738
779
  }
@@ -808,6 +849,11 @@ function getClientScript(options) {
808
849
  var vis = [];
809
850
  for (var i = 0; i < specs.length; i++) {
810
851
  var s = specs[i];
852
+ // Badges created at import-time can be wiped by a framework hydrating <body>
853
+ // (same cause as the singleton mount guard). Re-attach a detached wrap so the
854
+ // pin reappears; a removed spec is already out of the specs array, so this
855
+ // never resurrects a deleted badge.
856
+ if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
811
857
  if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
812
858
  if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
813
859
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
@@ -1007,6 +1053,16 @@ function getClientScript(options) {
1007
1053
  saveSpecs();
1008
1054
  }
1009
1055
 
1056
+ // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1057
+ // importComments so a received share replaces the prior set instead of stacking.
1058
+ function removeSharedSpecs() {
1059
+ if (highlightSpec && highlightSpec.shared) highlightSpec = null;
1060
+ if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
1061
+ for (var i = specs.length - 1; i >= 0; i--) {
1062
+ if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
1063
+ }
1064
+ }
1065
+
1010
1066
  // \u2500\u2500\u2500 Reload insurance (localStorage, per-URL, zero network) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1011
1067
  // Persist only the serializable parts of each Spec; the live element ref and
1012
1068
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
@@ -1038,11 +1094,13 @@ function getClientScript(options) {
1038
1094
  // element is gone OR its signature changed, the comment shows as MISSING (panel
1039
1095
  // only, greyed, with a reason) instead of being drawn on a guessed spot.
1040
1096
 
1041
- // Normalized element signature for change-detection: stable structural identity,
1042
- // NOT raw outerHTML (which false-trips on any text/attr churn). tag + sorted own
1043
- // classes + a few structural attrs + a short trimmed-text slice. The text slice is
1044
- // the strictness knob \u2014 include it to catch content edits, at the cost of a
1045
- // false-MISSING when dynamic text (a price/timestamp) changes under a stable node.
1097
+ // Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
1098
+ // element a shared comment belongs to (and to scan for it when selectors fail).
1099
+ // Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
1100
+ // price) was false-tripping this and blocking re-anchor on otherwise-identical
1101
+ // pages. tag + sorted own classes + key attrs + child count identifies the node
1102
+ // without that fragility. (Trade-off: pure text edits under a stable node no
1103
+ // longer read as "changed" \u2014 placement is favoured over change-detection.)
1046
1104
  function normSig(el) {
1047
1105
  if (!el || el.nodeType !== 1) return '';
1048
1106
  var cls = Array.prototype.slice.call(el.classList)
@@ -1050,8 +1108,7 @@ function getClientScript(options) {
1050
1108
  var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1051
1109
  var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1052
1110
  }).filter(Boolean).join('|');
1053
- var txt = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
1054
- return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
1111
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1055
1112
  }
1056
1113
  // djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
1057
1114
  // means "unchanged enough", and the re-find already narrowed us to one element).
@@ -1063,51 +1120,73 @@ function getClientScript(options) {
1063
1120
  return (h >>> 0).toString(36);
1064
1121
  }
1065
1122
 
1066
- // Scan for an element whose OWN text matches (trunc = the stored anchor was cut to
1067
- // 40 chars). Ambiguous (>1 match) \u2192 null, so we fall through to the nth-child path
1068
- // rather than guess. Skips Specter's own UI.
1069
- function findByOwnText(val, trunc) {
1070
- var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1123
+ function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
1124
+
1125
+ // Every element whose OWN text matches (trunc = the stored anchor was cut to 40
1126
+ // chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
1127
+ // so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
1128
+ function findAllByOwnText(val, trunc) {
1129
+ var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
1071
1130
  for (var i = 0; i < all.length; i++) {
1072
1131
  var el = all[i];
1073
1132
  if (el.closest && el.closest('[data-specter-ui]')) continue;
1074
1133
  var t = ownText(el);
1075
1134
  if (!t) continue;
1076
- if (trunc ? (t.slice(0, 40) === val) : (t === val)) { if (hit) return null; hit = el; }
1135
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1077
1136
  }
1078
- return hit;
1137
+ return out;
1079
1138
  }
1080
1139
 
1081
- // Real resolver for a resolveLocator() anchor: #id/.class/[attr] query straight;
1082
- // 'attr "value"' rebuilds the attribute selector; 'text "value"' scans own-text.
1083
- // Only trusts an anchor that resolves to EXACTLY ONE element \u2014 a class or CSS path
1084
- // shared by siblings (e.g. three .card boxes) would otherwise silently match the
1085
- // first one, mis-anchoring every comment onto it.
1086
- function resolveFind(find) {
1087
- if (!find) return null;
1140
+ // Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
1141
+ // querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
1142
+ // scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
1143
+ // fingerprint rather than rejecting anything ambiguous up front.
1144
+ function resolveFindCandidates(find) {
1145
+ if (!find) return [];
1088
1146
  var c = find.charAt(0);
1089
- if (c === '#' || c === '.' || c === '[') return uniqueSel(find) ? safeQuery(find) : null;
1147
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1090
1148
  var q = find.indexOf(' "');
1091
1149
  if (q > 0 && find.charAt(find.length - 1) === '"') {
1092
1150
  var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1093
1151
  if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
1094
- if (attr === 'text') return findByOwnText(val, trunc);
1095
- if (val.indexOf('"') < 0) { var sel = '[' + attr + '="' + val + '"]'; return uniqueSel(sel) ? safeQuery(sel) : null; }
1096
- return null;
1152
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1153
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1154
+ return [];
1097
1155
  }
1098
- return uniqueSel(find) ? safeQuery(find) : null; // a bare CSS path \u2014 only if unambiguous
1156
+ return queryAll(find); // a bare CSS path
1157
+ }
1158
+
1159
+ // Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
1160
+ // just a change-detector: gather every candidate from the find anchor AND the
1161
+ // nth-child path, then return the one whose signature matches \u2014 so a locator
1162
+ // shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
1163
+ // instead of failing as ambiguous. With no fp match: hand back a lone candidate
1164
+ // (so importComments can say "changed"), else null ("couldn't find").
1165
+ // Whole-DOM scan for the one element whose structural signature matches \u2014 the
1166
+ // last-resort re-finder when both the find anchor and the nth-of-type path fail
1167
+ // (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
1168
+ // (ambiguous, don't guess). Skips Specter's own UI.
1169
+ function findByFingerprint(fp) {
1170
+ if (!fp) return null;
1171
+ var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1172
+ for (var i = 0; i < all.length; i++) {
1173
+ var el = all[i];
1174
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1175
+ if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
1176
+ }
1177
+ return hit;
1099
1178
  }
1100
1179
 
1101
- // Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
1102
- // only to detect change): prefer whichever candidate \u2014 the find anchor or the
1103
- // nth-child path \u2014 actually matches the stored signature. Falls back to a
1104
- // best-effort element so importComments can still tell "changed" from "not found".
1105
1180
  function reFindShared(item) {
1106
- var byFind = resolveFind(item.find);
1107
- if (byFind && (!item.fp || fingerprint(byFind) === item.fp)) return byFind;
1108
- var byPath = safeQuery(item.path);
1109
- if (byPath && (!item.fp || fingerprint(byPath) === item.fp)) return byPath;
1110
- return byFind || byPath || null;
1181
+ var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
1182
+ var uniq = [], i;
1183
+ for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
1184
+ if (item.fp) {
1185
+ for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
1186
+ var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
1187
+ if (scan) return scan;
1188
+ }
1189
+ return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
1111
1190
  }
1112
1191
 
1113
1192
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1138,12 +1217,19 @@ function getClientScript(options) {
1138
1217
  console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
1139
1218
  return 0;
1140
1219
  }
1220
+ // A received share REPLACES the previously-imported set: re-opening the same
1221
+ // link (or a refreshed one) gives exactly that set, never a stacked-up pile of
1222
+ // duplicates across rounds. Your OWN local specs (shared:false) are untouched.
1223
+ removeSharedSpecs();
1141
1224
  var added = 0;
1142
1225
  comments.forEach(function (item) {
1143
1226
  if (!item) return;
1227
+ // reFindShared already used the fingerprint to pick/scan the right element, so
1228
+ // trust its result: if it located a node, PLACE the comment. Only a genuine
1229
+ // no-match is MISSING \u2014 we no longer hide a found element just because its
1230
+ // signature drifted (that over-fired on dynamic pages and buried real pins).
1144
1231
  var el = reFindShared(item), missing = false, reason = '';
1145
1232
  if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
1146
- else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
1147
1233
  // body stays empty on purpose: comments-only, never the shared element's props.
1148
1234
  // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
1149
1235
  var spec = { el: missing ? null : el, path: item.path || '', note: item.note || '', body: '', kind: item.kind || 'element', locate: '', shared: true, fp: item.fp || '', missing: missing, missReason: reason };
@@ -1522,7 +1608,7 @@ function getClientScript(options) {
1522
1608
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1523
1609
  rows.forEach(function (r) {
1524
1610
  var row = document.createElement('div');
1525
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1611
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1526
1612
  var k = document.createElement('span');
1527
1613
  k.textContent = r[0];
1528
1614
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1547,7 +1633,7 @@ function getClientScript(options) {
1547
1633
  panelWrap.appendChild(panelHint);
1548
1634
  panelWrap.appendChild(panelList);
1549
1635
  panelWrap.appendChild(panelKeys);
1550
- document.body.appendChild(panelWrap);
1636
+ mount(panelWrap);
1551
1637
 
1552
1638
  // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1553
1639
  // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
@@ -1806,8 +1892,8 @@ function getClientScript(options) {
1806
1892
  panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1807
1893
  }
1808
1894
 
1809
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1810
- function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
1895
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
1896
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
1811
1897
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1812
1898
 
1813
1899
  // \u2500\u2500\u2500 Spec editor (annotation box: add / edit / delete) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500