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.js CHANGED
@@ -30,7 +30,17 @@ function getClientScript(options) {
30
30
  var CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
31
31
  var SHARE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><polyline points="16 6 12 2 8 6"></polyline><line x1="12" y1="2" x2="12" y2="15"></line></svg>';
32
32
  var IMPORT = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13"></path><polyline points="8 12 12 16 16 12"></polyline><path d="M4 21h16"></path></svg>';
33
- var ACTIVATE = ${JSON.stringify(activateShortcut)};
33
+ // Toggle chord: the Vite-config value (or the built-in default) is the baseline; a
34
+ // per-origin localStorage override \u2014 set from the panel's rebind UI or window.__specter \u2014
35
+ // wins, so anyone can change it with no config edit and no dev-server restart.
36
+ var ACTIVATE_DEFAULT = ${JSON.stringify(activateShortcut)};
37
+ var ACTIVATE_KEY = '__specter_activate';
38
+ var ACTIVATE = ACTIVATE_DEFAULT;
39
+ try { var _actOv = localStorage.getItem(ACTIVATE_KEY); if (_actOv) ACTIVATE = _actOv; } catch (e) {}
40
+ function shortcutLabel(s) { return String(s || '').split('+').map(function (p) { return p.charAt(0).toUpperCase() + p.slice(1); }).join('+'); }
41
+ function setActivate(combo) { ACTIVATE = combo; try { localStorage.setItem(ACTIVATE_KEY, combo); } catch (e) {} }
42
+ function resetActivate() { ACTIVATE = ACTIVATE_DEFAULT; try { localStorage.removeItem(ACTIVATE_KEY); } catch (e) {} }
43
+ function activateOverridden() { try { return !!localStorage.getItem(ACTIVATE_KEY); } catch (e) { return false; } }
34
44
  var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
35
45
 
36
46
  // \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
@@ -70,6 +80,45 @@ function getClientScript(options) {
70
80
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
71
81
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
72
82
 
83
+ // \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
84
+ // We inject at document_end, but frameworks that hydrate <body> (Next.js App
85
+ // Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
86
+ // didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
87
+ // vanish; badges added later survive because they're created post-hydration).
88
+ // Track our persistent singletons and re-attach any that gets detached, so the
89
+ // UI survives the hydration pass and later SPA re-renders. The parent is resolved
90
+ // fresh on each remount so a wholesale body/head element swap is handled too.
91
+ var _mounted = [];
92
+ function mount(node, where) {
93
+ var parent = where === 'head' ? document.head : document.body;
94
+ if (parent) parent.appendChild(node);
95
+ _mounted.push({ node: node, where: where });
96
+ return node;
97
+ }
98
+ var _remountQueued = false;
99
+ function remountDetached() {
100
+ _remountQueued = false;
101
+ for (var i = 0; i < _mounted.length; i++) {
102
+ var m = _mounted[i];
103
+ if (m.node.isConnected) continue;
104
+ var p = m.where === 'head' ? document.head : document.body;
105
+ if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
106
+ }
107
+ }
108
+ try {
109
+ var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
110
+ var _mo = new MutationObserver(function () {
111
+ if (_remountQueued) return;
112
+ _remountQueued = true;
113
+ _defer(remountDetached); // coalesce a burst of mutations into one restore pass
114
+ });
115
+ // childList on the roots is enough \u2014 every singleton is a direct child of
116
+ // body/head; watching documentElement too catches a body/head element swap.
117
+ _mo.observe(document.documentElement, { childList: true });
118
+ _mo.observe(document.body, { childList: true });
119
+ _mo.observe(document.head, { childList: true });
120
+ } catch (e) {}
121
+
73
122
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
74
123
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
75
124
  function hoverFx(el, on, off) {
@@ -96,7 +145,7 @@ function getClientScript(options) {
96
145
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
97
146
  display: 'none',
98
147
  });
99
- document.body.appendChild(tooltip);
148
+ mount(tooltip);
100
149
 
101
150
  // Measure overlay
102
151
  var measureOverlay = document.createElement('div');
@@ -108,7 +157,7 @@ function getClientScript(options) {
108
157
  pointerEvents: 'none',
109
158
  display: 'none',
110
159
  });
111
- document.body.appendChild(measureOverlay);
160
+ mount(measureOverlay);
112
161
 
113
162
  // \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
114
163
  var pillWrap = document.createElement('div');
@@ -184,41 +233,34 @@ function getClientScript(options) {
184
233
  var pillText = document.createElement('span');
185
234
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
186
235
 
236
+ // Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
237
+ // (Open/Close) and always names the shortcut.
187
238
  var listBtn = document.createElement('span');
188
- listBtn.innerHTML = LIST;
189
- listBtn.title = 'Show Specs panel (L)';
239
+ listBtn.title = 'Toggle Specs panel (L)';
190
240
  Object.assign(listBtn.style, {
191
241
  display: 'none',
192
242
  alignItems: 'center',
243
+ gap: '6px',
193
244
  cursor: 'pointer',
194
245
  color: '#fff',
246
+ fontSize: '11px',
195
247
  flexShrink: '0',
196
- padding: '4px 6px',
248
+ padding: '4px 10px',
197
249
  marginLeft: '2px',
198
250
  borderRadius: '999px',
199
251
  background: 'rgba(255,255,255,0.16)',
200
252
  });
253
+ var listIcon = document.createElement('span');
254
+ listIcon.innerHTML = LIST;
255
+ Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
256
+ var listLabel = document.createElement('span');
257
+ listBtn.appendChild(listIcon);
258
+ listBtn.appendChild(listLabel);
259
+ function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
260
+ updateListBtn();
201
261
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
202
262
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
203
263
 
204
- var clearBtn = document.createElement('span');
205
- clearBtn.textContent = '\u2715 Delete all';
206
- clearBtn.title = 'Delete all annotations';
207
- Object.assign(clearBtn.style, {
208
- display: 'none',
209
- cursor: 'pointer',
210
- color: '#fff',
211
- fontSize: '11px',
212
- fontWeight: '600',
213
- flexShrink: '0',
214
- padding: '2px 8px',
215
- marginLeft: '2px',
216
- borderRadius: '999px',
217
- background: 'rgba(255,255,255,0.16)',
218
- });
219
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
220
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
221
-
222
264
  var chevron = document.createElement('span');
223
265
  chevron.textContent = '\u203A';
224
266
  chevron.title = 'Move to other side';
@@ -248,16 +290,15 @@ function getClientScript(options) {
248
290
  pill.appendChild(pillSync);
249
291
  pill.appendChild(pillText);
250
292
  pill.appendChild(listBtn);
251
- pill.appendChild(clearBtn);
252
293
  pill.appendChild(chevron);
253
294
  pillWrap.appendChild(pill);
254
- document.body.appendChild(pillWrap);
295
+ mount(pillWrap);
255
296
 
256
297
  // Keyframes for the sync spinner (injected once).
257
298
  var spinStyle = document.createElement('style');
258
299
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
259
300
  markUI(spinStyle);
260
- document.head.appendChild(spinStyle);
301
+ mount(spinStyle, 'head');
261
302
 
262
303
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
263
304
  // always match: spinner while syncing, green when synced, red on error.
@@ -309,7 +350,7 @@ function getClientScript(options) {
309
350
  // The mode is shown at all times (persistent prefix), so you always know whether
310
351
  // hovering shows properties, measurements, or nothing (Comment).
311
352
  function modeLabel() {
312
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
353
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
313
354
  }
314
355
 
315
356
  function expandPill(text) {
@@ -317,7 +358,7 @@ function getClientScript(options) {
317
358
  pillText.style.display = 'inline';
318
359
  chevron.style.display = 'inline';
319
360
  listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
320
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
361
+ updateListBtn();
321
362
  pillExpanded = true;
322
363
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
323
364
  // side panel now, so the pill stays short.
@@ -332,14 +373,12 @@ function getClientScript(options) {
332
373
  pill.style.maxWidth = '220px';
333
374
  chevron.style.display = 'none';
334
375
  listBtn.style.display = 'none';
335
- clearBtn.style.display = 'none';
336
376
  pillExpanded = false;
337
377
  }
338
378
 
339
379
  function flashMode() {
340
380
  if (pillExpanded && pillWrap.matches(':hover')) return;
341
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
342
- expandPill(text);
381
+ expandPill(modeLabel());
343
382
  clearTimeout(flashTimer);
344
383
  flashTimer = setTimeout(function () {
345
384
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -465,10 +504,13 @@ function getClientScript(options) {
465
504
  var cur = el;
466
505
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
467
506
  var part = cur.tagName.toLowerCase();
468
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
507
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
469
508
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
470
509
  if (cls.length) {
471
- part += '.' + cls.slice(0, 2).join('.');
510
+ // Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
511
+ // invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
512
+ // throw on query, so they must be CSS.escape-d first.
513
+ part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
472
514
  } else if (cur.parentElement) {
473
515
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
474
516
  if (sameTag.length > 1) {
@@ -548,13 +590,13 @@ function getClientScript(options) {
548
590
  var i, v;
549
591
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
550
592
  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; } }
551
- if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
593
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
552
594
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
553
595
  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) + '"'; } }
554
596
  var txt = ownText(el);
555
597
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
556
598
  var cls = ownClass(el);
557
- if (cls) return '.' + cls;
599
+ if (cls) return '.' + cssEsc(cls);
558
600
  return getSelector(el); // fallback: the CSS path
559
601
  }
560
602
 
@@ -656,6 +698,7 @@ function getClientScript(options) {
656
698
  var groups = [];
657
699
  for (var i = 0; i < specs.length; i++) {
658
700
  var s = specs[i], g = null;
701
+ 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
659
702
  if (s.kind === 'element' && s.el) {
660
703
  for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
661
704
  }
@@ -728,11 +771,20 @@ function getClientScript(options) {
728
771
  function elementPath(el) {
729
772
  if (!el || el.nodeType !== 1) return '';
730
773
  var parts = [], cur = el;
731
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
732
- if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
774
+ // Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
775
+ // nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
776
+ // child index (the old div:nth-child(11) bug \u2014 matched a different node, or
777
+ // none, on the recipient). nth-of-type counts only same-tag siblings, so it
778
+ // survives that. Deeper cap (12) so the chain reaches a rooting anchor.
779
+ while (cur && cur.nodeType === 1 && parts.length < 12) {
780
+ if (cur === document.body) { parts.unshift('body'); break; }
781
+ if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
733
782
  var seg = cur.tagName.toLowerCase();
734
783
  var parent = cur.parentElement;
735
- if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
784
+ if (parent) {
785
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
786
+ if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
787
+ }
736
788
  parts.unshift(seg);
737
789
  cur = cur.parentElement;
738
790
  }
@@ -808,6 +860,11 @@ function getClientScript(options) {
808
860
  var vis = [];
809
861
  for (var i = 0; i < specs.length; i++) {
810
862
  var s = specs[i];
863
+ // Badges created at import-time can be wiped by a framework hydrating <body>
864
+ // (same cause as the singleton mount guard). Re-attach a detached wrap so the
865
+ // pin reappears; a removed spec is already out of the specs array, so this
866
+ // never resurrects a deleted badge.
867
+ if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
811
868
  if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
812
869
  if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
813
870
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
@@ -882,10 +939,17 @@ function getClientScript(options) {
882
939
  function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
883
940
  function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
884
941
 
885
- function renumber() { for (var i = 0; i < specs.length; i++) specs[i].num.textContent = String(i + 1); }
942
+ // Paint a badge's number/\u2713 + done tint. Called by renumber and updateBadgeContent so
943
+ // the on-page pin reflects resolved state everywhere (reflowSpecs never touches these).
944
+ function paintBadgeState(spec, i) {
945
+ spec.num.textContent = String(i + 1); // always the number (for wayfinding) \u2014 green tint carries the "done" signal
946
+ if (spec.cap) { spec.cap.style.background = spec.resolved ? GREEN : PURPLE; spec.cap.style.opacity = spec.resolved ? '0.65' : '1'; }
947
+ }
948
+
949
+ function renumber() { for (var i = 0; i < specs.length; i++) paintBadgeState(specs[i], i); }
886
950
 
887
951
  function updateBadgeContent(spec) {
888
- spec.num.textContent = String(specs.indexOf(spec) + 1);
952
+ paintBadgeState(spec, specs.indexOf(spec));
889
953
  if (spec.note) spec.noteSpan.textContent = spec.note;
890
954
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
891
955
  }
@@ -1007,13 +1071,50 @@ function getClientScript(options) {
1007
1071
  saveSpecs();
1008
1072
  }
1009
1073
 
1074
+ // \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
1075
+ // A resolved Spec is NOT deleted \u2014 it stays as a record of what changed, greyed with
1076
+ // a \u2713, and drops out of the copy + bridge sync (see groupSpecs) so it's never re-applied.
1077
+ function resolvedCount() { var n = 0; for (var i = 0; i < specs.length; i++) if (specs[i].resolved) n++; return n; }
1078
+
1079
+ function toggleResolved(spec) {
1080
+ spec.resolved = !spec.resolved;
1081
+ if (spec.resolved && panelEditSpec === spec) panelEditSpec = null; // close an open editor on the one being marked done
1082
+ updateBadgeContent(spec);
1083
+ updatePill(); // re-renders the panel when open
1084
+ saveSpecs(); // persists resolved + re-syncs the bridge (now excluding it)
1085
+ }
1086
+
1087
+ // Sweep every resolved Spec once the user is satisfied \u2014 leaves open Specs untouched.
1088
+ function clearResolved() {
1089
+ for (var i = specs.length - 1; i >= 0; i--) {
1090
+ if (!specs[i].resolved) continue;
1091
+ if (highlightSpec === specs[i]) highlightSpec = null;
1092
+ if (panelEditSpec === specs[i]) panelEditSpec = null;
1093
+ if (specs[i].wrap) specs[i].wrap.remove();
1094
+ specs.splice(i, 1);
1095
+ }
1096
+ renumber();
1097
+ updatePill();
1098
+ saveSpecs();
1099
+ }
1100
+
1101
+ // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1102
+ // importComments so a received share replaces the prior set instead of stacking.
1103
+ function removeSharedSpecs() {
1104
+ if (highlightSpec && highlightSpec.shared) highlightSpec = null;
1105
+ if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
1106
+ for (var i = specs.length - 1; i >= 0; i--) {
1107
+ if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
1108
+ }
1109
+ }
1110
+
1010
1111
  // \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
1011
1112
  // Persist only the serializable parts of each Spec; the live element ref and
1012
1113
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
1013
1114
  function saveSpecs() {
1014
1115
  try {
1015
1116
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
1016
- 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 }; })));
1117
+ 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 }; })));
1017
1118
  } catch (e) {}
1018
1119
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1019
1120
  }
@@ -1023,7 +1124,7 @@ function getClientScript(options) {
1023
1124
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1024
1125
  if (!Array.isArray(data) || !data.length) return;
1025
1126
  data.forEach(function (d) {
1026
- 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 || '' };
1127
+ 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 };
1027
1128
  specs.push(spec);
1028
1129
  createBadge(spec);
1029
1130
  });
@@ -1038,11 +1139,13 @@ function getClientScript(options) {
1038
1139
  // element is gone OR its signature changed, the comment shows as MISSING (panel
1039
1140
  // only, greyed, with a reason) instead of being drawn on a guessed spot.
1040
1141
 
1041
- // Normalized element signature for change-detection: stable structural identity,
1042
- // NOT raw outerHTML (which false-trips on any text/attr churn). tag + sorted own
1043
- // classes + a few structural attrs + a short trimmed-text slice. The text slice is
1044
- // the strictness knob \u2014 include it to catch content edits, at the cost of a
1045
- // false-MISSING when dynamic text (a price/timestamp) changes under a stable node.
1142
+ // Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
1143
+ // element a shared comment belongs to (and to scan for it when selectors fail).
1144
+ // Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
1145
+ // price) was false-tripping this and blocking re-anchor on otherwise-identical
1146
+ // pages. tag + sorted own classes + key attrs + child count identifies the node
1147
+ // without that fragility. (Trade-off: pure text edits under a stable node no
1148
+ // longer read as "changed" \u2014 placement is favoured over change-detection.)
1046
1149
  function normSig(el) {
1047
1150
  if (!el || el.nodeType !== 1) return '';
1048
1151
  var cls = Array.prototype.slice.call(el.classList)
@@ -1050,8 +1153,7 @@ function getClientScript(options) {
1050
1153
  var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1051
1154
  var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1052
1155
  }).filter(Boolean).join('|');
1053
- var txt = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
1054
- return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
1156
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1055
1157
  }
1056
1158
  // djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
1057
1159
  // means "unchanged enough", and the re-find already narrowed us to one element).
@@ -1063,51 +1165,91 @@ function getClientScript(options) {
1063
1165
  return (h >>> 0).toString(36);
1064
1166
  }
1065
1167
 
1066
- // Scan for an element whose OWN text matches (trunc = the stored anchor was cut to
1067
- // 40 chars). Ambiguous (>1 match) \u2192 null, so we fall through to the nth-child path
1068
- // rather than guess. Skips Specter's own UI.
1069
- function findByOwnText(val, trunc) {
1070
- var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1168
+ function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
1169
+
1170
+ // Every element whose OWN text matches (trunc = the stored anchor was cut to 40
1171
+ // chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
1172
+ // so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
1173
+ function findAllByOwnText(val, trunc) {
1174
+ var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
1071
1175
  for (var i = 0; i < all.length; i++) {
1072
1176
  var el = all[i];
1073
1177
  if (el.closest && el.closest('[data-specter-ui]')) continue;
1074
1178
  var t = ownText(el);
1075
1179
  if (!t) continue;
1076
- if (trunc ? (t.slice(0, 40) === val) : (t === val)) { if (hit) return null; hit = el; }
1180
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1077
1181
  }
1078
- return hit;
1182
+ return out;
1079
1183
  }
1080
1184
 
1081
- // Real resolver for a resolveLocator() anchor: #id/.class/[attr] query straight;
1082
- // 'attr "value"' rebuilds the attribute selector; 'text "value"' scans own-text.
1083
- // Only trusts an anchor that resolves to EXACTLY ONE element \u2014 a class or CSS path
1084
- // shared by siblings (e.g. three .card boxes) would otherwise silently match the
1085
- // first one, mis-anchoring every comment onto it.
1086
- function resolveFind(find) {
1087
- if (!find) return null;
1185
+ // Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
1186
+ // querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
1187
+ // scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
1188
+ // fingerprint rather than rejecting anything ambiguous up front.
1189
+ function resolveFindCandidates(find) {
1190
+ if (!find) return [];
1088
1191
  var c = find.charAt(0);
1089
- if (c === '#' || c === '.' || c === '[') return uniqueSel(find) ? safeQuery(find) : null;
1192
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1090
1193
  var q = find.indexOf(' "');
1091
1194
  if (q > 0 && find.charAt(find.length - 1) === '"') {
1092
1195
  var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1093
1196
  if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
1094
- if (attr === 'text') return findByOwnText(val, trunc);
1095
- if (val.indexOf('"') < 0) { var sel = '[' + attr + '="' + val + '"]'; return uniqueSel(sel) ? safeQuery(sel) : null; }
1096
- return null;
1197
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1198
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1199
+ return [];
1200
+ }
1201
+ return queryAll(find); // a bare CSS path
1202
+ }
1203
+
1204
+ // Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
1205
+ // just a change-detector: gather every candidate from the find anchor AND the
1206
+ // nth-child path, then return the one whose signature matches \u2014 so a locator
1207
+ // shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
1208
+ // instead of failing as ambiguous. With no fp match: hand back a lone candidate
1209
+ // (so importComments can say "changed"), else null ("couldn't find").
1210
+ // Whole-DOM scan for the one element whose structural signature matches \u2014 the
1211
+ // last-resort re-finder when both the find anchor and the nth-of-type path fail
1212
+ // (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
1213
+ // (ambiguous, don't guess). Skips Specter's own UI.
1214
+ function findByFingerprint(fp) {
1215
+ if (!fp) return null;
1216
+ var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1217
+ for (var i = 0; i < all.length; i++) {
1218
+ var el = all[i];
1219
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1220
+ if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
1097
1221
  }
1098
- return uniqueSel(find) ? safeQuery(find) : null; // a bare CSS path \u2014 only if unambiguous
1222
+ return hit;
1099
1223
  }
1100
1224
 
1101
- // Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
1102
- // only to detect change): prefer whichever candidate \u2014 the find anchor or the
1103
- // nth-child path \u2014 actually matches the stored signature. Falls back to a
1104
- // best-effort element so importComments can still tell "changed" from "not found".
1225
+ function fpOk(el, item) { return !item.fp || fingerprint(el) === item.fp; }
1226
+
1227
+ // Rank the signals rather than pooling them, so a specific locator always beats a
1228
+ // vague one:
1229
+ // 1. a find anchor that resolves to exactly ONE element (id / unique text / class)
1230
+ // \u2014 the strongest, most semantic signal;
1231
+ // 2. the rooted nth-of-type path, if unique \u2014 this is what disambiguates repeated
1232
+ // structure (a class-path find matches every paragraph; the path pins the exact
1233
+ // one). Pooling let an ambiguous find shadow the unique path \u2014 the mis-anchor bug;
1234
+ // 3. the structural fingerprint across the pooled candidates, then a DOM-wide scan;
1235
+ // 4. any lone resolution.
1236
+ // fp guards 1 and 2 so a drifted anchor can't win blindly, but a unique anchor with a
1237
+ // changed signature is still trusted at step 4 (placement over false-MISSING).
1105
1238
  function reFindShared(item) {
1106
- var byFind = resolveFind(item.find);
1107
- if (byFind && (!item.fp || fingerprint(byFind) === item.fp)) return byFind;
1108
- var byPath = safeQuery(item.path);
1109
- if (byPath && (!item.fp || fingerprint(byPath) === item.fp)) return byPath;
1110
- return byFind || byPath || null;
1239
+ var byFind = resolveFindCandidates(item.find);
1240
+ if (byFind.length === 1 && fpOk(byFind[0], item)) return byFind[0];
1241
+ var byPath = item.path ? queryAll(item.path) : [];
1242
+ if (byPath.length === 1 && fpOk(byPath[0], item)) return byPath[0];
1243
+ var cands = [], i, all = byFind.concat(byPath);
1244
+ for (i = 0; i < all.length; i++) if (all[i] && cands.indexOf(all[i]) < 0) cands.push(all[i]);
1245
+ if (item.fp) {
1246
+ for (i = 0; i < cands.length; i++) if (fingerprint(cands[i]) === item.fp) return cands[i];
1247
+ var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
1248
+ if (scan) return scan;
1249
+ }
1250
+ if (byFind.length === 1) return byFind[0]; // unique anchor, fp drifted \u2014 trust the anchor
1251
+ if (byPath.length === 1) return byPath[0];
1252
+ return cands.length === 1 ? cands[0] : null;
1111
1253
  }
1112
1254
 
1113
1255
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1138,12 +1280,19 @@ function getClientScript(options) {
1138
1280
  console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
1139
1281
  return 0;
1140
1282
  }
1283
+ // A received share REPLACES the previously-imported set: re-opening the same
1284
+ // link (or a refreshed one) gives exactly that set, never a stacked-up pile of
1285
+ // duplicates across rounds. Your OWN local specs (shared:false) are untouched.
1286
+ removeSharedSpecs();
1141
1287
  var added = 0;
1142
1288
  comments.forEach(function (item) {
1143
1289
  if (!item) return;
1290
+ // reFindShared already used the fingerprint to pick/scan the right element, so
1291
+ // trust its result: if it located a node, PLACE the comment. Only a genuine
1292
+ // no-match is MISSING \u2014 we no longer hide a found element just because its
1293
+ // signature drifted (that over-fired on dynamic pages and buried real pins).
1144
1294
  var el = reFindShared(item), missing = false, reason = '';
1145
1295
  if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
1146
- else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
1147
1296
  // body stays empty on purpose: comments-only, never the shared element's props.
1148
1297
  // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
1149
1298
  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 };
@@ -1504,7 +1653,7 @@ function getClientScript(options) {
1504
1653
  var rows = [
1505
1654
  ['P', 'mark / comment the hovered element'],
1506
1655
  ['C', 'toggle Comment mode (hide properties)'],
1507
- ['\\u2325 Option', 'toggle Measure mode'],
1656
+ ['\\u2325 Option / Alt', 'toggle Measure mode'],
1508
1657
  ['M', 'pin an element to measure from'],
1509
1658
  ['Cmd/Ctrl+C', 'copy all Specs'],
1510
1659
  ['L', 'toggle this panel'],
@@ -1520,9 +1669,48 @@ function getClientScript(options) {
1520
1669
  head.appendChild(headText);
1521
1670
  var body = document.createElement('div');
1522
1671
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1672
+
1673
+ // Rebindable toggle shortcut \u2014 the one chord you might have to change if it collides
1674
+ // with a browser/extension binding. Click Change, press a new combo. Saved per origin,
1675
+ // no restart. Works identically in the Vite plugin overlay and the browser extension.
1676
+ (function () {
1677
+ var rrow = document.createElement('div');
1678
+ Object.assign(rrow.style, { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px', paddingBottom: '10px', borderBottom: '1px solid rgba(255,255,255,0.06)' });
1679
+ var chip = document.createElement('span');
1680
+ Object.assign(chip.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1681
+ var lbl = document.createElement('span');
1682
+ Object.assign(lbl.style, { flex: '1', minWidth: '0' });
1683
+ lbl.textContent = 'toggle Specter on/off';
1684
+ var change = document.createElement('span');
1685
+ Object.assign(change.style, { color: '#8AB4F8', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline' });
1686
+ change.textContent = 'Change';
1687
+ var reset = document.createElement('span');
1688
+ Object.assign(reset.style, { color: '#B9BBC2', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline', display: 'none' });
1689
+ reset.textContent = 'Reset';
1690
+ function refresh() {
1691
+ chip.textContent = shortcutLabel(ACTIVATE);
1692
+ chip.style.color = '#E0A3F5';
1693
+ change.style.display = 'inline';
1694
+ reset.style.display = activateOverridden() ? 'inline' : 'none';
1695
+ }
1696
+ change.addEventListener('click', function (e) {
1697
+ e.stopPropagation();
1698
+ change.style.display = 'none';
1699
+ reset.style.display = 'none';
1700
+ startRebind(
1701
+ function (text, color) { chip.textContent = text; chip.style.color = color; },
1702
+ function () { refresh(); }
1703
+ );
1704
+ });
1705
+ reset.addEventListener('click', function (e) { e.stopPropagation(); resetActivate(); refresh(); });
1706
+ refresh();
1707
+ rrow.appendChild(chip); rrow.appendChild(lbl); rrow.appendChild(change); rrow.appendChild(reset);
1708
+ body.appendChild(rrow);
1709
+ })();
1710
+
1523
1711
  rows.forEach(function (r) {
1524
1712
  var row = document.createElement('div');
1525
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1713
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1526
1714
  var k = document.createElement('span');
1527
1715
  k.textContent = r[0];
1528
1716
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1542,20 +1730,48 @@ function getClientScript(options) {
1542
1730
  panelKeys.appendChild(body);
1543
1731
  })();
1544
1732
 
1733
+ // Footer \u2014 a quiet link out to the GitHub README so first-timers can find the docs.
1734
+ var panelFoot = document.createElement('div');
1735
+ Object.assign(panelFoot.style, {
1736
+ flexShrink: '0', padding: '10px 16px', borderTop: '1px solid rgba(255,255,255,0.08)',
1737
+ display: 'flex', alignItems: 'center',
1738
+ });
1739
+ var docsLink = document.createElement('a');
1740
+ docsLink.href = 'https://github.com/setugk/vite-plugin-specter#readme';
1741
+ docsLink.target = '_blank';
1742
+ docsLink.rel = 'noopener noreferrer';
1743
+ docsLink.textContent = 'Documentation \\u2197'; // \u2197
1744
+ Object.assign(docsLink.style, { fontSize: '11px', fontWeight: '600', color: '#8AB4F8', textDecoration: 'none', cursor: 'pointer' });
1745
+ hoverFx(docsLink, { color: '#fff' }, { color: '#8AB4F8' });
1746
+ docsLink.addEventListener('click', function (e) { e.stopPropagation(); });
1747
+ panelFoot.appendChild(docsLink);
1748
+
1545
1749
  panelWrap.appendChild(panelHead);
1546
1750
  panelWrap.appendChild(panelTools);
1547
1751
  panelWrap.appendChild(panelHint);
1548
1752
  panelWrap.appendChild(panelList);
1549
1753
  panelWrap.appendChild(panelKeys);
1550
- document.body.appendChild(panelWrap);
1551
-
1552
- // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1553
- // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1554
- // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1754
+ panelWrap.appendChild(panelFoot);
1755
+ mount(panelWrap);
1756
+
1757
+ // A wheel anywhere over the panel must NEVER scroll the page behind it. Relying on
1758
+ // overscroll-behavior alone still lets trackpad momentum chain into the page at the
1759
+ // list bounds, so we take over: always swallow the event, and drive the list's own
1760
+ // scroll ourselves. (The inline note editor auto-grows instead of scrolling, so the
1761
+ // list is the only scrollable region inside the panel \u2014 nothing else needs the wheel.)
1762
+ var listScrolling = false, listScrollTimer = null;
1555
1763
  panelWrap.addEventListener('wheel', function (e) {
1556
- var canScroll = panelList.scrollHeight > panelList.clientHeight;
1557
- if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1558
1764
  e.preventDefault();
1765
+ if (panelList.scrollHeight > panelList.clientHeight && panelList.contains(e.target)) {
1766
+ var dy = e.deltaMode === 1 ? e.deltaY * 16 : (e.deltaMode === 2 ? e.deltaY * panelList.clientHeight : e.deltaY);
1767
+ panelList.scrollTop += dy;
1768
+ // Rows slide under a stationary cursor as the list scrolls, firing mouseenter \u2192
1769
+ // revealSpec \u2192 scrollIntoView, which would drag the PAGE around. Mark the list as
1770
+ // actively scrolling so those hover-reveals are suppressed until scrolling settles.
1771
+ listScrolling = true;
1772
+ clearTimeout(listScrollTimer);
1773
+ listScrollTimer = setTimeout(function () { listScrolling = false; }, 200);
1774
+ }
1559
1775
  }, { passive: false });
1560
1776
 
1561
1777
  // \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
@@ -1568,7 +1784,7 @@ function getClientScript(options) {
1568
1784
  setPillSync(s); // control-bar dot
1569
1785
  applyDotState(dot, s); // side-panel dot \u2014 same spinner/green/red
1570
1786
  if (s === 'syncing') { dotLabel.textContent = 'Syncing\u2026'; }
1571
- else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1787
+ else if (s === 'synced') { var open = specs.length - resolvedCount(); dotLabel.textContent = open > 0 ? (open + (open === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1572
1788
  else { dotLabel.textContent = 'Bridge offline'; }
1573
1789
  }
1574
1790
  function doSync() {
@@ -1615,11 +1831,28 @@ function getClientScript(options) {
1615
1831
  panelList.appendChild(empty);
1616
1832
  return;
1617
1833
  }
1834
+ // Summary of resolved Specs + a one-click sweep, pinned above the list.
1835
+ var rc = resolvedCount();
1836
+ if (rc > 0) {
1837
+ var doneBar = document.createElement('div');
1838
+ 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)' });
1839
+ var dlbl = document.createElement('span');
1840
+ dlbl.textContent = '\u2713 ' + rc + (rc === 1 ? ' done' : ' done');
1841
+ Object.assign(dlbl.style, { fontSize: '11px', color: GREEN, fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase' });
1842
+ var clr = document.createElement('span');
1843
+ clr.textContent = 'Clear done';
1844
+ Object.assign(clr.style, { fontSize: '11px', color: '#B9BBC2', cursor: 'pointer', textDecoration: 'underline' });
1845
+ clr.addEventListener('click', function (e) { e.stopPropagation(); clearResolved(); });
1846
+ hoverFx(clr, { color: '#fff' }, { color: '#B9BBC2' });
1847
+ doneBar.appendChild(dlbl); doneBar.appendChild(clr);
1848
+ panelList.appendChild(doneBar);
1849
+ }
1618
1850
  var focusEditor = null, measures = [];
1619
1851
  specs.forEach(function (spec, i) {
1620
1852
  var missing = !!spec.missing;
1621
1853
  var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
1622
1854
  var editing = (panelEditSpec === spec);
1855
+ var resolved = !!spec.resolved;
1623
1856
  var row = document.createElement('div');
1624
1857
  Object.assign(row.style, {
1625
1858
  position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
@@ -1630,7 +1863,7 @@ function getClientScript(options) {
1630
1863
  badge.textContent = String(i + 1);
1631
1864
  Object.assign(badge.style, {
1632
1865
  flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1633
- background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1866
+ background: resolved ? GREEN : BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1634
1867
  border: '1px solid #fff', boxSizing: 'border-box',
1635
1868
  display: 'flex', alignItems: 'center', justifyContent: 'center',
1636
1869
  transition: 'background 0.12s ease',
@@ -1702,6 +1935,7 @@ function getClientScript(options) {
1702
1935
  cursor: spec.note ? 'pointer' : 'default',
1703
1936
  });
1704
1937
  note.textContent = spec.note || (spec.kind === 'measure' ? '\u2B21 measurement' : '\u2014 no note \u2014');
1938
+ if (resolved) { note.style.textDecoration = 'line-through'; note.style.color = LABEL; note.style.cursor = spec.note ? 'pointer' : 'default'; }
1705
1939
 
1706
1940
  // Floated into the top-right corner so they don't reserve note width. On hover
1707
1941
  // they get a background that fades in from the left, masking the note text behind
@@ -1720,11 +1954,14 @@ function getClientScript(options) {
1720
1954
  // Show an expand toggle only when the collapsed note actually overflows.
1721
1955
  var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
1722
1956
  chev.firstChild.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
1957
+ 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' });
1723
1958
  var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
1724
1959
  var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
1725
- // Edit + delete reveal only on row hover; the expand chevron stays rightmost
1726
- // and fixed (edit/delete appear to its left, so it never shifts).
1727
- editBtn.style.display = delBtn.style.display = 'none';
1960
+ // \u2713 / edit / delete all reveal only on row hover (the expand chevron stays rightmost
1961
+ // and fixed). The done state reads from the green badge + strikethrough + "\u2713 DONE",
1962
+ // so the row needs no persistent icon \u2014 it looks like any other list item.
1963
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
1964
+ actions.appendChild(resolveBtn);
1728
1965
  actions.appendChild(editBtn);
1729
1966
  actions.appendChild(delBtn);
1730
1967
  actions.appendChild(chev);
@@ -1732,9 +1969,27 @@ function getClientScript(options) {
1732
1969
  content.appendChild(note);
1733
1970
  content.appendChild(meta);
1734
1971
 
1972
+ // Done: a green DONE tag + dimmed row. Supersedes HIDDEN/MISSING \u2014 once a Spec is
1973
+ // resolved you don't care whether its element is currently on screen.
1974
+ if (resolved) {
1975
+ row.style.opacity = '0.6';
1976
+ var rbar = document.createElement('div');
1977
+ Object.assign(rbar.style, { display: 'flex', alignItems: 'center', gap: '5px', marginTop: '2px', flexWrap: 'wrap' });
1978
+ var rcheck = document.createElement('span');
1979
+ rcheck.innerHTML = CHECK;
1980
+ Object.assign(rcheck.style, { display: 'inline-flex', alignItems: 'center', color: GREEN, flexShrink: '0' });
1981
+ if (rcheck.firstChild) { rcheck.firstChild.setAttribute('width', '12'); rcheck.firstChild.setAttribute('height', '12'); }
1982
+ var rtag = document.createElement('span');
1983
+ rtag.textContent = 'DONE';
1984
+ // Text-only status label (no fill/padding/radius) so it doesn't read as a clickable chip.
1985
+ Object.assign(rtag.style, { fontSize: '10px', fontWeight: '700', letterSpacing: '0.08em', textTransform: 'uppercase', color: GREEN, flexShrink: '0' });
1986
+ rbar.appendChild(rcheck);
1987
+ rbar.appendChild(rtag);
1988
+ content.appendChild(rbar);
1989
+ }
1735
1990
  // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1736
1991
  // never drawn on the page \u2014 design: show missing, don't guess a spot).
1737
- if (missing) {
1992
+ else if (missing) {
1738
1993
  row.style.opacity = '0.7';
1739
1994
  var mbar = document.createElement('div');
1740
1995
  Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
@@ -1771,15 +2026,15 @@ function getClientScript(options) {
1771
2026
  note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1772
2027
  row.addEventListener('mouseenter', function () {
1773
2028
  row.style.background = 'rgba(255,255,255,0.05)';
1774
- badge.style.background = PURPLE;
1775
- editBtn.style.display = delBtn.style.display = 'flex';
2029
+ if (!resolved) badge.style.background = PURPLE;
2030
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'flex';
1776
2031
  actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1777
- if (visible) { highlightSpec = spec; revealSpec(spec); }
2032
+ if (visible && !listScrolling) { highlightSpec = spec; revealSpec(spec); } // don't reveal on rows that merely slid under the cursor while scrolling
1778
2033
  });
1779
2034
  row.addEventListener('mouseleave', function () {
1780
2035
  row.style.background = 'transparent';
1781
- badge.style.background = BADGE_IDLE;
1782
- editBtn.style.display = delBtn.style.display = 'none';
2036
+ badge.style.background = resolved ? GREEN : BADGE_IDLE;
2037
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
1783
2038
  actions.style.background = 'transparent';
1784
2039
  if (highlightSpec === spec) highlightSpec = null;
1785
2040
  });
@@ -1806,8 +2061,8 @@ function getClientScript(options) {
1806
2061
  panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1807
2062
  }
1808
2063
 
1809
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1810
- function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
2064
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
2065
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
1811
2066
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1812
2067
 
1813
2068
  // \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
@@ -2207,6 +2462,41 @@ function getClientScript(options) {
2207
2462
  return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
2208
2463
  }
2209
2464
 
2465
+ // Capture the next chord the user presses and make it the new toggle. Runs on the
2466
+ // capture phase + stops propagation so a rebind keystroke (e.g. "L") can't leak into
2467
+ // Specter's own shortcuts. Requires at least one modifier so the toggle can't be a bare
2468
+ // key that fires while you type. onState(text, color) drives the prompt; onDone(committed).
2469
+ var rebinding = false;
2470
+ function startRebind(onState, onDone) {
2471
+ if (rebinding) return;
2472
+ rebinding = true;
2473
+ onState('Press a key combo\u2026', GREEN);
2474
+ function cap(e) {
2475
+ e.preventDefault(); e.stopPropagation();
2476
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
2477
+ var k = e.key;
2478
+ if (k === 'Escape') { finish(false); return; }
2479
+ if (k === 'Control' || k === 'Alt' || k === 'Shift' || k === 'Meta') return; // wait for the real key
2480
+ var parts = [];
2481
+ if (e.ctrlKey) parts.push('ctrl');
2482
+ if (e.altKey) parts.push('alt');
2483
+ if (e.shiftKey) parts.push('shift');
2484
+ if (e.metaKey) parts.push('meta');
2485
+ if (!parts.length) { onState('Hold Ctrl / Alt / \\u2318 too\u2026', '#F59E0B'); return; }
2486
+ var key = (e.key.length === 1 ? e.key : e.code.replace(/^Key/, '')).toLowerCase();
2487
+ if (!key) return;
2488
+ parts.push(key);
2489
+ setActivate(parts.join('+'));
2490
+ finish(true);
2491
+ }
2492
+ function finish(committed) {
2493
+ document.removeEventListener('keydown', cap, true);
2494
+ rebinding = false;
2495
+ onDone(committed);
2496
+ }
2497
+ document.addEventListener('keydown', cap, true);
2498
+ }
2499
+
2210
2500
  // \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
2211
2501
  function onMouseMove(e) {
2212
2502
  if (!fiActive) return;
@@ -2388,7 +2678,21 @@ function getClientScript(options) {
2388
2678
  importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2389
2679
  scheduleSync(); // mirror restored Specs to the Claude bridge on load
2390
2680
 
2391
- console.log('%c\u{1F47B} Specter \u2014 Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
2681
+ // Dev-only console API \u2014 the escape hatch when the toggle chord collides with a browser
2682
+ // shortcut so you can't even open the overlay. Specter is stripped from prod, so this
2683
+ // global never ships. Vite devs can run these straight from DevTools.
2684
+ try {
2685
+ window.__specter = {
2686
+ toggle: function () { if (fiActive) deactivate(); else activate(); },
2687
+ activate: activate,
2688
+ deactivate: deactivate,
2689
+ get shortcut() { return ACTIVATE; },
2690
+ 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; },
2691
+ resetShortcut: function () { resetActivate(); if (panelOpen) renderPanel(); console.log('%c\u{1F47B} Specter \u2014 toggle reset to ' + shortcutLabel(ACTIVATE), 'color:#aaa'); return ACTIVATE; },
2692
+ };
2693
+ } catch (e) {}
2694
+
2695
+ 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;');
2392
2696
  })();`;
2393
2697
  }
2394
2698