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.cjs CHANGED
@@ -97,6 +97,45 @@ function getClientScript(options) {
97
97
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
98
98
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
99
99
 
100
+ // \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
101
+ // We inject at document_end, but frameworks that hydrate <body> (Next.js App
102
+ // Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
103
+ // didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
104
+ // vanish; badges added later survive because they're created post-hydration).
105
+ // Track our persistent singletons and re-attach any that gets detached, so the
106
+ // UI survives the hydration pass and later SPA re-renders. The parent is resolved
107
+ // fresh on each remount so a wholesale body/head element swap is handled too.
108
+ var _mounted = [];
109
+ function mount(node, where) {
110
+ var parent = where === 'head' ? document.head : document.body;
111
+ if (parent) parent.appendChild(node);
112
+ _mounted.push({ node: node, where: where });
113
+ return node;
114
+ }
115
+ var _remountQueued = false;
116
+ function remountDetached() {
117
+ _remountQueued = false;
118
+ for (var i = 0; i < _mounted.length; i++) {
119
+ var m = _mounted[i];
120
+ if (m.node.isConnected) continue;
121
+ var p = m.where === 'head' ? document.head : document.body;
122
+ if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
123
+ }
124
+ }
125
+ try {
126
+ var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
127
+ var _mo = new MutationObserver(function () {
128
+ if (_remountQueued) return;
129
+ _remountQueued = true;
130
+ _defer(remountDetached); // coalesce a burst of mutations into one restore pass
131
+ });
132
+ // childList on the roots is enough \u2014 every singleton is a direct child of
133
+ // body/head; watching documentElement too catches a body/head element swap.
134
+ _mo.observe(document.documentElement, { childList: true });
135
+ _mo.observe(document.body, { childList: true });
136
+ _mo.observe(document.head, { childList: true });
137
+ } catch (e) {}
138
+
100
139
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
101
140
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
102
141
  function hoverFx(el, on, off) {
@@ -123,7 +162,7 @@ function getClientScript(options) {
123
162
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
124
163
  display: 'none',
125
164
  });
126
- document.body.appendChild(tooltip);
165
+ mount(tooltip);
127
166
 
128
167
  // Measure overlay
129
168
  var measureOverlay = document.createElement('div');
@@ -135,7 +174,7 @@ function getClientScript(options) {
135
174
  pointerEvents: 'none',
136
175
  display: 'none',
137
176
  });
138
- document.body.appendChild(measureOverlay);
177
+ mount(measureOverlay);
139
178
 
140
179
  // \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
141
180
  var pillWrap = document.createElement('div');
@@ -211,41 +250,34 @@ function getClientScript(options) {
211
250
  var pillText = document.createElement('span');
212
251
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
213
252
 
253
+ // Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
254
+ // (Open/Close) and always names the shortcut.
214
255
  var listBtn = document.createElement('span');
215
- listBtn.innerHTML = LIST;
216
- listBtn.title = 'Show Specs panel (L)';
256
+ listBtn.title = 'Toggle Specs panel (L)';
217
257
  Object.assign(listBtn.style, {
218
258
  display: 'none',
219
259
  alignItems: 'center',
260
+ gap: '6px',
220
261
  cursor: 'pointer',
221
262
  color: '#fff',
263
+ fontSize: '11px',
222
264
  flexShrink: '0',
223
- padding: '4px 6px',
265
+ padding: '4px 10px',
224
266
  marginLeft: '2px',
225
267
  borderRadius: '999px',
226
268
  background: 'rgba(255,255,255,0.16)',
227
269
  });
270
+ var listIcon = document.createElement('span');
271
+ listIcon.innerHTML = LIST;
272
+ Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
273
+ var listLabel = document.createElement('span');
274
+ listBtn.appendChild(listIcon);
275
+ listBtn.appendChild(listLabel);
276
+ function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
277
+ updateListBtn();
228
278
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
229
279
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
230
280
 
231
- var clearBtn = document.createElement('span');
232
- clearBtn.textContent = '\u2715 Delete all';
233
- clearBtn.title = 'Delete all annotations';
234
- Object.assign(clearBtn.style, {
235
- display: 'none',
236
- cursor: 'pointer',
237
- color: '#fff',
238
- fontSize: '11px',
239
- fontWeight: '600',
240
- flexShrink: '0',
241
- padding: '2px 8px',
242
- marginLeft: '2px',
243
- borderRadius: '999px',
244
- background: 'rgba(255,255,255,0.16)',
245
- });
246
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
247
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
248
-
249
281
  var chevron = document.createElement('span');
250
282
  chevron.textContent = '\u203A';
251
283
  chevron.title = 'Move to other side';
@@ -275,16 +307,15 @@ function getClientScript(options) {
275
307
  pill.appendChild(pillSync);
276
308
  pill.appendChild(pillText);
277
309
  pill.appendChild(listBtn);
278
- pill.appendChild(clearBtn);
279
310
  pill.appendChild(chevron);
280
311
  pillWrap.appendChild(pill);
281
- document.body.appendChild(pillWrap);
312
+ mount(pillWrap);
282
313
 
283
314
  // Keyframes for the sync spinner (injected once).
284
315
  var spinStyle = document.createElement('style');
285
316
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
286
317
  markUI(spinStyle);
287
- document.head.appendChild(spinStyle);
318
+ mount(spinStyle, 'head');
288
319
 
289
320
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
290
321
  // always match: spinner while syncing, green when synced, red on error.
@@ -336,7 +367,7 @@ function getClientScript(options) {
336
367
  // The mode is shown at all times (persistent prefix), so you always know whether
337
368
  // hovering shows properties, measurements, or nothing (Comment).
338
369
  function modeLabel() {
339
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
370
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
340
371
  }
341
372
 
342
373
  function expandPill(text) {
@@ -344,7 +375,7 @@ function getClientScript(options) {
344
375
  pillText.style.display = 'inline';
345
376
  chevron.style.display = 'inline';
346
377
  listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
347
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
378
+ updateListBtn();
348
379
  pillExpanded = true;
349
380
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
350
381
  // side panel now, so the pill stays short.
@@ -359,14 +390,12 @@ function getClientScript(options) {
359
390
  pill.style.maxWidth = '220px';
360
391
  chevron.style.display = 'none';
361
392
  listBtn.style.display = 'none';
362
- clearBtn.style.display = 'none';
363
393
  pillExpanded = false;
364
394
  }
365
395
 
366
396
  function flashMode() {
367
397
  if (pillExpanded && pillWrap.matches(':hover')) return;
368
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
369
- expandPill(text);
398
+ expandPill(modeLabel());
370
399
  clearTimeout(flashTimer);
371
400
  flashTimer = setTimeout(function () {
372
401
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -492,10 +521,13 @@ function getClientScript(options) {
492
521
  var cur = el;
493
522
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
494
523
  var part = cur.tagName.toLowerCase();
495
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
524
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
496
525
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
497
526
  if (cls.length) {
498
- part += '.' + cls.slice(0, 2).join('.');
527
+ // Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
528
+ // invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
529
+ // throw on query, so they must be CSS.escape-d first.
530
+ part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
499
531
  } else if (cur.parentElement) {
500
532
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
501
533
  if (sameTag.length > 1) {
@@ -575,13 +607,13 @@ function getClientScript(options) {
575
607
  var i, v;
576
608
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
577
609
  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; } }
578
- if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
610
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
579
611
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
580
612
  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) + '"'; } }
581
613
  var txt = ownText(el);
582
614
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
583
615
  var cls = ownClass(el);
584
- if (cls) return '.' + cls;
616
+ if (cls) return '.' + cssEsc(cls);
585
617
  return getSelector(el); // fallback: the CSS path
586
618
  }
587
619
 
@@ -755,11 +787,20 @@ function getClientScript(options) {
755
787
  function elementPath(el) {
756
788
  if (!el || el.nodeType !== 1) return '';
757
789
  var parts = [], cur = el;
758
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
759
- if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
790
+ // Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
791
+ // nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
792
+ // child index (the old div:nth-child(11) bug \u2014 matched a different node, or
793
+ // none, on the recipient). nth-of-type counts only same-tag siblings, so it
794
+ // survives that. Deeper cap (12) so the chain reaches a rooting anchor.
795
+ while (cur && cur.nodeType === 1 && parts.length < 12) {
796
+ if (cur === document.body) { parts.unshift('body'); break; }
797
+ if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
760
798
  var seg = cur.tagName.toLowerCase();
761
799
  var parent = cur.parentElement;
762
- if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
800
+ if (parent) {
801
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
802
+ if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
803
+ }
763
804
  parts.unshift(seg);
764
805
  cur = cur.parentElement;
765
806
  }
@@ -835,6 +876,11 @@ function getClientScript(options) {
835
876
  var vis = [];
836
877
  for (var i = 0; i < specs.length; i++) {
837
878
  var s = specs[i];
879
+ // Badges created at import-time can be wiped by a framework hydrating <body>
880
+ // (same cause as the singleton mount guard). Re-attach a detached wrap so the
881
+ // pin reappears; a removed spec is already out of the specs array, so this
882
+ // never resurrects a deleted badge.
883
+ if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
838
884
  if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
839
885
  if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
840
886
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
@@ -1034,6 +1080,16 @@ function getClientScript(options) {
1034
1080
  saveSpecs();
1035
1081
  }
1036
1082
 
1083
+ // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1084
+ // importComments so a received share replaces the prior set instead of stacking.
1085
+ function removeSharedSpecs() {
1086
+ if (highlightSpec && highlightSpec.shared) highlightSpec = null;
1087
+ if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
1088
+ for (var i = specs.length - 1; i >= 0; i--) {
1089
+ if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
1090
+ }
1091
+ }
1092
+
1037
1093
  // \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
1038
1094
  // Persist only the serializable parts of each Spec; the live element ref and
1039
1095
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
@@ -1065,11 +1121,13 @@ function getClientScript(options) {
1065
1121
  // element is gone OR its signature changed, the comment shows as MISSING (panel
1066
1122
  // only, greyed, with a reason) instead of being drawn on a guessed spot.
1067
1123
 
1068
- // Normalized element signature for change-detection: stable structural identity,
1069
- // NOT raw outerHTML (which false-trips on any text/attr churn). tag + sorted own
1070
- // classes + a few structural attrs + a short trimmed-text slice. The text slice is
1071
- // the strictness knob \u2014 include it to catch content edits, at the cost of a
1072
- // false-MISSING when dynamic text (a price/timestamp) changes under a stable node.
1124
+ // Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
1125
+ // element a shared comment belongs to (and to scan for it when selectors fail).
1126
+ // Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
1127
+ // price) was false-tripping this and blocking re-anchor on otherwise-identical
1128
+ // pages. tag + sorted own classes + key attrs + child count identifies the node
1129
+ // without that fragility. (Trade-off: pure text edits under a stable node no
1130
+ // longer read as "changed" \u2014 placement is favoured over change-detection.)
1073
1131
  function normSig(el) {
1074
1132
  if (!el || el.nodeType !== 1) return '';
1075
1133
  var cls = Array.prototype.slice.call(el.classList)
@@ -1077,8 +1135,7 @@ function getClientScript(options) {
1077
1135
  var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1078
1136
  var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1079
1137
  }).filter(Boolean).join('|');
1080
- var txt = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
1081
- return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
1138
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1082
1139
  }
1083
1140
  // djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
1084
1141
  // means "unchanged enough", and the re-find already narrowed us to one element).
@@ -1090,51 +1147,73 @@ function getClientScript(options) {
1090
1147
  return (h >>> 0).toString(36);
1091
1148
  }
1092
1149
 
1093
- // Scan for an element whose OWN text matches (trunc = the stored anchor was cut to
1094
- // 40 chars). Ambiguous (>1 match) \u2192 null, so we fall through to the nth-child path
1095
- // rather than guess. Skips Specter's own UI.
1096
- function findByOwnText(val, trunc) {
1097
- var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1150
+ function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
1151
+
1152
+ // Every element whose OWN text matches (trunc = the stored anchor was cut to 40
1153
+ // chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
1154
+ // so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
1155
+ function findAllByOwnText(val, trunc) {
1156
+ var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
1098
1157
  for (var i = 0; i < all.length; i++) {
1099
1158
  var el = all[i];
1100
1159
  if (el.closest && el.closest('[data-specter-ui]')) continue;
1101
1160
  var t = ownText(el);
1102
1161
  if (!t) continue;
1103
- if (trunc ? (t.slice(0, 40) === val) : (t === val)) { if (hit) return null; hit = el; }
1162
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1104
1163
  }
1105
- return hit;
1164
+ return out;
1106
1165
  }
1107
1166
 
1108
- // Real resolver for a resolveLocator() anchor: #id/.class/[attr] query straight;
1109
- // 'attr "value"' rebuilds the attribute selector; 'text "value"' scans own-text.
1110
- // Only trusts an anchor that resolves to EXACTLY ONE element \u2014 a class or CSS path
1111
- // shared by siblings (e.g. three .card boxes) would otherwise silently match the
1112
- // first one, mis-anchoring every comment onto it.
1113
- function resolveFind(find) {
1114
- if (!find) return null;
1167
+ // Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
1168
+ // querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
1169
+ // scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
1170
+ // fingerprint rather than rejecting anything ambiguous up front.
1171
+ function resolveFindCandidates(find) {
1172
+ if (!find) return [];
1115
1173
  var c = find.charAt(0);
1116
- if (c === '#' || c === '.' || c === '[') return uniqueSel(find) ? safeQuery(find) : null;
1174
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1117
1175
  var q = find.indexOf(' "');
1118
1176
  if (q > 0 && find.charAt(find.length - 1) === '"') {
1119
1177
  var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1120
1178
  if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
1121
- if (attr === 'text') return findByOwnText(val, trunc);
1122
- if (val.indexOf('"') < 0) { var sel = '[' + attr + '="' + val + '"]'; return uniqueSel(sel) ? safeQuery(sel) : null; }
1123
- return null;
1179
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1180
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1181
+ return [];
1124
1182
  }
1125
- return uniqueSel(find) ? safeQuery(find) : null; // a bare CSS path \u2014 only if unambiguous
1183
+ return queryAll(find); // a bare CSS path
1184
+ }
1185
+
1186
+ // Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
1187
+ // just a change-detector: gather every candidate from the find anchor AND the
1188
+ // nth-child path, then return the one whose signature matches \u2014 so a locator
1189
+ // shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
1190
+ // instead of failing as ambiguous. With no fp match: hand back a lone candidate
1191
+ // (so importComments can say "changed"), else null ("couldn't find").
1192
+ // Whole-DOM scan for the one element whose structural signature matches \u2014 the
1193
+ // last-resort re-finder when both the find anchor and the nth-of-type path fail
1194
+ // (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
1195
+ // (ambiguous, don't guess). Skips Specter's own UI.
1196
+ function findByFingerprint(fp) {
1197
+ if (!fp) return null;
1198
+ var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1199
+ for (var i = 0; i < all.length; i++) {
1200
+ var el = all[i];
1201
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1202
+ if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
1203
+ }
1204
+ return hit;
1126
1205
  }
1127
1206
 
1128
- // Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
1129
- // only to detect change): prefer whichever candidate \u2014 the find anchor or the
1130
- // nth-child path \u2014 actually matches the stored signature. Falls back to a
1131
- // best-effort element so importComments can still tell "changed" from "not found".
1132
1207
  function reFindShared(item) {
1133
- var byFind = resolveFind(item.find);
1134
- if (byFind && (!item.fp || fingerprint(byFind) === item.fp)) return byFind;
1135
- var byPath = safeQuery(item.path);
1136
- if (byPath && (!item.fp || fingerprint(byPath) === item.fp)) return byPath;
1137
- return byFind || byPath || null;
1208
+ var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
1209
+ var uniq = [], i;
1210
+ for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
1211
+ if (item.fp) {
1212
+ for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
1213
+ var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
1214
+ if (scan) return scan;
1215
+ }
1216
+ return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
1138
1217
  }
1139
1218
 
1140
1219
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1165,12 +1244,19 @@ function getClientScript(options) {
1165
1244
  console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
1166
1245
  return 0;
1167
1246
  }
1247
+ // A received share REPLACES the previously-imported set: re-opening the same
1248
+ // link (or a refreshed one) gives exactly that set, never a stacked-up pile of
1249
+ // duplicates across rounds. Your OWN local specs (shared:false) are untouched.
1250
+ removeSharedSpecs();
1168
1251
  var added = 0;
1169
1252
  comments.forEach(function (item) {
1170
1253
  if (!item) return;
1254
+ // reFindShared already used the fingerprint to pick/scan the right element, so
1255
+ // trust its result: if it located a node, PLACE the comment. Only a genuine
1256
+ // no-match is MISSING \u2014 we no longer hide a found element just because its
1257
+ // signature drifted (that over-fired on dynamic pages and buried real pins).
1171
1258
  var el = reFindShared(item), missing = false, reason = '';
1172
1259
  if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
1173
- else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
1174
1260
  // body stays empty on purpose: comments-only, never the shared element's props.
1175
1261
  // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
1176
1262
  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 };
@@ -1549,7 +1635,7 @@ function getClientScript(options) {
1549
1635
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1550
1636
  rows.forEach(function (r) {
1551
1637
  var row = document.createElement('div');
1552
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1638
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1553
1639
  var k = document.createElement('span');
1554
1640
  k.textContent = r[0];
1555
1641
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1574,7 +1660,7 @@ function getClientScript(options) {
1574
1660
  panelWrap.appendChild(panelHint);
1575
1661
  panelWrap.appendChild(panelList);
1576
1662
  panelWrap.appendChild(panelKeys);
1577
- document.body.appendChild(panelWrap);
1663
+ mount(panelWrap);
1578
1664
 
1579
1665
  // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1580
1666
  // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
@@ -1833,8 +1919,8 @@ function getClientScript(options) {
1833
1919
  panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1834
1920
  }
1835
1921
 
1836
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1837
- function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
1922
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
1923
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
1838
1924
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1839
1925
 
1840
1926
  // \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