vite-plugin-specter 0.4.0 → 0.5.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.cjs CHANGED
@@ -28,9 +28,17 @@ module.exports = __toCommonJS(index_exports);
28
28
  // src/client.ts
29
29
  function getClientScript(options) {
30
30
  const activateShortcut = options.shortcuts?.activate ?? "ctrl+alt+z";
31
+ const cb = options.claudeBridge;
32
+ const bridgeUrl = cb === true ? "http://127.0.0.1:8787" : cb && typeof cb === "object" ? cb.url ?? "http://127.0.0.1:8787" : "";
31
33
  return `(function() {
32
34
  'use strict';
33
- if (window.__specter) return; window.__specter = true;
35
+ // Guard against running twice on one page. Use a DOM marker (not just a
36
+ // window global) so the browser-extension build (isolated world) and the
37
+ // Vite-plugin build (page main world) can see each other and only one runs \u2014
38
+ // they share the DOM but not window globals, so a per-world flag misses.
39
+ if (window.__specter || document.documentElement.hasAttribute('data-specter')) return;
40
+ window.__specter = true;
41
+ document.documentElement.setAttribute('data-specter', '');
34
42
 
35
43
  // \u2500\u2500\u2500 Constants \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\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
36
44
  var PURPLE = '#AD24D3';
@@ -41,7 +49,13 @@ function getClientScript(options) {
41
49
  var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
42
50
  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>';
43
51
  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>';
52
+ var TRASH = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
53
+ var LIST = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>';
54
+ 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
+ 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
+ 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>';
44
57
  var ACTIVATE = ${JSON.stringify(activateShortcut)};
58
+ var BRIDGE = ${JSON.stringify(bridgeUrl)}; // Claude MCP bridge URL, or '' if disabled
45
59
 
46
60
  // \u2500\u2500\u2500 State \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\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
47
61
  var fiActive = false;
@@ -58,14 +72,38 @@ function getClientScript(options) {
58
72
  var copiedTimer = null;
59
73
  var flashTimer = null;
60
74
  var lastMouse = { x: 0, y: 0 };
61
- var selectedEls = [];
62
- var selectedBadges = [];
63
- var noteMap = new Map();
64
- var noteInputEl = null;
65
- var noteTargetEl = null;
75
+ var lastClick = { label: '', at: 0 }; // last interactive control clicked (likely opener of a modal)
76
+ // Specs = the unified marks (pick + optional annotation). Each:
77
+ // { el, path, note, body, kind:'element'|'measure', wrap, pill, num, noteSpan }
78
+ var specs = [];
79
+ var editorEl = null; // the open Spec-editor box, or null
80
+ var editorSpec = null; // the Spec being edited
81
+ var editorCommit = null; // idempotent commit fn for the open editor
82
+ var rafId = null; // reflow loop handle
83
+ var panelOpen = false; // Specs side panel visible?
84
+ var panelEditSpec = null; // Spec being inline-edited in the panel, or null
85
+ var highlightSpec = null; // Spec whose badge is enlarged (panel-row hover)
86
+ var groupHover = {}; // per-cluster fan-out hover state (keyed by member ids)
87
+ // Per-URL reload insurance: Specs survive an accidental refresh. Zero network \u2014
88
+ // localStorage only, keyed by path so different routes keep separate lists.
89
+ var STORAGE_KEY = '__specter_specs_' + location.pathname;
90
+
91
+ // Tag every Specter-owned node so inspect/hover logic can skip its own UI
92
+ // (no "Specter-ception" \u2014 never inspect our own overlays).
93
+ function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
94
+ function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
95
+
96
+ // Attach a hover effect to an interactive icon/button: apply the "on" styles
97
+ // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
98
+ function hoverFx(el, on, off) {
99
+ el.style.transition = (el.style.transition ? el.style.transition + ', ' : '') + 'background 0.12s ease, color 0.12s ease, opacity 0.12s ease, transform 0.12s ease';
100
+ el.addEventListener('mouseenter', function () { Object.assign(el.style, on); });
101
+ el.addEventListener('mouseleave', function () { Object.assign(el.style, off); });
102
+ }
66
103
 
67
104
  // \u2500\u2500\u2500 Tooltip \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\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
68
105
  var tooltip = document.createElement('div');
106
+ markUI(tooltip);
69
107
  Object.assign(tooltip.style, {
70
108
  position: 'fixed',
71
109
  zIndex: '2147483646',
@@ -85,6 +123,7 @@ function getClientScript(options) {
85
123
 
86
124
  // Measure overlay
87
125
  var measureOverlay = document.createElement('div');
126
+ markUI(measureOverlay);
88
127
  Object.assign(measureOverlay.style, {
89
128
  position: 'fixed',
90
129
  inset: '0',
@@ -96,6 +135,7 @@ function getClientScript(options) {
96
135
 
97
136
  // \u2500\u2500\u2500 Pill \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\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
98
137
  var pillWrap = document.createElement('div');
138
+ markUI(pillWrap);
99
139
  Object.assign(pillWrap.style, {
100
140
  position: 'fixed',
101
141
  bottom: '4px',
@@ -159,6 +199,7 @@ function getClientScript(options) {
159
199
  cursor: 'pointer',
160
200
  });
161
201
  closeEl.addEventListener('click', function (e) { e.stopPropagation(); deactivate(); });
202
+ hoverFx(closeEl, { transform: 'scale(1.25)' }, { transform: 'scale(1)' });
162
203
 
163
204
  iconBtn.appendChild(zapEl);
164
205
  iconBtn.appendChild(closeEl);
@@ -166,6 +207,41 @@ function getClientScript(options) {
166
207
  var pillText = document.createElement('span');
167
208
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
168
209
 
210
+ var listBtn = document.createElement('span');
211
+ listBtn.innerHTML = LIST;
212
+ listBtn.title = 'Show Specs panel (L)';
213
+ Object.assign(listBtn.style, {
214
+ display: 'none',
215
+ alignItems: 'center',
216
+ cursor: 'pointer',
217
+ color: '#fff',
218
+ flexShrink: '0',
219
+ padding: '4px 6px',
220
+ marginLeft: '2px',
221
+ borderRadius: '999px',
222
+ background: 'rgba(255,255,255,0.16)',
223
+ });
224
+ listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
225
+ hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
226
+
227
+ var clearBtn = document.createElement('span');
228
+ clearBtn.textContent = '\u2715 Delete all annotations';
229
+ clearBtn.title = 'Delete all annotations';
230
+ Object.assign(clearBtn.style, {
231
+ display: 'none',
232
+ cursor: 'pointer',
233
+ color: '#fff',
234
+ fontSize: '11px',
235
+ fontWeight: '600',
236
+ flexShrink: '0',
237
+ padding: '2px 8px',
238
+ marginLeft: '2px',
239
+ borderRadius: '999px',
240
+ background: 'rgba(255,255,255,0.16)',
241
+ });
242
+ clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
243
+ hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
244
+
169
245
  var chevron = document.createElement('span');
170
246
  chevron.textContent = '\u203A';
171
247
  chevron.title = 'Move to other side';
@@ -179,9 +255,12 @@ function getClientScript(options) {
179
255
  opacity: '0.8',
180
256
  });
181
257
  chevron.addEventListener('click', function (e) { e.stopPropagation(); moveSide(); });
258
+ hoverFx(chevron, { opacity: '1', transform: 'scale(1.2)' }, { opacity: '0.8', transform: 'scale(1)' });
182
259
 
183
260
  pill.appendChild(iconBtn);
184
261
  pill.appendChild(pillText);
262
+ pill.appendChild(listBtn);
263
+ pill.appendChild(clearBtn);
185
264
  pill.appendChild(chevron);
186
265
  pillWrap.appendChild(pill);
187
266
  document.body.appendChild(pillWrap);
@@ -196,7 +275,7 @@ function getClientScript(options) {
196
275
  closeEl.style.opacity = '1';
197
276
  });
198
277
  pillWrap.addEventListener('mouseleave', function () {
199
- if (selectedEls.length === 0 && !pinEl) collapsePill();
278
+ if (specs.length === 0 && !pinEl) collapsePill();
200
279
  zapEl.style.transform = 'rotate(0deg)';
201
280
  zapEl.style.opacity = '1';
202
281
  closeEl.style.opacity = '0';
@@ -216,17 +295,19 @@ function getClientScript(options) {
216
295
  }
217
296
 
218
297
  function expandPill(text) {
219
- pill.style.maxWidth = '760px';
298
+ pill.style.maxWidth = '820px';
220
299
  pillText.style.display = 'inline';
221
300
  chevron.style.display = 'inline';
301
+ listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
302
+ clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
222
303
  pillExpanded = true;
223
304
  if (text) { pillText.textContent = text; return; }
224
- if (selectedEls.length > 0) {
225
- pillText.textContent = selectedEls.length + ' selected \xB7 N annotate \xB7 Cmd+C copy all \xB7 Esc clear';
305
+ if (specs.length > 0) {
306
+ pillText.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs') + ' \xB7 P add \xB7 L panel \xB7 Cmd+C copy';
226
307
  } else if (measureMode) {
227
- pillText.textContent = 'Measure \xB7 hover distances \xB7 M pin \xB7 Cmd+C copy \xB7 Option toggle';
308
+ pillText.textContent = 'Measure \xB7 hover distances \xB7 P mark \xB7 M pin \xB7 Cmd+C copy \xB7 Option toggle';
228
309
  } else {
229
- pillText.textContent = 'Properties \xB7 hover \xB7 P pick \xB7 N annotate \xB7 Cmd+C copy \xB7 Option measure';
310
+ pillText.textContent = 'Properties \xB7 P mark / annotate \xB7 Cmd+C copy \xB7 Option measure';
230
311
  }
231
312
  }
232
313
 
@@ -234,6 +315,8 @@ function getClientScript(options) {
234
315
  pill.style.maxWidth = '32px';
235
316
  pillText.style.display = 'none';
236
317
  chevron.style.display = 'none';
318
+ listBtn.style.display = 'none';
319
+ clearBtn.style.display = 'none';
237
320
  pillExpanded = false;
238
321
  }
239
322
 
@@ -243,26 +326,36 @@ function getClientScript(options) {
243
326
  expandPill(text);
244
327
  clearTimeout(flashTimer);
245
328
  flashTimer = setTimeout(function () {
246
- if (!pillWrap.matches(':hover') && selectedEls.length === 0 && !pinEl) collapsePill();
329
+ if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
247
330
  else expandPill();
248
331
  }, 1200);
249
332
  }
250
333
 
334
+ // Show/collapse the pill based on whether any Specs exist.
335
+ function updatePill() {
336
+ if (specs.length > 0) expandPill();
337
+ else if (!pillWrap.matches(':hover')) collapsePill();
338
+ if (panelOpen) renderPanel();
339
+ }
340
+
251
341
  // \u2500\u2500\u2500 Activate / Deactivate \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
342
+ // Esc / toggle only HIDE the plugin \u2014 Specs persist and reappear on reactivate.
252
343
  function activate() {
253
344
  fiActive = true;
254
345
  measureMode = false;
255
346
  pillWrap.style.display = 'block';
256
347
  document.body.style.cursor = 'crosshair';
257
- collapsePill();
348
+ updatePill();
349
+ startLoop();
350
+ reflowSpecs();
258
351
  }
259
352
 
260
353
  function deactivate() {
354
+ commitEditor();
261
355
  fiActive = false;
262
356
  measureMode = false;
263
357
  optionHeld = false;
264
358
  lastHovered = null;
265
- clearSelection();
266
359
  clearPin();
267
360
  clearHoverOutline();
268
361
  clearMeasureTargetHL();
@@ -270,6 +363,9 @@ function getClientScript(options) {
270
363
  document.body.style.cursor = '';
271
364
  hideTooltip();
272
365
  clearMeasureOverlay();
366
+ hidePanel();
367
+ stopLoop();
368
+ reflowSpecs(); // hides all Spec badges while inactive (data kept)
273
369
  }
274
370
 
275
371
  // \u2500\u2500\u2500 Helpers \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\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
@@ -346,81 +442,6 @@ function getClientScript(options) {
346
442
  return null;
347
443
  }
348
444
 
349
- // Collapse a rule's declarations to their shortest lossless form: drop
350
- // 'initial' (= default, no signal) and always-'normal' noise, and fold
351
- // side/corner longhands back into shorthands (margin/padding/radius/gap/transition).
352
- function cleanDecls(style) {
353
- var m = {}, order = [];
354
- for (var i = 0; i < style.length; i++) {
355
- var p = style[i];
356
- var v = style.getPropertyValue(p);
357
- if (!v) continue;
358
- if (v === 'initial') continue;
359
- if (p === 'transition-behavior') continue;
360
- if (!(p in m)) order.push(p);
361
- m[p] = v;
362
- }
363
- var used = {}, collapses = {};
364
- function four(name, t, r, b, l) {
365
- if (m[t] === undefined || m[r] === undefined || m[b] === undefined || m[l] === undefined) return;
366
- used[t] = used[r] = used[b] = used[l] = 1;
367
- var a = m[t], c = m[r], d = m[b], e = m[l], sh;
368
- if (a === c && c === d && d === e) sh = a;
369
- else if (a === d && c === e) sh = a + ' ' + c;
370
- else if (c === e) sh = a + ' ' + c + ' ' + d;
371
- else sh = a + ' ' + c + ' ' + d + ' ' + e;
372
- collapses[t] = name + ': ' + sh;
373
- }
374
- four('margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left');
375
- four('padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left');
376
- four('border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius');
377
- if (m['row-gap'] !== undefined && m['column-gap'] !== undefined) {
378
- var g = m['row-gap'] === m['column-gap'] ? m['row-gap'] : m['row-gap'] + ' ' + m['column-gap'];
379
- collapses['row-gap'] = 'gap: ' + g; used['row-gap'] = used['column-gap'] = 1;
380
- }
381
- if (m['transition-property'] !== undefined) {
382
- var tr = 'transition: ' + m['transition-property'];
383
- if (m['transition-duration'] !== undefined) tr += ' ' + m['transition-duration'];
384
- if (m['transition-timing-function'] !== undefined) tr += ' ' + m['transition-timing-function'];
385
- if (m['transition-delay'] !== undefined && m['transition-delay'] !== '0s') tr += ' ' + m['transition-delay'];
386
- collapses['transition-property'] = tr;
387
- used['transition-property'] = used['transition-duration'] = used['transition-timing-function'] = used['transition-delay'] = 1;
388
- }
389
- var out = [];
390
- order.forEach(function (p) {
391
- if (collapses[p]) { out.push(' ' + collapses[p] + ';'); return; }
392
- if (used[p]) return;
393
- out.push(' ' + p + ': ' + m[p] + ';');
394
- });
395
- return out;
396
- }
397
-
398
- function getMatchingRules(el) {
399
- var results = [];
400
- try {
401
- for (var s = 0; s < document.styleSheets.length; s++) {
402
- var sheet = document.styleSheets[s];
403
- var rules;
404
- try { rules = sheet.cssRules; } catch (e) { continue; }
405
- if (!rules) continue;
406
- for (var r = 0; r < rules.length; r++) {
407
- var rule = rules[r];
408
- if (rule.type !== 1) continue;
409
- var sel = rule.selectorText;
410
- if (!sel) continue;
411
- if (/^[*,]|^:root|^html|^body$|^::before|^::after/.test(sel.trim())) continue;
412
- try {
413
- if (el.matches(sel)) {
414
- var props = cleanDecls(rule.style);
415
- if (props.length) results.push(sel + ' {\\n' + props.join('\\n') + '\\n}');
416
- }
417
- } catch (e) {}
418
- }
419
- }
420
- } catch (e) {}
421
- return results;
422
- }
423
-
424
445
  function getSelector(el) {
425
446
  var parts = [];
426
447
  var cur = el;
@@ -442,12 +463,90 @@ function getClientScript(options) {
442
463
  return parts.join(' > ');
443
464
  }
444
465
 
466
+ // \u2500\u2500\u2500 Layer A locator \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
467
+ // The single most greppable anchor for an element, so Claude jumps straight to the
468
+ // source instead of searching: a unique test-id \u2192 stable id \u2192 aria/name \u2192 short
469
+ // visible text \u2192 a unique class, falling back to the CSS path. Any stack, no build
470
+ // step, one line \u2014 fewer agent tool-calls + more accurate edits (the core tenet).
471
+ function uniqueSel(sel) { try { return !!sel && document.querySelectorAll(sel).length === 1; } catch (e) { return false; } }
472
+ function isHashedId(id) { return /:r[0-9a-z]+:/i.test(id) || /[a-f0-9]{8,}/i.test(id) || /__[a-zA-Z0-9]{5,}/.test(id) || /_[a-zA-Z0-9]{6,}$/.test(id); }
473
+ function ownText(el) {
474
+ if (el.children && el.children.length > 2) return '';
475
+ var t = (el.textContent || '').replace(/\\s+/g, ' ').trim();
476
+ return (t.length >= 2 && t.length <= 80) ? t : '';
477
+ }
478
+ function ownClass(el) {
479
+ var cls = Array.prototype.slice.call(el.classList || []).filter(function (c) { return c.indexOf('__specter') !== 0 && !/[a-f0-9]{6,}/i.test(c) && !/_[a-zA-Z0-9]{5,}$/.test(c); });
480
+ for (var i = 0; i < cls.length; i++) { if (uniqueSel('.' + cssEsc(cls[i]))) return cls[i]; }
481
+ return '';
482
+ }
483
+ // A 'text "\u2026"' anchor is only safe if that text isn't ALSO the own-text of a
484
+ // DIFFERENT element (an ancestor/descendant sharing it = the same source spot,
485
+ // so those don't count). Prevents an ambiguous grep (e.g. a button and a modal
486
+ // heading both reading "Book a demo").
487
+ function uniqueTextAnchor(el, txt) {
488
+ var all = document.getElementsByTagName('*');
489
+ for (var i = 0; i < all.length; i++) {
490
+ var o = all[i];
491
+ if (o === el || el.contains(o) || o.contains(el)) continue;
492
+ if (ownText(o) === txt) return false;
493
+ }
494
+ return true;
495
+ }
496
+ // Which of {color, background} are altered by a :hover/:active/:focus rule that
497
+ // matches this element \u2014 so a value read WHILE the cursor is on it (its computed
498
+ // style is the hovered state) can be flagged instead of reported as the resting
499
+ // value. Detect-only: recovering the resting value would need a cascade parser.
500
+ var STATE_PSEUDO = /:(hover|active|focus|focus-visible|focus-within)\\b/g;
501
+ function hoverAffected(el) {
502
+ var out = {};
503
+ var sheets = document.styleSheets;
504
+ for (var i = 0; i < sheets.length; i++) {
505
+ var rules;
506
+ try { rules = sheets[i].cssRules; } catch (e) { continue; } // cross-origin sheet
507
+ if (!rules) continue;
508
+ for (var j = 0; j < rules.length; j++) {
509
+ var r = rules[j];
510
+ if (!r.selectorText || r.selectorText.indexOf(':') < 0) continue;
511
+ STATE_PSEUDO.lastIndex = 0;
512
+ if (!STATE_PSEUDO.test(r.selectorText)) continue;
513
+ var sels = r.selectorText.split(',');
514
+ for (var k = 0; k < sels.length; k++) {
515
+ STATE_PSEUDO.lastIndex = 0;
516
+ var base = sels[k].replace(STATE_PSEUDO, '').trim();
517
+ if (!base) continue;
518
+ var m = false;
519
+ try { m = el.matches(base); } catch (e) { continue; }
520
+ if (!m) continue;
521
+ if (r.style.color) out.color = true;
522
+ if (r.style.background || r.style.backgroundColor) out.bg = true;
523
+ }
524
+ }
525
+ }
526
+ return out;
527
+ }
528
+ function resolveLocator(el) {
529
+ if (!el || el.nodeType !== 1) return '';
530
+ var i, v;
531
+ var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
532
+ for (i = 0; i < testAttrs.length; i++) { v = el.getAttribute(testAttrs[i]); if (v) { var ts = '[' + testAttrs[i] + '="' + cssEsc(v) + '"]'; if (uniqueSel(ts)) return ts; } }
533
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
534
+ var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
535
+ for (i = 0; i < attrs.length; i++) { v = el.getAttribute(attrs[i]); if (v && v.trim()) { v = v.trim(); if (uniqueSel('[' + attrs[i] + '="' + cssEsc(v) + '"]')) return attrs[i] + ' "' + v.slice(0, 60) + '"'; } }
536
+ var txt = ownText(el);
537
+ if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '\u2026' : '') + '"';
538
+ var cls = ownClass(el);
539
+ if (cls) return '.' + cls;
540
+ return getSelector(el); // fallback: the CSS path
541
+ }
542
+
445
543
  // \u2500\u2500\u2500 Structured data model \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
446
544
  function buildInfo(el) {
447
545
  var cs = getComputedStyle(el);
448
546
  var rect = el.getBoundingClientRect();
547
+ var hv = hoverAffected(el);
449
548
  var ff = cs.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
450
- var raw = el.textContent ? el.textContent.trim() : '';
549
+ var raw = el.textContent ? el.textContent.replace(/\\s+/g, ' ').trim() : '';
451
550
  return {
452
551
  el: el,
453
552
  tag: el.tagName.toLowerCase(),
@@ -462,13 +561,14 @@ function getClientScript(options) {
462
561
  font: { family: ff, weight: cs.fontWeight, size: cs.fontSize, lineHeight: cs.lineHeight },
463
562
  color: colorObj(cs.color),
464
563
  bg: colorObj(cs.backgroundColor),
564
+ colorHover: !!hv.color,
565
+ bgHover: !!hv.bg,
465
566
  padding: edge(cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft),
466
567
  margin: edge(cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft),
467
568
  radius: (cs.borderRadius && cs.borderRadius !== '0px') ? cs.borderRadius : null,
468
569
  display: ['flex', 'grid', 'inline-flex', 'inline-grid'].indexOf(cs.display) >= 0 ? cs.display : null,
469
570
  gap: (cs.gap && cs.gap !== 'normal') ? cs.gap : null,
470
571
  flexDir: (cs.display.indexOf('flex') >= 0 && cs.flexDirection !== 'row') ? cs.flexDirection : null,
471
- rules: getMatchingRules(el),
472
572
  };
473
573
  }
474
574
 
@@ -488,8 +588,8 @@ function getClientScript(options) {
488
588
  if (data.text) h += '<div style="color:' + LABEL + ';font-style:italic;margin-bottom:8px">"' + esc(data.text) + (data.textTrunc ? '\u2026' : '') + '"</div>';
489
589
  h += '<div style="height:8px"></div>';
490
590
  h += row('Font', esc(data.font.family) + ' \xB7 ' + weightName(data.font.weight) + ' \xB7 ' + data.font.size + '/' + data.font.lineHeight);
491
- if (data.color) h += row('Color', swatch(data.color.hex) + data.color.label);
492
- if (data.bg) h += row('Bg', swatch(data.bg.hex) + data.bg.label);
591
+ if (data.color) h += row('Color', swatch(data.color.hex) + data.color.label + (data.colorHover ? ' <span style="color:' + LABEL + '">(hover)</span>' : ''));
592
+ if (data.bg) h += row('Bg', swatch(data.bg.hex) + data.bg.label + (data.bgHover ? ' <span style="color:' + LABEL + '">(hover)</span>' : ''));
493
593
  if (data.padding) h += row('Padding', data.padding.value);
494
594
  if (data.margin) h += row('Margin', data.margin.value);
495
595
  if (data.radius) h += row('Radius', data.radius);
@@ -502,43 +602,45 @@ function getClientScript(options) {
502
602
  return h;
503
603
  }
504
604
 
605
+ // Lean copy: just enough to fix the issue \u2014 which element, and its current
606
+ // key values. Deliberately NO full CSS-rule dump (an AI with the repo finds
607
+ // the rule from the selector; one without it can't edit source anyway).
505
608
  function buildLLMClipboard(data) {
506
- var lines = ['[Specter]'];
609
+ // Identity: tag + component/styleKey + text + dims. Classes live in the
610
+ // selector line, so don't repeat them here.
507
611
  var head = '<' + data.tag + '>';
508
- if (data.id) head += '#' + data.id;
509
- if (data.classes.length) head += '.' + data.classes.join('.');
510
612
  if (data.component) head += ' ' + data.component;
511
613
  if (data.styleKey) head += ' [' + data.styleKey + ']';
614
+ if (data.text) head += ' "' + data.text + (data.textTrunc ? '\u2026' : '') + '"';
512
615
  head += ' ' + data.width + '\xD7' + data.height;
513
- lines.push(head);
514
- if (data.text) lines.push('"' + data.text + (data.textTrunc ? '\u2026' : '') + '"');
515
- lines.push('font: ' + data.font.family + ' ' + data.font.weight + ' ' + data.font.size + '/' + data.font.lineHeight);
516
- if (data.color) lines.push('color: ' + data.color.label);
517
- if (data.bg) lines.push('bg: ' + data.bg.label);
518
- if (data.padding) lines.push('padding: ' + data.padding.value);
519
- if (data.margin) lines.push('margin: ' + data.margin.value);
520
- if (data.radius) lines.push('radius: ' + data.radius);
616
+
617
+ // One compact line of the values that could be the target of the change.
618
+ var props = ['font: ' + data.font.family + ' ' + data.font.weight + ' ' + data.font.size + '/' + data.font.lineHeight];
619
+ if (data.color) props.push('color: ' + data.color.label + (data.colorHover ? ' (hover)' : ''));
620
+ if (data.bg) props.push('bg: ' + data.bg.label + (data.bgHover ? ' (hover)' : ''));
621
+ if (data.padding) props.push('padding: ' + data.padding.value);
622
+ if (data.margin) props.push('margin: ' + data.margin.value);
623
+ if (data.radius) props.push('radius: ' + data.radius);
521
624
  if (data.display) {
522
625
  var d = 'display: ' + data.display;
523
- if (data.gap) d += ' gap: ' + data.gap;
524
- if (data.flexDir) d += ' dir: ' + data.flexDir;
525
- lines.push(d);
626
+ if (data.gap) d += ' gap ' + data.gap;
627
+ if (data.flexDir) d += ' ' + data.flexDir;
628
+ props.push(d);
526
629
  }
527
- lines.push('selector: ' + getSelector(data.el));
528
- var out = lines.join('\\n');
529
- if (data.rules && data.rules.length) out += '\\n\\n--- CSS Rules ---\\n' + data.rules.join('\\n\\n');
530
- return out;
630
+
631
+ return ['[Specter]', head, 'find: ' + resolveLocator(data.el), props.join(' \xB7 ')].join('\\n');
531
632
  }
532
633
 
533
- function buildMultiSelectCopyText() {
534
- var n = selectedEls.length;
535
- return selectedEls.map(function (el, i) {
536
- var body = buildLLMClipboard(buildInfo(el));
537
- var note = noteMap.get(el);
538
- var header = n > 1 ? '[Specter ' + (i + 1) + '/' + n + ']' : '[Specter]';
539
- if (note) header += '\\n\u270F\uFE0F CHANGE: ' + note;
540
- return body.replace('[Specter]', header);
541
- }).join('\\n\\n' + Array(41).join('\u2500') + '\\n\\n');
634
+ // Copy every Spec \u2014 using each Spec's body snapshotted at mark time, so Specs
635
+ // whose element is currently hidden (e.g. inside a closed modal) still copy.
636
+ function buildSpecsCopyText() {
637
+ var n = specs.length;
638
+ return specs.map(function (spec, i) {
639
+ var tag = spec.kind === 'measure' ? 'Specter Measure' : 'Specter';
640
+ var header = n > 1 ? '[' + tag + ' ' + (i + 1) + '/' + n + ']' : '[' + tag + ']';
641
+ if (spec.note) header += '\\n\u270F\uFE0F CHANGE: ' + spec.note;
642
+ return spec.body.replace(/^\\[Specter[^\\]]*\\]/, function () { return header; });
643
+ }).join('\\n\\n---\\n\\n');
542
644
  }
543
645
 
544
646
  function linesToHTML(str) {
@@ -587,235 +689,819 @@ function getClientScript(options) {
587
689
  positionTooltip(lastMouse.x, lastMouse.y);
588
690
  }
589
691
 
590
- // \u2500\u2500\u2500 Multi-select \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
591
- function makeBadge(el, index, note) {
592
- var rect = el.getBoundingClientRect();
593
- var wrap = document.createElement('div');
594
- Object.assign(wrap.style, {
595
- position: 'fixed',
596
- left: (rect.left - 4) + 'px',
597
- top: (rect.top - 4) + 'px',
598
- zIndex: '2147483644',
599
- pointerEvents: 'none',
600
- display: 'flex',
601
- alignItems: 'flex-start',
602
- gap: '4px',
603
- });
604
- var badge = document.createElement('div');
605
- badge.textContent = String(index + 1);
606
- Object.assign(badge.style, {
607
- width: '20px',
608
- height: '20px',
609
- flexShrink: '0',
610
- background: PURPLE,
611
- color: '#fff',
612
- fontSize: '11px',
613
- fontWeight: '700',
614
- fontFamily: MONO,
615
- borderRadius: '50%',
616
- display: 'flex',
617
- alignItems: 'center',
618
- justifyContent: 'center',
619
- boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
620
- });
621
- wrap.appendChild(badge);
622
- if (note) {
623
- var chip = document.createElement('div');
624
- chip.textContent = '\u270F\uFE0F ' + (note.length > 32 ? note.slice(0, 32) + '\u2026' : note);
625
- Object.assign(chip.style, {
626
- maxWidth: '240px',
627
- background: TIP_BG,
628
- color: '#fff',
629
- fontSize: '10px',
630
- lineHeight: '16px',
631
- fontFamily: MONO,
632
- padding: '2px 6px',
633
- borderRadius: '4px',
634
- whiteSpace: 'nowrap',
635
- overflow: 'hidden',
636
- textOverflow: 'ellipsis',
637
- border: '1px solid ' + PURPLE,
638
- boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
639
- });
640
- wrap.appendChild(chip);
692
+ // \u2500\u2500\u2500 Specs (marks: pick + optional annotation) \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
693
+ // A stable-ish CSS path so a Spec can re-find its element if the DOM node is
694
+ // rebuilt (e.g. a modal that recreates its contents on reopen).
695
+ function elementPath(el) {
696
+ if (!el || el.nodeType !== 1) return '';
697
+ var parts = [], cur = el;
698
+ while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
699
+ if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
700
+ var seg = cur.tagName.toLowerCase();
701
+ var parent = cur.parentElement;
702
+ if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
703
+ parts.unshift(seg);
704
+ cur = cur.parentElement;
641
705
  }
642
- document.body.appendChild(wrap);
643
- return wrap;
706
+ return parts.join(' > ');
707
+ }
708
+
709
+ function safeQuery(sel) { try { return sel ? document.querySelector(sel) : null; } catch (e) { return null; } }
710
+
711
+ function isVisible(el) {
712
+ if (!el || !el.isConnected) return false;
713
+ if (el.checkVisibility) { try { if (!el.checkVisibility()) return false; } catch (e) {} }
714
+ var r = el.getBoundingClientRect();
715
+ return r.width > 0 || r.height > 0;
716
+ }
717
+
718
+ // Is the element's center covered by something else (e.g. a modal overlay)?
719
+ // Uses elementsFromPoint and skips Specter's own UI so a badge over the point
720
+ // doesn't count as occluding its own element.
721
+ function isOccluded(el, r) {
722
+ var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
723
+ if (cx < 0 || cy < 0 || cx > window.innerWidth || cy > window.innerHeight) return true;
724
+ var stack = document.elementsFromPoint(cx, cy);
725
+ var top = null;
726
+ for (var i = 0; i < stack.length; i++) { if (!isUI(stack[i])) { top = stack[i]; break; } }
727
+ if (!top) return true;
728
+ return !(top === el || el.contains(top) || top.contains(el));
729
+ }
730
+
731
+ // Place one badge: wrap at the element's anchor (left/top follows scroll, no
732
+ // transition), offset applied via transform (transitions smoothly). The expand
733
+ // flag grows the circle into its note capsule. A panel-highlighted badge pops to
734
+ // the anchor, enlarges, and rises on top.
735
+ function layoutBadge(s, bx, by, dx, dy, z, expand) {
736
+ var hi = (s === highlightSpec);
737
+ s.wrap.style.display = 'block';
738
+ s.wrap.style.left = bx + 'px';
739
+ s.wrap.style.top = by + 'px';
740
+ s.wrap.style.transform = hi ? 'translate(0px,0px)' : ('translate(' + dx + 'px,' + dy + 'px)');
741
+ s.wrap.style.zIndex = hi ? '2147483645' : String(z);
742
+ // Panel-row hover enlarges the circle (scale 1.5 \u2192 30px). Page hover expands the
743
+ // capsule to the SAME 30px height (taller, matching that enlarged size) + bigger text.
744
+ s.cap.style.transform = hi ? 'scale(1.5)' : '';
745
+ s.cap.style.maxWidth = expand ? '320px' : '20px';
746
+ s.cap.style.height = expand ? '30px' : '20px';
747
+ s.num.style.fontSize = expand ? '14px' : '12px';
748
+ s.num.style.width = expand ? '24px' : '16px';
749
+ s.noteSpan.style.fontSize = expand ? '13px' : '12px';
750
+ }
751
+
752
+ // The badge whose wrap sits under the cursor right now (topmost). Drives which
753
+ // single badge is expanded \u2014 never more than one.
754
+ function badgeUnderCursor(mx, my) {
755
+ var at = document.elementFromPoint(mx, my);
756
+ if (!at) return null;
757
+ var w = at.closest('[data-specter-ui]');
758
+ return (w && w.__spec) ? w.__spec : null;
759
+ }
760
+
761
+ // Position every Spec badge on its element each frame \u2014 follows scroll/layout,
762
+ // hides when the element is hidden or removed (closed modal), reappears when it
763
+ // returns (re-found by CSS path if the node was rebuilt). Badges that land on the
764
+ // same spot are clustered: shown side-by-side (latest centered, the previous two
765
+ // tucked half-behind on either side), and fanned into a full row on hover so any
766
+ // one is reachable. Only the badge under the cursor expands to show its note.
767
+ function reflowSpecs() {
768
+ var mx = lastMouse.x, my = lastMouse.y;
769
+ var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
770
+
771
+ // Pass 1 \u2014 resolve visibility + base anchor for each spec.
772
+ var vis = [];
773
+ for (var i = 0; i < specs.length; i++) {
774
+ var s = specs[i];
775
+ if (!fiActive) { s.wrap.style.display = 'none'; continue; }
776
+ if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
777
+ if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
778
+ var r = s.el.getBoundingClientRect();
779
+ if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered (e.g. behind a modal)
780
+ s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
781
+ vis.push(s);
782
+ }
783
+
784
+ // Pass 2 \u2014 cluster badges whose anchors coincide (within ~16px).
785
+ var clusters = [];
786
+ for (var a = 0; a < vis.length; a++) {
787
+ var sp = vis[a], placed = false;
788
+ for (var c = 0; c < clusters.length; c++) {
789
+ var m0 = clusters[c][0];
790
+ if (Math.abs(sp._bx - m0._bx) < 16 && Math.abs(sp._by - m0._by) < 16) { clusters[c].push(sp); placed = true; break; }
791
+ }
792
+ if (!placed) clusters.push([sp]);
793
+ }
794
+
795
+ // Pass 3 \u2014 lay out each cluster.
796
+ var TOP = 2147483644, MID = 2147483643, HIDE = 2147483640;
797
+ for (var ci = 0; ci < clusters.length; ci++) {
798
+ var members = clusters[ci];
799
+ var bx = members[0]._bx, by = members[0]._by;
800
+ if (members.length === 1) { layoutBadge(members[0], bx, by, 0, 0, TOP, members[0] === hoverBadge); continue; }
801
+
802
+ var n = members.length;
803
+ var key = members.map(function (m) { return specs.indexOf(m); }).sort(function (x, y) { return x - y; }).join(':');
804
+ var wasHover = !!groupHover[key];
805
+ var spreadDown = by < window.innerHeight - n * 30; // room below? else fan upward
806
+
807
+ // Hysteresis: while fanned, test the tall column bbox; while stacked, the small side-by-side bbox.
808
+ // Fan-out is a vertical column so an expanded badge grows rightward into empty
809
+ // space and never covers its siblings (which sit above/below it).
810
+ var hovered;
811
+ if (wasHover) {
812
+ var colTop = spreadDown ? by : by - (n - 1) * 26;
813
+ hovered = mx >= bx - 8 && mx <= bx + 330 && my >= colTop - 8 && my <= colTop + (n - 1) * 26 + 34;
814
+ } else {
815
+ hovered = mx >= bx - 14 && mx <= bx + 46 && my >= by - 8 && my <= by + 40;
816
+ }
817
+ groupHover[key] = hovered;
818
+
819
+ for (var mi = 0; mi < n; mi++) {
820
+ var mem = members[mi];
821
+ if (hovered) {
822
+ // Fanned: vertical column, creation order, only the cursor's badge expands + rises.
823
+ var isHov = (mem === hoverBadge);
824
+ layoutBadge(mem, bx, by, 0, (spreadDown ? 1 : -1) * mi * 26, isHov ? TOP : MID, isHov);
825
+ } else {
826
+ // Stacked: latest centered on top; the two before it peek out half-visible.
827
+ var rank = (n - 1) - mi; // 0 = latest
828
+ if (rank === 0) layoutBadge(mem, bx, by, 0, 0, TOP, false);
829
+ else if (rank === 1) layoutBadge(mem, bx, by, -11, 0, MID, false);
830
+ else if (rank === 2) layoutBadge(mem, bx, by, 11, 0, MID, false);
831
+ else layoutBadge(mem, bx, by, 0, 0, HIDE, false); // extra ones hide behind center
832
+ }
833
+ }
834
+ }
835
+ }
836
+
837
+ function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
838
+ function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
839
+
840
+ function renumber() { for (var i = 0; i < specs.length; i++) specs[i].num.textContent = String(i + 1); }
841
+
842
+ function updateBadgeContent(spec) {
843
+ spec.num.textContent = String(specs.indexOf(spec) + 1);
844
+ if (spec.note) spec.noteSpan.textContent = '\u270F\uFE0F ' + spec.note;
845
+ else spec.noteSpan.textContent = spec.kind === 'measure' ? '\u2B21 measure' : '';
644
846
  }
645
847
 
646
- function rebuildBadges() {
647
- selectedBadges.forEach(function (b) { b.remove(); });
648
- selectedBadges.length = 0;
649
- selectedEls.forEach(function (el, i) {
650
- selectedBadges.push(makeBadge(el, i, noteMap.get(el)));
848
+ // A Spec badge: a circle showing its number that expands (capsule morph) on
849
+ // hover to preview its note, and opens the editor when clicked.
850
+ function createBadge(spec) {
851
+ // wrap carries transparent padding \u2192 a larger hover boundary so reaching the
852
+ // trash icon doesn't require pixel-precise aim. reflow offsets for it.
853
+ var wrap = markUI(document.createElement('div'));
854
+ Object.assign(wrap.style, { position: 'fixed', zIndex: '2147483644', display: 'none', padding: '6px', transition: 'transform 0.15s ease' });
855
+ var cap2 = document.createElement('div');
856
+ Object.assign(cap2.style, {
857
+ display: 'inline-flex', alignItems: 'center', height: '20px',
858
+ maxWidth: '20px', overflow: 'hidden', background: PURPLE, color: '#fff',
859
+ border: '2px solid #fff', boxSizing: 'border-box',
860
+ borderRadius: '999px', fontFamily: MONO, whiteSpace: 'nowrap',
861
+ boxShadow: '0 2px 6px rgba(0,0,0,0.35)', cursor: 'pointer', userSelect: 'none',
862
+ transition: 'max-width 0.2s ease, height 0.15s ease, transform 0.15s ease',
863
+ transformOrigin: '10px 10px',
651
864
  });
865
+ var num = document.createElement('span');
866
+ Object.assign(num.style, { width: '16px', flexShrink: '0', textAlign: 'center', fontSize: '12px', fontWeight: '700', lineHeight: '16px' });
867
+ var noteSpan = document.createElement('span');
868
+ Object.assign(noteSpan.style, { fontSize: '12px', paddingLeft: '3px', maxWidth: '260px', overflow: 'hidden', textOverflow: 'ellipsis' });
869
+ var trash = document.createElement('span');
870
+ trash.innerHTML = TRASH;
871
+ trash.title = 'Delete this Spec';
872
+ Object.assign(trash.style, { display: 'flex', alignItems: 'center', flexShrink: '0', padding: '0 8px 0 6px', color: '#F3B0C0', cursor: 'pointer' });
873
+ cap2.appendChild(num);
874
+ cap2.appendChild(noteSpan);
875
+ cap2.appendChild(trash);
876
+ wrap.appendChild(cap2);
877
+ document.body.appendChild(wrap);
878
+ wrap.__spec = spec; // so reflow's cursor hit-test can find which Spec a wrap is
879
+ spec.wrap = wrap; spec.num = num; spec.noteSpan = noteSpan; spec.cap = cap2;
880
+
881
+ // Expansion (circle \u2192 note capsule) is driven per-frame in reflowSpecs from the
882
+ // cursor's exact target, so only the badge under the cursor ever expands.
883
+ hoverFx(trash, { color: '#fff', transform: 'scale(1.15)' }, { color: '#F3B0C0', transform: 'scale(1)' });
884
+ trash.addEventListener('click', function (e) { e.stopPropagation(); removeSpec(spec); });
885
+ cap2.addEventListener('click', function (e) { e.stopPropagation(); openSpecEditor(spec); });
886
+ updateBadgeContent(spec);
887
+ }
888
+
889
+ function findElementSpec(el) {
890
+ for (var i = 0; i < specs.length; i++) if (specs[i].kind === 'element' && specs[i].el === el) return specs[i];
891
+ return null;
892
+ }
893
+
894
+ // If the element sits inside a dialog/modal/drawer/menu, produce a hint for how to
895
+ // bring it back when it's later hidden \u2014 a declarative opener (aria-controls /
896
+ // data-*target) if the page has one, else the control the user just clicked (the
897
+ // likely opener), else "reopen the <container>". Empty when there's no such container.
898
+ function cssEsc(s) { try { return CSS.escape(s); } catch (e) { return s; } }
899
+ function computeLocateHint(el) {
900
+ if (!el) return '';
901
+ var re = /(modal|dialog|drawer|sheet|popover|offcanvas|overlay|lightbox|menu|dropdown|flyout|popup|tooltip)/i;
902
+ var cur = el, container = null;
903
+ while (cur && cur !== document.body) {
904
+ var role = cur.getAttribute ? cur.getAttribute('role') : null;
905
+ if ((cur.hasAttribute && cur.hasAttribute('aria-modal')) || role === 'dialog' || role === 'menu' ||
906
+ re.test(cur.className || '') || re.test(cur.id || '')) { container = cur; break; }
907
+ cur = cur.parentElement;
908
+ }
909
+ if (!container) return '';
910
+ var tag = (container.className || '') + ' ' + (container.id || '');
911
+ var type = /drawer|offcanvas|sheet/i.test(tag) ? 'drawer' : /menu|dropdown|flyout/i.test(tag) ? 'menu' : 'dialog';
912
+ var opener = '';
913
+ if (container.id) {
914
+ var o = safeQuery('[aria-controls="' + cssEsc(container.id) + '"],[data-target="#' + cssEsc(container.id) + '"],[data-bs-target="#' + cssEsc(container.id) + '"],[data-modal-target="' + cssEsc(container.id) + '"]');
915
+ if (o) opener = (o.getAttribute('aria-label') || o.textContent || '').replace(/\\s+/g, ' ').trim();
916
+ }
917
+ if (!opener && lastClick.label && (Date.now() - lastClick.at) < 120000) opener = lastClick.label;
918
+ if (opener) return 'Open \u201C' + opener.slice(0, 40) + '\u201D to reveal';
919
+ return 'Reopen the ' + type + ' to reveal';
652
920
  }
653
921
 
654
- function toggleSelection(el) {
655
- var idx = selectedEls.indexOf(el);
656
- if (idx >= 0) {
657
- selectedEls.splice(idx, 1);
658
- el.style.outline = '';
659
- noteMap.delete(el);
922
+ // Press P \u2192 create a Spec (+ open its annotation box). Measure mode captures
923
+ // distances instead of properties; a pinned measurement anchors on the pin.
924
+ function markSpec(anchorEl) {
925
+ var kind = 'element', el = anchorEl, body;
926
+ if (measureMode) {
927
+ kind = 'measure';
928
+ if (pinEl && lastHovered && lastHovered !== pinEl) { el = pinEl; body = buildMeasureCopyText(pinEl, lastHovered); }
929
+ else { el = anchorEl; body = buildNeighborCopyText(anchorEl); }
660
930
  } else {
661
- selectedEls.push(el);
662
- el.style.outline = '2px solid ' + PURPLE;
663
- el.style.outlineOffset = '-1px';
931
+ if (findElementSpec(anchorEl)) return; // P over an already-Spec'd element \u2192 nothing
932
+ body = buildLLMClipboard(buildInfo(anchorEl));
664
933
  }
665
- rebuildBadges();
666
- updatePillForSelection();
934
+ clearHoverOutline();
935
+ hideTooltip();
936
+ var spec = { el: el, path: elementPath(el), note: '', body: body, kind: kind, locate: computeLocateHint(el) };
937
+ specs.push(spec);
938
+ createBadge(spec);
939
+ reflowSpecs();
940
+ updatePill();
941
+ saveSpecs();
942
+ openSpecEditor(spec);
667
943
  }
668
944
 
669
- function clearSelection() {
670
- closeNoteInput();
671
- selectedEls.forEach(function (el) { el.style.outline = ''; el.style.outlineOffset = ''; });
672
- selectedEls.length = 0;
673
- selectedBadges.forEach(function (b) { b.remove(); });
674
- selectedBadges.length = 0;
675
- noteMap.clear();
945
+ function removeSpec(spec) {
946
+ var i = specs.indexOf(spec);
947
+ if (i < 0) return;
948
+ if (highlightSpec === spec) highlightSpec = null;
949
+ if (panelEditSpec === spec) panelEditSpec = null;
950
+ specs.splice(i, 1);
951
+ if (spec.wrap) spec.wrap.remove();
952
+ renumber();
953
+ updatePill();
954
+ saveSpecs();
676
955
  }
677
956
 
678
- function updatePillForSelection() {
679
- if (selectedEls.length > 0) expandPill();
680
- else if (!pillWrap.matches(':hover')) collapsePill();
957
+ function removeAllSpecs() {
958
+ commitEditor();
959
+ highlightSpec = null;
960
+ panelEditSpec = null;
961
+ specs.forEach(function (s) { if (s.wrap) s.wrap.remove(); });
962
+ specs.length = 0;
963
+ updatePill();
964
+ saveSpecs();
681
965
  }
682
966
 
683
- // \u2500\u2500\u2500 Annotations (inline change notes) \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\u2500\u2500
684
- function openNoteInput(el) {
685
- closeNoteInput();
686
- if (selectedEls.indexOf(el) < 0) {
687
- clearHoverOutline();
688
- hideTooltip();
689
- selectedEls.push(el);
690
- el.style.outline = '2px solid ' + PURPLE;
691
- el.style.outlineOffset = '-1px';
967
+ // \u2500\u2500\u2500 Reload insurance (localStorage, per-URL, zero network) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
968
+ // Persist only the serializable parts of each Spec; the live element ref and
969
+ // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
970
+ function saveSpecs() {
971
+ try {
972
+ if (!specs.length) localStorage.removeItem(STORAGE_KEY);
973
+ 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 }; })));
974
+ } catch (e) {}
975
+ scheduleSync(); // keep the Claude bridge mirrored to the current Specs
976
+ }
977
+
978
+ function restoreSpecs() {
979
+ var data;
980
+ try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
981
+ if (!Array.isArray(data) || !data.length) return;
982
+ data.forEach(function (d) {
983
+ var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
984
+ specs.push(spec);
985
+ createBadge(spec);
986
+ });
987
+ renumber();
988
+ }
989
+
990
+ // Re-anchor if the node detached, then scroll it into view. Returns false when
991
+ // the element can't be shown (removed / hidden \u2014 e.g. behind a closed modal).
992
+ // The badge enlargement is handled separately via highlightSpec (sustained
993
+ // while the panel row is hovered), so there's no transient ripple to miss.
994
+ function revealSpec(spec) {
995
+ if (!spec.el || !spec.el.isConnected) { var f = safeQuery(spec.path); if (f) spec.el = f; }
996
+ if (!isVisible(spec.el)) return false;
997
+ spec.el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
998
+ return true;
999
+ }
1000
+
1001
+ // "Visible" for the panel = renderable (connected, has a box, not display:none) \u2014
1002
+ // NOT whether it's currently in the viewport. Off-screen is fine (that's what the
1003
+ // hover-scroll is for); only a removed / hidden element (e.g. a closed modal) greys out.
1004
+ function specVisible(spec) {
1005
+ if (!spec.el || !spec.el.isConnected) { var f = safeQuery(spec.path); if (f) spec.el = f; }
1006
+ return isVisible(spec.el);
1007
+ }
1008
+
1009
+ // \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
1010
+ var panelWrap = markUI(document.createElement('div'));
1011
+ Object.assign(panelWrap.style, {
1012
+ position: 'fixed', top: '0', right: '0', height: '100vh', width: '320px',
1013
+ maxWidth: '86vw', zIndex: '2147483645', boxSizing: 'border-box',
1014
+ transform: 'translateX(100%)', transition: 'transform 0.22s ease',
1015
+ display: 'flex', flexDirection: 'column',
1016
+ background: TIP_BG, color: '#fff', fontFamily: MONO,
1017
+ borderLeft: '1px solid rgba(173,36,211,0.5)', boxShadow: '-8px 0 24px rgba(0,0,0,0.35)',
1018
+ });
1019
+
1020
+ var panelHead = document.createElement('div');
1021
+ Object.assign(panelHead.style, {
1022
+ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: '0',
1023
+ padding: '16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
1024
+ });
1025
+ var panelTitle = document.createElement('span');
1026
+ Object.assign(panelTitle.style, { fontSize: '13px', fontWeight: '700', letterSpacing: '0.02em' });
1027
+ var panelSpacer = document.createElement('span');
1028
+ panelSpacer.style.flex = '1';
1029
+ var panelClose = document.createElement('span');
1030
+ panelClose.textContent = '\xD7';
1031
+ panelClose.title = 'Close panel (L)';
1032
+ Object.assign(panelClose.style, { cursor: 'pointer', fontSize: '20px', lineHeight: '1', padding: '0 4px', color: '#B9BBC2', flexShrink: '0' });
1033
+ panelClose.addEventListener('click', function (e) { e.stopPropagation(); hidePanel(); });
1034
+ hoverFx(panelClose, { color: '#fff', transform: 'scale(1.2)' }, { color: '#B9BBC2', transform: 'scale(1)' });
1035
+ panelHead.appendChild(panelTitle);
1036
+ panelHead.appendChild(panelSpacer);
1037
+ panelHead.appendChild(panelClose);
1038
+
1039
+ // \u2500\u2500 Batch actions toolbar (row under the title) \u2500\u2500
1040
+ var panelTools = document.createElement('div');
1041
+ Object.assign(panelTools.style, {
1042
+ display: 'flex', gap: '8px', alignItems: 'center', flexShrink: '0',
1043
+ padding: '10px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
1044
+ });
1045
+
1046
+ // Status dot: Specs auto-sync to the Claude bridge; this shows the connection
1047
+ // (\u25CF synced / \u25CB offline). Click to force a re-sync. Only shown when a bridge is set.
1048
+ var syncDot = document.createElement('div');
1049
+ Object.assign(syncDot.style, {
1050
+ display: BRIDGE ? 'flex' : 'none', alignItems: 'center', gap: '7px', flex: '1',
1051
+ fontSize: '11px', color: LABEL, cursor: 'pointer', userSelect: 'none',
1052
+ });
1053
+ syncDot.title = 'Specs auto-sync to Claude \u2014 click to re-sync now';
1054
+ var dot = document.createElement('span');
1055
+ Object.assign(dot.style, { width: '8px', height: '8px', borderRadius: '999px', background: '#6B7280', flexShrink: '0', transition: 'background 0.2s ease' });
1056
+ var dotLabel = document.createElement('span');
1057
+ dotLabel.textContent = 'Bridge offline';
1058
+ syncDot.appendChild(dot);
1059
+ syncDot.appendChild(dotLabel);
1060
+ syncDot.addEventListener('click', function (e) { e.stopPropagation(); doSync(); });
1061
+
1062
+ // Icon-only round button with a native tooltip (title). No label reveal \u2014 the
1063
+ // hover-expanding text read as janky, so we let the browser tooltip do it.
1064
+ function iconPill(svg, title, idleColor, accentRGB) {
1065
+ var btn = document.createElement('button');
1066
+ btn.title = title;
1067
+ Object.assign(btn.style, {
1068
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
1069
+ color: idleColor, background: 'transparent', border: '1px solid rgba(' + accentRGB + ',0.5)',
1070
+ borderRadius: '999px', width: '32px', height: '32px', padding: '0', flexShrink: '0',
1071
+ transition: 'background 0.12s ease, color 0.12s ease',
1072
+ });
1073
+ var ic = document.createElement('span'); ic.innerHTML = svg;
1074
+ Object.assign(ic.style, { display: 'flex', alignItems: 'center' });
1075
+ btn.appendChild(ic);
1076
+ btn.addEventListener('mouseenter', function () { btn.style.background = 'rgba(' + accentRGB + ',0.16)'; btn.style.color = '#fff'; });
1077
+ btn.addEventListener('mouseleave', function () { btn.style.background = 'transparent'; btn.style.color = idleColor; });
1078
+ return { btn: btn, icon: ic };
1079
+ }
1080
+
1081
+ // Copy all \u2192 clipboard. Icon flashes to a check on success.
1082
+ var copyAll = iconPill(COPY, 'Copy all', '#E0A3F5', '224,163,245');
1083
+ copyAll.btn.addEventListener('click', function (e) {
1084
+ e.stopPropagation();
1085
+ if (!specs.length) return;
1086
+ navigator.clipboard.writeText(buildSpecsCopyText()).then(function () {
1087
+ copyAll.icon.innerHTML = CHECK; copyAll.btn.style.color = GREEN;
1088
+ setTimeout(function () { copyAll.icon.innerHTML = COPY; copyAll.btn.style.color = copyAll.btn.matches(':hover') ? '#fff' : '#E0A3F5'; }, 1200);
1089
+ }).catch(function () {});
1090
+ });
1091
+
1092
+ // Delete all \u2192 confirm popover \u2192 removeAllSpecs.
1093
+ var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
1094
+ delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
1095
+
1096
+ var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
1097
+ var deleteAllBtn = delAll.btn;
1098
+ if (BRIDGE) panelTools.appendChild(syncDot);
1099
+ else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
1100
+ panelTools.appendChild(copyAllBtn);
1101
+ panelTools.appendChild(deleteAllBtn);
1102
+
1103
+ // \u2500\u2500 "Delete all?" confirmation popover (Specter's own UI) \u2500\u2500
1104
+ var confirmPop = null;
1105
+ function closeConfirm() {
1106
+ if (confirmPop) { confirmPop.remove(); confirmPop = null; document.removeEventListener('mousedown', onConfirmOutside, true); }
1107
+ }
1108
+ function onConfirmOutside(e) { if (confirmPop && !confirmPop.contains(e.target)) closeConfirm(); }
1109
+ function confirmDeleteAll(anchor) {
1110
+ closeConfirm();
1111
+ if (!specs.length) return;
1112
+ var pop = markUI(document.createElement('div'));
1113
+ Object.assign(pop.style, {
1114
+ position: 'fixed', zIndex: '2147483647', boxSizing: 'border-box', width: '230px',
1115
+ background: TIP_BG, border: '1px solid ' + PURPLE, borderRadius: '8px', padding: '14px',
1116
+ boxShadow: '0 6px 20px rgba(0,0,0,0.45)', fontFamily: MONO,
1117
+ });
1118
+ var msg = document.createElement('div');
1119
+ msg.textContent = 'Delete all ' + specs.length + (specs.length === 1 ? ' annotation?' : ' annotations?');
1120
+ Object.assign(msg.style, { fontSize: '12px', lineHeight: '17px', color: '#fff', marginBottom: '12px' });
1121
+ var btnRow = document.createElement('div');
1122
+ Object.assign(btnRow.style, { display: 'flex', gap: '8px', justifyContent: 'flex-end' });
1123
+ var cancel = document.createElement('button');
1124
+ cancel.textContent = 'Cancel';
1125
+ Object.assign(cancel.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#B9BBC2', background: 'transparent', border: 'none', borderRadius: '999px', padding: '6px 10px' });
1126
+ cancel.addEventListener('click', function (e) { e.stopPropagation(); closeConfirm(); });
1127
+ hoverFx(cancel, { color: '#fff' }, { color: '#B9BBC2' });
1128
+ var confirm = document.createElement('button');
1129
+ confirm.textContent = 'Delete all';
1130
+ Object.assign(confirm.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '700', color: '#fff', background: RED, border: 'none', borderRadius: '999px', padding: '6px 12px' });
1131
+ confirm.addEventListener('click', function (e) { e.stopPropagation(); closeConfirm(); removeAllSpecs(); });
1132
+ hoverFx(confirm, { background: '#C42D4E' }, { background: RED });
1133
+ btnRow.appendChild(cancel); btnRow.appendChild(confirm);
1134
+ pop.appendChild(msg); pop.appendChild(btnRow);
1135
+ document.body.appendChild(pop);
1136
+ // Anchor below the delete-all button, right-aligned, clamped to viewport.
1137
+ var r = anchor.getBoundingClientRect();
1138
+ var w = pop.offsetWidth, h = pop.offsetHeight, m = 8;
1139
+ var left = Math.min(Math.max(r.right - w, m), window.innerWidth - w - m);
1140
+ var top = Math.min(r.bottom + 6, window.innerHeight - h - m);
1141
+ pop.style.left = left + 'px'; pop.style.top = top + 'px';
1142
+ confirmPop = pop;
1143
+ setTimeout(function () { document.addEventListener('mousedown', onConfirmOutside, true); }, 0);
1144
+ }
1145
+
1146
+ // Guidance line \u2014 tells the user what to actually DO with their annotations.
1147
+ var panelHint = document.createElement('div');
1148
+ Object.assign(panelHint.style, {
1149
+ display: 'none', fontSize: '11px', lineHeight: '17px', color: LABEL,
1150
+ padding: '10px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
1151
+ });
1152
+ function setHint() {
1153
+ panelHint.textContent = '';
1154
+ if (!specs.length) { panelHint.style.display = 'none'; return; }
1155
+ panelHint.style.display = 'block';
1156
+ var code = function (t) { var s = document.createElement('span'); s.textContent = t; Object.assign(s.style, { color: '#E0A3F5', fontWeight: '700' }); return s; };
1157
+ if (BRIDGE) {
1158
+ panelHint.appendChild(document.createTextNode('Switch to Claude Code and run '));
1159
+ panelHint.appendChild(code('/spectify'));
1160
+ panelHint.appendChild(document.createTextNode(' \u2014 it applies these annotations to your code. You can also edit or delete each one above.'));
1161
+ } else {
1162
+ panelHint.appendChild(document.createTextNode('Press '));
1163
+ panelHint.appendChild(code('Cmd+C'));
1164
+ panelHint.appendChild(document.createTextNode(' (or Copy all) and paste into Claude to apply these annotations. You can also edit or delete each one above.'));
692
1165
  }
693
- noteTargetEl = el;
694
- var rect = el.getBoundingClientRect();
1166
+ }
1167
+
1168
+ var panelList = document.createElement('div');
1169
+ Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden' });
1170
+
1171
+ panelWrap.appendChild(panelHead);
1172
+ panelWrap.appendChild(panelTools);
1173
+ panelWrap.appendChild(panelHint);
1174
+ panelWrap.appendChild(panelList);
1175
+ document.body.appendChild(panelWrap);
1176
+
1177
+ // \u2500\u2500 Auto-sync: mirror the browser's current Specs to the local bridge \u2500\u2500
1178
+ // Debounced so rapid edits/typing collapse into one POST. The bridge replaces
1179
+ // its snapshot for this URL, so it always reflects what's in the panel \u2014 no
1180
+ // button to press; Claude pulls whatever's current when you run /spectify.
1181
+ var syncTimer = null;
1182
+ function setSyncState(s) {
1183
+ if (!BRIDGE) return;
1184
+ if (s === 'syncing') { dot.style.background = '#F59E0B'; dotLabel.textContent = 'Syncing\u2026'; }
1185
+ else if (s === 'synced') { dot.style.background = GREEN; dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1186
+ else { dot.style.background = '#6B7280'; dotLabel.textContent = 'Bridge offline'; }
1187
+ }
1188
+ function doSync() {
1189
+ if (!BRIDGE) return;
1190
+ setSyncState('syncing');
1191
+ var payload = {
1192
+ url: location.href,
1193
+ text: buildSpecsCopyText(),
1194
+ specs: specs.map(function (s, i) {
1195
+ return { num: i + 1, note: s.note || '', kind: s.kind, body: s.body };
1196
+ }),
1197
+ };
1198
+ fetch(BRIDGE + '/specs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
1199
+ .then(function (r) { if (!r.ok) throw 0; return r.json(); })
1200
+ .then(function () { setSyncState('synced'); })
1201
+ .catch(function () { setSyncState('offline'); });
1202
+ }
1203
+ function scheduleSync() {
1204
+ if (!BRIDGE) return;
1205
+ clearTimeout(syncTimer);
1206
+ syncTimer = setTimeout(doSync, 500);
1207
+ }
1208
+
1209
+ function renderPanel() {
1210
+ panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
1211
+ panelTools.style.display = (specs.length || BRIDGE) ? 'flex' : 'none';
1212
+ copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1213
+ deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1214
+ setHint();
1215
+ panelList.textContent = '';
1216
+ if (!specs.length) {
1217
+ var empty = document.createElement('div');
1218
+ empty.textContent = 'No Specs yet \u2014 hover an element and press P.';
1219
+ Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
1220
+ panelList.appendChild(empty);
1221
+ return;
1222
+ }
1223
+ var focusEditor = null, measures = [];
1224
+ specs.forEach(function (spec, i) {
1225
+ var visible = specVisible(spec);
1226
+ var editing = (panelEditSpec === spec);
1227
+ var row = document.createElement('div');
1228
+ Object.assign(row.style, {
1229
+ position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
1230
+ padding: '12px 16px', borderBottom: '1px solid rgba(255,255,255,0.06)',
1231
+ opacity: '1', transition: 'background 0.12s ease',
1232
+ });
1233
+ var badge = document.createElement('span');
1234
+ badge.textContent = String(i + 1);
1235
+ Object.assign(badge.style, {
1236
+ flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1237
+ background: PURPLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1238
+ border: '2px solid #fff', boxSizing: 'border-box',
1239
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1240
+ });
1241
+ var content = document.createElement('div');
1242
+ Object.assign(content.style, { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '6px', paddingRight: '30px' });
1243
+
1244
+ var selName = (spec.el && spec.el.tagName) ? getSelector(spec.el) : (spec.path.split('>').pop() || '').trim();
1245
+ var meta = document.createElement('div');
1246
+ Object.assign(meta.style, { fontSize: '11px', color: LABEL, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' });
1247
+ meta.textContent = selName;
1248
+
1249
+ if (editing) {
1250
+ // \u2500\u2500 UPDATE: inline editor \u2500\u2500
1251
+ var ta = document.createElement('textarea');
1252
+ ta.value = spec.note || '';
1253
+ ta.placeholder = 'Describe the change\u2026';
1254
+ Object.assign(ta.style, {
1255
+ width: '100%', boxSizing: 'border-box', background: 'rgba(255,255,255,0.06)',
1256
+ border: '1px solid ' + PURPLE, borderRadius: '6px', fontFamily: MONO,
1257
+ fontSize: '14px', lineHeight: '20px', padding: '8px', resize: 'none', outline: 'none',
1258
+ overflow: 'hidden', minHeight: '56px',
1259
+ });
1260
+ // Form controls are the one thing arbitrary pages style hard \u2014 force white with
1261
+ // !important (and -webkit-text-fill-color, which otherwise wins over the color).
1262
+ ta.style.setProperty('color', '#fff', 'important');
1263
+ ta.style.setProperty('-webkit-text-fill-color', '#fff', 'important');
1264
+ ta.style.setProperty('caret-color', '#fff', 'important');
1265
+ var grow = function () { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; };
1266
+ ta.addEventListener('input', grow);
1267
+ var saveEdit = function () { spec.note = ta.value.trim(); updateBadgeContent(spec); panelEditSpec = null; saveSpecs(); updatePill(); };
1268
+ var cancelEdit = function () { panelEditSpec = null; renderPanel(); };
1269
+ ta.addEventListener('keydown', function (ev) {
1270
+ ev.stopPropagation();
1271
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); saveEdit(); }
1272
+ else if (ev.key === 'Escape') { ev.preventDefault(); cancelEdit(); }
1273
+ });
1274
+ var editActions = document.createElement('div');
1275
+ Object.assign(editActions.style, { display: 'flex', gap: '8px', alignItems: 'center' });
1276
+ var saveBtn = document.createElement('button');
1277
+ saveBtn.textContent = 'Save';
1278
+ Object.assign(saveBtn.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#fff', background: PURPLE, border: 'none', borderRadius: '999px', padding: '5px 12px' });
1279
+ saveBtn.addEventListener('click', function (e) { e.stopPropagation(); saveEdit(); });
1280
+ hoverFx(saveBtn, { background: '#C13AE0' }, { background: PURPLE });
1281
+ var cancelBtn = document.createElement('button');
1282
+ cancelBtn.textContent = 'Cancel';
1283
+ Object.assign(cancelBtn.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#B9BBC2', background: 'transparent', border: 'none', borderRadius: '999px', padding: '5px 8px' });
1284
+ // mousedown+preventDefault so the textarea's blur-save doesn't beat the cancel
1285
+ cancelBtn.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); cancelEdit(); });
1286
+ hoverFx(cancelBtn, { color: '#fff' }, { color: '#B9BBC2' });
1287
+ editActions.appendChild(saveBtn);
1288
+ editActions.appendChild(cancelBtn);
1289
+ content.appendChild(ta);
1290
+ content.appendChild(meta);
1291
+ content.appendChild(editActions);
1292
+ row.appendChild(badge);
1293
+ row.appendChild(content);
1294
+ focusEditor = function () { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); grow(); };
1295
+ } else {
1296
+ // \u2500\u2500 READ: note (collapsed by default, expandable) \u2500\u2500
1297
+ var expanded = !!spec._expanded;
1298
+ var note = document.createElement('div');
1299
+ // Built UNCLAMPED so its true height is measurable after it's in the DOM;
1300
+ // the clamp is applied in the post-render measure pass below (only when the
1301
+ // note actually overflows two lines \u2014 otherwise no chevron is shown).
1302
+ Object.assign(note.style, {
1303
+ fontSize: '14px', lineHeight: '20px', color: spec.note ? '#fff' : LABEL,
1304
+ overflow: 'hidden', overflowWrap: 'anywhere', whiteSpace: 'pre-wrap',
1305
+ cursor: spec.note ? 'pointer' : 'default',
1306
+ });
1307
+ note.textContent = spec.note || (spec.kind === 'measure' ? '\u2B21 measurement' : '\u2014 no note \u2014');
1308
+
1309
+ // Floated into the top-right corner so they don't reserve note width. On hover
1310
+ // they get a background that fades in from the left, masking the note text behind
1311
+ // them (instead of overlapping it). paddingLeft is the fade gutter.
1312
+ var actions = document.createElement('div');
1313
+ Object.assign(actions.style, { position: 'absolute', top: '10px', right: '14px', display: 'flex', gap: '2px', alignItems: 'center', paddingLeft: '22px', borderRadius: '6px' });
1314
+ var mkIcon = function (svg, title, color, onClick, onHover) {
1315
+ var b = document.createElement('span');
1316
+ b.innerHTML = svg; b.title = title;
1317
+ Object.assign(b.style, { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '24px', height: '24px', borderRadius: '6px', cursor: 'pointer', color: color, flexShrink: '0' });
1318
+ hoverFx(b, onHover || { background: 'rgba(255,255,255,0.14)', color: '#fff' }, { background: 'transparent', color: color });
1319
+ b.addEventListener('click', function (e) { e.stopPropagation(); onClick(); });
1320
+ return b;
1321
+ };
1322
+
1323
+ // Show an expand toggle only when the collapsed note actually overflows.
1324
+ var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
1325
+ chev.firstChild.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
1326
+ var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
1327
+ var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
1328
+ // Edit + delete reveal only on row hover; the expand chevron stays rightmost
1329
+ // and fixed (edit/delete appear to its left, so it never shifts).
1330
+ editBtn.style.display = delBtn.style.display = 'none';
1331
+ actions.appendChild(editBtn);
1332
+ actions.appendChild(delBtn);
1333
+ actions.appendChild(chev);
1334
+
1335
+ content.appendChild(note);
1336
+ content.appendChild(meta);
1337
+
1338
+ // Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
1339
+ // so hidden Specs are findable in a long list instead of silently unreachable.
1340
+ if (!visible) {
1341
+ var hbar = document.createElement('div');
1342
+ Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1343
+ var tag = document.createElement('span');
1344
+ tag.textContent = 'HIDDEN';
1345
+ Object.assign(tag.style, { fontSize: '9px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1346
+ var hint = document.createElement('span');
1347
+ hint.textContent = spec.locate || 'Not on the page right now';
1348
+ Object.assign(hint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
1349
+ hbar.appendChild(tag);
1350
+ hbar.appendChild(hint);
1351
+ content.appendChild(hbar);
1352
+ }
1353
+
1354
+ row.appendChild(badge);
1355
+ row.appendChild(content);
1356
+ row.appendChild(actions);
1357
+
1358
+ note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1359
+ row.addEventListener('mouseenter', function () {
1360
+ row.style.background = 'rgba(255,255,255,0.05)';
1361
+ editBtn.style.display = delBtn.style.display = 'flex';
1362
+ actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1363
+ if (visible) { highlightSpec = spec; revealSpec(spec); }
1364
+ });
1365
+ row.addEventListener('mouseleave', function () {
1366
+ row.style.background = 'transparent';
1367
+ editBtn.style.display = delBtn.style.display = 'none';
1368
+ actions.style.background = 'transparent';
1369
+ if (highlightSpec === spec) highlightSpec = null;
1370
+ });
1371
+
1372
+ if (expanded) chev.style.transform = ''; // note stays unclamped; chevron collapses it
1373
+ else measures.push({ note: note, chev: chev }); // measured after all rows are in the DOM
1374
+ }
1375
+ panelList.appendChild(row);
1376
+ });
695
1377
 
696
- var box = document.createElement('div');
1378
+ // Measure pass \u2014 now that rows are laid out, clamp collapsed notes that overflow
1379
+ // 2 lines and hide the expand chevron on notes that fit.
1380
+ measures.forEach(function (m) {
1381
+ if (m.note.scrollHeight > 42) { // > two 20px lines (+2 slack)
1382
+ m.note.style.display = '-webkit-box';
1383
+ m.note.style.whiteSpace = 'normal';
1384
+ m.note.style.setProperty('-webkit-box-orient', 'vertical');
1385
+ m.note.style.setProperty('-webkit-line-clamp', '2');
1386
+ } else {
1387
+ m.chev.style.display = 'none';
1388
+ }
1389
+ });
1390
+ if (focusEditor) setTimeout(focusEditor, 0);
1391
+ }
1392
+
1393
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1394
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
1395
+ function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1396
+
1397
+ // \u2500\u2500\u2500 Spec editor (annotation box: add / edit / delete) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1398
+ function commitEditor() { if (editorCommit) editorCommit(); }
1399
+ function closeEditorDom() {
1400
+ if (editorEl) { editorEl.remove(); editorEl = null; }
1401
+ editorSpec = null; editorCommit = null;
1402
+ }
1403
+
1404
+ // Open a Spec's annotation box: prefilled with its note, grows as you type,
1405
+ // saves on Enter or blur (click-away), and carries a Delete button.
1406
+ function openSpecEditor(spec) {
1407
+ commitEditor(); // commit whatever editor is already open
1408
+ if (!spec || !spec.el) return;
1409
+ editorSpec = spec;
1410
+ var rect = spec.el.getBoundingClientRect();
1411
+
1412
+ var box = markUI(document.createElement('div'));
697
1413
  Object.assign(box.style, {
698
- position: 'fixed',
699
- zIndex: '2147483647',
700
- display: 'inline-flex',
701
- alignItems: 'flex-start',
702
- gap: '8px',
703
- boxSizing: 'border-box',
704
- background: TIP_BG,
705
- border: '1px solid ' + PURPLE,
706
- borderRadius: '8px',
707
- padding: '11px 12px',
708
- boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
709
- fontFamily: MONO,
1414
+ position: 'fixed', zIndex: '2147483647', display: 'inline-flex',
1415
+ alignItems: 'flex-start', gap: '8px', boxSizing: 'border-box',
1416
+ background: TIP_BG, border: '1px solid ' + PURPLE, borderRadius: '8px',
1417
+ padding: '11px 12px', boxShadow: '0 4px 16px rgba(0,0,0,0.35)', fontFamily: MONO,
710
1418
  });
711
1419
  var pen = document.createElement('span');
712
1420
  pen.innerHTML = PENCIL;
713
- Object.assign(pen.style, {
714
- display: 'flex',
715
- alignItems: 'center',
716
- flexShrink: '0',
717
- marginTop: '2px',
718
- color: '#E0A3F5',
719
- });
1421
+ Object.assign(pen.style, { display: 'flex', alignItems: 'center', flexShrink: '0', marginTop: '2px', color: '#E0A3F5' });
720
1422
  var input = document.createElement('textarea');
721
1423
  input.rows = 1;
722
1424
  input.placeholder = 'Describe the change\u2026 (Enter to save)';
723
- input.value = noteMap.get(el) || '';
1425
+ input.value = spec.note || '';
724
1426
  Object.assign(input.style, {
725
- flexShrink: '0',
726
- background: 'transparent',
727
- border: 'none',
728
- outline: 'none',
729
- resize: 'none',
730
- overflow: 'hidden',
731
- color: '#fff',
732
- fontFamily: MONO,
733
- fontSize: '12px',
734
- lineHeight: '18px',
735
- padding: '0',
736
- margin: '0',
737
- whiteSpace: 'pre-wrap',
738
- overflowWrap: 'anywhere',
1427
+ flexShrink: '0', background: 'transparent', border: 'none', outline: 'none',
1428
+ resize: 'none', overflow: 'hidden', color: '#fff', fontFamily: MONO,
1429
+ fontSize: '12px', lineHeight: '18px', padding: '0', margin: '0',
1430
+ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere',
1431
+ });
1432
+ var del = document.createElement('button');
1433
+ del.innerHTML = TRASH;
1434
+ del.title = 'Delete this Spec';
1435
+ Object.assign(del.style, {
1436
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0',
1437
+ width: '24px', height: '24px', marginTop: '-2px', padding: '0',
1438
+ background: 'transparent', border: 'none', borderRadius: '6px',
1439
+ color: '#ED8FA6', cursor: 'pointer',
739
1440
  });
1441
+ hoverFx(del, { color: '#fff', background: 'rgba(237,62,97,0.35)' }, { color: '#ED8FA6', background: 'transparent' });
740
1442
  // Hidden mirror to measure single-line text width so the field grows as you type.
741
1443
  var meas = document.createElement('span');
742
- Object.assign(meas.style, {
743
- position: 'absolute',
744
- visibility: 'hidden',
745
- whiteSpace: 'pre',
746
- pointerEvents: 'none',
747
- fontFamily: MONO,
748
- fontSize: '12px',
749
- left: '-9999px',
750
- top: '0',
751
- });
1444
+ Object.assign(meas.style, { position: 'absolute', visibility: 'hidden', whiteSpace: 'pre', pointerEvents: 'none', fontFamily: MONO, fontSize: '12px', left: '-9999px', top: '0' });
752
1445
  box.appendChild(pen);
753
1446
  box.appendChild(input);
1447
+ box.appendChild(del);
754
1448
  box.appendChild(meas);
755
1449
  document.body.appendChild(box);
756
- noteInputEl = box;
1450
+ editorEl = box;
757
1451
 
758
1452
  var margin = 8;
759
1453
  function textW(t) { meas.textContent = t; return meas.offsetWidth; }
760
-
761
- // Anchor to the element; the box is repositioned around this as it grows.
762
1454
  var anchorL = rect.left, anchorT = rect.top, anchorB = rect.bottom;
763
1455
 
764
1456
  function sizeAndPosition() {
765
1457
  var vw = window.innerWidth, vh = window.innerHeight;
766
1458
  var maxBoxW = Math.min(560, vw - margin * 2);
767
- var maxTextW = Math.max(120, maxBoxW - 55); // room for icon + gaps + padding
1459
+ var maxTextW = Math.max(120, maxBoxW - 90); // room for pencil + delete + gaps + padding
768
1460
  var placeholderW = textW(input.placeholder);
769
1461
  var minTextW = Math.min(placeholderW, maxTextW);
770
- var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines, never past viewport
771
-
772
- // Width: grow with content up to the max, then wrapping takes over.
1462
+ var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines
773
1463
  var single = textW(input.value || input.placeholder) + 3;
774
1464
  input.style.width = Math.min(Math.max(single, minTextW), maxTextW) + 'px';
775
- // Height: fit wrapped content, scroll only in the extreme case.
776
1465
  input.style.height = 'auto';
777
1466
  var h = input.scrollHeight;
778
1467
  if (h > maxTaH) { input.style.height = maxTaH + 'px'; input.style.overflowY = 'auto'; }
779
1468
  else { input.style.height = h + 'px'; input.style.overflowY = 'hidden'; }
780
-
781
- // Keep the whole box on screen.
782
1469
  var bw = box.offsetWidth, bh = box.offsetHeight;
783
1470
  var left = Math.min(Math.max(anchorL, margin), Math.max(margin, vw - bw - margin));
784
- var top = anchorT - bh - 8; // prefer sitting above the element
785
- if (top < margin) top = anchorB + 8; // otherwise below it
1471
+ var top = anchorT - bh - 8; // prefer above the element
1472
+ if (top < margin) top = anchorB + 8; // otherwise below
786
1473
  if (top + bh > vh - margin) top = Math.max(margin, vh - bh - margin);
787
1474
  box.style.left = left + 'px';
788
1475
  box.style.top = top + 'px';
789
1476
  }
790
1477
  sizeAndPosition();
791
1478
 
1479
+ var done = false;
1480
+ function commit() {
1481
+ if (done) return; done = true;
1482
+ spec.note = input.value.trim();
1483
+ updateBadgeContent(spec);
1484
+ closeEditorDom();
1485
+ updatePill();
1486
+ saveSpecs();
1487
+ }
1488
+ editorCommit = commit;
1489
+
1490
+ // Delete via mousedown+preventDefault so the textarea doesn't blur-save first.
1491
+ del.addEventListener('mousedown', function (ev) {
1492
+ ev.preventDefault(); ev.stopPropagation();
1493
+ done = true; closeEditorDom(); removeSpec(spec);
1494
+ });
792
1495
  input.addEventListener('input', sizeAndPosition);
793
1496
  input.addEventListener('keydown', function (ev) {
794
1497
  ev.stopPropagation();
795
- if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commitNote(input.value); }
796
- else if (ev.key === 'Escape') { ev.preventDefault(); closeNoteInput(); }
1498
+ if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Escape') { ev.preventDefault(); commit(); }
797
1499
  });
1500
+ input.addEventListener('blur', function () { setTimeout(commit, 0); });
798
1501
 
799
- rebuildBadges();
800
- updatePillForSelection();
801
1502
  setTimeout(function () { input.focus(); }, 0);
802
1503
  }
803
1504
 
804
- function commitNote(val) {
805
- val = (val || '').trim();
806
- if (noteTargetEl) {
807
- if (val) noteMap.set(noteTargetEl, val);
808
- else noteMap.delete(noteTargetEl);
809
- }
810
- closeNoteInput();
811
- rebuildBadges();
812
- }
813
-
814
- function closeNoteInput() {
815
- if (noteInputEl) { noteInputEl.remove(); noteInputEl = null; }
816
- noteTargetEl = null;
817
- }
818
-
819
1505
  // \u2500\u2500\u2500 Pin (measure) \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
820
1506
  function setPin(el) {
821
1507
  pinEl = el;
@@ -826,7 +1512,7 @@ function getClientScript(options) {
826
1512
  if (!pinEl) return;
827
1513
  var r = pinEl.getBoundingClientRect();
828
1514
  if (!pinHighlight) {
829
- pinHighlight = document.createElement('div');
1515
+ pinHighlight = markUI(document.createElement('div'));
830
1516
  Object.assign(pinHighlight.style, {
831
1517
  position: 'fixed',
832
1518
  border: '2px dashed ' + RED,
@@ -856,7 +1542,7 @@ function getClientScript(options) {
856
1542
  function showMeasureTargetHL(el) {
857
1543
  clearMeasureTargetHL();
858
1544
  var r = el.getBoundingClientRect();
859
- measureHL = document.createElement('div');
1545
+ measureHL = markUI(document.createElement('div'));
860
1546
  Object.assign(measureHL.style, {
861
1547
  position: 'fixed',
862
1548
  left: r.left + 'px',
@@ -1006,7 +1692,7 @@ function getClientScript(options) {
1006
1692
  function buildNeighborCopyText(el) {
1007
1693
  var tr = el.getBoundingClientRect();
1008
1694
  var dirs = computeNeighbors(el, tr);
1009
- var lines = ['[Specter Measure]', '<' + el.tagName.toLowerCase() + '> ' + Math.round(tr.width) + '\xD7' + Math.round(tr.height), 'selector: ' + getSelector(el)];
1695
+ var lines = ['[Specter Measure]', '<' + el.tagName.toLowerCase() + '> ' + Math.round(tr.width) + '\xD7' + Math.round(tr.height), 'find: ' + resolveLocator(el)];
1010
1696
  ['top', 'right', 'bottom', 'left'].forEach(function (k) {
1011
1697
  if (dirs[k]) lines.push(k + ': ' + Math.round(dirs[k].gap) + 'px (to ' + dirs[k].ctx + ')');
1012
1698
  });
@@ -1070,9 +1756,9 @@ function getClientScript(options) {
1070
1756
 
1071
1757
  var lines = ['[Specter Measure]'];
1072
1758
  lines.push('From: <' + fTag + '>' + (fComp ? ' ' + fComp : '') + ' ' + Math.round(fr.width) + '\xD7' + Math.round(fr.height));
1073
- lines.push(' selector: ' + getSelector(fromEl));
1759
+ lines.push(' find: ' + resolveLocator(fromEl));
1074
1760
  lines.push('To: <' + tTag + '>' + (tComp ? ' ' + tComp : '') + ' ' + Math.round(tr.width) + '\xD7' + Math.round(tr.height));
1075
- lines.push(' selector: ' + getSelector(toEl));
1761
+ lines.push(' find: ' + resolveLocator(toEl));
1076
1762
 
1077
1763
  if (fromContainsTo || toContainsFrom) {
1078
1764
  var outer = fromContainsTo ? fr : tr, inner = fromContainsTo ? tr : fr;
@@ -1110,9 +1796,15 @@ function getClientScript(options) {
1110
1796
  if (!fiActive) return;
1111
1797
  lastMouse.x = e.clientX; lastMouse.y = e.clientY;
1112
1798
  var target = e.target;
1113
- if (!target || target === pillWrap || pillWrap.contains(target)) return;
1114
- if (target === tooltip || tooltip.contains(target)) return;
1115
- if (noteInputEl && (target === noteInputEl || noteInputEl.contains(target))) return;
1799
+ // Never inspect Specter's own UI \u2014 and clear the inspect overlays so the
1800
+ // properties box / measure lines don't block a Spec's hover pill.
1801
+ if (!target || isUI(target)) {
1802
+ hideTooltip();
1803
+ clearMeasureOverlay();
1804
+ clearMeasureTargetHL();
1805
+ clearHoverOutline();
1806
+ return;
1807
+ }
1116
1808
 
1117
1809
  lastHovered = target;
1118
1810
 
@@ -1147,8 +1839,9 @@ function getClientScript(options) {
1147
1839
 
1148
1840
  if (!fiActive) return;
1149
1841
 
1150
- // Note editor open: let the field handle its own keys (Enter/Esc) and native copy/paste.
1151
- if (noteInputEl) return;
1842
+ // Spec editor open (on-page OR panel inline): let the textarea own its keys
1843
+ // (Enter/Esc, native copy, the letter "p") \u2014 never treat them as shortcuts.
1844
+ if (editorEl || panelEditSpec) return;
1152
1845
 
1153
1846
  if (e.key === 'Alt' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
1154
1847
  optionHeld = true;
@@ -1158,8 +1851,8 @@ function getClientScript(options) {
1158
1851
  if ((e.metaKey || e.ctrlKey) && e.key === 'c' && !e.shiftKey && !e.altKey) {
1159
1852
  e.preventDefault();
1160
1853
  var text;
1161
- if (selectedEls.length > 0) {
1162
- text = buildMultiSelectCopyText();
1854
+ if (specs.length > 0) {
1855
+ text = buildSpecsCopyText();
1163
1856
  } else if (measureMode && pinEl && lastHovered && lastHovered !== pinEl) {
1164
1857
  text = buildMeasureCopyText(pinEl, lastHovered);
1165
1858
  } else if (measureMode && lastHovered) {
@@ -1173,16 +1866,13 @@ function getClientScript(options) {
1173
1866
 
1174
1867
  if (isInputFocused()) return;
1175
1868
 
1869
+ // P \u2014 the single mark/annotate key (empty note = a plain mark).
1870
+ // preventDefault so the "p" keystroke can't leak into the note box that
1871
+ // markSpec is about to focus (was an intermittent stray-"p" race).
1176
1872
  if (e.key === 'p' || e.key === 'P') {
1177
1873
  if (!lastHovered) return;
1178
- clearHoverOutline();
1179
- toggleSelection(lastHovered);
1180
- return;
1181
- }
1182
-
1183
- if (e.key === 'n' || e.key === 'N') {
1184
- if (!lastHovered) return;
1185
- openNoteInput(lastHovered);
1874
+ e.preventDefault();
1875
+ markSpec(lastHovered);
1186
1876
  return;
1187
1877
  }
1188
1878
 
@@ -1190,24 +1880,25 @@ function getClientScript(options) {
1190
1880
  if (!measureMode || !lastHovered) return;
1191
1881
  if (pinEl) {
1192
1882
  clearPin();
1193
- collapsePill();
1883
+ updatePill();
1194
1884
  } else {
1195
1885
  setPin(lastHovered);
1196
- expandPill('Pinned \xB7 hover another element \xB7 Cmd+C copy \xB7 Esc clear');
1886
+ expandPill('Pinned \xB7 hover another element \xB7 P mark \xB7 Cmd+C copy');
1197
1887
  }
1198
1888
  return;
1199
1889
  }
1200
1890
 
1891
+ // L \u2014 toggle the Specs review panel.
1892
+ if (e.key === 'l' || e.key === 'L') {
1893
+ e.preventDefault();
1894
+ togglePanel();
1895
+ return;
1896
+ }
1897
+
1898
+ // Esc only HIDES the plugin \u2014 Specs persist and return on reactivate.
1201
1899
  if (e.key === 'Escape') {
1202
- if (pinEl || selectedEls.length > 0) {
1203
- clearPin();
1204
- clearSelection();
1205
- clearMeasureOverlay();
1206
- hideTooltip();
1207
- collapsePill();
1208
- } else {
1209
- deactivate();
1210
- }
1900
+ if (panelOpen) { hidePanel(); return; }
1901
+ deactivate();
1211
1902
  return;
1212
1903
  }
1213
1904
  }, true);
@@ -1228,6 +1919,19 @@ function getClientScript(options) {
1228
1919
 
1229
1920
  document.addEventListener('mousemove', onMouseMove, { passive: true });
1230
1921
 
1922
+ // Remember the last interactive control the user clicked (not Specter's own UI).
1923
+ // If they open a modal then annotate inside it, this is the opener \u2014 used to tell
1924
+ // them how to reveal a Spec whose element is later hidden.
1925
+ document.addEventListener('click', function (e) {
1926
+ try {
1927
+ if (isUI(e.target)) return;
1928
+ var b = e.target.closest && e.target.closest('button, a, [role="button"], summary, [type="button"], [type="submit"]');
1929
+ if (!b) return;
1930
+ var t = (b.getAttribute('aria-label') || b.textContent || '').replace(/\\s+/g, ' ').trim();
1931
+ if (t) lastClick = { label: t.slice(0, 40), at: Date.now() };
1932
+ } catch (err) {}
1933
+ }, true);
1934
+
1231
1935
  window.__specterToggle = function() { if (fiActive) deactivate(); else activate(); };
1232
1936
 
1233
1937
  var _rt = (typeof browser !== 'undefined' && browser.runtime) || (typeof chrome !== 'undefined' && chrome.runtime);
@@ -1237,6 +1941,9 @@ function getClientScript(options) {
1237
1941
  });
1238
1942
  }
1239
1943
 
1944
+ restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
1945
+ scheduleSync(); // mirror restored Specs to the Claude bridge on load
1946
+
1240
1947
  console.log('%c\u{1F47B} Specter \u2014 Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
1241
1948
  })();`;
1242
1949
  }