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