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.
package/dist/index.js CHANGED
@@ -28,6 +28,8 @@ function getClientScript(options) {
28
28
  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>';
29
29
  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>';
30
30
  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>';
31
+ 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>';
32
+ 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>';
31
33
  var ACTIVATE = ${JSON.stringify(activateShortcut)};
32
34
  var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
33
35
 
@@ -68,6 +70,45 @@ function getClientScript(options) {
68
70
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
69
71
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
70
72
 
73
+ // \u2500\u2500\u2500 Hydration-proof mounting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
74
+ // We inject at document_end, but frameworks that hydrate <body> (Next.js App
75
+ // Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
76
+ // didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
77
+ // vanish; badges added later survive because they're created post-hydration).
78
+ // Track our persistent singletons and re-attach any that gets detached, so the
79
+ // UI survives the hydration pass and later SPA re-renders. The parent is resolved
80
+ // fresh on each remount so a wholesale body/head element swap is handled too.
81
+ var _mounted = [];
82
+ function mount(node, where) {
83
+ var parent = where === 'head' ? document.head : document.body;
84
+ if (parent) parent.appendChild(node);
85
+ _mounted.push({ node: node, where: where });
86
+ return node;
87
+ }
88
+ var _remountQueued = false;
89
+ function remountDetached() {
90
+ _remountQueued = false;
91
+ for (var i = 0; i < _mounted.length; i++) {
92
+ var m = _mounted[i];
93
+ if (m.node.isConnected) continue;
94
+ var p = m.where === 'head' ? document.head : document.body;
95
+ if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
96
+ }
97
+ }
98
+ try {
99
+ var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
100
+ var _mo = new MutationObserver(function () {
101
+ if (_remountQueued) return;
102
+ _remountQueued = true;
103
+ _defer(remountDetached); // coalesce a burst of mutations into one restore pass
104
+ });
105
+ // childList on the roots is enough \u2014 every singleton is a direct child of
106
+ // body/head; watching documentElement too catches a body/head element swap.
107
+ _mo.observe(document.documentElement, { childList: true });
108
+ _mo.observe(document.body, { childList: true });
109
+ _mo.observe(document.head, { childList: true });
110
+ } catch (e) {}
111
+
71
112
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
72
113
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
73
114
  function hoverFx(el, on, off) {
@@ -94,7 +135,7 @@ function getClientScript(options) {
94
135
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
95
136
  display: 'none',
96
137
  });
97
- document.body.appendChild(tooltip);
138
+ mount(tooltip);
98
139
 
99
140
  // Measure overlay
100
141
  var measureOverlay = document.createElement('div');
@@ -106,7 +147,7 @@ function getClientScript(options) {
106
147
  pointerEvents: 'none',
107
148
  display: 'none',
108
149
  });
109
- document.body.appendChild(measureOverlay);
150
+ mount(measureOverlay);
110
151
 
111
152
  // \u2500\u2500\u2500 Pill \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
112
153
  var pillWrap = document.createElement('div');
@@ -182,41 +223,34 @@ function getClientScript(options) {
182
223
  var pillText = document.createElement('span');
183
224
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
184
225
 
226
+ // Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
227
+ // (Open/Close) and always names the shortcut.
185
228
  var listBtn = document.createElement('span');
186
- listBtn.innerHTML = LIST;
187
- listBtn.title = 'Show Specs panel (L)';
229
+ listBtn.title = 'Toggle Specs panel (L)';
188
230
  Object.assign(listBtn.style, {
189
231
  display: 'none',
190
232
  alignItems: 'center',
233
+ gap: '6px',
191
234
  cursor: 'pointer',
192
235
  color: '#fff',
236
+ fontSize: '11px',
193
237
  flexShrink: '0',
194
- padding: '4px 6px',
238
+ padding: '4px 10px',
195
239
  marginLeft: '2px',
196
240
  borderRadius: '999px',
197
241
  background: 'rgba(255,255,255,0.16)',
198
242
  });
243
+ var listIcon = document.createElement('span');
244
+ listIcon.innerHTML = LIST;
245
+ Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
246
+ var listLabel = document.createElement('span');
247
+ listBtn.appendChild(listIcon);
248
+ listBtn.appendChild(listLabel);
249
+ function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
250
+ updateListBtn();
199
251
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
200
252
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
201
253
 
202
- var clearBtn = document.createElement('span');
203
- clearBtn.textContent = '\u2715 Delete all';
204
- clearBtn.title = 'Delete all annotations';
205
- Object.assign(clearBtn.style, {
206
- display: 'none',
207
- cursor: 'pointer',
208
- color: '#fff',
209
- fontSize: '11px',
210
- fontWeight: '600',
211
- flexShrink: '0',
212
- padding: '2px 8px',
213
- marginLeft: '2px',
214
- borderRadius: '999px',
215
- background: 'rgba(255,255,255,0.16)',
216
- });
217
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
218
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
219
-
220
254
  var chevron = document.createElement('span');
221
255
  chevron.textContent = '\u203A';
222
256
  chevron.title = 'Move to other side';
@@ -246,16 +280,15 @@ function getClientScript(options) {
246
280
  pill.appendChild(pillSync);
247
281
  pill.appendChild(pillText);
248
282
  pill.appendChild(listBtn);
249
- pill.appendChild(clearBtn);
250
283
  pill.appendChild(chevron);
251
284
  pillWrap.appendChild(pill);
252
- document.body.appendChild(pillWrap);
285
+ mount(pillWrap);
253
286
 
254
287
  // Keyframes for the sync spinner (injected once).
255
288
  var spinStyle = document.createElement('style');
256
289
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
257
290
  markUI(spinStyle);
258
- document.head.appendChild(spinStyle);
291
+ mount(spinStyle, 'head');
259
292
 
260
293
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
261
294
  // always match: spinner while syncing, green when synced, red on error.
@@ -307,15 +340,15 @@ function getClientScript(options) {
307
340
  // The mode is shown at all times (persistent prefix), so you always know whether
308
341
  // hovering shows properties, measurements, or nothing (Comment).
309
342
  function modeLabel() {
310
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
343
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
311
344
  }
312
345
 
313
346
  function expandPill(text) {
314
347
  pill.style.maxWidth = '820px';
315
348
  pillText.style.display = 'inline';
316
349
  chevron.style.display = 'inline';
317
- listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
318
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
350
+ listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
351
+ updateListBtn();
319
352
  pillExpanded = true;
320
353
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
321
354
  // side panel now, so the pill stays short.
@@ -330,14 +363,12 @@ function getClientScript(options) {
330
363
  pill.style.maxWidth = '220px';
331
364
  chevron.style.display = 'none';
332
365
  listBtn.style.display = 'none';
333
- clearBtn.style.display = 'none';
334
366
  pillExpanded = false;
335
367
  }
336
368
 
337
369
  function flashMode() {
338
370
  if (pillExpanded && pillWrap.matches(':hover')) return;
339
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
340
- expandPill(text);
371
+ expandPill(modeLabel());
341
372
  clearTimeout(flashTimer);
342
373
  flashTimer = setTimeout(function () {
343
374
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -463,10 +494,13 @@ function getClientScript(options) {
463
494
  var cur = el;
464
495
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
465
496
  var part = cur.tagName.toLowerCase();
466
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
497
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
467
498
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
468
499
  if (cls.length) {
469
- part += '.' + cls.slice(0, 2).join('.');
500
+ // Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
501
+ // invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
502
+ // throw on query, so they must be CSS.escape-d first.
503
+ part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
470
504
  } else if (cur.parentElement) {
471
505
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
472
506
  if (sameTag.length > 1) {
@@ -546,13 +580,13 @@ function getClientScript(options) {
546
580
  var i, v;
547
581
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
548
582
  for (i = 0; i < testAttrs.length; i++) { v = el.getAttribute(testAttrs[i]); if (v) { var ts = '[' + testAttrs[i] + '="' + cssEsc(v) + '"]'; if (uniqueSel(ts)) return ts; } }
549
- if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
583
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
550
584
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
551
585
  for (i = 0; i < attrs.length; i++) { v = el.getAttribute(attrs[i]); if (v && v.trim()) { v = v.trim(); if (uniqueSel('[' + attrs[i] + '="' + cssEsc(v) + '"]')) return attrs[i] + ' "' + v.slice(0, 60) + '"'; } }
552
586
  var txt = ownText(el);
553
587
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
554
588
  var cls = ownClass(el);
555
- if (cls) return '.' + cls;
589
+ if (cls) return '.' + cssEsc(cls);
556
590
  return getSelector(el); // fallback: the CSS path
557
591
  }
558
592
 
@@ -726,11 +760,20 @@ function getClientScript(options) {
726
760
  function elementPath(el) {
727
761
  if (!el || el.nodeType !== 1) return '';
728
762
  var parts = [], cur = el;
729
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
730
- if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
763
+ // Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
764
+ // nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
765
+ // child index (the old div:nth-child(11) bug \u2014 matched a different node, or
766
+ // none, on the recipient). nth-of-type counts only same-tag siblings, so it
767
+ // survives that. Deeper cap (12) so the chain reaches a rooting anchor.
768
+ while (cur && cur.nodeType === 1 && parts.length < 12) {
769
+ if (cur === document.body) { parts.unshift('body'); break; }
770
+ if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
731
771
  var seg = cur.tagName.toLowerCase();
732
772
  var parent = cur.parentElement;
733
- if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
773
+ if (parent) {
774
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
775
+ if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
776
+ }
734
777
  parts.unshift(seg);
735
778
  cur = cur.parentElement;
736
779
  }
@@ -799,19 +842,36 @@ function getClientScript(options) {
799
842
  var mx = lastMouse.x, my = lastMouse.y;
800
843
  var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
801
844
 
802
- // Pass 1 \u2014 resolve visibility + base anchor for each spec.
845
+ // Pass 1 \u2014 resolve visibility + base anchor for each spec. _liveVis tracks the
846
+ // panel's HIDDEN semantics (isVisible after re-anchor recovery, IGNORING occlusion \u2014
847
+ // off-screen is fine, that's what the hover-scroll is for) so the panel can stay in
848
+ // sync with what's actually on the page instead of showing a stale HIDDEN.
803
849
  var vis = [];
804
850
  for (var i = 0; i < specs.length; i++) {
805
851
  var s = specs[i];
806
- if (!fiActive) { s.wrap.style.display = 'none'; continue; }
852
+ // Badges created at import-time can be wiped by a framework hydrating <body>
853
+ // (same cause as the singleton mount guard). Re-attach a detached wrap so the
854
+ // pin reappears; a removed spec is already out of the specs array, so this
855
+ // never resurrects a deleted badge.
856
+ if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
857
+ if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
858
+ if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
807
859
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
808
- if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
860
+ if (!isVisible(s.el)) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
861
+ s._liveVis = true;
809
862
  var r = s.el.getBoundingClientRect();
810
- if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered (e.g. behind a modal)
863
+ if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered / off-screen: no badge, but panel not HIDDEN
811
864
  s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
812
865
  vis.push(s);
813
866
  }
814
867
 
868
+ // Keep the open panel's HIDDEN labels live: if any spec's on-page visibility
869
+ // flipped since the panel last rendered, re-render it (debounced).
870
+ if (panelOpen) {
871
+ var g = liveVisSig();
872
+ if (g !== panelVisSig) { panelVisSig = g; clearTimeout(panelVisTimer); panelVisTimer = setTimeout(function () { if (panelOpen) renderPanel(); }, 150); }
873
+ }
874
+
815
875
  // Pass 2 \u2014 cluster badges whose anchors coincide (within ~16px).
816
876
  var clusters = [];
817
877
  for (var a = 0; a < vis.length; a++) {
@@ -993,13 +1053,23 @@ function getClientScript(options) {
993
1053
  saveSpecs();
994
1054
  }
995
1055
 
1056
+ // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1057
+ // importComments so a received share replaces the prior set instead of stacking.
1058
+ function removeSharedSpecs() {
1059
+ if (highlightSpec && highlightSpec.shared) highlightSpec = null;
1060
+ if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
1061
+ for (var i = specs.length - 1; i >= 0; i--) {
1062
+ if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
1063
+ }
1064
+ }
1065
+
996
1066
  // \u2500\u2500\u2500 Reload insurance (localStorage, per-URL, zero network) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
997
1067
  // Persist only the serializable parts of each Spec; the live element ref and
998
1068
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
999
1069
  function saveSpecs() {
1000
1070
  try {
1001
1071
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
1002
- 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 }; })));
1072
+ 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 }; })));
1003
1073
  } catch (e) {}
1004
1074
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1005
1075
  }
@@ -1009,13 +1079,279 @@ function getClientScript(options) {
1009
1079
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1010
1080
  if (!Array.isArray(data) || !data.length) return;
1011
1081
  data.forEach(function (d) {
1012
- var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
1082
+ 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 || '' };
1013
1083
  specs.push(spec);
1014
1084
  createBadge(spec);
1015
1085
  });
1016
1086
  renumber();
1017
1087
  }
1018
1088
 
1089
+ // \u2500\u2500\u2500 Share Comments (human\u2194human) \u2014 Steps 1-2 \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
1090
+ // A shared comment is a LOCATOR, not a coordinate: on import we re-find the
1091
+ // element and place a badge, exactly like restoreSpecs. Comments only \u2014 we carry
1092
+ // the note + how to re-find the element, NEVER the props/measurements (body).
1093
+ // Step 2 adds change-detection: a fingerprint travels with each comment; if the
1094
+ // element is gone OR its signature changed, the comment shows as MISSING (panel
1095
+ // only, greyed, with a reason) instead of being drawn on a guessed spot.
1096
+
1097
+ // Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
1098
+ // element a shared comment belongs to (and to scan for it when selectors fail).
1099
+ // Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
1100
+ // price) was false-tripping this and blocking re-anchor on otherwise-identical
1101
+ // pages. tag + sorted own classes + key attrs + child count identifies the node
1102
+ // without that fragility. (Trade-off: pure text edits under a stable node no
1103
+ // longer read as "changed" \u2014 placement is favoured over change-detection.)
1104
+ function normSig(el) {
1105
+ if (!el || el.nodeType !== 1) return '';
1106
+ var cls = Array.prototype.slice.call(el.classList)
1107
+ .filter(function (c) { return c.indexOf('__specter') !== 0; }).sort().join('.');
1108
+ var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1109
+ var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1110
+ }).filter(Boolean).join('|');
1111
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1112
+ }
1113
+ // djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
1114
+ // means "unchanged enough", and the re-find already narrowed us to one element).
1115
+ function fingerprint(el) {
1116
+ var s = normSig(el);
1117
+ if (!s) return '';
1118
+ var h = 5381, i = s.length;
1119
+ while (i) h = (h * 33) ^ s.charCodeAt(--i);
1120
+ return (h >>> 0).toString(36);
1121
+ }
1122
+
1123
+ function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
1124
+
1125
+ // Every element whose OWN text matches (trunc = the stored anchor was cut to 40
1126
+ // chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
1127
+ // so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
1128
+ function findAllByOwnText(val, trunc) {
1129
+ var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
1130
+ for (var i = 0; i < all.length; i++) {
1131
+ var el = all[i];
1132
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1133
+ var t = ownText(el);
1134
+ if (!t) continue;
1135
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1136
+ }
1137
+ return out;
1138
+ }
1139
+
1140
+ // Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
1141
+ // querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
1142
+ // scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
1143
+ // fingerprint rather than rejecting anything ambiguous up front.
1144
+ function resolveFindCandidates(find) {
1145
+ if (!find) return [];
1146
+ var c = find.charAt(0);
1147
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1148
+ var q = find.indexOf(' "');
1149
+ if (q > 0 && find.charAt(find.length - 1) === '"') {
1150
+ var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1151
+ if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
1152
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1153
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1154
+ return [];
1155
+ }
1156
+ return queryAll(find); // a bare CSS path
1157
+ }
1158
+
1159
+ // Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
1160
+ // just a change-detector: gather every candidate from the find anchor AND the
1161
+ // nth-child path, then return the one whose signature matches \u2014 so a locator
1162
+ // shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
1163
+ // instead of failing as ambiguous. With no fp match: hand back a lone candidate
1164
+ // (so importComments can say "changed"), else null ("couldn't find").
1165
+ // Whole-DOM scan for the one element whose structural signature matches \u2014 the
1166
+ // last-resort re-finder when both the find anchor and the nth-of-type path fail
1167
+ // (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
1168
+ // (ambiguous, don't guess). Skips Specter's own UI.
1169
+ function findByFingerprint(fp) {
1170
+ if (!fp) return null;
1171
+ var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1172
+ for (var i = 0; i < all.length; i++) {
1173
+ var el = all[i];
1174
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1175
+ if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
1176
+ }
1177
+ return hit;
1178
+ }
1179
+
1180
+ function reFindShared(item) {
1181
+ var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
1182
+ var uniq = [], i;
1183
+ for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
1184
+ if (item.fp) {
1185
+ for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
1186
+ var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
1187
+ if (scan) return scan;
1188
+ }
1189
+ return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
1190
+ }
1191
+
1192
+ // URL gate: two people must be on the SAME page for a shared comment to place.
1193
+ // Match by origin + pathname only \u2014 hash AND query are dropped (the hash is the
1194
+ // doSync fork bug; a trailing #route or ?ref shouldn't split the same page).
1195
+ function pageKey(href) {
1196
+ try { var u = new URL(href || location.href); return u.origin + u.pathname; }
1197
+ catch (e) { return location.origin + location.pathname; }
1198
+ }
1199
+
1200
+ function exportComments() {
1201
+ return {
1202
+ url: location.href, // gated on pageKey() at import; full href kept for reference
1203
+ comments: specs.map(function (s) {
1204
+ return { find: resolveLocator(s.el), path: s.path, fp: fingerprint(s.el), note: s.note || '', kind: s.kind || 'element' };
1205
+ }),
1206
+ };
1207
+ }
1208
+
1209
+ function importComments(payload) {
1210
+ if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { return 0; } }
1211
+ var comments = null, srcUrl = '';
1212
+ if (Array.isArray(payload)) comments = payload; // legacy pre-URL blob: no gate
1213
+ else if (payload && Array.isArray(payload.comments)) { comments = payload.comments; srcUrl = payload.url || ''; }
1214
+ if (!comments) return 0;
1215
+ // Wrong page \u2192 decline rather than anchor onto whatever happens to match here.
1216
+ if (srcUrl && pageKey(srcUrl) !== pageKey()) {
1217
+ console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
1218
+ return 0;
1219
+ }
1220
+ // A received share REPLACES the previously-imported set: re-opening the same
1221
+ // link (or a refreshed one) gives exactly that set, never a stacked-up pile of
1222
+ // duplicates across rounds. Your OWN local specs (shared:false) are untouched.
1223
+ removeSharedSpecs();
1224
+ var added = 0;
1225
+ comments.forEach(function (item) {
1226
+ if (!item) return;
1227
+ // reFindShared already used the fingerprint to pick/scan the right element, so
1228
+ // trust its result: if it located a node, PLACE the comment. Only a genuine
1229
+ // no-match is MISSING \u2014 we no longer hide a found element just because its
1230
+ // signature drifted (that over-fired on dynamic pages and buried real pins).
1231
+ var el = reFindShared(item), missing = false, reason = '';
1232
+ if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
1233
+ // body stays empty on purpose: comments-only, never the shared element's props.
1234
+ // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
1235
+ var spec = { el: missing ? null : el, path: item.path || '', note: item.note || '', body: '', kind: item.kind || 'element', locate: '', shared: true, fp: item.fp || '', missing: missing, missReason: reason };
1236
+ specs.push(spec);
1237
+ createBadge(spec);
1238
+ added++;
1239
+ });
1240
+ renumber();
1241
+ reflowSpecs();
1242
+ updatePill();
1243
+ if (panelOpen) renderPanel();
1244
+ saveSpecs();
1245
+ return added;
1246
+ }
1247
+
1248
+ // \u2500\u2500 Transport: share link (#spx=) with a file fallback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1249
+ // The link is destination AND payload \u2014 pasted into Slack it looks like a normal
1250
+ // URL; clicked, it lands the recipient on the exact page, so the URL gate is
1251
+ // satisfied automatically. Payload = <fmt><base64url>: fmt 'z' = gzip (Compression
1252
+ // Stream, zero-dep), 'j' = plain UTF-8 when gzip is unavailable. split/join dodges
1253
+ // regex escaping inside this template-literal client.
1254
+ var LINK_MAX = 8000; // ~URL ceiling; beyond this we offer a .specter.json file
1255
+
1256
+ function bytesToB64url(bytes) {
1257
+ var bin = '';
1258
+ for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
1259
+ var b = btoa(bin).split('+').join('-').split('/').join('_');
1260
+ while (b.charAt(b.length - 1) === '=') b = b.slice(0, -1);
1261
+ return b;
1262
+ }
1263
+ function b64urlToBytes(s) {
1264
+ s = s.split('-').join('+').split('_').join('/');
1265
+ while (s.length % 4) s += '=';
1266
+ var bin = atob(s), bytes = new Uint8Array(bin.length);
1267
+ for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1268
+ return bytes;
1269
+ }
1270
+ function gzipStr(str) {
1271
+ var s = new Blob([str]).stream().pipeThrough(new CompressionStream('gzip'));
1272
+ return new Response(s).arrayBuffer().then(function (buf) { return new Uint8Array(buf); });
1273
+ }
1274
+ function gunzipBytes(bytes) {
1275
+ var s = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
1276
+ return new Response(s).text();
1277
+ }
1278
+ function encodePayload(str) {
1279
+ if (typeof CompressionStream !== 'undefined') {
1280
+ return gzipStr(str).then(function (gz) { return 'z' + bytesToB64url(gz); })
1281
+ .catch(function () { return 'j' + bytesToB64url(new TextEncoder().encode(str)); });
1282
+ }
1283
+ return Promise.resolve('j' + bytesToB64url(new TextEncoder().encode(str)));
1284
+ }
1285
+ function decodePayload(s) {
1286
+ var fmt = s.charAt(0);
1287
+ if (fmt === 'z') return gunzipBytes(b64urlToBytes(s.slice(1)));
1288
+ if (fmt === 'j') return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s.slice(1))));
1289
+ // legacy (no fmt prefix): raw base64url of the JSON string
1290
+ return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s)));
1291
+ }
1292
+
1293
+ // Build on the current page (keep query, replace any existing hash).
1294
+ function buildShareLink() {
1295
+ return encodePayload(JSON.stringify(exportComments())).then(function (enc) {
1296
+ return location.origin + location.pathname + location.search + '#spx=' + enc;
1297
+ });
1298
+ }
1299
+
1300
+ // A page that uses hash routing itself would collide with #spx= \u2014 treat any
1301
+ // non-trivial existing hash (beyond a bare '#') as in-use and prefer the file.
1302
+ function hashInUse() { return (location.hash || '').length > 1; }
1303
+
1304
+ // Download the comments as a .specter.json (the fallback when the link is too big
1305
+ // or the page owns the hash). Same {url, comments} payload \u2192 same URL gate on import.
1306
+ function downloadCommentsFile() {
1307
+ var data = JSON.stringify(exportComments(), null, 2);
1308
+ var url = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
1309
+ var a = document.createElement('a');
1310
+ a.href = url; a.download = 'comments.specter.json';
1311
+ document.body.appendChild(a); a.click(); a.remove();
1312
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
1313
+ }
1314
+
1315
+ // One coherent share action: a link by default, a file when it can't fit (or the
1316
+ // page owns the hash). Returns a descriptor so the caller/UI can tell the user which.
1317
+ function shareComments() {
1318
+ if (!specs.length) return Promise.resolve({ kind: 'empty' });
1319
+ if (hashInUse()) { downloadCommentsFile(); return Promise.resolve({ kind: 'file', reason: 'hash-routing' }); }
1320
+ return buildShareLink().then(function (link) {
1321
+ if (link.length > LINK_MAX) { downloadCommentsFile(); return { kind: 'file', reason: 'too-large', size: link.length }; }
1322
+ return { kind: 'link', link: link };
1323
+ });
1324
+ }
1325
+
1326
+ // Pick a .specter.json (or any JSON) and import it \u2014 the file-fallback receiver.
1327
+ function importCommentsFile() {
1328
+ var input = document.createElement('input');
1329
+ input.type = 'file';
1330
+ input.accept = '.json,application/json';
1331
+ input.addEventListener('change', function () {
1332
+ var f = input.files && input.files[0];
1333
+ if (!f) return;
1334
+ var reader = new FileReader();
1335
+ reader.onload = function () { var n = importComments(String(reader.result)); if (n > 0) { if (!fiActive) activate(); showPanel(); } };
1336
+ reader.readAsText(f);
1337
+ });
1338
+ input.click();
1339
+ }
1340
+
1341
+ // On load: if the URL carries #spx=, decode it, strip it from the address bar (so
1342
+ // the URL goes clean and a plain reload won't re-import), import, and surface it.
1343
+ function importFromHash() {
1344
+ var h = location.hash || '', k = h.indexOf('spx=');
1345
+ if (k < 0) return Promise.resolve(0);
1346
+ var enc = h.slice(k + 4);
1347
+ try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
1348
+ return decodePayload(enc).then(function (json) {
1349
+ var n = importComments(json);
1350
+ if (n > 0) { if (!fiActive) activate(); showPanel(); }
1351
+ return n;
1352
+ }).catch(function () { return 0; });
1353
+ }
1354
+
1019
1355
  // Re-anchor if the node detached, then scroll it into view. Returns false when
1020
1356
  // the element can't be shown (removed / hidden \u2014 e.g. behind a closed modal).
1021
1357
  // The badge enlargement is handled separately via highlightSpec (sustained
@@ -1035,6 +1371,11 @@ function getClientScript(options) {
1035
1371
  return isVisible(spec.el);
1036
1372
  }
1037
1373
 
1374
+ // Signature of on-page visibility across specs (from reflowSpecs' per-frame _liveVis),
1375
+ // used to detect when the panel's HIDDEN labels have gone stale and re-render.
1376
+ var panelVisSig = '', panelVisTimer = null;
1377
+ function liveVisSig() { var s = ''; for (var i = 0; i < specs.length; i++) s += specs[i]._liveVis ? '1' : '0'; return s; }
1378
+
1038
1379
  // \u2500\u2500\u2500 Specs side panel (in-session review) \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
1039
1380
  var panelWrap = markUI(document.createElement('div'));
1040
1381
  Object.assign(panelWrap.style, {
@@ -1122,10 +1463,40 @@ function getClientScript(options) {
1122
1463
  var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
1123
1464
  delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
1124
1465
 
1466
+ // Share comments (human\u2194human): copies a link, or downloads a file when it can't
1467
+ // fit / the page owns the hash. The hint line confirms which happened.
1468
+ var share = iconPill(SHARE, 'Share comments to another person', '#8AB4F8', '138,180,248');
1469
+ function resetShareIcon() { share.icon.innerHTML = SHARE; share.btn.style.color = share.btn.matches(':hover') ? '#fff' : '#8AB4F8'; }
1470
+ share.btn.addEventListener('click', function (e) {
1471
+ e.stopPropagation();
1472
+ if (!specs.length) return;
1473
+ shareComments().then(function (res) {
1474
+ if (res.kind === 'link') {
1475
+ navigator.clipboard.writeText(res.link).then(function () {
1476
+ share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
1477
+ flashHint('\u2713 Link copied \u2014 paste it to your reviewer. They see your comments on the same page.', GREEN);
1478
+ setTimeout(resetShareIcon, 1400);
1479
+ }).catch(function () { flashHint('Copy failed \u2014 check clipboard permission for this site.', '#ED8FA6'); });
1480
+ } else if (res.kind === 'file') {
1481
+ share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
1482
+ flashHint('\u2713 Saved comments.specter.json \u2014 send that file (too many comments to fit a link).', GREEN);
1483
+ setTimeout(resetShareIcon, 1400);
1484
+ }
1485
+ });
1486
+ });
1487
+
1488
+ // Import a comments file someone sent (the file-fallback receiver).
1489
+ var importTool = iconPill(IMPORT, 'Import a comments file', '#8AB4F8', '138,180,248');
1490
+ importTool.btn.addEventListener('click', function (e) { e.stopPropagation(); importCommentsFile(); });
1491
+
1125
1492
  var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
1126
1493
  var deleteAllBtn = delAll.btn;
1494
+ var shareBtn = share.btn;
1495
+ var importToolBtn = importTool.btn;
1127
1496
  if (BRIDGE) panelTools.appendChild(syncDot);
1128
1497
  else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
1498
+ panelTools.appendChild(shareBtn);
1499
+ panelTools.appendChild(importToolBtn);
1129
1500
  panelTools.appendChild(copyAllBtn);
1130
1501
  panelTools.appendChild(deleteAllBtn);
1131
1502
 
@@ -1193,6 +1564,16 @@ function getClientScript(options) {
1193
1564
  panelHint.appendChild(document.createTextNode(' (or Copy all) and paste into Claude to apply these annotations. You can also edit or delete each one above.'));
1194
1565
  }
1195
1566
  }
1567
+ // Briefly show a confirmation in the hint line (Share copied / file saved), then
1568
+ // restore the normal guidance.
1569
+ var hintFlashTimer = null;
1570
+ function flashHint(msg, color) {
1571
+ panelHint.style.display = 'block';
1572
+ panelHint.textContent = msg;
1573
+ panelHint.style.color = color || GREEN;
1574
+ if (hintFlashTimer) clearTimeout(hintFlashTimer);
1575
+ hintFlashTimer = setTimeout(function () { panelHint.style.color = LABEL; setHint(); }, 2800);
1576
+ }
1196
1577
 
1197
1578
  var panelList = document.createElement('div');
1198
1579
  // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
@@ -1227,7 +1608,7 @@ function getClientScript(options) {
1227
1608
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1228
1609
  rows.forEach(function (r) {
1229
1610
  var row = document.createElement('div');
1230
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1611
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1231
1612
  var k = document.createElement('span');
1232
1613
  k.textContent = r[0];
1233
1614
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1252,7 +1633,7 @@ function getClientScript(options) {
1252
1633
  panelWrap.appendChild(panelHint);
1253
1634
  panelWrap.appendChild(panelList);
1254
1635
  panelWrap.appendChild(panelKeys);
1255
- document.body.appendChild(panelWrap);
1636
+ mount(panelWrap);
1256
1637
 
1257
1638
  // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1258
1639
  // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
@@ -1306,21 +1687,24 @@ function getClientScript(options) {
1306
1687
 
1307
1688
  function renderPanel() {
1308
1689
  panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
1309
- panelTools.style.display = (specs.length || BRIDGE) ? 'flex' : 'none';
1690
+ panelTools.style.display = 'flex'; // always visible when the panel is open (Import needs no Specs)
1691
+ shareBtn.style.display = specs.length ? 'inline-flex' : 'none';
1310
1692
  copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1311
1693
  deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1694
+ importToolBtn.style.display = 'inline-flex'; // receiving a shared file needs no existing Specs
1312
1695
  setHint();
1313
1696
  panelList.textContent = '';
1314
1697
  if (!specs.length) {
1315
1698
  var empty = document.createElement('div');
1316
- empty.textContent = 'No Specs yet \u2014 hover an element and press P.';
1699
+ empty.textContent = 'No comments yet \u2014 hover an element and press P, or use the Import button above to open a comments file someone shared.';
1317
1700
  Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
1318
1701
  panelList.appendChild(empty);
1319
1702
  return;
1320
1703
  }
1321
1704
  var focusEditor = null, measures = [];
1322
1705
  specs.forEach(function (spec, i) {
1323
- var visible = specVisible(spec);
1706
+ var missing = !!spec.missing;
1707
+ var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
1324
1708
  var editing = (panelEditSpec === spec);
1325
1709
  var row = document.createElement('div');
1326
1710
  Object.assign(row.style, {
@@ -1434,9 +1818,25 @@ function getClientScript(options) {
1434
1818
  content.appendChild(note);
1435
1819
  content.appendChild(meta);
1436
1820
 
1821
+ // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1822
+ // never drawn on the page \u2014 design: show missing, don't guess a spot).
1823
+ if (missing) {
1824
+ row.style.opacity = '0.7';
1825
+ var mbar = document.createElement('div');
1826
+ Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1827
+ var mtag = document.createElement('span');
1828
+ mtag.textContent = 'MISSING';
1829
+ Object.assign(mtag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#fff', background: '#8B4A57', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1830
+ var mhint = document.createElement('span');
1831
+ mhint.textContent = spec.missReason || 'Not on this page';
1832
+ Object.assign(mhint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
1833
+ mbar.appendChild(mtag);
1834
+ mbar.appendChild(mhint);
1835
+ content.appendChild(mbar);
1836
+ }
1437
1837
  // Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
1438
1838
  // so hidden Specs are findable in a long list instead of silently unreachable.
1439
- if (!visible) {
1839
+ else if (!visible) {
1440
1840
  var hbar = document.createElement('div');
1441
1841
  Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1442
1842
  var tag = document.createElement('span');
@@ -1489,10 +1889,11 @@ function getClientScript(options) {
1489
1889
  }
1490
1890
  });
1491
1891
  if (focusEditor) setTimeout(focusEditor, 0);
1892
+ panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1492
1893
  }
1493
1894
 
1494
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1495
- function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
1895
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
1896
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
1496
1897
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1497
1898
 
1498
1899
  // \u2500\u2500\u2500 Spec editor (annotation box: add / edit / delete) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -2069,8 +2470,9 @@ function getClientScript(options) {
2069
2470
  });
2070
2471
  }
2071
2472
 
2072
- restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
2073
- scheduleSync(); // mirror restored Specs to the Claude bridge on load
2473
+ restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
2474
+ importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2475
+ scheduleSync(); // mirror restored Specs to the Claude bridge on load
2074
2476
 
2075
2477
  console.log('%c\u{1F47B} Specter \u2014 Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
2076
2478
  })();`;