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.
@@ -25,7 +25,17 @@
25
25
  var CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
26
26
  var SHARE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><polyline points="16 6 12 2 8 6"></polyline><line x1="12" y1="2" x2="12" y2="15"></line></svg>';
27
27
  var IMPORT = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13"></path><polyline points="8 12 12 16 16 12"></polyline><path d="M4 21h16"></path></svg>';
28
- var ACTIVATE = "ctrl+alt+z";
28
+ // Toggle chord: the Vite-config value (or the built-in default) is the baseline; a
29
+ // per-origin localStorage override — set from the panel's rebind UI or window.__specter —
30
+ // wins, so anyone can change it with no config edit and no dev-server restart.
31
+ var ACTIVATE_DEFAULT = "ctrl+alt+z";
32
+ var ACTIVATE_KEY = '__specter_activate';
33
+ var ACTIVATE = ACTIVATE_DEFAULT;
34
+ try { var _actOv = localStorage.getItem(ACTIVATE_KEY); if (_actOv) ACTIVATE = _actOv; } catch (e) {}
35
+ function shortcutLabel(s) { return String(s || '').split('+').map(function (p) { return p.charAt(0).toUpperCase() + p.slice(1); }).join('+'); }
36
+ function setActivate(combo) { ACTIVATE = combo; try { localStorage.setItem(ACTIVATE_KEY, combo); } catch (e) {} }
37
+ function resetActivate() { ACTIVATE = ACTIVATE_DEFAULT; try { localStorage.removeItem(ACTIVATE_KEY); } catch (e) {} }
38
+ function activateOverridden() { try { return !!localStorage.getItem(ACTIVATE_KEY); } catch (e) { return false; } }
29
39
  var BRIDGE = ""; // Claude MCP bridge URL, or '' if disabled
30
40
 
31
41
  // ─── State ────────────────────────────────────────────────────────────────
@@ -65,6 +75,45 @@
65
75
  function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
66
76
  function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
67
77
 
78
+ // ─── Hydration-proof mounting ───────────────────────────────────────────────
79
+ // We inject at document_end, but frameworks that hydrate <body> (Next.js App
80
+ // Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
81
+ // didn't render — silently deleting Specter's UI on those sites (pill/panel
82
+ // vanish; badges added later survive because they're created post-hydration).
83
+ // Track our persistent singletons and re-attach any that gets detached, so the
84
+ // UI survives the hydration pass and later SPA re-renders. The parent is resolved
85
+ // fresh on each remount so a wholesale body/head element swap is handled too.
86
+ var _mounted = [];
87
+ function mount(node, where) {
88
+ var parent = where === 'head' ? document.head : document.body;
89
+ if (parent) parent.appendChild(node);
90
+ _mounted.push({ node: node, where: where });
91
+ return node;
92
+ }
93
+ var _remountQueued = false;
94
+ function remountDetached() {
95
+ _remountQueued = false;
96
+ for (var i = 0; i < _mounted.length; i++) {
97
+ var m = _mounted[i];
98
+ if (m.node.isConnected) continue;
99
+ var p = m.where === 'head' ? document.head : document.body;
100
+ if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
101
+ }
102
+ }
103
+ try {
104
+ var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
105
+ var _mo = new MutationObserver(function () {
106
+ if (_remountQueued) return;
107
+ _remountQueued = true;
108
+ _defer(remountDetached); // coalesce a burst of mutations into one restore pass
109
+ });
110
+ // childList on the roots is enough — every singleton is a direct child of
111
+ // body/head; watching documentElement too catches a body/head element swap.
112
+ _mo.observe(document.documentElement, { childList: true });
113
+ _mo.observe(document.body, { childList: true });
114
+ _mo.observe(document.head, { childList: true });
115
+ } catch (e) {}
116
+
68
117
  // Attach a hover effect to an interactive icon/button: apply the "on" styles
69
118
  // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
70
119
  function hoverFx(el, on, off) {
@@ -91,7 +140,7 @@
91
140
  boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
92
141
  display: 'none',
93
142
  });
94
- document.body.appendChild(tooltip);
143
+ mount(tooltip);
95
144
 
96
145
  // Measure overlay
97
146
  var measureOverlay = document.createElement('div');
@@ -103,7 +152,7 @@
103
152
  pointerEvents: 'none',
104
153
  display: 'none',
105
154
  });
106
- document.body.appendChild(measureOverlay);
155
+ mount(measureOverlay);
107
156
 
108
157
  // ─── Pill ─────────────────────────────────────────────────────────────────
109
158
  var pillWrap = document.createElement('div');
@@ -179,41 +228,34 @@
179
228
  var pillText = document.createElement('span');
180
229
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
181
230
 
231
+ // Panel toggle — icon + label so it's self-explanatory. Label reflects state
232
+ // (Open/Close) and always names the shortcut.
182
233
  var listBtn = document.createElement('span');
183
- listBtn.innerHTML = LIST;
184
- listBtn.title = 'Show Specs panel (L)';
234
+ listBtn.title = 'Toggle Specs panel (L)';
185
235
  Object.assign(listBtn.style, {
186
236
  display: 'none',
187
237
  alignItems: 'center',
238
+ gap: '6px',
188
239
  cursor: 'pointer',
189
240
  color: '#fff',
241
+ fontSize: '11px',
190
242
  flexShrink: '0',
191
- padding: '4px 6px',
243
+ padding: '4px 10px',
192
244
  marginLeft: '2px',
193
245
  borderRadius: '999px',
194
246
  background: 'rgba(255,255,255,0.16)',
195
247
  });
248
+ var listIcon = document.createElement('span');
249
+ listIcon.innerHTML = LIST;
250
+ Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
251
+ var listLabel = document.createElement('span');
252
+ listBtn.appendChild(listIcon);
253
+ listBtn.appendChild(listLabel);
254
+ function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
255
+ updateListBtn();
196
256
  listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
197
257
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
198
258
 
199
- var clearBtn = document.createElement('span');
200
- clearBtn.textContent = '✕ Delete all';
201
- clearBtn.title = 'Delete all annotations';
202
- Object.assign(clearBtn.style, {
203
- display: 'none',
204
- cursor: 'pointer',
205
- color: '#fff',
206
- fontSize: '11px',
207
- fontWeight: '600',
208
- flexShrink: '0',
209
- padding: '2px 8px',
210
- marginLeft: '2px',
211
- borderRadius: '999px',
212
- background: 'rgba(255,255,255,0.16)',
213
- });
214
- clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
215
- hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
216
-
217
259
  var chevron = document.createElement('span');
218
260
  chevron.textContent = '›';
219
261
  chevron.title = 'Move to other side';
@@ -243,16 +285,15 @@
243
285
  pill.appendChild(pillSync);
244
286
  pill.appendChild(pillText);
245
287
  pill.appendChild(listBtn);
246
- pill.appendChild(clearBtn);
247
288
  pill.appendChild(chevron);
248
289
  pillWrap.appendChild(pill);
249
- document.body.appendChild(pillWrap);
290
+ mount(pillWrap);
250
291
 
251
292
  // Keyframes for the sync spinner (injected once).
252
293
  var spinStyle = document.createElement('style');
253
294
  spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
254
295
  markUI(spinStyle);
255
- document.head.appendChild(spinStyle);
296
+ mount(spinStyle, 'head');
256
297
 
257
298
  // Shared dot styling for the control-bar AND side-panel sync indicators, so they
258
299
  // always match: spinner while syncing, green when synced, red on error.
@@ -304,7 +345,7 @@
304
345
  // The mode is shown at all times (persistent prefix), so you always know whether
305
346
  // hovering shows properties, measurements, or nothing (Comment).
306
347
  function modeLabel() {
307
- return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
348
+ return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
308
349
  }
309
350
 
310
351
  function expandPill(text) {
@@ -312,7 +353,7 @@
312
353
  pillText.style.display = 'inline';
313
354
  chevron.style.display = 'inline';
314
355
  listBtn.style.display = 'inline-flex'; // always reachable — the panel is also where you Import a shared file
315
- clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
356
+ updateListBtn();
316
357
  pillExpanded = true;
317
358
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
318
359
  // side panel now, so the pill stays short.
@@ -327,14 +368,12 @@
327
368
  pill.style.maxWidth = '220px';
328
369
  chevron.style.display = 'none';
329
370
  listBtn.style.display = 'none';
330
- clearBtn.style.display = 'none';
331
371
  pillExpanded = false;
332
372
  }
333
373
 
334
374
  function flashMode() {
335
375
  if (pillExpanded && pillWrap.matches(':hover')) return;
336
- var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
337
- expandPill(text);
376
+ expandPill(modeLabel());
338
377
  clearTimeout(flashTimer);
339
378
  flashTimer = setTimeout(function () {
340
379
  if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
@@ -460,10 +499,13 @@
460
499
  var cur = el;
461
500
  for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
462
501
  var part = cur.tagName.toLowerCase();
463
- if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
502
+ if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
464
503
  var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
465
504
  if (cls.length) {
466
- part += '.' + cls.slice(0, 2).join('.');
505
+ // Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
506
+ // invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
507
+ // throw on query, so they must be CSS.escape-d first.
508
+ part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
467
509
  } else if (cur.parentElement) {
468
510
  var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
469
511
  if (sameTag.length > 1) {
@@ -543,13 +585,13 @@
543
585
  var i, v;
544
586
  var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
545
587
  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; } }
546
- if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
588
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
547
589
  var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
548
590
  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) + '"'; } }
549
591
  var txt = ownText(el);
550
592
  if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '…' : '') + '"';
551
593
  var cls = ownClass(el);
552
- if (cls) return '.' + cls;
594
+ if (cls) return '.' + cssEsc(cls);
553
595
  return getSelector(el); // fallback: the CSS path
554
596
  }
555
597
 
@@ -651,6 +693,7 @@
651
693
  var groups = [];
652
694
  for (var i = 0; i < specs.length; i++) {
653
695
  var s = specs[i], g = null;
696
+ if (s.resolved) continue; // done Specs drop out of both the Cmd+C copy and the bridge sync — so /spectify never re-applies them
654
697
  if (s.kind === 'element' && s.el) {
655
698
  for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
656
699
  }
@@ -723,11 +766,20 @@
723
766
  function elementPath(el) {
724
767
  if (!el || el.nodeType !== 1) return '';
725
768
  var parts = [], cur = el;
726
- while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
727
- if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
769
+ // Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
770
+ // nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
771
+ // child index (the old div:nth-child(11) bug — matched a different node, or
772
+ // none, on the recipient). nth-of-type counts only same-tag siblings, so it
773
+ // survives that. Deeper cap (12) so the chain reaches a rooting anchor.
774
+ while (cur && cur.nodeType === 1 && parts.length < 12) {
775
+ if (cur === document.body) { parts.unshift('body'); break; }
776
+ if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
728
777
  var seg = cur.tagName.toLowerCase();
729
778
  var parent = cur.parentElement;
730
- if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
779
+ if (parent) {
780
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
781
+ if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
782
+ }
731
783
  parts.unshift(seg);
732
784
  cur = cur.parentElement;
733
785
  }
@@ -803,6 +855,11 @@
803
855
  var vis = [];
804
856
  for (var i = 0; i < specs.length; i++) {
805
857
  var s = specs[i];
858
+ // Badges created at import-time can be wiped by a framework hydrating <body>
859
+ // (same cause as the singleton mount guard). Re-attach a detached wrap so the
860
+ // pin reappears; a removed spec is already out of the specs array, so this
861
+ // never resurrects a deleted badge.
862
+ if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
806
863
  if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
807
864
  if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
808
865
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
@@ -877,10 +934,17 @@
877
934
  function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
878
935
  function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
879
936
 
880
- function renumber() { for (var i = 0; i < specs.length; i++) specs[i].num.textContent = String(i + 1); }
937
+ // Paint a badge's number/✓ + done tint. Called by renumber and updateBadgeContent so
938
+ // the on-page pin reflects resolved state everywhere (reflowSpecs never touches these).
939
+ function paintBadgeState(spec, i) {
940
+ spec.num.textContent = String(i + 1); // always the number (for wayfinding) — green tint carries the "done" signal
941
+ if (spec.cap) { spec.cap.style.background = spec.resolved ? GREEN : PURPLE; spec.cap.style.opacity = spec.resolved ? '0.65' : '1'; }
942
+ }
943
+
944
+ function renumber() { for (var i = 0; i < specs.length; i++) paintBadgeState(specs[i], i); }
881
945
 
882
946
  function updateBadgeContent(spec) {
883
- spec.num.textContent = String(specs.indexOf(spec) + 1);
947
+ paintBadgeState(spec, specs.indexOf(spec));
884
948
  if (spec.note) spec.noteSpan.textContent = spec.note;
885
949
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '⬡ measure' : '';
886
950
  }
@@ -1002,13 +1066,50 @@
1002
1066
  saveSpecs();
1003
1067
  }
1004
1068
 
1069
+ // ── Resolved (done) lifecycle ────────────────────────────────────────────────
1070
+ // A resolved Spec is NOT deleted — it stays as a record of what changed, greyed with
1071
+ // a ✓, and drops out of the copy + bridge sync (see groupSpecs) so it's never re-applied.
1072
+ function resolvedCount() { var n = 0; for (var i = 0; i < specs.length; i++) if (specs[i].resolved) n++; return n; }
1073
+
1074
+ function toggleResolved(spec) {
1075
+ spec.resolved = !spec.resolved;
1076
+ if (spec.resolved && panelEditSpec === spec) panelEditSpec = null; // close an open editor on the one being marked done
1077
+ updateBadgeContent(spec);
1078
+ updatePill(); // re-renders the panel when open
1079
+ saveSpecs(); // persists resolved + re-syncs the bridge (now excluding it)
1080
+ }
1081
+
1082
+ // Sweep every resolved Spec once the user is satisfied — leaves open Specs untouched.
1083
+ function clearResolved() {
1084
+ for (var i = specs.length - 1; i >= 0; i--) {
1085
+ if (!specs[i].resolved) continue;
1086
+ if (highlightSpec === specs[i]) highlightSpec = null;
1087
+ if (panelEditSpec === specs[i]) panelEditSpec = null;
1088
+ if (specs[i].wrap) specs[i].wrap.remove();
1089
+ specs.splice(i, 1);
1090
+ }
1091
+ renumber();
1092
+ updatePill();
1093
+ saveSpecs();
1094
+ }
1095
+
1096
+ // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1097
+ // importComments so a received share replaces the prior set instead of stacking.
1098
+ function removeSharedSpecs() {
1099
+ if (highlightSpec && highlightSpec.shared) highlightSpec = null;
1100
+ if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
1101
+ for (var i = specs.length - 1; i >= 0; i--) {
1102
+ if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
1103
+ }
1104
+ }
1105
+
1005
1106
  // ─── Reload insurance (localStorage, per-URL, zero network) ──────────────────
1006
1107
  // Persist only the serializable parts of each Spec; the live element ref and
1007
1108
  // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
1008
1109
  function saveSpecs() {
1009
1110
  try {
1010
1111
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
1011
- 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 }; })));
1112
+ 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 }; })));
1012
1113
  } catch (e) {}
1013
1114
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1014
1115
  }
@@ -1018,7 +1119,7 @@
1018
1119
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1019
1120
  if (!Array.isArray(data) || !data.length) return;
1020
1121
  data.forEach(function (d) {
1021
- 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 || '' };
1122
+ 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 };
1022
1123
  specs.push(spec);
1023
1124
  createBadge(spec);
1024
1125
  });
@@ -1033,11 +1134,13 @@
1033
1134
  // element is gone OR its signature changed, the comment shows as MISSING (panel
1034
1135
  // only, greyed, with a reason) instead of being drawn on a guessed spot.
1035
1136
 
1036
- // Normalized element signature for change-detection: stable structural identity,
1037
- // NOT raw outerHTML (which false-trips on any text/attr churn). tag + sorted own
1038
- // classes + a few structural attrs + a short trimmed-text slice. The text slice is
1039
- // the strictness knob — include it to catch content edits, at the cost of a
1040
- // false-MISSING when dynamic text (a price/timestamp) changes under a stable node.
1137
+ // Normalized element signature — a STRUCTURAL identity used to disambiguate which
1138
+ // element a shared comment belongs to (and to scan for it when selectors fail).
1139
+ // Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
1140
+ // price) was false-tripping this and blocking re-anchor on otherwise-identical
1141
+ // pages. tag + sorted own classes + key attrs + child count identifies the node
1142
+ // without that fragility. (Trade-off: pure text edits under a stable node no
1143
+ // longer read as "changed" — placement is favoured over change-detection.)
1041
1144
  function normSig(el) {
1042
1145
  if (!el || el.nodeType !== 1) return '';
1043
1146
  var cls = Array.prototype.slice.call(el.classList)
@@ -1045,8 +1148,7 @@
1045
1148
  var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
1046
1149
  var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
1047
1150
  }).filter(Boolean).join('|');
1048
- var txt = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 50);
1049
- return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
1151
+ return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
1050
1152
  }
1051
1153
  // djb2 xor → short base36 hash. Zero-dep; collisions don't matter (a match just
1052
1154
  // means "unchanged enough", and the re-find already narrowed us to one element).
@@ -1058,51 +1160,91 @@
1058
1160
  return (h >>> 0).toString(36);
1059
1161
  }
1060
1162
 
1061
- // Scan for an element whose OWN text matches (trunc = the stored anchor was cut to
1062
- // 40 chars). Ambiguous (>1 match) → null, so we fall through to the nth-child path
1063
- // rather than guess. Skips Specter's own UI.
1064
- function findByOwnText(val, trunc) {
1065
- var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1163
+ function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
1164
+
1165
+ // Every element whose OWN text matches (trunc = the stored anchor was cut to 40
1166
+ // chars). Returns ALL matches — the fingerprint picks among them in reFindShared,
1167
+ // so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
1168
+ function findAllByOwnText(val, trunc) {
1169
+ var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
1066
1170
  for (var i = 0; i < all.length; i++) {
1067
1171
  var el = all[i];
1068
1172
  if (el.closest && el.closest('[data-specter-ui]')) continue;
1069
1173
  var t = ownText(el);
1070
1174
  if (!t) continue;
1071
- if (trunc ? (t.slice(0, 40) === val) : (t === val)) { if (hit) return null; hit = el; }
1175
+ if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
1072
1176
  }
1073
- return hit;
1177
+ return out;
1074
1178
  }
1075
1179
 
1076
- // Real resolver for a resolveLocator() anchor: #id/.class/[attr] query straight;
1077
- // 'attr "value"' rebuilds the attribute selector; 'text "value"' scans own-text.
1078
- // Only trusts an anchor that resolves to EXACTLY ONE element — a class or CSS path
1079
- // shared by siblings (e.g. three .card boxes) would otherwise silently match the
1080
- // first one, mis-anchoring every comment onto it.
1081
- function resolveFind(find) {
1082
- if (!find) return null;
1180
+ // Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
1181
+ // querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
1182
+ // scans own-text. Returns ALL matches (may be >1) — reFindShared narrows by
1183
+ // fingerprint rather than rejecting anything ambiguous up front.
1184
+ function resolveFindCandidates(find) {
1185
+ if (!find) return [];
1083
1186
  var c = find.charAt(0);
1084
- if (c === '#' || c === '.' || c === '[') return uniqueSel(find) ? safeQuery(find) : null;
1187
+ if (c === '#' || c === '.' || c === '[') return queryAll(find);
1085
1188
  var q = find.indexOf(' "');
1086
1189
  if (q > 0 && find.charAt(find.length - 1) === '"') {
1087
1190
  var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
1088
1191
  if (val.charAt(val.length - 1) === '…') { trunc = true; val = val.slice(0, -1); }
1089
- if (attr === 'text') return findByOwnText(val, trunc);
1090
- if (val.indexOf('"') < 0) { var sel = '[' + attr + '="' + val + '"]'; return uniqueSel(sel) ? safeQuery(sel) : null; }
1091
- return null;
1192
+ if (attr === 'text') return findAllByOwnText(val, trunc);
1193
+ if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
1194
+ return [];
1195
+ }
1196
+ return queryAll(find); // a bare CSS path
1197
+ }
1198
+
1199
+ // Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
1200
+ // just a change-detector: gather every candidate from the find anchor AND the
1201
+ // nth-child path, then return the one whose signature matches — so a locator
1202
+ // shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
1203
+ // instead of failing as ambiguous. With no fp match: hand back a lone candidate
1204
+ // (so importComments can say "changed"), else null ("couldn't find").
1205
+ // Whole-DOM scan for the one element whose structural signature matches — the
1206
+ // last-resort re-finder when both the find anchor and the nth-of-type path fail
1207
+ // (e.g. selectors that don't round-trip, or a shifted structure). >1 match → bail
1208
+ // (ambiguous, don't guess). Skips Specter's own UI.
1209
+ function findByFingerprint(fp) {
1210
+ if (!fp) return null;
1211
+ var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
1212
+ for (var i = 0; i < all.length; i++) {
1213
+ var el = all[i];
1214
+ if (el.closest && el.closest('[data-specter-ui]')) continue;
1215
+ if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
1092
1216
  }
1093
- return uniqueSel(find) ? safeQuery(find) : null; // a bare CSS path — only if unambiguous
1217
+ return hit;
1094
1218
  }
1095
1219
 
1096
- // Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
1097
- // only to detect change): prefer whichever candidate — the find anchor or the
1098
- // nth-child path — actually matches the stored signature. Falls back to a
1099
- // best-effort element so importComments can still tell "changed" from "not found".
1220
+ function fpOk(el, item) { return !item.fp || fingerprint(el) === item.fp; }
1221
+
1222
+ // Rank the signals rather than pooling them, so a specific locator always beats a
1223
+ // vague one:
1224
+ // 1. a find anchor that resolves to exactly ONE element (id / unique text / class)
1225
+ // — the strongest, most semantic signal;
1226
+ // 2. the rooted nth-of-type path, if unique — this is what disambiguates repeated
1227
+ // structure (a class-path find matches every paragraph; the path pins the exact
1228
+ // one). Pooling let an ambiguous find shadow the unique path — the mis-anchor bug;
1229
+ // 3. the structural fingerprint across the pooled candidates, then a DOM-wide scan;
1230
+ // 4. any lone resolution.
1231
+ // fp guards 1 and 2 so a drifted anchor can't win blindly, but a unique anchor with a
1232
+ // changed signature is still trusted at step 4 (placement over false-MISSING).
1100
1233
  function reFindShared(item) {
1101
- var byFind = resolveFind(item.find);
1102
- if (byFind && (!item.fp || fingerprint(byFind) === item.fp)) return byFind;
1103
- var byPath = safeQuery(item.path);
1104
- if (byPath && (!item.fp || fingerprint(byPath) === item.fp)) return byPath;
1105
- return byFind || byPath || null;
1234
+ var byFind = resolveFindCandidates(item.find);
1235
+ if (byFind.length === 1 && fpOk(byFind[0], item)) return byFind[0];
1236
+ var byPath = item.path ? queryAll(item.path) : [];
1237
+ if (byPath.length === 1 && fpOk(byPath[0], item)) return byPath[0];
1238
+ var cands = [], i, all = byFind.concat(byPath);
1239
+ for (i = 0; i < all.length; i++) if (all[i] && cands.indexOf(all[i]) < 0) cands.push(all[i]);
1240
+ if (item.fp) {
1241
+ for (i = 0; i < cands.length; i++) if (fingerprint(cands[i]) === item.fp) return cands[i];
1242
+ var scan = findByFingerprint(item.fp); // selectors failed/ambiguous → find it by signature
1243
+ if (scan) return scan;
1244
+ }
1245
+ if (byFind.length === 1) return byFind[0]; // unique anchor, fp drifted — trust the anchor
1246
+ if (byPath.length === 1) return byPath[0];
1247
+ return cands.length === 1 ? cands[0] : null;
1106
1248
  }
1107
1249
 
1108
1250
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1133,12 +1275,19 @@
1133
1275
  console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' — not this page (' + pageKey() + '). Not imported.');
1134
1276
  return 0;
1135
1277
  }
1278
+ // A received share REPLACES the previously-imported set: re-opening the same
1279
+ // link (or a refreshed one) gives exactly that set, never a stacked-up pile of
1280
+ // duplicates across rounds. Your OWN local specs (shared:false) are untouched.
1281
+ removeSharedSpecs();
1136
1282
  var added = 0;
1137
1283
  comments.forEach(function (item) {
1138
1284
  if (!item) return;
1285
+ // reFindShared already used the fingerprint to pick/scan the right element, so
1286
+ // trust its result: if it located a node, PLACE the comment. Only a genuine
1287
+ // no-match is MISSING — we no longer hide a found element just because its
1288
+ // signature drifted (that over-fired on dynamic pages and buried real pins).
1139
1289
  var el = reFindShared(item), missing = false, reason = '';
1140
1290
  if (!el) { missing = true; reason = 'Couldn’t find this element on the page'; }
1141
- else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed — can’t place the comment'; }
1142
1291
  // body stays empty on purpose: comments-only, never the shared element's props.
1143
1292
  // A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
1144
1293
  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 };
@@ -1499,7 +1648,7 @@
1499
1648
  var rows = [
1500
1649
  ['P', 'mark / comment the hovered element'],
1501
1650
  ['C', 'toggle Comment mode (hide properties)'],
1502
- ['\u2325 Option', 'toggle Measure mode'],
1651
+ ['\u2325 Option / Alt', 'toggle Measure mode'],
1503
1652
  ['M', 'pin an element to measure from'],
1504
1653
  ['Cmd/Ctrl+C', 'copy all Specs'],
1505
1654
  ['L', 'toggle this panel'],
@@ -1515,9 +1664,48 @@
1515
1664
  head.appendChild(headText);
1516
1665
  var body = document.createElement('div');
1517
1666
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1667
+
1668
+ // Rebindable toggle shortcut — the one chord you might have to change if it collides
1669
+ // with a browser/extension binding. Click Change, press a new combo. Saved per origin,
1670
+ // no restart. Works identically in the Vite plugin overlay and the browser extension.
1671
+ (function () {
1672
+ var rrow = document.createElement('div');
1673
+ Object.assign(rrow.style, { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px', paddingBottom: '10px', borderBottom: '1px solid rgba(255,255,255,0.06)' });
1674
+ var chip = document.createElement('span');
1675
+ Object.assign(chip.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1676
+ var lbl = document.createElement('span');
1677
+ Object.assign(lbl.style, { flex: '1', minWidth: '0' });
1678
+ lbl.textContent = 'toggle Specter on/off';
1679
+ var change = document.createElement('span');
1680
+ Object.assign(change.style, { color: '#8AB4F8', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline' });
1681
+ change.textContent = 'Change';
1682
+ var reset = document.createElement('span');
1683
+ Object.assign(reset.style, { color: '#B9BBC2', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline', display: 'none' });
1684
+ reset.textContent = 'Reset';
1685
+ function refresh() {
1686
+ chip.textContent = shortcutLabel(ACTIVATE);
1687
+ chip.style.color = '#E0A3F5';
1688
+ change.style.display = 'inline';
1689
+ reset.style.display = activateOverridden() ? 'inline' : 'none';
1690
+ }
1691
+ change.addEventListener('click', function (e) {
1692
+ e.stopPropagation();
1693
+ change.style.display = 'none';
1694
+ reset.style.display = 'none';
1695
+ startRebind(
1696
+ function (text, color) { chip.textContent = text; chip.style.color = color; },
1697
+ function () { refresh(); }
1698
+ );
1699
+ });
1700
+ reset.addEventListener('click', function (e) { e.stopPropagation(); resetActivate(); refresh(); });
1701
+ refresh();
1702
+ rrow.appendChild(chip); rrow.appendChild(lbl); rrow.appendChild(change); rrow.appendChild(reset);
1703
+ body.appendChild(rrow);
1704
+ })();
1705
+
1518
1706
  rows.forEach(function (r) {
1519
1707
  var row = document.createElement('div');
1520
- Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1708
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
1521
1709
  var k = document.createElement('span');
1522
1710
  k.textContent = r[0];
1523
1711
  Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
@@ -1537,20 +1725,48 @@
1537
1725
  panelKeys.appendChild(body);
1538
1726
  })();
1539
1727
 
1728
+ // Footer — a quiet link out to the GitHub README so first-timers can find the docs.
1729
+ var panelFoot = document.createElement('div');
1730
+ Object.assign(panelFoot.style, {
1731
+ flexShrink: '0', padding: '10px 16px', borderTop: '1px solid rgba(255,255,255,0.08)',
1732
+ display: 'flex', alignItems: 'center',
1733
+ });
1734
+ var docsLink = document.createElement('a');
1735
+ docsLink.href = 'https://github.com/setugk/vite-plugin-specter#readme';
1736
+ docsLink.target = '_blank';
1737
+ docsLink.rel = 'noopener noreferrer';
1738
+ docsLink.textContent = 'Documentation \u2197'; // ↗
1739
+ Object.assign(docsLink.style, { fontSize: '11px', fontWeight: '600', color: '#8AB4F8', textDecoration: 'none', cursor: 'pointer' });
1740
+ hoverFx(docsLink, { color: '#fff' }, { color: '#8AB4F8' });
1741
+ docsLink.addEventListener('click', function (e) { e.stopPropagation(); });
1742
+ panelFoot.appendChild(docsLink);
1743
+
1540
1744
  panelWrap.appendChild(panelHead);
1541
1745
  panelWrap.appendChild(panelTools);
1542
1746
  panelWrap.appendChild(panelHint);
1543
1747
  panelWrap.appendChild(panelList);
1544
1748
  panelWrap.appendChild(panelKeys);
1545
- document.body.appendChild(panelWrap);
1546
-
1547
- // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1548
- // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1549
- // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1749
+ panelWrap.appendChild(panelFoot);
1750
+ mount(panelWrap);
1751
+
1752
+ // A wheel anywhere over the panel must NEVER scroll the page behind it. Relying on
1753
+ // overscroll-behavior alone still lets trackpad momentum chain into the page at the
1754
+ // list bounds, so we take over: always swallow the event, and drive the list's own
1755
+ // scroll ourselves. (The inline note editor auto-grows instead of scrolling, so the
1756
+ // list is the only scrollable region inside the panel — nothing else needs the wheel.)
1757
+ var listScrolling = false, listScrollTimer = null;
1550
1758
  panelWrap.addEventListener('wheel', function (e) {
1551
- var canScroll = panelList.scrollHeight > panelList.clientHeight;
1552
- if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1553
1759
  e.preventDefault();
1760
+ if (panelList.scrollHeight > panelList.clientHeight && panelList.contains(e.target)) {
1761
+ var dy = e.deltaMode === 1 ? e.deltaY * 16 : (e.deltaMode === 2 ? e.deltaY * panelList.clientHeight : e.deltaY);
1762
+ panelList.scrollTop += dy;
1763
+ // Rows slide under a stationary cursor as the list scrolls, firing mouseenter →
1764
+ // revealSpec → scrollIntoView, which would drag the PAGE around. Mark the list as
1765
+ // actively scrolling so those hover-reveals are suppressed until scrolling settles.
1766
+ listScrolling = true;
1767
+ clearTimeout(listScrollTimer);
1768
+ listScrollTimer = setTimeout(function () { listScrolling = false; }, 200);
1769
+ }
1554
1770
  }, { passive: false });
1555
1771
 
1556
1772
  // ── Auto-sync: mirror the browser's current Specs to the local bridge ──
@@ -1563,7 +1779,7 @@
1563
1779
  setPillSync(s); // control-bar dot
1564
1780
  applyDotState(dot, s); // side-panel dot — same spinner/green/red
1565
1781
  if (s === 'syncing') { dotLabel.textContent = 'Syncing…'; }
1566
- else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1782
+ else if (s === 'synced') { var open = specs.length - resolvedCount(); dotLabel.textContent = open > 0 ? (open + (open === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1567
1783
  else { dotLabel.textContent = 'Bridge offline'; }
1568
1784
  }
1569
1785
  function doSync() {
@@ -1610,11 +1826,28 @@
1610
1826
  panelList.appendChild(empty);
1611
1827
  return;
1612
1828
  }
1829
+ // Summary of resolved Specs + a one-click sweep, pinned above the list.
1830
+ var rc = resolvedCount();
1831
+ if (rc > 0) {
1832
+ var doneBar = document.createElement('div');
1833
+ 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)' });
1834
+ var dlbl = document.createElement('span');
1835
+ dlbl.textContent = '✓ ' + rc + (rc === 1 ? ' done' : ' done');
1836
+ Object.assign(dlbl.style, { fontSize: '11px', color: GREEN, fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase' });
1837
+ var clr = document.createElement('span');
1838
+ clr.textContent = 'Clear done';
1839
+ Object.assign(clr.style, { fontSize: '11px', color: '#B9BBC2', cursor: 'pointer', textDecoration: 'underline' });
1840
+ clr.addEventListener('click', function (e) { e.stopPropagation(); clearResolved(); });
1841
+ hoverFx(clr, { color: '#fff' }, { color: '#B9BBC2' });
1842
+ doneBar.appendChild(dlbl); doneBar.appendChild(clr);
1843
+ panelList.appendChild(doneBar);
1844
+ }
1613
1845
  var focusEditor = null, measures = [];
1614
1846
  specs.forEach(function (spec, i) {
1615
1847
  var missing = !!spec.missing;
1616
1848
  var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec — it would re-anchor via path
1617
1849
  var editing = (panelEditSpec === spec);
1850
+ var resolved = !!spec.resolved;
1618
1851
  var row = document.createElement('div');
1619
1852
  Object.assign(row.style, {
1620
1853
  position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
@@ -1625,7 +1858,7 @@
1625
1858
  badge.textContent = String(i + 1);
1626
1859
  Object.assign(badge.style, {
1627
1860
  flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1628
- background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1861
+ background: resolved ? GREEN : BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1629
1862
  border: '1px solid #fff', boxSizing: 'border-box',
1630
1863
  display: 'flex', alignItems: 'center', justifyContent: 'center',
1631
1864
  transition: 'background 0.12s ease',
@@ -1697,6 +1930,7 @@
1697
1930
  cursor: spec.note ? 'pointer' : 'default',
1698
1931
  });
1699
1932
  note.textContent = spec.note || (spec.kind === 'measure' ? '⬡ measurement' : '— no note —');
1933
+ if (resolved) { note.style.textDecoration = 'line-through'; note.style.color = LABEL; note.style.cursor = spec.note ? 'pointer' : 'default'; }
1700
1934
 
1701
1935
  // Floated into the top-right corner so they don't reserve note width. On hover
1702
1936
  // they get a background that fades in from the left, masking the note text behind
@@ -1715,11 +1949,14 @@
1715
1949
  // Show an expand toggle only when the collapsed note actually overflows.
1716
1950
  var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
1717
1951
  chev.firstChild.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
1952
+ 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' });
1718
1953
  var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
1719
1954
  var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
1720
- // Edit + delete reveal only on row hover; the expand chevron stays rightmost
1721
- // and fixed (edit/delete appear to its left, so it never shifts).
1722
- editBtn.style.display = delBtn.style.display = 'none';
1955
+ // ✓ / edit / delete all reveal only on row hover (the expand chevron stays rightmost
1956
+ // and fixed). The done state reads from the green badge + strikethrough + "✓ DONE",
1957
+ // so the row needs no persistent icon — it looks like any other list item.
1958
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
1959
+ actions.appendChild(resolveBtn);
1723
1960
  actions.appendChild(editBtn);
1724
1961
  actions.appendChild(delBtn);
1725
1962
  actions.appendChild(chev);
@@ -1727,9 +1964,27 @@
1727
1964
  content.appendChild(note);
1728
1965
  content.appendChild(meta);
1729
1966
 
1967
+ // Done: a green DONE tag + dimmed row. Supersedes HIDDEN/MISSING — once a Spec is
1968
+ // resolved you don't care whether its element is currently on screen.
1969
+ if (resolved) {
1970
+ row.style.opacity = '0.6';
1971
+ var rbar = document.createElement('div');
1972
+ Object.assign(rbar.style, { display: 'flex', alignItems: 'center', gap: '5px', marginTop: '2px', flexWrap: 'wrap' });
1973
+ var rcheck = document.createElement('span');
1974
+ rcheck.innerHTML = CHECK;
1975
+ Object.assign(rcheck.style, { display: 'inline-flex', alignItems: 'center', color: GREEN, flexShrink: '0' });
1976
+ if (rcheck.firstChild) { rcheck.firstChild.setAttribute('width', '12'); rcheck.firstChild.setAttribute('height', '12'); }
1977
+ var rtag = document.createElement('span');
1978
+ rtag.textContent = 'DONE';
1979
+ // Text-only status label (no fill/padding/radius) so it doesn't read as a clickable chip.
1980
+ Object.assign(rtag.style, { fontSize: '10px', fontWeight: '700', letterSpacing: '0.08em', textTransform: 'uppercase', color: GREEN, flexShrink: '0' });
1981
+ rbar.appendChild(rcheck);
1982
+ rbar.appendChild(rtag);
1983
+ content.appendChild(rbar);
1984
+ }
1730
1985
  // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1731
1986
  // never drawn on the page — design: show missing, don't guess a spot).
1732
- if (missing) {
1987
+ else if (missing) {
1733
1988
  row.style.opacity = '0.7';
1734
1989
  var mbar = document.createElement('div');
1735
1990
  Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
@@ -1766,15 +2021,15 @@
1766
2021
  note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1767
2022
  row.addEventListener('mouseenter', function () {
1768
2023
  row.style.background = 'rgba(255,255,255,0.05)';
1769
- badge.style.background = PURPLE;
1770
- editBtn.style.display = delBtn.style.display = 'flex';
2024
+ if (!resolved) badge.style.background = PURPLE;
2025
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'flex';
1771
2026
  actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1772
- if (visible) { highlightSpec = spec; revealSpec(spec); }
2027
+ if (visible && !listScrolling) { highlightSpec = spec; revealSpec(spec); } // don't reveal on rows that merely slid under the cursor while scrolling
1773
2028
  });
1774
2029
  row.addEventListener('mouseleave', function () {
1775
2030
  row.style.background = 'transparent';
1776
- badge.style.background = BADGE_IDLE;
1777
- editBtn.style.display = delBtn.style.display = 'none';
2031
+ badge.style.background = resolved ? GREEN : BADGE_IDLE;
2032
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
1778
2033
  actions.style.background = 'transparent';
1779
2034
  if (highlightSpec === spec) highlightSpec = null;
1780
2035
  });
@@ -1801,8 +2056,8 @@
1801
2056
  panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1802
2057
  }
1803
2058
 
1804
- function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1805
- function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
2059
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
2060
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
1806
2061
  function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1807
2062
 
1808
2063
  // ─── Spec editor (annotation box: add / edit / delete) ───────────────────────
@@ -2202,6 +2457,41 @@
2202
2457
  return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
2203
2458
  }
2204
2459
 
2460
+ // Capture the next chord the user presses and make it the new toggle. Runs on the
2461
+ // capture phase + stops propagation so a rebind keystroke (e.g. "L") can't leak into
2462
+ // Specter's own shortcuts. Requires at least one modifier so the toggle can't be a bare
2463
+ // key that fires while you type. onState(text, color) drives the prompt; onDone(committed).
2464
+ var rebinding = false;
2465
+ function startRebind(onState, onDone) {
2466
+ if (rebinding) return;
2467
+ rebinding = true;
2468
+ onState('Press a key combo…', GREEN);
2469
+ function cap(e) {
2470
+ e.preventDefault(); e.stopPropagation();
2471
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
2472
+ var k = e.key;
2473
+ if (k === 'Escape') { finish(false); return; }
2474
+ if (k === 'Control' || k === 'Alt' || k === 'Shift' || k === 'Meta') return; // wait for the real key
2475
+ var parts = [];
2476
+ if (e.ctrlKey) parts.push('ctrl');
2477
+ if (e.altKey) parts.push('alt');
2478
+ if (e.shiftKey) parts.push('shift');
2479
+ if (e.metaKey) parts.push('meta');
2480
+ if (!parts.length) { onState('Hold Ctrl / Alt / \u2318 too…', '#F59E0B'); return; }
2481
+ var key = (e.key.length === 1 ? e.key : e.code.replace(/^Key/, '')).toLowerCase();
2482
+ if (!key) return;
2483
+ parts.push(key);
2484
+ setActivate(parts.join('+'));
2485
+ finish(true);
2486
+ }
2487
+ function finish(committed) {
2488
+ document.removeEventListener('keydown', cap, true);
2489
+ rebinding = false;
2490
+ onDone(committed);
2491
+ }
2492
+ document.addEventListener('keydown', cap, true);
2493
+ }
2494
+
2205
2495
  // ─── Mouse ────────────────────────────────────────────────────────────────
2206
2496
  function onMouseMove(e) {
2207
2497
  if (!fiActive) return;
@@ -2383,5 +2673,19 @@
2383
2673
  importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2384
2674
  scheduleSync(); // mirror restored Specs to the Claude bridge on load
2385
2675
 
2386
- console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
2676
+ // Dev-only console API — the escape hatch when the toggle chord collides with a browser
2677
+ // shortcut so you can't even open the overlay. Specter is stripped from prod, so this
2678
+ // global never ships. Vite devs can run these straight from DevTools.
2679
+ try {
2680
+ window.__specter = {
2681
+ toggle: function () { if (fiActive) deactivate(); else activate(); },
2682
+ activate: activate,
2683
+ deactivate: deactivate,
2684
+ get shortcut() { return ACTIVATE; },
2685
+ setShortcut: function (combo) { if (combo) { setActivate(String(combo).toLowerCase()); if (panelOpen) renderPanel(); console.log('%c👻 Specter — toggle is now ' + shortcutLabel(ACTIVATE), 'color:#aaa'); } return ACTIVATE; },
2686
+ resetShortcut: function () { resetActivate(); if (panelOpen) renderPanel(); console.log('%c👻 Specter — toggle reset to ' + shortcutLabel(ACTIVATE), 'color:#aaa'); return ACTIVATE; },
2687
+ };
2688
+ } catch (e) {}
2689
+
2690
+ console.log('%c👻 Specter — ' + shortcutLabel(ACTIVATE) + ' to toggle · change it: window.__specter.setShortcut("ctrl+alt+p")', 'color:#aaa;font-size:11px;');
2387
2691
  })();