vite-plugin-specter 0.6.1 → 0.7.0
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 +329 -13
- package/dist/extension-chrome/content.js +329 -13
- package/dist/extension-chrome/manifest.json +1 -1
- package/dist/extension-firefox/content.js +329 -13
- package/dist/extension-firefox/manifest.json +1 -1
- package/dist/index.cjs +329 -13
- package/dist/index.js +329 -13
- package/extension/content.js +329 -13
- 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
|
|
|
@@ -309,7 +311,7 @@
|
|
|
309
311
|
pill.style.maxWidth = '820px';
|
|
310
312
|
pillText.style.display = 'inline';
|
|
311
313
|
chevron.style.display = 'inline';
|
|
312
|
-
listBtn.style.display =
|
|
314
|
+
listBtn.style.display = 'inline-flex'; // always reachable — the panel is also where you Import a shared file
|
|
313
315
|
clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
|
|
314
316
|
pillExpanded = true;
|
|
315
317
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
@@ -794,19 +796,31 @@
|
|
|
794
796
|
var mx = lastMouse.x, my = lastMouse.y;
|
|
795
797
|
var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
|
|
796
798
|
|
|
797
|
-
// Pass 1 — resolve visibility + base anchor for each spec.
|
|
799
|
+
// Pass 1 — resolve visibility + base anchor for each spec. _liveVis tracks the
|
|
800
|
+
// panel's HIDDEN semantics (isVisible after re-anchor recovery, IGNORING occlusion —
|
|
801
|
+
// off-screen is fine, that's what the hover-scroll is for) so the panel can stay in
|
|
802
|
+
// sync with what's actually on the page instead of showing a stale HIDDEN.
|
|
798
803
|
var vis = [];
|
|
799
804
|
for (var i = 0; i < specs.length; i++) {
|
|
800
805
|
var s = specs[i];
|
|
801
|
-
if (!fiActive) { s.wrap.style.display = 'none'; continue; }
|
|
806
|
+
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
807
|
+
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
802
808
|
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; }
|
|
809
|
+
if (!isVisible(s.el)) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
810
|
+
s._liveVis = true;
|
|
804
811
|
var r = s.el.getBoundingClientRect();
|
|
805
|
-
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered
|
|
812
|
+
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered / off-screen: no badge, but panel not HIDDEN
|
|
806
813
|
s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
|
|
807
814
|
vis.push(s);
|
|
808
815
|
}
|
|
809
816
|
|
|
817
|
+
// Keep the open panel's HIDDEN labels live: if any spec's on-page visibility
|
|
818
|
+
// flipped since the panel last rendered, re-render it (debounced).
|
|
819
|
+
if (panelOpen) {
|
|
820
|
+
var g = liveVisSig();
|
|
821
|
+
if (g !== panelVisSig) { panelVisSig = g; clearTimeout(panelVisTimer); panelVisTimer = setTimeout(function () { if (panelOpen) renderPanel(); }, 150); }
|
|
822
|
+
}
|
|
823
|
+
|
|
810
824
|
// Pass 2 — cluster badges whose anchors coincide (within ~16px).
|
|
811
825
|
var clusters = [];
|
|
812
826
|
for (var a = 0; a < vis.length; a++) {
|
|
@@ -994,7 +1008,7 @@
|
|
|
994
1008
|
function saveSpecs() {
|
|
995
1009
|
try {
|
|
996
1010
|
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 }; })));
|
|
1011
|
+
else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason }; })));
|
|
998
1012
|
} catch (e) {}
|
|
999
1013
|
scheduleSync(); // keep the Claude bridge mirrored to the current Specs
|
|
1000
1014
|
}
|
|
@@ -1004,11 +1018,247 @@
|
|
|
1004
1018
|
try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
|
|
1005
1019
|
if (!Array.isArray(data) || !data.length) return;
|
|
1006
1020
|
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 || '' };
|
|
1021
|
+
var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '' };
|
|
1022
|
+
specs.push(spec);
|
|
1023
|
+
createBadge(spec);
|
|
1024
|
+
});
|
|
1025
|
+
renumber();
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
// ─── Share Comments (human↔human) — Steps 1-2 ────────────────────────────────
|
|
1029
|
+
// A shared comment is a LOCATOR, not a coordinate: on import we re-find the
|
|
1030
|
+
// element and place a badge, exactly like restoreSpecs. Comments only — we carry
|
|
1031
|
+
// the note + how to re-find the element, NEVER the props/measurements (body).
|
|
1032
|
+
// Step 2 adds change-detection: a fingerprint travels with each comment; if the
|
|
1033
|
+
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1034
|
+
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1035
|
+
|
|
1036
|
+
// Normalized element signature for change-detection: stable structural identity,
|
|
1037
|
+
// NOT raw outerHTML (which false-trips on any text/attr churn). tag + sorted own
|
|
1038
|
+
// classes + a few structural attrs + a short trimmed-text slice. The text slice is
|
|
1039
|
+
// the strictness knob — include it to catch content edits, at the cost of a
|
|
1040
|
+
// false-MISSING when dynamic text (a price/timestamp) changes under a stable node.
|
|
1041
|
+
function normSig(el) {
|
|
1042
|
+
if (!el || el.nodeType !== 1) return '';
|
|
1043
|
+
var cls = Array.prototype.slice.call(el.classList)
|
|
1044
|
+
.filter(function (c) { return c.indexOf('__specter') !== 0; }).sort().join('.');
|
|
1045
|
+
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1046
|
+
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1047
|
+
}).filter(Boolean).join('|');
|
|
1048
|
+
var txt = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 50);
|
|
1049
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
|
|
1050
|
+
}
|
|
1051
|
+
// djb2 xor → short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1052
|
+
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
1053
|
+
function fingerprint(el) {
|
|
1054
|
+
var s = normSig(el);
|
|
1055
|
+
if (!s) return '';
|
|
1056
|
+
var h = 5381, i = s.length;
|
|
1057
|
+
while (i) h = (h * 33) ^ s.charCodeAt(--i);
|
|
1058
|
+
return (h >>> 0).toString(36);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// Scan for an element whose OWN text matches (trunc = the stored anchor was cut to
|
|
1062
|
+
// 40 chars). Ambiguous (>1 match) → null, so we fall through to the nth-child path
|
|
1063
|
+
// rather than guess. Skips Specter's own UI.
|
|
1064
|
+
function findByOwnText(val, trunc) {
|
|
1065
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1066
|
+
for (var i = 0; i < all.length; i++) {
|
|
1067
|
+
var el = all[i];
|
|
1068
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1069
|
+
var t = ownText(el);
|
|
1070
|
+
if (!t) continue;
|
|
1071
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) { if (hit) return null; hit = el; }
|
|
1072
|
+
}
|
|
1073
|
+
return hit;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Real resolver for a resolveLocator() anchor: #id/.class/[attr] query straight;
|
|
1077
|
+
// 'attr "value"' rebuilds the attribute selector; 'text "value"' scans own-text.
|
|
1078
|
+
// Only trusts an anchor that resolves to EXACTLY ONE element — a class or CSS path
|
|
1079
|
+
// shared by siblings (e.g. three .card boxes) would otherwise silently match the
|
|
1080
|
+
// first one, mis-anchoring every comment onto it.
|
|
1081
|
+
function resolveFind(find) {
|
|
1082
|
+
if (!find) return null;
|
|
1083
|
+
var c = find.charAt(0);
|
|
1084
|
+
if (c === '#' || c === '.' || c === '[') return uniqueSel(find) ? safeQuery(find) : null;
|
|
1085
|
+
var q = find.indexOf(' "');
|
|
1086
|
+
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1087
|
+
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1088
|
+
if (val.charAt(val.length - 1) === '…') { trunc = true; val = val.slice(0, -1); }
|
|
1089
|
+
if (attr === 'text') return findByOwnText(val, trunc);
|
|
1090
|
+
if (val.indexOf('"') < 0) { var sel = '[' + attr + '="' + val + '"]'; return uniqueSel(sel) ? safeQuery(sel) : null; }
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
return uniqueSel(find) ? safeQuery(find) : null; // a bare CSS path — only if unambiguous
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
|
|
1097
|
+
// only to detect change): prefer whichever candidate — the find anchor or the
|
|
1098
|
+
// nth-child path — actually matches the stored signature. Falls back to a
|
|
1099
|
+
// best-effort element so importComments can still tell "changed" from "not found".
|
|
1100
|
+
function reFindShared(item) {
|
|
1101
|
+
var byFind = resolveFind(item.find);
|
|
1102
|
+
if (byFind && (!item.fp || fingerprint(byFind) === item.fp)) return byFind;
|
|
1103
|
+
var byPath = safeQuery(item.path);
|
|
1104
|
+
if (byPath && (!item.fp || fingerprint(byPath) === item.fp)) return byPath;
|
|
1105
|
+
return byFind || byPath || null;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
1109
|
+
// Match by origin + pathname only — hash AND query are dropped (the hash is the
|
|
1110
|
+
// doSync fork bug; a trailing #route or ?ref shouldn't split the same page).
|
|
1111
|
+
function pageKey(href) {
|
|
1112
|
+
try { var u = new URL(href || location.href); return u.origin + u.pathname; }
|
|
1113
|
+
catch (e) { return location.origin + location.pathname; }
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function exportComments() {
|
|
1117
|
+
return {
|
|
1118
|
+
url: location.href, // gated on pageKey() at import; full href kept for reference
|
|
1119
|
+
comments: specs.map(function (s) {
|
|
1120
|
+
return { find: resolveLocator(s.el), path: s.path, fp: fingerprint(s.el), note: s.note || '', kind: s.kind || 'element' };
|
|
1121
|
+
}),
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function importComments(payload) {
|
|
1126
|
+
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { return 0; } }
|
|
1127
|
+
var comments = null, srcUrl = '';
|
|
1128
|
+
if (Array.isArray(payload)) comments = payload; // legacy pre-URL blob: no gate
|
|
1129
|
+
else if (payload && Array.isArray(payload.comments)) { comments = payload.comments; srcUrl = payload.url || ''; }
|
|
1130
|
+
if (!comments) return 0;
|
|
1131
|
+
// Wrong page → decline rather than anchor onto whatever happens to match here.
|
|
1132
|
+
if (srcUrl && pageKey(srcUrl) !== pageKey()) {
|
|
1133
|
+
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' — not this page (' + pageKey() + '). Not imported.');
|
|
1134
|
+
return 0;
|
|
1135
|
+
}
|
|
1136
|
+
var added = 0;
|
|
1137
|
+
comments.forEach(function (item) {
|
|
1138
|
+
if (!item) return;
|
|
1139
|
+
var el = reFindShared(item), missing = false, reason = '';
|
|
1140
|
+
if (!el) { missing = true; reason = 'Couldn’t find this element on the page'; }
|
|
1141
|
+
else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed — can’t place the comment'; }
|
|
1142
|
+
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1143
|
+
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1144
|
+
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 };
|
|
1008
1145
|
specs.push(spec);
|
|
1009
1146
|
createBadge(spec);
|
|
1147
|
+
added++;
|
|
1010
1148
|
});
|
|
1011
1149
|
renumber();
|
|
1150
|
+
reflowSpecs();
|
|
1151
|
+
updatePill();
|
|
1152
|
+
if (panelOpen) renderPanel();
|
|
1153
|
+
saveSpecs();
|
|
1154
|
+
return added;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ── Transport: share link (#spx=) with a file fallback ──────────────────────
|
|
1158
|
+
// The link is destination AND payload — pasted into Slack it looks like a normal
|
|
1159
|
+
// URL; clicked, it lands the recipient on the exact page, so the URL gate is
|
|
1160
|
+
// satisfied automatically. Payload = <fmt><base64url>: fmt 'z' = gzip (Compression
|
|
1161
|
+
// Stream, zero-dep), 'j' = plain UTF-8 when gzip is unavailable. split/join dodges
|
|
1162
|
+
// regex escaping inside this template-literal client.
|
|
1163
|
+
var LINK_MAX = 8000; // ~URL ceiling; beyond this we offer a .specter.json file
|
|
1164
|
+
|
|
1165
|
+
function bytesToB64url(bytes) {
|
|
1166
|
+
var bin = '';
|
|
1167
|
+
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
1168
|
+
var b = btoa(bin).split('+').join('-').split('/').join('_');
|
|
1169
|
+
while (b.charAt(b.length - 1) === '=') b = b.slice(0, -1);
|
|
1170
|
+
return b;
|
|
1171
|
+
}
|
|
1172
|
+
function b64urlToBytes(s) {
|
|
1173
|
+
s = s.split('-').join('+').split('_').join('/');
|
|
1174
|
+
while (s.length % 4) s += '=';
|
|
1175
|
+
var bin = atob(s), bytes = new Uint8Array(bin.length);
|
|
1176
|
+
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
1177
|
+
return bytes;
|
|
1178
|
+
}
|
|
1179
|
+
function gzipStr(str) {
|
|
1180
|
+
var s = new Blob([str]).stream().pipeThrough(new CompressionStream('gzip'));
|
|
1181
|
+
return new Response(s).arrayBuffer().then(function (buf) { return new Uint8Array(buf); });
|
|
1182
|
+
}
|
|
1183
|
+
function gunzipBytes(bytes) {
|
|
1184
|
+
var s = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
|
|
1185
|
+
return new Response(s).text();
|
|
1186
|
+
}
|
|
1187
|
+
function encodePayload(str) {
|
|
1188
|
+
if (typeof CompressionStream !== 'undefined') {
|
|
1189
|
+
return gzipStr(str).then(function (gz) { return 'z' + bytesToB64url(gz); })
|
|
1190
|
+
.catch(function () { return 'j' + bytesToB64url(new TextEncoder().encode(str)); });
|
|
1191
|
+
}
|
|
1192
|
+
return Promise.resolve('j' + bytesToB64url(new TextEncoder().encode(str)));
|
|
1193
|
+
}
|
|
1194
|
+
function decodePayload(s) {
|
|
1195
|
+
var fmt = s.charAt(0);
|
|
1196
|
+
if (fmt === 'z') return gunzipBytes(b64urlToBytes(s.slice(1)));
|
|
1197
|
+
if (fmt === 'j') return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s.slice(1))));
|
|
1198
|
+
// legacy (no fmt prefix): raw base64url of the JSON string
|
|
1199
|
+
return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s)));
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// Build on the current page (keep query, replace any existing hash).
|
|
1203
|
+
function buildShareLink() {
|
|
1204
|
+
return encodePayload(JSON.stringify(exportComments())).then(function (enc) {
|
|
1205
|
+
return location.origin + location.pathname + location.search + '#spx=' + enc;
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// A page that uses hash routing itself would collide with #spx= — treat any
|
|
1210
|
+
// non-trivial existing hash (beyond a bare '#') as in-use and prefer the file.
|
|
1211
|
+
function hashInUse() { return (location.hash || '').length > 1; }
|
|
1212
|
+
|
|
1213
|
+
// Download the comments as a .specter.json (the fallback when the link is too big
|
|
1214
|
+
// or the page owns the hash). Same {url, comments} payload → same URL gate on import.
|
|
1215
|
+
function downloadCommentsFile() {
|
|
1216
|
+
var data = JSON.stringify(exportComments(), null, 2);
|
|
1217
|
+
var url = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
|
|
1218
|
+
var a = document.createElement('a');
|
|
1219
|
+
a.href = url; a.download = 'comments.specter.json';
|
|
1220
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
1221
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// One coherent share action: a link by default, a file when it can't fit (or the
|
|
1225
|
+
// page owns the hash). Returns a descriptor so the caller/UI can tell the user which.
|
|
1226
|
+
function shareComments() {
|
|
1227
|
+
if (!specs.length) return Promise.resolve({ kind: 'empty' });
|
|
1228
|
+
if (hashInUse()) { downloadCommentsFile(); return Promise.resolve({ kind: 'file', reason: 'hash-routing' }); }
|
|
1229
|
+
return buildShareLink().then(function (link) {
|
|
1230
|
+
if (link.length > LINK_MAX) { downloadCommentsFile(); return { kind: 'file', reason: 'too-large', size: link.length }; }
|
|
1231
|
+
return { kind: 'link', link: link };
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// Pick a .specter.json (or any JSON) and import it — the file-fallback receiver.
|
|
1236
|
+
function importCommentsFile() {
|
|
1237
|
+
var input = document.createElement('input');
|
|
1238
|
+
input.type = 'file';
|
|
1239
|
+
input.accept = '.json,application/json';
|
|
1240
|
+
input.addEventListener('change', function () {
|
|
1241
|
+
var f = input.files && input.files[0];
|
|
1242
|
+
if (!f) return;
|
|
1243
|
+
var reader = new FileReader();
|
|
1244
|
+
reader.onload = function () { var n = importComments(String(reader.result)); if (n > 0) { if (!fiActive) activate(); showPanel(); } };
|
|
1245
|
+
reader.readAsText(f);
|
|
1246
|
+
});
|
|
1247
|
+
input.click();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// On load: if the URL carries #spx=, decode it, strip it from the address bar (so
|
|
1251
|
+
// the URL goes clean and a plain reload won't re-import), import, and surface it.
|
|
1252
|
+
function importFromHash() {
|
|
1253
|
+
var h = location.hash || '', k = h.indexOf('spx=');
|
|
1254
|
+
if (k < 0) return Promise.resolve(0);
|
|
1255
|
+
var enc = h.slice(k + 4);
|
|
1256
|
+
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
|
|
1257
|
+
return decodePayload(enc).then(function (json) {
|
|
1258
|
+
var n = importComments(json);
|
|
1259
|
+
if (n > 0) { if (!fiActive) activate(); showPanel(); }
|
|
1260
|
+
return n;
|
|
1261
|
+
}).catch(function () { return 0; });
|
|
1012
1262
|
}
|
|
1013
1263
|
|
|
1014
1264
|
// Re-anchor if the node detached, then scroll it into view. Returns false when
|
|
@@ -1030,6 +1280,11 @@
|
|
|
1030
1280
|
return isVisible(spec.el);
|
|
1031
1281
|
}
|
|
1032
1282
|
|
|
1283
|
+
// Signature of on-page visibility across specs (from reflowSpecs' per-frame _liveVis),
|
|
1284
|
+
// used to detect when the panel's HIDDEN labels have gone stale and re-render.
|
|
1285
|
+
var panelVisSig = '', panelVisTimer = null;
|
|
1286
|
+
function liveVisSig() { var s = ''; for (var i = 0; i < specs.length; i++) s += specs[i]._liveVis ? '1' : '0'; return s; }
|
|
1287
|
+
|
|
1033
1288
|
// ─── Specs side panel (in-session review) ────────────────────────────────────
|
|
1034
1289
|
var panelWrap = markUI(document.createElement('div'));
|
|
1035
1290
|
Object.assign(panelWrap.style, {
|
|
@@ -1117,10 +1372,40 @@
|
|
|
1117
1372
|
var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
|
|
1118
1373
|
delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
|
|
1119
1374
|
|
|
1375
|
+
// Share comments (human↔human): copies a link, or downloads a file when it can't
|
|
1376
|
+
// fit / the page owns the hash. The hint line confirms which happened.
|
|
1377
|
+
var share = iconPill(SHARE, 'Share comments to another person', '#8AB4F8', '138,180,248');
|
|
1378
|
+
function resetShareIcon() { share.icon.innerHTML = SHARE; share.btn.style.color = share.btn.matches(':hover') ? '#fff' : '#8AB4F8'; }
|
|
1379
|
+
share.btn.addEventListener('click', function (e) {
|
|
1380
|
+
e.stopPropagation();
|
|
1381
|
+
if (!specs.length) return;
|
|
1382
|
+
shareComments().then(function (res) {
|
|
1383
|
+
if (res.kind === 'link') {
|
|
1384
|
+
navigator.clipboard.writeText(res.link).then(function () {
|
|
1385
|
+
share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
|
|
1386
|
+
flashHint('✓ Link copied — paste it to your reviewer. They see your comments on the same page.', GREEN);
|
|
1387
|
+
setTimeout(resetShareIcon, 1400);
|
|
1388
|
+
}).catch(function () { flashHint('Copy failed — check clipboard permission for this site.', '#ED8FA6'); });
|
|
1389
|
+
} else if (res.kind === 'file') {
|
|
1390
|
+
share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
|
|
1391
|
+
flashHint('✓ Saved comments.specter.json — send that file (too many comments to fit a link).', GREEN);
|
|
1392
|
+
setTimeout(resetShareIcon, 1400);
|
|
1393
|
+
}
|
|
1394
|
+
});
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
// Import a comments file someone sent (the file-fallback receiver).
|
|
1398
|
+
var importTool = iconPill(IMPORT, 'Import a comments file', '#8AB4F8', '138,180,248');
|
|
1399
|
+
importTool.btn.addEventListener('click', function (e) { e.stopPropagation(); importCommentsFile(); });
|
|
1400
|
+
|
|
1120
1401
|
var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
|
|
1121
1402
|
var deleteAllBtn = delAll.btn;
|
|
1403
|
+
var shareBtn = share.btn;
|
|
1404
|
+
var importToolBtn = importTool.btn;
|
|
1122
1405
|
if (BRIDGE) panelTools.appendChild(syncDot);
|
|
1123
1406
|
else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
|
|
1407
|
+
panelTools.appendChild(shareBtn);
|
|
1408
|
+
panelTools.appendChild(importToolBtn);
|
|
1124
1409
|
panelTools.appendChild(copyAllBtn);
|
|
1125
1410
|
panelTools.appendChild(deleteAllBtn);
|
|
1126
1411
|
|
|
@@ -1188,6 +1473,16 @@
|
|
|
1188
1473
|
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
1474
|
}
|
|
1190
1475
|
}
|
|
1476
|
+
// Briefly show a confirmation in the hint line (Share copied / file saved), then
|
|
1477
|
+
// restore the normal guidance.
|
|
1478
|
+
var hintFlashTimer = null;
|
|
1479
|
+
function flashHint(msg, color) {
|
|
1480
|
+
panelHint.style.display = 'block';
|
|
1481
|
+
panelHint.textContent = msg;
|
|
1482
|
+
panelHint.style.color = color || GREEN;
|
|
1483
|
+
if (hintFlashTimer) clearTimeout(hintFlashTimer);
|
|
1484
|
+
hintFlashTimer = setTimeout(function () { panelHint.style.color = LABEL; setHint(); }, 2800);
|
|
1485
|
+
}
|
|
1191
1486
|
|
|
1192
1487
|
var panelList = document.createElement('div');
|
|
1193
1488
|
// overscroll-behavior:contain stops the panel's scroll from chaining into the page.
|
|
@@ -1301,21 +1596,24 @@
|
|
|
1301
1596
|
|
|
1302
1597
|
function renderPanel() {
|
|
1303
1598
|
panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
|
|
1304
|
-
panelTools.style.display =
|
|
1599
|
+
panelTools.style.display = 'flex'; // always visible when the panel is open (Import needs no Specs)
|
|
1600
|
+
shareBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1305
1601
|
copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1306
1602
|
deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1603
|
+
importToolBtn.style.display = 'inline-flex'; // receiving a shared file needs no existing Specs
|
|
1307
1604
|
setHint();
|
|
1308
1605
|
panelList.textContent = '';
|
|
1309
1606
|
if (!specs.length) {
|
|
1310
1607
|
var empty = document.createElement('div');
|
|
1311
|
-
empty.textContent = 'No
|
|
1608
|
+
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
1609
|
Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
|
|
1313
1610
|
panelList.appendChild(empty);
|
|
1314
1611
|
return;
|
|
1315
1612
|
}
|
|
1316
1613
|
var focusEditor = null, measures = [];
|
|
1317
1614
|
specs.forEach(function (spec, i) {
|
|
1318
|
-
var
|
|
1615
|
+
var missing = !!spec.missing;
|
|
1616
|
+
var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec — it would re-anchor via path
|
|
1319
1617
|
var editing = (panelEditSpec === spec);
|
|
1320
1618
|
var row = document.createElement('div');
|
|
1321
1619
|
Object.assign(row.style, {
|
|
@@ -1429,9 +1727,25 @@
|
|
|
1429
1727
|
content.appendChild(note);
|
|
1430
1728
|
content.appendChild(meta);
|
|
1431
1729
|
|
|
1730
|
+
// Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
|
|
1731
|
+
// never drawn on the page — design: show missing, don't guess a spot).
|
|
1732
|
+
if (missing) {
|
|
1733
|
+
row.style.opacity = '0.7';
|
|
1734
|
+
var mbar = document.createElement('div');
|
|
1735
|
+
Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1736
|
+
var mtag = document.createElement('span');
|
|
1737
|
+
mtag.textContent = 'MISSING';
|
|
1738
|
+
Object.assign(mtag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#fff', background: '#8B4A57', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
|
|
1739
|
+
var mhint = document.createElement('span');
|
|
1740
|
+
mhint.textContent = spec.missReason || 'Not on this page';
|
|
1741
|
+
Object.assign(mhint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
|
|
1742
|
+
mbar.appendChild(mtag);
|
|
1743
|
+
mbar.appendChild(mhint);
|
|
1744
|
+
content.appendChild(mbar);
|
|
1745
|
+
}
|
|
1432
1746
|
// Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
|
|
1433
1747
|
// so hidden Specs are findable in a long list instead of silently unreachable.
|
|
1434
|
-
if (!visible) {
|
|
1748
|
+
else if (!visible) {
|
|
1435
1749
|
var hbar = document.createElement('div');
|
|
1436
1750
|
Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1437
1751
|
var tag = document.createElement('span');
|
|
@@ -1484,6 +1798,7 @@
|
|
|
1484
1798
|
}
|
|
1485
1799
|
});
|
|
1486
1800
|
if (focusEditor) setTimeout(focusEditor, 0);
|
|
1801
|
+
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1487
1802
|
}
|
|
1488
1803
|
|
|
1489
1804
|
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
@@ -2064,8 +2379,9 @@
|
|
|
2064
2379
|
});
|
|
2065
2380
|
}
|
|
2066
2381
|
|
|
2067
|
-
restoreSpecs();
|
|
2068
|
-
|
|
2382
|
+
restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
|
|
2383
|
+
importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
|
|
2384
|
+
scheduleSync(); // mirror restored Specs to the Claude bridge on load
|
|
2069
2385
|
|
|
2070
2386
|
console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
|
|
2071
2387
|
})();
|