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/README.md +42 -9
- package/dist/client.js +246 -28
- package/dist/extension-chrome/content.js +246 -28
- package/dist/extension-chrome/manifest.json +1 -1
- package/dist/extension-firefox/content.js +246 -28
- package/dist/extension-firefox/manifest.json +1 -1
- package/dist/index.cjs +246 -28
- package/dist/index.js +246 -28
- package/extension/content.js +246 -28
- package/mcp-bridge/README.md +73 -0
- package/mcp-bridge/package.json +10 -0
- package/mcp-bridge/server.mjs +161 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -8,6 +8,7 @@ Specter is a Vite plugin that overlays an element inspector on your dev server.
|
|
|
8
8
|
|
|
9
9
|
- Your project must use **Vite** as its dev server
|
|
10
10
|
- Works with React, Vue, Svelte, or plain HTML served through Vite
|
|
11
|
+
- Works on **macOS, Windows, and Linux** — shortcuts read the physical keys, so Mac's Option = Windows/Linux Alt, and Cmd+C = Ctrl+C
|
|
11
12
|
|
|
12
13
|
## Install
|
|
13
14
|
|
|
@@ -46,7 +47,9 @@ If you ever need Specter gone entirely, remove `specter()` from the Vite config
|
|
|
46
47
|
|
|
47
48
|
### Toggle on/off
|
|
48
49
|
|
|
49
|
-
Press **Ctrl+
|
|
50
|
+
Press **Ctrl+Alt+Z** to activate Specter (on a Mac the Alt key is labelled **Option**, so it's Ctrl+Option+Z — same keys). A small zap icon appears at the bottom-left. Press again (or **Esc**) to hide it — your Specs are kept and reappear when you reactivate.
|
|
51
|
+
|
|
52
|
+
**Shortcut taken by your browser?** Some setups bind `Ctrl+Alt+Z` (e.g. a Firefox side-panel extension). You can rebind Specter's toggle to any chord in seconds — from the overlay (**L** → Keyboard shortcuts → **Change**) or the DevTools console (`window.__specter.setShortcut('ctrl+alt+p')`), no config edit or restart. See [Change the toggle shortcut](#change-the-toggle-shortcut-without-touching-config). There's no chord that's free in every browser, so it's fully rebindable rather than fixed.
|
|
50
53
|
|
|
51
54
|
### Three modes
|
|
52
55
|
|
|
@@ -55,7 +58,7 @@ Specter has three modes; switching mode only changes *what's shown on screen*
|
|
|
55
58
|
| Mode | Enter | Shows |
|
|
56
59
|
|------|-------|-------|
|
|
57
60
|
| **Properties** (default) | — | Styles tooltip on hover |
|
|
58
|
-
| **Measure** | tap **Option** | Figma-style spacing to neighbors |
|
|
61
|
+
| **Measure** | tap **Option / Alt** | Figma-style spacing to neighbors |
|
|
59
62
|
| **Comment** | **C** | Just an outline — for clean design review |
|
|
60
63
|
|
|
61
64
|
### Inspect an element (Properties mode)
|
|
@@ -71,7 +74,7 @@ Specter has three modes; switching mode only changes *what's shown on screen*
|
|
|
71
74
|
|
|
72
75
|
### Measure spacing (Measure mode)
|
|
73
76
|
|
|
74
|
-
Tap **Option** to switch to Measure mode. Hover any element to see distances to its surrounding neighbors. Press **M** while hovering to **pin** that element, then hover another to measure the gap or inset between the two; **M** again unpins. Tap **Option** again to return to Properties.
|
|
77
|
+
Tap **Option / Alt** to switch to Measure mode. Hover any element to see distances to its surrounding neighbors. Press **M** while hovering to **pin** that element, then hover another to measure the gap or inset between the two; **M** again unpins. Tap **Option / Alt** again to return to Properties.
|
|
75
78
|
|
|
76
79
|
### Mark a Spec (and optionally annotate)
|
|
77
80
|
|
|
@@ -87,7 +90,7 @@ Press **L** to open the Specs review panel. It lists every Spec; hover a row to
|
|
|
87
90
|
|
|
88
91
|
### Copy to your AI
|
|
89
92
|
|
|
90
|
-
Press **Cmd+C
|
|
93
|
+
Press **Cmd+C** (**Ctrl+C** on Windows/Linux):
|
|
91
94
|
|
|
92
95
|
- **With Specs marked** → copies **all** of them at once. Each block leads with your `✏️ CHANGE:` note (if any).
|
|
93
96
|
- **With nothing marked** → copies the hovered element's properties (or, in Measure mode, the measurement).
|
|
@@ -129,16 +132,18 @@ Specs aren't only for your AI — you can send them to a **person**. Mark commen
|
|
|
129
132
|
|
|
130
133
|
| Action | Shortcut |
|
|
131
134
|
|--------|----------|
|
|
132
|
-
| Toggle Specter | **Ctrl+
|
|
135
|
+
| Toggle Specter | **Ctrl+Alt+Z** (Ctrl+Option+Z on Mac) |
|
|
133
136
|
| Inspect element | Hover (while active) |
|
|
134
137
|
| Mark a Spec (+ optional note) | **P** |
|
|
135
|
-
| Copy (all Specs, or hovered element) | **Cmd+C** |
|
|
136
|
-
| Measure mode (toggle) | **Option** (tap) |
|
|
138
|
+
| Copy (all Specs, or hovered element) | **Cmd+C** / **Ctrl+C** |
|
|
139
|
+
| Measure mode (toggle) | **Option / Alt** (tap) |
|
|
137
140
|
| Pin / unpin for measuring | **M** (in Measure mode) |
|
|
138
141
|
| Comment mode (toggle) | **C** |
|
|
139
142
|
| Specs panel (toggle) | **L** |
|
|
140
143
|
| Close panel / hide Specter | **Esc** |
|
|
141
144
|
|
|
145
|
+
> **Mac / Windows / Linux** — the keys are the same everywhere; only the labels differ. Mac's **Option** key is **Alt** on Windows/Linux, and **Cmd+C** is **Ctrl+C**. Specter reads the physical modifier, so every shortcut works on all three.
|
|
146
|
+
|
|
142
147
|
## Optional: push Specs straight to Claude Code (`/spectify`)
|
|
143
148
|
|
|
144
149
|
Instead of copy-paste, Specter can auto-sync your Specs to a local bridge that Claude Code reads on demand. Enable it in your Vite config:
|
|
@@ -147,7 +152,22 @@ Instead of copy-paste, Specter can auto-sync your Specs to a local bridge that C
|
|
|
147
152
|
specter({ claudeBridge: true })
|
|
148
153
|
```
|
|
149
154
|
|
|
150
|
-
With it on, every Spec you mark auto-syncs (watch for the sync dot in the panel) to a local bridge at `http://127.0.0.1:8787
|
|
155
|
+
With it on, every Spec you mark auto-syncs (watch for the sync dot in the panel) to a local bridge at `http://127.0.0.1:8787`, and Claude Code pulls from it — no copy-paste.
|
|
156
|
+
|
|
157
|
+
### Recommended: install the Claude Code plugin
|
|
158
|
+
|
|
159
|
+
The plugin bundles the `/spectify` command **and** the bridge as an MCP server, so Claude Code launches the bridge for you — nothing to run in a terminal:
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
/plugin marketplace add setugk/vite-plugin-specter
|
|
163
|
+
/plugin install specter@specter
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Then in Claude Code just run **`/spectify`** (or say "apply my Specter notes" — the plugin also exposes `pop_specs`/`peek_specs` tools). One bridge can serve several projects; `/spectify <port>` scopes to one.
|
|
167
|
+
|
|
168
|
+
### Manual (no plugin)
|
|
169
|
+
|
|
170
|
+
Prefer not to install the plugin? Run the bridge yourself and drop the command in by hand — see [`mcp-bridge/`](https://github.com/setugk/vite-plugin-specter/tree/main/mcp-bridge) for the `server.mjs` + `spectify.md`.
|
|
151
171
|
|
|
152
172
|
`claudeBridge` is **off by default** — the shipped plugin never opens a connection unless you enable it.
|
|
153
173
|
|
|
@@ -159,7 +179,20 @@ specter({ shortcuts: { activate: 'ctrl+shift+i' } }) // custom activate shortcu
|
|
|
159
179
|
specter({ claudeBridge: true }) // enable the Claude Code bridge
|
|
160
180
|
```
|
|
161
181
|
|
|
162
|
-
The `activate` shortcut accepts any combination of `ctrl`, `alt`, `shift`, `meta`/`cmd`, and a key. Default is `ctrl+alt+z`.
|
|
182
|
+
The `activate` shortcut accepts any combination of `ctrl`, `alt`, `shift`, `meta`/`cmd`, and a key. Default is `ctrl+alt+z`. This value is just the **default** — see below for changing it live.
|
|
183
|
+
|
|
184
|
+
### Change the toggle shortcut without touching config
|
|
185
|
+
|
|
186
|
+
The `vite.config` value is only the starting point. To rebind on the fly (no config edit, no restart):
|
|
187
|
+
|
|
188
|
+
- **In the overlay** — open the Specs panel (**L**), expand **Keyboard shortcuts**, and hit **Change** next to the toggle. Press your new combo. It's saved per site in `localStorage` and overrides the config default; **Reset** returns to the default.
|
|
189
|
+
- **From the DevTools console** — handy if your current chord collides with a browser shortcut and you *can't* open the overlay:
|
|
190
|
+
```js
|
|
191
|
+
window.__specter.setShortcut('ctrl+alt+p') // rebind
|
|
192
|
+
window.__specter.toggle() // open/close Specter
|
|
193
|
+
window.__specter.resetShortcut() // back to the config default
|
|
194
|
+
```
|
|
195
|
+
(`window.__specter` is dev-only and, like the rest of Specter, never ships to production.)
|
|
163
196
|
|
|
164
197
|
## Optional: `data-style` attribute
|
|
165
198
|
|
package/dist/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1177
|
-
|
|
1178
|
-
|
|
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 <
|
|
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
|
-
|
|
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
|
-
//
|
|
1634
|
-
//
|
|
1635
|
-
//
|
|
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') {
|
|
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
|
-
//
|
|
1807
|
-
// and fixed
|
|
1808
|
-
|
|
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
|
|
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
|
})();
|