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
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
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>';
|
|
24
24
|
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>';
|
|
25
25
|
var CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
|
26
|
+
var SHARE = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path><polyline points="16 6 12 2 8 6"></polyline><line x1="12" y1="2" x2="12" y2="15"></line></svg>';
|
|
27
|
+
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>';
|
|
26
28
|
var ACTIVATE = "ctrl+alt+z";
|
|
27
29
|
var BRIDGE = ""; // Claude MCP bridge URL, or '' if disabled
|
|
28
30
|
|
|
@@ -63,6 +65,45 @@
|
|
|
63
65
|
function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
|
|
64
66
|
function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
|
|
65
67
|
|
|
68
|
+
// ─── Hydration-proof mounting ───────────────────────────────────────────────
|
|
69
|
+
// We inject at document_end, but frameworks that hydrate <body> (Next.js App
|
|
70
|
+
// Router / React 18-19, some Remix/Gatsby) then reconcile away DOM nodes they
|
|
71
|
+
// didn't render — silently deleting Specter's UI on those sites (pill/panel
|
|
72
|
+
// vanish; badges added later survive because they're created post-hydration).
|
|
73
|
+
// Track our persistent singletons and re-attach any that gets detached, so the
|
|
74
|
+
// UI survives the hydration pass and later SPA re-renders. The parent is resolved
|
|
75
|
+
// fresh on each remount so a wholesale body/head element swap is handled too.
|
|
76
|
+
var _mounted = [];
|
|
77
|
+
function mount(node, where) {
|
|
78
|
+
var parent = where === 'head' ? document.head : document.body;
|
|
79
|
+
if (parent) parent.appendChild(node);
|
|
80
|
+
_mounted.push({ node: node, where: where });
|
|
81
|
+
return node;
|
|
82
|
+
}
|
|
83
|
+
var _remountQueued = false;
|
|
84
|
+
function remountDetached() {
|
|
85
|
+
_remountQueued = false;
|
|
86
|
+
for (var i = 0; i < _mounted.length; i++) {
|
|
87
|
+
var m = _mounted[i];
|
|
88
|
+
if (m.node.isConnected) continue;
|
|
89
|
+
var p = m.where === 'head' ? document.head : document.body;
|
|
90
|
+
if (p) p.appendChild(m.node); // re-append; isConnected guard above prevents an observer loop
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
var _defer = window.requestAnimationFrame ? function (fn) { requestAnimationFrame(fn); } : function (fn) { setTimeout(fn, 0); };
|
|
95
|
+
var _mo = new MutationObserver(function () {
|
|
96
|
+
if (_remountQueued) return;
|
|
97
|
+
_remountQueued = true;
|
|
98
|
+
_defer(remountDetached); // coalesce a burst of mutations into one restore pass
|
|
99
|
+
});
|
|
100
|
+
// childList on the roots is enough — every singleton is a direct child of
|
|
101
|
+
// body/head; watching documentElement too catches a body/head element swap.
|
|
102
|
+
_mo.observe(document.documentElement, { childList: true });
|
|
103
|
+
_mo.observe(document.body, { childList: true });
|
|
104
|
+
_mo.observe(document.head, { childList: true });
|
|
105
|
+
} catch (e) {}
|
|
106
|
+
|
|
66
107
|
// Attach a hover effect to an interactive icon/button: apply the "on" styles
|
|
67
108
|
// while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
|
|
68
109
|
function hoverFx(el, on, off) {
|
|
@@ -89,7 +130,7 @@
|
|
|
89
130
|
boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
|
|
90
131
|
display: 'none',
|
|
91
132
|
});
|
|
92
|
-
|
|
133
|
+
mount(tooltip);
|
|
93
134
|
|
|
94
135
|
// Measure overlay
|
|
95
136
|
var measureOverlay = document.createElement('div');
|
|
@@ -101,7 +142,7 @@
|
|
|
101
142
|
pointerEvents: 'none',
|
|
102
143
|
display: 'none',
|
|
103
144
|
});
|
|
104
|
-
|
|
145
|
+
mount(measureOverlay);
|
|
105
146
|
|
|
106
147
|
// ─── Pill ─────────────────────────────────────────────────────────────────
|
|
107
148
|
var pillWrap = document.createElement('div');
|
|
@@ -177,41 +218,34 @@
|
|
|
177
218
|
var pillText = document.createElement('span');
|
|
178
219
|
Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
|
|
179
220
|
|
|
221
|
+
// Panel toggle — icon + label so it's self-explanatory. Label reflects state
|
|
222
|
+
// (Open/Close) and always names the shortcut.
|
|
180
223
|
var listBtn = document.createElement('span');
|
|
181
|
-
listBtn.
|
|
182
|
-
listBtn.title = 'Show Specs panel (L)';
|
|
224
|
+
listBtn.title = 'Toggle Specs panel (L)';
|
|
183
225
|
Object.assign(listBtn.style, {
|
|
184
226
|
display: 'none',
|
|
185
227
|
alignItems: 'center',
|
|
228
|
+
gap: '6px',
|
|
186
229
|
cursor: 'pointer',
|
|
187
230
|
color: '#fff',
|
|
231
|
+
fontSize: '11px',
|
|
188
232
|
flexShrink: '0',
|
|
189
|
-
padding: '4px
|
|
233
|
+
padding: '4px 10px',
|
|
190
234
|
marginLeft: '2px',
|
|
191
235
|
borderRadius: '999px',
|
|
192
236
|
background: 'rgba(255,255,255,0.16)',
|
|
193
237
|
});
|
|
238
|
+
var listIcon = document.createElement('span');
|
|
239
|
+
listIcon.innerHTML = LIST;
|
|
240
|
+
Object.assign(listIcon.style, { display: 'flex', alignItems: 'center' });
|
|
241
|
+
var listLabel = document.createElement('span');
|
|
242
|
+
listBtn.appendChild(listIcon);
|
|
243
|
+
listBtn.appendChild(listLabel);
|
|
244
|
+
function updateListBtn() { listLabel.textContent = (panelOpen ? 'Close' : 'Open') + ' sidebar [L]'; }
|
|
245
|
+
updateListBtn();
|
|
194
246
|
listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
|
|
195
247
|
hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
196
248
|
|
|
197
|
-
var clearBtn = document.createElement('span');
|
|
198
|
-
clearBtn.textContent = '✕ Delete all';
|
|
199
|
-
clearBtn.title = 'Delete all annotations';
|
|
200
|
-
Object.assign(clearBtn.style, {
|
|
201
|
-
display: 'none',
|
|
202
|
-
cursor: 'pointer',
|
|
203
|
-
color: '#fff',
|
|
204
|
-
fontSize: '11px',
|
|
205
|
-
fontWeight: '600',
|
|
206
|
-
flexShrink: '0',
|
|
207
|
-
padding: '2px 8px',
|
|
208
|
-
marginLeft: '2px',
|
|
209
|
-
borderRadius: '999px',
|
|
210
|
-
background: 'rgba(255,255,255,0.16)',
|
|
211
|
-
});
|
|
212
|
-
clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
|
|
213
|
-
hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
214
|
-
|
|
215
249
|
var chevron = document.createElement('span');
|
|
216
250
|
chevron.textContent = '›';
|
|
217
251
|
chevron.title = 'Move to other side';
|
|
@@ -241,16 +275,15 @@
|
|
|
241
275
|
pill.appendChild(pillSync);
|
|
242
276
|
pill.appendChild(pillText);
|
|
243
277
|
pill.appendChild(listBtn);
|
|
244
|
-
pill.appendChild(clearBtn);
|
|
245
278
|
pill.appendChild(chevron);
|
|
246
279
|
pillWrap.appendChild(pill);
|
|
247
|
-
|
|
280
|
+
mount(pillWrap);
|
|
248
281
|
|
|
249
282
|
// Keyframes for the sync spinner (injected once).
|
|
250
283
|
var spinStyle = document.createElement('style');
|
|
251
284
|
spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
|
|
252
285
|
markUI(spinStyle);
|
|
253
|
-
|
|
286
|
+
mount(spinStyle, 'head');
|
|
254
287
|
|
|
255
288
|
// Shared dot styling for the control-bar AND side-panel sync indicators, so they
|
|
256
289
|
// always match: spinner while syncing, green when synced, red on error.
|
|
@@ -302,15 +335,15 @@
|
|
|
302
335
|
// The mode is shown at all times (persistent prefix), so you always know whether
|
|
303
336
|
// hovering shows properties, measurements, or nothing (Comment).
|
|
304
337
|
function modeLabel() {
|
|
305
|
-
return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
|
|
338
|
+
return (commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties')) + ' mode';
|
|
306
339
|
}
|
|
307
340
|
|
|
308
341
|
function expandPill(text) {
|
|
309
342
|
pill.style.maxWidth = '820px';
|
|
310
343
|
pillText.style.display = 'inline';
|
|
311
344
|
chevron.style.display = 'inline';
|
|
312
|
-
listBtn.style.display =
|
|
313
|
-
|
|
345
|
+
listBtn.style.display = 'inline-flex'; // always reachable — the panel is also where you Import a shared file
|
|
346
|
+
updateListBtn();
|
|
314
347
|
pillExpanded = true;
|
|
315
348
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
316
349
|
// side panel now, so the pill stays short.
|
|
@@ -325,14 +358,12 @@
|
|
|
325
358
|
pill.style.maxWidth = '220px';
|
|
326
359
|
chevron.style.display = 'none';
|
|
327
360
|
listBtn.style.display = 'none';
|
|
328
|
-
clearBtn.style.display = 'none';
|
|
329
361
|
pillExpanded = false;
|
|
330
362
|
}
|
|
331
363
|
|
|
332
364
|
function flashMode() {
|
|
333
365
|
if (pillExpanded && pillWrap.matches(':hover')) return;
|
|
334
|
-
|
|
335
|
-
expandPill(text);
|
|
366
|
+
expandPill(modeLabel());
|
|
336
367
|
clearTimeout(flashTimer);
|
|
337
368
|
flashTimer = setTimeout(function () {
|
|
338
369
|
if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
|
|
@@ -458,10 +489,13 @@
|
|
|
458
489
|
var cur = el;
|
|
459
490
|
for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
|
|
460
491
|
var part = cur.tagName.toLowerCase();
|
|
461
|
-
if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
|
|
492
|
+
if (cur.id) { part += '#' + cssEsc(cur.id); parts.unshift(part); break; }
|
|
462
493
|
var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
|
|
463
494
|
if (cls.length) {
|
|
464
|
-
|
|
495
|
+
// Escape each class: Tailwind classes like lg:block / bg-[#f5f5dc] are
|
|
496
|
+
// invalid raw in a selector (the : reads as a pseudo, [# as an attr) and
|
|
497
|
+
// throw on query, so they must be CSS.escape-d first.
|
|
498
|
+
part += '.' + cls.slice(0, 2).map(cssEsc).join('.');
|
|
465
499
|
} else if (cur.parentElement) {
|
|
466
500
|
var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
|
|
467
501
|
if (sameTag.length > 1) {
|
|
@@ -541,13 +575,13 @@
|
|
|
541
575
|
var i, v;
|
|
542
576
|
var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
|
|
543
577
|
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; } }
|
|
544
|
-
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
|
|
578
|
+
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + cssEsc(el.id);
|
|
545
579
|
var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
|
|
546
580
|
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) + '"'; } }
|
|
547
581
|
var txt = ownText(el);
|
|
548
582
|
if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '…' : '') + '"';
|
|
549
583
|
var cls = ownClass(el);
|
|
550
|
-
if (cls) return '.' + cls;
|
|
584
|
+
if (cls) return '.' + cssEsc(cls);
|
|
551
585
|
return getSelector(el); // fallback: the CSS path
|
|
552
586
|
}
|
|
553
587
|
|
|
@@ -721,11 +755,20 @@
|
|
|
721
755
|
function elementPath(el) {
|
|
722
756
|
if (!el || el.nodeType !== 1) return '';
|
|
723
757
|
var parts = [], cur = el;
|
|
724
|
-
|
|
725
|
-
|
|
758
|
+
// Root the path at <body> or a stable id, and index with nth-OF-TYPE rather than
|
|
759
|
+
// nth-child: a hydrated page injects <script>/<style> siblings that shift a raw
|
|
760
|
+
// child index (the old div:nth-child(11) bug — matched a different node, or
|
|
761
|
+
// none, on the recipient). nth-of-type counts only same-tag siblings, so it
|
|
762
|
+
// survives that. Deeper cap (12) so the chain reaches a rooting anchor.
|
|
763
|
+
while (cur && cur.nodeType === 1 && parts.length < 12) {
|
|
764
|
+
if (cur === document.body) { parts.unshift('body'); break; }
|
|
765
|
+
if (cur.id && !isHashedId(cur.id)) { parts.unshift('#' + cssEsc(cur.id)); break; }
|
|
726
766
|
var seg = cur.tagName.toLowerCase();
|
|
727
767
|
var parent = cur.parentElement;
|
|
728
|
-
if (parent)
|
|
768
|
+
if (parent) {
|
|
769
|
+
var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName; });
|
|
770
|
+
if (same.length > 1) seg += ':nth-of-type(' + (Array.prototype.indexOf.call(same, cur) + 1) + ')';
|
|
771
|
+
}
|
|
729
772
|
parts.unshift(seg);
|
|
730
773
|
cur = cur.parentElement;
|
|
731
774
|
}
|
|
@@ -794,19 +837,36 @@
|
|
|
794
837
|
var mx = lastMouse.x, my = lastMouse.y;
|
|
795
838
|
var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
|
|
796
839
|
|
|
797
|
-
// Pass 1 — resolve visibility + base anchor for each spec.
|
|
840
|
+
// Pass 1 — resolve visibility + base anchor for each spec. _liveVis tracks the
|
|
841
|
+
// panel's HIDDEN semantics (isVisible after re-anchor recovery, IGNORING occlusion —
|
|
842
|
+
// off-screen is fine, that's what the hover-scroll is for) so the panel can stay in
|
|
843
|
+
// sync with what's actually on the page instead of showing a stale HIDDEN.
|
|
798
844
|
var vis = [];
|
|
799
845
|
for (var i = 0; i < specs.length; i++) {
|
|
800
846
|
var s = specs[i];
|
|
801
|
-
|
|
847
|
+
// Badges created at import-time can be wiped by a framework hydrating <body>
|
|
848
|
+
// (same cause as the singleton mount guard). Re-attach a detached wrap so the
|
|
849
|
+
// pin reappears; a removed spec is already out of the specs array, so this
|
|
850
|
+
// never resurrects a deleted badge.
|
|
851
|
+
if (s.wrap && !s.wrap.isConnected && document.body) document.body.appendChild(s.wrap);
|
|
852
|
+
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
853
|
+
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
802
854
|
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
803
|
-
if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
|
|
855
|
+
if (!isVisible(s.el)) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
856
|
+
s._liveVis = true;
|
|
804
857
|
var r = s.el.getBoundingClientRect();
|
|
805
|
-
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered
|
|
858
|
+
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered / off-screen: no badge, but panel not HIDDEN
|
|
806
859
|
s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
|
|
807
860
|
vis.push(s);
|
|
808
861
|
}
|
|
809
862
|
|
|
863
|
+
// Keep the open panel's HIDDEN labels live: if any spec's on-page visibility
|
|
864
|
+
// flipped since the panel last rendered, re-render it (debounced).
|
|
865
|
+
if (panelOpen) {
|
|
866
|
+
var g = liveVisSig();
|
|
867
|
+
if (g !== panelVisSig) { panelVisSig = g; clearTimeout(panelVisTimer); panelVisTimer = setTimeout(function () { if (panelOpen) renderPanel(); }, 150); }
|
|
868
|
+
}
|
|
869
|
+
|
|
810
870
|
// Pass 2 — cluster badges whose anchors coincide (within ~16px).
|
|
811
871
|
var clusters = [];
|
|
812
872
|
for (var a = 0; a < vis.length; a++) {
|
|
@@ -988,13 +1048,23 @@
|
|
|
988
1048
|
saveSpecs();
|
|
989
1049
|
}
|
|
990
1050
|
|
|
1051
|
+
// Drop every imported (shared) spec, leaving the user's own local ones. Used by
|
|
1052
|
+
// importComments so a received share replaces the prior set instead of stacking.
|
|
1053
|
+
function removeSharedSpecs() {
|
|
1054
|
+
if (highlightSpec && highlightSpec.shared) highlightSpec = null;
|
|
1055
|
+
if (panelEditSpec && panelEditSpec.shared) panelEditSpec = null;
|
|
1056
|
+
for (var i = specs.length - 1; i >= 0; i--) {
|
|
1057
|
+
if (specs[i].shared) { if (specs[i].wrap) specs[i].wrap.remove(); specs.splice(i, 1); }
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
991
1061
|
// ─── Reload insurance (localStorage, per-URL, zero network) ──────────────────
|
|
992
1062
|
// Persist only the serializable parts of each Spec; the live element ref and
|
|
993
1063
|
// DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
|
|
994
1064
|
function saveSpecs() {
|
|
995
1065
|
try {
|
|
996
1066
|
if (!specs.length) localStorage.removeItem(STORAGE_KEY);
|
|
997
|
-
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 }; })));
|
|
1067
|
+
else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason }; })));
|
|
998
1068
|
} catch (e) {}
|
|
999
1069
|
scheduleSync(); // keep the Claude bridge mirrored to the current Specs
|
|
1000
1070
|
}
|
|
@@ -1004,13 +1074,279 @@
|
|
|
1004
1074
|
try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
|
|
1005
1075
|
if (!Array.isArray(data) || !data.length) return;
|
|
1006
1076
|
data.forEach(function (d) {
|
|
1007
|
-
var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
|
|
1077
|
+
var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '' };
|
|
1008
1078
|
specs.push(spec);
|
|
1009
1079
|
createBadge(spec);
|
|
1010
1080
|
});
|
|
1011
1081
|
renumber();
|
|
1012
1082
|
}
|
|
1013
1083
|
|
|
1084
|
+
// ─── Share Comments (human↔human) — Steps 1-2 ────────────────────────────────
|
|
1085
|
+
// A shared comment is a LOCATOR, not a coordinate: on import we re-find the
|
|
1086
|
+
// element and place a badge, exactly like restoreSpecs. Comments only — we carry
|
|
1087
|
+
// the note + how to re-find the element, NEVER the props/measurements (body).
|
|
1088
|
+
// Step 2 adds change-detection: a fingerprint travels with each comment; if the
|
|
1089
|
+
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1090
|
+
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1091
|
+
|
|
1092
|
+
// Normalized element signature — a STRUCTURAL identity used to disambiguate which
|
|
1093
|
+
// element a shared comment belongs to (and to scan for it when selectors fail).
|
|
1094
|
+
// Deliberately NO raw text: page-load-dynamic text (a clock, a random greeting, a
|
|
1095
|
+
// price) was false-tripping this and blocking re-anchor on otherwise-identical
|
|
1096
|
+
// pages. tag + sorted own classes + key attrs + child count identifies the node
|
|
1097
|
+
// without that fragility. (Trade-off: pure text edits under a stable node no
|
|
1098
|
+
// longer read as "changed" — placement is favoured over change-detection.)
|
|
1099
|
+
function normSig(el) {
|
|
1100
|
+
if (!el || el.nodeType !== 1) return '';
|
|
1101
|
+
var cls = Array.prototype.slice.call(el.classList)
|
|
1102
|
+
.filter(function (c) { return c.indexOf('__specter') !== 0; }).sort().join('.');
|
|
1103
|
+
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1104
|
+
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1105
|
+
}).filter(Boolean).join('|');
|
|
1106
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + el.childElementCount;
|
|
1107
|
+
}
|
|
1108
|
+
// djb2 xor → short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1109
|
+
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
1110
|
+
function fingerprint(el) {
|
|
1111
|
+
var s = normSig(el);
|
|
1112
|
+
if (!s) return '';
|
|
1113
|
+
var h = 5381, i = s.length;
|
|
1114
|
+
while (i) h = (h * 33) ^ s.charCodeAt(--i);
|
|
1115
|
+
return (h >>> 0).toString(36);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function queryAll(sel) { try { return sel ? Array.prototype.slice.call(document.querySelectorAll(sel)) : []; } catch (e) { return []; } }
|
|
1119
|
+
|
|
1120
|
+
// Every element whose OWN text matches (trunc = the stored anchor was cut to 40
|
|
1121
|
+
// chars). Returns ALL matches — the fingerprint picks among them in reFindShared,
|
|
1122
|
+
// so a repeated label (e.g. two "Read more") no longer forces a MISSING. Skips UI.
|
|
1123
|
+
function findAllByOwnText(val, trunc) {
|
|
1124
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], out = [];
|
|
1125
|
+
for (var i = 0; i < all.length; i++) {
|
|
1126
|
+
var el = all[i];
|
|
1127
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1128
|
+
var t = ownText(el);
|
|
1129
|
+
if (!t) continue;
|
|
1130
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) out.push(el);
|
|
1131
|
+
}
|
|
1132
|
+
return out;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// Candidate elements for a resolveLocator() anchor: #id/.class/[attr]/CSS-path via
|
|
1136
|
+
// querySelectorAll; 'attr "value"' rebuilds the attribute selector; 'text "value"'
|
|
1137
|
+
// scans own-text. Returns ALL matches (may be >1) — reFindShared narrows by
|
|
1138
|
+
// fingerprint rather than rejecting anything ambiguous up front.
|
|
1139
|
+
function resolveFindCandidates(find) {
|
|
1140
|
+
if (!find) return [];
|
|
1141
|
+
var c = find.charAt(0);
|
|
1142
|
+
if (c === '#' || c === '.' || c === '[') return queryAll(find);
|
|
1143
|
+
var q = find.indexOf(' "');
|
|
1144
|
+
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1145
|
+
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1146
|
+
if (val.charAt(val.length - 1) === '…') { trunc = true; val = val.slice(0, -1); }
|
|
1147
|
+
if (attr === 'text') return findAllByOwnText(val, trunc);
|
|
1148
|
+
if (val.indexOf('"') < 0) return queryAll('[' + attr + '="' + val + '"]');
|
|
1149
|
+
return [];
|
|
1150
|
+
}
|
|
1151
|
+
return queryAll(find); // a bare CSS path
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Re-find a shared comment's element. The fingerprint is the DISAMBIGUATOR, not
|
|
1155
|
+
// just a change-detector: gather every candidate from the find anchor AND the
|
|
1156
|
+
// nth-child path, then return the one whose signature matches — so a locator
|
|
1157
|
+
// shared by siblings (e.g. div:nth-child(1)) still resolves on the same page
|
|
1158
|
+
// instead of failing as ambiguous. With no fp match: hand back a lone candidate
|
|
1159
|
+
// (so importComments can say "changed"), else null ("couldn't find").
|
|
1160
|
+
// Whole-DOM scan for the one element whose structural signature matches — the
|
|
1161
|
+
// last-resort re-finder when both the find anchor and the nth-of-type path fail
|
|
1162
|
+
// (e.g. selectors that don't round-trip, or a shifted structure). >1 match → bail
|
|
1163
|
+
// (ambiguous, don't guess). Skips Specter's own UI.
|
|
1164
|
+
function findByFingerprint(fp) {
|
|
1165
|
+
if (!fp) return null;
|
|
1166
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1167
|
+
for (var i = 0; i < all.length; i++) {
|
|
1168
|
+
var el = all[i];
|
|
1169
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1170
|
+
if (fingerprint(el) === fp) { if (hit) return null; hit = el; }
|
|
1171
|
+
}
|
|
1172
|
+
return hit;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function reFindShared(item) {
|
|
1176
|
+
var cands = resolveFindCandidates(item.find).concat(queryAll(item.path));
|
|
1177
|
+
var uniq = [], i;
|
|
1178
|
+
for (i = 0; i < cands.length; i++) if (cands[i] && uniq.indexOf(cands[i]) < 0) uniq.push(cands[i]);
|
|
1179
|
+
if (item.fp) {
|
|
1180
|
+
for (i = 0; i < uniq.length; i++) if (fingerprint(uniq[i]) === item.fp) return uniq[i]; // fp picks the right candidate
|
|
1181
|
+
var scan = findByFingerprint(item.fp); // selectors failed/ambiguous → find it by signature
|
|
1182
|
+
if (scan) return scan;
|
|
1183
|
+
}
|
|
1184
|
+
return uniq.length === 1 ? uniq[0] : null; // last resort: a lone unambiguous candidate
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
1188
|
+
// Match by origin + pathname only — hash AND query are dropped (the hash is the
|
|
1189
|
+
// doSync fork bug; a trailing #route or ?ref shouldn't split the same page).
|
|
1190
|
+
function pageKey(href) {
|
|
1191
|
+
try { var u = new URL(href || location.href); return u.origin + u.pathname; }
|
|
1192
|
+
catch (e) { return location.origin + location.pathname; }
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function exportComments() {
|
|
1196
|
+
return {
|
|
1197
|
+
url: location.href, // gated on pageKey() at import; full href kept for reference
|
|
1198
|
+
comments: specs.map(function (s) {
|
|
1199
|
+
return { find: resolveLocator(s.el), path: s.path, fp: fingerprint(s.el), note: s.note || '', kind: s.kind || 'element' };
|
|
1200
|
+
}),
|
|
1201
|
+
};
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function importComments(payload) {
|
|
1205
|
+
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { return 0; } }
|
|
1206
|
+
var comments = null, srcUrl = '';
|
|
1207
|
+
if (Array.isArray(payload)) comments = payload; // legacy pre-URL blob: no gate
|
|
1208
|
+
else if (payload && Array.isArray(payload.comments)) { comments = payload.comments; srcUrl = payload.url || ''; }
|
|
1209
|
+
if (!comments) return 0;
|
|
1210
|
+
// Wrong page → decline rather than anchor onto whatever happens to match here.
|
|
1211
|
+
if (srcUrl && pageKey(srcUrl) !== pageKey()) {
|
|
1212
|
+
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' — not this page (' + pageKey() + '). Not imported.');
|
|
1213
|
+
return 0;
|
|
1214
|
+
}
|
|
1215
|
+
// A received share REPLACES the previously-imported set: re-opening the same
|
|
1216
|
+
// link (or a refreshed one) gives exactly that set, never a stacked-up pile of
|
|
1217
|
+
// duplicates across rounds. Your OWN local specs (shared:false) are untouched.
|
|
1218
|
+
removeSharedSpecs();
|
|
1219
|
+
var added = 0;
|
|
1220
|
+
comments.forEach(function (item) {
|
|
1221
|
+
if (!item) return;
|
|
1222
|
+
// reFindShared already used the fingerprint to pick/scan the right element, so
|
|
1223
|
+
// trust its result: if it located a node, PLACE the comment. Only a genuine
|
|
1224
|
+
// no-match is MISSING — we no longer hide a found element just because its
|
|
1225
|
+
// signature drifted (that over-fired on dynamic pages and buried real pins).
|
|
1226
|
+
var el = reFindShared(item), missing = false, reason = '';
|
|
1227
|
+
if (!el) { missing = true; reason = 'Couldn’t find this element on the page'; }
|
|
1228
|
+
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1229
|
+
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1230
|
+
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 };
|
|
1231
|
+
specs.push(spec);
|
|
1232
|
+
createBadge(spec);
|
|
1233
|
+
added++;
|
|
1234
|
+
});
|
|
1235
|
+
renumber();
|
|
1236
|
+
reflowSpecs();
|
|
1237
|
+
updatePill();
|
|
1238
|
+
if (panelOpen) renderPanel();
|
|
1239
|
+
saveSpecs();
|
|
1240
|
+
return added;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// ── Transport: share link (#spx=) with a file fallback ──────────────────────
|
|
1244
|
+
// The link is destination AND payload — pasted into Slack it looks like a normal
|
|
1245
|
+
// URL; clicked, it lands the recipient on the exact page, so the URL gate is
|
|
1246
|
+
// satisfied automatically. Payload = <fmt><base64url>: fmt 'z' = gzip (Compression
|
|
1247
|
+
// Stream, zero-dep), 'j' = plain UTF-8 when gzip is unavailable. split/join dodges
|
|
1248
|
+
// regex escaping inside this template-literal client.
|
|
1249
|
+
var LINK_MAX = 8000; // ~URL ceiling; beyond this we offer a .specter.json file
|
|
1250
|
+
|
|
1251
|
+
function bytesToB64url(bytes) {
|
|
1252
|
+
var bin = '';
|
|
1253
|
+
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
1254
|
+
var b = btoa(bin).split('+').join('-').split('/').join('_');
|
|
1255
|
+
while (b.charAt(b.length - 1) === '=') b = b.slice(0, -1);
|
|
1256
|
+
return b;
|
|
1257
|
+
}
|
|
1258
|
+
function b64urlToBytes(s) {
|
|
1259
|
+
s = s.split('-').join('+').split('_').join('/');
|
|
1260
|
+
while (s.length % 4) s += '=';
|
|
1261
|
+
var bin = atob(s), bytes = new Uint8Array(bin.length);
|
|
1262
|
+
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
1263
|
+
return bytes;
|
|
1264
|
+
}
|
|
1265
|
+
function gzipStr(str) {
|
|
1266
|
+
var s = new Blob([str]).stream().pipeThrough(new CompressionStream('gzip'));
|
|
1267
|
+
return new Response(s).arrayBuffer().then(function (buf) { return new Uint8Array(buf); });
|
|
1268
|
+
}
|
|
1269
|
+
function gunzipBytes(bytes) {
|
|
1270
|
+
var s = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
|
|
1271
|
+
return new Response(s).text();
|
|
1272
|
+
}
|
|
1273
|
+
function encodePayload(str) {
|
|
1274
|
+
if (typeof CompressionStream !== 'undefined') {
|
|
1275
|
+
return gzipStr(str).then(function (gz) { return 'z' + bytesToB64url(gz); })
|
|
1276
|
+
.catch(function () { return 'j' + bytesToB64url(new TextEncoder().encode(str)); });
|
|
1277
|
+
}
|
|
1278
|
+
return Promise.resolve('j' + bytesToB64url(new TextEncoder().encode(str)));
|
|
1279
|
+
}
|
|
1280
|
+
function decodePayload(s) {
|
|
1281
|
+
var fmt = s.charAt(0);
|
|
1282
|
+
if (fmt === 'z') return gunzipBytes(b64urlToBytes(s.slice(1)));
|
|
1283
|
+
if (fmt === 'j') return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s.slice(1))));
|
|
1284
|
+
// legacy (no fmt prefix): raw base64url of the JSON string
|
|
1285
|
+
return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s)));
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// Build on the current page (keep query, replace any existing hash).
|
|
1289
|
+
function buildShareLink() {
|
|
1290
|
+
return encodePayload(JSON.stringify(exportComments())).then(function (enc) {
|
|
1291
|
+
return location.origin + location.pathname + location.search + '#spx=' + enc;
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// A page that uses hash routing itself would collide with #spx= — treat any
|
|
1296
|
+
// non-trivial existing hash (beyond a bare '#') as in-use and prefer the file.
|
|
1297
|
+
function hashInUse() { return (location.hash || '').length > 1; }
|
|
1298
|
+
|
|
1299
|
+
// Download the comments as a .specter.json (the fallback when the link is too big
|
|
1300
|
+
// or the page owns the hash). Same {url, comments} payload → same URL gate on import.
|
|
1301
|
+
function downloadCommentsFile() {
|
|
1302
|
+
var data = JSON.stringify(exportComments(), null, 2);
|
|
1303
|
+
var url = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
|
|
1304
|
+
var a = document.createElement('a');
|
|
1305
|
+
a.href = url; a.download = 'comments.specter.json';
|
|
1306
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
1307
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// One coherent share action: a link by default, a file when it can't fit (or the
|
|
1311
|
+
// page owns the hash). Returns a descriptor so the caller/UI can tell the user which.
|
|
1312
|
+
function shareComments() {
|
|
1313
|
+
if (!specs.length) return Promise.resolve({ kind: 'empty' });
|
|
1314
|
+
if (hashInUse()) { downloadCommentsFile(); return Promise.resolve({ kind: 'file', reason: 'hash-routing' }); }
|
|
1315
|
+
return buildShareLink().then(function (link) {
|
|
1316
|
+
if (link.length > LINK_MAX) { downloadCommentsFile(); return { kind: 'file', reason: 'too-large', size: link.length }; }
|
|
1317
|
+
return { kind: 'link', link: link };
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Pick a .specter.json (or any JSON) and import it — the file-fallback receiver.
|
|
1322
|
+
function importCommentsFile() {
|
|
1323
|
+
var input = document.createElement('input');
|
|
1324
|
+
input.type = 'file';
|
|
1325
|
+
input.accept = '.json,application/json';
|
|
1326
|
+
input.addEventListener('change', function () {
|
|
1327
|
+
var f = input.files && input.files[0];
|
|
1328
|
+
if (!f) return;
|
|
1329
|
+
var reader = new FileReader();
|
|
1330
|
+
reader.onload = function () { var n = importComments(String(reader.result)); if (n > 0) { if (!fiActive) activate(); showPanel(); } };
|
|
1331
|
+
reader.readAsText(f);
|
|
1332
|
+
});
|
|
1333
|
+
input.click();
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// On load: if the URL carries #spx=, decode it, strip it from the address bar (so
|
|
1337
|
+
// the URL goes clean and a plain reload won't re-import), import, and surface it.
|
|
1338
|
+
function importFromHash() {
|
|
1339
|
+
var h = location.hash || '', k = h.indexOf('spx=');
|
|
1340
|
+
if (k < 0) return Promise.resolve(0);
|
|
1341
|
+
var enc = h.slice(k + 4);
|
|
1342
|
+
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
|
|
1343
|
+
return decodePayload(enc).then(function (json) {
|
|
1344
|
+
var n = importComments(json);
|
|
1345
|
+
if (n > 0) { if (!fiActive) activate(); showPanel(); }
|
|
1346
|
+
return n;
|
|
1347
|
+
}).catch(function () { return 0; });
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1014
1350
|
// Re-anchor if the node detached, then scroll it into view. Returns false when
|
|
1015
1351
|
// the element can't be shown (removed / hidden — e.g. behind a closed modal).
|
|
1016
1352
|
// The badge enlargement is handled separately via highlightSpec (sustained
|
|
@@ -1030,6 +1366,11 @@
|
|
|
1030
1366
|
return isVisible(spec.el);
|
|
1031
1367
|
}
|
|
1032
1368
|
|
|
1369
|
+
// Signature of on-page visibility across specs (from reflowSpecs' per-frame _liveVis),
|
|
1370
|
+
// used to detect when the panel's HIDDEN labels have gone stale and re-render.
|
|
1371
|
+
var panelVisSig = '', panelVisTimer = null;
|
|
1372
|
+
function liveVisSig() { var s = ''; for (var i = 0; i < specs.length; i++) s += specs[i]._liveVis ? '1' : '0'; return s; }
|
|
1373
|
+
|
|
1033
1374
|
// ─── Specs side panel (in-session review) ────────────────────────────────────
|
|
1034
1375
|
var panelWrap = markUI(document.createElement('div'));
|
|
1035
1376
|
Object.assign(panelWrap.style, {
|
|
@@ -1117,10 +1458,40 @@
|
|
|
1117
1458
|
var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
|
|
1118
1459
|
delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
|
|
1119
1460
|
|
|
1461
|
+
// Share comments (human↔human): copies a link, or downloads a file when it can't
|
|
1462
|
+
// fit / the page owns the hash. The hint line confirms which happened.
|
|
1463
|
+
var share = iconPill(SHARE, 'Share comments to another person', '#8AB4F8', '138,180,248');
|
|
1464
|
+
function resetShareIcon() { share.icon.innerHTML = SHARE; share.btn.style.color = share.btn.matches(':hover') ? '#fff' : '#8AB4F8'; }
|
|
1465
|
+
share.btn.addEventListener('click', function (e) {
|
|
1466
|
+
e.stopPropagation();
|
|
1467
|
+
if (!specs.length) return;
|
|
1468
|
+
shareComments().then(function (res) {
|
|
1469
|
+
if (res.kind === 'link') {
|
|
1470
|
+
navigator.clipboard.writeText(res.link).then(function () {
|
|
1471
|
+
share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
|
|
1472
|
+
flashHint('✓ Link copied — paste it to your reviewer. They see your comments on the same page.', GREEN);
|
|
1473
|
+
setTimeout(resetShareIcon, 1400);
|
|
1474
|
+
}).catch(function () { flashHint('Copy failed — check clipboard permission for this site.', '#ED8FA6'); });
|
|
1475
|
+
} else if (res.kind === 'file') {
|
|
1476
|
+
share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
|
|
1477
|
+
flashHint('✓ Saved comments.specter.json — send that file (too many comments to fit a link).', GREEN);
|
|
1478
|
+
setTimeout(resetShareIcon, 1400);
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
});
|
|
1482
|
+
|
|
1483
|
+
// Import a comments file someone sent (the file-fallback receiver).
|
|
1484
|
+
var importTool = iconPill(IMPORT, 'Import a comments file', '#8AB4F8', '138,180,248');
|
|
1485
|
+
importTool.btn.addEventListener('click', function (e) { e.stopPropagation(); importCommentsFile(); });
|
|
1486
|
+
|
|
1120
1487
|
var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
|
|
1121
1488
|
var deleteAllBtn = delAll.btn;
|
|
1489
|
+
var shareBtn = share.btn;
|
|
1490
|
+
var importToolBtn = importTool.btn;
|
|
1122
1491
|
if (BRIDGE) panelTools.appendChild(syncDot);
|
|
1123
1492
|
else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
|
|
1493
|
+
panelTools.appendChild(shareBtn);
|
|
1494
|
+
panelTools.appendChild(importToolBtn);
|
|
1124
1495
|
panelTools.appendChild(copyAllBtn);
|
|
1125
1496
|
panelTools.appendChild(deleteAllBtn);
|
|
1126
1497
|
|
|
@@ -1188,6 +1559,16 @@
|
|
|
1188
1559
|
panelHint.appendChild(document.createTextNode(' (or Copy all) and paste into Claude to apply these annotations. You can also edit or delete each one above.'));
|
|
1189
1560
|
}
|
|
1190
1561
|
}
|
|
1562
|
+
// Briefly show a confirmation in the hint line (Share copied / file saved), then
|
|
1563
|
+
// restore the normal guidance.
|
|
1564
|
+
var hintFlashTimer = null;
|
|
1565
|
+
function flashHint(msg, color) {
|
|
1566
|
+
panelHint.style.display = 'block';
|
|
1567
|
+
panelHint.textContent = msg;
|
|
1568
|
+
panelHint.style.color = color || GREEN;
|
|
1569
|
+
if (hintFlashTimer) clearTimeout(hintFlashTimer);
|
|
1570
|
+
hintFlashTimer = setTimeout(function () { panelHint.style.color = LABEL; setHint(); }, 2800);
|
|
1571
|
+
}
|
|
1191
1572
|
|
|
1192
1573
|
var panelList = document.createElement('div');
|
|
1193
1574
|
// overscroll-behavior:contain stops the panel's scroll from chaining into the page.
|
|
@@ -1222,7 +1603,7 @@
|
|
|
1222
1603
|
Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
|
|
1223
1604
|
rows.forEach(function (r) {
|
|
1224
1605
|
var row = document.createElement('div');
|
|
1225
|
-
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '
|
|
1606
|
+
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '8px' });
|
|
1226
1607
|
var k = document.createElement('span');
|
|
1227
1608
|
k.textContent = r[0];
|
|
1228
1609
|
Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
@@ -1247,7 +1628,7 @@
|
|
|
1247
1628
|
panelWrap.appendChild(panelHint);
|
|
1248
1629
|
panelWrap.appendChild(panelList);
|
|
1249
1630
|
panelWrap.appendChild(panelKeys);
|
|
1250
|
-
|
|
1631
|
+
mount(panelWrap);
|
|
1251
1632
|
|
|
1252
1633
|
// Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
|
|
1253
1634
|
// scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
|
|
@@ -1301,21 +1682,24 @@
|
|
|
1301
1682
|
|
|
1302
1683
|
function renderPanel() {
|
|
1303
1684
|
panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
|
|
1304
|
-
panelTools.style.display =
|
|
1685
|
+
panelTools.style.display = 'flex'; // always visible when the panel is open (Import needs no Specs)
|
|
1686
|
+
shareBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1305
1687
|
copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1306
1688
|
deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1689
|
+
importToolBtn.style.display = 'inline-flex'; // receiving a shared file needs no existing Specs
|
|
1307
1690
|
setHint();
|
|
1308
1691
|
panelList.textContent = '';
|
|
1309
1692
|
if (!specs.length) {
|
|
1310
1693
|
var empty = document.createElement('div');
|
|
1311
|
-
empty.textContent = 'No
|
|
1694
|
+
empty.textContent = 'No comments yet — hover an element and press P, or use the Import button above to open a comments file someone shared.';
|
|
1312
1695
|
Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
|
|
1313
1696
|
panelList.appendChild(empty);
|
|
1314
1697
|
return;
|
|
1315
1698
|
}
|
|
1316
1699
|
var focusEditor = null, measures = [];
|
|
1317
1700
|
specs.forEach(function (spec, i) {
|
|
1318
|
-
var
|
|
1701
|
+
var missing = !!spec.missing;
|
|
1702
|
+
var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec — it would re-anchor via path
|
|
1319
1703
|
var editing = (panelEditSpec === spec);
|
|
1320
1704
|
var row = document.createElement('div');
|
|
1321
1705
|
Object.assign(row.style, {
|
|
@@ -1429,9 +1813,25 @@
|
|
|
1429
1813
|
content.appendChild(note);
|
|
1430
1814
|
content.appendChild(meta);
|
|
1431
1815
|
|
|
1816
|
+
// Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
|
|
1817
|
+
// never drawn on the page — design: show missing, don't guess a spot).
|
|
1818
|
+
if (missing) {
|
|
1819
|
+
row.style.opacity = '0.7';
|
|
1820
|
+
var mbar = document.createElement('div');
|
|
1821
|
+
Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1822
|
+
var mtag = document.createElement('span');
|
|
1823
|
+
mtag.textContent = 'MISSING';
|
|
1824
|
+
Object.assign(mtag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#fff', background: '#8B4A57', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
|
|
1825
|
+
var mhint = document.createElement('span');
|
|
1826
|
+
mhint.textContent = spec.missReason || 'Not on this page';
|
|
1827
|
+
Object.assign(mhint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
|
|
1828
|
+
mbar.appendChild(mtag);
|
|
1829
|
+
mbar.appendChild(mhint);
|
|
1830
|
+
content.appendChild(mbar);
|
|
1831
|
+
}
|
|
1432
1832
|
// Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
|
|
1433
1833
|
// so hidden Specs are findable in a long list instead of silently unreachable.
|
|
1434
|
-
if (!visible) {
|
|
1834
|
+
else if (!visible) {
|
|
1435
1835
|
var hbar = document.createElement('div');
|
|
1436
1836
|
Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1437
1837
|
var tag = document.createElement('span');
|
|
@@ -1484,10 +1884,11 @@
|
|
|
1484
1884
|
}
|
|
1485
1885
|
});
|
|
1486
1886
|
if (focusEditor) setTimeout(focusEditor, 0);
|
|
1887
|
+
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1487
1888
|
}
|
|
1488
1889
|
|
|
1489
|
-
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
1490
|
-
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
|
|
1890
|
+
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; updateListBtn(); if (BRIDGE) doSync(); }
|
|
1891
|
+
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; updateListBtn(); }
|
|
1491
1892
|
function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
|
|
1492
1893
|
|
|
1493
1894
|
// ─── Spec editor (annotation box: add / edit / delete) ───────────────────────
|
|
@@ -2064,8 +2465,9 @@
|
|
|
2064
2465
|
});
|
|
2065
2466
|
}
|
|
2066
2467
|
|
|
2067
|
-
restoreSpecs();
|
|
2068
|
-
|
|
2468
|
+
restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
|
|
2469
|
+
importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
|
|
2470
|
+
scheduleSync(); // mirror restored Specs to the Claude bridge on load
|
|
2069
2471
|
|
|
2070
2472
|
console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
|
|
2071
2473
|
})();
|