vite-plugin-specter 0.6.1 → 0.7.1
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 +13 -0
- package/dist/client.js +457 -55
- package/dist/extension-chrome/content.js +457 -55
- package/dist/extension-chrome/manifest.json +1 -1
- package/dist/extension-firefox/content.js +457 -55
- package/dist/extension-firefox/manifest.json +1 -1
- package/dist/index.cjs +457 -55
- package/dist/index.js +457 -55
- package/extension/content.js +457 -55
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -55,6 +55,8 @@ function getClientScript(options) {
|
|
|
55
55
|
var CHEV = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>';
|
|
56
56
|
var COPY = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
|
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
|
+
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
|
+
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>';
|
|
58
60
|
var ACTIVATE = ${JSON.stringify(activateShortcut)};
|
|
59
61
|
var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
|
|
60
62
|
|
|
@@ -95,6 +97,45 @@ function getClientScript(options) {
|
|
|
95
97
|
function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
|
|
96
98
|
function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
|
|
97
99
|
|
|
100
|
+
// \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
|
|
101
|
+
// We inject at document_end, but frameworks that hydrate <body> (Next.js App
|
|
102
|
+
// Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
|
|
103
|
+
// didn't render \u2014 silently deleting Specter's UI on those sites (pill/panel
|
|
104
|
+
// vanish; badges added later survive because they're created post-hydration).
|
|
105
|
+
// Track our persistent singletons and re-attach any that gets detached, so the
|
|
106
|
+
// UI survives the hydration pass and later SPA re-renders. The parent is resolved
|
|
107
|
+
// fresh on each remount so a wholesale body/head element swap is handled too.
|
|
108
|
+
var _mounted = [];
|
|
109
|
+
function mount(node, where) {
|
|
110
|
+
var parent = where === 'head' ? document.head : document.body;
|
|
111
|
+
if (parent) parent.appendChild(node);
|
|
112
|
+
_mounted.push({ node: node, where: where });
|
|
113
|
+
return node;
|
|
114
|
+
}
|
|
115
|
+
var _remountQueued = false;
|
|
116
|
+
function remountDetached() {
|
|
117
|
+
_remountQueued = false;
|
|
118
|
+
for (var i = 0; i < _mounted.length; i++) {
|
|
119
|
+
var m = _mounted[i];
|
|
120
|
+
if (m.node.isConnected) continue;
|
|
121
|
+
var p = m.where === 'head' ? document.head : document.body;
|
|
122
|
+
if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
|
|
127
|
+
var _mo = new MutationObserver(function () {
|
|
128
|
+
if (_remountQueued) return;
|
|
129
|
+
_remountQueued = true;
|
|
130
|
+
_defer(remountDetached); // coalesce a burst of mutations into one restore pass
|
|
131
|
+
});
|
|
132
|
+
// childList on the roots is enough \u2014 every singleton is a direct child of
|
|
133
|
+
// body/head; watching documentElement too catches a body/head element swap.
|
|
134
|
+
_mo.observe(document.documentElement, { childList: true });
|
|
135
|
+
_mo.observe(document.body, { childList: true });
|
|
136
|
+
_mo.observe(document.head, { childList: true });
|
|
137
|
+
} catch (e) {}
|
|
138
|
+
|
|
98
139
|
// Attach a hover effect to an interactive icon/button: apply the "on" styles
|
|
99
140
|
// while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
|
|
100
141
|
function hoverFx(el, on, off) {
|
|
@@ -121,7 +162,7 @@ function getClientScript(options) {
|
|
|
121
162
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
122
163
|
display: 'none',
|
|
123
164
|
});
|
|
124
|
-
|
|
165
|
+
mount(tooltip);
|
|
125
166
|
|
|
126
167
|
// Measure overlay
|
|
127
168
|
var measureOverlay = document.createElement('div');
|
|
@@ -133,7 +174,7 @@ function getClientScript(options) {
|
|
|
133
174
|
pointerEvents: 'none',
|
|
134
175
|
display: 'none',
|
|
135
176
|
});
|
|
136
|
-
|
|
177
|
+
mount(measureOverlay);
|
|
137
178
|
|
|
138
179
|
// \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
|
|
139
180
|
var pillWrap = document.createElement('div');
|
|
@@ -209,41 +250,34 @@ function getClientScript(options) {
|
|
|
209
250
|
var pillText = document.createElement('span');
|
|
210
251
|
Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
|
|
211
252
|
|
|
253
|
+
// Panel toggle \u2014 icon + label so it's self-explanatory. Label reflects state
|
|
254
|
+
// (Open/Close) and always names the shortcut.
|
|
212
255
|
var listBtn = document.createElement('span');
|
|
213
|
-
listBtn.
|
|
214
|
-
listBtn.title = 'Show Specs panel (L)';
|
|
256
|
+
listBtn.title = 'Toggle Specs panel (L)';
|
|
215
257
|
Object.assign(listBtn.style, {
|
|
216
258
|
display: 'none',
|
|
217
259
|
alignItems: 'center',
|
|
260
|
+
gap: '6px',
|
|
218
261
|
cursor: 'pointer',
|
|
219
262
|
color: '#fff',
|
|
263
|
+
fontSize: '11px',
|
|
220
264
|
flexShrink: '0',
|
|
221
|
-
padding: '4px
|
|
265
|
+
padding: '4px 10px',
|
|
222
266
|
marginLeft: '2px',
|
|
223
267
|
borderRadius: '999px',
|
|
224
268
|
background: 'rgba(255,255,255,0.16)',
|
|
225
269
|
});
|
|
270
|
+
var listIcon = document.createElement('span');
|
|
271
|
+
listIcon.innerHTML = LIST;
|
|
272
|
+
Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
|
|
273
|
+
var listLabel = document.createElement('span');
|
|
274
|
+
listBtn.appendChild(listIcon);
|
|
275
|
+
listBtn.appendChild(listLabel);
|
|
276
|
+
function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
|
|
277
|
+
updateListBtn();
|
|
226
278
|
listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
|
|
227
279
|
hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
228
280
|
|
|
229
|
-
var clearBtn = document.createElement('span');
|
|
230
|
-
clearBtn.textContent = '\u2715 Delete all';
|
|
231
|
-
clearBtn.title = 'Delete all annotations';
|
|
232
|
-
Object.assign(clearBtn.style, {
|
|
233
|
-
display: 'none',
|
|
234
|
-
cursor: 'pointer',
|
|
235
|
-
color: '#fff',
|
|
236
|
-
fontSize: '11px',
|
|
237
|
-
fontWeight: '600',
|
|
238
|
-
flexShrink: '0',
|
|
239
|
-
padding: '2px 8px',
|
|
240
|
-
marginLeft: '2px',
|
|
241
|
-
borderRadius: '999px',
|
|
242
|
-
background: 'rgba(255,255,255,0.16)',
|
|
243
|
-
});
|
|
244
|
-
clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
|
|
245
|
-
hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
246
|
-
|
|
247
281
|
var chevron = document.createElement('span');
|
|
248
282
|
chevron.textContent = '\u203A';
|
|
249
283
|
chevron.title = 'Move to other side';
|
|
@@ -273,16 +307,15 @@ function getClientScript(options) {
|
|
|
273
307
|
pill.appendChild(pillSync);
|
|
274
308
|
pill.appendChild(pillText);
|
|
275
309
|
pill.appendChild(listBtn);
|
|
276
|
-
pill.appendChild(clearBtn);
|
|
277
310
|
pill.appendChild(chevron);
|
|
278
311
|
pillWrap.appendChild(pill);
|
|
279
|
-
|
|
312
|
+
mount(pillWrap);
|
|
280
313
|
|
|
281
314
|
// Keyframes for the sync spinner (injected once).
|
|
282
315
|
var spinStyle = document.createElement('style');
|
|
283
316
|
spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
|
|
284
317
|
markUI(spinStyle);
|
|
285
|
-
|
|
318
|
+
mount(spinStyle, 'head');
|
|
286
319
|
|
|
287
320
|
// Shared dot styling for the control-bar AND side-panel sync indicators, so they
|
|
288
321
|
// always match: spinner while syncing, green when synced, red on error.
|
|
@@ -334,15 +367,15 @@ function getClientScript(options) {
|
|
|
334
367
|
// The mode is shown at all times (persistent prefix), so you always know whether
|
|
335
368
|
// hovering shows properties, measurements, or nothing (Comment).
|
|
336
369
|
function modeLabel() {
|
|
337
|
-
return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
|
|
370
|
+
return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
|
|
338
371
|
}
|
|
339
372
|
|
|
340
373
|
function expandPill(text) {
|
|
341
374
|
pill.style.maxWidth = '820px';
|
|
342
375
|
pillText.style.display = 'inline';
|
|
343
376
|
chevron.style.display = 'inline';
|
|
344
|
-
listBtn.style.display =
|
|
345
|
-
|
|
377
|
+
listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
|
|
378
|
+
updateListBtn();
|
|
346
379
|
pillExpanded = true;
|
|
347
380
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
348
381
|
// side panel now, so the pill stays short.
|
|
@@ -357,14 +390,12 @@ function getClientScript(options) {
|
|
|
357
390
|
pill.style.maxWidth = '220px';
|
|
358
391
|
chevron.style.display = 'none';
|
|
359
392
|
listBtn.style.display = 'none';
|
|
360
|
-
clearBtn.style.display = 'none';
|
|
361
393
|
pillExpanded = false;
|
|
362
394
|
}
|
|
363
395
|
|
|
364
396
|
function flashMode() {
|
|
365
397
|
if (pillExpanded && pillWrap.matches(':hover')) return;
|
|
366
|
-
|
|
367
|
-
expandPill(text);
|
|
398
|
+
expandPill(modeLabel());
|
|
368
399
|
clearTimeout(flashTimer);
|
|
369
400
|
flashTimer = setTimeout(function () {
|
|
370
401
|
if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
|
|
@@ -490,10 +521,13 @@ function getClientScript(options) {
|
|
|
490
521
|
var cur = el;
|
|
491
522
|
for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
|
|
492
523
|
var part = cur.tagName.toLowerCase();
|
|
493
|
-
if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
|
|
524
|
+
if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
|
|
494
525
|
var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
|
|
495
526
|
if (cls.length) {
|
|
496
|
-
|
|
527
|
+
// Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
|
|
528
|
+
// invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
|
|
529
|
+
// throw on query, so they must be CSS.escape-d first.
|
|
530
|
+
part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
|
|
497
531
|
} else if (cur.parentElement) {
|
|
498
532
|
var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
|
|
499
533
|
if (sameTag.length > 1) {
|
|
@@ -573,13 +607,13 @@ function getClientScript(options) {
|
|
|
573
607
|
var i, v;
|
|
574
608
|
var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
|
|
575
609
|
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; } }
|
|
576
|
-
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
|
|
610
|
+
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
|
|
577
611
|
var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
|
|
578
612
|
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) + '"'; } }
|
|
579
613
|
var txt = ownText(el);
|
|
580
614
|
if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
|
|
581
615
|
var cls = ownClass(el);
|
|
582
|
-
if (cls) return '.' + cls;
|
|
616
|
+
if (cls) return '.' + cssEsc(cls);
|
|
583
617
|
return getSelector(el); // fallback: the CSS path
|
|
584
618
|
}
|
|
585
619
|
|
|
@@ -753,11 +787,20 @@ function getClientScript(options) {
|
|
|
753
787
|
function elementPath(el) {
|
|
754
788
|
if (!el || el.nodeType !== 1) return '';
|
|
755
789
|
var parts = [], cur = el;
|
|
756
|
-
|
|
757
|
-
|
|
790
|
+
// Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
|
|
791
|
+
// nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
|
|
792
|
+
// child index (the old div:nth-child(11) bug \u2014 matched a different node, or
|
|
793
|
+
// none, on the recipient). nth-of-type counts only same-tag siblings, so it
|
|
794
|
+
// survives that. Deeper cap (12) so the chain reaches a rooting anchor.
|
|
795
|
+
while (cur && cur.nodeType === 1 && parts.length < 12) {
|
|
796
|
+
if (cur === document.body) { parts.unshift('body'); break; }
|
|
797
|
+
if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
|
|
758
798
|
var seg = cur.tagName.toLowerCase();
|
|
759
799
|
var parent = cur.parentElement;
|
|
760
|
-
if (parent)
|
|
800
|
+
if (parent) {
|
|
801
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
|
|
802
|
+
if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
|
|
803
|
+
}
|
|
761
804
|
parts.unshift(seg);
|
|
762
805
|
cur = cur.parentElement;
|
|
763
806
|
}
|
|
@@ -826,19 +869,36 @@ function getClientScript(options) {
|
|
|
826
869
|
var mx = lastMouse.x, my = lastMouse.y;
|
|
827
870
|
var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
|
|
828
871
|
|
|
829
|
-
// Pass 1 \u2014 resolve visibility + base anchor for each spec.
|
|
872
|
+
// Pass 1 \u2014 resolve visibility + base anchor for each spec. _liveVis tracks the
|
|
873
|
+
// panel's HIDDEN semantics (isVisible after re-anchor recovery, IGNORING occlusion \u2014
|
|
874
|
+
// off-screen is fine, that's what the hover-scroll is for) so the panel can stay in
|
|
875
|
+
// sync with what's actually on the page instead of showing a stale HIDDEN.
|
|
830
876
|
var vis = [];
|
|
831
877
|
for (var i = 0; i < specs.length; i++) {
|
|
832
878
|
var s = specs[i];
|
|
833
|
-
|
|
879
|
+
// Badges created at import-time can be wiped by a framework hydrating <body>
|
|
880
|
+
// (same cause as the singleton mount guard). Re-attach a detached wrap so the
|
|
881
|
+
// pin reappears; a removed spec is already out of the specs array, so this
|
|
882
|
+
// never resurrects a deleted badge.
|
|
883
|
+
if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
|
|
884
|
+
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
885
|
+
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
834
886
|
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
835
|
-
if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
|
|
887
|
+
if (!isVisible(s.el)) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
888
|
+
s._liveVis = true;
|
|
836
889
|
var r = s.el.getBoundingClientRect();
|
|
837
|
-
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered
|
|
890
|
+
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered / off-screen: no badge, but panel not HIDDEN
|
|
838
891
|
s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
|
|
839
892
|
vis.push(s);
|
|
840
893
|
}
|
|
841
894
|
|
|
895
|
+
// Keep the open panel's HIDDEN labels live: if any spec's on-page visibility
|
|
896
|
+
// flipped since the panel last rendered, re-render it (debounced).
|
|
897
|
+
if (panelOpen) {
|
|
898
|
+
var g = liveVisSig();
|
|
899
|
+
if (g !== panelVisSig) { panelVisSig = g; clearTimeout(panelVisTimer); panelVisTimer = setTimeout(function () { if (panelOpen) renderPanel(); }, 150); }
|
|
900
|
+
}
|
|
901
|
+
|
|
842
902
|
// Pass 2 \u2014 cluster badges whose anchors coincide (within ~16px).
|
|
843
903
|
var clusters = [];
|
|
844
904
|
for (var a = 0; a < vis.length; a++) {
|
|
@@ -1020,13 +1080,23 @@ function getClientScript(options) {
|
|
|
1020
1080
|
saveSpecs();
|
|
1021
1081
|
}
|
|
1022
1082
|
|
|
1083
|
+
// Drop every imported (shared) spec, leaving the user's own local ones. Used by
|
|
1084
|
+
// importComments so a received share replaces the prior set instead of stacking.
|
|
1085
|
+
function removeSharedSpecs() {
|
|
1086
|
+
if (highlightSpec && highlightSpec.shared) highlightSpec = null;
|
|
1087
|
+
if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
|
|
1088
|
+
for (var i = specs.length - 1; i >= 0; i--) {
|
|
1089
|
+
if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1023
1093
|
// \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
|
|
1024
1094
|
// Persist only the serializable parts of each Spec; the live element ref and
|
|
1025
1095
|
// DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
|
|
1026
1096
|
function saveSpecs() {
|
|
1027
1097
|
try {
|
|
1028
1098
|
if (!specs.length) localStorage.removeItem(STORAGE_KEY);
|
|
1029
|
-
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 }; })));
|
|
1099
|
+
else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason }; })));
|
|
1030
1100
|
} catch (e) {}
|
|
1031
1101
|
scheduleSync(); // keep the Claude bridge mirrored to the current Specs
|
|
1032
1102
|
}
|
|
@@ -1036,13 +1106,279 @@ function getClientScript(options) {
|
|
|
1036
1106
|
try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
|
|
1037
1107
|
if (!Array.isArray(data) || !data.length) return;
|
|
1038
1108
|
data.forEach(function (d) {
|
|
1039
|
-
var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
|
|
1109
|
+
var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '' };
|
|
1040
1110
|
specs.push(spec);
|
|
1041
1111
|
createBadge(spec);
|
|
1042
1112
|
});
|
|
1043
1113
|
renumber();
|
|
1044
1114
|
}
|
|
1045
1115
|
|
|
1116
|
+
// \u2500\u2500\u2500 Share Comments (human\u2194human) \u2014 Steps 1-2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1117
|
+
// A shared comment is a LOCATOR, not a coordinate: on import we re-find the
|
|
1118
|
+
// element and place a badge, exactly like restoreSpecs. Comments only \u2014 we carry
|
|
1119
|
+
// the note + how to re-find the element, NEVER the props/measurements (body).
|
|
1120
|
+
// Step 2 adds change-detection: a fingerprint travels with each comment; if the
|
|
1121
|
+
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1122
|
+
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1123
|
+
|
|
1124
|
+
// Normalized element signature \u2014 a STRUCTURAL identity used to disambiguate which
|
|
1125
|
+
// element a shared comment belongs to (and to scan for it when selectors fail).
|
|
1126
|
+
// Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
|
|
1127
|
+
// price) was false-tripping this and blocking re-anchor on otherwise-identical
|
|
1128
|
+
// pages. tag + sorted own classes + key attrs + child count identifies the node
|
|
1129
|
+
// without that fragility. (Trade-off: pure text edits under a stable node no
|
|
1130
|
+
// longer read as "changed" \u2014 placement is favoured over change-detection.)
|
|
1131
|
+
function normSig(el) {
|
|
1132
|
+
if (!el || el.nodeType !== 1) return '';
|
|
1133
|
+
var cls = Array.prototype.slice.call(el.classList)
|
|
1134
|
+
.filter(function (c) { return c.indexOf('__specter') !== 0; }).sort().join('.');
|
|
1135
|
+
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1136
|
+
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1137
|
+
}).filter(Boolean).join('|');
|
|
1138
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
|
|
1139
|
+
}
|
|
1140
|
+
// djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1141
|
+
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
1142
|
+
function fingerprint(el) {
|
|
1143
|
+
var s = normSig(el);
|
|
1144
|
+
if (!s) return '';
|
|
1145
|
+
var h = 5381, i = s.length;
|
|
1146
|
+
while (i) h = (h * 33) ^ s.charCodeAt(--i);
|
|
1147
|
+
return (h >>> 0).toString(36);
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
|
|
1151
|
+
|
|
1152
|
+
// Every element whose OWN text matches (trunc = the stored anchor was cut to 40
|
|
1153
|
+
// chars). Returns ALL matches \u2014 the fingerprint picks among them in reFindShared,
|
|
1154
|
+
// so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
|
|
1155
|
+
function findAllByOwnText(val, trunc) {
|
|
1156
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
|
|
1157
|
+
for (var i = 0; i < all.length; i++) {
|
|
1158
|
+
var el = all[i];
|
|
1159
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1160
|
+
var t = ownText(el);
|
|
1161
|
+
if (!t) continue;
|
|
1162
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
|
|
1163
|
+
}
|
|
1164
|
+
return out;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
|
|
1168
|
+
// querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1169
|
+
// scans own-text. Returns ALL matches (may be >1) \u2014 reFindShared narrows by
|
|
1170
|
+
// fingerprint rather than rejecting anything ambiguous up front.
|
|
1171
|
+
function resolveFindCandidates(find) {
|
|
1172
|
+
if (!find) return [];
|
|
1173
|
+
var c = find.charAt(0);
|
|
1174
|
+
if (c === '#' || c === '.' || c === '[') return queryAll(find);
|
|
1175
|
+
var q = find.indexOf(' "');
|
|
1176
|
+
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1177
|
+
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1178
|
+
if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
|
|
1179
|
+
if (attr === 'text') return findAllByOwnText(val, trunc);
|
|
1180
|
+
if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
|
|
1181
|
+
return [];
|
|
1182
|
+
}
|
|
1183
|
+
return queryAll(find); // a bare CSS path
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
|
|
1187
|
+
// just a change-detector: gather every candidate from the find anchor AND the
|
|
1188
|
+
// nth-child path, then return the one whose signature matches \u2014 so a locator
|
|
1189
|
+
// shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
|
|
1190
|
+
// instead of failing as ambiguous. With no fp match: hand back a lone candidate
|
|
1191
|
+
// (so importComments can say "changed"), else null ("couldn't find").
|
|
1192
|
+
// Whole-DOM scan for the one element whose structural signature matches \u2014 the
|
|
1193
|
+
// last-resort re-finder when both the find anchor and the nth-of-type path fail
|
|
1194
|
+
// (e.g. selectors that don't round-trip, or a shifted structure). >1 match \u2192 bail
|
|
1195
|
+
// (ambiguous, don't guess). Skips Specter's own UI.
|
|
1196
|
+
function findByFingerprint(fp) {
|
|
1197
|
+
if (!fp) return null;
|
|
1198
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1199
|
+
for (var i = 0; i < all.length; i++) {
|
|
1200
|
+
var el = all[i];
|
|
1201
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1202
|
+
if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
|
|
1203
|
+
}
|
|
1204
|
+
return hit;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function reFindShared(item) {
|
|
1208
|
+
var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
|
|
1209
|
+
var uniq = [], i;
|
|
1210
|
+
for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
|
|
1211
|
+
if (item.fp) {
|
|
1212
|
+
for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
|
|
1213
|
+
var scan = findByFingerprint(item.fp); // selectors failed/ambiguous \u2192 find it by signature
|
|
1214
|
+
if (scan) return scan;
|
|
1215
|
+
}
|
|
1216
|
+
return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
1220
|
+
// Match by origin + pathname only \u2014 hash AND query are dropped (the hash is the
|
|
1221
|
+
// doSync fork bug; a trailing #route or ?ref shouldn't split the same page).
|
|
1222
|
+
function pageKey(href) {
|
|
1223
|
+
try { var u = new URL(href || location.href); return u.origin + u.pathname; }
|
|
1224
|
+
catch (e) { return location.origin + location.pathname; }
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
function exportComments() {
|
|
1228
|
+
return {
|
|
1229
|
+
url: location.href, // gated on pageKey() at import; full href kept for reference
|
|
1230
|
+
comments: specs.map(function (s) {
|
|
1231
|
+
return { find: resolveLocator(s.el), path: s.path, fp: fingerprint(s.el), note: s.note || '', kind: s.kind || 'element' };
|
|
1232
|
+
}),
|
|
1233
|
+
};
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function importComments(payload) {
|
|
1237
|
+
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { return 0; } }
|
|
1238
|
+
var comments = null, srcUrl = '';
|
|
1239
|
+
if (Array.isArray(payload)) comments = payload; // legacy pre-URL blob: no gate
|
|
1240
|
+
else if (payload && Array.isArray(payload.comments)) { comments = payload.comments; srcUrl = payload.url || ''; }
|
|
1241
|
+
if (!comments) return 0;
|
|
1242
|
+
// Wrong page \u2192 decline rather than anchor onto whatever happens to match here.
|
|
1243
|
+
if (srcUrl && pageKey(srcUrl) !== pageKey()) {
|
|
1244
|
+
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
|
|
1245
|
+
return 0;
|
|
1246
|
+
}
|
|
1247
|
+
// A received share REPLACES the previously-imported set: re-opening the same
|
|
1248
|
+
// link (or a refreshed one) gives exactly that set, never a stacked-up pile of
|
|
1249
|
+
// duplicates across rounds. Your OWN local specs (shared:false) are untouched.
|
|
1250
|
+
removeSharedSpecs();
|
|
1251
|
+
var added = 0;
|
|
1252
|
+
comments.forEach(function (item) {
|
|
1253
|
+
if (!item) return;
|
|
1254
|
+
// reFindShared already used the fingerprint to pick/scan the right element, so
|
|
1255
|
+
// trust its result: if it located a node, PLACE the comment. Only a genuine
|
|
1256
|
+
// no-match is MISSING \u2014 we no longer hide a found element just because its
|
|
1257
|
+
// signature drifted (that over-fired on dynamic pages and buried real pins).
|
|
1258
|
+
var el = reFindShared(item), missing = false, reason = '';
|
|
1259
|
+
if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
|
|
1260
|
+
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1261
|
+
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1262
|
+
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 };
|
|
1263
|
+
specs.push(spec);
|
|
1264
|
+
createBadge(spec);
|
|
1265
|
+
added++;
|
|
1266
|
+
});
|
|
1267
|
+
renumber();
|
|
1268
|
+
reflowSpecs();
|
|
1269
|
+
updatePill();
|
|
1270
|
+
if (panelOpen) renderPanel();
|
|
1271
|
+
saveSpecs();
|
|
1272
|
+
return added;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// \u2500\u2500 Transport: share link (#spx=) with a file fallback \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1276
|
+
// The link is destination AND payload \u2014 pasted into Slack it looks like a normal
|
|
1277
|
+
// URL; clicked, it lands the recipient on the exact page, so the URL gate is
|
|
1278
|
+
// satisfied automatically. Payload = <fmt><base64url>: fmt 'z' = gzip (Compression
|
|
1279
|
+
// Stream, zero-dep), 'j' = plain UTF-8 when gzip is unavailable. split/join dodges
|
|
1280
|
+
// regex escaping inside this template-literal client.
|
|
1281
|
+
var LINK_MAX = 8000; // ~URL ceiling; beyond this we offer a .specter.json file
|
|
1282
|
+
|
|
1283
|
+
function bytesToB64url(bytes) {
|
|
1284
|
+
var bin = '';
|
|
1285
|
+
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
1286
|
+
var b = btoa(bin).split('+').join('-').split('/').join('_');
|
|
1287
|
+
while (b.charAt(b.length - 1) === '=') b = b.slice(0, -1);
|
|
1288
|
+
return b;
|
|
1289
|
+
}
|
|
1290
|
+
function b64urlToBytes(s) {
|
|
1291
|
+
s = s.split('-').join('+').split('_').join('/');
|
|
1292
|
+
while (s.length % 4) s += '=';
|
|
1293
|
+
var bin = atob(s), bytes = new Uint8Array(bin.length);
|
|
1294
|
+
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
1295
|
+
return bytes;
|
|
1296
|
+
}
|
|
1297
|
+
function gzipStr(str) {
|
|
1298
|
+
var s = new Blob([str]).stream().pipeThrough(new CompressionStream('gzip'));
|
|
1299
|
+
return new Response(s).arrayBuffer().then(function (buf) { return new Uint8Array(buf); });
|
|
1300
|
+
}
|
|
1301
|
+
function gunzipBytes(bytes) {
|
|
1302
|
+
var s = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
|
|
1303
|
+
return new Response(s).text();
|
|
1304
|
+
}
|
|
1305
|
+
function encodePayload(str) {
|
|
1306
|
+
if (typeof CompressionStream !== 'undefined') {
|
|
1307
|
+
return gzipStr(str).then(function (gz) { return 'z' + bytesToB64url(gz); })
|
|
1308
|
+
.catch(function () { return 'j' + bytesToB64url(new TextEncoder().encode(str)); });
|
|
1309
|
+
}
|
|
1310
|
+
return Promise.resolve('j' + bytesToB64url(new TextEncoder().encode(str)));
|
|
1311
|
+
}
|
|
1312
|
+
function decodePayload(s) {
|
|
1313
|
+
var fmt = s.charAt(0);
|
|
1314
|
+
if (fmt === 'z') return gunzipBytes(b64urlToBytes(s.slice(1)));
|
|
1315
|
+
if (fmt === 'j') return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s.slice(1))));
|
|
1316
|
+
// legacy (no fmt prefix): raw base64url of the JSON string
|
|
1317
|
+
return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s)));
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Build on the current page (keep query, replace any existing hash).
|
|
1321
|
+
function buildShareLink() {
|
|
1322
|
+
return encodePayload(JSON.stringify(exportComments())).then(function (enc) {
|
|
1323
|
+
return location.origin + location.pathname + location.search + '#spx=' + enc;
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// A page that uses hash routing itself would collide with #spx= \u2014 treat any
|
|
1328
|
+
// non-trivial existing hash (beyond a bare '#') as in-use and prefer the file.
|
|
1329
|
+
function hashInUse() { return (location.hash || '').length > 1; }
|
|
1330
|
+
|
|
1331
|
+
// Download the comments as a .specter.json (the fallback when the link is too big
|
|
1332
|
+
// or the page owns the hash). Same {url, comments} payload \u2192 same URL gate on import.
|
|
1333
|
+
function downloadCommentsFile() {
|
|
1334
|
+
var data = JSON.stringify(exportComments(), null, 2);
|
|
1335
|
+
var url = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
|
|
1336
|
+
var a = document.createElement('a');
|
|
1337
|
+
a.href = url; a.download = 'comments.specter.json';
|
|
1338
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
1339
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
// One coherent share action: a link by default, a file when it can't fit (or the
|
|
1343
|
+
// page owns the hash). Returns a descriptor so the caller/UI can tell the user which.
|
|
1344
|
+
function shareComments() {
|
|
1345
|
+
if (!specs.length) return Promise.resolve({ kind: 'empty' });
|
|
1346
|
+
if (hashInUse()) { downloadCommentsFile(); return Promise.resolve({ kind: 'file', reason: 'hash-routing' }); }
|
|
1347
|
+
return buildShareLink().then(function (link) {
|
|
1348
|
+
if (link.length > LINK_MAX) { downloadCommentsFile(); return { kind: 'file', reason: 'too-large', size: link.length }; }
|
|
1349
|
+
return { kind: 'link', link: link };
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// Pick a .specter.json (or any JSON) and import it \u2014 the file-fallback receiver.
|
|
1354
|
+
function importCommentsFile() {
|
|
1355
|
+
var input = document.createElement('input');
|
|
1356
|
+
input.type = 'file';
|
|
1357
|
+
input.accept = '.json,application/json';
|
|
1358
|
+
input.addEventListener('change', function () {
|
|
1359
|
+
var f = input.files && input.files[0];
|
|
1360
|
+
if (!f) return;
|
|
1361
|
+
var reader = new FileReader();
|
|
1362
|
+
reader.onload = function () { var n = importComments(String(reader.result)); if (n > 0) { if (!fiActive) activate(); showPanel(); } };
|
|
1363
|
+
reader.readAsText(f);
|
|
1364
|
+
});
|
|
1365
|
+
input.click();
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
// On load: if the URL carries #spx=, decode it, strip it from the address bar (so
|
|
1369
|
+
// the URL goes clean and a plain reload won't re-import), import, and surface it.
|
|
1370
|
+
function importFromHash() {
|
|
1371
|
+
var h = location.hash || '', k = h.indexOf('spx=');
|
|
1372
|
+
if (k < 0) return Promise.resolve(0);
|
|
1373
|
+
var enc = h.slice(k + 4);
|
|
1374
|
+
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
|
|
1375
|
+
return decodePayload(enc).then(function (json) {
|
|
1376
|
+
var n = importComments(json);
|
|
1377
|
+
if (n > 0) { if (!fiActive) activate(); showPanel(); }
|
|
1378
|
+
return n;
|
|
1379
|
+
}).catch(function () { return 0; });
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1046
1382
|
// Re-anchor if the node detached, then scroll it into view. Returns false when
|
|
1047
1383
|
// the element can't be shown (removed / hidden \u2014 e.g. behind a closed modal).
|
|
1048
1384
|
// The badge enlargement is handled separately via highlightSpec (sustained
|
|
@@ -1062,6 +1398,11 @@ function getClientScript(options) {
|
|
|
1062
1398
|
return isVisible(spec.el);
|
|
1063
1399
|
}
|
|
1064
1400
|
|
|
1401
|
+
// Signature of on-page visibility across specs (from reflowSpecs' per-frame _liveVis),
|
|
1402
|
+
// used to detect when the panel's HIDDEN labels have gone stale and re-render.
|
|
1403
|
+
var panelVisSig = '', panelVisTimer = null;
|
|
1404
|
+
function liveVisSig() { var s = ''; for (var i = 0; i < specs.length; i++) s += specs[i]._liveVis ? '1' : '0'; return s; }
|
|
1405
|
+
|
|
1065
1406
|
// \u2500\u2500\u2500 Specs side panel (in-session review) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1066
1407
|
var panelWrap = markUI(document.createElement('div'));
|
|
1067
1408
|
Object.assign(panelWrap.style, {
|
|
@@ -1149,10 +1490,40 @@ function getClientScript(options) {
|
|
|
1149
1490
|
var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
|
|
1150
1491
|
delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
|
|
1151
1492
|
|
|
1493
|
+
// Share comments (human\u2194human): copies a link, or downloads a file when it can't
|
|
1494
|
+
// fit / the page owns the hash. The hint line confirms which happened.
|
|
1495
|
+
var share = iconPill(SHARE, 'Share comments to another person', '#8AB4F8', '138,180,248');
|
|
1496
|
+
function resetShareIcon() { share.icon.innerHTML = SHARE; share.btn.style.color = share.btn.matches(':hover') ? '#fff' : '#8AB4F8'; }
|
|
1497
|
+
share.btn.addEventListener('click', function (e) {
|
|
1498
|
+
e.stopPropagation();
|
|
1499
|
+
if (!specs.length) return;
|
|
1500
|
+
shareComments().then(function (res) {
|
|
1501
|
+
if (res.kind === 'link') {
|
|
1502
|
+
navigator.clipboard.writeText(res.link).then(function () {
|
|
1503
|
+
share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
|
|
1504
|
+
flashHint('\u2713 Link copied \u2014 paste it to your reviewer. They see your comments on the same page.', GREEN);
|
|
1505
|
+
setTimeout(resetShareIcon, 1400);
|
|
1506
|
+
}).catch(function () { flashHint('Copy failed \u2014 check clipboard permission for this site.', '#ED8FA6'); });
|
|
1507
|
+
} else if (res.kind === 'file') {
|
|
1508
|
+
share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
|
|
1509
|
+
flashHint('\u2713 Saved comments.specter.json \u2014 send that file (too many comments to fit a link).', GREEN);
|
|
1510
|
+
setTimeout(resetShareIcon, 1400);
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
// Import a comments file someone sent (the file-fallback receiver).
|
|
1516
|
+
var importTool = iconPill(IMPORT, 'Import a comments file', '#8AB4F8', '138,180,248');
|
|
1517
|
+
importTool.btn.addEventListener('click', function (e) { e.stopPropagation(); importCommentsFile(); });
|
|
1518
|
+
|
|
1152
1519
|
var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
|
|
1153
1520
|
var deleteAllBtn = delAll.btn;
|
|
1521
|
+
var shareBtn = share.btn;
|
|
1522
|
+
var importToolBtn = importTool.btn;
|
|
1154
1523
|
if (BRIDGE) panelTools.appendChild(syncDot);
|
|
1155
1524
|
else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
|
|
1525
|
+
panelTools.appendChild(shareBtn);
|
|
1526
|
+
panelTools.appendChild(importToolBtn);
|
|
1156
1527
|
panelTools.appendChild(copyAllBtn);
|
|
1157
1528
|
panelTools.appendChild(deleteAllBtn);
|
|
1158
1529
|
|
|
@@ -1220,6 +1591,16 @@ function getClientScript(options) {
|
|
|
1220
1591
|
panelHint.appendChild(document.createTextNode(' (or Copy all) and paste into Claude to apply these annotations. You can also edit or delete each one above.'));
|
|
1221
1592
|
}
|
|
1222
1593
|
}
|
|
1594
|
+
// Briefly show a confirmation in the hint line (Share copied / file saved), then
|
|
1595
|
+
// restore the normal guidance.
|
|
1596
|
+
var hintFlashTimer = null;
|
|
1597
|
+
function flashHint(msg, color) {
|
|
1598
|
+
panelHint.style.display = 'block';
|
|
1599
|
+
panelHint.textContent = msg;
|
|
1600
|
+
panelHint.style.color = color || GREEN;
|
|
1601
|
+
if (hintFlashTimer) clearTimeout(hintFlashTimer);
|
|
1602
|
+
hintFlashTimer = setTimeout(function () { panelHint.style.color = LABEL; setHint(); }, 2800);
|
|
1603
|
+
}
|
|
1223
1604
|
|
|
1224
1605
|
var panelList = document.createElement('div');
|
|
1225
1606
|
// overscroll-behavior:contain stops the panel's scroll from chaining into the page.
|
|
@@ -1254,7 +1635,7 @@ function getClientScript(options) {
|
|
|
1254
1635
|
Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
|
|
1255
1636
|
rows.forEach(function (r) {
|
|
1256
1637
|
var row = document.createElement('div');
|
|
1257
|
-
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '
|
|
1638
|
+
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
|
|
1258
1639
|
var k = document.createElement('span');
|
|
1259
1640
|
k.textContent = r[0];
|
|
1260
1641
|
Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
@@ -1279,7 +1660,7 @@ function getClientScript(options) {
|
|
|
1279
1660
|
panelWrap.appendChild(panelHint);
|
|
1280
1661
|
panelWrap.appendChild(panelList);
|
|
1281
1662
|
panelWrap.appendChild(panelKeys);
|
|
1282
|
-
|
|
1663
|
+
mount(panelWrap);
|
|
1283
1664
|
|
|
1284
1665
|
// Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
|
|
1285
1666
|
// scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
|
|
@@ -1333,21 +1714,24 @@ function getClientScript(options) {
|
|
|
1333
1714
|
|
|
1334
1715
|
function renderPanel() {
|
|
1335
1716
|
panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
|
|
1336
|
-
panelTools.style.display =
|
|
1717
|
+
panelTools.style.display = 'flex'; // always visible when the panel is open (Import needs no Specs)
|
|
1718
|
+
shareBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1337
1719
|
copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1338
1720
|
deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1721
|
+
importToolBtn.style.display = 'inline-flex'; // receiving a shared file needs no existing Specs
|
|
1339
1722
|
setHint();
|
|
1340
1723
|
panelList.textContent = '';
|
|
1341
1724
|
if (!specs.length) {
|
|
1342
1725
|
var empty = document.createElement('div');
|
|
1343
|
-
empty.textContent = 'No
|
|
1726
|
+
empty.textContent = 'No comments yet \u2014 hover an element and press P, or use the Import button above to open a comments file someone shared.';
|
|
1344
1727
|
Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
|
|
1345
1728
|
panelList.appendChild(empty);
|
|
1346
1729
|
return;
|
|
1347
1730
|
}
|
|
1348
1731
|
var focusEditor = null, measures = [];
|
|
1349
1732
|
specs.forEach(function (spec, i) {
|
|
1350
|
-
var
|
|
1733
|
+
var missing = !!spec.missing;
|
|
1734
|
+
var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
|
|
1351
1735
|
var editing = (panelEditSpec === spec);
|
|
1352
1736
|
var row = document.createElement('div');
|
|
1353
1737
|
Object.assign(row.style, {
|
|
@@ -1461,9 +1845,25 @@ function getClientScript(options) {
|
|
|
1461
1845
|
content.appendChild(note);
|
|
1462
1846
|
content.appendChild(meta);
|
|
1463
1847
|
|
|
1848
|
+
// Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
|
|
1849
|
+
// never drawn on the page \u2014 design: show missing, don't guess a spot).
|
|
1850
|
+
if (missing) {
|
|
1851
|
+
row.style.opacity = '0.7';
|
|
1852
|
+
var mbar = document.createElement('div');
|
|
1853
|
+
Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1854
|
+
var mtag = document.createElement('span');
|
|
1855
|
+
mtag.textContent = 'MISSING';
|
|
1856
|
+
Object.assign(mtag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#fff', background: '#8B4A57', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
|
|
1857
|
+
var mhint = document.createElement('span');
|
|
1858
|
+
mhint.textContent = spec.missReason || 'Not on this page';
|
|
1859
|
+
Object.assign(mhint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
|
|
1860
|
+
mbar.appendChild(mtag);
|
|
1861
|
+
mbar.appendChild(mhint);
|
|
1862
|
+
content.appendChild(mbar);
|
|
1863
|
+
}
|
|
1464
1864
|
// Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
|
|
1465
1865
|
// so hidden Specs are findable in a long list instead of silently unreachable.
|
|
1466
|
-
if (!visible) {
|
|
1866
|
+
else if (!visible) {
|
|
1467
1867
|
var hbar = document.createElement('div');
|
|
1468
1868
|
Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1469
1869
|
var tag = document.createElement('span');
|
|
@@ -1516,10 +1916,11 @@ function getClientScript(options) {
|
|
|
1516
1916
|
}
|
|
1517
1917
|
});
|
|
1518
1918
|
if (focusEditor) setTimeout(focusEditor, 0);
|
|
1919
|
+
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1519
1920
|
}
|
|
1520
1921
|
|
|
1521
|
-
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
1522
|
-
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
|
|
1922
|
+
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
|
|
1923
|
+
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
|
|
1523
1924
|
function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
|
|
1524
1925
|
|
|
1525
1926
|
// \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
|
|
@@ -2096,8 +2497,9 @@ function getClientScript(options) {
|
|
|
2096
2497
|
});
|
|
2097
2498
|
}
|
|
2098
2499
|
|
|
2099
|
-
restoreSpecs();
|
|
2100
|
-
|
|
2500
|
+
restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
|
|
2501
|
+
importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
|
|
2502
|
+
scheduleSync(); // mirror restored Specs to the Claude bridge on load
|
|
2101
2503
|
|
|
2102
2504
|
console.log('%c\u{1F47B} Specter \u2014 Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
|
|
2103
2505
|
})();`;
|