vite-plugin-specter 0.7.0 → 0.7.5

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
@@ -57,7 +57,17 @@ function getClientScript(options) {
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
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
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>';
60
- var ACTIVATE = ${JSON.stringify(activateShortcut)};
60
+ // Toggle chord: the Vite-config value (or the built-in default) is the baseline; a
61
+ // per-origin localStorage override \u2014 set from the panel's rebind UI or window.__specter \u2014
62
+ // wins, so anyone can change it with no config edit and no dev-server restart.
63
+ var ACTIVATE_DEFAULT = ${JSON.stringify(activateShortcut)};
64
+ var ACTIVATE_KEY = '__specter_activate';
65
+ var ACTIVATE = ACTIVATE_DEFAULT;
66
+ try { var _actOv = localStorage.getItem(ACTIVATE_KEY); if (_actOv) ACTIVATE = _actOv; } catch (e) {}
67
+ function shortcutLabel(s) { return String(s || '').split('+').map(function (p) { return p.charAt(0).toUpperCase() + p.slice(1); }).join('+'); }
68
+ function setActivate(combo) { ACTIVATE = combo; try { localStorage.setItem(ACTIVATE_KEY, combo); } catch (e) {} }
69
+ function resetActivate() { ACTIVATE = ACTIVATE_DEFAULT; try { localStorage.removeItem(ACTIVATE_KEY); } catch (e) {} }
70
+ function activateOverridden() { try { return !!localStorage.getItem(ACTIVATE_KEY); } catch (e) { return false; } }
61
71
  var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
62
72
 
63
73
  // \u2500\u2500\u2500 State \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
@@ -97,6 +107,45 @@ function getClientScript(options) {
97
107
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
98
108
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
99
109
 
110
+ // \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
111
+ // We inject at document_end, but frameworks that hydrate <body> (Next.js App
112
+ // Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
113
+ // didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
114
+ // vanish; badges added later survive because they're created post-hydration).
115
+ // Track our persistent singletons and re-attach any that gets detached, so the
116
+ // UI survives the hydration pass and later SPA re-renders. The parent is resolved
117
+ // fresh on each remount so a wholesale body/head element swap is handled too.
118
+ var _mounted = [];
119
+ function mount(node, where) {
120
+ var parent = where === 'head' ? document.head : document.body;
121
+ if (parent) parent.appendChild(node);
122
+ _mounted.push({ node: node, where: where });
123
+ return node;
124
+ }
125
+ var _remountQueued = false;
126
+ function remountDetached() {
127
+ _remountQueued = false;
128
+ for (var i = 0; i < _mounted.length; i++) {
129
+ var m = _mounted[i];
130
+ if (m.node.isConnected) continue;
131
+ var p = m.where === 'head' ? document.head : document.body;
132
+ if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
133
+ }
134
+ }
135
+ try {
136
+ var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
137
+ var _mo = new MutationObserver(function () {
138
+ if (_remountQueued) return;
139
+ _remountQueued = true;
140
+ _defer(remountDetached); // coalesce a burst of mutations into one restore pass
141
+ });
142
+ // childList on the roots is enough \u2014 every singleton is a direct child of
143
+ // body/head; watching documentElement too catches a body/head element swap.
144
+ _mo.observe(document.documentElement, { childList: true });
145
+ _mo.observe(document.body, { childList: true });
146
+ _mo.observe(document.head, { childList: true });
147
+ } catch (e) {}
148
+
100
149
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
101
150
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
102
151
  function hoverFx(el, on, off) {
@@ -123,7 +172,7 @@ function getClientScript(options) {
123
172
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
124
173
  display: 'none',
125
174
  });
126
- document.body.appendChild(tooltip);
175
+ mount(tooltip);
127
176
 
128
177
  // Measure overlay
129
178
  var measureOverlay = document.createElement('div');
@@ -135,7 +184,7 @@ function getClientScript(options) {
135
184
  pointerEvents: 'none',
136
185
  display: 'none',
137
186
  });
138
- document.body.appendChild(measureOverlay);
187
+ mount(measureOverlay);
139
188
 
140
189
  // \u2500\u2500\u2500 Pill \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
141
190
  var pillWrap = document.createElement('div');
@@ -211,41 +260,34 @@ function getClientScript(options) {
211
260
  var pillText = document.createElement('span');
212
261
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
213
262
 
263
+ // Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
264
+ // (Open/Close) and always names the shortcut.
214
265
  var listBtn = document.createElement('span');
215
- listBtn.innerHTML = LIST;
216
- listBtn.title = 'Show Specs panel (L)';
266
+ listBtn.title = 'Toggle Specs panel (L)';
217
267
  Object.assign(listBtn.style, {
218
268
  display: 'none',
219
269
  alignItems: 'center',
270
+ gap: '6px',
220
271
  cursor: 'pointer',
221
272
  color: '#fff',
273
+ fontSize: '11px',
222
274
  flexShrink: '0',
223
- padding: '4px 6px',
275
+ padding: '4px 10px',
224
276
  marginLeft: '2px',
225
277
  borderRadius: '999px',
226
278
  background: 'rgba(255,255,255,0.16)',
227
279
  });
280
+ var listIcon = document.createElement('span');
281
+ listIcon.innerHTML = LIST;
282
+ Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
283
+ var listLabel = document.createElement('span');
284
+ listBtn.appendChild(listIcon);
285
+ listBtn.appendChild(listLabel);
286
+ function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
287
+ updateListBtn();
228
288
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
229
289
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
230
290
 
231
- var clearBtn = document.createElement('span');
232
- clearBtn.textContent = '\u2715 Delete all';
233
- clearBtn.title = 'Delete all annotations';
234
- Object.assign(clearBtn.style, {
235
- display: 'none',
236
- cursor: 'pointer',
237
- color: '#fff',
238
- fontSize: '11px',
239
- fontWeight: '600',
240
- flexShrink: '0',
241
- padding: '2px 8px',
242
- marginLeft: '2px',
243
- borderRadius: '999px',
244
- background: 'rgba(255,255,255,0.16)',
245
- });
246
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
247
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
248
-
249
291
  var chevron = document.createElement('span');
250
292
  chevron.textContent = '\u203A';
251
293
  chevron.title = 'Move to other side';
@@ -275,16 +317,15 @@ function getClientScript(options) {
275
317
  pill.appendChild(pillSync);
276
318
  pill.appendChild(pillText);
277
319
  pill.appendChild(listBtn);
278
- pill.appendChild(clearBtn);
279
320
  pill.appendChild(chevron);
280
321
  pillWrap.appendChild(pill);
281
- document.body.appendChild(pillWrap);
322
+ mount(pillWrap);
282
323
 
283
324
  // Keyframes for the sync spinner (injected once).
284
325
  var spinStyle = document.createElement('style');
285
326
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
286
327
  markUI(spinStyle);
287
- document.head.appendChild(spinStyle);
328
+ mount(spinStyle, 'head');
288
329
 
289
330
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
290
331
  // always match: spinner while syncing, green when synced, red on error.
@@ -336,7 +377,7 @@ function getClientScript(options) {
336
377
  // The mode is shown at all times (persistent prefix), so you always know whether
337
378
  // hovering shows properties, measurements, or nothing (Comment).
338
379
  function modeLabel() {
339
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
380
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
340
381
  }
341
382
 
342
383
  function expandPill(text) {
@@ -344,7 +385,7 @@ function getClientScript(options) {
344
385
  pillText.style.display = 'inline';
345
386
  chevron.style.display = 'inline';
346
387
  listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
347
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
388
+ updateListBtn();
348
389
  pillExpanded = true;
349
390
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
350
391
  // side panel now, so the pill stays short.
@@ -359,14 +400,12 @@ function getClientScript(options) {
359
400
  pill.style.maxWidth = '220px';
360
401
  chevron.style.display = 'none';
361
402
  listBtn.style.display = 'none';
362
- clearBtn.style.display = 'none';
363
403
  pillExpanded = false;
364
404
  }
365
405
 
366
406
  function flashMode() {
367
407
  if (pillExpanded && pillWrap.matches(':hover')) return;
368
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
369
- expandPill(text);
408
+ expandPill(modeLabel());
370
409
  clearTimeout(flashTimer);
371
410
  flashTimer = setTimeout(function () {
372
411
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -492,10 +531,13 @@ function getClientScript(options) {
492
531
  var cur = el;
493
532
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
494
533
  var part = cur.tagName.toLowerCase();
495
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
534
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
496
535
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
497
536
  if (cls.length) {
498
- part += '.' + cls.slice(0, 2).join('.');
537
+ // Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
538
+ // invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
539
+ // throw on query, so they must be CSS.escape-d first.
540
+ part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
499
541
  } else if (cur.parentElement) {
500
542
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
501
543
  if (sameTag.length > 1) {
@@ -575,13 +617,13 @@ function getClientScript(options) {
575
617
  var i, v;
576
618
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
577
619
  for (i = 0; i < testAttrs.length; i++) { v = el.getAttribute(testAttrs[i]); if (v) { var ts = '[' + testAttrs[i] + '="' + cssEsc(v) + '"]'; if (uniqueSel(ts)) return ts; } }
578
- if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
620
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
579
621
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
580
622
  for (i = 0; i < attrs.length; i++) { v = el.getAttribute(attrs[i]); if (v && v.trim()) { v = v.trim(); if (uniqueSel('[' + attrs[i] + '="' + cssEsc(v) + '"]')) return attrs[i] + ' "' + v.slice(0, 60) + '"'; } }
581
623
  var txt = ownText(el);
582
624
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
583
625
  var cls = ownClass(el);
584
- if (cls) return '.' + cls;
626
+ if (cls) return '.' + cssEsc(cls);
585
627
  return getSelector(el); // fallback: the CSS path
586
628
  }
587
629
 
@@ -683,6 +725,7 @@ function getClientScript(options) {
683
725
  var groups = [];
684
726
  for (var i = 0; i < specs.length; i++) {
685
727
  var s = specs[i], g = null;
728
+ if (s.resolved) continue; // done Specs drop out of both the Cmd+C copy and the bridge sync \u2014 so /spectify never re-applies them
686
729
  if (s.kind === 'element' && s.el) {
687
730
  for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
688
731
  }
@@ -755,11 +798,20 @@ function getClientScript(options) {
755
798
  function elementPath(el) {
756
799
  if (!el || el.nodeType !== 1) return '';
757
800
  var parts = [], cur = el;
758
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
759
- if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
801
+ // Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
802
+ // nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
803
+ // child index (the old div:nth-child(11) bug \u2014 matched a different node, or
804
+ // none, on the recipient). nth-of-type counts only same-tag siblings, so it
805
+ // survives that. Deeper cap (12) so the chain reaches a rooting anchor.
806
+ while (cur && cur.nodeType === 1 && parts.length < 12) {
807
+ if (cur === document.body) { parts.unshift('body'); break; }
808
+ if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
760
809
  var seg = cur.tagName.toLowerCase();
761
810
  var parent = cur.parentElement;
762
- if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
811
+ if (parent) {
812
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
813
+ if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
814
+ }
763
815
  parts.unshift(seg);
764
816
  cur = cur.parentElement;
765
817
  }
@@ -835,6 +887,11 @@ function getClientScript(options) {
835
887
  var vis = [];
836
888
  for (var i = 0; i < specs.length; i++) {
837
889
  var s = specs[i];
890
+ // Badges created at import-time can be wiped by a framework hydrating <body>
891
+ // (same cause as the singleton mount guard). Re-attach a detached wrap so the
892
+ // pin reappears; a removed spec is already out of the specs array, so this
893
+ // never resurrects a deleted badge.
894
+ if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
838
895
  if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
839
896
  if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
840
897
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
@@ -909,10 +966,17 @@ function getClientScript(options) {
909
966
  function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
910
967
  function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
911
968
 
912
- function renumber() { for (var i = 0; i < specs.length; i++) specs[i].num.textContent = String(i + 1); }
969
+ // Paint a badge's number/\u2713 + done tint. Called by renumber and updateBadgeContent so
970
+ // the on-page pin reflects resolved state everywhere (reflowSpecs never touches these).
971
+ function paintBadgeState(spec, i) {
972
+ spec.num.textContent = String(i + 1); // always the number (for wayfinding) \u2014 green tint carries the "done" signal
973
+ if (spec.cap) { spec.cap.style.background = spec.resolved ? GREEN : PURPLE; spec.cap.style.opacity = spec.resolved ? '0.65' : '1'; }
974
+ }
975
+
976
+ function renumber() { for (var i = 0; i < specs.length; i++) paintBadgeState(specs[i], i); }
913
977
 
914
978
  function updateBadgeContent(spec) {
915
- spec.num.textContent = String(specs.indexOf(spec) + 1);
979
+ paintBadgeState(spec, specs.indexOf(spec));
916
980
  if (spec.note) spec.noteSpan.textContent = spec.note;
917
981
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
918
982
  }
@@ -1034,13 +1098,50 @@ function getClientScript(options) {
1034
1098
  saveSpecs();
1035
1099
  }
1036
1100
 
1101
+ // \u2500\u2500 Resolved (done) lifecycle \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
1102
+ // A resolved Spec is NOT deleted \u2014 it stays as a record of what changed, greyed with
1103
+ // a \u2713, and drops out of the copy + bridge sync (see groupSpecs) so it's never re-applied.
1104
+ function resolvedCount() { var n = 0; for (var i = 0; i < specs.length; i++) if (specs[i].resolved) n++; return n; }
1105
+
1106
+ function toggleResolved(spec) {
1107
+ spec.resolved = !spec.resolved;
1108
+ if (spec.resolved && panelEditSpec === spec) panelEditSpec = null; // close an open editor on the one being marked done
1109
+ updateBadgeContent(spec);
1110
+ updatePill(); // re-renders the panel when open
1111
+ saveSpecs(); // persists resolved + re-syncs the bridge (now excluding it)
1112
+ }
1113
+
1114
+ // Sweep every resolved Spec once the user is satisfied \u2014 leaves open Specs untouched.
1115
+ function clearResolved() {
1116
+ for (var i = specs.length - 1; i >= 0; i--) {
1117
+ if (!specs[i].resolved) continue;
1118
+ if (highlightSpec === specs[i]) highlightSpec = null;
1119
+ if (panelEditSpec === specs[i]) panelEditSpec = null;
1120
+ if (specs[i].wrap) specs[i].wrap.remove();
1121
+ specs.splice(i, 1);
1122
+ }
1123
+ renumber();
1124
+ updatePill();
1125
+ saveSpecs();
1126
+ }
1127
+
1128
+ // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1129
+ // importComments so a received share replaces the prior set instead of stacking.
1130
+ function removeSharedSpecs() {
1131
+ if (highlightSpec && highlightSpec.shared) highlightSpec = null;
1132
+ if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
1133
+ for (var i = specs.length - 1; i >= 0; i--) {
1134
+ if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
1135
+ }
1136
+ }
1137
+
1037
1138
  // \u2500\u2500\u2500 Reload insurance (localStorage, per-URL, zero network) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1038
1139
  // Persist only the serializable parts of each Spec; the live element ref and
1039
1140
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
1040
1141
  function saveSpecs() {
1041
1142
  try {
1042
1143
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
1043
- 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 }; })));
1144
+ 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, resolved: s.resolved }; })));
1044
1145
  } catch (e) {}
1045
1146
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1046
1147
  }
@@ -1050,7 +1151,7 @@ function getClientScript(options) {
1050
1151
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1051
1152
  if (!Array.isArray(data) || !data.length) return;
1052
1153
  data.forEach(function (d) {
1053
- 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 || '' };
1154
+ 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 || '', resolved: !!d.resolved };
1054
1155
  specs.push(spec);
1055
1156
  createBadge(spec);
1056
1157
  });
@@ -1065,11 +1166,13 @@ function getClientScript(options) {
1065
1166
  // element is gone OR its signature changed, the comment shows as MISSING (panel
1066
1167
  // only, greyed, with a reason) instead of being drawn on a guessed spot.
1067
1168
 
1068
- // Normalized element signature for change-detection: stable structural identity,
1069
- // NOT raw outerHTML (which false-trips on any text/attr churn). tag + sorted own
1070
- // classes + a few structural attrs + a short trimmed-text slice. The text slice is
1071
- // the strictness knob \u2014 include it to catch content edits, at the cost of a
1072
- // false-MISSING when dynamic text (a price/timestamp) changes under a stable node.
1169
+ // Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
1170
+ // element a shared comment belongs to (and to scan for it when selectors fail).
1171
+ // Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
1172
+ // price) was false-tripping this and blocking re-anchor on otherwise-identical
1173
+ // pages. tag + sorted own classes + key attrs + child count identifies the node
1174
+ // without that fragility. (Trade-off: pure text edits under a stable node no
1175
+ // longer read as "changed" \u2014 placement is favoured over change-detection.)
1073
1176
  function normSig(el) {
1074
1177
  if (!el || el.nodeType !== 1) return '';
1075
1178
  var cls = Array.prototype.slice.call(el.classList)
@@ -1077,8 +1180,7 @@ function getClientScript(options) {
1077
1180
  var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1078
1181
  var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1079
1182
  }).filter(Boolean).join('|');
1080
- var txt = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
1081
- return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
1183
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1082
1184
  }
1083
1185
  // djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
1084
1186
  // means "unchanged enough", and the re-find already narrowed us to one element).
@@ -1090,51 +1192,91 @@ function getClientScript(options) {
1090
1192
  return (h >>> 0).toString(36);
1091
1193
  }
1092
1194
 
1093
- // Scan for an element whose OWN text matches (trunc = the stored anchor was cut to
1094
- // 40 chars). Ambiguous (>1 match) \u2192 null, so we fall through to the nth-child path
1095
- // rather than guess. Skips Specter's own UI.
1096
- function findByOwnText(val, trunc) {
1097
- var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1195
+ function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
1196
+
1197
+ // Every element whose OWN text matches (trunc = the stored anchor was cut to 40
1198
+ // chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
1199
+ // so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
1200
+ function findAllByOwnText(val, trunc) {
1201
+ var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
1098
1202
  for (var i = 0; i < all.length; i++) {
1099
1203
  var el = all[i];
1100
1204
  if (el.closest && el.closest('[data-specter-ui]')) continue;
1101
1205
  var t = ownText(el);
1102
1206
  if (!t) continue;
1103
- if (trunc ? (t.slice(0, 40) === val) : (t === val)) { if (hit) return null; hit = el; }
1207
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1104
1208
  }
1105
- return hit;
1209
+ return out;
1106
1210
  }
1107
1211
 
1108
- // Real resolver for a resolveLocator() anchor: #id/.class/[attr] query straight;
1109
- // 'attr "value"' rebuilds the attribute selector; 'text "value"' scans own-text.
1110
- // Only trusts an anchor that resolves to EXACTLY ONE element \u2014 a class or CSS path
1111
- // shared by siblings (e.g. three .card boxes) would otherwise silently match the
1112
- // first one, mis-anchoring every comment onto it.
1113
- function resolveFind(find) {
1114
- if (!find) return null;
1212
+ // Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
1213
+ // querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
1214
+ // scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
1215
+ // fingerprint rather than rejecting anything ambiguous up front.
1216
+ function resolveFindCandidates(find) {
1217
+ if (!find) return [];
1115
1218
  var c = find.charAt(0);
1116
- if (c === '#' || c === '.' || c === '[') return uniqueSel(find) ? safeQuery(find) : null;
1219
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1117
1220
  var q = find.indexOf(' "');
1118
1221
  if (q > 0 && find.charAt(find.length - 1) === '"') {
1119
1222
  var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1120
1223
  if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
1121
- if (attr === 'text') return findByOwnText(val, trunc);
1122
- if (val.indexOf('"') < 0) { var sel = '[' + attr + '="' + val + '"]'; return uniqueSel(sel) ? safeQuery(sel) : null; }
1123
- return null;
1224
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1225
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1226
+ return [];
1227
+ }
1228
+ return queryAll(find); // a bare CSS path
1229
+ }
1230
+
1231
+ // Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
1232
+ // just a change-detector: gather every candidate from the find anchor AND the
1233
+ // nth-child path, then return the one whose signature matches \u2014 so a locator
1234
+ // shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
1235
+ // instead of failing as ambiguous. With no fp match: hand back a lone candidate
1236
+ // (so importComments can say "changed"), else null ("couldn't find").
1237
+ // Whole-DOM scan for the one element whose structural signature matches \u2014 the
1238
+ // last-resort re-finder when both the find anchor and the nth-of-type path fail
1239
+ // (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
1240
+ // (ambiguous, don't guess). Skips Specter's own UI.
1241
+ function findByFingerprint(fp) {
1242
+ if (!fp) return null;
1243
+ var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1244
+ for (var i = 0; i < all.length; i++) {
1245
+ var el = all[i];
1246
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1247
+ if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
1124
1248
  }
1125
- return uniqueSel(find) ? safeQuery(find) : null; // a bare CSS path \u2014 only if unambiguous
1249
+ return hit;
1126
1250
  }
1127
1251
 
1128
- // Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
1129
- // only to detect change): prefer whichever candidate \u2014 the find anchor or the
1130
- // nth-child path \u2014 actually matches the stored signature. Falls back to a
1131
- // best-effort element so importComments can still tell "changed" from "not found".
1252
+ function fpOk(el, item) { return !item.fp || fingerprint(el) === item.fp; }
1253
+
1254
+ // Rank the signals rather than pooling them, so a specific locator always beats a
1255
+ // vague one:
1256
+ // 1. a find anchor that resolves to exactly ONE element (id / unique text / class)
1257
+ // \u2014 the strongest, most semantic signal;
1258
+ // 2. the rooted nth-of-type path, if unique \u2014 this is what disambiguates repeated
1259
+ // structure (a class-path find matches every paragraph; the path pins the exact
1260
+ // one). Pooling let an ambiguous find shadow the unique path \u2014 the mis-anchor bug;
1261
+ // 3. the structural fingerprint across the pooled candidates, then a DOM-wide scan;
1262
+ // 4. any lone resolution.
1263
+ // fp guards 1 and 2 so a drifted anchor can't win blindly, but a unique anchor with a
1264
+ // changed signature is still trusted at step 4 (placement over false-MISSING).
1132
1265
  function reFindShared(item) {
1133
- var byFind = resolveFind(item.find);
1134
- if (byFind && (!item.fp || fingerprint(byFind) === item.fp)) return byFind;
1135
- var byPath = safeQuery(item.path);
1136
- if (byPath && (!item.fp || fingerprint(byPath) === item.fp)) return byPath;
1137
- return byFind || byPath || null;
1266
+ var byFind = resolveFindCandidates(item.find);
1267
+ if (byFind.length === 1 && fpOk(byFind[0], item)) return byFind[0];
1268
+ var byPath = item.path ? queryAll(item.path) : [];
1269
+ if (byPath.length === 1 && fpOk(byPath[0], item)) return byPath[0];
1270
+ var cands = [], i, all = byFind.concat(byPath);
1271
+ for (i = 0; i < all.length; i++) if (all[i] && cands.indexOf(all[i]) < 0) cands.push(all[i]);
1272
+ if (item.fp) {
1273
+ for (i = 0; i < cands.length; i++) if (fingerprint(cands[i]) === item.fp) return cands[i];
1274
+ var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
1275
+ if (scan) return scan;
1276
+ }
1277
+ if (byFind.length === 1) return byFind[0]; // unique anchor, fp drifted \u2014 trust the anchor
1278
+ if (byPath.length === 1) return byPath[0];
1279
+ return cands.length === 1 ? cands[0] : null;
1138
1280
  }
1139
1281
 
1140
1282
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1165,12 +1307,19 @@ function getClientScript(options) {
1165
1307
  console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
1166
1308
  return 0;
1167
1309
  }
1310
+ // A received share REPLACES the previously-imported set: re-opening the same
1311
+ // link (or a refreshed one) gives exactly that set, never a stacked-up pile of
1312
+ // duplicates across rounds. Your OWN local specs (shared:false) are untouched.
1313
+ removeSharedSpecs();
1168
1314
  var added = 0;
1169
1315
  comments.forEach(function (item) {
1170
1316
  if (!item) return;
1317
+ // reFindShared already used the fingerprint to pick/scan the right element, so
1318
+ // trust its result: if it located a node, PLACE the comment. Only a genuine
1319
+ // no-match is MISSING \u2014 we no longer hide a found element just because its
1320
+ // signature drifted (that over-fired on dynamic pages and buried real pins).
1171
1321
  var el = reFindShared(item), missing = false, reason = '';
1172
1322
  if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
1173
- else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
1174
1323
  // body stays empty on purpose: comments-only, never the shared element's props.
1175
1324
  // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
1176
1325
  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 };
@@ -1531,7 +1680,7 @@ function getClientScript(options) {
1531
1680
  var rows = [
1532
1681
  ['P', 'mark / comment the hovered element'],
1533
1682
  ['C', 'toggle Comment mode (hide properties)'],
1534
- ['\\u2325 Option', 'toggle Measure mode'],
1683
+ ['\\u2325 Option / Alt', 'toggle Measure mode'],
1535
1684
  ['M', 'pin an element to measure from'],
1536
1685
  ['Cmd/Ctrl+C', 'copy all Specs'],
1537
1686
  ['L', 'toggle this panel'],
@@ -1547,9 +1696,48 @@ function getClientScript(options) {
1547
1696
  head.appendChild(headText);
1548
1697
  var body = document.createElement('div');
1549
1698
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1699
+
1700
+ // Rebindable toggle shortcut \u2014 the one chord you might have to change if it collides
1701
+ // with a browser/extension binding. Click Change, press a new combo. Saved per origin,
1702
+ // no restart. Works identically in the Vite plugin overlay and the browser extension.
1703
+ (function () {
1704
+ var rrow = document.createElement('div');
1705
+ Object.assign(rrow.style, { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px', paddingBottom: '10px', borderBottom: '1px solid rgba(255,255,255,0.06)' });
1706
+ var chip = document.createElement('span');
1707
+ Object.assign(chip.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1708
+ var lbl = document.createElement('span');
1709
+ Object.assign(lbl.style, { flex: '1', minWidth: '0' });
1710
+ lbl.textContent = 'toggle Specter on/off';
1711
+ var change = document.createElement('span');
1712
+ Object.assign(change.style, { color: '#8AB4F8', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline' });
1713
+ change.textContent = 'Change';
1714
+ var reset = document.createElement('span');
1715
+ Object.assign(reset.style, { color: '#B9BBC2', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline', display: 'none' });
1716
+ reset.textContent = 'Reset';
1717
+ function refresh() {
1718
+ chip.textContent = shortcutLabel(ACTIVATE);
1719
+ chip.style.color = '#E0A3F5';
1720
+ change.style.display = 'inline';
1721
+ reset.style.display = activateOverridden() ? 'inline' : 'none';
1722
+ }
1723
+ change.addEventListener('click', function (e) {
1724
+ e.stopPropagation();
1725
+ change.style.display = 'none';
1726
+ reset.style.display = 'none';
1727
+ startRebind(
1728
+ function (text, color) { chip.textContent = text; chip.style.color = color; },
1729
+ function () { refresh(); }
1730
+ );
1731
+ });
1732
+ reset.addEventListener('click', function (e) { e.stopPropagation(); resetActivate(); refresh(); });
1733
+ refresh();
1734
+ rrow.appendChild(chip); rrow.appendChild(lbl); rrow.appendChild(change); rrow.appendChild(reset);
1735
+ body.appendChild(rrow);
1736
+ })();
1737
+
1550
1738
  rows.forEach(function (r) {
1551
1739
  var row = document.createElement('div');
1552
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1740
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1553
1741
  var k = document.createElement('span');
1554
1742
  k.textContent = r[0];
1555
1743
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1569,20 +1757,48 @@ function getClientScript(options) {
1569
1757
  panelKeys.appendChild(body);
1570
1758
  })();
1571
1759
 
1760
+ // Footer \u2014 a quiet link out to the GitHub README so first-timers can find the docs.
1761
+ var panelFoot = document.createElement('div');
1762
+ Object.assign(panelFoot.style, {
1763
+ flexShrink: '0', padding: '10px 16px', borderTop: '1px solid rgba(255,255,255,0.08)',
1764
+ display: 'flex', alignItems: 'center',
1765
+ });
1766
+ var docsLink = document.createElement('a');
1767
+ docsLink.href = 'https://github.com/setugk/vite-plugin-specter#readme';
1768
+ docsLink.target = '_blank';
1769
+ docsLink.rel = 'noopener noreferrer';
1770
+ docsLink.textContent = 'Documentation \\u2197'; // \u2197
1771
+ Object.assign(docsLink.style, { fontSize: '11px', fontWeight: '600', color: '#8AB4F8', textDecoration: 'none', cursor: 'pointer' });
1772
+ hoverFx(docsLink, { color: '#fff' }, { color: '#8AB4F8' });
1773
+ docsLink.addEventListener('click', function (e) { e.stopPropagation(); });
1774
+ panelFoot.appendChild(docsLink);
1775
+
1572
1776
  panelWrap.appendChild(panelHead);
1573
1777
  panelWrap.appendChild(panelTools);
1574
1778
  panelWrap.appendChild(panelHint);
1575
1779
  panelWrap.appendChild(panelList);
1576
1780
  panelWrap.appendChild(panelKeys);
1577
- document.body.appendChild(panelWrap);
1578
-
1579
- // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1580
- // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1581
- // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1781
+ panelWrap.appendChild(panelFoot);
1782
+ mount(panelWrap);
1783
+
1784
+ // A wheel anywhere over the panel must NEVER scroll the page behind it. Relying on
1785
+ // overscroll-behavior alone still lets trackpad momentum chain into the page at the
1786
+ // list bounds, so we take over: always swallow the event, and drive the list's own
1787
+ // scroll ourselves. (The inline note editor auto-grows instead of scrolling, so the
1788
+ // list is the only scrollable region inside the panel \u2014 nothing else needs the wheel.)
1789
+ var listScrolling = false, listScrollTimer = null;
1582
1790
  panelWrap.addEventListener('wheel', function (e) {
1583
- var canScroll = panelList.scrollHeight > panelList.clientHeight;
1584
- if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1585
1791
  e.preventDefault();
1792
+ if (panelList.scrollHeight > panelList.clientHeight && panelList.contains(e.target)) {
1793
+ var dy = e.deltaMode === 1 ? e.deltaY * 16 : (e.deltaMode === 2 ? e.deltaY * panelList.clientHeight : e.deltaY);
1794
+ panelList.scrollTop += dy;
1795
+ // Rows slide under a stationary cursor as the list scrolls, firing mouseenter \u2192
1796
+ // revealSpec \u2192 scrollIntoView, which would drag the PAGE around. Mark the list as
1797
+ // actively scrolling so those hover-reveals are suppressed until scrolling settles.
1798
+ listScrolling = true;
1799
+ clearTimeout(listScrollTimer);
1800
+ listScrollTimer = setTimeout(function () { listScrolling = false; }, 200);
1801
+ }
1586
1802
  }, { passive: false });
1587
1803
 
1588
1804
  // \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
@@ -1595,7 +1811,7 @@ function getClientScript(options) {
1595
1811
  setPillSync(s); // control-bar dot
1596
1812
  applyDotState(dot, s); // side-panel dot \u2014 same spinner/green/red
1597
1813
  if (s === 'syncing') { dotLabel.textContent = 'Syncing\u2026'; }
1598
- else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1814
+ else if (s === 'synced') { var open = specs.length - resolvedCount(); dotLabel.textContent = open > 0 ? (open + (open === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1599
1815
  else { dotLabel.textContent = 'Bridge offline'; }
1600
1816
  }
1601
1817
  function doSync() {
@@ -1642,11 +1858,28 @@ function getClientScript(options) {
1642
1858
  panelList.appendChild(empty);
1643
1859
  return;
1644
1860
  }
1861
+ // Summary of resolved Specs + a one-click sweep, pinned above the list.
1862
+ var rc = resolvedCount();
1863
+ if (rc > 0) {
1864
+ var doneBar = document.createElement('div');
1865
+ Object.assign(doneBar.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.06)' });
1866
+ var dlbl = document.createElement('span');
1867
+ dlbl.textContent = '\u2713 ' + rc + (rc === 1 ? ' done' : ' done');
1868
+ Object.assign(dlbl.style, { fontSize: '11px', color: GREEN, fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase' });
1869
+ var clr = document.createElement('span');
1870
+ clr.textContent = 'Clear done';
1871
+ Object.assign(clr.style, { fontSize: '11px', color: '#B9BBC2', cursor: 'pointer', textDecoration: 'underline' });
1872
+ clr.addEventListener('click', function (e) { e.stopPropagation(); clearResolved(); });
1873
+ hoverFx(clr, { color: '#fff' }, { color: '#B9BBC2' });
1874
+ doneBar.appendChild(dlbl); doneBar.appendChild(clr);
1875
+ panelList.appendChild(doneBar);
1876
+ }
1645
1877
  var focusEditor = null, measures = [];
1646
1878
  specs.forEach(function (spec, i) {
1647
1879
  var missing = !!spec.missing;
1648
1880
  var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
1649
1881
  var editing = (panelEditSpec === spec);
1882
+ var resolved = !!spec.resolved;
1650
1883
  var row = document.createElement('div');
1651
1884
  Object.assign(row.style, {
1652
1885
  position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
@@ -1657,7 +1890,7 @@ function getClientScript(options) {
1657
1890
  badge.textContent = String(i + 1);
1658
1891
  Object.assign(badge.style, {
1659
1892
  flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1660
- background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1893
+ background: resolved ? GREEN : BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1661
1894
  border: '1px solid #fff', boxSizing: 'border-box',
1662
1895
  display: 'flex', alignItems: 'center', justifyContent: 'center',
1663
1896
  transition: 'background 0.12s ease',
@@ -1729,6 +1962,7 @@ function getClientScript(options) {
1729
1962
  cursor: spec.note ? 'pointer' : 'default',
1730
1963
  });
1731
1964
  note.textContent = spec.note || (spec.kind === 'measure' ? '\u2B21 measurement' : '\u2014 no note \u2014');
1965
+ if (resolved) { note.style.textDecoration = 'line-through'; note.style.color = LABEL; note.style.cursor = spec.note ? 'pointer' : 'default'; }
1732
1966
 
1733
1967
  // Floated into the top-right corner so they don't reserve note width. On hover
1734
1968
  // they get a background that fades in from the left, masking the note text behind
@@ -1747,11 +1981,14 @@ function getClientScript(options) {
1747
1981
  // Show an expand toggle only when the collapsed note actually overflows.
1748
1982
  var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
1749
1983
  chev.firstChild.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
1984
+ var resolveBtn = mkIcon(CHECK, resolved ? 'Mark as not done' : 'Mark as done', resolved ? GREEN : '#9BE6B0', function () { toggleResolved(spec); }, { background: 'rgba(34,197,94,0.28)', color: '#fff' });
1750
1985
  var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
1751
1986
  var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
1752
- // Edit + delete reveal only on row hover; the expand chevron stays rightmost
1753
- // and fixed (edit/delete appear to its left, so it never shifts).
1754
- editBtn.style.display = delBtn.style.display = 'none';
1987
+ // \u2713 / edit / delete all reveal only on row hover (the expand chevron stays rightmost
1988
+ // and fixed). The done state reads from the green badge + strikethrough + "\u2713 DONE",
1989
+ // so the row needs no persistent icon \u2014 it looks like any other list item.
1990
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
1991
+ actions.appendChild(resolveBtn);
1755
1992
  actions.appendChild(editBtn);
1756
1993
  actions.appendChild(delBtn);
1757
1994
  actions.appendChild(chev);
@@ -1759,9 +1996,27 @@ function getClientScript(options) {
1759
1996
  content.appendChild(note);
1760
1997
  content.appendChild(meta);
1761
1998
 
1999
+ // Done: a green DONE tag + dimmed row. Supersedes HIDDEN/MISSING \u2014 once a Spec is
2000
+ // resolved you don't care whether its element is currently on screen.
2001
+ if (resolved) {
2002
+ row.style.opacity = '0.6';
2003
+ var rbar = document.createElement('div');
2004
+ Object.assign(rbar.style, { display: 'flex', alignItems: 'center', gap: '5px', marginTop: '2px', flexWrap: 'wrap' });
2005
+ var rcheck = document.createElement('span');
2006
+ rcheck.innerHTML = CHECK;
2007
+ Object.assign(rcheck.style, { display: 'inline-flex', alignItems: 'center', color: GREEN, flexShrink: '0' });
2008
+ if (rcheck.firstChild) { rcheck.firstChild.setAttribute('width', '12'); rcheck.firstChild.setAttribute('height', '12'); }
2009
+ var rtag = document.createElement('span');
2010
+ rtag.textContent = 'DONE';
2011
+ // Text-only status label (no fill/padding/radius) so it doesn't read as a clickable chip.
2012
+ Object.assign(rtag.style, { fontSize: '10px', fontWeight: '700', letterSpacing: '0.08em', textTransform: 'uppercase', color: GREEN, flexShrink: '0' });
2013
+ rbar.appendChild(rcheck);
2014
+ rbar.appendChild(rtag);
2015
+ content.appendChild(rbar);
2016
+ }
1762
2017
  // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1763
2018
  // never drawn on the page \u2014 design: show missing, don't guess a spot).
1764
- if (missing) {
2019
+ else if (missing) {
1765
2020
  row.style.opacity = '0.7';
1766
2021
  var mbar = document.createElement('div');
1767
2022
  Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
@@ -1798,15 +2053,15 @@ function getClientScript(options) {
1798
2053
  note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1799
2054
  row.addEventListener('mouseenter', function () {
1800
2055
  row.style.background = 'rgba(255,255,255,0.05)';
1801
- badge.style.background = PURPLE;
1802
- editBtn.style.display = delBtn.style.display = 'flex';
2056
+ if (!resolved) badge.style.background = PURPLE;
2057
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'flex';
1803
2058
  actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1804
- if (visible) { highlightSpec = spec; revealSpec(spec); }
2059
+ if (visible && !listScrolling) { highlightSpec = spec; revealSpec(spec); } // don't reveal on rows that merely slid under the cursor while scrolling
1805
2060
  });
1806
2061
  row.addEventListener('mouseleave', function () {
1807
2062
  row.style.background = 'transparent';
1808
- badge.style.background = BADGE_IDLE;
1809
- editBtn.style.display = delBtn.style.display = 'none';
2063
+ badge.style.background = resolved ? GREEN : BADGE_IDLE;
2064
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
1810
2065
  actions.style.background = 'transparent';
1811
2066
  if (highlightSpec === spec) highlightSpec = null;
1812
2067
  });
@@ -1833,8 +2088,8 @@ function getClientScript(options) {
1833
2088
  panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1834
2089
  }
1835
2090
 
1836
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1837
- function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
2091
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
2092
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
1838
2093
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1839
2094
 
1840
2095
  // \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
@@ -2234,6 +2489,41 @@ function getClientScript(options) {
2234
2489
  return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
2235
2490
  }
2236
2491
 
2492
+ // Capture the next chord the user presses and make it the new toggle. Runs on the
2493
+ // capture phase + stops propagation so a rebind keystroke (e.g. "L") can't leak into
2494
+ // Specter's own shortcuts. Requires at least one modifier so the toggle can't be a bare
2495
+ // key that fires while you type. onState(text, color) drives the prompt; onDone(committed).
2496
+ var rebinding = false;
2497
+ function startRebind(onState, onDone) {
2498
+ if (rebinding) return;
2499
+ rebinding = true;
2500
+ onState('Press a key combo\u2026', GREEN);
2501
+ function cap(e) {
2502
+ e.preventDefault(); e.stopPropagation();
2503
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
2504
+ var k = e.key;
2505
+ if (k === 'Escape') { finish(false); return; }
2506
+ if (k === 'Control' || k === 'Alt' || k === 'Shift' || k === 'Meta') return; // wait for the real key
2507
+ var parts = [];
2508
+ if (e.ctrlKey) parts.push('ctrl');
2509
+ if (e.altKey) parts.push('alt');
2510
+ if (e.shiftKey) parts.push('shift');
2511
+ if (e.metaKey) parts.push('meta');
2512
+ if (!parts.length) { onState('Hold Ctrl / Alt / \\u2318 too\u2026', '#F59E0B'); return; }
2513
+ var key = (e.key.length === 1 ? e.key : e.code.replace(/^Key/, '')).toLowerCase();
2514
+ if (!key) return;
2515
+ parts.push(key);
2516
+ setActivate(parts.join('+'));
2517
+ finish(true);
2518
+ }
2519
+ function finish(committed) {
2520
+ document.removeEventListener('keydown', cap, true);
2521
+ rebinding = false;
2522
+ onDone(committed);
2523
+ }
2524
+ document.addEventListener('keydown', cap, true);
2525
+ }
2526
+
2237
2527
  // \u2500\u2500\u2500 Mouse \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
2238
2528
  function onMouseMove(e) {
2239
2529
  if (!fiActive) return;
@@ -2415,7 +2705,21 @@ function getClientScript(options) {
2415
2705
  importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2416
2706
  scheduleSync(); // mirror restored Specs to the Claude bridge on load
2417
2707
 
2418
- console.log('%c\u{1F47B} Specter \u2014 Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
2708
+ // Dev-only console API \u2014 the escape hatch when the toggle chord collides with a browser
2709
+ // shortcut so you can't even open the overlay. Specter is stripped from prod, so this
2710
+ // global never ships. Vite devs can run these straight from DevTools.
2711
+ try {
2712
+ window.__specter = {
2713
+ toggle: function () { if (fiActive) deactivate(); else activate(); },
2714
+ activate: activate,
2715
+ deactivate: deactivate,
2716
+ get shortcut() { return ACTIVATE; },
2717
+ setShortcut: function (combo) { if (combo) { setActivate(String(combo).toLowerCase()); if (panelOpen) renderPanel(); console.log('%c\u{1F47B} Specter \u2014 toggle is now ' + shortcutLabel(ACTIVATE), 'color:#aaa'); } return ACTIVATE; },
2718
+ resetShortcut: function () { resetActivate(); if (panelOpen) renderPanel(); console.log('%c\u{1F47B} Specter \u2014 toggle reset to ' + shortcutLabel(ACTIVATE), 'color:#aaa'); return ACTIVATE; },
2719
+ };
2720
+ } catch (e) {}
2721
+
2722
+ console.log('%c\u{1F47B} Specter \u2014 ' + shortcutLabel(ACTIVATE) + ' to toggle \xB7 change it: window.__specter.setShortcut("ctrl+alt+p")', 'color:#aaa;font-size:11px;');
2419
2723
  })();`;
2420
2724
  }
2421
2725