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