vite-plugin-specter 0.3.3 → 0.4.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.
@@ -0,0 +1,1211 @@
1
+ (function() {
2
+ 'use strict';
3
+ if (window.__specter) return; window.__specter = true;
4
+
5
+ // ─── Constants ──────────────────────────────────────────────────────────────
6
+ var PURPLE = '#AD24D3';
7
+ var RED = '#ED3E61';
8
+ var TIP_BG = '#292B32';
9
+ var GREEN = '#22C55E';
10
+ var LABEL = '#8B8D94';
11
+ var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
12
+ 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>';
13
+ 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>';
14
+ var ACTIVATE = "ctrl+alt+z";
15
+
16
+ // ─── State ────────────────────────────────────────────────────────────────
17
+ var fiActive = false;
18
+ var measureMode = false;
19
+ var pinEl = null;
20
+ var pinHighlight = null;
21
+ var lastHovered = null;
22
+ var optionHeld = false;
23
+ var pillExpanded = false;
24
+ var sideRight = false;
25
+ var outlinedEl = null;
26
+ var prevOutline = '';
27
+ var measureHL = null;
28
+ var copiedTimer = null;
29
+ var flashTimer = null;
30
+ var lastMouse = { x: 0, y: 0 };
31
+ var selectedEls = [];
32
+ var selectedBadges = [];
33
+ var noteMap = new Map();
34
+ var noteInputEl = null;
35
+ var noteTargetEl = null;
36
+
37
+ // ─── Tooltip ──────────────────────────────────────────────────────────────
38
+ var tooltip = document.createElement('div');
39
+ Object.assign(tooltip.style, {
40
+ position: 'fixed',
41
+ zIndex: '2147483646',
42
+ pointerEvents: 'none',
43
+ fontFamily: MONO,
44
+ fontSize: '13px',
45
+ lineHeight: '20px',
46
+ background: TIP_BG,
47
+ color: '#FFFFFF',
48
+ padding: '24px',
49
+ borderRadius: '8px',
50
+ maxWidth: '480px',
51
+ boxShadow: '0 4px 16px rgba(0,0,0,0.3)',
52
+ display: 'none',
53
+ });
54
+ document.body.appendChild(tooltip);
55
+
56
+ // Measure overlay
57
+ var measureOverlay = document.createElement('div');
58
+ Object.assign(measureOverlay.style, {
59
+ position: 'fixed',
60
+ inset: '0',
61
+ zIndex: '2147483645',
62
+ pointerEvents: 'none',
63
+ display: 'none',
64
+ });
65
+ document.body.appendChild(measureOverlay);
66
+
67
+ // ─── Pill ─────────────────────────────────────────────────────────────────
68
+ var pillWrap = document.createElement('div');
69
+ Object.assign(pillWrap.style, {
70
+ position: 'fixed',
71
+ bottom: '4px',
72
+ left: '4px',
73
+ zIndex: '2147483647',
74
+ padding: '12px',
75
+ display: 'none',
76
+ boxSizing: 'border-box',
77
+ });
78
+
79
+ var pill = document.createElement('div');
80
+ Object.assign(pill.style, {
81
+ display: 'flex',
82
+ alignItems: 'center',
83
+ gap: '8px',
84
+ background: PURPLE,
85
+ borderRadius: '16px',
86
+ height: '32px',
87
+ padding: '0 9px',
88
+ fontFamily: MONO,
89
+ fontSize: '11px',
90
+ fontWeight: '400',
91
+ color: '#fff',
92
+ cursor: 'default',
93
+ userSelect: 'none',
94
+ boxShadow: '0 4px 12px rgba(173,36,211,0.4)',
95
+ overflow: 'hidden',
96
+ maxWidth: '32px',
97
+ transition: 'max-width 0.2s ease',
98
+ boxSizing: 'border-box',
99
+ whiteSpace: 'nowrap',
100
+ });
101
+
102
+ var iconBtn = document.createElement('div');
103
+ Object.assign(iconBtn.style, {
104
+ position: 'relative',
105
+ width: '14px',
106
+ height: '14px',
107
+ flexShrink: '0',
108
+ display: 'flex',
109
+ alignItems: 'center',
110
+ justifyContent: 'center',
111
+ });
112
+
113
+ var zapEl = document.createElement('span');
114
+ zapEl.innerHTML = ZAP;
115
+ Object.assign(zapEl.style, {
116
+ position: 'absolute',
117
+ display: 'flex',
118
+ transition: 'transform 0.4s ease, opacity 0.2s ease',
119
+ });
120
+
121
+ var closeEl = document.createElement('span');
122
+ closeEl.textContent = '×';
123
+ Object.assign(closeEl.style, {
124
+ position: 'absolute',
125
+ fontSize: '17px',
126
+ lineHeight: '1',
127
+ opacity: '0',
128
+ transition: 'opacity 0.2s ease',
129
+ cursor: 'pointer',
130
+ });
131
+ closeEl.addEventListener('click', function (e) { e.stopPropagation(); deactivate(); });
132
+
133
+ iconBtn.appendChild(zapEl);
134
+ iconBtn.appendChild(closeEl);
135
+
136
+ var pillText = document.createElement('span');
137
+ Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
138
+
139
+ var chevron = document.createElement('span');
140
+ chevron.textContent = '›';
141
+ chevron.title = 'Move to other side';
142
+ Object.assign(chevron.style, {
143
+ display: 'none',
144
+ cursor: 'pointer',
145
+ color: '#fff',
146
+ fontSize: '14px',
147
+ flexShrink: '0',
148
+ padding: '0 2px',
149
+ opacity: '0.8',
150
+ });
151
+ chevron.addEventListener('click', function (e) { e.stopPropagation(); moveSide(); });
152
+
153
+ pill.appendChild(iconBtn);
154
+ pill.appendChild(pillText);
155
+ pill.appendChild(chevron);
156
+ pillWrap.appendChild(pill);
157
+ document.body.appendChild(pillWrap);
158
+
159
+ pillWrap.addEventListener('mouseenter', function () {
160
+ clearMeasureOverlay();
161
+ clearMeasureTargetHL();
162
+ hideTooltip();
163
+ expandPill();
164
+ zapEl.style.transform = 'rotate(360deg)';
165
+ zapEl.style.opacity = '0';
166
+ closeEl.style.opacity = '1';
167
+ });
168
+ pillWrap.addEventListener('mouseleave', function () {
169
+ if (selectedEls.length === 0 && !pinEl) collapsePill();
170
+ zapEl.style.transform = 'rotate(0deg)';
171
+ zapEl.style.opacity = '1';
172
+ closeEl.style.opacity = '0';
173
+ });
174
+
175
+ function moveSide() {
176
+ sideRight = !sideRight;
177
+ if (sideRight) {
178
+ pillWrap.style.left = 'auto';
179
+ pillWrap.style.right = '4px';
180
+ chevron.textContent = '‹';
181
+ } else {
182
+ pillWrap.style.right = 'auto';
183
+ pillWrap.style.left = '4px';
184
+ chevron.textContent = '›';
185
+ }
186
+ }
187
+
188
+ function expandPill(text) {
189
+ pill.style.maxWidth = '760px';
190
+ pillText.style.display = 'inline';
191
+ chevron.style.display = 'inline';
192
+ pillExpanded = true;
193
+ if (text) { pillText.textContent = text; return; }
194
+ if (selectedEls.length > 0) {
195
+ pillText.textContent = selectedEls.length + ' selected · N annotate · Cmd+C copy all · Esc clear';
196
+ } else if (measureMode) {
197
+ pillText.textContent = 'Measure · hover distances · M pin · Cmd+C copy · Option toggle';
198
+ } else {
199
+ pillText.textContent = 'Properties · hover · P pick · N annotate · Cmd+C copy · Option measure';
200
+ }
201
+ }
202
+
203
+ function collapsePill() {
204
+ pill.style.maxWidth = '32px';
205
+ pillText.style.display = 'none';
206
+ chevron.style.display = 'none';
207
+ pillExpanded = false;
208
+ }
209
+
210
+ function flashMode() {
211
+ if (pillExpanded && pillWrap.matches(':hover')) return;
212
+ var text = measureMode ? '⬡ Measure mode' : '◉ Properties mode';
213
+ expandPill(text);
214
+ clearTimeout(flashTimer);
215
+ flashTimer = setTimeout(function () {
216
+ if (!pillWrap.matches(':hover') && selectedEls.length === 0 && !pinEl) collapsePill();
217
+ else expandPill();
218
+ }, 1200);
219
+ }
220
+
221
+ // ─── Activate / Deactivate ────────────────────────────────────────────────
222
+ function activate() {
223
+ fiActive = true;
224
+ measureMode = false;
225
+ pillWrap.style.display = 'block';
226
+ document.body.style.cursor = 'crosshair';
227
+ collapsePill();
228
+ }
229
+
230
+ function deactivate() {
231
+ fiActive = false;
232
+ measureMode = false;
233
+ optionHeld = false;
234
+ lastHovered = null;
235
+ clearSelection();
236
+ clearPin();
237
+ clearHoverOutline();
238
+ clearMeasureTargetHL();
239
+ pillWrap.style.display = 'none';
240
+ document.body.style.cursor = '';
241
+ hideTooltip();
242
+ clearMeasureOverlay();
243
+ }
244
+
245
+ // ─── Helpers ──────────────────────────────────────────────────────────────
246
+ function hideTooltip() { tooltip.style.display = 'none'; }
247
+
248
+ function esc(s) {
249
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
250
+ }
251
+
252
+ function positionTooltip(x, y) {
253
+ tooltip.style.display = 'block';
254
+ var tw = tooltip.offsetWidth;
255
+ var th = tooltip.offsetHeight;
256
+ var vw = window.innerWidth;
257
+ var vh = window.innerHeight;
258
+ var margin = 16;
259
+ var left = x + 16;
260
+ var top = y + 16;
261
+ if (left + tw > vw - margin) left = x - tw - 16;
262
+ if (top + th > vh - margin) top = y - th - 16;
263
+ if (left < margin) left = margin;
264
+ if (top < margin) top = margin;
265
+ tooltip.style.left = left + 'px';
266
+ tooltip.style.top = top + 'px';
267
+ }
268
+
269
+ function toHex(r, g, b) {
270
+ return '#' + [r, g, b].map(function (v) { return Math.round(v).toString(16).padStart(2, '0'); }).join('');
271
+ }
272
+
273
+ function parseColor(str) {
274
+ if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return null;
275
+ var m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
276
+ if (!m) return null;
277
+ return { hex: toHex(m[1], m[2], m[3]), alpha: m[4] !== undefined ? parseFloat(m[4]) : 1 };
278
+ }
279
+
280
+ function colorObj(str) {
281
+ var c = parseColor(str);
282
+ if (!c) return null;
283
+ var label = c.alpha < 1 ? c.hex + ' (' + Math.round(c.alpha * 100) + '% opacity)' : c.hex;
284
+ return { raw: str, hex: c.hex, alpha: c.alpha, label: label };
285
+ }
286
+
287
+ function edge(t, r, b, l) {
288
+ if ([t, r, b, l].every(function (v) { return v === '0px'; })) return null;
289
+ return { value: t + ' ' + r + ' ' + b + ' ' + l };
290
+ }
291
+
292
+ function weightName(w) {
293
+ var m = { '100': 'Thin', '200': 'ExtraLight', '300': 'Light', '400': 'Regular', '500': 'Medium', '600': 'Semibold', '700': 'Bold', '800': 'ExtraBold', '900': 'Black', 'normal': 'Regular', 'bold': 'Bold' };
294
+ return m[w] || w;
295
+ }
296
+
297
+ function swatch(hex) {
298
+ return '<span style="display:inline-block;width:9px;height:9px;border-radius:2px;background:' + hex + ';margin-right:6px;vertical-align:middle;border:1px solid rgba(255,255,255,0.2)"></span>';
299
+ }
300
+
301
+ function getComponentName(el) {
302
+ for (var key in el) {
303
+ if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {
304
+ var fiber = el[key];
305
+ while (fiber) {
306
+ var name = (fiber.type && (fiber.type.displayName || fiber.type.name));
307
+ if (name && name.length > 3 && /^[A-Z]/.test(name)) return name;
308
+ fiber = fiber.return;
309
+ }
310
+ }
311
+ }
312
+ if (el.__vueParentComponent) {
313
+ var vname = el.__vueParentComponent.type && (el.__vueParentComponent.type.name || el.__vueParentComponent.type.__name);
314
+ if (vname && vname.length > 3) return vname;
315
+ }
316
+ return null;
317
+ }
318
+
319
+ // Collapse a rule's declarations to their shortest lossless form: drop
320
+ // 'initial' (= default, no signal) and always-'normal' noise, and fold
321
+ // side/corner longhands back into shorthands (margin/padding/radius/gap/transition).
322
+ function cleanDecls(style) {
323
+ var m = {}, order = [];
324
+ for (var i = 0; i < style.length; i++) {
325
+ var p = style[i];
326
+ var v = style.getPropertyValue(p);
327
+ if (!v) continue;
328
+ if (v === 'initial') continue;
329
+ if (p === 'transition-behavior') continue;
330
+ if (!(p in m)) order.push(p);
331
+ m[p] = v;
332
+ }
333
+ var used = {}, collapses = {};
334
+ function four(name, t, r, b, l) {
335
+ if (m[t] === undefined || m[r] === undefined || m[b] === undefined || m[l] === undefined) return;
336
+ used[t] = used[r] = used[b] = used[l] = 1;
337
+ var a = m[t], c = m[r], d = m[b], e = m[l], sh;
338
+ if (a === c && c === d && d === e) sh = a;
339
+ else if (a === d && c === e) sh = a + ' ' + c;
340
+ else if (c === e) sh = a + ' ' + c + ' ' + d;
341
+ else sh = a + ' ' + c + ' ' + d + ' ' + e;
342
+ collapses[t] = name + ': ' + sh;
343
+ }
344
+ four('margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left');
345
+ four('padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left');
346
+ four('border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius');
347
+ if (m['row-gap'] !== undefined && m['column-gap'] !== undefined) {
348
+ var g = m['row-gap'] === m['column-gap'] ? m['row-gap'] : m['row-gap'] + ' ' + m['column-gap'];
349
+ collapses['row-gap'] = 'gap: ' + g; used['row-gap'] = used['column-gap'] = 1;
350
+ }
351
+ if (m['transition-property'] !== undefined) {
352
+ var tr = 'transition: ' + m['transition-property'];
353
+ if (m['transition-duration'] !== undefined) tr += ' ' + m['transition-duration'];
354
+ if (m['transition-timing-function'] !== undefined) tr += ' ' + m['transition-timing-function'];
355
+ if (m['transition-delay'] !== undefined && m['transition-delay'] !== '0s') tr += ' ' + m['transition-delay'];
356
+ collapses['transition-property'] = tr;
357
+ used['transition-property'] = used['transition-duration'] = used['transition-timing-function'] = used['transition-delay'] = 1;
358
+ }
359
+ var out = [];
360
+ order.forEach(function (p) {
361
+ if (collapses[p]) { out.push(' ' + collapses[p] + ';'); return; }
362
+ if (used[p]) return;
363
+ out.push(' ' + p + ': ' + m[p] + ';');
364
+ });
365
+ return out;
366
+ }
367
+
368
+ function getMatchingRules(el) {
369
+ var results = [];
370
+ try {
371
+ for (var s = 0; s < document.styleSheets.length; s++) {
372
+ var sheet = document.styleSheets[s];
373
+ var rules;
374
+ try { rules = sheet.cssRules; } catch (e) { continue; }
375
+ if (!rules) continue;
376
+ for (var r = 0; r < rules.length; r++) {
377
+ var rule = rules[r];
378
+ if (rule.type !== 1) continue;
379
+ var sel = rule.selectorText;
380
+ if (!sel) continue;
381
+ if (/^[*,]|^:root|^html|^body$|^::before|^::after/.test(sel.trim())) continue;
382
+ try {
383
+ if (el.matches(sel)) {
384
+ var props = cleanDecls(rule.style);
385
+ if (props.length) results.push(sel + ' {\n' + props.join('\n') + '\n}');
386
+ }
387
+ } catch (e) {}
388
+ }
389
+ }
390
+ } catch (e) {}
391
+ return results;
392
+ }
393
+
394
+ function getSelector(el) {
395
+ var parts = [];
396
+ var cur = el;
397
+ for (var i = 0; i < 3 && cur && cur !== document.body; i++) {
398
+ var part = cur.tagName.toLowerCase();
399
+ if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
400
+ var cls = Array.prototype.slice.call(cur.classList).filter(function (c) { return c.indexOf('__specter') !== 0; });
401
+ if (cls.length) {
402
+ part += '.' + cls.slice(0, 2).join('.');
403
+ } else if (cur.parentElement) {
404
+ var sameTag = Array.prototype.slice.call(cur.parentElement.children).filter(function (c) { return c.tagName === cur.tagName; });
405
+ if (sameTag.length > 1) {
406
+ part += ':nth-child(' + (Array.prototype.indexOf.call(cur.parentElement.children, cur) + 1) + ')';
407
+ }
408
+ }
409
+ parts.unshift(part);
410
+ cur = cur.parentElement;
411
+ }
412
+ return parts.join(' > ');
413
+ }
414
+
415
+ // ─── Structured data model ──────────────────────────────────────────────────
416
+ function buildInfo(el) {
417
+ var cs = getComputedStyle(el);
418
+ var rect = el.getBoundingClientRect();
419
+ var ff = cs.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
420
+ var raw = el.textContent ? el.textContent.trim() : '';
421
+ return {
422
+ el: el,
423
+ tag: el.tagName.toLowerCase(),
424
+ id: el.id || null,
425
+ classes: Array.prototype.slice.call(el.classList).filter(function (c) { return c.indexOf('__specter') !== 0; }),
426
+ component: getComponentName(el),
427
+ styleKey: el.dataset ? el.dataset.style : null,
428
+ width: Math.round(rect.width),
429
+ height: Math.round(rect.height),
430
+ text: raw.slice(0, 40),
431
+ textTrunc: raw.length > 40,
432
+ font: { family: ff, weight: cs.fontWeight, size: cs.fontSize, lineHeight: cs.lineHeight },
433
+ color: colorObj(cs.color),
434
+ bg: colorObj(cs.backgroundColor),
435
+ padding: edge(cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft),
436
+ margin: edge(cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft),
437
+ radius: (cs.borderRadius && cs.borderRadius !== '0px') ? cs.borderRadius : null,
438
+ display: ['flex', 'grid', 'inline-flex', 'inline-grid'].indexOf(cs.display) >= 0 ? cs.display : null,
439
+ gap: (cs.gap && cs.gap !== 'normal') ? cs.gap : null,
440
+ flexDir: (cs.display.indexOf('flex') >= 0 && cs.flexDirection !== 'row') ? cs.flexDirection : null,
441
+ rules: getMatchingRules(el),
442
+ };
443
+ }
444
+
445
+ function row(label, val) {
446
+ return '<div style="display:flex;margin-bottom:8px"><span style="color:' + LABEL + ';min-width:76px;flex-shrink:0">' + label + '</span><span style="color:#fff;word-break:break-word">' + val + '</span></div>';
447
+ }
448
+
449
+ function buildHumanDisplay(data) {
450
+ var h = '';
451
+ var id = '<span style="color:#fff">&lt;' + data.tag + '&gt;</span>';
452
+ if (data.id) id += '<span style="color:#F0B86E">#' + esc(data.id) + '</span>';
453
+ if (data.classes.length) id += '<span style="color:#5FD3C0">.' + data.classes.map(esc).join('.') + '</span>';
454
+ if (data.component) id += ' <span style="color:#E0A3F5;font-weight:600">' + esc(data.component) + '</span>';
455
+ if (data.styleKey) id += ' <span style="color:' + LABEL + '">[' + esc(data.styleKey) + ']</span>';
456
+ id += ' <span style="color:' + LABEL + '">' + data.width + '×' + data.height + '</span>';
457
+ h += '<div style="margin-bottom:8px">' + id + '</div>';
458
+ if (data.text) h += '<div style="color:' + LABEL + ';font-style:italic;margin-bottom:8px">"' + esc(data.text) + (data.textTrunc ? '…' : '') + '"</div>';
459
+ h += '<div style="height:8px"></div>';
460
+ h += row('Font', esc(data.font.family) + ' · ' + weightName(data.font.weight) + ' · ' + data.font.size + '/' + data.font.lineHeight);
461
+ if (data.color) h += row('Color', swatch(data.color.hex) + data.color.label);
462
+ if (data.bg) h += row('Bg', swatch(data.bg.hex) + data.bg.label);
463
+ if (data.padding) h += row('Padding', data.padding.value);
464
+ if (data.margin) h += row('Margin', data.margin.value);
465
+ if (data.radius) h += row('Radius', data.radius);
466
+ if (data.display) {
467
+ var d = data.display;
468
+ if (data.gap) d += ' · gap ' + data.gap;
469
+ if (data.flexDir) d += ' · ' + data.flexDir;
470
+ h += row('Display', d);
471
+ }
472
+ return h;
473
+ }
474
+
475
+ function buildLLMClipboard(data) {
476
+ var lines = ['[Specter]'];
477
+ var head = '<' + data.tag + '>';
478
+ if (data.id) head += '#' + data.id;
479
+ if (data.classes.length) head += '.' + data.classes.join('.');
480
+ if (data.component) head += ' ' + data.component;
481
+ if (data.styleKey) head += ' [' + data.styleKey + ']';
482
+ head += ' ' + data.width + '×' + data.height;
483
+ lines.push(head);
484
+ if (data.text) lines.push('"' + data.text + (data.textTrunc ? '…' : '') + '"');
485
+ lines.push('font: ' + data.font.family + ' ' + data.font.weight + ' ' + data.font.size + '/' + data.font.lineHeight);
486
+ if (data.color) lines.push('color: ' + data.color.label);
487
+ if (data.bg) lines.push('bg: ' + data.bg.label);
488
+ if (data.padding) lines.push('padding: ' + data.padding.value);
489
+ if (data.margin) lines.push('margin: ' + data.margin.value);
490
+ if (data.radius) lines.push('radius: ' + data.radius);
491
+ if (data.display) {
492
+ var d = 'display: ' + data.display;
493
+ if (data.gap) d += ' gap: ' + data.gap;
494
+ if (data.flexDir) d += ' dir: ' + data.flexDir;
495
+ lines.push(d);
496
+ }
497
+ lines.push('selector: ' + getSelector(data.el));
498
+ var out = lines.join('\n');
499
+ if (data.rules && data.rules.length) out += '\n\n--- CSS Rules ---\n' + data.rules.join('\n\n');
500
+ return out;
501
+ }
502
+
503
+ function buildMultiSelectCopyText() {
504
+ var n = selectedEls.length;
505
+ return selectedEls.map(function (el, i) {
506
+ var body = buildLLMClipboard(buildInfo(el));
507
+ var note = noteMap.get(el);
508
+ var header = n > 1 ? '[Specter ' + (i + 1) + '/' + n + ']' : '[Specter]';
509
+ if (note) header += '\n✏️ CHANGE: ' + note;
510
+ return body.replace('[Specter]', header);
511
+ }).join('\n\n' + Array(41).join('─') + '\n\n');
512
+ }
513
+
514
+ function linesToHTML(str) {
515
+ return str.split('\n').map(function (l) {
516
+ return '<div style="margin-bottom:4px;color:#fff">' + esc(l) + '</div>';
517
+ }).join('');
518
+ }
519
+
520
+ // ─── Hover outline ──────────────────────────────────────────────────────────
521
+ function setHoverOutline(el) {
522
+ if (outlinedEl === el) return;
523
+ clearHoverOutline();
524
+ if (!el) return;
525
+ outlinedEl = el;
526
+ prevOutline = el.style.outline;
527
+ el.style.outline = '2px solid ' + PURPLE;
528
+ el.style.outlineOffset = '-1px';
529
+ }
530
+
531
+ function clearHoverOutline() {
532
+ if (outlinedEl) {
533
+ outlinedEl.style.outline = prevOutline;
534
+ outlinedEl.style.outlineOffset = '';
535
+ outlinedEl = null;
536
+ }
537
+ }
538
+
539
+ // ─── Copied feedback ────────────────────────────────────────────────────────
540
+ function showCopied() {
541
+ tooltip.innerHTML = '<div style="color:' + GREEN + ';font-weight:600;display:flex;align-items:center;gap:6px"><span>✓</span><span>Copied!</span></div>';
542
+ tooltip.style.display = 'block';
543
+ clearTimeout(copiedTimer);
544
+ copiedTimer = setTimeout(function () {
545
+ if (fiActive && lastHovered) reRenderTooltip();
546
+ else hideTooltip();
547
+ }, 1200);
548
+ }
549
+
550
+ function reRenderTooltip() {
551
+ if (measureMode) {
552
+ var text = (pinEl && pinEl !== lastHovered) ? measureBetween(pinEl, lastHovered) : measureToNeighbor(lastHovered);
553
+ tooltip.innerHTML = linesToHTML(text);
554
+ } else {
555
+ tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
556
+ }
557
+ positionTooltip(lastMouse.x, lastMouse.y);
558
+ }
559
+
560
+ // ─── Multi-select ─────────────────────────────────────────────────────────
561
+ function makeBadge(el, index, note) {
562
+ var rect = el.getBoundingClientRect();
563
+ var wrap = document.createElement('div');
564
+ Object.assign(wrap.style, {
565
+ position: 'fixed',
566
+ left: (rect.left - 4) + 'px',
567
+ top: (rect.top - 4) + 'px',
568
+ zIndex: '2147483644',
569
+ pointerEvents: 'none',
570
+ display: 'flex',
571
+ alignItems: 'flex-start',
572
+ gap: '4px',
573
+ });
574
+ var badge = document.createElement('div');
575
+ badge.textContent = String(index + 1);
576
+ Object.assign(badge.style, {
577
+ width: '20px',
578
+ height: '20px',
579
+ flexShrink: '0',
580
+ background: PURPLE,
581
+ color: '#fff',
582
+ fontSize: '11px',
583
+ fontWeight: '700',
584
+ fontFamily: MONO,
585
+ borderRadius: '50%',
586
+ display: 'flex',
587
+ alignItems: 'center',
588
+ justifyContent: 'center',
589
+ boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
590
+ });
591
+ wrap.appendChild(badge);
592
+ if (note) {
593
+ var chip = document.createElement('div');
594
+ chip.textContent = '✏️ ' + (note.length > 32 ? note.slice(0, 32) + '…' : note);
595
+ Object.assign(chip.style, {
596
+ maxWidth: '240px',
597
+ background: TIP_BG,
598
+ color: '#fff',
599
+ fontSize: '10px',
600
+ lineHeight: '16px',
601
+ fontFamily: MONO,
602
+ padding: '2px 6px',
603
+ borderRadius: '4px',
604
+ whiteSpace: 'nowrap',
605
+ overflow: 'hidden',
606
+ textOverflow: 'ellipsis',
607
+ border: '1px solid ' + PURPLE,
608
+ boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
609
+ });
610
+ wrap.appendChild(chip);
611
+ }
612
+ document.body.appendChild(wrap);
613
+ return wrap;
614
+ }
615
+
616
+ function rebuildBadges() {
617
+ selectedBadges.forEach(function (b) { b.remove(); });
618
+ selectedBadges.length = 0;
619
+ selectedEls.forEach(function (el, i) {
620
+ selectedBadges.push(makeBadge(el, i, noteMap.get(el)));
621
+ });
622
+ }
623
+
624
+ function toggleSelection(el) {
625
+ var idx = selectedEls.indexOf(el);
626
+ if (idx >= 0) {
627
+ selectedEls.splice(idx, 1);
628
+ el.style.outline = '';
629
+ noteMap.delete(el);
630
+ } else {
631
+ selectedEls.push(el);
632
+ el.style.outline = '2px solid ' + PURPLE;
633
+ el.style.outlineOffset = '-1px';
634
+ }
635
+ rebuildBadges();
636
+ updatePillForSelection();
637
+ }
638
+
639
+ function clearSelection() {
640
+ closeNoteInput();
641
+ selectedEls.forEach(function (el) { el.style.outline = ''; el.style.outlineOffset = ''; });
642
+ selectedEls.length = 0;
643
+ selectedBadges.forEach(function (b) { b.remove(); });
644
+ selectedBadges.length = 0;
645
+ noteMap.clear();
646
+ }
647
+
648
+ function updatePillForSelection() {
649
+ if (selectedEls.length > 0) expandPill();
650
+ else if (!pillWrap.matches(':hover')) collapsePill();
651
+ }
652
+
653
+ // ─── Annotations (inline change notes) ──────────────────────────────────────
654
+ function openNoteInput(el) {
655
+ closeNoteInput();
656
+ if (selectedEls.indexOf(el) < 0) {
657
+ clearHoverOutline();
658
+ hideTooltip();
659
+ selectedEls.push(el);
660
+ el.style.outline = '2px solid ' + PURPLE;
661
+ el.style.outlineOffset = '-1px';
662
+ }
663
+ noteTargetEl = el;
664
+ var rect = el.getBoundingClientRect();
665
+
666
+ var box = document.createElement('div');
667
+ Object.assign(box.style, {
668
+ position: 'fixed',
669
+ zIndex: '2147483647',
670
+ display: 'inline-flex',
671
+ alignItems: 'flex-start',
672
+ gap: '8px',
673
+ boxSizing: 'border-box',
674
+ background: TIP_BG,
675
+ border: '1px solid ' + PURPLE,
676
+ borderRadius: '8px',
677
+ padding: '11px 12px',
678
+ boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
679
+ fontFamily: MONO,
680
+ });
681
+ var pen = document.createElement('span');
682
+ pen.innerHTML = PENCIL;
683
+ Object.assign(pen.style, {
684
+ display: 'flex',
685
+ alignItems: 'center',
686
+ flexShrink: '0',
687
+ marginTop: '2px',
688
+ color: '#E0A3F5',
689
+ });
690
+ var input = document.createElement('textarea');
691
+ input.rows = 1;
692
+ input.placeholder = 'Describe the change… (Enter to save)';
693
+ input.value = noteMap.get(el) || '';
694
+ Object.assign(input.style, {
695
+ flexShrink: '0',
696
+ background: 'transparent',
697
+ border: 'none',
698
+ outline: 'none',
699
+ resize: 'none',
700
+ overflow: 'hidden',
701
+ color: '#fff',
702
+ fontFamily: MONO,
703
+ fontSize: '12px',
704
+ lineHeight: '18px',
705
+ padding: '0',
706
+ margin: '0',
707
+ whiteSpace: 'pre-wrap',
708
+ overflowWrap: 'anywhere',
709
+ });
710
+ // Hidden mirror to measure single-line text width so the field grows as you type.
711
+ var meas = document.createElement('span');
712
+ Object.assign(meas.style, {
713
+ position: 'absolute',
714
+ visibility: 'hidden',
715
+ whiteSpace: 'pre',
716
+ pointerEvents: 'none',
717
+ fontFamily: MONO,
718
+ fontSize: '12px',
719
+ left: '-9999px',
720
+ top: '0',
721
+ });
722
+ box.appendChild(pen);
723
+ box.appendChild(input);
724
+ box.appendChild(meas);
725
+ document.body.appendChild(box);
726
+ noteInputEl = box;
727
+
728
+ var margin = 8;
729
+ function textW(t) { meas.textContent = t; return meas.offsetWidth; }
730
+
731
+ // Anchor to the element; the box is repositioned around this as it grows.
732
+ var anchorL = rect.left, anchorT = rect.top, anchorB = rect.bottom;
733
+
734
+ function sizeAndPosition() {
735
+ var vw = window.innerWidth, vh = window.innerHeight;
736
+ var maxBoxW = Math.min(560, vw - margin * 2);
737
+ var maxTextW = Math.max(120, maxBoxW - 55); // room for icon + gaps + padding
738
+ var placeholderW = textW(input.placeholder);
739
+ var minTextW = Math.min(placeholderW, maxTextW);
740
+ var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines, never past viewport
741
+
742
+ // Width: grow with content up to the max, then wrapping takes over.
743
+ var single = textW(input.value || input.placeholder) + 3;
744
+ input.style.width = Math.min(Math.max(single, minTextW), maxTextW) + 'px';
745
+ // Height: fit wrapped content, scroll only in the extreme case.
746
+ input.style.height = 'auto';
747
+ var h = input.scrollHeight;
748
+ if (h > maxTaH) { input.style.height = maxTaH + 'px'; input.style.overflowY = 'auto'; }
749
+ else { input.style.height = h + 'px'; input.style.overflowY = 'hidden'; }
750
+
751
+ // Keep the whole box on screen.
752
+ var bw = box.offsetWidth, bh = box.offsetHeight;
753
+ var left = Math.min(Math.max(anchorL, margin), Math.max(margin, vw - bw - margin));
754
+ var top = anchorT - bh - 8; // prefer sitting above the element
755
+ if (top < margin) top = anchorB + 8; // otherwise below it
756
+ if (top + bh > vh - margin) top = Math.max(margin, vh - bh - margin);
757
+ box.style.left = left + 'px';
758
+ box.style.top = top + 'px';
759
+ }
760
+ sizeAndPosition();
761
+
762
+ input.addEventListener('input', sizeAndPosition);
763
+ input.addEventListener('keydown', function (ev) {
764
+ ev.stopPropagation();
765
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commitNote(input.value); }
766
+ else if (ev.key === 'Escape') { ev.preventDefault(); closeNoteInput(); }
767
+ });
768
+
769
+ rebuildBadges();
770
+ updatePillForSelection();
771
+ setTimeout(function () { input.focus(); }, 0);
772
+ }
773
+
774
+ function commitNote(val) {
775
+ val = (val || '').trim();
776
+ if (noteTargetEl) {
777
+ if (val) noteMap.set(noteTargetEl, val);
778
+ else noteMap.delete(noteTargetEl);
779
+ }
780
+ closeNoteInput();
781
+ rebuildBadges();
782
+ }
783
+
784
+ function closeNoteInput() {
785
+ if (noteInputEl) { noteInputEl.remove(); noteInputEl = null; }
786
+ noteTargetEl = null;
787
+ }
788
+
789
+ // ─── Pin (measure) ──────────────────────────────────────────────────────────
790
+ function setPin(el) {
791
+ pinEl = el;
792
+ updatePinHL();
793
+ }
794
+
795
+ function updatePinHL() {
796
+ if (!pinEl) return;
797
+ var r = pinEl.getBoundingClientRect();
798
+ if (!pinHighlight) {
799
+ pinHighlight = document.createElement('div');
800
+ Object.assign(pinHighlight.style, {
801
+ position: 'fixed',
802
+ border: '2px dashed ' + RED,
803
+ background: 'rgba(237,62,97,0.05)',
804
+ boxSizing: 'border-box',
805
+ pointerEvents: 'none',
806
+ zIndex: '2147483643',
807
+ });
808
+ document.body.appendChild(pinHighlight);
809
+ }
810
+ pinHighlight.style.left = r.left + 'px';
811
+ pinHighlight.style.top = r.top + 'px';
812
+ pinHighlight.style.width = r.width + 'px';
813
+ pinHighlight.style.height = r.height + 'px';
814
+ }
815
+
816
+ function clearPin() {
817
+ if (pinHighlight) { pinHighlight.remove(); pinHighlight = null; }
818
+ pinEl = null;
819
+ }
820
+
821
+ function clearMeasureOverlay() {
822
+ measureOverlay.innerHTML = '';
823
+ measureOverlay.style.display = 'none';
824
+ }
825
+
826
+ function showMeasureTargetHL(el) {
827
+ clearMeasureTargetHL();
828
+ var r = el.getBoundingClientRect();
829
+ measureHL = document.createElement('div');
830
+ Object.assign(measureHL.style, {
831
+ position: 'fixed',
832
+ left: r.left + 'px',
833
+ top: r.top + 'px',
834
+ width: r.width + 'px',
835
+ height: r.height + 'px',
836
+ background: 'rgba(173,36,211,0.08)',
837
+ border: '1.5px solid ' + PURPLE,
838
+ boxSizing: 'border-box',
839
+ pointerEvents: 'none',
840
+ zIndex: '2147483643',
841
+ });
842
+ document.body.appendChild(measureHL);
843
+ }
844
+
845
+ function clearMeasureTargetHL() {
846
+ if (measureHL) { measureHL.remove(); measureHL = null; }
847
+ }
848
+
849
+ // ─── Measurement drawing ────────────────────────────────────────────────────
850
+ function cap(x, y, mainHoriz) {
851
+ var c = document.createElement('div');
852
+ Object.assign(c.style, { position: 'absolute', background: RED, pointerEvents: 'none' });
853
+ if (mainHoriz) {
854
+ c.style.left = (x - 0.5) + 'px';
855
+ c.style.top = (y - 4) + 'px';
856
+ c.style.width = '1px';
857
+ c.style.height = '8px';
858
+ } else {
859
+ c.style.left = (x - 4) + 'px';
860
+ c.style.top = (y - 0.5) + 'px';
861
+ c.style.width = '8px';
862
+ c.style.height = '1px';
863
+ }
864
+ measureOverlay.appendChild(c);
865
+ }
866
+
867
+ function drawLine(x1, y1, x2, y2, label) {
868
+ var isHoriz = Math.abs(y2 - y1) < 1;
869
+ var line = document.createElement('div');
870
+ Object.assign(line.style, { position: 'absolute', background: RED, pointerEvents: 'none' });
871
+ if (isHoriz) {
872
+ Object.assign(line.style, {
873
+ left: Math.min(x1, x2) + 'px', top: (y1 - 0.5) + 'px',
874
+ width: Math.abs(x2 - x1) + 'px', height: '1px',
875
+ });
876
+ } else {
877
+ Object.assign(line.style, {
878
+ left: (x1 - 0.5) + 'px', top: Math.min(y1, y2) + 'px',
879
+ width: '1px', height: Math.abs(y2 - y1) + 'px',
880
+ });
881
+ }
882
+ measureOverlay.appendChild(line);
883
+ cap(x1, y1, isHoriz);
884
+ cap(x2, y2, isHoriz);
885
+
886
+ if (label && label !== '0px') {
887
+ var lbl = document.createElement('div');
888
+ lbl.textContent = label;
889
+ Object.assign(lbl.style, {
890
+ position: 'absolute',
891
+ background: RED,
892
+ color: '#fff',
893
+ fontSize: '10px',
894
+ fontFamily: MONO,
895
+ padding: '1px 4px',
896
+ borderRadius: '3px',
897
+ pointerEvents: 'none',
898
+ whiteSpace: 'nowrap',
899
+ });
900
+ if (isHoriz) {
901
+ var cxl = (Math.min(x1, x2) + Math.max(x1, x2)) / 2;
902
+ lbl.style.left = cxl + 'px';
903
+ lbl.style.top = (y1 - 16) + 'px';
904
+ lbl.style.transform = 'translateX(-50%)';
905
+ } else {
906
+ var cyl = (Math.min(y1, y2) + Math.max(y1, y2)) / 2;
907
+ lbl.style.left = (x1 + 6) + 'px';
908
+ lbl.style.top = cyl + 'px';
909
+ lbl.style.transform = 'translateY(-50%)';
910
+ }
911
+ measureOverlay.appendChild(lbl);
912
+ }
913
+ }
914
+
915
+ // ─── Neighbor computation (parent-first w/ container fallback) ───────────────
916
+ function edgeToAncestor(targetEl, tr, dir) {
917
+ var cur = targetEl.parentElement;
918
+ var ctx = 'parent';
919
+ for (var i = 0; i < 4 && cur && cur !== document.body; i++) {
920
+ var pr = cur.getBoundingClientRect();
921
+ var gap;
922
+ if (dir === 'top') gap = tr.top - pr.top;
923
+ else if (dir === 'bottom') gap = pr.bottom - tr.bottom;
924
+ else if (dir === 'left') gap = tr.left - pr.left;
925
+ else gap = pr.right - tr.right;
926
+ if (gap > 0.5) {
927
+ var ed;
928
+ if (dir === 'top') ed = pr.top;
929
+ else if (dir === 'bottom') ed = pr.bottom;
930
+ else if (dir === 'left') ed = pr.left;
931
+ else ed = pr.right;
932
+ return { gap: gap, ctx: ctx, edge: ed };
933
+ }
934
+ cur = cur.parentElement;
935
+ ctx = 'container';
936
+ }
937
+ return null;
938
+ }
939
+
940
+ function computeNeighbors(targetEl, tr) {
941
+ var out = { top: null, bottom: null, left: null, right: null };
942
+ var parent = targetEl.parentElement;
943
+ if (parent) {
944
+ var sibs = Array.prototype.slice.call(parent.children).filter(function (c) { return c !== targetEl && c.offsetParent !== null; });
945
+ sibs.forEach(function (sib) {
946
+ var sr = sib.getBoundingClientRect();
947
+ if (sr.bottom <= tr.top) { var g = tr.top - sr.bottom; if (!out.top || g < out.top.gap) out.top = { gap: g, ctx: 'sibling', edge: sr.bottom }; }
948
+ if (sr.top >= tr.bottom) { var g2 = sr.top - tr.bottom; if (!out.bottom || g2 < out.bottom.gap) out.bottom = { gap: g2, ctx: 'sibling', edge: sr.top }; }
949
+ if (sr.right <= tr.left) { var g3 = tr.left - sr.right; if (!out.left || g3 < out.left.gap) out.left = { gap: g3, ctx: 'sibling', edge: sr.right }; }
950
+ if (sr.left >= tr.right) { var g4 = sr.left - tr.right; if (!out.right || g4 < out.right.gap) out.right = { gap: g4, ctx: 'sibling', edge: sr.left }; }
951
+ });
952
+ }
953
+ ['top', 'bottom', 'left', 'right'].forEach(function (k) { if (!out[k]) out[k] = edgeToAncestor(targetEl, tr, k); });
954
+ return out;
955
+ }
956
+
957
+ function measureToNeighbor(targetEl) {
958
+ clearMeasureOverlay();
959
+ var tr = targetEl.getBoundingClientRect();
960
+ var dirs = computeNeighbors(targetEl, tr);
961
+ var cx = (tr.left + tr.right) / 2;
962
+ var cy = (tr.top + tr.bottom) / 2;
963
+ if (dirs.top) drawLine(cx, dirs.top.edge, cx, tr.top, Math.round(dirs.top.gap) + 'px');
964
+ if (dirs.bottom) drawLine(cx, tr.bottom, cx, dirs.bottom.edge, Math.round(dirs.bottom.gap) + 'px');
965
+ if (dirs.left) drawLine(dirs.left.edge, cy, tr.left, cy, Math.round(dirs.left.gap) + 'px');
966
+ if (dirs.right) drawLine(tr.right, cy, dirs.right.edge, cy, Math.round(dirs.right.gap) + 'px');
967
+ measureOverlay.style.display = 'block';
968
+ var tag = targetEl.tagName.toLowerCase();
969
+ var lines = ['⬡ Measure (M pin · Cmd+C copy)', '<' + tag + '> ' + Math.round(tr.width) + '×' + Math.round(tr.height)];
970
+ ['top', 'right', 'bottom', 'left'].forEach(function (k) {
971
+ if (dirs[k]) lines.push(k + ': ' + Math.round(dirs[k].gap) + 'px (to ' + dirs[k].ctx + ')');
972
+ });
973
+ return lines.join('\n');
974
+ }
975
+
976
+ function buildNeighborCopyText(el) {
977
+ var tr = el.getBoundingClientRect();
978
+ var dirs = computeNeighbors(el, tr);
979
+ var lines = ['[Specter Measure]', '<' + el.tagName.toLowerCase() + '> ' + Math.round(tr.width) + '×' + Math.round(tr.height), 'selector: ' + getSelector(el)];
980
+ ['top', 'right', 'bottom', 'left'].forEach(function (k) {
981
+ if (dirs[k]) lines.push(k + ': ' + Math.round(dirs[k].gap) + 'px (to ' + dirs[k].ctx + ')');
982
+ });
983
+ return lines.join('\n');
984
+ }
985
+
986
+ function measureBetween(fromEl, toEl) {
987
+ clearMeasureOverlay();
988
+ var fr = fromEl.getBoundingClientRect();
989
+ var tr = toEl.getBoundingClientRect();
990
+ var fTag = fromEl.tagName.toLowerCase(), tTag = toEl.tagName.toLowerCase();
991
+ var fComp = getComponentName(fromEl), tComp = getComponentName(toEl);
992
+
993
+ var lines = ['⬡ Measure (Cmd+C copy)'];
994
+ lines.push('From: <' + fTag + '>' + (fComp ? ' ' + fComp : '') + ' ' + Math.round(fr.width) + '×' + Math.round(fr.height));
995
+ lines.push('To: <' + tTag + '>' + (tComp ? ' ' + tComp : '') + ' ' + Math.round(tr.width) + '×' + Math.round(tr.height));
996
+
997
+ var fromContainsTo = fr.left <= tr.left && fr.top <= tr.top && fr.right >= tr.right && fr.bottom >= tr.bottom;
998
+ var toContainsFrom = tr.left <= fr.left && tr.top <= fr.top && tr.right >= fr.right && tr.bottom >= fr.bottom;
999
+
1000
+ if (fromContainsTo || toContainsFrom) {
1001
+ var outer = fromContainsTo ? fr : tr;
1002
+ var inner = fromContainsTo ? tr : fr;
1003
+ var cx = (inner.left + inner.right) / 2, cy = (inner.top + inner.bottom) / 2;
1004
+ var iT = inner.top - outer.top, iB = outer.bottom - inner.bottom;
1005
+ var iL = inner.left - outer.left, iR = outer.right - inner.right;
1006
+ if (iT > 0) drawLine(cx, outer.top, cx, inner.top, Math.round(iT) + 'px');
1007
+ if (iB > 0) drawLine(cx, inner.bottom, cx, outer.bottom, Math.round(iB) + 'px');
1008
+ if (iL > 0) drawLine(outer.left, cy, inner.left, cy, Math.round(iL) + 'px');
1009
+ if (iR > 0) drawLine(inner.right, cy, outer.right, cy, Math.round(iR) + 'px');
1010
+ lines.push('inset: ' + Math.round(iT) + 'px ' + Math.round(iR) + 'px ' + Math.round(iB) + 'px ' + Math.round(iL) + 'px');
1011
+ } else {
1012
+ var vGap = fr.bottom <= tr.top ? tr.top - fr.bottom : tr.bottom <= fr.top ? fr.top - tr.bottom : 0;
1013
+ var hGap = fr.right <= tr.left ? tr.left - fr.right : tr.right <= fr.left ? fr.left - tr.right : 0;
1014
+ var cx2 = (fr.left + fr.right) / 2, cy2 = (fr.top + fr.bottom) / 2;
1015
+ if (vGap > 0) {
1016
+ var y1 = fr.bottom <= tr.top ? fr.bottom : tr.bottom;
1017
+ var y2 = fr.bottom <= tr.top ? tr.top : fr.top;
1018
+ drawLine(cx2, y1, cx2, y2, Math.round(vGap) + 'px');
1019
+ lines.push('vertical gap: ' + Math.round(vGap) + 'px');
1020
+ }
1021
+ if (hGap > 0) {
1022
+ var x1 = fr.right <= tr.left ? fr.right : tr.right;
1023
+ var x2 = fr.right <= tr.left ? tr.left : fr.left;
1024
+ drawLine(x1, cy2, x2, cy2, Math.round(hGap) + 'px');
1025
+ lines.push('horizontal gap: ' + Math.round(hGap) + 'px');
1026
+ }
1027
+ if (vGap === 0 && hGap === 0) lines.push('Elements overlap');
1028
+ }
1029
+
1030
+ measureOverlay.style.display = 'block';
1031
+ return lines.join('\n');
1032
+ }
1033
+
1034
+ function buildMeasureCopyText(fromEl, toEl) {
1035
+ var fr = fromEl.getBoundingClientRect(), tr = toEl.getBoundingClientRect();
1036
+ var fromContainsTo = fr.left <= tr.left && fr.top <= tr.top && fr.right >= tr.right && fr.bottom >= tr.bottom;
1037
+ var toContainsFrom = tr.left <= fr.left && tr.top <= fr.top && tr.right >= fr.right && tr.bottom >= fr.bottom;
1038
+ var fTag = fromEl.tagName.toLowerCase(), tTag = toEl.tagName.toLowerCase();
1039
+ var fComp = getComponentName(fromEl), tComp = getComponentName(toEl);
1040
+
1041
+ var lines = ['[Specter Measure]'];
1042
+ lines.push('From: <' + fTag + '>' + (fComp ? ' ' + fComp : '') + ' ' + Math.round(fr.width) + '×' + Math.round(fr.height));
1043
+ lines.push(' selector: ' + getSelector(fromEl));
1044
+ lines.push('To: <' + tTag + '>' + (tComp ? ' ' + tComp : '') + ' ' + Math.round(tr.width) + '×' + Math.round(tr.height));
1045
+ lines.push(' selector: ' + getSelector(toEl));
1046
+
1047
+ if (fromContainsTo || toContainsFrom) {
1048
+ var outer = fromContainsTo ? fr : tr, inner = fromContainsTo ? tr : fr;
1049
+ lines.push('inset: ' + Math.round(inner.top - outer.top) + 'px ' + Math.round(outer.right - inner.right) + 'px ' + Math.round(outer.bottom - inner.bottom) + 'px ' + Math.round(inner.left - outer.left) + 'px');
1050
+ } else {
1051
+ var vGap = fr.bottom <= tr.top ? tr.top - fr.bottom : tr.bottom <= fr.top ? fr.top - tr.bottom : 0;
1052
+ var hGap = fr.right <= tr.left ? tr.left - fr.right : tr.right <= fr.left ? fr.left - tr.right : 0;
1053
+ if (vGap > 0) lines.push('vertical gap: ' + Math.round(vGap) + 'px');
1054
+ if (hGap > 0) lines.push('horizontal gap: ' + Math.round(hGap) + 'px');
1055
+ if (vGap === 0 && hGap === 0) lines.push('Elements overlap');
1056
+ }
1057
+ return lines.join('\n');
1058
+ }
1059
+
1060
+ // ─── Shortcut parser ──────────────────────────────────────────────────────
1061
+ function matchesActivate(e) {
1062
+ var parts = ACTIVATE.toLowerCase().split('+');
1063
+ var needCtrl = parts.indexOf('ctrl') >= 0;
1064
+ var needAlt = parts.indexOf('alt') >= 0;
1065
+ var needShift = parts.indexOf('shift') >= 0;
1066
+ var needMeta = parts.indexOf('meta') >= 0 || parts.indexOf('cmd') >= 0;
1067
+ var key = parts.filter(function (p) { return ['ctrl', 'alt', 'shift', 'meta', 'cmd'].indexOf(p) < 0; })[0];
1068
+ if (needCtrl !== e.ctrlKey) return false;
1069
+ if (needAlt !== e.altKey) return false;
1070
+ if (needShift !== e.shiftKey) return false;
1071
+ if (needMeta !== e.metaKey) return false;
1072
+ if (!key) return false;
1073
+ var eKey = e.key.toLowerCase();
1074
+ var eCode = e.code.toLowerCase();
1075
+ return eKey === key || eCode === 'key' + key || (key === 'period' && (eKey === '.' || eCode === 'period'));
1076
+ }
1077
+
1078
+ // ─── Mouse ────────────────────────────────────────────────────────────────
1079
+ function onMouseMove(e) {
1080
+ if (!fiActive) return;
1081
+ lastMouse.x = e.clientX; lastMouse.y = e.clientY;
1082
+ var target = e.target;
1083
+ if (!target || target === pillWrap || pillWrap.contains(target)) return;
1084
+ if (target === tooltip || tooltip.contains(target)) return;
1085
+ if (noteInputEl && (target === noteInputEl || noteInputEl.contains(target))) return;
1086
+
1087
+ lastHovered = target;
1088
+
1089
+ if (measureMode) {
1090
+ clearHoverOutline();
1091
+ showMeasureTargetHL(target);
1092
+ var text = (pinEl && pinEl !== target) ? measureBetween(pinEl, target) : measureToNeighbor(target);
1093
+ tooltip.innerHTML = linesToHTML(text);
1094
+ positionTooltip(e.clientX, e.clientY);
1095
+ if (pinEl) updatePinHL();
1096
+ } else {
1097
+ clearMeasureOverlay();
1098
+ clearMeasureTargetHL();
1099
+ setHoverOutline(target);
1100
+ tooltip.innerHTML = buildHumanDisplay(buildInfo(target));
1101
+ positionTooltip(e.clientX, e.clientY);
1102
+ }
1103
+ }
1104
+
1105
+ // ─── Keyboard ─────────────────────────────────────────────────────────────
1106
+ function isInputFocused() {
1107
+ var el = document.activeElement;
1108
+ return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
1109
+ }
1110
+
1111
+ document.addEventListener('keydown', function (e) {
1112
+ if (matchesActivate(e)) {
1113
+ e.preventDefault();
1114
+ if (fiActive) deactivate(); else activate();
1115
+ return;
1116
+ }
1117
+
1118
+ if (!fiActive) return;
1119
+
1120
+ // Note editor open: let the field handle its own keys (Enter/Esc) and native copy/paste.
1121
+ if (noteInputEl) return;
1122
+
1123
+ if (e.key === 'Alt' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
1124
+ optionHeld = true;
1125
+ return;
1126
+ }
1127
+
1128
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c' && !e.shiftKey && !e.altKey) {
1129
+ e.preventDefault();
1130
+ var text;
1131
+ if (selectedEls.length > 0) {
1132
+ text = buildMultiSelectCopyText();
1133
+ } else if (measureMode && pinEl && lastHovered && lastHovered !== pinEl) {
1134
+ text = buildMeasureCopyText(pinEl, lastHovered);
1135
+ } else if (measureMode && lastHovered) {
1136
+ text = buildNeighborCopyText(lastHovered);
1137
+ } else if (lastHovered) {
1138
+ text = buildLLMClipboard(buildInfo(lastHovered));
1139
+ }
1140
+ if (text) navigator.clipboard.writeText(text).then(showCopied).catch(function () {});
1141
+ return;
1142
+ }
1143
+
1144
+ if (isInputFocused()) return;
1145
+
1146
+ if (e.key === 'p' || e.key === 'P') {
1147
+ if (!lastHovered) return;
1148
+ clearHoverOutline();
1149
+ toggleSelection(lastHovered);
1150
+ return;
1151
+ }
1152
+
1153
+ if (e.key === 'n' || e.key === 'N') {
1154
+ if (!lastHovered) return;
1155
+ openNoteInput(lastHovered);
1156
+ return;
1157
+ }
1158
+
1159
+ if (e.key === 'm' || e.key === 'M') {
1160
+ if (!measureMode || !lastHovered) return;
1161
+ if (pinEl) {
1162
+ clearPin();
1163
+ collapsePill();
1164
+ } else {
1165
+ setPin(lastHovered);
1166
+ expandPill('Pinned · hover another element · Cmd+C copy · Esc clear');
1167
+ }
1168
+ return;
1169
+ }
1170
+
1171
+ if (e.key === 'Escape') {
1172
+ if (pinEl || selectedEls.length > 0) {
1173
+ clearPin();
1174
+ clearSelection();
1175
+ clearMeasureOverlay();
1176
+ hideTooltip();
1177
+ collapsePill();
1178
+ } else {
1179
+ deactivate();
1180
+ }
1181
+ return;
1182
+ }
1183
+ }, true);
1184
+
1185
+ document.addEventListener('keyup', function (e) {
1186
+ if (!fiActive) return;
1187
+ if (e.key === 'Alt' && optionHeld) {
1188
+ optionHeld = false;
1189
+ measureMode = !measureMode;
1190
+ clearMeasureOverlay();
1191
+ clearHoverOutline();
1192
+ clearMeasureTargetHL();
1193
+ if (!measureMode) clearPin();
1194
+ hideTooltip();
1195
+ flashMode();
1196
+ }
1197
+ }, true);
1198
+
1199
+ document.addEventListener('mousemove', onMouseMove, { passive: true });
1200
+
1201
+ window.__specterToggle = function() { if (fiActive) deactivate(); else activate(); };
1202
+
1203
+ var _rt = (typeof browser !== 'undefined' && browser.runtime) || (typeof chrome !== 'undefined' && chrome.runtime);
1204
+ if (_rt && _rt.onMessage) {
1205
+ _rt.onMessage.addListener(function(msg) {
1206
+ if (msg && msg.type === 'specter-toggle') window.__specterToggle();
1207
+ });
1208
+ }
1209
+
1210
+ console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
1211
+ })();