vite-plugin-specter 0.6.0 → 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 +77 -41
- package/dist/client.js +335 -15
- package/dist/extension-chrome/content.js +335 -15
- package/dist/extension-chrome/manifest.json +1 -1
- package/dist/extension-firefox/content.js +335 -15
- package/dist/extension-firefox/manifest.json +1 -1
- package/dist/index.cjs +335 -15
- package/dist/index.js +335 -15
- package/extension/content.js +335 -15
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -46,6 +46,7 @@ function getClientScript(options) {
|
|
|
46
46
|
var TIP_BG = '#292B32';
|
|
47
47
|
var GREEN = '#22C55E';
|
|
48
48
|
var LABEL = '#8B8D94';
|
|
49
|
+
var BADGE_IDLE = '#6B6D75'; // panel row-number badges sit greyscale; the hovered row's badge colors to PURPLE
|
|
49
50
|
var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
|
|
50
51
|
var ZAP = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>';
|
|
51
52
|
var PENCIL = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"></path><path d="m15 5 4 4"></path></svg>';
|
|
@@ -54,6 +55,8 @@ function getClientScript(options) {
|
|
|
54
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>';
|
|
55
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>';
|
|
56
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>';
|
|
57
60
|
var ACTIVATE = ${JSON.stringify(activateShortcut)};
|
|
58
61
|
var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
|
|
59
62
|
|
|
@@ -340,7 +343,7 @@ function getClientScript(options) {
|
|
|
340
343
|
pill.style.maxWidth = '820px';
|
|
341
344
|
pillText.style.display = 'inline';
|
|
342
345
|
chevron.style.display = 'inline';
|
|
343
|
-
listBtn.style.display =
|
|
346
|
+
listBtn.style.display = 'inline-flex'; // always reachable \u2014 the panel is also where you Import a shared file
|
|
344
347
|
clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
|
|
345
348
|
pillExpanded = true;
|
|
346
349
|
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
@@ -825,19 +828,31 @@ function getClientScript(options) {
|
|
|
825
828
|
var mx = lastMouse.x, my = lastMouse.y;
|
|
826
829
|
var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
|
|
827
830
|
|
|
828
|
-
// Pass 1 \u2014 resolve visibility + base anchor for each spec.
|
|
831
|
+
// Pass 1 \u2014 resolve visibility + base anchor for each spec. _liveVis tracks the
|
|
832
|
+
// panel's HIDDEN semantics (isVisible after re-anchor recovery, IGNORING occlusion \u2014
|
|
833
|
+
// off-screen is fine, that's what the hover-scroll is for) so the panel can stay in
|
|
834
|
+
// sync with what's actually on the page instead of showing a stale HIDDEN.
|
|
829
835
|
var vis = [];
|
|
830
836
|
for (var i = 0; i < specs.length; i++) {
|
|
831
837
|
var s = specs[i];
|
|
832
|
-
if (!fiActive) { s.wrap.style.display = 'none'; continue; }
|
|
838
|
+
if (!fiActive) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
839
|
+
if (s.missing) { s.wrap.style.display = 'none'; s._liveVis = false; continue; } // shared comment whose target is gone/changed
|
|
833
840
|
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
834
|
-
if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
|
|
841
|
+
if (!isVisible(s.el)) { s.wrap.style.display = 'none'; s._liveVis = false; continue; }
|
|
842
|
+
s._liveVis = true;
|
|
835
843
|
var r = s.el.getBoundingClientRect();
|
|
836
|
-
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered
|
|
844
|
+
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered / off-screen: no badge, but panel not HIDDEN
|
|
837
845
|
s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
|
|
838
846
|
vis.push(s);
|
|
839
847
|
}
|
|
840
848
|
|
|
849
|
+
// Keep the open panel's HIDDEN labels live: if any spec's on-page visibility
|
|
850
|
+
// flipped since the panel last rendered, re-render it (debounced).
|
|
851
|
+
if (panelOpen) {
|
|
852
|
+
var g = liveVisSig();
|
|
853
|
+
if (g !== panelVisSig) { panelVisSig = g; clearTimeout(panelVisTimer); panelVisTimer = setTimeout(function () { if (panelOpen) renderPanel(); }, 150); }
|
|
854
|
+
}
|
|
855
|
+
|
|
841
856
|
// Pass 2 \u2014 cluster badges whose anchors coincide (within ~16px).
|
|
842
857
|
var clusters = [];
|
|
843
858
|
for (var a = 0; a < vis.length; a++) {
|
|
@@ -1025,7 +1040,7 @@ function getClientScript(options) {
|
|
|
1025
1040
|
function saveSpecs() {
|
|
1026
1041
|
try {
|
|
1027
1042
|
if (!specs.length) localStorage.removeItem(STORAGE_KEY);
|
|
1028
|
-
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 }; })));
|
|
1043
|
+
else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate, shared: s.shared, fp: s.fp, missing: s.missing, missReason: s.missReason }; })));
|
|
1029
1044
|
} catch (e) {}
|
|
1030
1045
|
scheduleSync(); // keep the Claude bridge mirrored to the current Specs
|
|
1031
1046
|
}
|
|
@@ -1035,11 +1050,247 @@ function getClientScript(options) {
|
|
|
1035
1050
|
try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
|
|
1036
1051
|
if (!Array.isArray(data) || !data.length) return;
|
|
1037
1052
|
data.forEach(function (d) {
|
|
1038
|
-
var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
|
|
1053
|
+
var spec = { el: d.missing ? null : safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '', shared: !!d.shared, fp: d.fp || '', missing: !!d.missing, missReason: d.missReason || '' };
|
|
1054
|
+
specs.push(spec);
|
|
1055
|
+
createBadge(spec);
|
|
1056
|
+
});
|
|
1057
|
+
renumber();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// \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
|
|
1061
|
+
// A shared comment is a LOCATOR, not a coordinate: on import we re-find the
|
|
1062
|
+
// element and place a badge, exactly like restoreSpecs. Comments only \u2014 we carry
|
|
1063
|
+
// the note + how to re-find the element, NEVER the props/measurements (body).
|
|
1064
|
+
// Step 2 adds change-detection: a fingerprint travels with each comment; if the
|
|
1065
|
+
// element is gone OR its signature changed, the comment shows as MISSING (panel
|
|
1066
|
+
// only, greyed, with a reason) instead of being drawn on a guessed spot.
|
|
1067
|
+
|
|
1068
|
+
// Normalized element signature for change-detection: stable structural identity,
|
|
1069
|
+
// NOT raw outerHTML (which false-trips on any text/attr churn). tag + sorted own
|
|
1070
|
+
// classes + a few structural attrs + a short trimmed-text slice. The text slice is
|
|
1071
|
+
// the strictness knob \u2014 include it to catch content edits, at the cost of a
|
|
1072
|
+
// false-MISSING when dynamic text (a price/timestamp) changes under a stable node.
|
|
1073
|
+
function normSig(el) {
|
|
1074
|
+
if (!el || el.nodeType !== 1) return '';
|
|
1075
|
+
var cls = Array.prototype.slice.call(el.classList)
|
|
1076
|
+
.filter(function (c) { return c.indexOf('__specter') !== 0; }).sort().join('.');
|
|
1077
|
+
var attrs = ['type', 'role', 'name', 'href', 'aria-label'].map(function (a) {
|
|
1078
|
+
var v = el.getAttribute(a); return v ? a + '=' + v.trim() : '';
|
|
1079
|
+
}).filter(Boolean).join('|');
|
|
1080
|
+
var txt = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
|
|
1081
|
+
return el.tagName.toLowerCase() + '#' + cls + '#' + attrs + '#' + txt;
|
|
1082
|
+
}
|
|
1083
|
+
// djb2 xor \u2192 short base36 hash. Zero-dep; collisions don't matter (a match just
|
|
1084
|
+
// means "unchanged enough", and the re-find already narrowed us to one element).
|
|
1085
|
+
function fingerprint(el) {
|
|
1086
|
+
var s = normSig(el);
|
|
1087
|
+
if (!s) return '';
|
|
1088
|
+
var h = 5381, i = s.length;
|
|
1089
|
+
while (i) h = (h * 33) ^ s.charCodeAt(--i);
|
|
1090
|
+
return (h >>> 0).toString(36);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Scan for an element whose OWN text matches (trunc = the stored anchor was cut to
|
|
1094
|
+
// 40 chars). Ambiguous (>1 match) \u2192 null, so we fall through to the nth-child path
|
|
1095
|
+
// rather than guess. Skips Specter's own UI.
|
|
1096
|
+
function findByOwnText(val, trunc) {
|
|
1097
|
+
var all = document.body ? document.body.getElementsByTagName('*') : [], hit = null;
|
|
1098
|
+
for (var i = 0; i < all.length; i++) {
|
|
1099
|
+
var el = all[i];
|
|
1100
|
+
if (el.closest && el.closest('[data-specter-ui]')) continue;
|
|
1101
|
+
var t = ownText(el);
|
|
1102
|
+
if (!t) continue;
|
|
1103
|
+
if (trunc ? (t.slice(0, 40) === val) : (t === val)) { if (hit) return null; hit = el; }
|
|
1104
|
+
}
|
|
1105
|
+
return hit;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Real resolver for a resolveLocator() anchor: #id/.class/[attr] query straight;
|
|
1109
|
+
// 'attr "value"' rebuilds the attribute selector; 'text "value"' scans own-text.
|
|
1110
|
+
// Only trusts an anchor that resolves to EXACTLY ONE element \u2014 a class or CSS path
|
|
1111
|
+
// shared by siblings (e.g. three .card boxes) would otherwise silently match the
|
|
1112
|
+
// first one, mis-anchoring every comment onto it.
|
|
1113
|
+
function resolveFind(find) {
|
|
1114
|
+
if (!find) return null;
|
|
1115
|
+
var c = find.charAt(0);
|
|
1116
|
+
if (c === '#' || c === '.' || c === '[') return uniqueSel(find) ? safeQuery(find) : null;
|
|
1117
|
+
var q = find.indexOf(' "');
|
|
1118
|
+
if (q > 0 && find.charAt(find.length - 1) === '"') {
|
|
1119
|
+
var attr = find.slice(0, q), val = find.slice(q + 2, -1), trunc = false;
|
|
1120
|
+
if (val.charAt(val.length - 1) === '\u2026') { trunc = true; val = val.slice(0, -1); }
|
|
1121
|
+
if (attr === 'text') return findByOwnText(val, trunc);
|
|
1122
|
+
if (val.indexOf('"') < 0) { var sel = '[' + attr + '="' + val + '"]'; return uniqueSel(sel) ? safeQuery(sel) : null; }
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
return uniqueSel(find) ? safeQuery(find) : null; // a bare CSS path \u2014 only if unambiguous
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Re-find a shared comment's element, using the fingerprint to DISAMBIGUATE (not
|
|
1129
|
+
// only to detect change): prefer whichever candidate \u2014 the find anchor or the
|
|
1130
|
+
// nth-child path \u2014 actually matches the stored signature. Falls back to a
|
|
1131
|
+
// best-effort element so importComments can still tell "changed" from "not found".
|
|
1132
|
+
function reFindShared(item) {
|
|
1133
|
+
var byFind = resolveFind(item.find);
|
|
1134
|
+
if (byFind && (!item.fp || fingerprint(byFind) === item.fp)) return byFind;
|
|
1135
|
+
var byPath = safeQuery(item.path);
|
|
1136
|
+
if (byPath && (!item.fp || fingerprint(byPath) === item.fp)) return byPath;
|
|
1137
|
+
return byFind || byPath || null;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// URL gate: two people must be on the SAME page for a shared comment to place.
|
|
1141
|
+
// Match by origin + pathname only \u2014 hash AND query are dropped (the hash is the
|
|
1142
|
+
// doSync fork bug; a trailing #route or ?ref shouldn't split the same page).
|
|
1143
|
+
function pageKey(href) {
|
|
1144
|
+
try { var u = new URL(href || location.href); return u.origin + u.pathname; }
|
|
1145
|
+
catch (e) { return location.origin + location.pathname; }
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function exportComments() {
|
|
1149
|
+
return {
|
|
1150
|
+
url: location.href, // gated on pageKey() at import; full href kept for reference
|
|
1151
|
+
comments: specs.map(function (s) {
|
|
1152
|
+
return { find: resolveLocator(s.el), path: s.path, fp: fingerprint(s.el), note: s.note || '', kind: s.kind || 'element' };
|
|
1153
|
+
}),
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function importComments(payload) {
|
|
1158
|
+
if (typeof payload === 'string') { try { payload = JSON.parse(payload); } catch (e) { return 0; } }
|
|
1159
|
+
var comments = null, srcUrl = '';
|
|
1160
|
+
if (Array.isArray(payload)) comments = payload; // legacy pre-URL blob: no gate
|
|
1161
|
+
else if (payload && Array.isArray(payload.comments)) { comments = payload.comments; srcUrl = payload.url || ''; }
|
|
1162
|
+
if (!comments) return 0;
|
|
1163
|
+
// Wrong page \u2192 decline rather than anchor onto whatever happens to match here.
|
|
1164
|
+
if (srcUrl && pageKey(srcUrl) !== pageKey()) {
|
|
1165
|
+
console.warn('[Specter] These comments are for ' + pageKey(srcUrl) + ' \u2014 not this page (' + pageKey() + '). Not imported.');
|
|
1166
|
+
return 0;
|
|
1167
|
+
}
|
|
1168
|
+
var added = 0;
|
|
1169
|
+
comments.forEach(function (item) {
|
|
1170
|
+
if (!item) return;
|
|
1171
|
+
var el = reFindShared(item), missing = false, reason = '';
|
|
1172
|
+
if (!el) { missing = true; reason = 'Couldn\u2019t find this element on the page'; }
|
|
1173
|
+
else if (item.fp && fingerprint(el) !== item.fp) { missing = true; reason = 'This element changed \u2014 can\u2019t place the comment'; }
|
|
1174
|
+
// body stays empty on purpose: comments-only, never the shared element's props.
|
|
1175
|
+
// A MISSING spec keeps el=null so it's never drawn or re-anchored via its path.
|
|
1176
|
+
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 };
|
|
1039
1177
|
specs.push(spec);
|
|
1040
1178
|
createBadge(spec);
|
|
1179
|
+
added++;
|
|
1041
1180
|
});
|
|
1042
1181
|
renumber();
|
|
1182
|
+
reflowSpecs();
|
|
1183
|
+
updatePill();
|
|
1184
|
+
if (panelOpen) renderPanel();
|
|
1185
|
+
saveSpecs();
|
|
1186
|
+
return added;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// \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
|
|
1190
|
+
// The link is destination AND payload \u2014 pasted into Slack it looks like a normal
|
|
1191
|
+
// URL; clicked, it lands the recipient on the exact page, so the URL gate is
|
|
1192
|
+
// satisfied automatically. Payload = <fmt><base64url>: fmt 'z' = gzip (Compression
|
|
1193
|
+
// Stream, zero-dep), 'j' = plain UTF-8 when gzip is unavailable. split/join dodges
|
|
1194
|
+
// regex escaping inside this template-literal client.
|
|
1195
|
+
var LINK_MAX = 8000; // ~URL ceiling; beyond this we offer a .specter.json file
|
|
1196
|
+
|
|
1197
|
+
function bytesToB64url(bytes) {
|
|
1198
|
+
var bin = '';
|
|
1199
|
+
for (var i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
1200
|
+
var b = btoa(bin).split('+').join('-').split('/').join('_');
|
|
1201
|
+
while (b.charAt(b.length - 1) === '=') b = b.slice(0, -1);
|
|
1202
|
+
return b;
|
|
1203
|
+
}
|
|
1204
|
+
function b64urlToBytes(s) {
|
|
1205
|
+
s = s.split('-').join('+').split('_').join('/');
|
|
1206
|
+
while (s.length % 4) s += '=';
|
|
1207
|
+
var bin = atob(s), bytes = new Uint8Array(bin.length);
|
|
1208
|
+
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
1209
|
+
return bytes;
|
|
1210
|
+
}
|
|
1211
|
+
function gzipStr(str) {
|
|
1212
|
+
var s = new Blob([str]).stream().pipeThrough(new CompressionStream('gzip'));
|
|
1213
|
+
return new Response(s).arrayBuffer().then(function (buf) { return new Uint8Array(buf); });
|
|
1214
|
+
}
|
|
1215
|
+
function gunzipBytes(bytes) {
|
|
1216
|
+
var s = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
|
|
1217
|
+
return new Response(s).text();
|
|
1218
|
+
}
|
|
1219
|
+
function encodePayload(str) {
|
|
1220
|
+
if (typeof CompressionStream !== 'undefined') {
|
|
1221
|
+
return gzipStr(str).then(function (gz) { return 'z' + bytesToB64url(gz); })
|
|
1222
|
+
.catch(function () { return 'j' + bytesToB64url(new TextEncoder().encode(str)); });
|
|
1223
|
+
}
|
|
1224
|
+
return Promise.resolve('j' + bytesToB64url(new TextEncoder().encode(str)));
|
|
1225
|
+
}
|
|
1226
|
+
function decodePayload(s) {
|
|
1227
|
+
var fmt = s.charAt(0);
|
|
1228
|
+
if (fmt === 'z') return gunzipBytes(b64urlToBytes(s.slice(1)));
|
|
1229
|
+
if (fmt === 'j') return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s.slice(1))));
|
|
1230
|
+
// legacy (no fmt prefix): raw base64url of the JSON string
|
|
1231
|
+
return Promise.resolve(new TextDecoder().decode(b64urlToBytes(s)));
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// Build on the current page (keep query, replace any existing hash).
|
|
1235
|
+
function buildShareLink() {
|
|
1236
|
+
return encodePayload(JSON.stringify(exportComments())).then(function (enc) {
|
|
1237
|
+
return location.origin + location.pathname + location.search + '#spx=' + enc;
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// A page that uses hash routing itself would collide with #spx= \u2014 treat any
|
|
1242
|
+
// non-trivial existing hash (beyond a bare '#') as in-use and prefer the file.
|
|
1243
|
+
function hashInUse() { return (location.hash || '').length > 1; }
|
|
1244
|
+
|
|
1245
|
+
// Download the comments as a .specter.json (the fallback when the link is too big
|
|
1246
|
+
// or the page owns the hash). Same {url, comments} payload \u2192 same URL gate on import.
|
|
1247
|
+
function downloadCommentsFile() {
|
|
1248
|
+
var data = JSON.stringify(exportComments(), null, 2);
|
|
1249
|
+
var url = URL.createObjectURL(new Blob([data], { type: 'application/json' }));
|
|
1250
|
+
var a = document.createElement('a');
|
|
1251
|
+
a.href = url; a.download = 'comments.specter.json';
|
|
1252
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
1253
|
+
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// One coherent share action: a link by default, a file when it can't fit (or the
|
|
1257
|
+
// page owns the hash). Returns a descriptor so the caller/UI can tell the user which.
|
|
1258
|
+
function shareComments() {
|
|
1259
|
+
if (!specs.length) return Promise.resolve({ kind: 'empty' });
|
|
1260
|
+
if (hashInUse()) { downloadCommentsFile(); return Promise.resolve({ kind: 'file', reason: 'hash-routing' }); }
|
|
1261
|
+
return buildShareLink().then(function (link) {
|
|
1262
|
+
if (link.length > LINK_MAX) { downloadCommentsFile(); return { kind: 'file', reason: 'too-large', size: link.length }; }
|
|
1263
|
+
return { kind: 'link', link: link };
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Pick a .specter.json (or any JSON) and import it \u2014 the file-fallback receiver.
|
|
1268
|
+
function importCommentsFile() {
|
|
1269
|
+
var input = document.createElement('input');
|
|
1270
|
+
input.type = 'file';
|
|
1271
|
+
input.accept = '.json,application/json';
|
|
1272
|
+
input.addEventListener('change', function () {
|
|
1273
|
+
var f = input.files && input.files[0];
|
|
1274
|
+
if (!f) return;
|
|
1275
|
+
var reader = new FileReader();
|
|
1276
|
+
reader.onload = function () { var n = importComments(String(reader.result)); if (n > 0) { if (!fiActive) activate(); showPanel(); } };
|
|
1277
|
+
reader.readAsText(f);
|
|
1278
|
+
});
|
|
1279
|
+
input.click();
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// On load: if the URL carries #spx=, decode it, strip it from the address bar (so
|
|
1283
|
+
// the URL goes clean and a plain reload won't re-import), import, and surface it.
|
|
1284
|
+
function importFromHash() {
|
|
1285
|
+
var h = location.hash || '', k = h.indexOf('spx=');
|
|
1286
|
+
if (k < 0) return Promise.resolve(0);
|
|
1287
|
+
var enc = h.slice(k + 4);
|
|
1288
|
+
try { history.replaceState(null, '', location.pathname + location.search); } catch (e) {}
|
|
1289
|
+
return decodePayload(enc).then(function (json) {
|
|
1290
|
+
var n = importComments(json);
|
|
1291
|
+
if (n > 0) { if (!fiActive) activate(); showPanel(); }
|
|
1292
|
+
return n;
|
|
1293
|
+
}).catch(function () { return 0; });
|
|
1043
1294
|
}
|
|
1044
1295
|
|
|
1045
1296
|
// Re-anchor if the node detached, then scroll it into view. Returns false when
|
|
@@ -1061,6 +1312,11 @@ function getClientScript(options) {
|
|
|
1061
1312
|
return isVisible(spec.el);
|
|
1062
1313
|
}
|
|
1063
1314
|
|
|
1315
|
+
// Signature of on-page visibility across specs (from reflowSpecs' per-frame _liveVis),
|
|
1316
|
+
// used to detect when the panel's HIDDEN labels have gone stale and re-render.
|
|
1317
|
+
var panelVisSig = '', panelVisTimer = null;
|
|
1318
|
+
function liveVisSig() { var s = ''; for (var i = 0; i < specs.length; i++) s += specs[i]._liveVis ? '1' : '0'; return s; }
|
|
1319
|
+
|
|
1064
1320
|
// \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
|
|
1065
1321
|
var panelWrap = markUI(document.createElement('div'));
|
|
1066
1322
|
Object.assign(panelWrap.style, {
|
|
@@ -1148,10 +1404,40 @@ function getClientScript(options) {
|
|
|
1148
1404
|
var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
|
|
1149
1405
|
delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
|
|
1150
1406
|
|
|
1407
|
+
// Share comments (human\u2194human): copies a link, or downloads a file when it can't
|
|
1408
|
+
// fit / the page owns the hash. The hint line confirms which happened.
|
|
1409
|
+
var share = iconPill(SHARE, 'Share comments to another person', '#8AB4F8', '138,180,248');
|
|
1410
|
+
function resetShareIcon() { share.icon.innerHTML = SHARE; share.btn.style.color = share.btn.matches(':hover') ? '#fff' : '#8AB4F8'; }
|
|
1411
|
+
share.btn.addEventListener('click', function (e) {
|
|
1412
|
+
e.stopPropagation();
|
|
1413
|
+
if (!specs.length) return;
|
|
1414
|
+
shareComments().then(function (res) {
|
|
1415
|
+
if (res.kind === 'link') {
|
|
1416
|
+
navigator.clipboard.writeText(res.link).then(function () {
|
|
1417
|
+
share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
|
|
1418
|
+
flashHint('\u2713 Link copied \u2014 paste it to your reviewer. They see your comments on the same page.', GREEN);
|
|
1419
|
+
setTimeout(resetShareIcon, 1400);
|
|
1420
|
+
}).catch(function () { flashHint('Copy failed \u2014 check clipboard permission for this site.', '#ED8FA6'); });
|
|
1421
|
+
} else if (res.kind === 'file') {
|
|
1422
|
+
share.icon.innerHTML = CHECK; share.btn.style.color = GREEN;
|
|
1423
|
+
flashHint('\u2713 Saved comments.specter.json \u2014 send that file (too many comments to fit a link).', GREEN);
|
|
1424
|
+
setTimeout(resetShareIcon, 1400);
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
// Import a comments file someone sent (the file-fallback receiver).
|
|
1430
|
+
var importTool = iconPill(IMPORT, 'Import a comments file', '#8AB4F8', '138,180,248');
|
|
1431
|
+
importTool.btn.addEventListener('click', function (e) { e.stopPropagation(); importCommentsFile(); });
|
|
1432
|
+
|
|
1151
1433
|
var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
|
|
1152
1434
|
var deleteAllBtn = delAll.btn;
|
|
1435
|
+
var shareBtn = share.btn;
|
|
1436
|
+
var importToolBtn = importTool.btn;
|
|
1153
1437
|
if (BRIDGE) panelTools.appendChild(syncDot);
|
|
1154
1438
|
else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
|
|
1439
|
+
panelTools.appendChild(shareBtn);
|
|
1440
|
+
panelTools.appendChild(importToolBtn);
|
|
1155
1441
|
panelTools.appendChild(copyAllBtn);
|
|
1156
1442
|
panelTools.appendChild(deleteAllBtn);
|
|
1157
1443
|
|
|
@@ -1219,6 +1505,16 @@ function getClientScript(options) {
|
|
|
1219
1505
|
panelHint.appendChild(document.createTextNode(' (or Copy all) and paste into Claude to apply these annotations. You can also edit or delete each one above.'));
|
|
1220
1506
|
}
|
|
1221
1507
|
}
|
|
1508
|
+
// Briefly show a confirmation in the hint line (Share copied / file saved), then
|
|
1509
|
+
// restore the normal guidance.
|
|
1510
|
+
var hintFlashTimer = null;
|
|
1511
|
+
function flashHint(msg, color) {
|
|
1512
|
+
panelHint.style.display = 'block';
|
|
1513
|
+
panelHint.textContent = msg;
|
|
1514
|
+
panelHint.style.color = color || GREEN;
|
|
1515
|
+
if (hintFlashTimer) clearTimeout(hintFlashTimer);
|
|
1516
|
+
hintFlashTimer = setTimeout(function () { panelHint.style.color = LABEL; setHint(); }, 2800);
|
|
1517
|
+
}
|
|
1222
1518
|
|
|
1223
1519
|
var panelList = document.createElement('div');
|
|
1224
1520
|
// overscroll-behavior:contain stops the panel's scroll from chaining into the page.
|
|
@@ -1332,21 +1628,24 @@ function getClientScript(options) {
|
|
|
1332
1628
|
|
|
1333
1629
|
function renderPanel() {
|
|
1334
1630
|
panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
|
|
1335
|
-
panelTools.style.display =
|
|
1631
|
+
panelTools.style.display = 'flex'; // always visible when the panel is open (Import needs no Specs)
|
|
1632
|
+
shareBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1336
1633
|
copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1337
1634
|
deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1635
|
+
importToolBtn.style.display = 'inline-flex'; // receiving a shared file needs no existing Specs
|
|
1338
1636
|
setHint();
|
|
1339
1637
|
panelList.textContent = '';
|
|
1340
1638
|
if (!specs.length) {
|
|
1341
1639
|
var empty = document.createElement('div');
|
|
1342
|
-
empty.textContent = 'No
|
|
1640
|
+
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.';
|
|
1343
1641
|
Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
|
|
1344
1642
|
panelList.appendChild(empty);
|
|
1345
1643
|
return;
|
|
1346
1644
|
}
|
|
1347
1645
|
var focusEditor = null, measures = [];
|
|
1348
1646
|
specs.forEach(function (spec, i) {
|
|
1349
|
-
var
|
|
1647
|
+
var missing = !!spec.missing;
|
|
1648
|
+
var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec \u2014 it would re-anchor via path
|
|
1350
1649
|
var editing = (panelEditSpec === spec);
|
|
1351
1650
|
var row = document.createElement('div');
|
|
1352
1651
|
Object.assign(row.style, {
|
|
@@ -1358,9 +1657,10 @@ function getClientScript(options) {
|
|
|
1358
1657
|
badge.textContent = String(i + 1);
|
|
1359
1658
|
Object.assign(badge.style, {
|
|
1360
1659
|
flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
|
|
1361
|
-
background:
|
|
1362
|
-
border: '
|
|
1660
|
+
background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
|
|
1661
|
+
border: '1px solid #fff', boxSizing: 'border-box',
|
|
1363
1662
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
1663
|
+
transition: 'background 0.12s ease',
|
|
1364
1664
|
});
|
|
1365
1665
|
var content = document.createElement('div');
|
|
1366
1666
|
Object.assign(content.style, { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '6px', paddingRight: '30px' });
|
|
@@ -1459,9 +1759,25 @@ function getClientScript(options) {
|
|
|
1459
1759
|
content.appendChild(note);
|
|
1460
1760
|
content.appendChild(meta);
|
|
1461
1761
|
|
|
1762
|
+
// Shared comment whose target is gone/changed: MISSING (greyed, reason shown,
|
|
1763
|
+
// never drawn on the page \u2014 design: show missing, don't guess a spot).
|
|
1764
|
+
if (missing) {
|
|
1765
|
+
row.style.opacity = '0.7';
|
|
1766
|
+
var mbar = document.createElement('div');
|
|
1767
|
+
Object.assign(mbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1768
|
+
var mtag = document.createElement('span');
|
|
1769
|
+
mtag.textContent = 'MISSING';
|
|
1770
|
+
Object.assign(mtag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#fff', background: '#8B4A57', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
|
|
1771
|
+
var mhint = document.createElement('span');
|
|
1772
|
+
mhint.textContent = spec.missReason || 'Not on this page';
|
|
1773
|
+
Object.assign(mhint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
|
|
1774
|
+
mbar.appendChild(mtag);
|
|
1775
|
+
mbar.appendChild(mhint);
|
|
1776
|
+
content.appendChild(mbar);
|
|
1777
|
+
}
|
|
1462
1778
|
// Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
|
|
1463
1779
|
// so hidden Specs are findable in a long list instead of silently unreachable.
|
|
1464
|
-
if (!visible) {
|
|
1780
|
+
else if (!visible) {
|
|
1465
1781
|
var hbar = document.createElement('div');
|
|
1466
1782
|
Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1467
1783
|
var tag = document.createElement('span');
|
|
@@ -1482,12 +1798,14 @@ function getClientScript(options) {
|
|
|
1482
1798
|
note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
|
|
1483
1799
|
row.addEventListener('mouseenter', function () {
|
|
1484
1800
|
row.style.background = 'rgba(255,255,255,0.05)';
|
|
1801
|
+
badge.style.background = PURPLE;
|
|
1485
1802
|
editBtn.style.display = delBtn.style.display = 'flex';
|
|
1486
1803
|
actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
|
|
1487
1804
|
if (visible) { highlightSpec = spec; revealSpec(spec); }
|
|
1488
1805
|
});
|
|
1489
1806
|
row.addEventListener('mouseleave', function () {
|
|
1490
1807
|
row.style.background = 'transparent';
|
|
1808
|
+
badge.style.background = BADGE_IDLE;
|
|
1491
1809
|
editBtn.style.display = delBtn.style.display = 'none';
|
|
1492
1810
|
actions.style.background = 'transparent';
|
|
1493
1811
|
if (highlightSpec === spec) highlightSpec = null;
|
|
@@ -1512,6 +1830,7 @@ function getClientScript(options) {
|
|
|
1512
1830
|
}
|
|
1513
1831
|
});
|
|
1514
1832
|
if (focusEditor) setTimeout(focusEditor, 0);
|
|
1833
|
+
panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
|
|
1515
1834
|
}
|
|
1516
1835
|
|
|
1517
1836
|
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
@@ -2092,8 +2411,9 @@ function getClientScript(options) {
|
|
|
2092
2411
|
});
|
|
2093
2412
|
}
|
|
2094
2413
|
|
|
2095
|
-
restoreSpecs();
|
|
2096
|
-
|
|
2414
|
+
restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
|
|
2415
|
+
importFromHash(); // if arrived via a #spx= share link, import + reveal the shared comments
|
|
2416
|
+
scheduleSync(); // mirror restored Specs to the Claude bridge on load
|
|
2097
2417
|
|
|
2098
2418
|
console.log('%c\u{1F47B} Specter \u2014 Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
|
|
2099
2419
|
})();`;
|