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
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
|
-
|
|
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
|
|
@@ -97,6 +107,45 @@ function getClientScript(options) {
|
|
|
97
107
|
function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
|
|
98
108
|
function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
|
|
99
109
|
|
|
110
|
+
// \u2500\u2500\u2500 Hydration-proof mounting \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
|
|
111
|
+
// We inject at document_end, but frameworks that hydrate <body> (Next.js App
|
|
112
|
+
// Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
|
|
113
|
+
// didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
|
|
114
|
+
// vanish; badges added later survive because they're created post-hydration).
|
|
115
|
+
// Track our persistent singletons and re-attach any that gets detached, so the
|
|
116
|
+
// UI survives the hydration pass and later SPA re-renders. The parent is resolved
|
|
117
|
+
// fresh on each remount so a wholesale body/head element swap is handled too.
|
|
118
|
+
var _mounted = [];
|
|
119
|
+
function mount(node, where) {
|
|
120
|
+
var parent = where === 'head' ? document.head : document.body;
|
|
121
|
+
if (parent) parent.appendChild(node);
|
|
122
|
+
_mounted.push({ node: node, where: where });
|
|
123
|
+
return node;
|
|
124
|
+
}
|
|
125
|
+
var _remountQueued = false;
|
|
126
|
+
function remountDetached() {
|
|
127
|
+
_remountQueued = false;
|
|
128
|
+
for (var i = 0; i < _mounted.length; i++) {
|
|
129
|
+
var m = _mounted[i];
|
|
130
|
+
if (m.node.isConnected) continue;
|
|
131
|
+
var p = m.where === 'head' ? document.head : document.body;
|
|
132
|
+
if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
|
|
137
|
+
var _mo = new MutationObserver(function () {
|
|
138
|
+
if (_remountQueued) return;
|
|
139
|
+
_remountQueued = true;
|
|
140
|
+
_defer(remountDetached); // coalesce a burst of mutations into one restore pass
|
|
141
|
+
});
|
|
142
|
+
// childList on the roots is enough \u2014 every singleton is a direct child of
|
|
143
|
+
// body/head; watching documentElement too catches a body/head element swap.
|
|
144
|
+
_mo.observe(document.documentElement, { childList: true });
|
|
145
|
+
_mo.observe(document.body, { childList: true });
|
|
146
|
+
_mo.observe(document.head, { childList: true });
|
|
147
|
+
} catch (e) {}
|
|
148
|
+
|
|
100
149
|
// Attach a hover effect to an interactive icon/button: apply the "on" styles
|
|
101
150
|
// while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
|
|
102
151
|
function hoverFx(el, on, off) {
|
|
@@ -123,7 +172,7 @@ function getClientScript(options) {
|
|
|
123
172
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
124
173
|
display: 'none',
|
|
125
174
|
});
|
|
126
|
-
|
|
175
|
+
mount(tooltip);
|
|
127
176
|
|
|
128
177
|
// Measure overlay
|
|
129
178
|
var measureOverlay = document.createElement('div');
|
|
@@ -135,7 +184,7 @@ function getClientScript(options) {
|
|
|
135
184
|
pointerEvents: 'none',
|
|
136
185
|
display: 'none',
|
|
137
186
|
});
|
|
138
|
-
|
|
187
|
+
mount(measureOverlay);
|
|
139
188
|
|
|
140
189
|
// \u2500\u2500\u2500 Pill \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\u2500
|
|
141
190
|
var pillWrap = document.createElement('div');
|
|
@@ -211,41 +260,34 @@ function getClientScript(options) {
|
|
|
211
260
|
var pillText = document.createElement('span');
|
|
212
261
|
Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
|
|
213
262
|
|
|
263
|
+
// Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
|
|
264
|
+
// (Open/Close) and always names the shortcut.
|
|
214
265
|
var listBtn = document.createElement('span');
|
|
215
|
-
listBtn.
|
|
216
|
-
listBtn.title = 'Show Specs panel (L)';
|
|
266
|
+
listBtn.title = 'Toggle Specs panel (L)';
|
|
217
267
|
Object.assign(listBtn.style, {
|
|
218
268
|
display: 'none',
|
|
219
269
|
alignItems: 'center',
|
|
270
|
+
gap: '6px',
|
|
220
271
|
cursor: 'pointer',
|
|
221
272
|
color: '#fff',
|
|
273
|
+
fontSize: '11px',
|
|
222
274
|
flexShrink: '0',
|
|
223
|
-
padding: '4px
|
|
275
|
+
padding: '4px 10px',
|
|
224
276
|
marginLeft: '2px',
|
|
225
277
|
borderRadius: '999px',
|
|
226
278
|
background: 'rgba(255,255,255,0.16)',
|
|
227
279
|
});
|
|
280
|
+
var listIcon = document.createElement('span');
|
|
281
|
+
listIcon.innerHTML = LIST;
|
|
282
|
+
Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
|
|
283
|
+
var listLabel = document.createElement('span');
|
|
284
|
+
listBtn.appendChild(listIcon);
|
|
285
|
+
listBtn.appendChild(listLabel);
|
|
286
|
+
function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
|
|
287
|
+
updateListBtn();
|
|
228
288
|
listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
|
|
229
289
|
hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
230
290
|
|
|
231
|
-
var clearBtn = document.createElement('span');
|
|
232
|
-
clearBtn.textContent = '\u2715 Delete all';
|
|
233
|
-
clearBtn.title = 'Delete all annotations';
|
|
234
|
-
Object.assign(clearBtn.style, {
|
|
235
|
-
display: 'none',
|
|
236
|
-
cursor: 'pointer',
|
|
237
|
-
color: '#fff',
|
|
238
|
-
fontSize: '11px',
|
|
239
|
-
fontWeight: '600',
|
|
240
|
-
flexShrink: '0',
|
|
241
|
-
padding: '2px 8px',
|
|
242
|
-
marginLeft: '2px',
|
|
243
|
-
borderRadius: '999px',
|
|
244
|
-
background: 'rgba(255,255,255,0.16)',
|
|
245
|
-
});
|
|
246
|
-
clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
|
|
247
|
-
hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
248
|
-
|
|
249
291
|
var chevron = document.createElement('span');
|
|
250
292
|
chevron.textContent = '\u203A';
|
|
251
293
|
chevron.title = 'Move to other side';
|
|
@@ -275,16 +317,15 @@ function getClientScript(options) {
|
|
|
275
317
|
pill.appendChild(pillSync);
|
|
276
318
|
pill.appendChild(pillText);
|
|
277
319
|
pill.appendChild(listBtn);
|
|
278
|
-
pill.appendChild(clearBtn);
|
|
279
320
|
pill.appendChild(chevron);
|
|
280
321
|
pillWrap.appendChild(pill);
|
|
281
|
-
|
|
322
|
+
mount(pillWrap);
|
|
282
323
|
|
|
283
324
|
// Keyframes for the sync spinner (injected once).
|
|
284
325
|
var spinStyle = document.createElement('style');
|
|
285
326
|
spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
|
|
286
327
|
markUI(spinStyle);
|
|
287
|
-
|
|
328
|
+
mount(spinStyle, 'head');
|
|
288
329
|
|
|
289
330
|
// Shared dot styling for the control-bar AND side-panel sync indicators, so they
|
|
290
331
|
// always match: spinner while syncing, green when synced, red on error.
|
|
@@ -336,7 +377,7 @@ function getClientScript(options) {
|
|
|
336
377
|
// The mode is shown at all times (persistent prefix), so you always know whether
|
|
337
378
|
// hovering shows properties, measurements, or nothing (Comment).
|
|
338
379
|
function modeLabel() {
|
|
339
|
-
return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
|
|
380
|
+
return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
|
|
340
381
|
}
|
|
341
382
|
|
|
342
383
|
function expandPill(text) {
|
|
@@ -344,7 +385,7 @@ function getClientScript(options) {
|
|
|
344
385
|
pillText.style.display = 'inline';
|
|
345
386
|
chevron.style.display = 'inline';
|
|
346
387
|
listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
|
|
347
|
-
|
|
388
|
+
updateListBtn();
|
|
348
389
|
pillExpanded = true;
|
|
349
390
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
350
391
|
// side panel now, so the pill stays short.
|
|
@@ -359,14 +400,12 @@ function getClientScript(options) {
|
|
|
359
400
|
pill.style.maxWidth = '220px';
|
|
360
401
|
chevron.style.display = 'none';
|
|
361
402
|
listBtn.style.display = 'none';
|
|
362
|
-
clearBtn.style.display = 'none';
|
|
363
403
|
pillExpanded = false;
|
|
364
404
|
}
|
|
365
405
|
|
|
366
406
|
function flashMode() {
|
|
367
407
|
if (pillExpanded && pillWrap.matches(':hover')) return;
|
|
368
|
-
|
|
369
|
-
expandPill(text);
|
|
408
|
+
expandPill(modeLabel());
|
|
370
409
|
clearTimeout(flashTimer);
|
|
371
410
|
flashTimer = setTimeout(function () {
|
|
372
411
|
if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
|
|
@@ -492,10 +531,13 @@ function getClientScript(options) {
|
|
|
492
531
|
var cur = el;
|
|
493
532
|
for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
|
|
494
533
|
var part = cur.tagName.toLowerCase();
|
|
495
|
-
if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
|
|
534
|
+
if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
|
|
496
535
|
var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
|
|
497
536
|
if (cls.length) {
|
|
498
|
-
|
|
537
|
+
// Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
|
|
538
|
+
// invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
|
|
539
|
+
// throw on query, so they must be CSS.escape-d first.
|
|
540
|
+
part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
|
|
499
541
|
} else if (cur.parentElement) {
|
|
500
542
|
var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
|
|
501
543
|
if (sameTag.length > 1) {
|
|
@@ -575,13 +617,13 @@ function getClientScript(options) {
|
|
|
575
617
|
var i, v;
|
|
576
618
|
var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
|
|
577
619
|
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; } }
|
|
578
|
-
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
|
|
620
|
+
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
|
|
579
621
|
var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
|
|
580
622
|
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) + '"'; } }
|
|
581
623
|
var txt = ownText(el);
|
|
582
624
|
if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
|
|
583
625
|
var cls = ownClass(el);
|
|
584
|
-
if (cls) return '.' + cls;
|
|
626
|
+
if (cls) return '.' + cssEsc(cls);
|
|
585
627
|
return getSelector(el); // fallback: the CSS path
|
|
586
628
|
}
|
|
587
629
|
|
|
@@ -683,6 +725,7 @@ function getClientScript(options) {
|
|
|
683
725
|
var groups = [];
|
|
684
726
|
for (var i = 0; i < specs.length; i++) {
|
|
685
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
|
|
686
729
|
if (s.kind === 'element' && s.el) {
|
|
687
730
|
for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
|
|
688
731
|
}
|
|
@@ -755,11 +798,20 @@ function getClientScript(options) {
|
|
|
755
798
|
function elementPath(el) {
|
|
756
799
|
if (!el || el.nodeType !== 1) return '';
|
|
757
800
|
var parts = [], cur = el;
|
|
758
|
-
|
|
759
|
-
|
|
801
|
+
// Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
|
|
802
|
+
// nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
|
|
803
|
+
// child index (the old div:nth-child(11) bug \u2014 matched a different node, or
|
|
804
|
+
// none, on the recipient). nth-of-type counts only same-tag siblings, so it
|
|
805
|
+
// survives that. Deeper cap (12) so the chain reaches a rooting anchor.
|
|
806
|
+
while (cur && cur.nodeType === 1 && parts.length < 12) {
|
|
807
|
+
if (cur === document.body) { parts.unshift('body'); break; }
|
|
808
|
+
if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
|
|
760
809
|
var seg = cur.tagName.toLowerCase();
|
|
761
810
|
var parent = cur.parentElement;
|
|
762
|
-
if (parent)
|
|
811
|
+
if (parent) {
|
|
812
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
|
|
813
|
+
if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
|
|
814
|
+
}
|
|
763
815
|
parts.unshift(seg);
|
|
764
816
|
cur = cur.parentElement;
|
|
765
817
|
}
|
|
@@ -835,6 +887,11 @@ function getClientScript(options) {
|
|
|
835
887
|
var vis = [];
|
|
836
888
|
for (var i = 0; i < specs.length; i++) {
|
|
837
889
|
var s = specs[i];
|
|
890
|
+
// Badges created at import-time can be wiped by a framework hydrating <body>
|
|
891
|
+
// (same cause as the singleton mount guard). Re-attach a detached wrap so the
|
|
892
|
+
// pin reappears; a removed spec is already out of the specs array, so this
|
|
893
|
+
// never resurrects a deleted badge.
|
|
894
|
+
if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
|
|
838
895
|
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
839
896
|
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
840
897
|
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
@@ -909,10 +966,17 @@ function getClientScript(options) {
|
|
|
909
966
|
function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
|
|
910
967
|
function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
|
|
911
968
|
|
|
912
|
-
|
|
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); }
|
|
913
977
|
|
|
914
978
|
function updateBadgeContent(spec) {
|
|
915
|
-
spec
|
|
979
|
+
paintBadgeState(spec, specs.indexOf(spec));
|
|
916
980
|
if (spec.note) spec.noteSpan.textContent = spec.note;
|
|
917
981
|
else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
|
|
918
982
|
}
|
|
@@ -1034,13 +1098,50 @@ function getClientScript(options) {
|
|
|
1034
1098
|
saveSpecs();
|
|
1035
1099
|
}
|
|
1036
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
|
+
|
|
1128
|
+
// Drop every imported (shared) spec, leaving the user's own local ones. Used by
|
|
1129
|
+
// importComments so a received share replaces the prior set instead of stacking.
|
|
1130
|
+
function removeSharedSpecs() {
|
|
1131
|
+
if (highlightSpec && highlightSpec.shared) highlightSpec = null;
|
|
1132
|
+
if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
|
|
1133
|
+
for (var i = specs.length - 1; i >= 0; i--) {
|
|
1134
|
+
if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1037
1138
|
// \u2500\u2500\u2500 Reload insurance (localStorage, per-URL, zero network) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1038
1139
|
// Persist only the serializable parts of each Spec; the live element ref and
|
|
1039
1140
|
// DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
|
|
1040
1141
|
function saveSpecs() {
|
|
1041
1142
|
try {
|
|
1042
1143
|
if (!specs.length) localStorage.removeItem(STORAGE_KEY);
|
|
1043
|
-
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 }; })));
|
|
1044
1145
|
} catch (e) {}
|
|
1045
1146
|
scheduleSync(); // keep the Claude bridge mirrored to the current Specs
|
|
1046
1147
|
}
|
|
@@ -1050,7 +1151,7 @@ function getClientScript(options) {
|
|
|
1050
1151
|
try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
|
|
1051
1152
|
if (!Array.isArray(data) || !data.length) return;
|
|
1052
1153
|
data.forEach(function (d) {
|
|
1053
|
-
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 };
|
|
1054
1155
|
specs.push(spec);
|
|
1055
1156
|
createBadge(spec);
|
|
1056
1157
|
});
|
|
@@ -1065,11 +1166,13 @@ function getClientScript(options) {
|
|
|
1065
1166
|
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1066
1167
|
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1067
1168
|
|
|
1068
|
-
// Normalized element signature
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1071
|
-
//
|
|
1072
|
-
//
|
|
1169
|
+
// Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
|
|
1170
|
+
// element a shared comment belongs to (and to scan for it when selectors fail).
|
|
1171
|
+
// Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
|
|
1172
|
+
// price) was false-tripping this and blocking re-anchor on otherwise-identical
|
|
1173
|
+
// pages. tag + sorted own classes + key attrs + child count identifies the node
|
|
1174
|
+
// without that fragility. (Trade-off: pure text edits under a stable node no
|
|
1175
|
+
// longer read as "changed" \u2014 placement is favoured over change-detection.)
|
|
1073
1176
|
function normSig(el) {
|
|
1074
1177
|
if (!el || el.nodeType !== 1) return '';
|
|
1075
1178
|
var cls = Array.prototype.slice.call(el.classList)
|
|
@@ -1077,8 +1180,7 @@ function getClientScript(options) {
|
|
|
1077
1180
|
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1078
1181
|
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1079
1182
|
}).filter(Boolean).join('|');
|
|
1080
|
-
|
|
1081
|
-
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
|
|
1183
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
|
|
1082
1184
|
}
|
|
1083
1185
|
// djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1084
1186
|
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
@@ -1090,51 +1192,91 @@ function getClientScript(options) {
|
|
|
1090
1192
|
return (h >>> 0).toString(36);
|
|
1091
1193
|
}
|
|
1092
1194
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
//
|
|
1096
|
-
|
|
1097
|
-
|
|
1195
|
+
function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
|
|
1196
|
+
|
|
1197
|
+
// Every element whose OWN text matches (trunc = the stored anchor was cut to 40
|
|
1198
|
+
// chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
|
|
1199
|
+
// so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
|
|
1200
|
+
function findAllByOwnText(val, trunc) {
|
|
1201
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
|
|
1098
1202
|
for (var i = 0; i < all.length; i++) {
|
|
1099
1203
|
var el = all[i];
|
|
1100
1204
|
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1101
1205
|
var t = ownText(el);
|
|
1102
1206
|
if (!t) continue;
|
|
1103
|
-
if (trunc ? (t.slice(0, 40) === val) : (t === val))
|
|
1207
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
|
|
1104
1208
|
}
|
|
1105
|
-
return
|
|
1209
|
+
return out;
|
|
1106
1210
|
}
|
|
1107
1211
|
|
|
1108
|
-
//
|
|
1109
|
-
// 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1110
|
-
//
|
|
1111
|
-
//
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
if (!find) return null;
|
|
1212
|
+
// Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
|
|
1213
|
+
// querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1214
|
+
// scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
|
|
1215
|
+
// fingerprint rather than rejecting anything ambiguous up front.
|
|
1216
|
+
function resolveFindCandidates(find) {
|
|
1217
|
+
if (!find) return [];
|
|
1115
1218
|
var c = find.charAt(0);
|
|
1116
|
-
if (c === '#' || c === '.' || c === '[') return
|
|
1219
|
+
if (c === '#' || c === '.' || c === '[') return queryAll(find);
|
|
1117
1220
|
var q = find.indexOf(' "');
|
|
1118
1221
|
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1119
1222
|
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1120
1223
|
if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
|
|
1121
|
-
if (attr === 'text') return
|
|
1122
|
-
if (val.indexOf('"') < 0)
|
|
1123
|
-
return
|
|
1224
|
+
if (attr === 'text') return findAllByOwnText(val, trunc);
|
|
1225
|
+
if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
|
|
1226
|
+
return [];
|
|
1227
|
+
}
|
|
1228
|
+
return queryAll(find); // a bare CSS path
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
|
|
1232
|
+
// just a change-detector: gather every candidate from the find anchor AND the
|
|
1233
|
+
// nth-child path, then return the one whose signature matches \u2014 so a locator
|
|
1234
|
+
// shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
|
|
1235
|
+
// instead of failing as ambiguous. With no fp match: hand back a lone candidate
|
|
1236
|
+
// (so importComments can say "changed"), else null ("couldn't find").
|
|
1237
|
+
// Whole-DOM scan for the one element whose structural signature matches \u2014 the
|
|
1238
|
+
// last-resort re-finder when both the find anchor and the nth-of-type path fail
|
|
1239
|
+
// (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
|
|
1240
|
+
// (ambiguous, don't guess). Skips Specter's own UI.
|
|
1241
|
+
function findByFingerprint(fp) {
|
|
1242
|
+
if (!fp) return null;
|
|
1243
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1244
|
+
for (var i = 0; i < all.length; i++) {
|
|
1245
|
+
var el = all[i];
|
|
1246
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1247
|
+
if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
|
|
1124
1248
|
}
|
|
1125
|
-
return
|
|
1249
|
+
return hit;
|
|
1126
1250
|
}
|
|
1127
1251
|
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
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).
|
|
1132
1265
|
function reFindShared(item) {
|
|
1133
|
-
var byFind =
|
|
1134
|
-
if (byFind
|
|
1135
|
-
var byPath =
|
|
1136
|
-
if (byPath
|
|
1137
|
-
|
|
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]);
|
|
1272
|
+
if (item.fp) {
|
|
1273
|
+
for (i = 0; i < cands.length; i++) if (fingerprint(cands[i]) === item.fp) return cands[i];
|
|
1274
|
+
var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
|
|
1275
|
+
if (scan) return scan;
|
|
1276
|
+
}
|
|
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;
|
|
1138
1280
|
}
|
|
1139
1281
|
|
|
1140
1282
|
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
@@ -1165,12 +1307,19 @@ function getClientScript(options) {
|
|
|
1165
1307
|
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
|
|
1166
1308
|
return 0;
|
|
1167
1309
|
}
|
|
1310
|
+
// A received share REPLACES the previously-imported set: re-opening the same
|
|
1311
|
+
// link (or a refreshed one) gives exactly that set, never a stacked-up pile of
|
|
1312
|
+
// duplicates across rounds. Your OWN local specs (shared:false) are untouched.
|
|
1313
|
+
removeSharedSpecs();
|
|
1168
1314
|
var added = 0;
|
|
1169
1315
|
comments.forEach(function (item) {
|
|
1170
1316
|
if (!item) return;
|
|
1317
|
+
// reFindShared already used the fingerprint to pick/scan the right element, so
|
|
1318
|
+
// trust its result: if it located a node, PLACE the comment. Only a genuine
|
|
1319
|
+
// no-match is MISSING \u2014 we no longer hide a found element just because its
|
|
1320
|
+
// signature drifted (that over-fired on dynamic pages and buried real pins).
|
|
1171
1321
|
var el = reFindShared(item), missing = false, reason = '';
|
|
1172
1322
|
if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
|
|
1173
|
-
else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
|
|
1174
1323
|
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1175
1324
|
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1176
1325
|
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 };
|
|
@@ -1531,7 +1680,7 @@ function getClientScript(options) {
|
|
|
1531
1680
|
var rows = [
|
|
1532
1681
|
['P', 'mark / comment the hovered element'],
|
|
1533
1682
|
['C', 'toggle Comment mode (hide properties)'],
|
|
1534
|
-
['\\u2325 Option', 'toggle Measure mode'],
|
|
1683
|
+
['\\u2325 Option / Alt', 'toggle Measure mode'],
|
|
1535
1684
|
['M', 'pin an element to measure from'],
|
|
1536
1685
|
['Cmd/Ctrl+C', 'copy all Specs'],
|
|
1537
1686
|
['L', 'toggle this panel'],
|
|
@@ -1547,9 +1696,48 @@ function getClientScript(options) {
|
|
|
1547
1696
|
head.appendChild(headText);
|
|
1548
1697
|
var body = document.createElement('div');
|
|
1549
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
|
+
|
|
1550
1738
|
rows.forEach(function (r) {
|
|
1551
1739
|
var row = document.createElement('div');
|
|
1552
|
-
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '
|
|
1740
|
+
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
|
|
1553
1741
|
var k = document.createElement('span');
|
|
1554
1742
|
k.textContent = r[0];
|
|
1555
1743
|
Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
@@ -1569,20 +1757,48 @@ function getClientScript(options) {
|
|
|
1569
1757
|
panelKeys.appendChild(body);
|
|
1570
1758
|
})();
|
|
1571
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
|
+
|
|
1572
1776
|
panelWrap.appendChild(panelHead);
|
|
1573
1777
|
panelWrap.appendChild(panelTools);
|
|
1574
1778
|
panelWrap.appendChild(panelHint);
|
|
1575
1779
|
panelWrap.appendChild(panelList);
|
|
1576
1780
|
panelWrap.appendChild(panelKeys);
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
//
|
|
1581
|
-
//
|
|
1781
|
+
panelWrap.appendChild(panelFoot);
|
|
1782
|
+
mount(panelWrap);
|
|
1783
|
+
|
|
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;
|
|
1582
1790
|
panelWrap.addEventListener('wheel', function (e) {
|
|
1583
|
-
var canScroll = panelList.scrollHeight > panelList.clientHeight;
|
|
1584
|
-
if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
|
|
1585
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
|
+
}
|
|
1586
1802
|
}, { passive: false });
|
|
1587
1803
|
|
|
1588
1804
|
// \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
|
|
@@ -1595,7 +1811,7 @@ function getClientScript(options) {
|
|
|
1595
1811
|
setPillSync(s); // control-bar dot
|
|
1596
1812
|
applyDotState(dot, s); // side-panel dot \u2014 same spinner/green/red
|
|
1597
1813
|
if (s === 'syncing') { dotLabel.textContent = 'Syncing\u2026'; }
|
|
1598
|
-
else if (s === 'synced') {
|
|
1814
|
+
else if (s === 'synced') { var open = specs.length - resolvedCount(); dotLabel.textContent = open > 0 ? (open + (open === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
|
|
1599
1815
|
else { dotLabel.textContent = 'Bridge offline'; }
|
|
1600
1816
|
}
|
|
1601
1817
|
function doSync() {
|
|
@@ -1642,11 +1858,28 @@ function getClientScript(options) {
|
|
|
1642
1858
|
panelList.appendChild(empty);
|
|
1643
1859
|
return;
|
|
1644
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
|
+
}
|
|
1645
1877
|
var focusEditor = null, measures = [];
|
|
1646
1878
|
specs.forEach(function (spec, i) {
|
|
1647
1879
|
var missing = !!spec.missing;
|
|
1648
1880
|
var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
|
|
1649
1881
|
var editing = (panelEditSpec === spec);
|
|
1882
|
+
var resolved = !!spec.resolved;
|
|
1650
1883
|
var row = document.createElement('div');
|
|
1651
1884
|
Object.assign(row.style, {
|
|
1652
1885
|
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
|
|
@@ -1657,7 +1890,7 @@ function getClientScript(options) {
|
|
|
1657
1890
|
badge.textContent = String(i + 1);
|
|
1658
1891
|
Object.assign(badge.style, {
|
|
1659
1892
|
flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
|
|
1660
|
-
background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
|
|
1893
|
+
background: resolved ? GREEN : BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
|
|
1661
1894
|
border: '1px solid #fff', boxSizing: 'border-box',
|
|
1662
1895
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
1663
1896
|
transition: 'background 0.12s ease',
|
|
@@ -1729,6 +1962,7 @@ function getClientScript(options) {
|
|
|
1729
1962
|
cursor: spec.note ? 'pointer' : 'default',
|
|
1730
1963
|
});
|
|
1731
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'; }
|
|
1732
1966
|
|
|
1733
1967
|
// Floated into the top-right corner so they don't reserve note width. On hover
|
|
1734
1968
|
// they get a background that fades in from the left, masking the note text behind
|
|
@@ -1747,11 +1981,14 @@ function getClientScript(options) {
|
|
|
1747
1981
|
// Show an expand toggle only when the collapsed note actually overflows.
|
|
1748
1982
|
var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
|
|
1749
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' });
|
|
1750
1985
|
var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
|
|
1751
1986
|
var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
|
|
1752
|
-
//
|
|
1753
|
-
// and fixed
|
|
1754
|
-
|
|
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);
|
|
1755
1992
|
actions.appendChild(editBtn);
|
|
1756
1993
|
actions.appendChild(delBtn);
|
|
1757
1994
|
actions.appendChild(chev);
|
|
@@ -1759,9 +1996,27 @@ function getClientScript(options) {
|
|
|
1759
1996
|
content.appendChild(note);
|
|
1760
1997
|
content.appendChild(meta);
|
|
1761
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
|
+
}
|
|
1762
2017
|
// Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
|
|
1763
2018
|
// never drawn on the page \u2014 design: show missing, don't guess a spot).
|
|
1764
|
-
if (missing) {
|
|
2019
|
+
else if (missing) {
|
|
1765
2020
|
row.style.opacity = '0.7';
|
|
1766
2021
|
var mbar = document.createElement('div');
|
|
1767
2022
|
Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
@@ -1798,15 +2053,15 @@ function getClientScript(options) {
|
|
|
1798
2053
|
note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
|
|
1799
2054
|
row.addEventListener('mouseenter', function () {
|
|
1800
2055
|
row.style.background = 'rgba(255,255,255,0.05)';
|
|
1801
|
-
badge.style.background = PURPLE;
|
|
1802
|
-
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';
|
|
1803
2058
|
actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
|
|
1804
|
-
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
|
|
1805
2060
|
});
|
|
1806
2061
|
row.addEventListener('mouseleave', function () {
|
|
1807
2062
|
row.style.background = 'transparent';
|
|
1808
|
-
badge.style.background = BADGE_IDLE;
|
|
1809
|
-
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';
|
|
1810
2065
|
actions.style.background = 'transparent';
|
|
1811
2066
|
if (highlightSpec === spec) highlightSpec = null;
|
|
1812
2067
|
});
|
|
@@ -1833,8 +2088,8 @@ function getClientScript(options) {
|
|
|
1833
2088
|
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1834
2089
|
}
|
|
1835
2090
|
|
|
1836
|
-
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
1837
|
-
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
|
|
2091
|
+
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
|
|
2092
|
+
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
|
|
1838
2093
|
function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
|
|
1839
2094
|
|
|
1840
2095
|
// \u2500\u2500\u2500 Spec editor (annotation box: add / edit / delete) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
@@ -2234,6 +2489,41 @@ function getClientScript(options) {
|
|
|
2234
2489
|
return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
|
|
2235
2490
|
}
|
|
2236
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
|
+
|
|
2237
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
|
|
2238
2528
|
function onMouseMove(e) {
|
|
2239
2529
|
if (!fiActive) return;
|
|
@@ -2415,7 +2705,21 @@ function getClientScript(options) {
|
|
|
2415
2705
|
importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
|
|
2416
2706
|
scheduleSync(); // mirror restored Specs to the Claude bridge on load
|
|
2417
2707
|
|
|
2418
|
-
console
|
|
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;');
|
|
2419
2723
|
})();`;
|
|
2420
2724
|
}
|
|
2421
2725
|
|