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