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.cjs CHANGED
@@ -57,7 +57,17 @@ function getClientScript(options) {
57
57
  var CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
58
58
  var SHARE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><polyline points="16 6 12 2 8 6"></polyline><line x1="12" y1="2" x2="12" y2="15"></line></svg>';
59
59
  var IMPORT = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13"></path><polyline points="8 12 12 16 16 12"></polyline><path d="M4 21h16"></path></svg>';
60
- var ACTIVATE = ${JSON.stringify(activateShortcut)};
60
+ // Toggle chord: the Vite-config value (or the built-in default) is the baseline; a
61
+ // per-origin localStorage override \u2014 set from the panel's rebind UI or window.__specter \u2014
62
+ // wins, so anyone can change it with no config edit and no dev-server restart.
63
+ var ACTIVATE_DEFAULT = ${JSON.stringify(activateShortcut)};
64
+ var ACTIVATE_KEY = '__specter_activate';
65
+ var ACTIVATE = ACTIVATE_DEFAULT;
66
+ try { var _actOv = localStorage.getItem(ACTIVATE_KEY); if (_actOv) ACTIVATE = _actOv; } catch (e) {}
67
+ function shortcutLabel(s) { return String(s || '').split('+').map(function (p) { return p.charAt(0).toUpperCase() + p.slice(1); }).join('+'); }
68
+ function setActivate(combo) { ACTIVATE = combo; try { localStorage.setItem(ACTIVATE_KEY, combo); } catch (e) {} }
69
+ function resetActivate() { ACTIVATE = ACTIVATE_DEFAULT; try { localStorage.removeItem(ACTIVATE_KEY); } catch (e) {} }
70
+ function activateOverridden() { try { return !!localStorage.getItem(ACTIVATE_KEY); } catch (e) { return false; } }
61
71
  var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
62
72
 
63
73
  // \u2500\u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -715,6 +725,7 @@ function getClientScript(options) {
715
725
  var groups = [];
716
726
  for (var i = 0; i < specs.length; i++) {
717
727
  var s = specs[i], g = null;
728
+ if (s.resolved) continue; // done Specs drop out of both the Cmd+C copy and the bridge sync \u2014 so /spectify never re-applies them
718
729
  if (s.kind === 'element' && s.el) {
719
730
  for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
720
731
  }
@@ -955,10 +966,17 @@ function getClientScript(options) {
955
966
  function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
956
967
  function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
957
968
 
958
- function renumber() { for (var i = 0; i < specs.length; i++) specs[i].num.textContent = String(i + 1); }
969
+ // Paint a badge's number/\u2713 + done tint. Called by renumber and updateBadgeContent so
970
+ // the on-page pin reflects resolved state everywhere (reflowSpecs never touches these).
971
+ function paintBadgeState(spec, i) {
972
+ spec.num.textContent = String(i + 1); // always the number (for wayfinding) \u2014 green tint carries the "done" signal
973
+ if (spec.cap) { spec.cap.style.background = spec.resolved ? GREEN : PURPLE; spec.cap.style.opacity = spec.resolved ? '0.65' : '1'; }
974
+ }
975
+
976
+ function renumber() { for (var i = 0; i < specs.length; i++) paintBadgeState(specs[i], i); }
959
977
 
960
978
  function updateBadgeContent(spec) {
961
- spec.num.textContent = String(specs.indexOf(spec) + 1);
979
+ paintBadgeState(spec, specs.indexOf(spec));
962
980
  if (spec.note) spec.noteSpan.textContent = spec.note;
963
981
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
964
982
  }
@@ -1080,6 +1098,33 @@ function getClientScript(options) {
1080
1098
  saveSpecs();
1081
1099
  }
1082
1100
 
1101
+ // \u2500\u2500 Resolved (done) lifecycle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1102
+ // A resolved Spec is NOT deleted \u2014 it stays as a record of what changed, greyed with
1103
+ // a \u2713, and drops out of the copy + bridge sync (see groupSpecs) so it's never re-applied.
1104
+ function resolvedCount() { var n = 0; for (var i = 0; i < specs.length; i++) if (specs[i].resolved) n++; return n; }
1105
+
1106
+ function toggleResolved(spec) {
1107
+ spec.resolved = !spec.resolved;
1108
+ if (spec.resolved && panelEditSpec === spec) panelEditSpec = null; // close an open editor on the one being marked done
1109
+ updateBadgeContent(spec);
1110
+ updatePill(); // re-renders the panel when open
1111
+ saveSpecs(); // persists resolved + re-syncs the bridge (now excluding it)
1112
+ }
1113
+
1114
+ // Sweep every resolved Spec once the user is satisfied \u2014 leaves open Specs untouched.
1115
+ function clearResolved() {
1116
+ for (var i = specs.length - 1; i >= 0; i--) {
1117
+ if (!specs[i].resolved) continue;
1118
+ if (highlightSpec === specs[i]) highlightSpec = null;
1119
+ if (panelEditSpec === specs[i]) panelEditSpec = null;
1120
+ if (specs[i].wrap) specs[i].wrap.remove();
1121
+ specs.splice(i, 1);
1122
+ }
1123
+ renumber();
1124
+ updatePill();
1125
+ saveSpecs();
1126
+ }
1127
+
1083
1128
  // Drop every imported (shared) spec, leaving the user's own local ones. Used by
1084
1129
  // importComments so a received share replaces the prior set instead of stacking.
1085
1130
  function removeSharedSpecs() {
@@ -1096,7 +1141,7 @@ function getClientScript(options) {
1096
1141
  function saveSpecs() {
1097
1142
  try {
1098
1143
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
1099
- else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason }; })));
1144
+ else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason, resolved: s.resolved }; })));
1100
1145
  } catch (e) {}
1101
1146
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
1102
1147
  }
@@ -1106,7 +1151,7 @@ function getClientScript(options) {
1106
1151
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1107
1152
  if (!Array.isArray(data) || !data.length) return;
1108
1153
  data.forEach(function (d) {
1109
- var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '' };
1154
+ var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '', resolved: !!d.resolved };
1110
1155
  specs.push(spec);
1111
1156
  createBadge(spec);
1112
1157
  });
@@ -1204,16 +1249,34 @@ function getClientScript(options) {
1204
1249
  return hit;
1205
1250
  }
1206
1251
 
1252
+ function fpOk(el, item) { return !item.fp || fingerprint(el) === item.fp; }
1253
+
1254
+ // Rank the signals rather than pooling them, so a specific locator always beats a
1255
+ // vague one:
1256
+ // 1. a find anchor that resolves to exactly ONE element (id / unique text / class)
1257
+ // \u2014 the strongest, most semantic signal;
1258
+ // 2. the rooted nth-of-type path, if unique \u2014 this is what disambiguates repeated
1259
+ // structure (a class-path find matches every paragraph; the path pins the exact
1260
+ // one). Pooling let an ambiguous find shadow the unique path \u2014 the mis-anchor bug;
1261
+ // 3. the structural fingerprint across the pooled candidates, then a DOM-wide scan;
1262
+ // 4. any lone resolution.
1263
+ // fp guards 1 and 2 so a drifted anchor can't win blindly, but a unique anchor with a
1264
+ // changed signature is still trusted at step 4 (placement over false-MISSING).
1207
1265
  function reFindShared(item) {
1208
- var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
1209
- var uniq = [], i;
1210
- for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
1266
+ var byFind = resolveFindCandidates(item.find);
1267
+ if (byFind.length === 1 && fpOk(byFind[0], item)) return byFind[0];
1268
+ var byPath = item.path ? queryAll(item.path) : [];
1269
+ if (byPath.length === 1 && fpOk(byPath[0], item)) return byPath[0];
1270
+ var cands = [], i, all = byFind.concat(byPath);
1271
+ for (i = 0; i < all.length; i++) if (all[i] && cands.indexOf(all[i]) < 0) cands.push(all[i]);
1211
1272
  if (item.fp) {
1212
- for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
1273
+ for (i = 0; i < cands.length; i++) if (fingerprint(cands[i]) === item.fp) return cands[i];
1213
1274
  var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
1214
1275
  if (scan) return scan;
1215
1276
  }
1216
- return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
1277
+ if (byFind.length === 1) return byFind[0]; // unique anchor, fp drifted \u2014 trust the anchor
1278
+ if (byPath.length === 1) return byPath[0];
1279
+ return cands.length === 1 ? cands[0] : null;
1217
1280
  }
1218
1281
 
1219
1282
  // URL gate: two people must be on the SAME page for a shared comment to place.
@@ -1617,7 +1680,7 @@ function getClientScript(options) {
1617
1680
  var rows = [
1618
1681
  ['P', 'mark / comment the hovered element'],
1619
1682
  ['C', 'toggle Comment mode (hide properties)'],
1620
- ['\\u2325 Option', 'toggle Measure mode'],
1683
+ ['\\u2325 Option / Alt', 'toggle Measure mode'],
1621
1684
  ['M', 'pin an element to measure from'],
1622
1685
  ['Cmd/Ctrl+C', 'copy all Specs'],
1623
1686
  ['L', 'toggle this panel'],
@@ -1633,6 +1696,45 @@ function getClientScript(options) {
1633
1696
  head.appendChild(headText);
1634
1697
  var body = document.createElement('div');
1635
1698
  Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1699
+
1700
+ // Rebindable toggle shortcut \u2014 the one chord you might have to change if it collides
1701
+ // with a browser/extension binding. Click Change, press a new combo. Saved per origin,
1702
+ // no restart. Works identically in the Vite plugin overlay and the browser extension.
1703
+ (function () {
1704
+ var rrow = document.createElement('div');
1705
+ Object.assign(rrow.style, { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px', paddingBottom: '10px', borderBottom: '1px solid rgba(255,255,255,0.06)' });
1706
+ var chip = document.createElement('span');
1707
+ Object.assign(chip.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1708
+ var lbl = document.createElement('span');
1709
+ Object.assign(lbl.style, { flex: '1', minWidth: '0' });
1710
+ lbl.textContent = 'toggle Specter on/off';
1711
+ var change = document.createElement('span');
1712
+ Object.assign(change.style, { color: '#8AB4F8', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline' });
1713
+ change.textContent = 'Change';
1714
+ var reset = document.createElement('span');
1715
+ Object.assign(reset.style, { color: '#B9BBC2', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline', display: 'none' });
1716
+ reset.textContent = 'Reset';
1717
+ function refresh() {
1718
+ chip.textContent = shortcutLabel(ACTIVATE);
1719
+ chip.style.color = '#E0A3F5';
1720
+ change.style.display = 'inline';
1721
+ reset.style.display = activateOverridden() ? 'inline' : 'none';
1722
+ }
1723
+ change.addEventListener('click', function (e) {
1724
+ e.stopPropagation();
1725
+ change.style.display = 'none';
1726
+ reset.style.display = 'none';
1727
+ startRebind(
1728
+ function (text, color) { chip.textContent = text; chip.style.color = color; },
1729
+ function () { refresh(); }
1730
+ );
1731
+ });
1732
+ reset.addEventListener('click', function (e) { e.stopPropagation(); resetActivate(); refresh(); });
1733
+ refresh();
1734
+ rrow.appendChild(chip); rrow.appendChild(lbl); rrow.appendChild(change); rrow.appendChild(reset);
1735
+ body.appendChild(rrow);
1736
+ })();
1737
+
1636
1738
  rows.forEach(function (r) {
1637
1739
  var row = document.createElement('div');
1638
1740
  Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
@@ -1655,20 +1757,48 @@ function getClientScript(options) {
1655
1757
  panelKeys.appendChild(body);
1656
1758
  })();
1657
1759
 
1760
+ // Footer \u2014 a quiet link out to the GitHub README so first-timers can find the docs.
1761
+ var panelFoot = document.createElement('div');
1762
+ Object.assign(panelFoot.style, {
1763
+ flexShrink: '0', padding: '10px 16px', borderTop: '1px solid rgba(255,255,255,0.08)',
1764
+ display: 'flex', alignItems: 'center',
1765
+ });
1766
+ var docsLink = document.createElement('a');
1767
+ docsLink.href = 'https://github.com/setugk/vite-plugin-specter#readme';
1768
+ docsLink.target = '_blank';
1769
+ docsLink.rel = 'noopener noreferrer';
1770
+ docsLink.textContent = 'Documentation \\u2197'; // \u2197
1771
+ Object.assign(docsLink.style, { fontSize: '11px', fontWeight: '600', color: '#8AB4F8', textDecoration: 'none', cursor: 'pointer' });
1772
+ hoverFx(docsLink, { color: '#fff' }, { color: '#8AB4F8' });
1773
+ docsLink.addEventListener('click', function (e) { e.stopPropagation(); });
1774
+ panelFoot.appendChild(docsLink);
1775
+
1658
1776
  panelWrap.appendChild(panelHead);
1659
1777
  panelWrap.appendChild(panelTools);
1660
1778
  panelWrap.appendChild(panelHint);
1661
1779
  panelWrap.appendChild(panelList);
1662
1780
  panelWrap.appendChild(panelKeys);
1781
+ panelWrap.appendChild(panelFoot);
1663
1782
  mount(panelWrap);
1664
1783
 
1665
- // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1666
- // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1667
- // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1784
+ // A wheel anywhere over the panel must NEVER scroll the page behind it. Relying on
1785
+ // overscroll-behavior alone still lets trackpad momentum chain into the page at the
1786
+ // list bounds, so we take over: always swallow the event, and drive the list's own
1787
+ // scroll ourselves. (The inline note editor auto-grows instead of scrolling, so the
1788
+ // list is the only scrollable region inside the panel \u2014 nothing else needs the wheel.)
1789
+ var listScrolling = false, listScrollTimer = null;
1668
1790
  panelWrap.addEventListener('wheel', function (e) {
1669
- var canScroll = panelList.scrollHeight > panelList.clientHeight;
1670
- if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1671
1791
  e.preventDefault();
1792
+ if (panelList.scrollHeight > panelList.clientHeight && panelList.contains(e.target)) {
1793
+ var dy = e.deltaMode === 1 ? e.deltaY * 16 : (e.deltaMode === 2 ? e.deltaY * panelList.clientHeight : e.deltaY);
1794
+ panelList.scrollTop += dy;
1795
+ // Rows slide under a stationary cursor as the list scrolls, firing mouseenter \u2192
1796
+ // revealSpec \u2192 scrollIntoView, which would drag the PAGE around. Mark the list as
1797
+ // actively scrolling so those hover-reveals are suppressed until scrolling settles.
1798
+ listScrolling = true;
1799
+ clearTimeout(listScrollTimer);
1800
+ listScrollTimer = setTimeout(function () { listScrolling = false; }, 200);
1801
+ }
1672
1802
  }, { passive: false });
1673
1803
 
1674
1804
  // \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
@@ -1681,7 +1811,7 @@ function getClientScript(options) {
1681
1811
  setPillSync(s); // control-bar dot
1682
1812
  applyDotState(dot, s); // side-panel dot \u2014 same spinner/green/red
1683
1813
  if (s === 'syncing') { dotLabel.textContent = 'Syncing\u2026'; }
1684
- else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1814
+ else if (s === 'synced') { var open = specs.length - resolvedCount(); dotLabel.textContent = open > 0 ? (open + (open === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1685
1815
  else { dotLabel.textContent = 'Bridge offline'; }
1686
1816
  }
1687
1817
  function doSync() {
@@ -1728,11 +1858,28 @@ function getClientScript(options) {
1728
1858
  panelList.appendChild(empty);
1729
1859
  return;
1730
1860
  }
1861
+ // Summary of resolved Specs + a one-click sweep, pinned above the list.
1862
+ var rc = resolvedCount();
1863
+ if (rc > 0) {
1864
+ var doneBar = document.createElement('div');
1865
+ Object.assign(doneBar.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.06)' });
1866
+ var dlbl = document.createElement('span');
1867
+ dlbl.textContent = '\u2713 ' + rc + (rc === 1 ? ' done' : ' done');
1868
+ Object.assign(dlbl.style, { fontSize: '11px', color: GREEN, fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase' });
1869
+ var clr = document.createElement('span');
1870
+ clr.textContent = 'Clear done';
1871
+ Object.assign(clr.style, { fontSize: '11px', color: '#B9BBC2', cursor: 'pointer', textDecoration: 'underline' });
1872
+ clr.addEventListener('click', function (e) { e.stopPropagation(); clearResolved(); });
1873
+ hoverFx(clr, { color: '#fff' }, { color: '#B9BBC2' });
1874
+ doneBar.appendChild(dlbl); doneBar.appendChild(clr);
1875
+ panelList.appendChild(doneBar);
1876
+ }
1731
1877
  var focusEditor = null, measures = [];
1732
1878
  specs.forEach(function (spec, i) {
1733
1879
  var missing = !!spec.missing;
1734
1880
  var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
1735
1881
  var editing = (panelEditSpec === spec);
1882
+ var resolved = !!spec.resolved;
1736
1883
  var row = document.createElement('div');
1737
1884
  Object.assign(row.style, {
1738
1885
  position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
@@ -1743,7 +1890,7 @@ function getClientScript(options) {
1743
1890
  badge.textContent = String(i + 1);
1744
1891
  Object.assign(badge.style, {
1745
1892
  flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1746
- background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1893
+ background: resolved ? GREEN : BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1747
1894
  border: '1px solid #fff', boxSizing: 'border-box',
1748
1895
  display: 'flex', alignItems: 'center', justifyContent: 'center',
1749
1896
  transition: 'background 0.12s ease',
@@ -1815,6 +1962,7 @@ function getClientScript(options) {
1815
1962
  cursor: spec.note ? 'pointer' : 'default',
1816
1963
  });
1817
1964
  note.textContent = spec.note || (spec.kind === 'measure' ? '\u2B21 measurement' : '\u2014 no note \u2014');
1965
+ if (resolved) { note.style.textDecoration = 'line-through'; note.style.color = LABEL; note.style.cursor = spec.note ? 'pointer' : 'default'; }
1818
1966
 
1819
1967
  // Floated into the top-right corner so they don't reserve note width. On hover
1820
1968
  // they get a background that fades in from the left, masking the note text behind
@@ -1833,11 +1981,14 @@ function getClientScript(options) {
1833
1981
  // Show an expand toggle only when the collapsed note actually overflows.
1834
1982
  var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
1835
1983
  chev.firstChild.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
1984
+ var resolveBtn = mkIcon(CHECK, resolved ? 'Mark as not done' : 'Mark as done', resolved ? GREEN : '#9BE6B0', function () { toggleResolved(spec); }, { background: 'rgba(34,197,94,0.28)', color: '#fff' });
1836
1985
  var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
1837
1986
  var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
1838
- // Edit + delete reveal only on row hover; the expand chevron stays rightmost
1839
- // and fixed (edit/delete appear to its left, so it never shifts).
1840
- editBtn.style.display = delBtn.style.display = 'none';
1987
+ // \u2713 / edit / delete all reveal only on row hover (the expand chevron stays rightmost
1988
+ // and fixed). The done state reads from the green badge + strikethrough + "\u2713 DONE",
1989
+ // so the row needs no persistent icon \u2014 it looks like any other list item.
1990
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
1991
+ actions.appendChild(resolveBtn);
1841
1992
  actions.appendChild(editBtn);
1842
1993
  actions.appendChild(delBtn);
1843
1994
  actions.appendChild(chev);
@@ -1845,9 +1996,27 @@ function getClientScript(options) {
1845
1996
  content.appendChild(note);
1846
1997
  content.appendChild(meta);
1847
1998
 
1999
+ // Done: a green DONE tag + dimmed row. Supersedes HIDDEN/MISSING \u2014 once a Spec is
2000
+ // resolved you don't care whether its element is currently on screen.
2001
+ if (resolved) {
2002
+ row.style.opacity = '0.6';
2003
+ var rbar = document.createElement('div');
2004
+ Object.assign(rbar.style, { display: 'flex', alignItems: 'center', gap: '5px', marginTop: '2px', flexWrap: 'wrap' });
2005
+ var rcheck = document.createElement('span');
2006
+ rcheck.innerHTML = CHECK;
2007
+ Object.assign(rcheck.style, { display: 'inline-flex', alignItems: 'center', color: GREEN, flexShrink: '0' });
2008
+ if (rcheck.firstChild) { rcheck.firstChild.setAttribute('width', '12'); rcheck.firstChild.setAttribute('height', '12'); }
2009
+ var rtag = document.createElement('span');
2010
+ rtag.textContent = 'DONE';
2011
+ // Text-only status label (no fill/padding/radius) so it doesn't read as a clickable chip.
2012
+ Object.assign(rtag.style, { fontSize: '10px', fontWeight: '700', letterSpacing: '0.08em', textTransform: 'uppercase', color: GREEN, flexShrink: '0' });
2013
+ rbar.appendChild(rcheck);
2014
+ rbar.appendChild(rtag);
2015
+ content.appendChild(rbar);
2016
+ }
1848
2017
  // Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
1849
2018
  // never drawn on the page \u2014 design: show missing, don't guess a spot).
1850
- if (missing) {
2019
+ else if (missing) {
1851
2020
  row.style.opacity = '0.7';
1852
2021
  var mbar = document.createElement('div');
1853
2022
  Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
@@ -1884,15 +2053,15 @@ function getClientScript(options) {
1884
2053
  note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1885
2054
  row.addEventListener('mouseenter', function () {
1886
2055
  row.style.background = 'rgba(255,255,255,0.05)';
1887
- badge.style.background = PURPLE;
1888
- editBtn.style.display = delBtn.style.display = 'flex';
2056
+ if (!resolved) badge.style.background = PURPLE;
2057
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'flex';
1889
2058
  actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1890
- if (visible) { highlightSpec = spec; revealSpec(spec); }
2059
+ if (visible && !listScrolling) { highlightSpec = spec; revealSpec(spec); } // don't reveal on rows that merely slid under the cursor while scrolling
1891
2060
  });
1892
2061
  row.addEventListener('mouseleave', function () {
1893
2062
  row.style.background = 'transparent';
1894
- badge.style.background = BADGE_IDLE;
1895
- editBtn.style.display = delBtn.style.display = 'none';
2063
+ badge.style.background = resolved ? GREEN : BADGE_IDLE;
2064
+ editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
1896
2065
  actions.style.background = 'transparent';
1897
2066
  if (highlightSpec === spec) highlightSpec = null;
1898
2067
  });
@@ -2320,6 +2489,41 @@ function getClientScript(options) {
2320
2489
  return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
2321
2490
  }
2322
2491
 
2492
+ // Capture the next chord the user presses and make it the new toggle. Runs on the
2493
+ // capture phase + stops propagation so a rebind keystroke (e.g. "L") can't leak into
2494
+ // Specter's own shortcuts. Requires at least one modifier so the toggle can't be a bare
2495
+ // key that fires while you type. onState(text, color) drives the prompt; onDone(committed).
2496
+ var rebinding = false;
2497
+ function startRebind(onState, onDone) {
2498
+ if (rebinding) return;
2499
+ rebinding = true;
2500
+ onState('Press a key combo\u2026', GREEN);
2501
+ function cap(e) {
2502
+ e.preventDefault(); e.stopPropagation();
2503
+ if (e.stopImmediatePropagation) e.stopImmediatePropagation();
2504
+ var k = e.key;
2505
+ if (k === 'Escape') { finish(false); return; }
2506
+ if (k === 'Control' || k === 'Alt' || k === 'Shift' || k === 'Meta') return; // wait for the real key
2507
+ var parts = [];
2508
+ if (e.ctrlKey) parts.push('ctrl');
2509
+ if (e.altKey) parts.push('alt');
2510
+ if (e.shiftKey) parts.push('shift');
2511
+ if (e.metaKey) parts.push('meta');
2512
+ if (!parts.length) { onState('Hold Ctrl / Alt / \\u2318 too\u2026', '#F59E0B'); return; }
2513
+ var key = (e.key.length === 1 ? e.key : e.code.replace(/^Key/, '')).toLowerCase();
2514
+ if (!key) return;
2515
+ parts.push(key);
2516
+ setActivate(parts.join('+'));
2517
+ finish(true);
2518
+ }
2519
+ function finish(committed) {
2520
+ document.removeEventListener('keydown', cap, true);
2521
+ rebinding = false;
2522
+ onDone(committed);
2523
+ }
2524
+ document.addEventListener('keydown', cap, true);
2525
+ }
2526
+
2323
2527
  // \u2500\u2500\u2500 Mouse \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2324
2528
  function onMouseMove(e) {
2325
2529
  if (!fiActive) return;
@@ -2501,7 +2705,21 @@ function getClientScript(options) {
2501
2705
  importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
2502
2706
  scheduleSync(); // mirror restored Specs to the Claude bridge on load
2503
2707
 
2504
- console.log('%c\u{1F47B} Specter \u2014 Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
2708
+ // Dev-only console API \u2014 the escape hatch when the toggle chord collides with a browser
2709
+ // shortcut so you can't even open the overlay. Specter is stripped from prod, so this
2710
+ // global never ships. Vite devs can run these straight from DevTools.
2711
+ try {
2712
+ window.__specter = {
2713
+ toggle: function () { if (fiActive) deactivate(); else activate(); },
2714
+ activate: activate,
2715
+ deactivate: deactivate,
2716
+ get shortcut() { return ACTIVATE; },
2717
+ setShortcut: function (combo) { if (combo) { setActivate(String(combo).toLowerCase()); if (panelOpen) renderPanel(); console.log('%c\u{1F47B} Specter \u2014 toggle is now ' + shortcutLabel(ACTIVATE), 'color:#aaa'); } return ACTIVATE; },
2718
+ resetShortcut: function () { resetActivate(); if (panelOpen) renderPanel(); console.log('%c\u{1F47B} Specter \u2014 toggle reset to ' + shortcutLabel(ACTIVATE), 'color:#aaa'); return ACTIVATE; },
2719
+ };
2720
+ } catch (e) {}
2721
+
2722
+ console.log('%c\u{1F47B} Specter \u2014 ' + shortcutLabel(ACTIVATE) + ' to toggle \xB7 change it: window.__specter.setShortcut("ctrl+alt+p")', 'color:#aaa;font-size:11px;');
2505
2723
  })();`;
2506
2724
  }
2507
2725