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/client.js CHANGED
@@ -14,6 +14,7 @@
14
14
  var TIP_BG = '#292B32';
15
15
  var GREEN = '#22C55E';
16
16
  var LABEL = '#8B8D94';
17
+ var BADGE_IDLE = '#6B6D75'; // panel row-number badges sit greyscale; the hovered row's badge colors to PURPLE
17
18
  var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
18
19
  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>';
19
20
  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>';
@@ -22,6 +23,8 @@
22
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>';
23
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>';
24
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>';
25
28
  var ACTIVATE = "ctrl+alt+z";
26
29
  var BRIDGE = ""; // Claude MCP bridge URL, or '' if disabled
27
30
 
@@ -308,7 +311,7 @@
308
311
  pill.style.maxWidth = '820px';
309
312
  pillText.style.display = 'inline';
310
313
  chevron.style.display = 'inline';
311
- listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
314
+ listBtn.style.display = 'inline-flex'; // always reachable — the panel is also where you Import a shared file
312
315
  clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
313
316
  pillExpanded = true;
314
317
  // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
@@ -793,19 +796,31 @@
793
796
  var mx = lastMouse.x, my = lastMouse.y;
794
797
  var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
795
798
 
796
- // 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.
797
803
  var vis = [];
798
804
  for (var i = 0; i < specs.length; i++) {
799
805
  var s = specs[i];
800
- 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
801
808
  if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
802
- 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;
803
811
  var r = s.el.getBoundingClientRect();
804
- if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered (e.g. behind a modal)
812
+ if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered / off-screen: no badge, but panel not HIDDEN
805
813
  s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
806
814
  vis.push(s);
807
815
  }
808
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
+
809
824
  // Pass 2 — cluster badges whose anchors coincide (within ~16px).
810
825
  var clusters = [];
811
826
  for (var a = 0; a < vis.length; a++) {
@@ -993,7 +1008,7 @@
993
1008
  function saveSpecs() {
994
1009
  try {
995
1010
  if (!specs.length) localStorage.removeItem(STORAGE_KEY);
996
- 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 }; })));
997
1012
  } catch (e) {}
998
1013
  scheduleSync(); // keep the Claude bridge mirrored to the current Specs
999
1014
  }
@@ -1003,11 +1018,247 @@
1003
1018
  try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1004
1019
  if (!Array.isArray(data) || !data.length) return;
1005
1020
  data.forEach(function (d) {
1006
- 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 };
1007
1145
  specs.push(spec);
1008
1146
  createBadge(spec);
1147
+ added++;
1009
1148
  });
1010
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; });
1011
1262
  }
1012
1263
 
1013
1264
  // Re-anchor if the node detached, then scroll it into view. Returns false when
@@ -1029,6 +1280,11 @@
1029
1280
  return isVisible(spec.el);
1030
1281
  }
1031
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
+
1032
1288
  // ─── Specs side panel (in-session review) ────────────────────────────────────
1033
1289
  var panelWrap = markUI(document.createElement('div'));
1034
1290
  Object.assign(panelWrap.style, {
@@ -1116,10 +1372,40 @@
1116
1372
  var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
1117
1373
  delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
1118
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
+
1119
1401
  var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
1120
1402
  var deleteAllBtn = delAll.btn;
1403
+ var shareBtn = share.btn;
1404
+ var importToolBtn = importTool.btn;
1121
1405
  if (BRIDGE) panelTools.appendChild(syncDot);
1122
1406
  else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
1407
+ panelTools.appendChild(shareBtn);
1408
+ panelTools.appendChild(importToolBtn);
1123
1409
  panelTools.appendChild(copyAllBtn);
1124
1410
  panelTools.appendChild(deleteAllBtn);
1125
1411
 
@@ -1187,6 +1473,16 @@
1187
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.'));
1188
1474
  }
1189
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
+ }
1190
1486
 
1191
1487
  var panelList = document.createElement('div');
1192
1488
  // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
@@ -1300,21 +1596,24 @@
1300
1596
 
1301
1597
  function renderPanel() {
1302
1598
  panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
1303
- panelTools.style.display = (specs.length || BRIDGE) ? 'flex' : 'none';
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';
1304
1601
  copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1305
1602
  deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1603
+ importToolBtn.style.display = 'inline-flex'; // receiving a shared file needs no existing Specs
1306
1604
  setHint();
1307
1605
  panelList.textContent = '';
1308
1606
  if (!specs.length) {
1309
1607
  var empty = document.createElement('div');
1310
- empty.textContent = 'No Specs yet — hover an element and press P.';
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.';
1311
1609
  Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
1312
1610
  panelList.appendChild(empty);
1313
1611
  return;
1314
1612
  }
1315
1613
  var focusEditor = null, measures = [];
1316
1614
  specs.forEach(function (spec, i) {
1317
- var visible = specVisible(spec);
1615
+ var missing = !!spec.missing;
1616
+ var visible = missing ? false : specVisible(spec); // don't specVisible() a missing spec — it would re-anchor via path
1318
1617
  var editing = (panelEditSpec === spec);
1319
1618
  var row = document.createElement('div');
1320
1619
  Object.assign(row.style, {
@@ -1326,9 +1625,10 @@
1326
1625
  badge.textContent = String(i + 1);
1327
1626
  Object.assign(badge.style, {
1328
1627
  flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1329
- background: PURPLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1330
- border: '2px solid #fff', boxSizing: 'border-box',
1628
+ background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1629
+ border: '1px solid #fff', boxSizing: 'border-box',
1331
1630
  display: 'flex', alignItems: 'center', justifyContent: 'center',
1631
+ transition: 'background 0.12s ease',
1332
1632
  });
1333
1633
  var content = document.createElement('div');
1334
1634
  Object.assign(content.style, { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '6px', paddingRight: '30px' });
@@ -1427,9 +1727,25 @@
1427
1727
  content.appendChild(note);
1428
1728
  content.appendChild(meta);
1429
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
+ }
1430
1746
  // Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
1431
1747
  // so hidden Specs are findable in a long list instead of silently unreachable.
1432
- if (!visible) {
1748
+ else if (!visible) {
1433
1749
  var hbar = document.createElement('div');
1434
1750
  Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1435
1751
  var tag = document.createElement('span');
@@ -1450,12 +1766,14 @@
1450
1766
  note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1451
1767
  row.addEventListener('mouseenter', function () {
1452
1768
  row.style.background = 'rgba(255,255,255,0.05)';
1769
+ badge.style.background = PURPLE;
1453
1770
  editBtn.style.display = delBtn.style.display = 'flex';
1454
1771
  actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1455
1772
  if (visible) { highlightSpec = spec; revealSpec(spec); }
1456
1773
  });
1457
1774
  row.addEventListener('mouseleave', function () {
1458
1775
  row.style.background = 'transparent';
1776
+ badge.style.background = BADGE_IDLE;
1459
1777
  editBtn.style.display = delBtn.style.display = 'none';
1460
1778
  actions.style.background = 'transparent';
1461
1779
  if (highlightSpec === spec) highlightSpec = null;
@@ -1480,6 +1798,7 @@
1480
1798
  }
1481
1799
  });
1482
1800
  if (focusEditor) setTimeout(focusEditor, 0);
1801
+ panelVisSig = liveVisSig(); // record what this render reflects, so reflow only re-renders on a real flip
1483
1802
  }
1484
1803
 
1485
1804
  function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
@@ -2060,8 +2379,9 @@
2060
2379
  });
2061
2380
  }
2062
2381
 
2063
- restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
2064
- scheduleSync(); // mirror restored Specs to the Claude bridge on load
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
2065
2385
 
2066
2386
  console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
2067
2387
  })();