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.cjs CHANGED
@@ -55,6 +55,8 @@ function getClientScript(options) {
55
55
  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>';
56
56
  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>';
57
57
  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>';
58
+ 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>';
59
+ 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>';
58
60
  var ACTIVATE = ${JSON.stringify(activateShortcut)};
59
61
  var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
60
62
 
@@ -95,6 +97,45 @@ function getClientScript(options) {
95
97
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
96
98
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
97
99
 
100
+ // \u2500\u2500\u2500 Hydration-proof mounting \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
101
+ // We inject at document_end, but frameworks that hydrate <body> (Next.js App
102
+ // Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
103
+ // didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
104
+ // vanish; badges added later survive because they're created post-hydration).
105
+ // Track our persistent singletons and re-attach any that gets detached, so the
106
+ // UI survives the hydration pass and later SPA re-renders. The parent is resolved
107
+ // fresh on each remount so a wholesale body/head element swap is handled too.
108
+ var _mounted = [];
109
+ function mount(node, where) {
110
+ var parent = where === 'head' ? document.head : document.body;
111
+ if (parent) parent.appendChild(node);
112
+ _mounted.push({ node: node, where: where });
113
+ return node;
114
+ }
115
+ var _remountQueued = false;
116
+ function remountDetached() {
117
+ _remountQueued = false;
118
+ for (var i = 0; i < _mounted.length; i++) {
119
+ var m = _mounted[i];
120
+ if (m.node.isConnected) continue;
121
+ var p = m.where === 'head' ? document.head : document.body;
122
+ if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
123
+ }
124
+ }
125
+ try {
126
+ var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
127
+ var _mo = new MutationObserver(function () {
128
+ if (_remountQueued) return;
129
+ _remountQueued = true;
130
+ _defer(remountDetached); // coalesce a burst of mutations into one restore pass
131
+ });
132
+ // childList on the roots is enough \u2014 every singleton is a direct child of
133
+ // body/head; watching documentElement too catches a body/head element swap.
134
+ _mo.observe(document.documentElement, { childList: true });
135
+ _mo.observe(document.body, { childList: true });
136
+ _mo.observe(document.head, { childList: true });
137
+ } catch (e) {}
138
+
98
139
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
99
140
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
100
141
  function hoverFx(el, on, off) {
@@ -121,7 +162,7 @@ function getClientScript(options) {
121
162
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
122
163
  display: 'none',
123
164
  });
124
- document.body.appendChild(tooltip);
165
+ mount(tooltip);
125
166
 
126
167
  // Measure overlay
127
168
  var measureOverlay = document.createElement('div');
@@ -133,7 +174,7 @@ function getClientScript(options) {
133
174
  pointerEvents: 'none',
134
175
  display: 'none',
135
176
  });
136
- document.body.appendChild(measureOverlay);
177
+ mount(measureOverlay);
137
178
 
138
179
  // \u2500\u2500\u2500 Pill \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
139
180
  var pillWrap = document.createElement('div');
@@ -209,41 +250,34 @@ function getClientScript(options) {
209
250
  var pillText = document.createElement('span');
210
251
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
211
252
 
253
+ // Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
254
+ // (Open/Close) and always names the shortcut.
212
255
  var listBtn = document.createElement('span');
213
- listBtn.innerHTML = LIST;
214
- listBtn.title = 'Show Specs panel (L)';
256
+ listBtn.title = 'Toggle Specs panel (L)';
215
257
  Object.assign(listBtn.style, {
216
258
  display: 'none',
217
259
  alignItems: 'center',
260
+ gap: '6px',
218
261
  cursor: 'pointer',
219
262
  color: '#fff',
263
+ fontSize: '11px',
220
264
  flexShrink: '0',
221
- padding: '4px 6px',
265
+ padding: '4px 10px',
222
266
  marginLeft: '2px',
223
267
  borderRadius: '999px',
224
268
  background: 'rgba(255,255,255,0.16)',
225
269
  });
270
+ var listIcon = document.createElement('span');
271
+ listIcon.innerHTML = LIST;
272
+ Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
273
+ var listLabel = document.createElement('span');
274
+ listBtn.appendChild(listIcon);
275
+ listBtn.appendChild(listLabel);
276
+ function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
277
+ updateListBtn();
226
278
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
227
279
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
228
280
 
229
- var clearBtn = document.createElement('span');
230
- clearBtn.textContent = '\u2715 Delete all';
231
- clearBtn.title = 'Delete all annotations';
232
- Object.assign(clearBtn.style, {
233
- display: 'none',
234
- cursor: 'pointer',
235
- color: '#fff',
236
- fontSize: '11px',
237
- fontWeight: '600',
238
- flexShrink: '0',
239
- padding: '2px 8px',
240
- marginLeft: '2px',
241
- borderRadius: '999px',
242
- background: 'rgba(255,255,255,0.16)',
243
- });
244
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
245
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
246
-
247
281
  var chevron = document.createElement('span');
248
282
  chevron.textContent = '\u203A';
249
283
  chevron.title = 'Move to other side';
@@ -273,16 +307,15 @@ function getClientScript(options) {
273
307
  pill.appendChild(pillSync);
274
308
  pill.appendChild(pillText);
275
309
  pill.appendChild(listBtn);
276
- pill.appendChild(clearBtn);
277
310
  pill.appendChild(chevron);
278
311
  pillWrap.appendChild(pill);
279
- document.body.appendChild(pillWrap);
312
+ mount(pillWrap);
280
313
 
281
314
  // Keyframes for the sync spinner (injected once).
282
315
  var spinStyle = document.createElement('style');
283
316
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
284
317
  markUI(spinStyle);
285
- document.head.appendChild(spinStyle);
318
+ mount(spinStyle, 'head');
286
319
 
287
320
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
288
321
  // always match: spinner while syncing, green when synced, red on error.
@@ -334,15 +367,15 @@ function getClientScript(options) {
334
367
  // The mode is shown at all times (persistent prefix), so you always know whether
335
368
  // hovering shows properties, measurements, or nothing (Comment).
336
369
  function modeLabel() {
337
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
370
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
338
371
  }
339
372
 
340
373
  function expandPill(text) {
341
374
  pill.style.maxWidth = '820px';
342
375
  pillText.style.display = 'inline';
343
376
  chevron.style.display = 'inline';
344
- listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
345
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
377
+ listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
378
+ updateListBtn();
346
379
  pillExpanded = true;
347
380
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
348
381
  // side panel now, so the pill stays short.
@@ -357,14 +390,12 @@ function getClientScript(options) {
357
390
  pill.style.maxWidth = '220px';
358
391
  chevron.style.display = 'none';
359
392
  listBtn.style.display = 'none';
360
- clearBtn.style.display = 'none';
361
393
  pillExpanded = false;
362
394
  }
363
395
 
364
396
  function flashMode() {
365
397
  if (pillExpanded && pillWrap.matches(':hover')) return;
366
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
367
- expandPill(text);
398
+ expandPill(modeLabel());
368
399
  clearTimeout(flashTimer);
369
400
  flashTimer = setTimeout(function () {
370
401
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -490,10 +521,13 @@ function getClientScript(options) {
490
521
  var cur = el;
491
522
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
492
523
  var part = cur.tagName.toLowerCase();
493
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
524
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
494
525
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
495
526
  if (cls.length) {
496
- part += '.' + cls.slice(0, 2).join('.');
527
+ // Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
528
+ // invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
529
+ // throw on query, so they must be CSS.escape-d first.
530
+ part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
497
531
  } else if (cur.parentElement) {
498
532
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
499
533
  if (sameTag.length > 1) {
@@ -573,13 +607,13 @@ function getClientScript(options) {
573
607
  var i, v;
574
608
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
575
609
  for (i = 0; i < testAttrs.length; i++) { v = el.getAttribute(testAttrs[i]); if (v) { var ts = '[' + testAttrs[i] + '="' + cssEsc(v) + '"]'; if (uniqueSel(ts)) return ts; } }
576
- if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
610
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
577
611
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
578
612
  for (i = 0; i < attrs.length; i++) { v = el.getAttribute(attrs[i]); if (v && v.trim()) { v = v.trim(); if (uniqueSel('[' + attrs[i] + '="' + cssEsc(v) + '"]')) return attrs[i] + ' "' + v.slice(0, 60) + '"'; } }
579
613
  var txt = ownText(el);
580
614
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
581
615
  var cls = ownClass(el);
582
- if (cls) return '.' + cls;
616
+ if (cls) return '.' + cssEsc(cls);
583
617
  return getSelector(el); // fallback: the CSS path
584
618
  }
585
619
 
@@ -753,11 +787,20 @@ function getClientScript(options) {
753
787
  function elementPath(el) {
754
788
  if (!el || el.nodeType !== 1) return '';
755
789
  var parts = [], cur = el;
756
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
757
- if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
790
+ // Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
791
+ // nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
792
+ // child index (the old div:nth-child(11) bug \u2014 matched a different node, or
793
+ // none, on the recipient). nth-of-type counts only same-tag siblings, so it
794
+ // survives that. Deeper cap (12) so the chain reaches a rooting anchor.
795
+ while (cur && cur.nodeType === 1 && parts.length < 12) {
796
+ if (cur === document.body) { parts.unshift('body'); break; }
797
+ if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
758
798
  var seg = cur.tagName.toLowerCase();
759
799
  var parent = cur.parentElement;
760
- if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
800
+ if (parent) {
801
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
802
+ if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
803
+ }
761
804
  parts.unshift(seg);
762
805
  cur = cur.parentElement;
763
806
  }
@@ -826,19 +869,36 @@ function getClientScript(options) {
826
869
  var mx = lastMouse.x, my = lastMouse.y;
827
870
  var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
828
871
 
829
- // Pass 1 \u2014 resolve visibility + base anchor for each spec.
872
+ // Pass 1 \u2014 resolve visibility + base anchor for each spec. _liveVis tracks the
873
+ // panel's HIDDEN semantics (isVisible after re-anchor recovery, IGNORING occlusion \u2014
874
+ // off-screen is fine, that's what the hover-scroll is for) so the panel can stay in
875
+ // sync with what's actually on the page instead of showing a stale HIDDEN.
830
876
  var vis = [];
831
877
  for (var i = 0; i < specs.length; i++) {
832
878
  var s = specs[i];
833
- if (!fiActive) { s.wrap.style.display = 'none'; continue; }
879
+ // Badges created at import-time can be wiped by a framework hydrating <body>
880
+ // (same cause as the singleton mount guard). Re-attach a detached wrap so the
881
+ // pin reappears; a removed spec is already out of the specs array, so this
882
+ // never resurrects a deleted badge.
883
+ if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
884
+ if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
885
+ if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
834
886
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
835
- if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
887
+ if (!isVisible(s.el)) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
888
+ s._liveVis = true;
836
889
  var r = s.el.getBoundingClientRect();
837
- if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered (e.g. behind a modal)
890
+ if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered / off-screen: no badge, but panel not HIDDEN
838
891
  s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
839
892
  vis.push(s);
840
893
  }
841
894
 
895
+ // Keep the open panel's HIDDEN labels live: if any spec's on-page visibility
896
+ // flipped since the panel last rendered, re-render it (debounced).
897
+ if (panelOpen) {
898
+ var g = liveVisSig();
899
+ if (g !== panelVisSig) { panelVisSig = g; clearTimeout(panelVisTimer); panelVisTimer = setTimeout(function () { if (panelOpen) renderPanel(); }, 150); }
900
+ }
901
+
842
902
  // Pass 2 \u2014 cluster badges whose anchors coincide (within ~16px).
843
903
  var clusters = [];
844
904
  for (var a = 0; a < vis.length; a++) {
@@ -1020,13 +1080,23 @@ function getClientScript(options) {
1020
1080
  saveSpecs();
1021
1081
  }
1022
1082
 
1083
+ // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1084
+ // importComments so a received share replaces the prior set instead of stacking.
1085
+ function removeSharedSpecs() {
1086
+ if (highlightSpec && highlightSpec.shared) highlightSpec = null;
1087
+ if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
1088
+ for (var i = specs.length - 1; i >= 0; i--) {
1089
+ if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
1090
+ }
1091
+ }
1092
+
1023
1093
  // \u2500\u2500\u2500 Reload insurance (localStorage, per-URL, zero network) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1024
1094
  // Persist only the serializable parts of each Spec; the live element ref and
1025
1095
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
1026
1096
  function saveSpecs() {
1027
1097
  try {
1028
1098
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
1029
- 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 }; })));
1099
+ 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 }; })));
1030
1100
  } catch (e) {}
1031
1101
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1032
1102
  }
@@ -1036,13 +1106,279 @@ function getClientScript(options) {
1036
1106
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1037
1107
  if (!Array.isArray(data) || !data.length) return;
1038
1108
  data.forEach(function (d) {
1039
- var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
1109
+ 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 || '' };
1040
1110
  specs.push(spec);
1041
1111
  createBadge(spec);
1042
1112
  });
1043
1113
  renumber();
1044
1114
  }
1045
1115
 
1116
+ // \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
1117
+ // A shared comment is a LOCATOR, not a coordinate: on import we re-find the
1118
+ // element and place a badge, exactly like restoreSpecs. Comments only \u2014 we carry
1119
+ // the note + how to re-find the element, NEVER the props/measurements (body).
1120
+ // Step 2 adds change-detection: a fingerprint travels with each comment; if the
1121
+ // element is gone OR its signature changed, the comment shows as MISSING (panel
1122
+ // only, greyed, with a reason) instead of being drawn on a guessed spot.
1123
+
1124
+ // Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
1125
+ // element a shared comment belongs to (and to scan for it when selectors fail).
1126
+ // Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
1127
+ // price) was false-tripping this and blocking re-anchor on otherwise-identical
1128
+ // pages. tag + sorted own classes + key attrs + child count identifies the node
1129
+ // without that fragility. (Trade-off: pure text edits under a stable node no
1130
+ // longer read as "changed" \u2014 placement is favoured over change-detection.)
1131
+ function normSig(el) {
1132
+ if (!el || el.nodeType !== 1) return '';
1133
+ var cls = Array.prototype.slice.call(el.classList)
1134
+ .filter(function (c) { return c.indexOf('__specter') !== 0; }).sort().join('.');
1135
+ var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1136
+ var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1137
+ }).filter(Boolean).join('|');
1138
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1139
+ }
1140
+ // djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
1141
+ // means "unchanged enough", and the re-find already narrowed us to one element).
1142
+ function fingerprint(el) {
1143
+ var s = normSig(el);
1144
+ if (!s) return '';
1145
+ var h = 5381, i = s.length;
1146
+ while (i) h = (h * 33) ^ s.charCodeAt(--i);
1147
+ return (h >>> 0).toString(36);
1148
+ }
1149
+
1150
+ function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
1151
+
1152
+ // Every element whose OWN text matches (trunc = the stored anchor was cut to 40
1153
+ // chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
1154
+ // so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
1155
+ function findAllByOwnText(val, trunc) {
1156
+ var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
1157
+ for (var i = 0; i < all.length; i++) {
1158
+ var el = all[i];
1159
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1160
+ var t = ownText(el);
1161
+ if (!t) continue;
1162
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1163
+ }
1164
+ return out;
1165
+ }
1166
+
1167
+ // Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
1168
+ // querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
1169
+ // scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
1170
+ // fingerprint rather than rejecting anything ambiguous up front.
1171
+ function resolveFindCandidates(find) {
1172
+ if (!find) return [];
1173
+ var c = find.charAt(0);
1174
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1175
+ var q = find.indexOf(' "');
1176
+ if (q > 0 && find.charAt(find.length - 1) === '"') {
1177
+ var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1178
+ if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
1179
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1180
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1181
+ return [];
1182
+ }
1183
+ return queryAll(find); // a bare CSS path
1184
+ }
1185
+
1186
+ // Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
1187
+ // just a change-detector: gather every candidate from the find anchor AND the
1188
+ // nth-child path, then return the one whose signature matches \u2014 so a locator
1189
+ // shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
1190
+ // instead of failing as ambiguous. With no fp match: hand back a lone candidate
1191
+ // (so importComments can say "changed"), else null ("couldn't find").
1192
+ // Whole-DOM scan for the one element whose structural signature matches \u2014 the
1193
+ // last-resort re-finder when both the find anchor and the nth-of-type path fail
1194
+ // (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
1195
+ // (ambiguous, don't guess). Skips Specter's own UI.
1196
+ function findByFingerprint(fp) {
1197
+ if (!fp) return null;
1198
+ var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1199
+ for (var i = 0; i < all.length; i++) {
1200
+ var el = all[i];
1201
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1202
+ if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
1203
+ }
1204
+ return hit;
1205
+ }
1206
+
1207
+ function reFindShared(item) {
1208
+ var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
1209
+ var uniq = [], i;
1210
+ for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
1211
+ if (item.fp) {
1212
+ for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
1213
+ var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
1214
+ if (scan) return scan;
1215
+ }
1216
+ return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
1217
+ }
1218
+
1219
+ // URL gate: two people must be on the SAME page for a shared comment to place.
1220
+ // Match by origin + pathname only \u2014 hash AND query are dropped (the hash is the
1221
+ // doSync fork bug; a trailing #route or ?ref shouldn't split the same page).
1222
+ function pageKey(href) {
1223
+ try { var u = new URL(href || location.href); return u.origin + u.pathname; }
1224
+ catch (e) { return location.origin + location.pathname; }
1225
+ }
1226
+
1227
+ function exportComments() {
1228
+ return {
1229
+ url: location.href, // gated on pageKey() at import; full href kept for reference
1230
+ comments: specs.map(function (s) {
1231
+ return { find: resolveLocator(s.el), path: s.path, fp: fingerprint(s.el), note: s.note || '', kind: s.kind || 'element' };
1232
+ }),
1233
+ };
1234
+ }
1235
+
1236
+ function importComments(payload) {
1237
+ if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { return 0; } }
1238
+ var comments = null, srcUrl = '';
1239
+ if (Array.isArray(payload)) comments = payload; // legacy pre-URL blob: no gate
1240
+ else if (payload && Array.isArray(payload.comments)) { comments = payload.comments; srcUrl = payload.url || ''; }
1241
+ if (!comments) return 0;
1242
+ // Wrong page \u2192 decline rather than anchor onto whatever happens to match here.
1243
+ if (srcUrl && pageKey(srcUrl) !== pageKey()) {
1244
+ console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
1245
+ return 0;
1246
+ }
1247
+ // A received share REPLACES the previously-imported set: re-opening the same
1248
+ // link (or a refreshed one) gives exactly that set, never a stacked-up pile of
1249
+ // duplicates across rounds. Your OWN local specs (shared:false) are untouched.
1250
+ removeSharedSpecs();
1251
+ var added = 0;
1252
+ comments.forEach(function (item) {
1253
+ if (!item) return;
1254
+ // reFindShared already used the fingerprint to pick/scan the right element, so
1255
+ // trust its result: if it located a node, PLACE the comment. Only a genuine
1256
+ // no-match is MISSING \u2014 we no longer hide a found element just because its
1257
+ // signature drifted (that over-fired on dynamic pages and buried real pins).
1258
+ var el = reFindShared(item), missing = false, reason = '';
1259
+ if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
1260
+ // body stays empty on purpose: comments-only, never the shared element's props.
1261
+ // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
1262
+ var spec = { el: missing ? null : el, path: item.path || '', note: item.note || '', body: '', kind: item.kind || 'element', locate: '', shared: true, fp: item.fp || '', missing: missing, missReason: reason };
1263
+ specs.push(spec);
1264
+ createBadge(spec);
1265
+ added++;
1266
+ });
1267
+ renumber();
1268
+ reflowSpecs();
1269
+ updatePill();
1270
+ if (panelOpen) renderPanel();
1271
+ saveSpecs();
1272
+ return added;
1273
+ }
1274
+
1275
+ // \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
1276
+ // The link is destination AND payload \u2014 pasted into Slack it looks like a normal
1277
+ // URL; clicked, it lands the recipient on the exact page, so the URL gate is
1278
+ // satisfied automatically. Payload = <fmt><base64url>: fmt 'z' = gzip (Compression
1279
+ // Stream, zero-dep), 'j' = plain UTF-8 when gzip is unavailable. split/join dodges
1280
+ // regex escaping inside this template-literal client.
1281
+ var LINK_MAX = 8000; // ~URL ceiling; beyond this we offer a .specter.json file
1282
+
1283
+ function bytesToB64url(bytes) {
1284
+ var bin = '';
1285
+ for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
1286
+ var b = btoa(bin).split('+').join('-').split('/').join('_');
1287
+ while (b.charAt(b.length - 1) === '=') b = b.slice(0, -1);
1288
+ return b;
1289
+ }
1290
+ function b64urlToBytes(s) {
1291
+ s = s.split('-').join('+').split('_').join('/');
1292
+ while (s.length % 4) s += '=';
1293
+ var bin = atob(s), bytes = new Uint8Array(bin.length);
1294
+ for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
1295
+ return bytes;
1296
+ }
1297
+ function gzipStr(str) {
1298
+ var s = new Blob([str]).stream().pipeThrough(new CompressionStream('gzip'));
1299
+ return new Response(s).arrayBuffer().then(function (buf) { return new Uint8Array(buf); });
1300
+ }
1301
+ function gunzipBytes(bytes) {
1302
+ var s = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
1303
+ return new Response(s).text();
1304
+ }
1305
+ function encodePayload(str) {
1306
+ if (typeof CompressionStream !== 'undefined') {
1307
+ return gzipStr(str).then(function (gz) { return 'z' + bytesToB64url(gz); })
1308
+ .catch(function () { return 'j' + bytesToB64url(new TextEncoder().encode(str)); });
1309
+ }
1310
+ return Promise.resolve('j' + bytesToB64url(new TextEncoder().encode(str)));
1311
+ }
1312
+ function decodePayload(s) {
1313
+ var fmt = s.charAt(0);
1314
+ if (fmt === 'z') return gunzipBytes(b64urlToBytes(s.slice(1)));
1315
+ if (fmt === 'j') return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s.slice(1))));
1316
+ // legacy (no fmt prefix): raw base64url of the JSON string
1317
+ return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s)));
1318
+ }
1319
+
1320
+ // Build on the current page (keep query, replace any existing hash).
1321
+ function buildShareLink() {
1322
+ return encodePayload(JSON.stringify(exportComments())).then(function (enc) {
1323
+ return location.origin + location.pathname + location.search + '#spx=' + enc;
1324
+ });
1325
+ }
1326
+
1327
+ // A page that uses hash routing itself would collide with #spx= \u2014 treat any
1328
+ // non-trivial existing hash (beyond a bare '#') as in-use and prefer the file.
1329
+ function hashInUse() { return (location.hash || '').length > 1; }
1330
+
1331
+ // Download the comments as a .specter.json (the fallback when the link is too big
1332
+ // or the page owns the hash). Same {url, comments} payload \u2192 same URL gate on import.
1333
+ function downloadCommentsFile() {
1334
+ var data = JSON.stringify(exportComments(), null, 2);
1335
+ var url = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
1336
+ var a = document.createElement('a');
1337
+ a.href = url; a.download = 'comments.specter.json';
1338
+ document.body.appendChild(a); a.click(); a.remove();
1339
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
1340
+ }
1341
+
1342
+ // One coherent share action: a link by default, a file when it can't fit (or the
1343
+ // page owns the hash). Returns a descriptor so the caller/UI can tell the user which.
1344
+ function shareComments() {
1345
+ if (!specs.length) return Promise.resolve({ kind: 'empty' });
1346
+ if (hashInUse()) { downloadCommentsFile(); return Promise.resolve({ kind: 'file', reason: 'hash-routing' }); }
1347
+ return buildShareLink().then(function (link) {
1348
+ if (link.length > LINK_MAX) { downloadCommentsFile(); return { kind: 'file', reason: 'too-large', size: link.length }; }
1349
+ return { kind: 'link', link: link };
1350
+ });
1351
+ }
1352
+
1353
+ // Pick a .specter.json (or any JSON) and import it \u2014 the file-fallback receiver.
1354
+ function importCommentsFile() {
1355
+ var input = document.createElement('input');
1356
+ input.type = 'file';
1357
+ input.accept = '.json,application/json';
1358
+ input.addEventListener('change', function () {
1359
+ var f = input.files && input.files[0];
1360
+ if (!f) return;
1361
+ var reader = new FileReader();
1362
+ reader.onload = function () { var n = importComments(String(reader.result)); if (n > 0) { if (!fiActive) activate(); showPanel(); } };
1363
+ reader.readAsText(f);
1364
+ });
1365
+ input.click();
1366
+ }
1367
+
1368
+ // On load: if the URL carries #spx=, decode it, strip it from the address bar (so
1369
+ // the URL goes clean and a plain reload won't re-import), import, and surface it.
1370
+ function importFromHash() {
1371
+ var h = location.hash || '', k = h.indexOf('spx=');
1372
+ if (k < 0) return Promise.resolve(0);
1373
+ var enc = h.slice(k + 4);
1374
+ try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
1375
+ return decodePayload(enc).then(function (json) {
1376
+ var n = importComments(json);
1377
+ if (n > 0) { if (!fiActive) activate(); showPanel(); }
1378
+ return n;
1379
+ }).catch(function () { return 0; });
1380
+ }
1381
+
1046
1382
  // Re-anchor if the node detached, then scroll it into view. Returns false when
1047
1383
  // the element can't be shown (removed / hidden \u2014 e.g. behind a closed modal).
1048
1384
  // The badge enlargement is handled separately via highlightSpec (sustained
@@ -1062,6 +1398,11 @@ function getClientScript(options) {
1062
1398
  return isVisible(spec.el);
1063
1399
  }
1064
1400
 
1401
+ // Signature of on-page visibility across specs (from reflowSpecs' per-frame _liveVis),
1402
+ // used to detect when the panel's HIDDEN labels have gone stale and re-render.
1403
+ var panelVisSig = '', panelVisTimer = null;
1404
+ function liveVisSig() { var s = ''; for (var i = 0; i < specs.length; i++) s += specs[i]._liveVis ? '1' : '0'; return s; }
1405
+
1065
1406
  // \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
1066
1407
  var panelWrap = markUI(document.createElement('div'));
1067
1408
  Object.assign(panelWrap.style, {
@@ -1149,10 +1490,40 @@ function getClientScript(options) {
1149
1490
  var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
1150
1491
  delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
1151
1492
 
1493
+ // Share comments (human\u2194human): copies a link, or downloads a file when it can't
1494
+ // fit / the page owns the hash. The hint line confirms which happened.
1495
+ var share = iconPill(SHARE, 'Share comments to another person', '#8AB4F8', '138,180,248');
1496
+ function resetShareIcon() { share.icon.innerHTML = SHARE; share.btn.style.color = share.btn.matches(':hover') ? '#fff' : '#8AB4F8'; }
1497
+ share.btn.addEventListener('click', function (e) {
1498
+ e.stopPropagation();
1499
+ if (!specs.length) return;
1500
+ shareComments().then(function (res) {
1501
+ if (res.kind === 'link') {
1502
+ navigator.clipboard.writeText(res.link).then(function () {
1503
+ share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
1504
+ flashHint('\u2713 Link copied \u2014 paste it to your reviewer. They see your comments on the same page.', GREEN);
1505
+ setTimeout(resetShareIcon, 1400);
1506
+ }).catch(function () { flashHint('Copy failed \u2014 check clipboard permission for this site.', '#ED8FA6'); });
1507
+ } else if (res.kind === 'file') {
1508
+ share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
1509
+ flashHint('\u2713 Saved comments.specter.json \u2014 send that file (too many comments to fit a link).', GREEN);
1510
+ setTimeout(resetShareIcon, 1400);
1511
+ }
1512
+ });
1513
+ });
1514
+
1515
+ // Import a comments file someone sent (the file-fallback receiver).
1516
+ var importTool = iconPill(IMPORT, 'Import a comments file', '#8AB4F8', '138,180,248');
1517
+ importTool.btn.addEventListener('click', function (e) { e.stopPropagation(); importCommentsFile(); });
1518
+
1152
1519
  var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
1153
1520
  var deleteAllBtn = delAll.btn;
1521
+ var shareBtn = share.btn;
1522
+ var importToolBtn = importTool.btn;
1154
1523
  if (BRIDGE) panelTools.appendChild(syncDot);
1155
1524
  else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
1525
+ panelTools.appendChild(shareBtn);
1526
+ panelTools.appendChild(importToolBtn);
1156
1527
  panelTools.appendChild(copyAllBtn);
1157
1528
  panelTools.appendChild(deleteAllBtn);
1158
1529
 
@@ -1220,6 +1591,16 @@ function getClientScript(options) {
1220
1591
  panelHint.appendChild(document.createTextNode(' (or Copy all) and paste into Claude to apply these annotations. You can also edit or delete each one above.'));
1221
1592
  }
1222
1593
  }
1594
+ // Briefly show a confirmation in the hint line (Share copied / file saved), then
1595
+ // restore the normal guidance.
1596
+ var hintFlashTimer = null;
1597
+ function flashHint(msg, color) {
1598
+ panelHint.style.display = 'block';
1599
+ panelHint.textContent = msg;
1600
+ panelHint.style.color = color || GREEN;
1601
+ if (hintFlashTimer) clearTimeout(hintFlashTimer);
1602
+ hintFlashTimer = setTimeout(function () { panelHint.style.color = LABEL; setHint(); }, 2800);
1603
+ }
1223
1604
 
1224
1605
  var panelList = document.createElement('div');
1225
1606
  // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
@@ -1254,7 +1635,7 @@ function getClientScript(options) {
1254
1635
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1255
1636
  rows.forEach(function (r) {
1256
1637
  var row = document.createElement('div');
1257
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1638
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1258
1639
  var k = document.createElement('span');
1259
1640
  k.textContent = r[0];
1260
1641
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1279,7 +1660,7 @@ function getClientScript(options) {
1279
1660
  panelWrap.appendChild(panelHint);
1280
1661
  panelWrap.appendChild(panelList);
1281
1662
  panelWrap.appendChild(panelKeys);
1282
- document.body.appendChild(panelWrap);
1663
+ mount(panelWrap);
1283
1664
 
1284
1665
  // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1285
1666
  // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
@@ -1333,21 +1714,24 @@ function getClientScript(options) {
1333
1714
 
1334
1715
  function renderPanel() {
1335
1716
  panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
1336
- panelTools.style.display = (specs.length || BRIDGE) ? 'flex' : 'none';
1717
+ panelTools.style.display = 'flex'; // always visible when the panel is open (Import needs no Specs)
1718
+ shareBtn.style.display = specs.length ? 'inline-flex' : 'none';
1337
1719
  copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1338
1720
  deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1721
+ importToolBtn.style.display = 'inline-flex'; // receiving a shared file needs no existing Specs
1339
1722
  setHint();
1340
1723
  panelList.textContent = '';
1341
1724
  if (!specs.length) {
1342
1725
  var empty = document.createElement('div');
1343
- empty.textContent = 'No Specs yet \u2014 hover an element and press P.';
1726
+ 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.';
1344
1727
  Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
1345
1728
  panelList.appendChild(empty);
1346
1729
  return;
1347
1730
  }
1348
1731
  var focusEditor = null, measures = [];
1349
1732
  specs.forEach(function (spec, i) {
1350
- var visible = specVisible(spec);
1733
+ var missing = !!spec.missing;
1734
+ var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
1351
1735
  var editing = (panelEditSpec === spec);
1352
1736
  var row = document.createElement('div');
1353
1737
  Object.assign(row.style, {
@@ -1461,9 +1845,25 @@ function getClientScript(options) {
1461
1845
  content.appendChild(note);
1462
1846
  content.appendChild(meta);
1463
1847
 
1848
+ // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1849
+ // never drawn on the page \u2014 design: show missing, don't guess a spot).
1850
+ if (missing) {
1851
+ row.style.opacity = '0.7';
1852
+ var mbar = document.createElement('div');
1853
+ Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1854
+ var mtag = document.createElement('span');
1855
+ mtag.textContent = 'MISSING';
1856
+ Object.assign(mtag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#fff', background: '#8B4A57', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1857
+ var mhint = document.createElement('span');
1858
+ mhint.textContent = spec.missReason || 'Not on this page';
1859
+ Object.assign(mhint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
1860
+ mbar.appendChild(mtag);
1861
+ mbar.appendChild(mhint);
1862
+ content.appendChild(mbar);
1863
+ }
1464
1864
  // Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
1465
1865
  // so hidden Specs are findable in a long list instead of silently unreachable.
1466
- if (!visible) {
1866
+ else if (!visible) {
1467
1867
  var hbar = document.createElement('div');
1468
1868
  Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1469
1869
  var tag = document.createElement('span');
@@ -1516,10 +1916,11 @@ function getClientScript(options) {
1516
1916
  }
1517
1917
  });
1518
1918
  if (focusEditor) setTimeout(focusEditor, 0);
1919
+ panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1519
1920
  }
1520
1921
 
1521
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1522
- function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
1922
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
1923
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
1523
1924
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1524
1925
 
1525
1926
  // \u2500\u2500\u2500 Spec editor (annotation box: add / edit / delete) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -2096,8 +2497,9 @@ function getClientScript(options) {
2096
2497
  });
2097
2498
  }
2098
2499
 
2099
- restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
2100
- scheduleSync(); // mirror restored Specs to the Claude bridge on load
2500
+ restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
2501
+ importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2502
+ scheduleSync(); // mirror restored Specs to the Claude bridge on load
2101
2503
 
2102
2504
  console.log('%c\u{1F47B} Specter \u2014 Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
2103
2505
  })();`;