vite-plugin-specter 0.6.1 → 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.
@@ -23,6 +23,8 @@
23
23
  var CHEV = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>';
24
24
  var COPY = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
25
25
  var CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
26
+ var SHARE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><polyline points="16 6 12 2 8 6"></polyline><line x1="12" y1="2" x2="12" y2="15"></line></svg>';
27
+ var IMPORT = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13"></path><polyline points="8 12 12 16 16 12"></polyline><path d="M4 21h16"></path></svg>';
26
28
  var ACTIVATE = "ctrl+alt+z";
27
29
  var BRIDGE = ""; // Claude MCP bridge URL, or '' if disabled
28
30
 
@@ -63,6 +65,45 @@
63
65
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
64
66
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
65
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
+
66
107
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
67
108
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
68
109
  function hoverFx(el, on, off) {
@@ -89,7 +130,7 @@
89
130
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
90
131
  display: 'none',
91
132
  });
92
- document.body.appendChild(tooltip);
133
+ mount(tooltip);
93
134
 
94
135
  // Measure overlay
95
136
  var measureOverlay = document.createElement('div');
@@ -101,7 +142,7 @@
101
142
  pointerEvents: 'none',
102
143
  display: 'none',
103
144
  });
104
- document.body.appendChild(measureOverlay);
145
+ mount(measureOverlay);
105
146
 
106
147
  // ─── Pill ─────────────────────────────────────────────────────────────────
107
148
  var pillWrap = document.createElement('div');
@@ -177,41 +218,34 @@
177
218
  var pillText = document.createElement('span');
178
219
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
179
220
 
221
+ // Panel toggle — icon + label so it's self-explanatory. Label reflects state
222
+ // (Open/Close) and always names the shortcut.
180
223
  var listBtn = document.createElement('span');
181
- listBtn.innerHTML = LIST;
182
- listBtn.title = 'Show Specs panel (L)';
224
+ listBtn.title = 'Toggle Specs panel (L)';
183
225
  Object.assign(listBtn.style, {
184
226
  display: 'none',
185
227
  alignItems: 'center',
228
+ gap: '6px',
186
229
  cursor: 'pointer',
187
230
  color: '#fff',
231
+ fontSize: '11px',
188
232
  flexShrink: '0',
189
- padding: '4px 6px',
233
+ padding: '4px 10px',
190
234
  marginLeft: '2px',
191
235
  borderRadius: '999px',
192
236
  background: 'rgba(255,255,255,0.16)',
193
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();
194
246
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
195
247
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
196
248
 
197
- var clearBtn = document.createElement('span');
198
- clearBtn.textContent = '✕ Delete all';
199
- clearBtn.title = 'Delete all annotations';
200
- Object.assign(clearBtn.style, {
201
- display: 'none',
202
- cursor: 'pointer',
203
- color: '#fff',
204
- fontSize: '11px',
205
- fontWeight: '600',
206
- flexShrink: '0',
207
- padding: '2px 8px',
208
- marginLeft: '2px',
209
- borderRadius: '999px',
210
- background: 'rgba(255,255,255,0.16)',
211
- });
212
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
213
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
214
-
215
249
  var chevron = document.createElement('span');
216
250
  chevron.textContent = '›';
217
251
  chevron.title = 'Move to other side';
@@ -241,16 +275,15 @@
241
275
  pill.appendChild(pillSync);
242
276
  pill.appendChild(pillText);
243
277
  pill.appendChild(listBtn);
244
- pill.appendChild(clearBtn);
245
278
  pill.appendChild(chevron);
246
279
  pillWrap.appendChild(pill);
247
- document.body.appendChild(pillWrap);
280
+ mount(pillWrap);
248
281
 
249
282
  // Keyframes for the sync spinner (injected once).
250
283
  var spinStyle = document.createElement('style');
251
284
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
252
285
  markUI(spinStyle);
253
- document.head.appendChild(spinStyle);
286
+ mount(spinStyle, 'head');
254
287
 
255
288
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
256
289
  // always match: spinner while syncing, green when synced, red on error.
@@ -302,15 +335,15 @@
302
335
  // The mode is shown at all times (persistent prefix), so you always know whether
303
336
  // hovering shows properties, measurements, or nothing (Comment).
304
337
  function modeLabel() {
305
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
338
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
306
339
  }
307
340
 
308
341
  function expandPill(text) {
309
342
  pill.style.maxWidth = '820px';
310
343
  pillText.style.display = 'inline';
311
344
  chevron.style.display = 'inline';
312
- listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
313
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
345
+ listBtn.style.display = 'inline-flex'; // always reachable — the panel is also where you Import a shared file
346
+ updateListBtn();
314
347
  pillExpanded = true;
315
348
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
316
349
  // side panel now, so the pill stays short.
@@ -325,14 +358,12 @@
325
358
  pill.style.maxWidth = '220px';
326
359
  chevron.style.display = 'none';
327
360
  listBtn.style.display = 'none';
328
- clearBtn.style.display = 'none';
329
361
  pillExpanded = false;
330
362
  }
331
363
 
332
364
  function flashMode() {
333
365
  if (pillExpanded && pillWrap.matches(':hover')) return;
334
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
335
- expandPill(text);
366
+ expandPill(modeLabel());
336
367
  clearTimeout(flashTimer);
337
368
  flashTimer = setTimeout(function () {
338
369
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -458,10 +489,13 @@
458
489
  var cur = el;
459
490
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
460
491
  var part = cur.tagName.toLowerCase();
461
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
492
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
462
493
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
463
494
  if (cls.length) {
464
- 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('.');
465
499
  } else if (cur.parentElement) {
466
500
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
467
501
  if (sameTag.length > 1) {
@@ -541,13 +575,13 @@
541
575
  var i, v;
542
576
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
543
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; } }
544
- 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);
545
579
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
546
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) + '"'; } }
547
581
  var txt = ownText(el);
548
582
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '…' : '') + '"';
549
583
  var cls = ownClass(el);
550
- if (cls) return '.' + cls;
584
+ if (cls) return '.' + cssEsc(cls);
551
585
  return getSelector(el); // fallback: the CSS path
552
586
  }
553
587
 
@@ -721,11 +755,20 @@
721
755
  function elementPath(el) {
722
756
  if (!el || el.nodeType !== 1) return '';
723
757
  var parts = [], cur = el;
724
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
725
- 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; }
726
766
  var seg = cur.tagName.toLowerCase();
727
767
  var parent = cur.parentElement;
728
- 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
+ }
729
772
  parts.unshift(seg);
730
773
  cur = cur.parentElement;
731
774
  }
@@ -794,19 +837,36 @@
794
837
  var mx = lastMouse.x, my = lastMouse.y;
795
838
  var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
796
839
 
797
- // Pass 1 — resolve visibility + base anchor for each spec.
840
+ // Pass 1 — resolve visibility + base anchor for each spec. _liveVis tracks the
841
+ // panel's HIDDEN semantics (isVisible after re-anchor recovery, IGNORING occlusion —
842
+ // off-screen is fine, that's what the hover-scroll is for) so the panel can stay in
843
+ // sync with what's actually on the page instead of showing a stale HIDDEN.
798
844
  var vis = [];
799
845
  for (var i = 0; i < specs.length; i++) {
800
846
  var s = specs[i];
801
- if (!fiActive) { s.wrap.style.display = 'none'; continue; }
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);
852
+ if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
853
+ if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
802
854
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
803
- if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
855
+ if (!isVisible(s.el)) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
856
+ s._liveVis = true;
804
857
  var r = s.el.getBoundingClientRect();
805
- if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered (e.g. behind a modal)
858
+ if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered / off-screen: no badge, but panel not HIDDEN
806
859
  s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
807
860
  vis.push(s);
808
861
  }
809
862
 
863
+ // Keep the open panel's HIDDEN labels live: if any spec's on-page visibility
864
+ // flipped since the panel last rendered, re-render it (debounced).
865
+ if (panelOpen) {
866
+ var g = liveVisSig();
867
+ if (g !== panelVisSig) { panelVisSig = g; clearTimeout(panelVisTimer); panelVisTimer = setTimeout(function () { if (panelOpen) renderPanel(); }, 150); }
868
+ }
869
+
810
870
  // Pass 2 — cluster badges whose anchors coincide (within ~16px).
811
871
  var clusters = [];
812
872
  for (var a = 0; a < vis.length; a++) {
@@ -988,13 +1048,23 @@
988
1048
  saveSpecs();
989
1049
  }
990
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
+
991
1061
  // ─── Reload insurance (localStorage, per-URL, zero network) ──────────────────
992
1062
  // Persist only the serializable parts of each Spec; the live element ref and
993
1063
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
994
1064
  function saveSpecs() {
995
1065
  try {
996
1066
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
997
- else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate }; })));
1067
+ else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason }; })));
998
1068
  } catch (e) {}
999
1069
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1000
1070
  }
@@ -1004,13 +1074,279 @@
1004
1074
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1005
1075
  if (!Array.isArray(data) || !data.length) return;
1006
1076
  data.forEach(function (d) {
1007
- var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
1077
+ var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '' };
1008
1078
  specs.push(spec);
1009
1079
  createBadge(spec);
1010
1080
  });
1011
1081
  renumber();
1012
1082
  }
1013
1083
 
1084
+ // ─── Share Comments (human↔human) — Steps 1-2 ────────────────────────────────
1085
+ // A shared comment is a LOCATOR, not a coordinate: on import we re-find the
1086
+ // element and place a badge, exactly like restoreSpecs. Comments only — we carry
1087
+ // the note + how to re-find the element, NEVER the props/measurements (body).
1088
+ // Step 2 adds change-detection: a fingerprint travels with each comment; if the
1089
+ // element is gone OR its signature changed, the comment shows as MISSING (panel
1090
+ // only, greyed, with a reason) instead of being drawn on a guessed spot.
1091
+
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.)
1099
+ function normSig(el) {
1100
+ if (!el || el.nodeType !== 1) return '';
1101
+ var cls = Array.prototype.slice.call(el.classList)
1102
+ .filter(function (c) { return c.indexOf('__specter') !== 0; }).sort().join('.');
1103
+ var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1104
+ var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1105
+ }).filter(Boolean).join('|');
1106
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1107
+ }
1108
+ // djb2 xor → short base36 hash. Zero-dep; collisions don't matter (a match just
1109
+ // means "unchanged enough", and the re-find already narrowed us to one element).
1110
+ function fingerprint(el) {
1111
+ var s = normSig(el);
1112
+ if (!s) return '';
1113
+ var h = 5381, i = s.length;
1114
+ while (i) h = (h * 33) ^ s.charCodeAt(--i);
1115
+ return (h >>> 0).toString(36);
1116
+ }
1117
+
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 = [];
1125
+ for (var i = 0; i < all.length; i++) {
1126
+ var el = all[i];
1127
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1128
+ var t = ownText(el);
1129
+ if (!t) continue;
1130
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1131
+ }
1132
+ return out;
1133
+ }
1134
+
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 [];
1141
+ var c = find.charAt(0);
1142
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1143
+ var q = find.indexOf(' "');
1144
+ if (q > 0 && find.charAt(find.length - 1) === '"') {
1145
+ var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1146
+ if (val.charAt(val.length - 1) === '…') { trunc = true; val = val.slice(0, -1); }
1147
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1148
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1149
+ return [];
1150
+ }
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;
1173
+ }
1174
+
1175
+ function reFindShared(item) {
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
1185
+ }
1186
+
1187
+ // URL gate: two people must be on the SAME page for a shared comment to place.
1188
+ // Match by origin + pathname only — hash AND query are dropped (the hash is the
1189
+ // doSync fork bug; a trailing #route or ?ref shouldn't split the same page).
1190
+ function pageKey(href) {
1191
+ try { var u = new URL(href || location.href); return u.origin + u.pathname; }
1192
+ catch (e) { return location.origin + location.pathname; }
1193
+ }
1194
+
1195
+ function exportComments() {
1196
+ return {
1197
+ url: location.href, // gated on pageKey() at import; full href kept for reference
1198
+ comments: specs.map(function (s) {
1199
+ return { find: resolveLocator(s.el), path: s.path, fp: fingerprint(s.el), note: s.note || '', kind: s.kind || 'element' };
1200
+ }),
1201
+ };
1202
+ }
1203
+
1204
+ function importComments(payload) {
1205
+ if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { return 0; } }
1206
+ var comments = null, srcUrl = '';
1207
+ if (Array.isArray(payload)) comments = payload; // legacy pre-URL blob: no gate
1208
+ else if (payload && Array.isArray(payload.comments)) { comments = payload.comments; srcUrl = payload.url || ''; }
1209
+ if (!comments) return 0;
1210
+ // Wrong page → decline rather than anchor onto whatever happens to match here.
1211
+ if (srcUrl && pageKey(srcUrl) !== pageKey()) {
1212
+ console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' — not this page (' + pageKey() + '). Not imported.');
1213
+ return 0;
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();
1219
+ var added = 0;
1220
+ comments.forEach(function (item) {
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).
1226
+ var el = reFindShared(item), missing = false, reason = '';
1227
+ if (!el) { missing = true; reason = 'Couldn’t find this element on the page'; }
1228
+ // body stays empty on purpose: comments-only, never the shared element's props.
1229
+ // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
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 };
1231
+ specs.push(spec);
1232
+ createBadge(spec);
1233
+ added++;
1234
+ });
1235
+ renumber();
1236
+ reflowSpecs();
1237
+ updatePill();
1238
+ if (panelOpen) renderPanel();
1239
+ saveSpecs();
1240
+ return added;
1241
+ }
1242
+
1243
+ // ── Transport: share link (#spx=) with a file fallback ──────────────────────
1244
+ // The link is destination AND payload — pasted into Slack it looks like a normal
1245
+ // URL; clicked, it lands the recipient on the exact page, so the URL gate is
1246
+ // satisfied automatically. Payload = <fmt><base64url>: fmt 'z' = gzip (Compression
1247
+ // Stream, zero-dep), 'j' = plain UTF-8 when gzip is unavailable. split/join dodges
1248
+ // regex escaping inside this template-literal client.
1249
+ var LINK_MAX = 8000; // ~URL ceiling; beyond this we offer a .specter.json file
1250
+
1251
+ function bytesToB64url(bytes) {
1252
+ var bin = '';
1253
+ for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
1254
+ var b = btoa(bin).split('+').join('-').split('/').join('_');
1255
+ while (b.charAt(b.length - 1) === '=') b = b.slice(0, -1);
1256
+ return b;
1257
+ }
1258
+ function b64urlToBytes(s) {
1259
+ s = s.split('-').join('+').split('_').join('/');
1260
+ while (s.length % 4) s += '=';
1261
+ var bin = atob(s), bytes = new Uint8Array(bin.length);
1262
+ for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1263
+ return bytes;
1264
+ }
1265
+ function gzipStr(str) {
1266
+ var s = new Blob([str]).stream().pipeThrough(new CompressionStream('gzip'));
1267
+ return new Response(s).arrayBuffer().then(function (buf) { return new Uint8Array(buf); });
1268
+ }
1269
+ function gunzipBytes(bytes) {
1270
+ var s = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
1271
+ return new Response(s).text();
1272
+ }
1273
+ function encodePayload(str) {
1274
+ if (typeof CompressionStream !== 'undefined') {
1275
+ return gzipStr(str).then(function (gz) { return 'z' + bytesToB64url(gz); })
1276
+ .catch(function () { return 'j' + bytesToB64url(new TextEncoder().encode(str)); });
1277
+ }
1278
+ return Promise.resolve('j' + bytesToB64url(new TextEncoder().encode(str)));
1279
+ }
1280
+ function decodePayload(s) {
1281
+ var fmt = s.charAt(0);
1282
+ if (fmt === 'z') return gunzipBytes(b64urlToBytes(s.slice(1)));
1283
+ if (fmt === 'j') return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s.slice(1))));
1284
+ // legacy (no fmt prefix): raw base64url of the JSON string
1285
+ return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s)));
1286
+ }
1287
+
1288
+ // Build on the current page (keep query, replace any existing hash).
1289
+ function buildShareLink() {
1290
+ return encodePayload(JSON.stringify(exportComments())).then(function (enc) {
1291
+ return location.origin + location.pathname + location.search + '#spx=' + enc;
1292
+ });
1293
+ }
1294
+
1295
+ // A page that uses hash routing itself would collide with #spx= — treat any
1296
+ // non-trivial existing hash (beyond a bare '#') as in-use and prefer the file.
1297
+ function hashInUse() { return (location.hash || '').length > 1; }
1298
+
1299
+ // Download the comments as a .specter.json (the fallback when the link is too big
1300
+ // or the page owns the hash). Same {url, comments} payload → same URL gate on import.
1301
+ function downloadCommentsFile() {
1302
+ var data = JSON.stringify(exportComments(), null, 2);
1303
+ var url = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
1304
+ var a = document.createElement('a');
1305
+ a.href = url; a.download = 'comments.specter.json';
1306
+ document.body.appendChild(a); a.click(); a.remove();
1307
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
1308
+ }
1309
+
1310
+ // One coherent share action: a link by default, a file when it can't fit (or the
1311
+ // page owns the hash). Returns a descriptor so the caller/UI can tell the user which.
1312
+ function shareComments() {
1313
+ if (!specs.length) return Promise.resolve({ kind: 'empty' });
1314
+ if (hashInUse()) { downloadCommentsFile(); return Promise.resolve({ kind: 'file', reason: 'hash-routing' }); }
1315
+ return buildShareLink().then(function (link) {
1316
+ if (link.length > LINK_MAX) { downloadCommentsFile(); return { kind: 'file', reason: 'too-large', size: link.length }; }
1317
+ return { kind: 'link', link: link };
1318
+ });
1319
+ }
1320
+
1321
+ // Pick a .specter.json (or any JSON) and import it — the file-fallback receiver.
1322
+ function importCommentsFile() {
1323
+ var input = document.createElement('input');
1324
+ input.type = 'file';
1325
+ input.accept = '.json,application/json';
1326
+ input.addEventListener('change', function () {
1327
+ var f = input.files && input.files[0];
1328
+ if (!f) return;
1329
+ var reader = new FileReader();
1330
+ reader.onload = function () { var n = importComments(String(reader.result)); if (n > 0) { if (!fiActive) activate(); showPanel(); } };
1331
+ reader.readAsText(f);
1332
+ });
1333
+ input.click();
1334
+ }
1335
+
1336
+ // On load: if the URL carries #spx=, decode it, strip it from the address bar (so
1337
+ // the URL goes clean and a plain reload won't re-import), import, and surface it.
1338
+ function importFromHash() {
1339
+ var h = location.hash || '', k = h.indexOf('spx=');
1340
+ if (k < 0) return Promise.resolve(0);
1341
+ var enc = h.slice(k + 4);
1342
+ try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
1343
+ return decodePayload(enc).then(function (json) {
1344
+ var n = importComments(json);
1345
+ if (n > 0) { if (!fiActive) activate(); showPanel(); }
1346
+ return n;
1347
+ }).catch(function () { return 0; });
1348
+ }
1349
+
1014
1350
  // Re-anchor if the node detached, then scroll it into view. Returns false when
1015
1351
  // the element can't be shown (removed / hidden — e.g. behind a closed modal).
1016
1352
  // The badge enlargement is handled separately via highlightSpec (sustained
@@ -1030,6 +1366,11 @@
1030
1366
  return isVisible(spec.el);
1031
1367
  }
1032
1368
 
1369
+ // Signature of on-page visibility across specs (from reflowSpecs' per-frame _liveVis),
1370
+ // used to detect when the panel's HIDDEN labels have gone stale and re-render.
1371
+ var panelVisSig = '', panelVisTimer = null;
1372
+ function liveVisSig() { var s = ''; for (var i = 0; i < specs.length; i++) s += specs[i]._liveVis ? '1' : '0'; return s; }
1373
+
1033
1374
  // ─── Specs side panel (in-session review) ────────────────────────────────────
1034
1375
  var panelWrap = markUI(document.createElement('div'));
1035
1376
  Object.assign(panelWrap.style, {
@@ -1117,10 +1458,40 @@
1117
1458
  var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
1118
1459
  delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
1119
1460
 
1461
+ // Share comments (human↔human): copies a link, or downloads a file when it can't
1462
+ // fit / the page owns the hash. The hint line confirms which happened.
1463
+ var share = iconPill(SHARE, 'Share comments to another person', '#8AB4F8', '138,180,248');
1464
+ function resetShareIcon() { share.icon.innerHTML = SHARE; share.btn.style.color = share.btn.matches(':hover') ? '#fff' : '#8AB4F8'; }
1465
+ share.btn.addEventListener('click', function (e) {
1466
+ e.stopPropagation();
1467
+ if (!specs.length) return;
1468
+ shareComments().then(function (res) {
1469
+ if (res.kind === 'link') {
1470
+ navigator.clipboard.writeText(res.link).then(function () {
1471
+ share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
1472
+ flashHint('✓ Link copied — paste it to your reviewer. They see your comments on the same page.', GREEN);
1473
+ setTimeout(resetShareIcon, 1400);
1474
+ }).catch(function () { flashHint('Copy failed — check clipboard permission for this site.', '#ED8FA6'); });
1475
+ } else if (res.kind === 'file') {
1476
+ share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
1477
+ flashHint('✓ Saved comments.specter.json — send that file (too many comments to fit a link).', GREEN);
1478
+ setTimeout(resetShareIcon, 1400);
1479
+ }
1480
+ });
1481
+ });
1482
+
1483
+ // Import a comments file someone sent (the file-fallback receiver).
1484
+ var importTool = iconPill(IMPORT, 'Import a comments file', '#8AB4F8', '138,180,248');
1485
+ importTool.btn.addEventListener('click', function (e) { e.stopPropagation(); importCommentsFile(); });
1486
+
1120
1487
  var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
1121
1488
  var deleteAllBtn = delAll.btn;
1489
+ var shareBtn = share.btn;
1490
+ var importToolBtn = importTool.btn;
1122
1491
  if (BRIDGE) panelTools.appendChild(syncDot);
1123
1492
  else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
1493
+ panelTools.appendChild(shareBtn);
1494
+ panelTools.appendChild(importToolBtn);
1124
1495
  panelTools.appendChild(copyAllBtn);
1125
1496
  panelTools.appendChild(deleteAllBtn);
1126
1497
 
@@ -1188,6 +1559,16 @@
1188
1559
  panelHint.appendChild(document.createTextNode(' (or Copy all) and paste into Claude to apply these annotations. You can also edit or delete each one above.'));
1189
1560
  }
1190
1561
  }
1562
+ // Briefly show a confirmation in the hint line (Share copied / file saved), then
1563
+ // restore the normal guidance.
1564
+ var hintFlashTimer = null;
1565
+ function flashHint(msg, color) {
1566
+ panelHint.style.display = 'block';
1567
+ panelHint.textContent = msg;
1568
+ panelHint.style.color = color || GREEN;
1569
+ if (hintFlashTimer) clearTimeout(hintFlashTimer);
1570
+ hintFlashTimer = setTimeout(function () { panelHint.style.color = LABEL; setHint(); }, 2800);
1571
+ }
1191
1572
 
1192
1573
  var panelList = document.createElement('div');
1193
1574
  // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
@@ -1222,7 +1603,7 @@
1222
1603
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1223
1604
  rows.forEach(function (r) {
1224
1605
  var row = document.createElement('div');
1225
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1606
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1226
1607
  var k = document.createElement('span');
1227
1608
  k.textContent = r[0];
1228
1609
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1247,7 +1628,7 @@
1247
1628
  panelWrap.appendChild(panelHint);
1248
1629
  panelWrap.appendChild(panelList);
1249
1630
  panelWrap.appendChild(panelKeys);
1250
- document.body.appendChild(panelWrap);
1631
+ mount(panelWrap);
1251
1632
 
1252
1633
  // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1253
1634
  // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
@@ -1301,21 +1682,24 @@
1301
1682
 
1302
1683
  function renderPanel() {
1303
1684
  panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
1304
- panelTools.style.display = (specs.length || BRIDGE) ? 'flex' : 'none';
1685
+ panelTools.style.display = 'flex'; // always visible when the panel is open (Import needs no Specs)
1686
+ shareBtn.style.display = specs.length ? 'inline-flex' : 'none';
1305
1687
  copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1306
1688
  deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1689
+ importToolBtn.style.display = 'inline-flex'; // receiving a shared file needs no existing Specs
1307
1690
  setHint();
1308
1691
  panelList.textContent = '';
1309
1692
  if (!specs.length) {
1310
1693
  var empty = document.createElement('div');
1311
- empty.textContent = 'No Specs yet — hover an element and press P.';
1694
+ empty.textContent = 'No comments yet — hover an element and press P, or use the Import button above to open a comments file someone shared.';
1312
1695
  Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
1313
1696
  panelList.appendChild(empty);
1314
1697
  return;
1315
1698
  }
1316
1699
  var focusEditor = null, measures = [];
1317
1700
  specs.forEach(function (spec, i) {
1318
- var visible = specVisible(spec);
1701
+ var missing = !!spec.missing;
1702
+ var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec — it would re-anchor via path
1319
1703
  var editing = (panelEditSpec === spec);
1320
1704
  var row = document.createElement('div');
1321
1705
  Object.assign(row.style, {
@@ -1429,9 +1813,25 @@
1429
1813
  content.appendChild(note);
1430
1814
  content.appendChild(meta);
1431
1815
 
1816
+ // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1817
+ // never drawn on the page — design: show missing, don't guess a spot).
1818
+ if (missing) {
1819
+ row.style.opacity = '0.7';
1820
+ var mbar = document.createElement('div');
1821
+ Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1822
+ var mtag = document.createElement('span');
1823
+ mtag.textContent = 'MISSING';
1824
+ Object.assign(mtag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#fff', background: '#8B4A57', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1825
+ var mhint = document.createElement('span');
1826
+ mhint.textContent = spec.missReason || 'Not on this page';
1827
+ Object.assign(mhint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
1828
+ mbar.appendChild(mtag);
1829
+ mbar.appendChild(mhint);
1830
+ content.appendChild(mbar);
1831
+ }
1432
1832
  // Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
1433
1833
  // so hidden Specs are findable in a long list instead of silently unreachable.
1434
- if (!visible) {
1834
+ else if (!visible) {
1435
1835
  var hbar = document.createElement('div');
1436
1836
  Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1437
1837
  var tag = document.createElement('span');
@@ -1484,10 +1884,11 @@
1484
1884
  }
1485
1885
  });
1486
1886
  if (focusEditor) setTimeout(focusEditor, 0);
1887
+ panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1487
1888
  }
1488
1889
 
1489
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1490
- 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(); }
1491
1892
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1492
1893
 
1493
1894
  // ─── Spec editor (annotation box: add / edit / delete) ───────────────────────
@@ -2064,8 +2465,9 @@
2064
2465
  });
2065
2466
  }
2066
2467
 
2067
- restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
2068
- scheduleSync(); // mirror restored Specs to the Claude bridge on load
2468
+ restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
2469
+ importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2470
+ scheduleSync(); // mirror restored Specs to the Claude bridge on load
2069
2471
 
2070
2472
  console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
2071
2473
  })();