vite-plugin-specter 0.7.1 → 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 ────────────────────────────────────────────────────────────────
@@ -683,6 +693,7 @@
683
693
  var groups = [];
684
694
  for (var i = 0; i < specs.length; i++) {
685
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
686
697
  if (s.kind === 'element' && s.el) {
687
698
  for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
688
699
  }
@@ -923,10 +934,17 @@
923
934
  function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
924
935
  function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
925
936
 
926
- 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); }
927
945
 
928
946
  function updateBadgeContent(spec) {
929
- spec.num.textContent = String(specs.indexOf(spec) + 1);
947
+ paintBadgeState(spec, specs.indexOf(spec));
930
948
  if (spec.note) spec.noteSpan.textContent = spec.note;
931
949
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '⬡ measure' : '';
932
950
  }
@@ -1048,6 +1066,33 @@
1048
1066
  saveSpecs();
1049
1067
  }
1050
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
+
1051
1096
  // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1052
1097
  // importComments so a received share replaces the prior set instead of stacking.
1053
1098
  function removeSharedSpecs() {
@@ -1064,7 +1109,7 @@
1064
1109
  function saveSpecs() {
1065
1110
  try {
1066
1111
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
1067
- 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 }; })));
1068
1113
  } catch (e) {}
1069
1114
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1070
1115
  }
@@ -1074,7 +1119,7 @@
1074
1119
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1075
1120
  if (!Array.isArray(data) || !data.length) return;
1076
1121
  data.forEach(function (d) {
1077
- 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 };
1078
1123
  specs.push(spec);
1079
1124
  createBadge(spec);
1080
1125
  });
@@ -1172,16 +1217,34 @@
1172
1217
  return hit;
1173
1218
  }
1174
1219
 
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).
1175
1233
  function reFindShared(item) {
1176
- var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
1177
- var uniq = [], i;
1178
- for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
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]);
1179
1240
  if (item.fp) {
1180
- for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
1241
+ for (i = 0; i < cands.length; i++) if (fingerprint(cands[i]) === item.fp) return cands[i];
1181
1242
  var scan = findByFingerprint(item.fp); // selectors failed/ambiguous → find it by signature
1182
1243
  if (scan) return scan;
1183
1244
  }
1184
- return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
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;
1185
1248
  }
1186
1249
 
1187
1250
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1585,7 +1648,7 @@
1585
1648
  var rows = [
1586
1649
  ['P', 'mark / comment the hovered element'],
1587
1650
  ['C', 'toggle Comment mode (hide properties)'],
1588
- ['\u2325 Option', 'toggle Measure mode'],
1651
+ ['\u2325 Option / Alt', 'toggle Measure mode'],
1589
1652
  ['M', 'pin an element to measure from'],
1590
1653
  ['Cmd/Ctrl+C', 'copy all Specs'],
1591
1654
  ['L', 'toggle this panel'],
@@ -1601,6 +1664,45 @@
1601
1664
  head.appendChild(headText);
1602
1665
  var body = document.createElement('div');
1603
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
+
1604
1706
  rows.forEach(function (r) {
1605
1707
  var row = document.createElement('div');
1606
1708
  Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
@@ -1623,20 +1725,48 @@
1623
1725
  panelKeys.appendChild(body);
1624
1726
  })();
1625
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
+
1626
1744
  panelWrap.appendChild(panelHead);
1627
1745
  panelWrap.appendChild(panelTools);
1628
1746
  panelWrap.appendChild(panelHint);
1629
1747
  panelWrap.appendChild(panelList);
1630
1748
  panelWrap.appendChild(panelKeys);
1749
+ panelWrap.appendChild(panelFoot);
1631
1750
  mount(panelWrap);
1632
1751
 
1633
- // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1634
- // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1635
- // wheel over any non-scrolling part of the panel never scrolls the page behind it.
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;
1636
1758
  panelWrap.addEventListener('wheel', function (e) {
1637
- var canScroll = panelList.scrollHeight > panelList.clientHeight;
1638
- if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1639
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
+ }
1640
1770
  }, { passive: false });
1641
1771
 
1642
1772
  // ── Auto-sync: mirror the browser's current Specs to the local bridge ──
@@ -1649,7 +1779,7 @@
1649
1779
  setPillSync(s); // control-bar dot
1650
1780
  applyDotState(dot, s); // side-panel dot — same spinner/green/red
1651
1781
  if (s === 'syncing') { dotLabel.textContent = 'Syncing…'; }
1652
- 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'; }
1653
1783
  else { dotLabel.textContent = 'Bridge offline'; }
1654
1784
  }
1655
1785
  function doSync() {
@@ -1696,11 +1826,28 @@
1696
1826
  panelList.appendChild(empty);
1697
1827
  return;
1698
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
+ }
1699
1845
  var focusEditor = null, measures = [];
1700
1846
  specs.forEach(function (spec, i) {
1701
1847
  var missing = !!spec.missing;
1702
1848
  var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec — it would re-anchor via path
1703
1849
  var editing = (panelEditSpec === spec);
1850
+ var resolved = !!spec.resolved;
1704
1851
  var row = document.createElement('div');
1705
1852
  Object.assign(row.style, {
1706
1853
  position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
@@ -1711,7 +1858,7 @@
1711
1858
  badge.textContent = String(i + 1);
1712
1859
  Object.assign(badge.style, {
1713
1860
  flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1714
- background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1861
+ background: resolved ? GREEN : BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1715
1862
  border: '1px solid #fff', boxSizing: 'border-box',
1716
1863
  display: 'flex', alignItems: 'center', justifyContent: 'center',
1717
1864
  transition: 'background 0.12s ease',
@@ -1783,6 +1930,7 @@
1783
1930
  cursor: spec.note ? 'pointer' : 'default',
1784
1931
  });
1785
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'; }
1786
1934
 
1787
1935
  // Floated into the top-right corner so they don't reserve note width. On hover
1788
1936
  // they get a background that fades in from the left, masking the note text behind
@@ -1801,11 +1949,14 @@
1801
1949
  // Show an expand toggle only when the collapsed note actually overflows.
1802
1950
  var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
1803
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' });
1804
1953
  var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
1805
1954
  var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
1806
- // Edit + delete reveal only on row hover; the expand chevron stays rightmost
1807
- // and fixed (edit/delete appear to its left, so it never shifts).
1808
- 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);
1809
1960
  actions.appendChild(editBtn);
1810
1961
  actions.appendChild(delBtn);
1811
1962
  actions.appendChild(chev);
@@ -1813,9 +1964,27 @@
1813
1964
  content.appendChild(note);
1814
1965
  content.appendChild(meta);
1815
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
+ }
1816
1985
  // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1817
1986
  // never drawn on the page — design: show missing, don't guess a spot).
1818
- if (missing) {
1987
+ else if (missing) {
1819
1988
  row.style.opacity = '0.7';
1820
1989
  var mbar = document.createElement('div');
1821
1990
  Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
@@ -1852,15 +2021,15 @@
1852
2021
  note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1853
2022
  row.addEventListener('mouseenter', function () {
1854
2023
  row.style.background = 'rgba(255,255,255,0.05)';
1855
- badge.style.background = PURPLE;
1856
- 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';
1857
2026
  actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1858
- 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
1859
2028
  });
1860
2029
  row.addEventListener('mouseleave', function () {
1861
2030
  row.style.background = 'transparent';
1862
- badge.style.background = BADGE_IDLE;
1863
- 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';
1864
2033
  actions.style.background = 'transparent';
1865
2034
  if (highlightSpec === spec) highlightSpec = null;
1866
2035
  });
@@ -2288,6 +2457,41 @@
2288
2457
  return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
2289
2458
  }
2290
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
+
2291
2495
  // ─── Mouse ────────────────────────────────────────────────────────────────
2292
2496
  function onMouseMove(e) {
2293
2497
  if (!fiActive) return;
@@ -2469,5 +2673,19 @@
2469
2673
  importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2470
2674
  scheduleSync(); // mirror restored Specs to the Claude bridge on load
2471
2675
 
2472
- 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;');
2473
2691
  })();
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 2,
3
3
  "name": "Specter",
4
- "version": "0.7.1",
4
+ "version": "0.7.5",
5
5
  "description": "Element inspector for AI-assisted development. Ctrl+Option+Z to activate.",
6
6
  "browser_specific_settings": {
7
7
  "gecko": {