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