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.js
CHANGED
|
@@ -30,7 +30,17 @@ function getClientScript(options) {
|
|
|
30
30
|
var CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
|
31
31
|
var SHARE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><polyline points="16 6 12 2 8 6"></polyline><line x1="12" y1="2" x2="12" y2="15"></line></svg>';
|
|
32
32
|
var IMPORT = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v13"></path><polyline points="8 12 12 16 16 12"></polyline><path d="M4 21h16"></path></svg>';
|
|
33
|
-
|
|
33
|
+
// Toggle chord: the Vite-config value (or the built-in default) is the baseline; a
|
|
34
|
+
// per-origin localStorage override \u2014 set from the panel's rebind UI or window.__specter \u2014
|
|
35
|
+
// wins, so anyone can change it with no config edit and no dev-server restart.
|
|
36
|
+
var ACTIVATE_DEFAULT = ${JSON.stringify(activateShortcut)};
|
|
37
|
+
var ACTIVATE_KEY = '__specter_activate';
|
|
38
|
+
var ACTIVATE = ACTIVATE_DEFAULT;
|
|
39
|
+
try { var _actOv = localStorage.getItem(ACTIVATE_KEY); if (_actOv) ACTIVATE = _actOv; } catch (e) {}
|
|
40
|
+
function shortcutLabel(s) { return String(s || '').split('+').map(function (p) { return p.charAt(0).toUpperCase() + p.slice(1); }).join('+'); }
|
|
41
|
+
function setActivate(combo) { ACTIVATE = combo; try { localStorage.setItem(ACTIVATE_KEY, combo); } catch (e) {} }
|
|
42
|
+
function resetActivate() { ACTIVATE = ACTIVATE_DEFAULT; try { localStorage.removeItem(ACTIVATE_KEY); } catch (e) {} }
|
|
43
|
+
function activateOverridden() { try { return !!localStorage.getItem(ACTIVATE_KEY); } catch (e) { return false; } }
|
|
34
44
|
var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
|
|
35
45
|
|
|
36
46
|
// \u2500\u2500\u2500 State \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
@@ -70,6 +80,45 @@ function getClientScript(options) {
|
|
|
70
80
|
function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
|
|
71
81
|
function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
|
|
72
82
|
|
|
83
|
+
// \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
|
|
84
|
+
// We inject at document_end, but frameworks that hydrate <body> (Next.js App
|
|
85
|
+
// Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
|
|
86
|
+
// didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
|
|
87
|
+
// vanish; badges added later survive because they're created post-hydration).
|
|
88
|
+
// Track our persistent singletons and re-attach any that gets detached, so the
|
|
89
|
+
// UI survives the hydration pass and later SPA re-renders. The parent is resolved
|
|
90
|
+
// fresh on each remount so a wholesale body/head element swap is handled too.
|
|
91
|
+
var _mounted = [];
|
|
92
|
+
function mount(node, where) {
|
|
93
|
+
var parent = where === 'head' ? document.head : document.body;
|
|
94
|
+
if (parent) parent.appendChild(node);
|
|
95
|
+
_mounted.push({ node: node, where: where });
|
|
96
|
+
return node;
|
|
97
|
+
}
|
|
98
|
+
var _remountQueued = false;
|
|
99
|
+
function remountDetached() {
|
|
100
|
+
_remountQueued = false;
|
|
101
|
+
for (var i = 0; i < _mounted.length; i++) {
|
|
102
|
+
var m = _mounted[i];
|
|
103
|
+
if (m.node.isConnected) continue;
|
|
104
|
+
var p = m.where === 'head' ? document.head : document.body;
|
|
105
|
+
if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
|
|
110
|
+
var _mo = new MutationObserver(function () {
|
|
111
|
+
if (_remountQueued) return;
|
|
112
|
+
_remountQueued = true;
|
|
113
|
+
_defer(remountDetached); // coalesce a burst of mutations into one restore pass
|
|
114
|
+
});
|
|
115
|
+
// childList on the roots is enough \u2014 every singleton is a direct child of
|
|
116
|
+
// body/head; watching documentElement too catches a body/head element swap.
|
|
117
|
+
_mo.observe(document.documentElement, { childList: true });
|
|
118
|
+
_mo.observe(document.body, { childList: true });
|
|
119
|
+
_mo.observe(document.head, { childList: true });
|
|
120
|
+
} catch (e) {}
|
|
121
|
+
|
|
73
122
|
// Attach a hover effect to an interactive icon/button: apply the "on" styles
|
|
74
123
|
// while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
|
|
75
124
|
function hoverFx(el, on, off) {
|
|
@@ -96,7 +145,7 @@ function getClientScript(options) {
|
|
|
96
145
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
97
146
|
display: 'none',
|
|
98
147
|
});
|
|
99
|
-
|
|
148
|
+
mount(tooltip);
|
|
100
149
|
|
|
101
150
|
// Measure overlay
|
|
102
151
|
var measureOverlay = document.createElement('div');
|
|
@@ -108,7 +157,7 @@ function getClientScript(options) {
|
|
|
108
157
|
pointerEvents: 'none',
|
|
109
158
|
display: 'none',
|
|
110
159
|
});
|
|
111
|
-
|
|
160
|
+
mount(measureOverlay);
|
|
112
161
|
|
|
113
162
|
// \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
|
|
114
163
|
var pillWrap = document.createElement('div');
|
|
@@ -184,41 +233,34 @@ function getClientScript(options) {
|
|
|
184
233
|
var pillText = document.createElement('span');
|
|
185
234
|
Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
|
|
186
235
|
|
|
236
|
+
// Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
|
|
237
|
+
// (Open/Close) and always names the shortcut.
|
|
187
238
|
var listBtn = document.createElement('span');
|
|
188
|
-
listBtn.
|
|
189
|
-
listBtn.title = 'Show Specs panel (L)';
|
|
239
|
+
listBtn.title = 'Toggle Specs panel (L)';
|
|
190
240
|
Object.assign(listBtn.style, {
|
|
191
241
|
display: 'none',
|
|
192
242
|
alignItems: 'center',
|
|
243
|
+
gap: '6px',
|
|
193
244
|
cursor: 'pointer',
|
|
194
245
|
color: '#fff',
|
|
246
|
+
fontSize: '11px',
|
|
195
247
|
flexShrink: '0',
|
|
196
|
-
padding: '4px
|
|
248
|
+
padding: '4px 10px',
|
|
197
249
|
marginLeft: '2px',
|
|
198
250
|
borderRadius: '999px',
|
|
199
251
|
background: 'rgba(255,255,255,0.16)',
|
|
200
252
|
});
|
|
253
|
+
var listIcon = document.createElement('span');
|
|
254
|
+
listIcon.innerHTML = LIST;
|
|
255
|
+
Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
|
|
256
|
+
var listLabel = document.createElement('span');
|
|
257
|
+
listBtn.appendChild(listIcon);
|
|
258
|
+
listBtn.appendChild(listLabel);
|
|
259
|
+
function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
|
|
260
|
+
updateListBtn();
|
|
201
261
|
listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
|
|
202
262
|
hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
203
263
|
|
|
204
|
-
var clearBtn = document.createElement('span');
|
|
205
|
-
clearBtn.textContent = '\u2715 Delete all';
|
|
206
|
-
clearBtn.title = 'Delete all annotations';
|
|
207
|
-
Object.assign(clearBtn.style, {
|
|
208
|
-
display: 'none',
|
|
209
|
-
cursor: 'pointer',
|
|
210
|
-
color: '#fff',
|
|
211
|
-
fontSize: '11px',
|
|
212
|
-
fontWeight: '600',
|
|
213
|
-
flexShrink: '0',
|
|
214
|
-
padding: '2px 8px',
|
|
215
|
-
marginLeft: '2px',
|
|
216
|
-
borderRadius: '999px',
|
|
217
|
-
background: 'rgba(255,255,255,0.16)',
|
|
218
|
-
});
|
|
219
|
-
clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
|
|
220
|
-
hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
221
|
-
|
|
222
264
|
var chevron = document.createElement('span');
|
|
223
265
|
chevron.textContent = '\u203A';
|
|
224
266
|
chevron.title = 'Move to other side';
|
|
@@ -248,16 +290,15 @@ function getClientScript(options) {
|
|
|
248
290
|
pill.appendChild(pillSync);
|
|
249
291
|
pill.appendChild(pillText);
|
|
250
292
|
pill.appendChild(listBtn);
|
|
251
|
-
pill.appendChild(clearBtn);
|
|
252
293
|
pill.appendChild(chevron);
|
|
253
294
|
pillWrap.appendChild(pill);
|
|
254
|
-
|
|
295
|
+
mount(pillWrap);
|
|
255
296
|
|
|
256
297
|
// Keyframes for the sync spinner (injected once).
|
|
257
298
|
var spinStyle = document.createElement('style');
|
|
258
299
|
spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
|
|
259
300
|
markUI(spinStyle);
|
|
260
|
-
|
|
301
|
+
mount(spinStyle, 'head');
|
|
261
302
|
|
|
262
303
|
// Shared dot styling for the control-bar AND side-panel sync indicators, so they
|
|
263
304
|
// always match: spinner while syncing, green when synced, red on error.
|
|
@@ -309,7 +350,7 @@ function getClientScript(options) {
|
|
|
309
350
|
// The mode is shown at all times (persistent prefix), so you always know whether
|
|
310
351
|
// hovering shows properties, measurements, or nothing (Comment).
|
|
311
352
|
function modeLabel() {
|
|
312
|
-
return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
|
|
353
|
+
return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
|
|
313
354
|
}
|
|
314
355
|
|
|
315
356
|
function expandPill(text) {
|
|
@@ -317,7 +358,7 @@ function getClientScript(options) {
|
|
|
317
358
|
pillText.style.display = 'inline';
|
|
318
359
|
chevron.style.display = 'inline';
|
|
319
360
|
listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
|
|
320
|
-
|
|
361
|
+
updateListBtn();
|
|
321
362
|
pillExpanded = true;
|
|
322
363
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
323
364
|
// side panel now, so the pill stays short.
|
|
@@ -332,14 +373,12 @@ function getClientScript(options) {
|
|
|
332
373
|
pill.style.maxWidth = '220px';
|
|
333
374
|
chevron.style.display = 'none';
|
|
334
375
|
listBtn.style.display = 'none';
|
|
335
|
-
clearBtn.style.display = 'none';
|
|
336
376
|
pillExpanded = false;
|
|
337
377
|
}
|
|
338
378
|
|
|
339
379
|
function flashMode() {
|
|
340
380
|
if (pillExpanded && pillWrap.matches(':hover')) return;
|
|
341
|
-
|
|
342
|
-
expandPill(text);
|
|
381
|
+
expandPill(modeLabel());
|
|
343
382
|
clearTimeout(flashTimer);
|
|
344
383
|
flashTimer = setTimeout(function () {
|
|
345
384
|
if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
|
|
@@ -465,10 +504,13 @@ function getClientScript(options) {
|
|
|
465
504
|
var cur = el;
|
|
466
505
|
for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
|
|
467
506
|
var part = cur.tagName.toLowerCase();
|
|
468
|
-
if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
|
|
507
|
+
if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
|
|
469
508
|
var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
|
|
470
509
|
if (cls.length) {
|
|
471
|
-
|
|
510
|
+
// Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
|
|
511
|
+
// invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
|
|
512
|
+
// throw on query, so they must be CSS.escape-d first.
|
|
513
|
+
part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
|
|
472
514
|
} else if (cur.parentElement) {
|
|
473
515
|
var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
|
|
474
516
|
if (sameTag.length > 1) {
|
|
@@ -548,13 +590,13 @@ function getClientScript(options) {
|
|
|
548
590
|
var i, v;
|
|
549
591
|
var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
|
|
550
592
|
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; } }
|
|
551
|
-
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
|
|
593
|
+
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
|
|
552
594
|
var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
|
|
553
595
|
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) + '"'; } }
|
|
554
596
|
var txt = ownText(el);
|
|
555
597
|
if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
|
|
556
598
|
var cls = ownClass(el);
|
|
557
|
-
if (cls) return '.' + cls;
|
|
599
|
+
if (cls) return '.' + cssEsc(cls);
|
|
558
600
|
return getSelector(el); // fallback: the CSS path
|
|
559
601
|
}
|
|
560
602
|
|
|
@@ -656,6 +698,7 @@ function getClientScript(options) {
|
|
|
656
698
|
var groups = [];
|
|
657
699
|
for (var i = 0; i < specs.length; i++) {
|
|
658
700
|
var s = specs[i], g = null;
|
|
701
|
+
if (s.resolved) continue; // done Specs drop out of both the Cmd+C copy and the bridge sync \u2014 so /spectify never re-applies them
|
|
659
702
|
if (s.kind === 'element' && s.el) {
|
|
660
703
|
for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
|
|
661
704
|
}
|
|
@@ -728,11 +771,20 @@ function getClientScript(options) {
|
|
|
728
771
|
function elementPath(el) {
|
|
729
772
|
if (!el || el.nodeType !== 1) return '';
|
|
730
773
|
var parts = [], cur = el;
|
|
731
|
-
|
|
732
|
-
|
|
774
|
+
// Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
|
|
775
|
+
// nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
|
|
776
|
+
// child index (the old div:nth-child(11) bug \u2014 matched a different node, or
|
|
777
|
+
// none, on the recipient). nth-of-type counts only same-tag siblings, so it
|
|
778
|
+
// survives that. Deeper cap (12) so the chain reaches a rooting anchor.
|
|
779
|
+
while (cur && cur.nodeType === 1 && parts.length < 12) {
|
|
780
|
+
if (cur === document.body) { parts.unshift('body'); break; }
|
|
781
|
+
if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
|
|
733
782
|
var seg = cur.tagName.toLowerCase();
|
|
734
783
|
var parent = cur.parentElement;
|
|
735
|
-
if (parent)
|
|
784
|
+
if (parent) {
|
|
785
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
|
|
786
|
+
if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
|
|
787
|
+
}
|
|
736
788
|
parts.unshift(seg);
|
|
737
789
|
cur = cur.parentElement;
|
|
738
790
|
}
|
|
@@ -808,6 +860,11 @@ function getClientScript(options) {
|
|
|
808
860
|
var vis = [];
|
|
809
861
|
for (var i = 0; i < specs.length; i++) {
|
|
810
862
|
var s = specs[i];
|
|
863
|
+
// Badges created at import-time can be wiped by a framework hydrating <body>
|
|
864
|
+
// (same cause as the singleton mount guard). Re-attach a detached wrap so the
|
|
865
|
+
// pin reappears; a removed spec is already out of the specs array, so this
|
|
866
|
+
// never resurrects a deleted badge.
|
|
867
|
+
if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
|
|
811
868
|
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
812
869
|
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
813
870
|
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
@@ -882,10 +939,17 @@ function getClientScript(options) {
|
|
|
882
939
|
function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
|
|
883
940
|
function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
|
|
884
941
|
|
|
885
|
-
|
|
942
|
+
// Paint a badge's number/\u2713 + done tint. Called by renumber and updateBadgeContent so
|
|
943
|
+
// the on-page pin reflects resolved state everywhere (reflowSpecs never touches these).
|
|
944
|
+
function paintBadgeState(spec, i) {
|
|
945
|
+
spec.num.textContent = String(i + 1); // always the number (for wayfinding) \u2014 green tint carries the "done" signal
|
|
946
|
+
if (spec.cap) { spec.cap.style.background = spec.resolved ? GREEN : PURPLE; spec.cap.style.opacity = spec.resolved ? '0.65' : '1'; }
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function renumber() { for (var i = 0; i < specs.length; i++) paintBadgeState(specs[i], i); }
|
|
886
950
|
|
|
887
951
|
function updateBadgeContent(spec) {
|
|
888
|
-
spec
|
|
952
|
+
paintBadgeState(spec, specs.indexOf(spec));
|
|
889
953
|
if (spec.note) spec.noteSpan.textContent = spec.note;
|
|
890
954
|
else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
|
|
891
955
|
}
|
|
@@ -1007,13 +1071,50 @@ function getClientScript(options) {
|
|
|
1007
1071
|
saveSpecs();
|
|
1008
1072
|
}
|
|
1009
1073
|
|
|
1074
|
+
// \u2500\u2500 Resolved (done) lifecycle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1075
|
+
// A resolved Spec is NOT deleted \u2014 it stays as a record of what changed, greyed with
|
|
1076
|
+
// a \u2713, and drops out of the copy + bridge sync (see groupSpecs) so it's never re-applied.
|
|
1077
|
+
function resolvedCount() { var n = 0; for (var i = 0; i < specs.length; i++) if (specs[i].resolved) n++; return n; }
|
|
1078
|
+
|
|
1079
|
+
function toggleResolved(spec) {
|
|
1080
|
+
spec.resolved = !spec.resolved;
|
|
1081
|
+
if (spec.resolved && panelEditSpec === spec) panelEditSpec = null; // close an open editor on the one being marked done
|
|
1082
|
+
updateBadgeContent(spec);
|
|
1083
|
+
updatePill(); // re-renders the panel when open
|
|
1084
|
+
saveSpecs(); // persists resolved + re-syncs the bridge (now excluding it)
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// Sweep every resolved Spec once the user is satisfied \u2014 leaves open Specs untouched.
|
|
1088
|
+
function clearResolved() {
|
|
1089
|
+
for (var i = specs.length - 1; i >= 0; i--) {
|
|
1090
|
+
if (!specs[i].resolved) continue;
|
|
1091
|
+
if (highlightSpec === specs[i]) highlightSpec = null;
|
|
1092
|
+
if (panelEditSpec === specs[i]) panelEditSpec = null;
|
|
1093
|
+
if (specs[i].wrap) specs[i].wrap.remove();
|
|
1094
|
+
specs.splice(i, 1);
|
|
1095
|
+
}
|
|
1096
|
+
renumber();
|
|
1097
|
+
updatePill();
|
|
1098
|
+
saveSpecs();
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Drop every imported (shared) spec, leaving the user's own local ones. Used by
|
|
1102
|
+
// importComments so a received share replaces the prior set instead of stacking.
|
|
1103
|
+
function removeSharedSpecs() {
|
|
1104
|
+
if (highlightSpec && highlightSpec.shared) highlightSpec = null;
|
|
1105
|
+
if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
|
|
1106
|
+
for (var i = specs.length - 1; i >= 0; i--) {
|
|
1107
|
+
if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1010
1111
|
// \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
|
|
1011
1112
|
// Persist only the serializable parts of each Spec; the live element ref and
|
|
1012
1113
|
// DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
|
|
1013
1114
|
function saveSpecs() {
|
|
1014
1115
|
try {
|
|
1015
1116
|
if (!specs.length) localStorage.removeItem(STORAGE_KEY);
|
|
1016
|
-
else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason }; })));
|
|
1117
|
+
else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason, resolved: s.resolved }; })));
|
|
1017
1118
|
} catch (e) {}
|
|
1018
1119
|
scheduleSync(); // keep the Claude bridge mirrored to the current Specs
|
|
1019
1120
|
}
|
|
@@ -1023,7 +1124,7 @@ function getClientScript(options) {
|
|
|
1023
1124
|
try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
|
|
1024
1125
|
if (!Array.isArray(data) || !data.length) return;
|
|
1025
1126
|
data.forEach(function (d) {
|
|
1026
|
-
var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '' };
|
|
1127
|
+
var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '', resolved: !!d.resolved };
|
|
1027
1128
|
specs.push(spec);
|
|
1028
1129
|
createBadge(spec);
|
|
1029
1130
|
});
|
|
@@ -1038,11 +1139,13 @@ function getClientScript(options) {
|
|
|
1038
1139
|
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1039
1140
|
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1040
1141
|
|
|
1041
|
-
// Normalized element signature
|
|
1042
|
-
//
|
|
1043
|
-
//
|
|
1044
|
-
//
|
|
1045
|
-
//
|
|
1142
|
+
// Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
|
|
1143
|
+
// element a shared comment belongs to (and to scan for it when selectors fail).
|
|
1144
|
+
// Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
|
|
1145
|
+
// price) was false-tripping this and blocking re-anchor on otherwise-identical
|
|
1146
|
+
// pages. tag + sorted own classes + key attrs + child count identifies the node
|
|
1147
|
+
// without that fragility. (Trade-off: pure text edits under a stable node no
|
|
1148
|
+
// longer read as "changed" \u2014 placement is favoured over change-detection.)
|
|
1046
1149
|
function normSig(el) {
|
|
1047
1150
|
if (!el || el.nodeType !== 1) return '';
|
|
1048
1151
|
var cls = Array.prototype.slice.call(el.classList)
|
|
@@ -1050,8 +1153,7 @@ function getClientScript(options) {
|
|
|
1050
1153
|
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1051
1154
|
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1052
1155
|
}).filter(Boolean).join('|');
|
|
1053
|
-
|
|
1054
|
-
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
|
|
1156
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
|
|
1055
1157
|
}
|
|
1056
1158
|
// djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1057
1159
|
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
@@ -1063,51 +1165,91 @@ function getClientScript(options) {
|
|
|
1063
1165
|
return (h >>> 0).toString(36);
|
|
1064
1166
|
}
|
|
1065
1167
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
//
|
|
1069
|
-
|
|
1070
|
-
|
|
1168
|
+
function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
|
|
1169
|
+
|
|
1170
|
+
// Every element whose OWN text matches (trunc = the stored anchor was cut to 40
|
|
1171
|
+
// chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
|
|
1172
|
+
// so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
|
|
1173
|
+
function findAllByOwnText(val, trunc) {
|
|
1174
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
|
|
1071
1175
|
for (var i = 0; i < all.length; i++) {
|
|
1072
1176
|
var el = all[i];
|
|
1073
1177
|
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1074
1178
|
var t = ownText(el);
|
|
1075
1179
|
if (!t) continue;
|
|
1076
|
-
if (trunc ? (t.slice(0, 40) === val) : (t === val))
|
|
1180
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
|
|
1077
1181
|
}
|
|
1078
|
-
return
|
|
1182
|
+
return out;
|
|
1079
1183
|
}
|
|
1080
1184
|
|
|
1081
|
-
//
|
|
1082
|
-
// 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
if (!find) return null;
|
|
1185
|
+
// Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
|
|
1186
|
+
// querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1187
|
+
// scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
|
|
1188
|
+
// fingerprint rather than rejecting anything ambiguous up front.
|
|
1189
|
+
function resolveFindCandidates(find) {
|
|
1190
|
+
if (!find) return [];
|
|
1088
1191
|
var c = find.charAt(0);
|
|
1089
|
-
if (c === '#' || c === '.' || c === '[') return
|
|
1192
|
+
if (c === '#' || c === '.' || c === '[') return queryAll(find);
|
|
1090
1193
|
var q = find.indexOf(' "');
|
|
1091
1194
|
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1092
1195
|
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1093
1196
|
if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
|
|
1094
|
-
if (attr === 'text') return
|
|
1095
|
-
if (val.indexOf('"') < 0)
|
|
1096
|
-
return
|
|
1197
|
+
if (attr === 'text') return findAllByOwnText(val, trunc);
|
|
1198
|
+
if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
|
|
1199
|
+
return [];
|
|
1200
|
+
}
|
|
1201
|
+
return queryAll(find); // a bare CSS path
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
|
|
1205
|
+
// just a change-detector: gather every candidate from the find anchor AND the
|
|
1206
|
+
// nth-child path, then return the one whose signature matches \u2014 so a locator
|
|
1207
|
+
// shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
|
|
1208
|
+
// instead of failing as ambiguous. With no fp match: hand back a lone candidate
|
|
1209
|
+
// (so importComments can say "changed"), else null ("couldn't find").
|
|
1210
|
+
// Whole-DOM scan for the one element whose structural signature matches \u2014 the
|
|
1211
|
+
// last-resort re-finder when both the find anchor and the nth-of-type path fail
|
|
1212
|
+
// (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
|
|
1213
|
+
// (ambiguous, don't guess). Skips Specter's own UI.
|
|
1214
|
+
function findByFingerprint(fp) {
|
|
1215
|
+
if (!fp) return null;
|
|
1216
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1217
|
+
for (var i = 0; i < all.length; i++) {
|
|
1218
|
+
var el = all[i];
|
|
1219
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1220
|
+
if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
|
|
1097
1221
|
}
|
|
1098
|
-
return
|
|
1222
|
+
return hit;
|
|
1099
1223
|
}
|
|
1100
1224
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
//
|
|
1104
|
-
//
|
|
1225
|
+
function fpOk(el, item) { return !item.fp || fingerprint(el) === item.fp; }
|
|
1226
|
+
|
|
1227
|
+
// Rank the signals rather than pooling them, so a specific locator always beats a
|
|
1228
|
+
// vague one:
|
|
1229
|
+
// 1. a find anchor that resolves to exactly ONE element (id / unique text / class)
|
|
1230
|
+
// \u2014 the strongest, most semantic signal;
|
|
1231
|
+
// 2. the rooted nth-of-type path, if unique \u2014 this is what disambiguates repeated
|
|
1232
|
+
// structure (a class-path find matches every paragraph; the path pins the exact
|
|
1233
|
+
// one). Pooling let an ambiguous find shadow the unique path \u2014 the mis-anchor bug;
|
|
1234
|
+
// 3. the structural fingerprint across the pooled candidates, then a DOM-wide scan;
|
|
1235
|
+
// 4. any lone resolution.
|
|
1236
|
+
// fp guards 1 and 2 so a drifted anchor can't win blindly, but a unique anchor with a
|
|
1237
|
+
// changed signature is still trusted at step 4 (placement over false-MISSING).
|
|
1105
1238
|
function reFindShared(item) {
|
|
1106
|
-
var byFind =
|
|
1107
|
-
if (byFind
|
|
1108
|
-
var byPath =
|
|
1109
|
-
if (byPath
|
|
1110
|
-
|
|
1239
|
+
var byFind = resolveFindCandidates(item.find);
|
|
1240
|
+
if (byFind.length === 1 && fpOk(byFind[0], item)) return byFind[0];
|
|
1241
|
+
var byPath = item.path ? queryAll(item.path) : [];
|
|
1242
|
+
if (byPath.length === 1 && fpOk(byPath[0], item)) return byPath[0];
|
|
1243
|
+
var cands = [], i, all = byFind.concat(byPath);
|
|
1244
|
+
for (i = 0; i < all.length; i++) if (all[i] && cands.indexOf(all[i]) < 0) cands.push(all[i]);
|
|
1245
|
+
if (item.fp) {
|
|
1246
|
+
for (i = 0; i < cands.length; i++) if (fingerprint(cands[i]) === item.fp) return cands[i];
|
|
1247
|
+
var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
|
|
1248
|
+
if (scan) return scan;
|
|
1249
|
+
}
|
|
1250
|
+
if (byFind.length === 1) return byFind[0]; // unique anchor, fp drifted \u2014 trust the anchor
|
|
1251
|
+
if (byPath.length === 1) return byPath[0];
|
|
1252
|
+
return cands.length === 1 ? cands[0] : null;
|
|
1111
1253
|
}
|
|
1112
1254
|
|
|
1113
1255
|
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
@@ -1138,12 +1280,19 @@ function getClientScript(options) {
|
|
|
1138
1280
|
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
|
|
1139
1281
|
return 0;
|
|
1140
1282
|
}
|
|
1283
|
+
// A received share REPLACES the previously-imported set: re-opening the same
|
|
1284
|
+
// link (or a refreshed one) gives exactly that set, never a stacked-up pile of
|
|
1285
|
+
// duplicates across rounds. Your OWN local specs (shared:false) are untouched.
|
|
1286
|
+
removeSharedSpecs();
|
|
1141
1287
|
var added = 0;
|
|
1142
1288
|
comments.forEach(function (item) {
|
|
1143
1289
|
if (!item) return;
|
|
1290
|
+
// reFindShared already used the fingerprint to pick/scan the right element, so
|
|
1291
|
+
// trust its result: if it located a node, PLACE the comment. Only a genuine
|
|
1292
|
+
// no-match is MISSING \u2014 we no longer hide a found element just because its
|
|
1293
|
+
// signature drifted (that over-fired on dynamic pages and buried real pins).
|
|
1144
1294
|
var el = reFindShared(item), missing = false, reason = '';
|
|
1145
1295
|
if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
|
|
1146
|
-
else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
|
|
1147
1296
|
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1148
1297
|
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1149
1298
|
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 };
|
|
@@ -1504,7 +1653,7 @@ function getClientScript(options) {
|
|
|
1504
1653
|
var rows = [
|
|
1505
1654
|
['P', 'mark / comment the hovered element'],
|
|
1506
1655
|
['C', 'toggle Comment mode (hide properties)'],
|
|
1507
|
-
['\\u2325 Option', 'toggle Measure mode'],
|
|
1656
|
+
['\\u2325 Option / Alt', 'toggle Measure mode'],
|
|
1508
1657
|
['M', 'pin an element to measure from'],
|
|
1509
1658
|
['Cmd/Ctrl+C', 'copy all Specs'],
|
|
1510
1659
|
['L', 'toggle this panel'],
|
|
@@ -1520,9 +1669,48 @@ function getClientScript(options) {
|
|
|
1520
1669
|
head.appendChild(headText);
|
|
1521
1670
|
var body = document.createElement('div');
|
|
1522
1671
|
Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
|
|
1672
|
+
|
|
1673
|
+
// Rebindable toggle shortcut \u2014 the one chord you might have to change if it collides
|
|
1674
|
+
// with a browser/extension binding. Click Change, press a new combo. Saved per origin,
|
|
1675
|
+
// no restart. Works identically in the Vite plugin overlay and the browser extension.
|
|
1676
|
+
(function () {
|
|
1677
|
+
var rrow = document.createElement('div');
|
|
1678
|
+
Object.assign(rrow.style, { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px', paddingBottom: '10px', borderBottom: '1px solid rgba(255,255,255,0.06)' });
|
|
1679
|
+
var chip = document.createElement('span');
|
|
1680
|
+
Object.assign(chip.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
1681
|
+
var lbl = document.createElement('span');
|
|
1682
|
+
Object.assign(lbl.style, { flex: '1', minWidth: '0' });
|
|
1683
|
+
lbl.textContent = 'toggle Specter on/off';
|
|
1684
|
+
var change = document.createElement('span');
|
|
1685
|
+
Object.assign(change.style, { color: '#8AB4F8', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline' });
|
|
1686
|
+
change.textContent = 'Change';
|
|
1687
|
+
var reset = document.createElement('span');
|
|
1688
|
+
Object.assign(reset.style, { color: '#B9BBC2', cursor: 'pointer', flexShrink: '0', textDecoration: 'underline', display: 'none' });
|
|
1689
|
+
reset.textContent = 'Reset';
|
|
1690
|
+
function refresh() {
|
|
1691
|
+
chip.textContent = shortcutLabel(ACTIVATE);
|
|
1692
|
+
chip.style.color = '#E0A3F5';
|
|
1693
|
+
change.style.display = 'inline';
|
|
1694
|
+
reset.style.display = activateOverridden() ? 'inline' : 'none';
|
|
1695
|
+
}
|
|
1696
|
+
change.addEventListener('click', function (e) {
|
|
1697
|
+
e.stopPropagation();
|
|
1698
|
+
change.style.display = 'none';
|
|
1699
|
+
reset.style.display = 'none';
|
|
1700
|
+
startRebind(
|
|
1701
|
+
function (text, color) { chip.textContent = text; chip.style.color = color; },
|
|
1702
|
+
function () { refresh(); }
|
|
1703
|
+
);
|
|
1704
|
+
});
|
|
1705
|
+
reset.addEventListener('click', function (e) { e.stopPropagation(); resetActivate(); refresh(); });
|
|
1706
|
+
refresh();
|
|
1707
|
+
rrow.appendChild(chip); rrow.appendChild(lbl); rrow.appendChild(change); rrow.appendChild(reset);
|
|
1708
|
+
body.appendChild(rrow);
|
|
1709
|
+
})();
|
|
1710
|
+
|
|
1523
1711
|
rows.forEach(function (r) {
|
|
1524
1712
|
var row = document.createElement('div');
|
|
1525
|
-
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '
|
|
1713
|
+
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
|
|
1526
1714
|
var k = document.createElement('span');
|
|
1527
1715
|
k.textContent = r[0];
|
|
1528
1716
|
Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
@@ -1542,20 +1730,48 @@ function getClientScript(options) {
|
|
|
1542
1730
|
panelKeys.appendChild(body);
|
|
1543
1731
|
})();
|
|
1544
1732
|
|
|
1733
|
+
// Footer \u2014 a quiet link out to the GitHub README so first-timers can find the docs.
|
|
1734
|
+
var panelFoot = document.createElement('div');
|
|
1735
|
+
Object.assign(panelFoot.style, {
|
|
1736
|
+
flexShrink: '0', padding: '10px 16px', borderTop: '1px solid rgba(255,255,255,0.08)',
|
|
1737
|
+
display: 'flex', alignItems: 'center',
|
|
1738
|
+
});
|
|
1739
|
+
var docsLink = document.createElement('a');
|
|
1740
|
+
docsLink.href = 'https://github.com/setugk/vite-plugin-specter#readme';
|
|
1741
|
+
docsLink.target = '_blank';
|
|
1742
|
+
docsLink.rel = 'noopener noreferrer';
|
|
1743
|
+
docsLink.textContent = 'Documentation \\u2197'; // \u2197
|
|
1744
|
+
Object.assign(docsLink.style, { fontSize: '11px', fontWeight: '600', color: '#8AB4F8', textDecoration: 'none', cursor: 'pointer' });
|
|
1745
|
+
hoverFx(docsLink, { color: '#fff' }, { color: '#8AB4F8' });
|
|
1746
|
+
docsLink.addEventListener('click', function (e) { e.stopPropagation(); });
|
|
1747
|
+
panelFoot.appendChild(docsLink);
|
|
1748
|
+
|
|
1545
1749
|
panelWrap.appendChild(panelHead);
|
|
1546
1750
|
panelWrap.appendChild(panelTools);
|
|
1547
1751
|
panelWrap.appendChild(panelHint);
|
|
1548
1752
|
panelWrap.appendChild(panelList);
|
|
1549
1753
|
panelWrap.appendChild(panelKeys);
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
//
|
|
1554
|
-
//
|
|
1754
|
+
panelWrap.appendChild(panelFoot);
|
|
1755
|
+
mount(panelWrap);
|
|
1756
|
+
|
|
1757
|
+
// A wheel anywhere over the panel must NEVER scroll the page behind it. Relying on
|
|
1758
|
+
// overscroll-behavior alone still lets trackpad momentum chain into the page at the
|
|
1759
|
+
// list bounds, so we take over: always swallow the event, and drive the list's own
|
|
1760
|
+
// scroll ourselves. (The inline note editor auto-grows instead of scrolling, so the
|
|
1761
|
+
// list is the only scrollable region inside the panel \u2014 nothing else needs the wheel.)
|
|
1762
|
+
var listScrolling = false, listScrollTimer = null;
|
|
1555
1763
|
panelWrap.addEventListener('wheel', function (e) {
|
|
1556
|
-
var canScroll = panelList.scrollHeight > panelList.clientHeight;
|
|
1557
|
-
if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
|
|
1558
1764
|
e.preventDefault();
|
|
1765
|
+
if (panelList.scrollHeight > panelList.clientHeight && panelList.contains(e.target)) {
|
|
1766
|
+
var dy = e.deltaMode === 1 ? e.deltaY * 16 : (e.deltaMode === 2 ? e.deltaY * panelList.clientHeight : e.deltaY);
|
|
1767
|
+
panelList.scrollTop += dy;
|
|
1768
|
+
// Rows slide under a stationary cursor as the list scrolls, firing mouseenter \u2192
|
|
1769
|
+
// revealSpec \u2192 scrollIntoView, which would drag the PAGE around. Mark the list as
|
|
1770
|
+
// actively scrolling so those hover-reveals are suppressed until scrolling settles.
|
|
1771
|
+
listScrolling = true;
|
|
1772
|
+
clearTimeout(listScrollTimer);
|
|
1773
|
+
listScrollTimer = setTimeout(function () { listScrolling = false; }, 200);
|
|
1774
|
+
}
|
|
1559
1775
|
}, { passive: false });
|
|
1560
1776
|
|
|
1561
1777
|
// \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
|
|
@@ -1568,7 +1784,7 @@ function getClientScript(options) {
|
|
|
1568
1784
|
setPillSync(s); // control-bar dot
|
|
1569
1785
|
applyDotState(dot, s); // side-panel dot \u2014 same spinner/green/red
|
|
1570
1786
|
if (s === 'syncing') { dotLabel.textContent = 'Syncing\u2026'; }
|
|
1571
|
-
else if (s === 'synced') {
|
|
1787
|
+
else if (s === 'synced') { var open = specs.length - resolvedCount(); dotLabel.textContent = open > 0 ? (open + (open === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
|
|
1572
1788
|
else { dotLabel.textContent = 'Bridge offline'; }
|
|
1573
1789
|
}
|
|
1574
1790
|
function doSync() {
|
|
@@ -1615,11 +1831,28 @@ function getClientScript(options) {
|
|
|
1615
1831
|
panelList.appendChild(empty);
|
|
1616
1832
|
return;
|
|
1617
1833
|
}
|
|
1834
|
+
// Summary of resolved Specs + a one-click sweep, pinned above the list.
|
|
1835
|
+
var rc = resolvedCount();
|
|
1836
|
+
if (rc > 0) {
|
|
1837
|
+
var doneBar = document.createElement('div');
|
|
1838
|
+
Object.assign(doneBar.style, { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px', padding: '8px 16px', borderBottom: '1px solid rgba(255,255,255,0.06)' });
|
|
1839
|
+
var dlbl = document.createElement('span');
|
|
1840
|
+
dlbl.textContent = '\u2713 ' + rc + (rc === 1 ? ' done' : ' done');
|
|
1841
|
+
Object.assign(dlbl.style, { fontSize: '11px', color: GREEN, fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase' });
|
|
1842
|
+
var clr = document.createElement('span');
|
|
1843
|
+
clr.textContent = 'Clear done';
|
|
1844
|
+
Object.assign(clr.style, { fontSize: '11px', color: '#B9BBC2', cursor: 'pointer', textDecoration: 'underline' });
|
|
1845
|
+
clr.addEventListener('click', function (e) { e.stopPropagation(); clearResolved(); });
|
|
1846
|
+
hoverFx(clr, { color: '#fff' }, { color: '#B9BBC2' });
|
|
1847
|
+
doneBar.appendChild(dlbl); doneBar.appendChild(clr);
|
|
1848
|
+
panelList.appendChild(doneBar);
|
|
1849
|
+
}
|
|
1618
1850
|
var focusEditor = null, measures = [];
|
|
1619
1851
|
specs.forEach(function (spec, i) {
|
|
1620
1852
|
var missing = !!spec.missing;
|
|
1621
1853
|
var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
|
|
1622
1854
|
var editing = (panelEditSpec === spec);
|
|
1855
|
+
var resolved = !!spec.resolved;
|
|
1623
1856
|
var row = document.createElement('div');
|
|
1624
1857
|
Object.assign(row.style, {
|
|
1625
1858
|
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
|
|
@@ -1630,7 +1863,7 @@ function getClientScript(options) {
|
|
|
1630
1863
|
badge.textContent = String(i + 1);
|
|
1631
1864
|
Object.assign(badge.style, {
|
|
1632
1865
|
flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
|
|
1633
|
-
background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
|
|
1866
|
+
background: resolved ? GREEN : BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
|
|
1634
1867
|
border: '1px solid #fff', boxSizing: 'border-box',
|
|
1635
1868
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
1636
1869
|
transition: 'background 0.12s ease',
|
|
@@ -1702,6 +1935,7 @@ function getClientScript(options) {
|
|
|
1702
1935
|
cursor: spec.note ? 'pointer' : 'default',
|
|
1703
1936
|
});
|
|
1704
1937
|
note.textContent = spec.note || (spec.kind === 'measure' ? '\u2B21 measurement' : '\u2014 no note \u2014');
|
|
1938
|
+
if (resolved) { note.style.textDecoration = 'line-through'; note.style.color = LABEL; note.style.cursor = spec.note ? 'pointer' : 'default'; }
|
|
1705
1939
|
|
|
1706
1940
|
// Floated into the top-right corner so they don't reserve note width. On hover
|
|
1707
1941
|
// they get a background that fades in from the left, masking the note text behind
|
|
@@ -1720,11 +1954,14 @@ function getClientScript(options) {
|
|
|
1720
1954
|
// Show an expand toggle only when the collapsed note actually overflows.
|
|
1721
1955
|
var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
|
|
1722
1956
|
chev.firstChild.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
|
|
1957
|
+
var resolveBtn = mkIcon(CHECK, resolved ? 'Mark as not done' : 'Mark as done', resolved ? GREEN : '#9BE6B0', function () { toggleResolved(spec); }, { background: 'rgba(34,197,94,0.28)', color: '#fff' });
|
|
1723
1958
|
var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
|
|
1724
1959
|
var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
|
|
1725
|
-
//
|
|
1726
|
-
// and fixed
|
|
1727
|
-
|
|
1960
|
+
// \u2713 / edit / delete all reveal only on row hover (the expand chevron stays rightmost
|
|
1961
|
+
// and fixed). The done state reads from the green badge + strikethrough + "\u2713 DONE",
|
|
1962
|
+
// so the row needs no persistent icon \u2014 it looks like any other list item.
|
|
1963
|
+
editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
|
|
1964
|
+
actions.appendChild(resolveBtn);
|
|
1728
1965
|
actions.appendChild(editBtn);
|
|
1729
1966
|
actions.appendChild(delBtn);
|
|
1730
1967
|
actions.appendChild(chev);
|
|
@@ -1732,9 +1969,27 @@ function getClientScript(options) {
|
|
|
1732
1969
|
content.appendChild(note);
|
|
1733
1970
|
content.appendChild(meta);
|
|
1734
1971
|
|
|
1972
|
+
// Done: a green DONE tag + dimmed row. Supersedes HIDDEN/MISSING \u2014 once a Spec is
|
|
1973
|
+
// resolved you don't care whether its element is currently on screen.
|
|
1974
|
+
if (resolved) {
|
|
1975
|
+
row.style.opacity = '0.6';
|
|
1976
|
+
var rbar = document.createElement('div');
|
|
1977
|
+
Object.assign(rbar.style, { display: 'flex', alignItems: 'center', gap: '5px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1978
|
+
var rcheck = document.createElement('span');
|
|
1979
|
+
rcheck.innerHTML = CHECK;
|
|
1980
|
+
Object.assign(rcheck.style, { display: 'inline-flex', alignItems: 'center', color: GREEN, flexShrink: '0' });
|
|
1981
|
+
if (rcheck.firstChild) { rcheck.firstChild.setAttribute('width', '12'); rcheck.firstChild.setAttribute('height', '12'); }
|
|
1982
|
+
var rtag = document.createElement('span');
|
|
1983
|
+
rtag.textContent = 'DONE';
|
|
1984
|
+
// Text-only status label (no fill/padding/radius) so it doesn't read as a clickable chip.
|
|
1985
|
+
Object.assign(rtag.style, { fontSize: '10px', fontWeight: '700', letterSpacing: '0.08em', textTransform: 'uppercase', color: GREEN, flexShrink: '0' });
|
|
1986
|
+
rbar.appendChild(rcheck);
|
|
1987
|
+
rbar.appendChild(rtag);
|
|
1988
|
+
content.appendChild(rbar);
|
|
1989
|
+
}
|
|
1735
1990
|
// Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
|
|
1736
1991
|
// never drawn on the page \u2014 design: show missing, don't guess a spot).
|
|
1737
|
-
if (missing) {
|
|
1992
|
+
else if (missing) {
|
|
1738
1993
|
row.style.opacity = '0.7';
|
|
1739
1994
|
var mbar = document.createElement('div');
|
|
1740
1995
|
Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
@@ -1771,15 +2026,15 @@ function getClientScript(options) {
|
|
|
1771
2026
|
note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
|
|
1772
2027
|
row.addEventListener('mouseenter', function () {
|
|
1773
2028
|
row.style.background = 'rgba(255,255,255,0.05)';
|
|
1774
|
-
badge.style.background = PURPLE;
|
|
1775
|
-
editBtn.style.display = delBtn.style.display = 'flex';
|
|
2029
|
+
if (!resolved) badge.style.background = PURPLE;
|
|
2030
|
+
editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'flex';
|
|
1776
2031
|
actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
|
|
1777
|
-
if (visible) { highlightSpec = spec; revealSpec(spec); }
|
|
2032
|
+
if (visible && !listScrolling) { highlightSpec = spec; revealSpec(spec); } // don't reveal on rows that merely slid under the cursor while scrolling
|
|
1778
2033
|
});
|
|
1779
2034
|
row.addEventListener('mouseleave', function () {
|
|
1780
2035
|
row.style.background = 'transparent';
|
|
1781
|
-
badge.style.background = BADGE_IDLE;
|
|
1782
|
-
editBtn.style.display = delBtn.style.display = 'none';
|
|
2036
|
+
badge.style.background = resolved ? GREEN : BADGE_IDLE;
|
|
2037
|
+
editBtn.style.display = delBtn.style.display = resolveBtn.style.display = 'none';
|
|
1783
2038
|
actions.style.background = 'transparent';
|
|
1784
2039
|
if (highlightSpec === spec) highlightSpec = null;
|
|
1785
2040
|
});
|
|
@@ -1806,8 +2061,8 @@ function getClientScript(options) {
|
|
|
1806
2061
|
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1807
2062
|
}
|
|
1808
2063
|
|
|
1809
|
-
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
1810
|
-
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
|
|
2064
|
+
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
|
|
2065
|
+
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
|
|
1811
2066
|
function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
|
|
1812
2067
|
|
|
1813
2068
|
// \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
|
|
@@ -2207,6 +2462,41 @@ function getClientScript(options) {
|
|
|
2207
2462
|
return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
|
|
2208
2463
|
}
|
|
2209
2464
|
|
|
2465
|
+
// Capture the next chord the user presses and make it the new toggle. Runs on the
|
|
2466
|
+
// capture phase + stops propagation so a rebind keystroke (e.g. "L") can't leak into
|
|
2467
|
+
// Specter's own shortcuts. Requires at least one modifier so the toggle can't be a bare
|
|
2468
|
+
// key that fires while you type. onState(text, color) drives the prompt; onDone(committed).
|
|
2469
|
+
var rebinding = false;
|
|
2470
|
+
function startRebind(onState, onDone) {
|
|
2471
|
+
if (rebinding) return;
|
|
2472
|
+
rebinding = true;
|
|
2473
|
+
onState('Press a key combo\u2026', GREEN);
|
|
2474
|
+
function cap(e) {
|
|
2475
|
+
e.preventDefault(); e.stopPropagation();
|
|
2476
|
+
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
|
|
2477
|
+
var k = e.key;
|
|
2478
|
+
if (k === 'Escape') { finish(false); return; }
|
|
2479
|
+
if (k === 'Control' || k === 'Alt' || k === 'Shift' || k === 'Meta') return; // wait for the real key
|
|
2480
|
+
var parts = [];
|
|
2481
|
+
if (e.ctrlKey) parts.push('ctrl');
|
|
2482
|
+
if (e.altKey) parts.push('alt');
|
|
2483
|
+
if (e.shiftKey) parts.push('shift');
|
|
2484
|
+
if (e.metaKey) parts.push('meta');
|
|
2485
|
+
if (!parts.length) { onState('Hold Ctrl / Alt / \\u2318 too\u2026', '#F59E0B'); return; }
|
|
2486
|
+
var key = (e.key.length === 1 ? e.key : e.code.replace(/^Key/, '')).toLowerCase();
|
|
2487
|
+
if (!key) return;
|
|
2488
|
+
parts.push(key);
|
|
2489
|
+
setActivate(parts.join('+'));
|
|
2490
|
+
finish(true);
|
|
2491
|
+
}
|
|
2492
|
+
function finish(committed) {
|
|
2493
|
+
document.removeEventListener('keydown', cap, true);
|
|
2494
|
+
rebinding = false;
|
|
2495
|
+
onDone(committed);
|
|
2496
|
+
}
|
|
2497
|
+
document.addEventListener('keydown', cap, true);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2210
2500
|
// \u2500\u2500\u2500 Mouse \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2211
2501
|
function onMouseMove(e) {
|
|
2212
2502
|
if (!fiActive) return;
|
|
@@ -2388,7 +2678,21 @@ function getClientScript(options) {
|
|
|
2388
2678
|
importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
|
|
2389
2679
|
scheduleSync(); // mirror restored Specs to the Claude bridge on load
|
|
2390
2680
|
|
|
2391
|
-
console
|
|
2681
|
+
// Dev-only console API \u2014 the escape hatch when the toggle chord collides with a browser
|
|
2682
|
+
// shortcut so you can't even open the overlay. Specter is stripped from prod, so this
|
|
2683
|
+
// global never ships. Vite devs can run these straight from DevTools.
|
|
2684
|
+
try {
|
|
2685
|
+
window.__specter = {
|
|
2686
|
+
toggle: function () { if (fiActive) deactivate(); else activate(); },
|
|
2687
|
+
activate: activate,
|
|
2688
|
+
deactivate: deactivate,
|
|
2689
|
+
get shortcut() { return ACTIVATE; },
|
|
2690
|
+
setShortcut: function (combo) { if (combo) { setActivate(String(combo).toLowerCase()); if (panelOpen) renderPanel(); console.log('%c\u{1F47B} Specter \u2014 toggle is now ' + shortcutLabel(ACTIVATE), 'color:#aaa'); } return ACTIVATE; },
|
|
2691
|
+
resetShortcut: function () { resetActivate(); if (panelOpen) renderPanel(); console.log('%c\u{1F47B} Specter \u2014 toggle reset to ' + shortcutLabel(ACTIVATE), 'color:#aaa'); return ACTIVATE; },
|
|
2692
|
+
};
|
|
2693
|
+
} catch (e) {}
|
|
2694
|
+
|
|
2695
|
+
console.log('%c\u{1F47B} Specter \u2014 ' + shortcutLabel(ACTIVATE) + ' to toggle \xB7 change it: window.__specter.setShortcut("ctrl+alt+p")', 'color:#aaa;font-size:11px;');
|
|
2392
2696
|
})();`;
|
|
2393
2697
|
}
|
|
2394
2698
|
|