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