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.
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
@@ -688,6 +698,7 @@ function getClientScript(options) {
688
698
  var groups = [];
689
699
  for (var i = 0; i < specs.length; i++) {
690
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
691
702
  if (s.kind === 'element' && s.el) {
692
703
  for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
693
704
  }
@@ -928,10 +939,17 @@ function getClientScript(options) {
928
939
  function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
929
940
  function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
930
941
 
931
- 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); }
932
950
 
933
951
  function updateBadgeContent(spec) {
934
- spec.num.textContent = String(specs.indexOf(spec) + 1);
952
+ paintBadgeState(spec, specs.indexOf(spec));
935
953
  if (spec.note) spec.noteSpan.textContent = spec.note;
936
954
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
937
955
  }
@@ -1053,6 +1071,33 @@ function getClientScript(options) {
1053
1071
  saveSpecs();
1054
1072
  }
1055
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
+
1056
1101
  // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1057
1102
  // importComments so a received share replaces the prior set instead of stacking.
1058
1103
  function removeSharedSpecs() {
@@ -1069,7 +1114,7 @@ function getClientScript(options) {
1069
1114
  function saveSpecs() {
1070
1115
  try {
1071
1116
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
1072
- 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 }; })));
1073
1118
  } catch (e) {}
1074
1119
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1075
1120
  }
@@ -1079,7 +1124,7 @@ function getClientScript(options) {
1079
1124
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1080
1125
  if (!Array.isArray(data) || !data.length) return;
1081
1126
  data.forEach(function (d) {
1082
- 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 };
1083
1128
  specs.push(spec);
1084
1129
  createBadge(spec);
1085
1130
  });
@@ -1177,16 +1222,34 @@ function getClientScript(options) {
1177
1222
  return hit;
1178
1223
  }
1179
1224
 
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).
1180
1238
  function reFindShared(item) {
1181
- var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
1182
- var uniq = [], i;
1183
- for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
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]);
1184
1245
  if (item.fp) {
1185
- for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
1246
+ for (i = 0; i < cands.length; i++) if (fingerprint(cands[i]) === item.fp) return cands[i];
1186
1247
  var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
1187
1248
  if (scan) return scan;
1188
1249
  }
1189
- return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
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;
1190
1253
  }
1191
1254
 
1192
1255
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1590,7 +1653,7 @@ function getClientScript(options) {
1590
1653
  var rows = [
1591
1654
  ['P', 'mark / comment the hovered element'],
1592
1655
  ['C', 'toggle Comment mode (hide properties)'],
1593
- ['\\u2325 Option', 'toggle Measure mode'],
1656
+ ['\\u2325 Option / Alt', 'toggle Measure mode'],
1594
1657
  ['M', 'pin an element to measure from'],
1595
1658
  ['Cmd/Ctrl+C', 'copy all Specs'],
1596
1659
  ['L', 'toggle this panel'],
@@ -1606,6 +1669,45 @@ function getClientScript(options) {
1606
1669
  head.appendChild(headText);
1607
1670
  var body = document.createElement('div');
1608
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
+
1609
1711
  rows.forEach(function (r) {
1610
1712
  var row = document.createElement('div');
1611
1713
  Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
@@ -1628,20 +1730,48 @@ function getClientScript(options) {
1628
1730
  panelKeys.appendChild(body);
1629
1731
  })();
1630
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
+
1631
1749
  panelWrap.appendChild(panelHead);
1632
1750
  panelWrap.appendChild(panelTools);
1633
1751
  panelWrap.appendChild(panelHint);
1634
1752
  panelWrap.appendChild(panelList);
1635
1753
  panelWrap.appendChild(panelKeys);
1754
+ panelWrap.appendChild(panelFoot);
1636
1755
  mount(panelWrap);
1637
1756
 
1638
- // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1639
- // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1640
- // wheel over any non-scrolling part of the panel never scrolls the page behind it.
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;
1641
1763
  panelWrap.addEventListener('wheel', function (e) {
1642
- var canScroll = panelList.scrollHeight > panelList.clientHeight;
1643
- if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1644
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
+ }
1645
1775
  }, { passive: false });
1646
1776
 
1647
1777
  // \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
@@ -1654,7 +1784,7 @@ function getClientScript(options) {
1654
1784
  setPillSync(s); // control-bar dot
1655
1785
  applyDotState(dot, s); // side-panel dot \u2014 same spinner/green/red
1656
1786
  if (s === 'syncing') { dotLabel.textContent = 'Syncing\u2026'; }
1657
- 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'; }
1658
1788
  else { dotLabel.textContent = 'Bridge offline'; }
1659
1789
  }
1660
1790
  function doSync() {
@@ -1701,11 +1831,28 @@ function getClientScript(options) {
1701
1831
  panelList.appendChild(empty);
1702
1832
  return;
1703
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
+ }
1704
1850
  var focusEditor = null, measures = [];
1705
1851
  specs.forEach(function (spec, i) {
1706
1852
  var missing = !!spec.missing;
1707
1853
  var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
1708
1854
  var editing = (panelEditSpec === spec);
1855
+ var resolved = !!spec.resolved;
1709
1856
  var row = document.createElement('div');
1710
1857
  Object.assign(row.style, {
1711
1858
  position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
@@ -1716,7 +1863,7 @@ function getClientScript(options) {
1716
1863
  badge.textContent = String(i + 1);
1717
1864
  Object.assign(badge.style, {
1718
1865
  flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1719
- background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1866
+ background: resolved ? GREEN : BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1720
1867
  border: '1px solid #fff', boxSizing: 'border-box',
1721
1868
  display: 'flex', alignItems: 'center', justifyContent: 'center',
1722
1869
  transition: 'background 0.12s ease',
@@ -1788,6 +1935,7 @@ function getClientScript(options) {
1788
1935
  cursor: spec.note ? 'pointer' : 'default',
1789
1936
  });
1790
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'; }
1791
1939
 
1792
1940
  // Floated into the top-right corner so they don't reserve note width. On hover
1793
1941
  // they get a background that fades in from the left, masking the note text behind
@@ -1806,11 +1954,14 @@ function getClientScript(options) {
1806
1954
  // Show an expand toggle only when the collapsed note actually overflows.
1807
1955
  var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
1808
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' });
1809
1958
  var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
1810
1959
  var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
1811
- // Edit + delete reveal only on row hover; the expand chevron stays rightmost
1812
- // and fixed (edit/delete appear to its left, so it never shifts).
1813
- 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);
1814
1965
  actions.appendChild(editBtn);
1815
1966
  actions.appendChild(delBtn);
1816
1967
  actions.appendChild(chev);
@@ -1818,9 +1969,27 @@ function getClientScript(options) {
1818
1969
  content.appendChild(note);
1819
1970
  content.appendChild(meta);
1820
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
+ }
1821
1990
  // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1822
1991
  // never drawn on the page \u2014 design: show missing, don't guess a spot).
1823
- if (missing) {
1992
+ else if (missing) {
1824
1993
  row.style.opacity = '0.7';
1825
1994
  var mbar = document.createElement('div');
1826
1995
  Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
@@ -1857,15 +2026,15 @@ function getClientScript(options) {
1857
2026
  note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1858
2027
  row.addEventListener('mouseenter', function () {
1859
2028
  row.style.background = 'rgba(255,255,255,0.05)';
1860
- badge.style.background = PURPLE;
1861
- 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';
1862
2031
  actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1863
- 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
1864
2033
  });
1865
2034
  row.addEventListener('mouseleave', function () {
1866
2035
  row.style.background = 'transparent';
1867
- badge.style.background = BADGE_IDLE;
1868
- 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';
1869
2038
  actions.style.background = 'transparent';
1870
2039
  if (highlightSpec === spec) highlightSpec = null;
1871
2040
  });
@@ -2293,6 +2462,41 @@ function getClientScript(options) {
2293
2462
  return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
2294
2463
  }
2295
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
+
2296
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
2297
2501
  function onMouseMove(e) {
2298
2502
  if (!fiActive) return;
@@ -2474,7 +2678,21 @@ function getClientScript(options) {
2474
2678
  importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2475
2679
  scheduleSync(); // mirror restored Specs to the Claude bridge on load
2476
2680
 
2477
- 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;');
2478
2696
  })();`;
2479
2697
  }
2480
2698