vite-plugin-specter 0.4.1 → 0.6.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,11 +17,18 @@
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;
24
30
  var measureMode = false;
31
+ var commentMode = false; // C: hide props/measure overlays for design review — Specs still capture them
25
32
  var pinEl = null;
26
33
  var pinHighlight = null;
27
34
  var lastHovered = null;
@@ -34,14 +41,38 @@
34
41
  var copiedTimer = null;
35
42
  var flashTimer = null;
36
43
  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;
44
+ var lastClick = { label: '', at: 0 }; // last interactive control clicked (likely opener of a modal)
45
+ // Specs = the unified marks (pick + optional annotation). Each:
46
+ // { el, path, note, body, kind:'element'|'measure', wrap, pill, num, noteSpan }
47
+ var specs = [];
48
+ var editorEl = null; // the open Spec-editor box, or null
49
+ var editorSpec = null; // the Spec being edited
50
+ var editorCommit = null; // idempotent commit fn for the open editor
51
+ var rafId = null; // reflow loop handle
52
+ var panelOpen = false; // Specs side panel visible?
53
+ var panelEditSpec = null; // Spec being inline-edited in the panel, or null
54
+ var highlightSpec = null; // Spec whose badge is enlarged (panel-row hover)
55
+ var groupHover = {}; // per-cluster fan-out hover state (keyed by member ids)
56
+ // Per-URL reload insurance: Specs survive an accidental refresh. Zero network —
57
+ // localStorage only, keyed by path so different routes keep separate lists.
58
+ var STORAGE_KEY = '__specter_specs_' + location.pathname;
59
+
60
+ // Tag every Specter-owned node so inspect/hover logic can skip its own UI
61
+ // (no "Specter-ception" — never inspect our own overlays).
62
+ function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
63
+ function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
64
+
65
+ // Attach a hover effect to an interactive icon/button: apply the "on" styles
66
+ // while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
67
+ function hoverFx(el, on, off) {
68
+ 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';
69
+ el.addEventListener('mouseenter', function () { Object.assign(el.style, on); });
70
+ el.addEventListener('mouseleave', function () { Object.assign(el.style, off); });
71
+ }
42
72
 
43
73
  // ─── Tooltip ──────────────────────────────────────────────────────────────
44
74
  var tooltip = document.createElement('div');
75
+ markUI(tooltip);
45
76
  Object.assign(tooltip.style, {
46
77
  position: 'fixed',
47
78
  zIndex: '2147483646',
@@ -61,6 +92,7 @@
61
92
 
62
93
  // Measure overlay
63
94
  var measureOverlay = document.createElement('div');
95
+ markUI(measureOverlay);
64
96
  Object.assign(measureOverlay.style, {
65
97
  position: 'fixed',
66
98
  inset: '0',
@@ -72,6 +104,7 @@
72
104
 
73
105
  // ─── Pill ─────────────────────────────────────────────────────────────────
74
106
  var pillWrap = document.createElement('div');
107
+ markUI(pillWrap);
75
108
  Object.assign(pillWrap.style, {
76
109
  position: 'fixed',
77
110
  bottom: '4px',
@@ -135,6 +168,7 @@
135
168
  cursor: 'pointer',
136
169
  });
137
170
  closeEl.addEventListener('click', function (e) { e.stopPropagation(); deactivate(); });
171
+ hoverFx(closeEl, { transform: 'scale(1.25)' }, { transform: 'scale(1)' });
138
172
 
139
173
  iconBtn.appendChild(zapEl);
140
174
  iconBtn.appendChild(closeEl);
@@ -142,6 +176,41 @@
142
176
  var pillText = document.createElement('span');
143
177
  Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
144
178
 
179
+ var listBtn = document.createElement('span');
180
+ listBtn.innerHTML = LIST;
181
+ listBtn.title = 'Show Specs panel (L)';
182
+ Object.assign(listBtn.style, {
183
+ display: 'none',
184
+ alignItems: 'center',
185
+ cursor: 'pointer',
186
+ color: '#fff',
187
+ flexShrink: '0',
188
+ padding: '4px 6px',
189
+ marginLeft: '2px',
190
+ borderRadius: '999px',
191
+ background: 'rgba(255,255,255,0.16)',
192
+ });
193
+ listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
194
+ hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
195
+
196
+ var clearBtn = document.createElement('span');
197
+ clearBtn.textContent = '✕ Delete all';
198
+ clearBtn.title = 'Delete all annotations';
199
+ Object.assign(clearBtn.style, {
200
+ display: 'none',
201
+ cursor: 'pointer',
202
+ color: '#fff',
203
+ fontSize: '11px',
204
+ fontWeight: '600',
205
+ flexShrink: '0',
206
+ padding: '2px 8px',
207
+ marginLeft: '2px',
208
+ borderRadius: '999px',
209
+ background: 'rgba(255,255,255,0.16)',
210
+ });
211
+ clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
212
+ hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
213
+
145
214
  var chevron = document.createElement('span');
146
215
  chevron.textContent = '›';
147
216
  chevron.title = 'Move to other side';
@@ -155,13 +224,51 @@
155
224
  opacity: '0.8',
156
225
  });
157
226
  chevron.addEventListener('click', function (e) { e.stopPropagation(); moveSide(); });
227
+ hoverFx(chevron, { opacity: '1', transform: 'scale(1.2)' }, { opacity: '0.8', transform: 'scale(1)' });
228
+
229
+ // Sync status in the control bar (only when a bridge is configured): spinner while
230
+ // syncing, green when synced, red on error — so you always know what's staged.
231
+ var pillSync = document.createElement('span');
232
+ Object.assign(pillSync.style, {
233
+ display: 'none', width: '8px', height: '8px', borderRadius: '999px',
234
+ background: '#6B7280', flexShrink: '0', boxSizing: 'border-box',
235
+ });
236
+ pillSync.title = 'Sync status — click to re-sync';
237
+ pillSync.addEventListener('click', function (e) { e.stopPropagation(); if (BRIDGE) doSync(); });
158
238
 
159
239
  pill.appendChild(iconBtn);
240
+ pill.appendChild(pillSync);
160
241
  pill.appendChild(pillText);
242
+ pill.appendChild(listBtn);
243
+ pill.appendChild(clearBtn);
161
244
  pill.appendChild(chevron);
162
245
  pillWrap.appendChild(pill);
163
246
  document.body.appendChild(pillWrap);
164
247
 
248
+ // Keyframes for the sync spinner (injected once).
249
+ var spinStyle = document.createElement('style');
250
+ spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
251
+ markUI(spinStyle);
252
+ document.head.appendChild(spinStyle);
253
+
254
+ // Shared dot styling for the control-bar AND side-panel sync indicators, so they
255
+ // always match: spinner while syncing, green when synced, red on error.
256
+ function applyDotState(el, state) {
257
+ el.style.boxSizing = 'border-box';
258
+ if (state === 'syncing') {
259
+ Object.assign(el.style, { width: '10px', height: '10px', background: 'transparent', border: '2px solid rgba(255,255,255,0.35)', borderTopColor: '#fff', animation: '__specterSpin 0.6s linear infinite' });
260
+ } else if (state === 'synced') {
261
+ Object.assign(el.style, { width: '8px', height: '8px', background: GREEN, border: 'none', animation: 'none' });
262
+ } else { // offline / error
263
+ Object.assign(el.style, { width: '8px', height: '8px', background: '#F26D6D', border: 'none', animation: 'none' });
264
+ }
265
+ }
266
+ function setPillSync(state) {
267
+ if (!BRIDGE) { pillSync.style.display = 'none'; return; }
268
+ pillSync.style.display = 'inline-block';
269
+ applyDotState(pillSync, state);
270
+ }
271
+
165
272
  pillWrap.addEventListener('mouseenter', function () {
166
273
  clearMeasureOverlay();
167
274
  clearMeasureTargetHL();
@@ -172,7 +279,7 @@
172
279
  closeEl.style.opacity = '1';
173
280
  });
174
281
  pillWrap.addEventListener('mouseleave', function () {
175
- if (selectedEls.length === 0 && !pinEl) collapsePill();
282
+ if (specs.length === 0 && !pinEl) collapsePill();
176
283
  zapEl.style.transform = 'rotate(0deg)';
177
284
  zapEl.style.opacity = '1';
178
285
  closeEl.style.opacity = '0';
@@ -191,54 +298,74 @@
191
298
  }
192
299
  }
193
300
 
301
+ // The mode is shown at all times (persistent prefix), so you always know whether
302
+ // hovering shows properties, measurements, or nothing (Comment).
303
+ function modeLabel() {
304
+ return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
305
+ }
306
+
194
307
  function expandPill(text) {
195
- pill.style.maxWidth = '760px';
308
+ pill.style.maxWidth = '820px';
196
309
  pillText.style.display = 'inline';
197
310
  chevron.style.display = 'inline';
311
+ listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
312
+ clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
198
313
  pillExpanded = true;
199
- 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';
202
- } else if (measureMode) {
203
- pillText.textContent = 'Measure · hover distances · M pin · Cmd+C copy · Option toggle';
204
- } else {
205
- pillText.textContent = 'Properties · hover · P pick · N annotate · Cmd+C copy · Option measure';
206
- }
314
+ // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
315
+ // side panel now, so the pill stays short.
316
+ pillText.textContent = text || modeLabel();
207
317
  }
208
318
 
319
+ // "Collapsed" = the compact resting state: mode label only, no hints/buttons.
320
+ // Still shows the mode so it's visible at all times.
209
321
  function collapsePill() {
210
- pill.style.maxWidth = '32px';
211
- pillText.style.display = 'none';
322
+ pillText.textContent = modeLabel();
323
+ pillText.style.display = 'inline';
324
+ pill.style.maxWidth = '220px';
212
325
  chevron.style.display = 'none';
326
+ listBtn.style.display = 'none';
327
+ clearBtn.style.display = 'none';
213
328
  pillExpanded = false;
214
329
  }
215
330
 
216
331
  function flashMode() {
217
332
  if (pillExpanded && pillWrap.matches(':hover')) return;
218
- var text = measureMode ? 'Measure mode' : 'Properties mode';
333
+ var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
219
334
  expandPill(text);
220
335
  clearTimeout(flashTimer);
221
336
  flashTimer = setTimeout(function () {
222
- if (!pillWrap.matches(':hover') && selectedEls.length === 0 && !pinEl) collapsePill();
337
+ if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
223
338
  else expandPill();
224
339
  }, 1200);
225
340
  }
226
341
 
342
+ // Show/collapse the pill based on whether any Specs exist.
343
+ function updatePill() {
344
+ if (specs.length > 0) expandPill();
345
+ else if (!pillWrap.matches(':hover')) collapsePill();
346
+ if (panelOpen) renderPanel();
347
+ }
348
+
227
349
  // ─── Activate / Deactivate ────────────────────────────────────────────────
350
+ // Esc / toggle only HIDE the plugin — Specs persist and reappear on reactivate.
228
351
  function activate() {
229
352
  fiActive = true;
230
353
  measureMode = false;
354
+ commentMode = false;
231
355
  pillWrap.style.display = 'block';
232
356
  document.body.style.cursor = 'crosshair';
233
- collapsePill();
357
+ updatePill();
358
+ startLoop();
359
+ reflowSpecs();
360
+ if (BRIDGE) doSync(); // resolve the control-bar sync dot (green if reachable, red if not)
234
361
  }
235
362
 
236
363
  function deactivate() {
364
+ commitEditor();
237
365
  fiActive = false;
238
366
  measureMode = false;
239
367
  optionHeld = false;
240
368
  lastHovered = null;
241
- clearSelection();
242
369
  clearPin();
243
370
  clearHoverOutline();
244
371
  clearMeasureTargetHL();
@@ -246,6 +373,9 @@
246
373
  document.body.style.cursor = '';
247
374
  hideTooltip();
248
375
  clearMeasureOverlay();
376
+ hidePanel();
377
+ stopLoop();
378
+ reflowSpecs(); // hides all Spec badges while inactive (data kept)
249
379
  }
250
380
 
251
381
  // ─── Helpers ──────────────────────────────────────────────────────────────
@@ -322,81 +452,6 @@
322
452
  return null;
323
453
  }
324
454
 
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
455
  function getSelector(el) {
401
456
  var parts = [];
402
457
  var cur = el;
@@ -418,12 +473,90 @@
418
473
  return parts.join(' > ');
419
474
  }
420
475
 
476
+ // ─── Layer A locator ──────────────────────────────────────────────────────────
477
+ // The single most greppable anchor for an element, so Claude jumps straight to the
478
+ // source instead of searching: a unique test-id → stable id → aria/name → short
479
+ // visible text → a unique class, falling back to the CSS path. Any stack, no build
480
+ // step, one line — fewer agent tool-calls + more accurate edits (the core tenet).
481
+ function uniqueSel(sel) { try { return !!sel && document.querySelectorAll(sel).length === 1; } catch (e) { return false; } }
482
+ 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); }
483
+ function ownText(el) {
484
+ if (el.children && el.children.length > 2) return '';
485
+ var t = (el.textContent || '').replace(/\s+/g, ' ').trim();
486
+ return (t.length >= 2 && t.length <= 80) ? t : '';
487
+ }
488
+ function ownClass(el) {
489
+ 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); });
490
+ for (var i = 0; i < cls.length; i++) { if (uniqueSel('.' + cssEsc(cls[i]))) return cls[i]; }
491
+ return '';
492
+ }
493
+ // A 'text "…"' anchor is only safe if that text isn't ALSO the own-text of a
494
+ // DIFFERENT element (an ancestor/descendant sharing it = the same source spot,
495
+ // so those don't count). Prevents an ambiguous grep (e.g. a button and a modal
496
+ // heading both reading "Book a demo").
497
+ function uniqueTextAnchor(el, txt) {
498
+ var all = document.getElementsByTagName('*');
499
+ for (var i = 0; i < all.length; i++) {
500
+ var o = all[i];
501
+ if (o === el || el.contains(o) || o.contains(el)) continue;
502
+ if (ownText(o) === txt) return false;
503
+ }
504
+ return true;
505
+ }
506
+ // Which of {color, background} are altered by a :hover/:active/:focus rule that
507
+ // matches this element — so a value read WHILE the cursor is on it (its computed
508
+ // style is the hovered state) can be flagged instead of reported as the resting
509
+ // value. Detect-only: recovering the resting value would need a cascade parser.
510
+ var STATE_PSEUDO = /:(hover|active|focus|focus-visible|focus-within)\b/g;
511
+ function hoverAffected(el) {
512
+ var out = {};
513
+ var sheets = document.styleSheets;
514
+ for (var i = 0; i < sheets.length; i++) {
515
+ var rules;
516
+ try { rules = sheets[i].cssRules; } catch (e) { continue; } // cross-origin sheet
517
+ if (!rules) continue;
518
+ for (var j = 0; j < rules.length; j++) {
519
+ var r = rules[j];
520
+ if (!r.selectorText || r.selectorText.indexOf(':') < 0) continue;
521
+ STATE_PSEUDO.lastIndex = 0;
522
+ if (!STATE_PSEUDO.test(r.selectorText)) continue;
523
+ var sels = r.selectorText.split(',');
524
+ for (var k = 0; k < sels.length; k++) {
525
+ STATE_PSEUDO.lastIndex = 0;
526
+ var base = sels[k].replace(STATE_PSEUDO, '').trim();
527
+ if (!base) continue;
528
+ var m = false;
529
+ try { m = el.matches(base); } catch (e) { continue; }
530
+ if (!m) continue;
531
+ if (r.style.color) out.color = true;
532
+ if (r.style.background || r.style.backgroundColor) out.bg = true;
533
+ }
534
+ }
535
+ }
536
+ return out;
537
+ }
538
+ function resolveLocator(el) {
539
+ if (!el || el.nodeType !== 1) return '';
540
+ var i, v;
541
+ var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
542
+ 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; } }
543
+ if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
544
+ var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
545
+ 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) + '"'; } }
546
+ var txt = ownText(el);
547
+ if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '…' : '') + '"';
548
+ var cls = ownClass(el);
549
+ if (cls) return '.' + cls;
550
+ return getSelector(el); // fallback: the CSS path
551
+ }
552
+
421
553
  // ─── Structured data model ──────────────────────────────────────────────────
422
554
  function buildInfo(el) {
423
555
  var cs = getComputedStyle(el);
424
556
  var rect = el.getBoundingClientRect();
557
+ var hv = hoverAffected(el);
425
558
  var ff = cs.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
426
- var raw = el.textContent ? el.textContent.trim() : '';
559
+ var raw = el.textContent ? el.textContent.replace(/\s+/g, ' ').trim() : '';
427
560
  return {
428
561
  el: el,
429
562
  tag: el.tagName.toLowerCase(),
@@ -438,13 +571,14 @@
438
571
  font: { family: ff, weight: cs.fontWeight, size: cs.fontSize, lineHeight: cs.lineHeight },
439
572
  color: colorObj(cs.color),
440
573
  bg: colorObj(cs.backgroundColor),
574
+ colorHover: !!hv.color,
575
+ bgHover: !!hv.bg,
441
576
  padding: edge(cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft),
442
577
  margin: edge(cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft),
443
578
  radius: (cs.borderRadius && cs.borderRadius !== '0px') ? cs.borderRadius : null,
444
579
  display: ['flex', 'grid', 'inline-flex', 'inline-grid'].indexOf(cs.display) >= 0 ? cs.display : null,
445
580
  gap: (cs.gap && cs.gap !== 'normal') ? cs.gap : null,
446
581
  flexDir: (cs.display.indexOf('flex') >= 0 && cs.flexDirection !== 'row') ? cs.flexDirection : null,
447
- rules: getMatchingRules(el),
448
582
  };
449
583
  }
450
584
 
@@ -464,8 +598,8 @@
464
598
  if (data.text) h += '<div style="color:' + LABEL + ';font-style:italic;margin-bottom:8px">"' + esc(data.text) + (data.textTrunc ? '…' : '') + '"</div>';
465
599
  h += '<div style="height:8px"></div>';
466
600
  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);
601
+ if (data.color) h += row('Color', swatch(data.color.hex) + data.color.label + (data.colorHover ? ' <span style="color:' + LABEL + '">(hover)</span>' : ''));
602
+ if (data.bg) h += row('Bg', swatch(data.bg.hex) + data.bg.label + (data.bgHover ? ' <span style="color:' + LABEL + '">(hover)</span>' : ''));
469
603
  if (data.padding) h += row('Padding', data.padding.value);
470
604
  if (data.margin) h += row('Margin', data.margin.value);
471
605
  if (data.radius) h += row('Radius', data.radius);
@@ -478,49 +612,64 @@
478
612
  return h;
479
613
  }
480
614
 
615
+ // Lean copy: just enough to fix the issue — which element, and its current
616
+ // key values. Deliberately NO full CSS-rule dump (an AI with the repo finds
617
+ // the rule from the selector; one without it can't edit source anyway).
481
618
  function buildLLMClipboard(data) {
482
- var lines = ['[Specter]'];
619
+ // Identity: tag + component/styleKey + text + dims. Classes live in the
620
+ // selector line, so don't repeat them here.
483
621
  var head = '<' + data.tag + '>';
484
- if (data.id) head += '#' + data.id;
485
- if (data.classes.length) head += '.' + data.classes.join('.');
486
622
  if (data.component) head += ' ' + data.component;
487
623
  if (data.styleKey) head += ' [' + data.styleKey + ']';
624
+ if (data.text) head += ' "' + data.text + (data.textTrunc ? '…' : '') + '"';
488
625
  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);
626
+
627
+ // One compact line of the values that could be the target of the change.
628
+ var props = ['font: ' + data.font.family + ' ' + data.font.weight + ' ' + data.font.size + '/' + data.font.lineHeight];
629
+ if (data.color) props.push('color: ' + data.color.label + (data.colorHover ? ' (hover)' : ''));
630
+ if (data.bg) props.push('bg: ' + data.bg.label + (data.bgHover ? ' (hover)' : ''));
631
+ if (data.padding) props.push('padding: ' + data.padding.value);
632
+ if (data.margin) props.push('margin: ' + data.margin.value);
633
+ if (data.radius) props.push('radius: ' + data.radius);
497
634
  if (data.display) {
498
635
  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);
636
+ if (data.gap) d += ' gap ' + data.gap;
637
+ if (data.flexDir) d += ' ' + data.flexDir;
638
+ props.push(d);
502
639
  }
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;
640
+
641
+ return ['[Specter]', head, 'find: ' + resolveLocator(data.el), props.join(' · ')].join('\n');
507
642
  }
508
643
 
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');
644
+ // Group Specs on the SAME element so their identical properties aren't emitted
645
+ // twice several comments on one thing share ONE property block, each with its
646
+ // own change note. Measure Specs always stand alone (each is a distinct reading).
647
+ function groupSpecs() {
648
+ var groups = [];
649
+ for (var i = 0; i < specs.length; i++) {
650
+ var s = specs[i], g = null;
651
+ if (s.kind === 'element' && s.el) {
652
+ for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
653
+ }
654
+ if (g) g.specs.push(s);
655
+ else groups.push({ kind: s.kind, el: s.el, specs: [s] });
656
+ }
657
+ return groups;
518
658
  }
519
659
 
520
- function linesToHTML(str) {
521
- return str.split('\n').map(function (l) {
522
- return '<div style="margin-bottom:4px;color:#fff">' + esc(l) + '</div>';
523
- }).join('');
660
+ // Copy every Spec — using each Spec's body snapshotted at mark time, so Specs
661
+ // whose element is currently hidden (e.g. inside a closed modal) still copy.
662
+ // Same-element Specs collapse into one block (properties once, notes stacked).
663
+ function buildSpecsCopyText() {
664
+ var groups = groupSpecs();
665
+ var n = groups.length;
666
+ return groups.map(function (g, i) {
667
+ var tag = g.kind === 'measure' ? 'Specter Measure' : 'Specter';
668
+ var header = n > 1 ? '[' + tag + ' ' + (i + 1) + '/' + n + ']' : '[' + tag + ']';
669
+ var notes = g.specs.filter(function (s) { return s.note; }).map(function (s) { return '✏️ CHANGE: ' + s.note; });
670
+ if (notes.length) header += '\n' + notes.join('\n');
671
+ return g.specs[0].body.replace(/^\[Specter[^\]]*\]/, function () { return header; });
672
+ }).join('\n\n---\n\n');
524
673
  }
525
674
 
526
675
  // ─── Hover outline ──────────────────────────────────────────────────────────
@@ -554,244 +703,897 @@
554
703
  }
555
704
 
556
705
  function reRenderTooltip() {
706
+ if (commentMode) { hideTooltip(); return; }
557
707
  if (measureMode) {
558
- var text = (pinEl && pinEl !== lastHovered) ? measureBetween(pinEl, lastHovered) : measureToNeighbor(lastHovered);
559
- tooltip.innerHTML = linesToHTML(text);
560
- } else {
561
- tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
708
+ // Redraw the overlay on scroll/resize; readout box stays hidden (see hover handler).
709
+ if (pinEl && pinEl !== lastHovered) measureBetween(pinEl, lastHovered); else measureToNeighbor(lastHovered);
710
+ hideTooltip();
711
+ return;
562
712
  }
713
+ tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
563
714
  positionTooltip(lastMouse.x, lastMouse.y);
564
715
  }
565
716
 
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);
717
+ // ─── Specs (marks: pick + optional annotation) ──────────────────────────────
718
+ // A stable-ish CSS path so a Spec can re-find its element if the DOM node is
719
+ // rebuilt (e.g. a modal that recreates its contents on reopen).
720
+ function elementPath(el) {
721
+ if (!el || el.nodeType !== 1) return '';
722
+ var parts = [], cur = el;
723
+ while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
724
+ if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
725
+ var seg = cur.tagName.toLowerCase();
726
+ var parent = cur.parentElement;
727
+ if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
728
+ parts.unshift(seg);
729
+ cur = cur.parentElement;
617
730
  }
618
- document.body.appendChild(wrap);
619
- return wrap;
731
+ return parts.join(' > ');
620
732
  }
621
733
 
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)));
734
+ function safeQuery(sel) { try { return sel ? document.querySelector(sel) : null; } catch (e) { return null; } }
735
+
736
+ function isVisible(el) {
737
+ if (!el || !el.isConnected) return false;
738
+ if (el.checkVisibility) { try { if (!el.checkVisibility()) return false; } catch (e) {} }
739
+ var r = el.getBoundingClientRect();
740
+ return r.width > 0 || r.height > 0;
741
+ }
742
+
743
+ // Is the element's center covered by something else (e.g. a modal overlay)?
744
+ // Uses elementsFromPoint and skips Specter's own UI so a badge over the point
745
+ // doesn't count as occluding its own element.
746
+ function isOccluded(el, r) {
747
+ var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
748
+ if (cx < 0 || cy < 0 || cx > window.innerWidth || cy > window.innerHeight) return true;
749
+ var stack = document.elementsFromPoint(cx, cy);
750
+ var top = null;
751
+ for (var i = 0; i < stack.length; i++) { if (!isUI(stack[i])) { top = stack[i]; break; } }
752
+ if (!top) return true;
753
+ return !(top === el || el.contains(top) || top.contains(el));
754
+ }
755
+
756
+ // Place one badge: wrap at the element's anchor (left/top follows scroll, no
757
+ // transition), offset applied via transform (transitions smoothly). The expand
758
+ // flag grows the circle into its note capsule. A panel-highlighted badge pops to
759
+ // the anchor, enlarges, and rises on top.
760
+ function layoutBadge(s, bx, by, dx, dy, z, expand) {
761
+ var hi = (s === highlightSpec);
762
+ s.wrap.style.display = 'block';
763
+ s.wrap.style.left = bx + 'px';
764
+ s.wrap.style.top = by + 'px';
765
+ s.wrap.style.transform = hi ? 'translate(0px,0px)' : ('translate(' + dx + 'px,' + dy + 'px)');
766
+ s.wrap.style.zIndex = hi ? '2147483645' : String(z);
767
+ // Panel-row hover enlarges the circle (scale 1.5 → 30px). Page hover expands the
768
+ // capsule to the SAME 30px height (taller, matching that enlarged size) + bigger text.
769
+ s.cap.style.transform = hi ? 'scale(1.5)' : '';
770
+ s.cap.style.maxWidth = expand ? '320px' : '20px';
771
+ s.cap.style.height = expand ? '30px' : '20px';
772
+ s.num.style.fontSize = expand ? '14px' : '12px';
773
+ s.num.style.width = expand ? '24px' : '16px';
774
+ s.noteSpan.style.fontSize = expand ? '13px' : '12px';
775
+ }
776
+
777
+ // The badge whose wrap sits under the cursor right now (topmost). Drives which
778
+ // single badge is expanded — never more than one.
779
+ function badgeUnderCursor(mx, my) {
780
+ var at = document.elementFromPoint(mx, my);
781
+ if (!at) return null;
782
+ var w = at.closest('[data-specter-ui]');
783
+ return (w && w.__spec) ? w.__spec : null;
784
+ }
785
+
786
+ // Position every Spec badge on its element each frame — follows scroll/layout,
787
+ // hides when the element is hidden or removed (closed modal), reappears when it
788
+ // returns (re-found by CSS path if the node was rebuilt). Badges that land on the
789
+ // same spot are clustered: shown side-by-side (latest centered, the previous two
790
+ // tucked half-behind on either side), and fanned into a full row on hover so any
791
+ // one is reachable. Only the badge under the cursor expands to show its note.
792
+ function reflowSpecs() {
793
+ var mx = lastMouse.x, my = lastMouse.y;
794
+ var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
795
+
796
+ // Pass 1 — resolve visibility + base anchor for each spec.
797
+ var vis = [];
798
+ for (var i = 0; i < specs.length; i++) {
799
+ var s = specs[i];
800
+ if (!fiActive) { s.wrap.style.display = 'none'; continue; }
801
+ if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
802
+ if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
803
+ var r = s.el.getBoundingClientRect();
804
+ if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered (e.g. behind a modal)
805
+ s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
806
+ vis.push(s);
807
+ }
808
+
809
+ // Pass 2 — cluster badges whose anchors coincide (within ~16px).
810
+ var clusters = [];
811
+ for (var a = 0; a < vis.length; a++) {
812
+ var sp = vis[a], placed = false;
813
+ for (var c = 0; c < clusters.length; c++) {
814
+ var m0 = clusters[c][0];
815
+ if (Math.abs(sp._bx - m0._bx) < 16 && Math.abs(sp._by - m0._by) < 16) { clusters[c].push(sp); placed = true; break; }
816
+ }
817
+ if (!placed) clusters.push([sp]);
818
+ }
819
+
820
+ // Pass 3 — lay out each cluster.
821
+ var TOP = 2147483644, MID = 2147483643, HIDE = 2147483640;
822
+ for (var ci = 0; ci < clusters.length; ci++) {
823
+ var members = clusters[ci];
824
+ var bx = members[0]._bx, by = members[0]._by;
825
+ if (members.length === 1) { layoutBadge(members[0], bx, by, 0, 0, TOP, members[0] === hoverBadge); continue; }
826
+
827
+ var n = members.length;
828
+ var key = members.map(function (m) { return specs.indexOf(m); }).sort(function (x, y) { return x - y; }).join(':');
829
+ var wasHover = !!groupHover[key];
830
+ var spreadDown = by < window.innerHeight - n * 30; // room below? else fan upward
831
+
832
+ // Hysteresis: while fanned, test the tall column bbox; while stacked, the small side-by-side bbox.
833
+ // Fan-out is a vertical column so an expanded badge grows rightward into empty
834
+ // space and never covers its siblings (which sit above/below it).
835
+ var hovered;
836
+ if (wasHover) {
837
+ var colTop = spreadDown ? by : by - (n - 1) * 26;
838
+ hovered = mx >= bx - 8 && mx <= bx + 330 && my >= colTop - 8 && my <= colTop + (n - 1) * 26 + 34;
839
+ } else {
840
+ hovered = mx >= bx - 14 && mx <= bx + 46 && my >= by - 8 && my <= by + 40;
841
+ }
842
+ groupHover[key] = hovered;
843
+
844
+ for (var mi = 0; mi < n; mi++) {
845
+ var mem = members[mi];
846
+ if (hovered) {
847
+ // Fanned: vertical column, creation order, only the cursor's badge expands + rises.
848
+ var isHov = (mem === hoverBadge);
849
+ layoutBadge(mem, bx, by, 0, (spreadDown ? 1 : -1) * mi * 26, isHov ? TOP : MID, isHov);
850
+ } else {
851
+ // Stacked: latest centered on top; the two before it peek out half-visible.
852
+ var rank = (n - 1) - mi; // 0 = latest
853
+ if (rank === 0) layoutBadge(mem, bx, by, 0, 0, TOP, false);
854
+ else if (rank === 1) layoutBadge(mem, bx, by, -11, 0, MID, false);
855
+ else if (rank === 2) layoutBadge(mem, bx, by, 11, 0, MID, false);
856
+ else layoutBadge(mem, bx, by, 0, 0, HIDE, false); // extra ones hide behind center
857
+ }
858
+ }
859
+ }
860
+ }
861
+
862
+ function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
863
+ function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
864
+
865
+ function renumber() { for (var i = 0; i < specs.length; i++) specs[i].num.textContent = String(i + 1); }
866
+
867
+ function updateBadgeContent(spec) {
868
+ spec.num.textContent = String(specs.indexOf(spec) + 1);
869
+ if (spec.note) spec.noteSpan.textContent = spec.note;
870
+ else spec.noteSpan.textContent = spec.kind === 'measure' ? '⬡ measure' : '';
871
+ }
872
+
873
+ // A Spec badge: a circle showing its number that expands (capsule morph) on
874
+ // hover to preview its note, and opens the editor when clicked.
875
+ function createBadge(spec) {
876
+ // wrap carries transparent padding → a larger hover boundary so reaching the
877
+ // trash icon doesn't require pixel-precise aim. reflow offsets for it.
878
+ var wrap = markUI(document.createElement('div'));
879
+ Object.assign(wrap.style, { position: 'fixed', zIndex: '2147483644', display: 'none', padding: '6px', transition: 'transform 0.15s ease' });
880
+ var cap2 = document.createElement('div');
881
+ Object.assign(cap2.style, {
882
+ display: 'inline-flex', alignItems: 'center', height: '20px',
883
+ maxWidth: '20px', overflow: 'hidden', background: PURPLE, color: '#fff',
884
+ border: '2px solid #fff', boxSizing: 'border-box',
885
+ borderRadius: '999px', fontFamily: MONO, whiteSpace: 'nowrap',
886
+ boxShadow: '0 2px 6px rgba(0,0,0,0.35)', cursor: 'pointer', userSelect: 'none',
887
+ transition: 'max-width 0.2s ease, height 0.15s ease, transform 0.15s ease',
888
+ transformOrigin: '10px 10px',
627
889
  });
890
+ var num = document.createElement('span');
891
+ Object.assign(num.style, { width: '16px', flexShrink: '0', textAlign: 'center', fontSize: '12px', fontWeight: '700', lineHeight: '16px' });
892
+ var noteSpan = document.createElement('span');
893
+ Object.assign(noteSpan.style, { fontSize: '12px', paddingLeft: '3px', maxWidth: '260px', overflow: 'hidden', textOverflow: 'ellipsis' });
894
+ var trash = document.createElement('span');
895
+ trash.innerHTML = TRASH;
896
+ trash.title = 'Delete this Spec';
897
+ Object.assign(trash.style, { display: 'flex', alignItems: 'center', flexShrink: '0', padding: '0 8px 0 6px', color: '#F3B0C0', cursor: 'pointer' });
898
+ cap2.appendChild(num);
899
+ cap2.appendChild(noteSpan);
900
+ cap2.appendChild(trash);
901
+ wrap.appendChild(cap2);
902
+ document.body.appendChild(wrap);
903
+ wrap.__spec = spec; // so reflow's cursor hit-test can find which Spec a wrap is
904
+ spec.wrap = wrap; spec.num = num; spec.noteSpan = noteSpan; spec.cap = cap2;
905
+
906
+ // Expansion (circle → note capsule) is driven per-frame in reflowSpecs from the
907
+ // cursor's exact target, so only the badge under the cursor ever expands.
908
+ hoverFx(trash, { color: '#fff', transform: 'scale(1.15)' }, { color: '#F3B0C0', transform: 'scale(1)' });
909
+ trash.addEventListener('click', function (e) { e.stopPropagation(); removeSpec(spec); });
910
+ cap2.addEventListener('click', function (e) { e.stopPropagation(); openSpecEditor(spec); });
911
+ updateBadgeContent(spec);
912
+ }
913
+
914
+
915
+ // If the element sits inside a dialog/modal/drawer/menu, produce a hint for how to
916
+ // bring it back when it's later hidden — a declarative opener (aria-controls /
917
+ // data-*target) if the page has one, else the control the user just clicked (the
918
+ // likely opener), else "reopen the <container>". Empty when there's no such container.
919
+ function cssEsc(s) { try { return CSS.escape(s); } catch (e) { return s; } }
920
+ function computeLocateHint(el) {
921
+ if (!el) return '';
922
+ var re = /(modal|dialog|drawer|sheet|popover|offcanvas|overlay|lightbox|menu|dropdown|flyout|popup|tooltip)/i;
923
+ var cur = el, container = null;
924
+ while (cur && cur !== document.body) {
925
+ var role = cur.getAttribute ? cur.getAttribute('role') : null;
926
+ if ((cur.hasAttribute && cur.hasAttribute('aria-modal')) || role === 'dialog' || role === 'menu' ||
927
+ re.test(cur.className || '') || re.test(cur.id || '')) { container = cur; break; }
928
+ cur = cur.parentElement;
929
+ }
930
+ if (!container) return '';
931
+ var tag = (container.className || '') + ' ' + (container.id || '');
932
+ var type = /drawer|offcanvas|sheet/i.test(tag) ? 'drawer' : /menu|dropdown|flyout/i.test(tag) ? 'menu' : 'dialog';
933
+ var opener = '';
934
+ if (container.id) {
935
+ 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) + '"]');
936
+ if (o) opener = (o.getAttribute('aria-label') || o.textContent || '').replace(/\s+/g, ' ').trim();
937
+ }
938
+ if (!opener && lastClick.label && (Date.now() - lastClick.at) < 120000) opener = lastClick.label;
939
+ if (opener) return 'Open “' + opener.slice(0, 40) + '” to reveal';
940
+ return 'Reopen the ' + type + ' to reveal';
628
941
  }
629
942
 
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);
943
+ // Press P → create a Spec (+ open its annotation box). Measure mode captures
944
+ // distances instead of properties; a pinned measurement anchors on the pin.
945
+ function markSpec(anchorEl) {
946
+ var kind = 'element', el = anchorEl, body;
947
+ if (measureMode) {
948
+ kind = 'measure';
949
+ if (pinEl && lastHovered && lastHovered !== pinEl) { el = pinEl; body = buildMeasureCopyText(pinEl, lastHovered); }
950
+ else { el = anchorEl; body = buildNeighborCopyText(anchorEl); }
636
951
  } else {
637
- selectedEls.push(el);
638
- el.style.outline = '2px solid ' + PURPLE;
639
- el.style.outlineOffset = '-1px';
952
+ // Multiple Specs per element are allowed in every mode — clustering fans the badges
953
+ // out and same-element output collapses to one property block. Mode only changes
954
+ // what's shown on screen, never whether you can add an annotation.
955
+ body = buildLLMClipboard(buildInfo(anchorEl));
640
956
  }
641
- rebuildBadges();
642
- updatePillForSelection();
957
+ clearHoverOutline();
958
+ hideTooltip();
959
+ var spec = { el: el, path: elementPath(el), note: '', body: body, kind: kind, locate: computeLocateHint(el) };
960
+ specs.push(spec);
961
+ createBadge(spec);
962
+ reflowSpecs();
963
+ updatePill();
964
+ saveSpecs();
965
+ openSpecEditor(spec);
643
966
  }
644
967
 
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();
968
+ function removeSpec(spec) {
969
+ var i = specs.indexOf(spec);
970
+ if (i < 0) return;
971
+ if (highlightSpec === spec) highlightSpec = null;
972
+ if (panelEditSpec === spec) panelEditSpec = null;
973
+ specs.splice(i, 1);
974
+ if (spec.wrap) spec.wrap.remove();
975
+ renumber();
976
+ updatePill();
977
+ saveSpecs();
652
978
  }
653
979
 
654
- function updatePillForSelection() {
655
- if (selectedEls.length > 0) expandPill();
656
- else if (!pillWrap.matches(':hover')) collapsePill();
980
+ function removeAllSpecs() {
981
+ commitEditor();
982
+ highlightSpec = null;
983
+ panelEditSpec = null;
984
+ specs.forEach(function (s) { if (s.wrap) s.wrap.remove(); });
985
+ specs.length = 0;
986
+ updatePill();
987
+ saveSpecs();
657
988
  }
658
989
 
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';
990
+ // ─── Reload insurance (localStorage, per-URL, zero network) ──────────────────
991
+ // Persist only the serializable parts of each Spec; the live element ref and
992
+ // DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
993
+ function saveSpecs() {
994
+ try {
995
+ if (!specs.length) localStorage.removeItem(STORAGE_KEY);
996
+ else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate }; })));
997
+ } catch (e) {}
998
+ scheduleSync(); // keep the Claude bridge mirrored to the current Specs
999
+ }
1000
+
1001
+ function restoreSpecs() {
1002
+ var data;
1003
+ try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
1004
+ if (!Array.isArray(data) || !data.length) return;
1005
+ data.forEach(function (d) {
1006
+ var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
1007
+ specs.push(spec);
1008
+ createBadge(spec);
1009
+ });
1010
+ renumber();
1011
+ }
1012
+
1013
+ // Re-anchor if the node detached, then scroll it into view. Returns false when
1014
+ // the element can't be shown (removed / hidden — e.g. behind a closed modal).
1015
+ // The badge enlargement is handled separately via highlightSpec (sustained
1016
+ // while the panel row is hovered), so there's no transient ripple to miss.
1017
+ function revealSpec(spec) {
1018
+ if (!spec.el || !spec.el.isConnected) { var f = safeQuery(spec.path); if (f) spec.el = f; }
1019
+ if (!isVisible(spec.el)) return false;
1020
+ spec.el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
1021
+ return true;
1022
+ }
1023
+
1024
+ // "Visible" for the panel = renderable (connected, has a box, not display:none) —
1025
+ // NOT whether it's currently in the viewport. Off-screen is fine (that's what the
1026
+ // hover-scroll is for); only a removed / hidden element (e.g. a closed modal) greys out.
1027
+ function specVisible(spec) {
1028
+ if (!spec.el || !spec.el.isConnected) { var f = safeQuery(spec.path); if (f) spec.el = f; }
1029
+ return isVisible(spec.el);
1030
+ }
1031
+
1032
+ // ─── Specs side panel (in-session review) ────────────────────────────────────
1033
+ var panelWrap = markUI(document.createElement('div'));
1034
+ Object.assign(panelWrap.style, {
1035
+ position: 'fixed', top: '0', right: '0', height: '100vh', width: '320px',
1036
+ maxWidth: '86vw', zIndex: '2147483645', boxSizing: 'border-box',
1037
+ transform: 'translateX(100%)', transition: 'transform 0.22s ease',
1038
+ display: 'flex', flexDirection: 'column',
1039
+ background: TIP_BG, color: '#fff', fontFamily: MONO,
1040
+ borderLeft: '1px solid rgba(173,36,211,0.5)', boxShadow: '-8px 0 24px rgba(0,0,0,0.35)',
1041
+ });
1042
+
1043
+ var panelHead = document.createElement('div');
1044
+ Object.assign(panelHead.style, {
1045
+ display: 'flex', alignItems: 'center', gap: '8px', flexShrink: '0',
1046
+ padding: '16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
1047
+ });
1048
+ var panelTitle = document.createElement('span');
1049
+ Object.assign(panelTitle.style, { fontSize: '13px', fontWeight: '700', letterSpacing: '0.02em' });
1050
+ var panelSpacer = document.createElement('span');
1051
+ panelSpacer.style.flex = '1';
1052
+ var panelClose = document.createElement('span');
1053
+ panelClose.textContent = '×';
1054
+ panelClose.title = 'Close panel (L)';
1055
+ Object.assign(panelClose.style, { cursor: 'pointer', fontSize: '20px', lineHeight: '1', padding: '0 4px', color: '#B9BBC2', flexShrink: '0' });
1056
+ panelClose.addEventListener('click', function (e) { e.stopPropagation(); hidePanel(); });
1057
+ hoverFx(panelClose, { color: '#fff', transform: 'scale(1.2)' }, { color: '#B9BBC2', transform: 'scale(1)' });
1058
+ panelHead.appendChild(panelTitle);
1059
+ panelHead.appendChild(panelSpacer);
1060
+ panelHead.appendChild(panelClose);
1061
+
1062
+ // ── Batch actions toolbar (row under the title) ──
1063
+ var panelTools = document.createElement('div');
1064
+ Object.assign(panelTools.style, {
1065
+ display: 'flex', gap: '8px', alignItems: 'center', flexShrink: '0',
1066
+ padding: '10px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
1067
+ });
1068
+
1069
+ // Status dot: Specs auto-sync to the Claude bridge; this shows the connection
1070
+ // (● synced / ○ offline). Click to force a re-sync. Only shown when a bridge is set.
1071
+ var syncDot = document.createElement('div');
1072
+ Object.assign(syncDot.style, {
1073
+ display: BRIDGE ? 'flex' : 'none', alignItems: 'center', gap: '7px', flex: '1',
1074
+ fontSize: '11px', color: LABEL, cursor: 'pointer', userSelect: 'none',
1075
+ });
1076
+ syncDot.title = 'Specs auto-sync to Claude — click to re-sync now';
1077
+ var dot = document.createElement('span');
1078
+ Object.assign(dot.style, { width: '8px', height: '8px', borderRadius: '999px', background: '#6B7280', flexShrink: '0', transition: 'background 0.2s ease' });
1079
+ var dotLabel = document.createElement('span');
1080
+ dotLabel.textContent = 'Bridge offline';
1081
+ syncDot.appendChild(dot);
1082
+ syncDot.appendChild(dotLabel);
1083
+ syncDot.addEventListener('click', function (e) { e.stopPropagation(); doSync(); });
1084
+
1085
+ // Icon-only round button with a native tooltip (title). No label reveal — the
1086
+ // hover-expanding text read as janky, so we let the browser tooltip do it.
1087
+ function iconPill(svg, title, idleColor, accentRGB) {
1088
+ var btn = document.createElement('button');
1089
+ btn.title = title;
1090
+ Object.assign(btn.style, {
1091
+ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
1092
+ color: idleColor, background: 'transparent', border: '1px solid rgba(' + accentRGB + ',0.5)',
1093
+ borderRadius: '999px', width: '32px', height: '32px', padding: '0', flexShrink: '0',
1094
+ transition: 'background 0.12s ease, color 0.12s ease',
1095
+ });
1096
+ var ic = document.createElement('span'); ic.innerHTML = svg;
1097
+ Object.assign(ic.style, { display: 'flex', alignItems: 'center' });
1098
+ btn.appendChild(ic);
1099
+ btn.addEventListener('mouseenter', function () { btn.style.background = 'rgba(' + accentRGB + ',0.16)'; btn.style.color = '#fff'; });
1100
+ btn.addEventListener('mouseleave', function () { btn.style.background = 'transparent'; btn.style.color = idleColor; });
1101
+ return { btn: btn, icon: ic };
1102
+ }
1103
+
1104
+ // Copy all → clipboard. Icon flashes to a check on success.
1105
+ var copyAll = iconPill(COPY, 'Copy all', '#E0A3F5', '224,163,245');
1106
+ copyAll.btn.addEventListener('click', function (e) {
1107
+ e.stopPropagation();
1108
+ if (!specs.length) return;
1109
+ navigator.clipboard.writeText(buildSpecsCopyText()).then(function () {
1110
+ copyAll.icon.innerHTML = CHECK; copyAll.btn.style.color = GREEN;
1111
+ setTimeout(function () { copyAll.icon.innerHTML = COPY; copyAll.btn.style.color = copyAll.btn.matches(':hover') ? '#fff' : '#E0A3F5'; }, 1200);
1112
+ }).catch(function () {});
1113
+ });
1114
+
1115
+ // Delete all → confirm popover → removeAllSpecs.
1116
+ var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
1117
+ delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
1118
+
1119
+ var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
1120
+ var deleteAllBtn = delAll.btn;
1121
+ if (BRIDGE) panelTools.appendChild(syncDot);
1122
+ else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
1123
+ panelTools.appendChild(copyAllBtn);
1124
+ panelTools.appendChild(deleteAllBtn);
1125
+
1126
+ // ── "Delete all?" confirmation popover (Specter's own UI) ──
1127
+ var confirmPop = null;
1128
+ function closeConfirm() {
1129
+ if (confirmPop) { confirmPop.remove(); confirmPop = null; document.removeEventListener('mousedown', onConfirmOutside, true); }
1130
+ }
1131
+ function onConfirmOutside(e) { if (confirmPop && !confirmPop.contains(e.target)) closeConfirm(); }
1132
+ function confirmDeleteAll(anchor) {
1133
+ closeConfirm();
1134
+ if (!specs.length) return;
1135
+ var pop = markUI(document.createElement('div'));
1136
+ Object.assign(pop.style, {
1137
+ position: 'fixed', zIndex: '2147483647', boxSizing: 'border-box', width: '230px',
1138
+ background: TIP_BG, border: '1px solid ' + PURPLE, borderRadius: '8px', padding: '14px',
1139
+ boxShadow: '0 6px 20px rgba(0,0,0,0.45)', fontFamily: MONO,
1140
+ });
1141
+ var msg = document.createElement('div');
1142
+ msg.textContent = 'Delete all ' + specs.length + (specs.length === 1 ? ' annotation?' : ' annotations?');
1143
+ Object.assign(msg.style, { fontSize: '12px', lineHeight: '17px', color: '#fff', marginBottom: '12px' });
1144
+ var btnRow = document.createElement('div');
1145
+ Object.assign(btnRow.style, { display: 'flex', gap: '8px', justifyContent: 'flex-end' });
1146
+ var cancel = document.createElement('button');
1147
+ cancel.textContent = 'Cancel';
1148
+ Object.assign(cancel.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#B9BBC2', background: 'transparent', border: 'none', borderRadius: '999px', padding: '6px 10px' });
1149
+ cancel.addEventListener('click', function (e) { e.stopPropagation(); closeConfirm(); });
1150
+ hoverFx(cancel, { color: '#fff' }, { color: '#B9BBC2' });
1151
+ var confirm = document.createElement('button');
1152
+ confirm.textContent = 'Delete all';
1153
+ Object.assign(confirm.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '700', color: '#fff', background: RED, border: 'none', borderRadius: '999px', padding: '6px 12px' });
1154
+ confirm.addEventListener('click', function (e) { e.stopPropagation(); closeConfirm(); removeAllSpecs(); });
1155
+ hoverFx(confirm, { background: '#C42D4E' }, { background: RED });
1156
+ btnRow.appendChild(cancel); btnRow.appendChild(confirm);
1157
+ pop.appendChild(msg); pop.appendChild(btnRow);
1158
+ document.body.appendChild(pop);
1159
+ // Anchor below the delete-all button, right-aligned, clamped to viewport.
1160
+ var r = anchor.getBoundingClientRect();
1161
+ var w = pop.offsetWidth, h = pop.offsetHeight, m = 8;
1162
+ var left = Math.min(Math.max(r.right - w, m), window.innerWidth - w - m);
1163
+ var top = Math.min(r.bottom + 6, window.innerHeight - h - m);
1164
+ pop.style.left = left + 'px'; pop.style.top = top + 'px';
1165
+ confirmPop = pop;
1166
+ setTimeout(function () { document.addEventListener('mousedown', onConfirmOutside, true); }, 0);
1167
+ }
1168
+
1169
+ // Guidance line — tells the user what to actually DO with their annotations.
1170
+ var panelHint = document.createElement('div');
1171
+ Object.assign(panelHint.style, {
1172
+ display: 'none', fontSize: '11px', lineHeight: '17px', color: LABEL,
1173
+ padding: '10px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
1174
+ });
1175
+ function setHint() {
1176
+ panelHint.textContent = '';
1177
+ if (!specs.length) { panelHint.style.display = 'none'; return; }
1178
+ panelHint.style.display = 'block';
1179
+ var code = function (t) { var s = document.createElement('span'); s.textContent = t; Object.assign(s.style, { color: '#E0A3F5', fontWeight: '700' }); return s; };
1180
+ if (BRIDGE) {
1181
+ panelHint.appendChild(document.createTextNode('Switch to Claude Code and run '));
1182
+ panelHint.appendChild(code('/spectify'));
1183
+ panelHint.appendChild(document.createTextNode(' — it applies these annotations to your code. You can also edit or delete each one above.'));
1184
+ } else {
1185
+ panelHint.appendChild(document.createTextNode('Press '));
1186
+ panelHint.appendChild(code('Cmd+C'));
1187
+ 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
1188
  }
669
- noteTargetEl = el;
670
- var rect = el.getBoundingClientRect();
1189
+ }
1190
+
1191
+ var panelList = document.createElement('div');
1192
+ // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
1193
+ Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain' });
671
1194
 
672
- var box = document.createElement('div');
1195
+ // Keyboard shortcuts reference — collapsible, collapsed by default, lives here (not
1196
+ // in the pill) so the pill stays short.
1197
+ var panelKeys = document.createElement('div');
1198
+ Object.assign(panelKeys.style, {
1199
+ flexShrink: '0', fontSize: '11px', lineHeight: '18px', color: LABEL,
1200
+ borderTop: '1px solid rgba(255,255,255,0.08)',
1201
+ });
1202
+ (function () {
1203
+ var rows = [
1204
+ ['P', 'mark / comment the hovered element'],
1205
+ ['C', 'toggle Comment mode (hide properties)'],
1206
+ ['\u2325 Option', 'toggle Measure mode'],
1207
+ ['M', 'pin an element to measure from'],
1208
+ ['Cmd/Ctrl+C', 'copy all Specs'],
1209
+ ['L', 'toggle this panel'],
1210
+ ];
1211
+ var head = document.createElement('div');
1212
+ Object.assign(head.style, { display: 'flex', alignItems: 'center', gap: '6px', color: '#8A8D96', fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase', fontSize: '11px', padding: '10px 16px', cursor: 'pointer', userSelect: 'none' });
1213
+ var caret = document.createElement('span');
1214
+ caret.textContent = '\u203A'; // ›
1215
+ Object.assign(caret.style, { display: 'inline-block', fontSize: '16px', lineHeight: '1', transition: 'transform 0.15s ease', transform: 'rotate(0deg)' });
1216
+ var headText = document.createElement('span');
1217
+ headText.textContent = 'Keyboard shortcuts';
1218
+ head.appendChild(caret);
1219
+ head.appendChild(headText);
1220
+ var body = document.createElement('div');
1221
+ Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1222
+ rows.forEach(function (r) {
1223
+ var row = document.createElement('div');
1224
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1225
+ var k = document.createElement('span');
1226
+ k.textContent = r[0];
1227
+ Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1228
+ var d = document.createElement('span');
1229
+ d.textContent = r[1];
1230
+ row.appendChild(k); row.appendChild(d);
1231
+ body.appendChild(row);
1232
+ });
1233
+ var open = false;
1234
+ head.addEventListener('click', function (e) {
1235
+ e.stopPropagation();
1236
+ open = !open;
1237
+ body.style.display = open ? 'block' : 'none';
1238
+ caret.style.transform = open ? 'rotate(90deg)' : 'rotate(0deg)';
1239
+ });
1240
+ panelKeys.appendChild(head);
1241
+ panelKeys.appendChild(body);
1242
+ })();
1243
+
1244
+ panelWrap.appendChild(panelHead);
1245
+ panelWrap.appendChild(panelTools);
1246
+ panelWrap.appendChild(panelHint);
1247
+ panelWrap.appendChild(panelList);
1248
+ panelWrap.appendChild(panelKeys);
1249
+ document.body.appendChild(panelWrap);
1250
+
1251
+ // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1252
+ // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1253
+ // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1254
+ panelWrap.addEventListener('wheel', function (e) {
1255
+ var canScroll = panelList.scrollHeight > panelList.clientHeight;
1256
+ if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1257
+ e.preventDefault();
1258
+ }, { passive: false });
1259
+
1260
+ // ── Auto-sync: mirror the browser's current Specs to the local bridge ──
1261
+ // Debounced so rapid edits/typing collapse into one POST. The bridge replaces
1262
+ // its snapshot for this URL, so it always reflects what's in the panel — no
1263
+ // button to press; Claude pulls whatever's current when you run /spectify.
1264
+ var syncTimer = null;
1265
+ function setSyncState(s) {
1266
+ if (!BRIDGE) return;
1267
+ setPillSync(s); // control-bar dot
1268
+ applyDotState(dot, s); // side-panel dot — same spinner/green/red
1269
+ if (s === 'syncing') { dotLabel.textContent = 'Syncing…'; }
1270
+ else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1271
+ else { dotLabel.textContent = 'Bridge offline'; }
1272
+ }
1273
+ function doSync() {
1274
+ if (!BRIDGE) return;
1275
+ setSyncState('syncing');
1276
+ // Keep the spinner up for at least 1s even on an instant localhost sync, so the
1277
+ // feedback is actually perceptible instead of flashing by.
1278
+ var started = Date.now();
1279
+ var settle = function (state) {
1280
+ var wait = Math.max(0, 1000 - (Date.now() - started));
1281
+ setTimeout(function () { setSyncState(state); }, wait);
1282
+ };
1283
+ var payload = {
1284
+ url: location.href,
1285
+ text: buildSpecsCopyText(),
1286
+ specs: groupSpecs().map(function (g, i) {
1287
+ return { num: i + 1, note: g.specs.map(function (s) { return s.note; }).filter(Boolean).join('\n'), kind: g.kind, body: g.specs[0].body };
1288
+ }),
1289
+ };
1290
+ fetch(BRIDGE + '/specs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
1291
+ .then(function (r) { if (!r.ok) throw 0; return r.json(); })
1292
+ .then(function () { settle('synced'); })
1293
+ .catch(function () { settle('offline'); });
1294
+ }
1295
+ function scheduleSync() {
1296
+ if (!BRIDGE) return;
1297
+ clearTimeout(syncTimer);
1298
+ syncTimer = setTimeout(doSync, 500);
1299
+ }
1300
+
1301
+ function renderPanel() {
1302
+ panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
1303
+ panelTools.style.display = (specs.length || BRIDGE) ? 'flex' : 'none';
1304
+ copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1305
+ deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
1306
+ setHint();
1307
+ panelList.textContent = '';
1308
+ if (!specs.length) {
1309
+ var empty = document.createElement('div');
1310
+ empty.textContent = 'No Specs yet — hover an element and press P.';
1311
+ Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
1312
+ panelList.appendChild(empty);
1313
+ return;
1314
+ }
1315
+ var focusEditor = null, measures = [];
1316
+ specs.forEach(function (spec, i) {
1317
+ var visible = specVisible(spec);
1318
+ var editing = (panelEditSpec === spec);
1319
+ var row = document.createElement('div');
1320
+ Object.assign(row.style, {
1321
+ position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
1322
+ padding: '12px 16px', borderBottom: '1px solid rgba(255,255,255,0.06)',
1323
+ opacity: '1', transition: 'background 0.12s ease',
1324
+ });
1325
+ var badge = document.createElement('span');
1326
+ badge.textContent = String(i + 1);
1327
+ Object.assign(badge.style, {
1328
+ flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1329
+ background: PURPLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1330
+ border: '2px solid #fff', boxSizing: 'border-box',
1331
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
1332
+ });
1333
+ var content = document.createElement('div');
1334
+ Object.assign(content.style, { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '6px', paddingRight: '30px' });
1335
+
1336
+ var selName = (spec.el && spec.el.tagName) ? getSelector(spec.el) : (spec.path.split('>').pop() || '').trim();
1337
+ var meta = document.createElement('div');
1338
+ Object.assign(meta.style, { fontSize: '11px', color: LABEL, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' });
1339
+ meta.textContent = selName;
1340
+
1341
+ if (editing) {
1342
+ // ── UPDATE: inline editor ──
1343
+ var ta = document.createElement('textarea');
1344
+ ta.value = spec.note || '';
1345
+ ta.placeholder = 'Describe the change…';
1346
+ Object.assign(ta.style, {
1347
+ width: '100%', boxSizing: 'border-box', background: 'rgba(255,255,255,0.06)',
1348
+ border: '1px solid ' + PURPLE, borderRadius: '6px', fontFamily: MONO,
1349
+ fontSize: '14px', lineHeight: '20px', padding: '8px', resize: 'none', outline: 'none',
1350
+ overflow: 'hidden', minHeight: '56px',
1351
+ });
1352
+ // Form controls are the one thing arbitrary pages style hard — force white with
1353
+ // !important (and -webkit-text-fill-color, which otherwise wins over the color).
1354
+ ta.style.setProperty('color', '#fff', 'important');
1355
+ ta.style.setProperty('-webkit-text-fill-color', '#fff', 'important');
1356
+ ta.style.setProperty('caret-color', '#fff', 'important');
1357
+ var grow = function () { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; };
1358
+ ta.addEventListener('input', grow);
1359
+ var saveEdit = function () { spec.note = ta.value.trim(); updateBadgeContent(spec); panelEditSpec = null; saveSpecs(); updatePill(); };
1360
+ var cancelEdit = function () { panelEditSpec = null; renderPanel(); };
1361
+ ta.addEventListener('keydown', function (ev) {
1362
+ ev.stopPropagation();
1363
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); saveEdit(); }
1364
+ else if (ev.key === 'Escape') { ev.preventDefault(); cancelEdit(); }
1365
+ });
1366
+ var editActions = document.createElement('div');
1367
+ Object.assign(editActions.style, { display: 'flex', gap: '8px', alignItems: 'center' });
1368
+ var saveBtn = document.createElement('button');
1369
+ saveBtn.textContent = 'Save';
1370
+ Object.assign(saveBtn.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#fff', background: PURPLE, border: 'none', borderRadius: '999px', padding: '5px 12px' });
1371
+ saveBtn.addEventListener('click', function (e) { e.stopPropagation(); saveEdit(); });
1372
+ hoverFx(saveBtn, { background: '#C13AE0' }, { background: PURPLE });
1373
+ var cancelBtn = document.createElement('button');
1374
+ cancelBtn.textContent = 'Cancel';
1375
+ Object.assign(cancelBtn.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#B9BBC2', background: 'transparent', border: 'none', borderRadius: '999px', padding: '5px 8px' });
1376
+ // mousedown+preventDefault so the textarea's blur-save doesn't beat the cancel
1377
+ cancelBtn.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); cancelEdit(); });
1378
+ hoverFx(cancelBtn, { color: '#fff' }, { color: '#B9BBC2' });
1379
+ editActions.appendChild(saveBtn);
1380
+ editActions.appendChild(cancelBtn);
1381
+ content.appendChild(ta);
1382
+ content.appendChild(meta);
1383
+ content.appendChild(editActions);
1384
+ row.appendChild(badge);
1385
+ row.appendChild(content);
1386
+ focusEditor = function () { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); grow(); };
1387
+ } else {
1388
+ // ── READ: note (collapsed by default, expandable) ──
1389
+ var expanded = !!spec._expanded;
1390
+ var note = document.createElement('div');
1391
+ // Built UNCLAMPED so its true height is measurable after it's in the DOM;
1392
+ // the clamp is applied in the post-render measure pass below (only when the
1393
+ // note actually overflows two lines — otherwise no chevron is shown).
1394
+ Object.assign(note.style, {
1395
+ fontSize: '14px', lineHeight: '20px', color: spec.note ? '#fff' : LABEL,
1396
+ overflow: 'hidden', overflowWrap: 'anywhere', whiteSpace: 'pre-wrap',
1397
+ cursor: spec.note ? 'pointer' : 'default',
1398
+ });
1399
+ note.textContent = spec.note || (spec.kind === 'measure' ? '⬡ measurement' : '— no note —');
1400
+
1401
+ // Floated into the top-right corner so they don't reserve note width. On hover
1402
+ // they get a background that fades in from the left, masking the note text behind
1403
+ // them (instead of overlapping it). paddingLeft is the fade gutter.
1404
+ var actions = document.createElement('div');
1405
+ Object.assign(actions.style, { position: 'absolute', top: '10px', right: '14px', display: 'flex', gap: '2px', alignItems: 'center', paddingLeft: '22px', borderRadius: '6px' });
1406
+ var mkIcon = function (svg, title, color, onClick, onHover) {
1407
+ var b = document.createElement('span');
1408
+ b.innerHTML = svg; b.title = title;
1409
+ Object.assign(b.style, { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '24px', height: '24px', borderRadius: '6px', cursor: 'pointer', color: color, flexShrink: '0' });
1410
+ hoverFx(b, onHover || { background: 'rgba(255,255,255,0.14)', color: '#fff' }, { background: 'transparent', color: color });
1411
+ b.addEventListener('click', function (e) { e.stopPropagation(); onClick(); });
1412
+ return b;
1413
+ };
1414
+
1415
+ // Show an expand toggle only when the collapsed note actually overflows.
1416
+ var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
1417
+ chev.firstChild.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
1418
+ var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
1419
+ var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
1420
+ // Edit + delete reveal only on row hover; the expand chevron stays rightmost
1421
+ // and fixed (edit/delete appear to its left, so it never shifts).
1422
+ editBtn.style.display = delBtn.style.display = 'none';
1423
+ actions.appendChild(editBtn);
1424
+ actions.appendChild(delBtn);
1425
+ actions.appendChild(chev);
1426
+
1427
+ content.appendChild(note);
1428
+ content.appendChild(meta);
1429
+
1430
+ // Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
1431
+ // so hidden Specs are findable in a long list instead of silently unreachable.
1432
+ if (!visible) {
1433
+ var hbar = document.createElement('div');
1434
+ Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1435
+ var tag = document.createElement('span');
1436
+ tag.textContent = 'HIDDEN';
1437
+ Object.assign(tag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1438
+ var hint = document.createElement('span');
1439
+ hint.textContent = spec.locate || 'Not on the page right now';
1440
+ Object.assign(hint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
1441
+ hbar.appendChild(tag);
1442
+ hbar.appendChild(hint);
1443
+ content.appendChild(hbar);
1444
+ }
1445
+
1446
+ row.appendChild(badge);
1447
+ row.appendChild(content);
1448
+ row.appendChild(actions);
1449
+
1450
+ note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1451
+ row.addEventListener('mouseenter', function () {
1452
+ row.style.background = 'rgba(255,255,255,0.05)';
1453
+ editBtn.style.display = delBtn.style.display = 'flex';
1454
+ actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1455
+ if (visible) { highlightSpec = spec; revealSpec(spec); }
1456
+ });
1457
+ row.addEventListener('mouseleave', function () {
1458
+ row.style.background = 'transparent';
1459
+ editBtn.style.display = delBtn.style.display = 'none';
1460
+ actions.style.background = 'transparent';
1461
+ if (highlightSpec === spec) highlightSpec = null;
1462
+ });
1463
+
1464
+ if (expanded) chev.style.transform = ''; // note stays unclamped; chevron collapses it
1465
+ else measures.push({ note: note, chev: chev }); // measured after all rows are in the DOM
1466
+ }
1467
+ panelList.appendChild(row);
1468
+ });
1469
+
1470
+ // Measure pass — now that rows are laid out, clamp collapsed notes that overflow
1471
+ // 2 lines and hide the expand chevron on notes that fit.
1472
+ measures.forEach(function (m) {
1473
+ if (m.note.scrollHeight > 42) { // > two 20px lines (+2 slack)
1474
+ m.note.style.display = '-webkit-box';
1475
+ m.note.style.whiteSpace = 'normal';
1476
+ m.note.style.setProperty('-webkit-box-orient', 'vertical');
1477
+ m.note.style.setProperty('-webkit-line-clamp', '2');
1478
+ } else {
1479
+ m.chev.style.display = 'none';
1480
+ }
1481
+ });
1482
+ if (focusEditor) setTimeout(focusEditor, 0);
1483
+ }
1484
+
1485
+ function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
1486
+ function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
1487
+ function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
1488
+
1489
+ // ─── Spec editor (annotation box: add / edit / delete) ───────────────────────
1490
+ function commitEditor() { if (editorCommit) editorCommit(); }
1491
+ function closeEditorDom() {
1492
+ if (editorEl) { editorEl.remove(); editorEl = null; }
1493
+ editorSpec = null; editorCommit = null;
1494
+ }
1495
+
1496
+ // Open a Spec's annotation box: prefilled with its note, grows as you type,
1497
+ // saves on Enter or blur (click-away), and carries a Delete button.
1498
+ function openSpecEditor(spec) {
1499
+ commitEditor(); // commit whatever editor is already open
1500
+ if (!spec || !spec.el) return;
1501
+ editorSpec = spec;
1502
+ var rect = spec.el.getBoundingClientRect();
1503
+
1504
+ var box = markUI(document.createElement('div'));
673
1505
  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,
1506
+ position: 'fixed', zIndex: '2147483647', display: 'inline-flex',
1507
+ alignItems: 'flex-start', gap: '8px', boxSizing: 'border-box',
1508
+ background: TIP_BG, border: '1px solid ' + PURPLE, borderRadius: '8px',
1509
+ padding: '11px 12px', boxShadow: '0 4px 16px rgba(0,0,0,0.35)', fontFamily: MONO,
686
1510
  });
687
1511
  var pen = document.createElement('span');
688
1512
  pen.innerHTML = PENCIL;
689
- Object.assign(pen.style, {
690
- display: 'flex',
691
- alignItems: 'center',
692
- flexShrink: '0',
693
- marginTop: '2px',
694
- color: '#E0A3F5',
695
- });
1513
+ Object.assign(pen.style, { display: 'flex', alignItems: 'center', flexShrink: '0', marginTop: '2px', color: '#E0A3F5' });
696
1514
  var input = document.createElement('textarea');
697
1515
  input.rows = 1;
698
1516
  input.placeholder = 'Describe the change… (Enter to save)';
699
- input.value = noteMap.get(el) || '';
1517
+ input.value = spec.note || '';
700
1518
  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',
1519
+ flexShrink: '0', background: 'transparent', border: 'none', outline: 'none',
1520
+ resize: 'none', overflow: 'hidden', color: '#fff', fontFamily: MONO,
1521
+ fontSize: '12px', lineHeight: '18px', padding: '0', margin: '0',
1522
+ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere',
715
1523
  });
1524
+ var del = document.createElement('button');
1525
+ del.innerHTML = TRASH;
1526
+ del.title = 'Delete this Spec';
1527
+ Object.assign(del.style, {
1528
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0',
1529
+ width: '24px', height: '24px', marginTop: '-2px', padding: '0',
1530
+ background: 'transparent', border: 'none', borderRadius: '6px',
1531
+ color: '#ED8FA6', cursor: 'pointer',
1532
+ });
1533
+ hoverFx(del, { color: '#fff', background: 'rgba(237,62,97,0.35)' }, { color: '#ED8FA6', background: 'transparent' });
716
1534
  // Hidden mirror to measure single-line text width so the field grows as you type.
717
1535
  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
- });
1536
+ Object.assign(meas.style, { position: 'absolute', visibility: 'hidden', whiteSpace: 'pre', pointerEvents: 'none', fontFamily: MONO, fontSize: '12px', left: '-9999px', top: '0' });
728
1537
  box.appendChild(pen);
729
1538
  box.appendChild(input);
1539
+ box.appendChild(del);
730
1540
  box.appendChild(meas);
731
1541
  document.body.appendChild(box);
732
- noteInputEl = box;
1542
+ editorEl = box;
733
1543
 
734
1544
  var margin = 8;
735
1545
  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
1546
  var anchorL = rect.left, anchorT = rect.top, anchorB = rect.bottom;
739
1547
 
740
1548
  function sizeAndPosition() {
741
1549
  var vw = window.innerWidth, vh = window.innerHeight;
742
1550
  var maxBoxW = Math.min(560, vw - margin * 2);
743
- var maxTextW = Math.max(120, maxBoxW - 55); // room for icon + gaps + padding
1551
+ var maxTextW = Math.max(120, maxBoxW - 90); // room for pencil + delete + gaps + padding
744
1552
  var placeholderW = textW(input.placeholder);
745
1553
  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.
1554
+ var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines
749
1555
  var single = textW(input.value || input.placeholder) + 3;
750
1556
  input.style.width = Math.min(Math.max(single, minTextW), maxTextW) + 'px';
751
- // Height: fit wrapped content, scroll only in the extreme case.
752
1557
  input.style.height = 'auto';
753
1558
  var h = input.scrollHeight;
754
1559
  if (h > maxTaH) { input.style.height = maxTaH + 'px'; input.style.overflowY = 'auto'; }
755
1560
  else { input.style.height = h + 'px'; input.style.overflowY = 'hidden'; }
756
-
757
- // Keep the whole box on screen.
758
1561
  var bw = box.offsetWidth, bh = box.offsetHeight;
759
1562
  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
1563
+ var top = anchorT - bh - 8; // prefer above the element
1564
+ if (top < margin) top = anchorB + 8; // otherwise below
762
1565
  if (top + bh > vh - margin) top = Math.max(margin, vh - bh - margin);
763
1566
  box.style.left = left + 'px';
764
1567
  box.style.top = top + 'px';
765
1568
  }
766
1569
  sizeAndPosition();
767
1570
 
1571
+ var done = false;
1572
+ function commit() {
1573
+ if (done) return; done = true;
1574
+ spec.note = input.value.trim();
1575
+ updateBadgeContent(spec);
1576
+ closeEditorDom();
1577
+ updatePill();
1578
+ saveSpecs();
1579
+ }
1580
+ editorCommit = commit;
1581
+
1582
+ // Delete via mousedown+preventDefault so the textarea doesn't blur-save first.
1583
+ del.addEventListener('mousedown', function (ev) {
1584
+ ev.preventDefault(); ev.stopPropagation();
1585
+ done = true; closeEditorDom(); removeSpec(spec);
1586
+ });
768
1587
  input.addEventListener('input', sizeAndPosition);
769
1588
  input.addEventListener('keydown', function (ev) {
770
1589
  ev.stopPropagation();
771
- if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commitNote(input.value); }
772
- else if (ev.key === 'Escape') { ev.preventDefault(); closeNoteInput(); }
1590
+ if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Escape') { ev.preventDefault(); commit(); }
773
1591
  });
1592
+ input.addEventListener('blur', function () { setTimeout(commit, 0); });
774
1593
 
775
- rebuildBadges();
776
- updatePillForSelection();
777
1594
  setTimeout(function () { input.focus(); }, 0);
778
1595
  }
779
1596
 
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
1597
  // ─── Pin (measure) ──────────────────────────────────────────────────────────
796
1598
  function setPin(el) {
797
1599
  pinEl = el;
@@ -802,7 +1604,7 @@
802
1604
  if (!pinEl) return;
803
1605
  var r = pinEl.getBoundingClientRect();
804
1606
  if (!pinHighlight) {
805
- pinHighlight = document.createElement('div');
1607
+ pinHighlight = markUI(document.createElement('div'));
806
1608
  Object.assign(pinHighlight.style, {
807
1609
  position: 'fixed',
808
1610
  border: '2px dashed ' + RED,
@@ -832,7 +1634,7 @@
832
1634
  function showMeasureTargetHL(el) {
833
1635
  clearMeasureTargetHL();
834
1636
  var r = el.getBoundingClientRect();
835
- measureHL = document.createElement('div');
1637
+ measureHL = markUI(document.createElement('div'));
836
1638
  Object.assign(measureHL.style, {
837
1639
  position: 'fixed',
838
1640
  left: r.left + 'px',
@@ -896,9 +1698,9 @@
896
1698
  position: 'absolute',
897
1699
  background: RED,
898
1700
  color: '#fff',
899
- fontSize: '10px',
1701
+ fontSize: '12px',
900
1702
  fontFamily: MONO,
901
- padding: '1px 4px',
1703
+ padding: '2px 5px',
902
1704
  borderRadius: '3px',
903
1705
  pointerEvents: 'none',
904
1706
  whiteSpace: 'nowrap',
@@ -982,7 +1784,7 @@
982
1784
  function buildNeighborCopyText(el) {
983
1785
  var tr = el.getBoundingClientRect();
984
1786
  var dirs = computeNeighbors(el, tr);
985
- var lines = ['[Specter Measure]', '<' + el.tagName.toLowerCase() + '> ' + Math.round(tr.width) + '×' + Math.round(tr.height), 'selector: ' + getSelector(el)];
1787
+ var lines = ['[Specter Measure]', '<' + el.tagName.toLowerCase() + '> ' + Math.round(tr.width) + '×' + Math.round(tr.height), 'find: ' + resolveLocator(el)];
986
1788
  ['top', 'right', 'bottom', 'left'].forEach(function (k) {
987
1789
  if (dirs[k]) lines.push(k + ': ' + Math.round(dirs[k].gap) + 'px (to ' + dirs[k].ctx + ')');
988
1790
  });
@@ -1046,9 +1848,9 @@
1046
1848
 
1047
1849
  var lines = ['[Specter Measure]'];
1048
1850
  lines.push('From: <' + fTag + '>' + (fComp ? ' ' + fComp : '') + ' ' + Math.round(fr.width) + '×' + Math.round(fr.height));
1049
- lines.push(' selector: ' + getSelector(fromEl));
1851
+ lines.push(' find: ' + resolveLocator(fromEl));
1050
1852
  lines.push('To: <' + tTag + '>' + (tComp ? ' ' + tComp : '') + ' ' + Math.round(tr.width) + '×' + Math.round(tr.height));
1051
- lines.push(' selector: ' + getSelector(toEl));
1853
+ lines.push(' find: ' + resolveLocator(toEl));
1052
1854
 
1053
1855
  if (fromContainsTo || toContainsFrom) {
1054
1856
  var outer = fromContainsTo ? fr : tr, inner = fromContainsTo ? tr : fr;
@@ -1086,18 +1888,36 @@
1086
1888
  if (!fiActive) return;
1087
1889
  lastMouse.x = e.clientX; lastMouse.y = e.clientY;
1088
1890
  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;
1891
+ // Never inspect Specter's own UI and clear the inspect overlays so the
1892
+ // properties box / measure lines don't block a Spec's hover pill.
1893
+ if (!target || isUI(target)) {
1894
+ hideTooltip();
1895
+ clearMeasureOverlay();
1896
+ clearMeasureTargetHL();
1897
+ clearHoverOutline();
1898
+ return;
1899
+ }
1092
1900
 
1093
1901
  lastHovered = target;
1094
1902
 
1903
+ // Comment mode: outline only (so you know what you're commenting on), no
1904
+ // properties/measure overlay on screen. The Spec still captures everything.
1905
+ if (commentMode) {
1906
+ clearMeasureOverlay();
1907
+ clearMeasureTargetHL();
1908
+ setHoverOutline(target);
1909
+ hideTooltip();
1910
+ return;
1911
+ }
1912
+
1095
1913
  if (measureMode) {
1096
1914
  clearHoverOutline();
1097
1915
  showMeasureTargetHL(target);
1098
- var text = (pinEl && pinEl !== target) ? measureBetween(pinEl, target) : measureToNeighbor(target);
1099
- tooltip.innerHTML = linesToHTML(text);
1100
- positionTooltip(e.clientX, e.clientY);
1916
+ // Draw the on-screen measurement overlay (the px badges + lines) but keep
1917
+ // the dark readout box hidden — it occludes the very measurements it reports.
1918
+ // The full readout still copies with Cmd+C. (Properties mode keeps its box.)
1919
+ if (pinEl && pinEl !== target) measureBetween(pinEl, target); else measureToNeighbor(target);
1920
+ hideTooltip();
1101
1921
  if (pinEl) updatePinHL();
1102
1922
  } else {
1103
1923
  clearMeasureOverlay();
@@ -1123,8 +1943,9 @@
1123
1943
 
1124
1944
  if (!fiActive) return;
1125
1945
 
1126
- // Note editor open: let the field handle its own keys (Enter/Esc) and native copy/paste.
1127
- if (noteInputEl) return;
1946
+ // Spec editor open (on-page OR panel inline): let the textarea own its keys
1947
+ // (Enter/Esc, native copy, the letter "p") — never treat them as shortcuts.
1948
+ if (editorEl || panelEditSpec) return;
1128
1949
 
1129
1950
  if (e.key === 'Alt' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
1130
1951
  optionHeld = true;
@@ -1134,8 +1955,8 @@
1134
1955
  if ((e.metaKey || e.ctrlKey) && e.key === 'c' && !e.shiftKey && !e.altKey) {
1135
1956
  e.preventDefault();
1136
1957
  var text;
1137
- if (selectedEls.length > 0) {
1138
- text = buildMultiSelectCopyText();
1958
+ if (specs.length > 0) {
1959
+ text = buildSpecsCopyText();
1139
1960
  } else if (measureMode && pinEl && lastHovered && lastHovered !== pinEl) {
1140
1961
  text = buildMeasureCopyText(pinEl, lastHovered);
1141
1962
  } else if (measureMode && lastHovered) {
@@ -1149,16 +1970,13 @@
1149
1970
 
1150
1971
  if (isInputFocused()) return;
1151
1972
 
1973
+ // P — the single mark/annotate key (empty note = a plain mark).
1974
+ // preventDefault so the "p" keystroke can't leak into the note box that
1975
+ // markSpec is about to focus (was an intermittent stray-"p" race).
1152
1976
  if (e.key === 'p' || e.key === 'P') {
1153
1977
  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);
1978
+ e.preventDefault();
1979
+ markSpec(lastHovered);
1162
1980
  return;
1163
1981
  }
1164
1982
 
@@ -1166,24 +1984,40 @@
1166
1984
  if (!measureMode || !lastHovered) return;
1167
1985
  if (pinEl) {
1168
1986
  clearPin();
1169
- collapsePill();
1987
+ updatePill();
1170
1988
  } else {
1171
1989
  setPin(lastHovered);
1172
- expandPill('Pinned · hover another element · Cmd+C copy · Esc clear');
1990
+ expandPill('Pinned · hover another element · P mark · Cmd+C copy');
1173
1991
  }
1174
1992
  return;
1175
1993
  }
1176
1994
 
1995
+ // L — toggle the Specs review panel.
1996
+ if (e.key === 'l' || e.key === 'L') {
1997
+ e.preventDefault();
1998
+ togglePanel();
1999
+ return;
2000
+ }
2001
+
2002
+ // C — toggle Comment mode (outline only; props/measure hidden but still captured).
2003
+ // Plain c only — Cmd/Ctrl+C copy was handled above and returned.
2004
+ if ((e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey) {
2005
+ e.preventDefault();
2006
+ commentMode = !commentMode;
2007
+ if (commentMode && measureMode) { measureMode = false; clearPin(); }
2008
+ clearMeasureOverlay();
2009
+ clearMeasureTargetHL();
2010
+ clearHoverOutline();
2011
+ hideTooltip();
2012
+ if (lastHovered && commentMode) setHoverOutline(lastHovered);
2013
+ flashMode();
2014
+ return;
2015
+ }
2016
+
2017
+ // Esc only HIDES the plugin — Specs persist and return on reactivate.
1177
2018
  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
- }
2019
+ if (panelOpen) { hidePanel(); return; }
2020
+ deactivate();
1187
2021
  return;
1188
2022
  }
1189
2023
  }, true);
@@ -1204,6 +2038,19 @@
1204
2038
 
1205
2039
  document.addEventListener('mousemove', onMouseMove, { passive: true });
1206
2040
 
2041
+ // Remember the last interactive control the user clicked (not Specter's own UI).
2042
+ // If they open a modal then annotate inside it, this is the opener — used to tell
2043
+ // them how to reveal a Spec whose element is later hidden.
2044
+ document.addEventListener('click', function (e) {
2045
+ try {
2046
+ if (isUI(e.target)) return;
2047
+ var b = e.target.closest && e.target.closest('button, a, [role="button"], summary, [type="button"], [type="submit"]');
2048
+ if (!b) return;
2049
+ var t = (b.getAttribute('aria-label') || b.textContent || '').replace(/\s+/g, ' ').trim();
2050
+ if (t) lastClick = { label: t.slice(0, 40), at: Date.now() };
2051
+ } catch (err) {}
2052
+ }, true);
2053
+
1207
2054
  window.__specterToggle = function() { if (fiActive) deactivate(); else activate(); };
1208
2055
 
1209
2056
  var _rt = (typeof browser !== 'undefined' && browser.runtime) || (typeof chrome !== 'undefined' && chrome.runtime);
@@ -1213,5 +2060,8 @@
1213
2060
  });
1214
2061
  }
1215
2062
 
2063
+ restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
2064
+ scheduleSync(); // mirror restored Specs to the Claude bridge on load
2065
+
1216
2066
  console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
1217
2067
  })();