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.
@@ -65,6 +65,45 @@
65
65
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
66
66
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
67
67
 
68
+ // ─── Hydration-proof mounting ───────────────────────────────────────────────
69
+ // We inject at document_end, but frameworks that hydrate <body> (Next.js App
70
+ // Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
71
+ // didn't render — silently deleting Specter's UI on those sites (pill/panel
72
+ // vanish; badges added later survive because they're created post-hydration).
73
+ // Track our persistent singletons and re-attach any that gets detached, so the
74
+ // UI survives the hydration pass and later SPA re-renders. The parent is resolved
75
+ // fresh on each remount so a wholesale body/head element swap is handled too.
76
+ var _mounted = [];
77
+ function mount(node, where) {
78
+ var parent = where === 'head' ? document.head : document.body;
79
+ if (parent) parent.appendChild(node);
80
+ _mounted.push({ node: node, where: where });
81
+ return node;
82
+ }
83
+ var _remountQueued = false;
84
+ function remountDetached() {
85
+ _remountQueued = false;
86
+ for (var i = 0; i < _mounted.length; i++) {
87
+ var m = _mounted[i];
88
+ if (m.node.isConnected) continue;
89
+ var p = m.where === 'head' ? document.head : document.body;
90
+ if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
91
+ }
92
+ }
93
+ try {
94
+ var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
95
+ var _mo = new MutationObserver(function () {
96
+ if (_remountQueued) return;
97
+ _remountQueued = true;
98
+ _defer(remountDetached); // coalesce a burst of mutations into one restore pass
99
+ });
100
+ // childList on the roots is enough — every singleton is a direct child of
101
+ // body/head; watching documentElement too catches a body/head element swap.
102
+ _mo.observe(document.documentElement, { childList: true });
103
+ _mo.observe(document.body, { childList: true });
104
+ _mo.observe(document.head, { childList: true });
105
+ } catch (e) {}
106
+
68
107
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
69
108
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
70
109
  function hoverFx(el, on, off) {
@@ -91,7 +130,7 @@
91
130
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
92
131
  display: 'none',
93
132
  });
94
- document.body.appendChild(tooltip);
133
+ mount(tooltip);
95
134
 
96
135
  // Measure overlay
97
136
  var measureOverlay = document.createElement('div');
@@ -103,7 +142,7 @@
103
142
  pointerEvents: 'none',
104
143
  display: 'none',
105
144
  });
106
- document.body.appendChild(measureOverlay);
145
+ mount(measureOverlay);
107
146
 
108
147
  // ─── Pill ─────────────────────────────────────────────────────────────────
109
148
  var pillWrap = document.createElement('div');
@@ -179,41 +218,34 @@
179
218
  var pillText = document.createElement('span');
180
219
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
181
220
 
221
+ // Panel toggle — icon + label so it's self-explanatory. Label reflects state
222
+ // (Open/Close) and always names the shortcut.
182
223
  var listBtn = document.createElement('span');
183
- listBtn.innerHTML = LIST;
184
- listBtn.title = 'Show Specs panel (L)';
224
+ listBtn.title = 'Toggle Specs panel (L)';
185
225
  Object.assign(listBtn.style, {
186
226
  display: 'none',
187
227
  alignItems: 'center',
228
+ gap: '6px',
188
229
  cursor: 'pointer',
189
230
  color: '#fff',
231
+ fontSize: '11px',
190
232
  flexShrink: '0',
191
- padding: '4px 6px',
233
+ padding: '4px 10px',
192
234
  marginLeft: '2px',
193
235
  borderRadius: '999px',
194
236
  background: 'rgba(255,255,255,0.16)',
195
237
  });
238
+ var listIcon = document.createElement('span');
239
+ listIcon.innerHTML = LIST;
240
+ Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
241
+ var listLabel = document.createElement('span');
242
+ listBtn.appendChild(listIcon);
243
+ listBtn.appendChild(listLabel);
244
+ function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
245
+ updateListBtn();
196
246
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
197
247
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
198
248
 
199
- var clearBtn = document.createElement('span');
200
- clearBtn.textContent = '✕ Delete all';
201
- clearBtn.title = 'Delete all annotations';
202
- Object.assign(clearBtn.style, {
203
- display: 'none',
204
- cursor: 'pointer',
205
- color: '#fff',
206
- fontSize: '11px',
207
- fontWeight: '600',
208
- flexShrink: '0',
209
- padding: '2px 8px',
210
- marginLeft: '2px',
211
- borderRadius: '999px',
212
- background: 'rgba(255,255,255,0.16)',
213
- });
214
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
215
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
216
-
217
249
  var chevron = document.createElement('span');
218
250
  chevron.textContent = '›';
219
251
  chevron.title = 'Move to other side';
@@ -243,16 +275,15 @@
243
275
  pill.appendChild(pillSync);
244
276
  pill.appendChild(pillText);
245
277
  pill.appendChild(listBtn);
246
- pill.appendChild(clearBtn);
247
278
  pill.appendChild(chevron);
248
279
  pillWrap.appendChild(pill);
249
- document.body.appendChild(pillWrap);
280
+ mount(pillWrap);
250
281
 
251
282
  // Keyframes for the sync spinner (injected once).
252
283
  var spinStyle = document.createElement('style');
253
284
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
254
285
  markUI(spinStyle);
255
- document.head.appendChild(spinStyle);
286
+ mount(spinStyle, 'head');
256
287
 
257
288
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
258
289
  // always match: spinner while syncing, green when synced, red on error.
@@ -304,7 +335,7 @@
304
335
  // The mode is shown at all times (persistent prefix), so you always know whether
305
336
  // hovering shows properties, measurements, or nothing (Comment).
306
337
  function modeLabel() {
307
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
338
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
308
339
  }
309
340
 
310
341
  function expandPill(text) {
@@ -312,7 +343,7 @@
312
343
  pillText.style.display = 'inline';
313
344
  chevron.style.display = 'inline';
314
345
  listBtn.style.display = 'inline-flex'; // always reachable — the panel is also where you Import a shared file
315
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
346
+ updateListBtn();
316
347
  pillExpanded = true;
317
348
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
318
349
  // side panel now, so the pill stays short.
@@ -327,14 +358,12 @@
327
358
  pill.style.maxWidth = '220px';
328
359
  chevron.style.display = 'none';
329
360
  listBtn.style.display = 'none';
330
- clearBtn.style.display = 'none';
331
361
  pillExpanded = false;
332
362
  }
333
363
 
334
364
  function flashMode() {
335
365
  if (pillExpanded && pillWrap.matches(':hover')) return;
336
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
337
- expandPill(text);
366
+ expandPill(modeLabel());
338
367
  clearTimeout(flashTimer);
339
368
  flashTimer = setTimeout(function () {
340
369
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -460,10 +489,13 @@
460
489
  var cur = el;
461
490
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
462
491
  var part = cur.tagName.toLowerCase();
463
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
492
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
464
493
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
465
494
  if (cls.length) {
466
- part += '.' + cls.slice(0, 2).join('.');
495
+ // Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
496
+ // invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
497
+ // throw on query, so they must be CSS.escape-d first.
498
+ part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
467
499
  } else if (cur.parentElement) {
468
500
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
469
501
  if (sameTag.length > 1) {
@@ -543,13 +575,13 @@
543
575
  var i, v;
544
576
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
545
577
  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; } }
546
- if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
578
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
547
579
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
548
580
  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) + '"'; } }
549
581
  var txt = ownText(el);
550
582
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '…' : '') + '"';
551
583
  var cls = ownClass(el);
552
- if (cls) return '.' + cls;
584
+ if (cls) return '.' + cssEsc(cls);
553
585
  return getSelector(el); // fallback: the CSS path
554
586
  }
555
587
 
@@ -723,11 +755,20 @@
723
755
  function elementPath(el) {
724
756
  if (!el || el.nodeType !== 1) return '';
725
757
  var parts = [], cur = el;
726
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
727
- if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
758
+ // Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
759
+ // nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
760
+ // child index (the old div:nth-child(11) bug — matched a different node, or
761
+ // none, on the recipient). nth-of-type counts only same-tag siblings, so it
762
+ // survives that. Deeper cap (12) so the chain reaches a rooting anchor.
763
+ while (cur && cur.nodeType === 1 && parts.length < 12) {
764
+ if (cur === document.body) { parts.unshift('body'); break; }
765
+ if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
728
766
  var seg = cur.tagName.toLowerCase();
729
767
  var parent = cur.parentElement;
730
- if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
768
+ if (parent) {
769
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
770
+ if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
771
+ }
731
772
  parts.unshift(seg);
732
773
  cur = cur.parentElement;
733
774
  }
@@ -803,6 +844,11 @@
803
844
  var vis = [];
804
845
  for (var i = 0; i < specs.length; i++) {
805
846
  var s = specs[i];
847
+ // Badges created at import-time can be wiped by a framework hydrating <body>
848
+ // (same cause as the singleton mount guard). Re-attach a detached wrap so the
849
+ // pin reappears; a removed spec is already out of the specs array, so this
850
+ // never resurrects a deleted badge.
851
+ if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
806
852
  if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
807
853
  if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
808
854
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
@@ -1002,6 +1048,16 @@
1002
1048
  saveSpecs();
1003
1049
  }
1004
1050
 
1051
+ // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1052
+ // importComments so a received share replaces the prior set instead of stacking.
1053
+ function removeSharedSpecs() {
1054
+ if (highlightSpec && highlightSpec.shared) highlightSpec = null;
1055
+ if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
1056
+ for (var i = specs.length - 1; i >= 0; i--) {
1057
+ if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
1058
+ }
1059
+ }
1060
+
1005
1061
  // ─── Reload insurance (localStorage, per-URL, zero network) ──────────────────
1006
1062
  // Persist only the serializable parts of each Spec; the live element ref and
1007
1063
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
@@ -1033,11 +1089,13 @@
1033
1089
  // element is gone OR its signature changed, the comment shows as MISSING (panel
1034
1090
  // only, greyed, with a reason) instead of being drawn on a guessed spot.
1035
1091
 
1036
- // Normalized element signature for change-detection: stable structural identity,
1037
- // NOT raw outerHTML (which false-trips on any text/attr churn). tag + sorted own
1038
- // classes + a few structural attrs + a short trimmed-text slice. The text slice is
1039
- // the strictness knob — include it to catch content edits, at the cost of a
1040
- // false-MISSING when dynamic text (a price/timestamp) changes under a stable node.
1092
+ // Normalized element signature — a STRUCTURAL identity used to disambiguate which
1093
+ // element a shared comment belongs to (and to scan for it when selectors fail).
1094
+ // Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
1095
+ // price) was false-tripping this and blocking re-anchor on otherwise-identical
1096
+ // pages. tag + sorted own classes + key attrs + child count identifies the node
1097
+ // without that fragility. (Trade-off: pure text edits under a stable node no
1098
+ // longer read as "changed" — placement is favoured over change-detection.)
1041
1099
  function normSig(el) {
1042
1100
  if (!el || el.nodeType !== 1) return '';
1043
1101
  var cls = Array.prototype.slice.call(el.classList)
@@ -1045,8 +1103,7 @@
1045
1103
  var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1046
1104
  var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1047
1105
  }).filter(Boolean).join('|');
1048
- var txt = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 50);
1049
- return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
1106
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1050
1107
  }
1051
1108
  // djb2 xor → short base36 hash. Zero-dep; collisions don't matter (a match just
1052
1109
  // means "unchanged enough", and the re-find already narrowed us to one element).
@@ -1058,51 +1115,73 @@
1058
1115
  return (h >>> 0).toString(36);
1059
1116
  }
1060
1117
 
1061
- // Scan for an element whose OWN text matches (trunc = the stored anchor was cut to
1062
- // 40 chars). Ambiguous (>1 match) → null, so we fall through to the nth-child path
1063
- // rather than guess. Skips Specter's own UI.
1064
- function findByOwnText(val, trunc) {
1065
- var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1118
+ function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
1119
+
1120
+ // Every element whose OWN text matches (trunc = the stored anchor was cut to 40
1121
+ // chars). Returns ALL matches — the fingerprint picks among them in reFindShared,
1122
+ // so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
1123
+ function findAllByOwnText(val, trunc) {
1124
+ var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
1066
1125
  for (var i = 0; i < all.length; i++) {
1067
1126
  var el = all[i];
1068
1127
  if (el.closest && el.closest('[data-specter-ui]')) continue;
1069
1128
  var t = ownText(el);
1070
1129
  if (!t) continue;
1071
- if (trunc ? (t.slice(0, 40) === val) : (t === val)) { if (hit) return null; hit = el; }
1130
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1072
1131
  }
1073
- return hit;
1132
+ return out;
1074
1133
  }
1075
1134
 
1076
- // Real resolver for a resolveLocator() anchor: #id/.class/[attr] query straight;
1077
- // 'attr "value"' rebuilds the attribute selector; 'text "value"' scans own-text.
1078
- // Only trusts an anchor that resolves to EXACTLY ONE element — a class or CSS path
1079
- // shared by siblings (e.g. three .card boxes) would otherwise silently match the
1080
- // first one, mis-anchoring every comment onto it.
1081
- function resolveFind(find) {
1082
- if (!find) return null;
1135
+ // Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
1136
+ // querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
1137
+ // scans own-text. Returns ALL matches (may be >1) — reFindShared narrows by
1138
+ // fingerprint rather than rejecting anything ambiguous up front.
1139
+ function resolveFindCandidates(find) {
1140
+ if (!find) return [];
1083
1141
  var c = find.charAt(0);
1084
- if (c === '#' || c === '.' || c === '[') return uniqueSel(find) ? safeQuery(find) : null;
1142
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1085
1143
  var q = find.indexOf(' "');
1086
1144
  if (q > 0 && find.charAt(find.length - 1) === '"') {
1087
1145
  var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1088
1146
  if (val.charAt(val.length - 1) === '…') { trunc = true; val = val.slice(0, -1); }
1089
- if (attr === 'text') return findByOwnText(val, trunc);
1090
- if (val.indexOf('"') < 0) { var sel = '[' + attr + '="' + val + '"]'; return uniqueSel(sel) ? safeQuery(sel) : null; }
1091
- return null;
1147
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1148
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1149
+ return [];
1092
1150
  }
1093
- return uniqueSel(find) ? safeQuery(find) : null; // a bare CSS path — only if unambiguous
1151
+ return queryAll(find); // a bare CSS path
1152
+ }
1153
+
1154
+ // Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
1155
+ // just a change-detector: gather every candidate from the find anchor AND the
1156
+ // nth-child path, then return the one whose signature matches — so a locator
1157
+ // shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
1158
+ // instead of failing as ambiguous. With no fp match: hand back a lone candidate
1159
+ // (so importComments can say "changed"), else null ("couldn't find").
1160
+ // Whole-DOM scan for the one element whose structural signature matches — the
1161
+ // last-resort re-finder when both the find anchor and the nth-of-type path fail
1162
+ // (e.g. selectors that don't round-trip, or a shifted structure). >1 match → bail
1163
+ // (ambiguous, don't guess). Skips Specter's own UI.
1164
+ function findByFingerprint(fp) {
1165
+ if (!fp) return null;
1166
+ var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1167
+ for (var i = 0; i < all.length; i++) {
1168
+ var el = all[i];
1169
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1170
+ if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
1171
+ }
1172
+ return hit;
1094
1173
  }
1095
1174
 
1096
- // Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
1097
- // only to detect change): prefer whichever candidate — the find anchor or the
1098
- // nth-child path — actually matches the stored signature. Falls back to a
1099
- // best-effort element so importComments can still tell "changed" from "not found".
1100
1175
  function reFindShared(item) {
1101
- var byFind = resolveFind(item.find);
1102
- if (byFind && (!item.fp || fingerprint(byFind) === item.fp)) return byFind;
1103
- var byPath = safeQuery(item.path);
1104
- if (byPath && (!item.fp || fingerprint(byPath) === item.fp)) return byPath;
1105
- return byFind || byPath || null;
1176
+ var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
1177
+ var uniq = [], i;
1178
+ for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
1179
+ if (item.fp) {
1180
+ for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
1181
+ var scan = findByFingerprint(item.fp); // selectors failed/ambiguous → find it by signature
1182
+ if (scan) return scan;
1183
+ }
1184
+ return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
1106
1185
  }
1107
1186
 
1108
1187
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1133,12 +1212,19 @@
1133
1212
  console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' — not this page (' + pageKey() + '). Not imported.');
1134
1213
  return 0;
1135
1214
  }
1215
+ // A received share REPLACES the previously-imported set: re-opening the same
1216
+ // link (or a refreshed one) gives exactly that set, never a stacked-up pile of
1217
+ // duplicates across rounds. Your OWN local specs (shared:false) are untouched.
1218
+ removeSharedSpecs();
1136
1219
  var added = 0;
1137
1220
  comments.forEach(function (item) {
1138
1221
  if (!item) return;
1222
+ // reFindShared already used the fingerprint to pick/scan the right element, so
1223
+ // trust its result: if it located a node, PLACE the comment. Only a genuine
1224
+ // no-match is MISSING — we no longer hide a found element just because its
1225
+ // signature drifted (that over-fired on dynamic pages and buried real pins).
1139
1226
  var el = reFindShared(item), missing = false, reason = '';
1140
1227
  if (!el) { missing = true; reason = 'Couldn’t find this element on the page'; }
1141
- else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed — can’t place the comment'; }
1142
1228
  // body stays empty on purpose: comments-only, never the shared element's props.
1143
1229
  // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
1144
1230
  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 };
@@ -1517,7 +1603,7 @@
1517
1603
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1518
1604
  rows.forEach(function (r) {
1519
1605
  var row = document.createElement('div');
1520
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1606
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1521
1607
  var k = document.createElement('span');
1522
1608
  k.textContent = r[0];
1523
1609
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1542,7 +1628,7 @@
1542
1628
  panelWrap.appendChild(panelHint);
1543
1629
  panelWrap.appendChild(panelList);
1544
1630
  panelWrap.appendChild(panelKeys);
1545
- document.body.appendChild(panelWrap);
1631
+ mount(panelWrap);
1546
1632
 
1547
1633
  // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1548
1634
  // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
@@ -1801,8 +1887,8 @@
1801
1887
  panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1802
1888
  }
1803
1889
 
1804
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1805
- function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
1890
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
1891
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
1806
1892
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1807
1893
 
1808
1894
  // ─── Spec editor (annotation box: add / edit / delete) ───────────────────────
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Specter",
4
- "version": "0.7.0",
4
+ "version": "0.7.1",
5
5
  "description": "Element inspector for AI-assisted development. Ctrl+Option+Z to activate.",
6
6
  "icons": {
7
7
  "16": "icons/icon16.png",