vite-plugin-specter 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js ADDED
@@ -0,0 +1,735 @@
1
+ (function() {
2
+ 'use strict';
3
+ if (window.__specter) return; window.__specter = true;
4
+
5
+ // ─── State ────────────────────────────────────────────────────────────────
6
+ let fiActive = false;
7
+ let measureMode = false;
8
+ let pinEl = null;
9
+ let lastHovered = null;
10
+ let optionHeld = false;
11
+ const selectedEls = [];
12
+ const selectedBadges = [];
13
+
14
+ const ACTIVATE = "ctrl+alt+z";
15
+
16
+ // ─── Tooltip ──────────────────────────────────────────────────────────────
17
+ const tooltip = document.createElement('div');
18
+ Object.assign(tooltip.style, {
19
+ position: 'fixed',
20
+ zIndex: '2147483646',
21
+ pointerEvents: 'none',
22
+ fontFamily: 'ui-monospace, "Cascadia Code", "Fira Code", monospace',
23
+ fontSize: '12px',
24
+ lineHeight: '1.6',
25
+ background: '#1a1a1a',
26
+ color: '#e8e8e8',
27
+ padding: '12px 16px',
28
+ borderRadius: '8px',
29
+ maxWidth: '560px',
30
+ boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
31
+ whiteSpace: 'pre-wrap',
32
+ wordBreak: 'break-word',
33
+ display: 'none',
34
+ border: '1px solid rgba(255,255,255,0.08)',
35
+ });
36
+ document.body.appendChild(tooltip);
37
+
38
+ // Measure overlay
39
+ const measureOverlay = document.createElement('div');
40
+ Object.assign(measureOverlay.style, {
41
+ position: 'fixed',
42
+ inset: '0',
43
+ zIndex: '2147483645',
44
+ pointerEvents: 'none',
45
+ display: 'none',
46
+ });
47
+ document.body.appendChild(measureOverlay);
48
+
49
+ // ─── Pill ─────────────────────────────────────────────────────────────────
50
+ const pill = document.createElement('div');
51
+ Object.assign(pill.style, {
52
+ position: 'fixed',
53
+ bottom: '16px',
54
+ left: '16px',
55
+ zIndex: '2147483647',
56
+ display: 'none',
57
+ alignItems: 'center',
58
+ gap: '8px',
59
+ background: '#1a1a1a',
60
+ border: '1px solid rgba(255,255,255,0.12)',
61
+ borderRadius: '20px',
62
+ padding: '6px 10px 6px 8px',
63
+ fontFamily: 'ui-monospace, "Cascadia Code", "Fira Code", monospace',
64
+ fontSize: '11px',
65
+ color: '#e8e8e8',
66
+ cursor: 'default',
67
+ userSelect: 'none',
68
+ transition: 'max-width 0.2s ease',
69
+ overflow: 'hidden',
70
+ maxWidth: '32px',
71
+ boxShadow: '0 4px 16px rgba(0,0,0,0.4)',
72
+ });
73
+
74
+ const pillIcon = document.createElement('span');
75
+ pillIcon.textContent = '⚡';
76
+ Object.assign(pillIcon.style, { fontSize: '13px', flexShrink: '0' });
77
+
78
+ const pillText = document.createElement('span');
79
+ Object.assign(pillText.style, { visibility: 'hidden', whiteSpace: 'nowrap', color: '#aaa' });
80
+
81
+ const pillClose = document.createElement('span');
82
+ pillClose.textContent = '×';
83
+ pillClose.title = 'Close Inspekt';
84
+ Object.assign(pillClose.style, {
85
+ cursor: 'pointer',
86
+ color: '#666',
87
+ fontSize: '15px',
88
+ lineHeight: '1',
89
+ visibility: 'hidden',
90
+ flexShrink: '0',
91
+ padding: '0 2px',
92
+ });
93
+ pillClose.addEventListener('click', (e) => { e.stopPropagation(); deactivate(); });
94
+
95
+ pill.appendChild(pillIcon);
96
+ pill.appendChild(pillText);
97
+ pill.appendChild(pillClose);
98
+ document.body.appendChild(pill);
99
+
100
+ pill.addEventListener('mouseenter', () => {
101
+ clearMeasureOverlay();
102
+ hideTooltip();
103
+ expandPill();
104
+ });
105
+ pill.addEventListener('mouseleave', () => {
106
+ if (selectedEls.length === 0 && !pinEl) collapsePill();
107
+ });
108
+
109
+ function expandPill(text) {
110
+ pill.style.maxWidth = '700px';
111
+ pillText.style.visibility = 'visible';
112
+ pillClose.style.visibility = 'visible';
113
+ if (text) {
114
+ pillText.textContent = text;
115
+ return;
116
+ }
117
+ if (selectedEls.length > 0) {
118
+ pillText.textContent = `${selectedEls.length} selected · Cmd+C → copy all · Esc → clear`;
119
+ } else if (measureMode) {
120
+ pillText.textContent = 'Measure · hover → distances · M → pin · Cmd+C → copy · Option → toggle mode';
121
+ } else {
122
+ pillText.textContent = 'Properties · hover → inspect · P → pick · Cmd+C → copy · Option → measure mode';
123
+ }
124
+ }
125
+
126
+ function collapsePill() {
127
+ pill.style.maxWidth = '32px';
128
+ pillText.style.visibility = 'hidden';
129
+ pillClose.style.visibility = 'hidden';
130
+ }
131
+
132
+ function updatePillForSelection() {
133
+ if (selectedEls.length > 0) {
134
+ expandPill();
135
+ } else if (!pill.matches(':hover')) {
136
+ collapsePill();
137
+ }
138
+ }
139
+
140
+ function flashMode() {
141
+ const text = measureMode ? '⬡ Measure mode' : '◉ Properties mode';
142
+ expandPill(text);
143
+ setTimeout(() => {
144
+ if (!pill.matches(':hover') && selectedEls.length === 0 && !pinEl) collapsePill();
145
+ else if (selectedEls.length > 0 || pinEl) expandPill();
146
+ }, 1200);
147
+ }
148
+
149
+ // ─── Activate / Deactivate ────────────────────────────────────────────────
150
+ function activate() {
151
+ fiActive = true;
152
+ measureMode = false;
153
+ pill.style.display = 'flex';
154
+ collapsePill();
155
+ }
156
+
157
+ function deactivate() {
158
+ fiActive = false;
159
+ measureMode = false;
160
+ optionHeld = false;
161
+ lastHovered = null;
162
+ clearSelection();
163
+ clearPin();
164
+ pill.style.display = 'none';
165
+ hideTooltip();
166
+ clearMeasureOverlay();
167
+ }
168
+
169
+ // ─── Helpers ──────────────────────────────────────────────────────────────
170
+ function hideTooltip() { tooltip.style.display = 'none'; }
171
+
172
+ function positionTooltip(x, y) {
173
+ tooltip.style.display = 'block';
174
+ const tw = tooltip.offsetWidth;
175
+ const th = tooltip.offsetHeight;
176
+ const vw = window.innerWidth;
177
+ const vh = window.innerHeight;
178
+ const margin = 16;
179
+ let left = x + 16;
180
+ let top = y + 16;
181
+ if (left + tw > vw - margin) left = x - tw - 16;
182
+ if (top + th > vh - margin) top = y - th - 16;
183
+ if (left < margin) left = margin;
184
+ if (top < margin) top = margin;
185
+ tooltip.style.left = left + 'px';
186
+ tooltip.style.top = top + 'px';
187
+ }
188
+
189
+ function toHex(r, g, b) {
190
+ return '#' + [r, g, b].map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
191
+ }
192
+
193
+ function parseColor(str) {
194
+ if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return null;
195
+ const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
196
+ if (!m) return { hex: str, alpha: 1 };
197
+ const [, r, g, b, a = '1'] = m;
198
+ return { hex: toHex(r, g, b), alpha: parseFloat(a) };
199
+ }
200
+
201
+ function colorLabel(str) {
202
+ const c = parseColor(str);
203
+ if (!c) return null;
204
+ if (c.alpha < 1) return `${c.hex} (${Math.round(c.alpha * 100)}% opacity)`;
205
+ return c.hex;
206
+ }
207
+
208
+ function getComponentName(el) {
209
+ for (const key of Object.keys(el)) {
210
+ if (key.startsWith('__reactFiber$') || key.startsWith('__reactInternalInstance$')) {
211
+ let fiber = el[key];
212
+ while (fiber) {
213
+ const name = fiber.type?.displayName || fiber.type?.name;
214
+ if (name && name.length > 3 && /^[A-Z]/.test(name)) return name;
215
+ fiber = fiber.return;
216
+ }
217
+ }
218
+ }
219
+ if (el.__vueParentComponent) {
220
+ const name = el.__vueParentComponent.type?.name || el.__vueParentComponent.type?.__name;
221
+ if (name && name.length > 3) return name;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function getMatchingRules(el) {
227
+ const results = [];
228
+ try {
229
+ for (const sheet of document.styleSheets) {
230
+ let rules;
231
+ try { rules = sheet.cssRules; } catch { continue; }
232
+ if (!rules) continue;
233
+ for (const rule of rules) {
234
+ if (rule.type !== 1) continue;
235
+ const sel = rule.selectorText;
236
+ if (!sel) continue;
237
+ if (/^[*,]|^:root|^html|^body$|^::before|^::after/.test(sel.trim())) continue;
238
+ try {
239
+ if (el.matches(sel)) {
240
+ const props = [];
241
+ for (let i = 0; i < rule.style.length; i++) {
242
+ const prop = rule.style[i];
243
+ const val = rule.style.getPropertyValue(prop);
244
+ if (val) props.push(` ${prop}: ${val};`);
245
+ }
246
+ if (props.length) results.push(`${sel} {\n${props.join('\n')}\n}`);
247
+ }
248
+ } catch {}
249
+ }
250
+ }
251
+ } catch {}
252
+ return results;
253
+ }
254
+
255
+ function getSelector(el) {
256
+ const parts = [];
257
+ let cur = el;
258
+ for (let i = 0; i < 3 && cur && cur !== document.body; i++) {
259
+ let part = cur.tagName.toLowerCase();
260
+ if (cur.id) { part += '#' + cur.id; parts.unshift(part); break; }
261
+ if (cur.classList.length) part += '.' + Array.from(cur.classList).slice(0, 2).join('.');
262
+ parts.unshift(part);
263
+ cur = cur.parentElement;
264
+ }
265
+ return parts.join(' > ');
266
+ }
267
+
268
+ // ─── Build info ───────────────────────────────────────────────────────────
269
+ function buildInfo(el) {
270
+ const cs = getComputedStyle(el);
271
+ const rect = el.getBoundingClientRect();
272
+ const tag = el.tagName.toLowerCase();
273
+ const comp = getComponentName(el);
274
+ const dataStyle = el.dataset?.style;
275
+ const lines = [];
276
+
277
+ let header = `<${tag}>`;
278
+ if (comp) header += ` ${comp}`;
279
+ if (dataStyle) header += ` [${dataStyle}]`;
280
+ header += ` ${Math.round(rect.width)}×${Math.round(rect.height)}`;
281
+ lines.push(header);
282
+
283
+ const text = el.textContent?.trim().slice(0, 40);
284
+ if (text) lines.push(`"${text}${el.textContent.trim().length > 40 ? '…' : ''}"`);
285
+
286
+ const ff = cs.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
287
+ lines.push(`font: ${ff} ${cs.fontWeight} ${cs.fontSize}/${cs.lineHeight}`);
288
+
289
+ const color = colorLabel(cs.color);
290
+ if (color) lines.push(`color: ${color}`);
291
+
292
+ const bg = colorLabel(cs.backgroundColor);
293
+ if (bg) lines.push(`bg: ${bg}`);
294
+
295
+ const [pt, pr, pb, pl] = [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft];
296
+ if ([pt, pr, pb, pl].some(v => v !== '0px')) lines.push(`padding: ${pt} ${pr} ${pb} ${pl}`);
297
+
298
+ const [mt, mr, mb, ml] = [cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft];
299
+ if ([mt, mr, mb, ml].some(v => v !== '0px')) lines.push(`margin: ${mt} ${mr} ${mb} ${ml}`);
300
+
301
+ if (cs.borderRadius && cs.borderRadius !== '0px') lines.push(`radius: ${cs.borderRadius}`);
302
+
303
+ const disp = cs.display;
304
+ if (['flex','grid','inline-flex','inline-grid'].includes(disp)) {
305
+ let d = `display: ${disp}`;
306
+ if (cs.gap && cs.gap !== 'normal') d += ` gap: ${cs.gap}`;
307
+ if (disp.includes('flex') && cs.flexDirection !== 'row') d += ` dir: ${cs.flexDirection}`;
308
+ lines.push(d);
309
+ }
310
+
311
+ return lines.join('\n');
312
+ }
313
+
314
+ function buildCopyText(el) {
315
+ const info = buildInfo(el);
316
+ const rules = getMatchingRules(el);
317
+ let out = `[Specter]\n${info}\n selector: ${getSelector(el)}`;
318
+ if (rules.length) out += `\n\n--- CSS Rules ---\n${rules.join('\n\n')}`;
319
+ return out;
320
+ }
321
+
322
+ function buildMultiSelectCopyText() {
323
+ return selectedEls.map((el, i) => {
324
+ const info = buildInfo(el);
325
+ const rules = getMatchingRules(el);
326
+ let out = `[Inspekt ${i + 1}/${selectedEls.length}]\n${info}\n selector: ${getSelector(el)}`;
327
+ if (rules.length) out += `\n\n--- CSS Rules ---\n${rules.join('\n\n')}`;
328
+ return out;
329
+ }).join('\n\n' + '─'.repeat(40) + '\n\n');
330
+ }
331
+
332
+ // ─── Multi-select ─────────────────────────────────────────────────────────
333
+ function makeBadge(el, index) {
334
+ const rect = el.getBoundingClientRect();
335
+ const badge = document.createElement('div');
336
+ badge.textContent = String(index + 1);
337
+ Object.assign(badge.style, {
338
+ position: 'fixed',
339
+ left: rect.left + 'px',
340
+ top: rect.top + 'px',
341
+ width: '18px',
342
+ height: '18px',
343
+ background: '#9b59b6',
344
+ color: '#fff',
345
+ fontSize: '10px',
346
+ fontWeight: '700',
347
+ fontFamily: 'ui-monospace, monospace',
348
+ borderRadius: '50%',
349
+ display: 'flex',
350
+ alignItems: 'center',
351
+ justifyContent: 'center',
352
+ zIndex: '2147483644',
353
+ pointerEvents: 'none',
354
+ boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
355
+ transform: 'translate(-50%, -50%)',
356
+ });
357
+ document.body.appendChild(badge);
358
+ return badge;
359
+ }
360
+
361
+ function rebuildBadges() {
362
+ selectedBadges.forEach(b => b.remove());
363
+ selectedBadges.length = 0;
364
+ selectedEls.forEach((el, i) => {
365
+ selectedBadges.push(makeBadge(el, i));
366
+ });
367
+ }
368
+
369
+ function toggleSelection(el) {
370
+ const idx = selectedEls.indexOf(el);
371
+ if (idx >= 0) {
372
+ selectedEls.splice(idx, 1);
373
+ } else {
374
+ selectedEls.push(el);
375
+ }
376
+ rebuildBadges();
377
+ updatePillForSelection();
378
+ }
379
+
380
+ function clearSelection() {
381
+ selectedEls.length = 0;
382
+ selectedBadges.forEach(b => b.remove());
383
+ selectedBadges.length = 0;
384
+ }
385
+
386
+ // ─── Measure mode ─────────────────────────────────────────────────────────
387
+ let pinHighlight = null;
388
+
389
+ function clearPin() {
390
+ if (pinHighlight) { pinHighlight.remove(); pinHighlight = null; }
391
+ pinEl = null;
392
+ }
393
+
394
+ function clearMeasureOverlay() {
395
+ measureOverlay.innerHTML = '';
396
+ measureOverlay.style.display = 'none';
397
+ }
398
+
399
+ function drawLine(x1, y1, x2, y2, label) {
400
+ const isHoriz = Math.abs(y2 - y1) < 1;
401
+ const line = document.createElement('div');
402
+ Object.assign(line.style, {
403
+ position: 'absolute',
404
+ background: '#e53e3e',
405
+ pointerEvents: 'none',
406
+ });
407
+ if (isHoriz) {
408
+ Object.assign(line.style, {
409
+ left: Math.min(x1, x2) + 'px', top: (y1 - 0.5) + 'px',
410
+ width: Math.abs(x2 - x1) + 'px', height: '1px',
411
+ });
412
+ } else {
413
+ Object.assign(line.style, {
414
+ left: (x1 - 0.5) + 'px', top: Math.min(y1, y2) + 'px',
415
+ width: '1px', height: Math.abs(y2 - y1) + 'px',
416
+ });
417
+ }
418
+ measureOverlay.appendChild(line);
419
+
420
+ if (label && label !== '0px') {
421
+ const lbl = document.createElement('div');
422
+ lbl.textContent = label;
423
+ Object.assign(lbl.style, {
424
+ position: 'absolute',
425
+ background: '#e53e3e',
426
+ color: '#fff',
427
+ fontSize: '10px',
428
+ fontFamily: 'ui-monospace, monospace',
429
+ padding: '1px 4px',
430
+ borderRadius: '3px',
431
+ pointerEvents: 'none',
432
+ whiteSpace: 'nowrap',
433
+ });
434
+ if (isHoriz) {
435
+ const cx = (Math.min(x1, x2) + Math.max(x1, x2)) / 2;
436
+ lbl.style.left = cx + 'px';
437
+ lbl.style.top = (y1 - 14) + 'px';
438
+ lbl.style.transform = 'translateX(-50%)';
439
+ } else {
440
+ const cy = (Math.min(y1, y2) + Math.max(y1, y2)) / 2;
441
+ lbl.style.left = (x1 + 6) + 'px';
442
+ lbl.style.top = cy + 'px';
443
+ lbl.style.transform = 'translateY(-50%)';
444
+ }
445
+ measureOverlay.appendChild(lbl);
446
+ }
447
+ }
448
+
449
+ function highlightEl(el, color) {
450
+ const rect = el.getBoundingClientRect();
451
+ const hl = document.createElement('div');
452
+ Object.assign(hl.style, {
453
+ position: 'fixed',
454
+ left: rect.left + 'px', top: rect.top + 'px',
455
+ width: rect.width + 'px', height: rect.height + 'px',
456
+ border: `2px solid ${color}`,
457
+ borderRadius: '2px',
458
+ pointerEvents: 'none',
459
+ zIndex: '2147483644',
460
+ boxSizing: 'border-box',
461
+ });
462
+ document.body.appendChild(hl);
463
+ return hl;
464
+ }
465
+
466
+ function measureToNeighbor(targetEl) {
467
+ clearMeasureOverlay();
468
+ const tr = targetEl.getBoundingClientRect();
469
+ let measured = false;
470
+ let cur = targetEl.parentElement;
471
+
472
+ for (let depth = 0; depth < 3 && cur && !measured; depth++) {
473
+ const children = Array.from(cur.children).filter(c => c !== targetEl && c.offsetParent !== null);
474
+ if (children.length === 0) { cur = cur.parentElement; continue; }
475
+
476
+ const closest = { top: null, bottom: null, left: null, right: null };
477
+ const gaps = { top: Infinity, bottom: Infinity, left: Infinity, right: Infinity };
478
+
479
+ for (const sib of children) {
480
+ const sr = sib.getBoundingClientRect();
481
+ if (sr.bottom <= tr.top) {
482
+ const g = tr.top - sr.bottom;
483
+ if (g < gaps.top) { gaps.top = g; closest.top = sib; }
484
+ }
485
+ if (sr.top >= tr.bottom) {
486
+ const g = sr.top - tr.bottom;
487
+ if (g < gaps.bottom) { gaps.bottom = g; closest.bottom = sib; }
488
+ }
489
+ if (sr.right <= tr.left) {
490
+ const g = tr.left - sr.right;
491
+ if (g < gaps.left) { gaps.left = g; closest.left = sib; }
492
+ }
493
+ if (sr.left >= tr.right) {
494
+ const g = sr.left - tr.right;
495
+ if (g < gaps.right) { gaps.right = g; closest.right = sib; }
496
+ }
497
+ }
498
+
499
+ if (closest.top || closest.bottom || closest.left || closest.right) {
500
+ measured = true;
501
+ const cx = (tr.left + tr.right) / 2;
502
+ const cy = (tr.top + tr.bottom) / 2;
503
+ if (closest.top) drawLine(cx, closest.top.getBoundingClientRect().bottom, cx, tr.top, Math.round(gaps.top) + 'px');
504
+ if (closest.bottom) drawLine(cx, tr.bottom, cx, closest.bottom.getBoundingClientRect().top, Math.round(gaps.bottom) + 'px');
505
+ if (closest.left) drawLine(closest.left.getBoundingClientRect().right, cy, tr.left, cy, Math.round(gaps.left) + 'px');
506
+ if (closest.right) drawLine(tr.right, cy, closest.right.getBoundingClientRect().left, cy, Math.round(gaps.right) + 'px');
507
+ } else {
508
+ const pr = cur.getBoundingClientRect();
509
+ const cx = (tr.left + tr.right) / 2;
510
+ const cy = (tr.top + tr.bottom) / 2;
511
+ const gT = tr.top - pr.top, gB = pr.bottom - tr.bottom;
512
+ const gL = tr.left - pr.left, gR = pr.right - tr.right;
513
+ if (gT > 0) drawLine(cx, pr.top, cx, tr.top, Math.round(gT) + 'px');
514
+ if (gB > 0) drawLine(cx, tr.bottom, cx, pr.bottom, Math.round(gB) + 'px');
515
+ if (gL > 0) drawLine(pr.left, cy, tr.left, cy, Math.round(gL) + 'px');
516
+ if (gR > 0) drawLine(tr.right, cy, pr.right, cy, Math.round(gR) + 'px');
517
+ measured = true;
518
+ }
519
+
520
+ cur = cur.parentElement;
521
+ }
522
+
523
+ measureOverlay.style.display = 'block';
524
+ const tag = targetEl.tagName.toLowerCase();
525
+ const rect = targetEl.getBoundingClientRect();
526
+ return `⬡ Measure (M → pin element)\n<${tag}> ${Math.round(rect.width)}×${Math.round(rect.height)}`;
527
+ }
528
+
529
+ function measureBetween(fromEl, toEl) {
530
+ clearMeasureOverlay();
531
+ const fr = fromEl.getBoundingClientRect();
532
+ const tr = toEl.getBoundingClientRect();
533
+ const fTag = fromEl.tagName.toLowerCase(), tTag = toEl.tagName.toLowerCase();
534
+ const fComp = getComponentName(fromEl), tComp = getComponentName(toEl);
535
+
536
+ const lines = ['⬡ Measure (Cmd+C → copy)'];
537
+ lines.push(`From: <${fTag}>${fComp ? ' ' + fComp : ''} ${Math.round(fr.width)}×${Math.round(fr.height)}`);
538
+ lines.push(`To: <${tTag}>${tComp ? ' ' + tComp : ''} ${Math.round(tr.width)}×${Math.round(tr.height)}`);
539
+
540
+ const fromContainsTo = fr.left <= tr.left && fr.top <= tr.top && fr.right >= tr.right && fr.bottom >= tr.bottom;
541
+ const toContainsFrom = tr.left <= fr.left && tr.top <= fr.top && tr.right >= fr.right && tr.bottom >= fr.bottom;
542
+
543
+ if (fromContainsTo || toContainsFrom) {
544
+ const outer = fromContainsTo ? fr : tr;
545
+ const inner = fromContainsTo ? tr : fr;
546
+ const cx = (inner.left + inner.right) / 2, cy = (inner.top + inner.bottom) / 2;
547
+ const iT = inner.top - outer.top, iB = outer.bottom - inner.bottom;
548
+ const iL = inner.left - outer.left, iR = outer.right - inner.right;
549
+ if (iT > 0) drawLine(cx, outer.top, cx, inner.top, Math.round(iT) + 'px');
550
+ if (iB > 0) drawLine(cx, inner.bottom, cx, outer.bottom, Math.round(iB) + 'px');
551
+ if (iL > 0) drawLine(outer.left, cy, inner.left, cy, Math.round(iL) + 'px');
552
+ if (iR > 0) drawLine(inner.right, cy, outer.right, cy, Math.round(iR) + 'px');
553
+ lines.push(`inset: ${Math.round(iT)}px ${Math.round(iR)}px ${Math.round(iB)}px ${Math.round(iL)}px`);
554
+ } else {
555
+ const vGap = fr.bottom <= tr.top ? tr.top - fr.bottom : tr.bottom <= fr.top ? fr.top - tr.bottom : 0;
556
+ const hGap = fr.right <= tr.left ? tr.left - fr.right : tr.right <= fr.left ? fr.left - tr.right : 0;
557
+ const cx = (fr.left + fr.right) / 2, cy = (fr.top + fr.bottom) / 2;
558
+ if (vGap > 0) {
559
+ const y1 = fr.bottom <= tr.top ? fr.bottom : tr.bottom;
560
+ const y2 = fr.bottom <= tr.top ? tr.top : fr.top;
561
+ drawLine(cx, y1, cx, y2, Math.round(vGap) + 'px');
562
+ lines.push(`vertical gap: ${Math.round(vGap)}px (${fr.bottom <= tr.top ? 'From above To' : 'To above From'})`);
563
+ }
564
+ if (hGap > 0) {
565
+ const x1 = fr.right <= tr.left ? fr.right : tr.right;
566
+ const x2 = fr.right <= tr.left ? tr.left : fr.left;
567
+ drawLine(x1, cy, x2, cy, Math.round(hGap) + 'px');
568
+ lines.push(`horizontal gap: ${Math.round(hGap)}px`);
569
+ }
570
+ if (vGap === 0 && hGap === 0) lines.push('Elements overlap');
571
+ }
572
+
573
+ measureOverlay.style.display = 'block';
574
+ return lines.join('\n');
575
+ }
576
+
577
+ function buildMeasureCopyText(fromEl, toEl) {
578
+ const fr = fromEl.getBoundingClientRect(), tr = toEl.getBoundingClientRect();
579
+ const fromContainsTo = fr.left <= tr.left && fr.top <= tr.top && fr.right >= tr.right && fr.bottom >= tr.bottom;
580
+ const toContainsFrom = tr.left <= fr.left && tr.top <= fr.top && tr.right >= fr.right && tr.bottom >= fr.bottom;
581
+ const fTag = fromEl.tagName.toLowerCase(), tTag = toEl.tagName.toLowerCase();
582
+ const fComp = getComponentName(fromEl), tComp = getComponentName(toEl);
583
+ const fText = fromEl.textContent?.trim().slice(0, 40);
584
+ const tText = toEl.textContent?.trim().slice(0, 40);
585
+
586
+ const lines = ['[Inspekt Measure]'];
587
+ lines.push(`From: <${fTag}>${fComp ? ' ' + fComp : ''} ${Math.round(fr.width)}×${Math.round(fr.height)}${fText ? ' "' + fText + '"' : ''}`);
588
+ lines.push(` selector: ${getSelector(fromEl)}`);
589
+ lines.push(`To: <${tTag}>${tComp ? ' ' + tComp : ''} ${Math.round(tr.width)}×${Math.round(tr.height)}${tText ? ' "' + tText + '"' : ''}`);
590
+ lines.push(` selector: ${getSelector(toEl)}`);
591
+
592
+ if (fromContainsTo || toContainsFrom) {
593
+ const outer = fromContainsTo ? fr : tr, inner = fromContainsTo ? tr : fr;
594
+ 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`);
595
+ } else {
596
+ const vGap = fr.bottom <= tr.top ? tr.top - fr.bottom : tr.bottom <= fr.top ? fr.top - tr.bottom : 0;
597
+ const hGap = fr.right <= tr.left ? tr.left - fr.right : tr.right <= fr.left ? fr.left - tr.right : 0;
598
+ if (vGap > 0) lines.push(`vertical gap: ${Math.round(vGap)}px (${fr.bottom <= tr.top ? 'From above To' : 'To above From'})`);
599
+ if (hGap > 0) lines.push(`horizontal gap: ${Math.round(hGap)}px`);
600
+ if (vGap === 0 && hGap === 0) lines.push('Elements overlap');
601
+ }
602
+ return lines.join('\n');
603
+ }
604
+
605
+ // ─── Shortcut parser ──────────────────────────────────────────────────────
606
+ function matchesActivate(e) {
607
+ const parts = ACTIVATE.toLowerCase().split('+');
608
+ const needCtrl = parts.includes('ctrl');
609
+ const needAlt = parts.includes('alt');
610
+ const needShift = parts.includes('shift');
611
+ const needMeta = parts.includes('meta') || parts.includes('cmd');
612
+ const key = parts.find(p => !['ctrl','alt','shift','meta','cmd'].includes(p));
613
+ if (needCtrl !== e.ctrlKey) return false;
614
+ if (needAlt !== e.altKey) return false;
615
+ if (needShift !== e.shiftKey) return false;
616
+ if (needMeta !== e.metaKey) return false;
617
+ if (!key) return false;
618
+ const eKey = e.key.toLowerCase();
619
+ return eKey === key || (key === 'period' && eKey === '.');
620
+ }
621
+
622
+ // ─── Mouse ────────────────────────────────────────────────────────────────
623
+ function onMouseMove(e) {
624
+ if (!fiActive) return;
625
+ const target = e.target;
626
+ if (!target || target === pill || pill.contains(target)) return;
627
+ if (target === tooltip || tooltip.contains(target)) return;
628
+
629
+ lastHovered = target;
630
+
631
+ if (measureMode) {
632
+ const text = (pinEl && pinEl !== target)
633
+ ? measureBetween(pinEl, target)
634
+ : measureToNeighbor(target);
635
+ tooltip.textContent = text;
636
+ positionTooltip(e.clientX, e.clientY);
637
+ } else {
638
+ clearMeasureOverlay();
639
+ tooltip.textContent = buildInfo(target);
640
+ positionTooltip(e.clientX, e.clientY);
641
+ }
642
+ }
643
+
644
+ // ─── Keyboard ─────────────────────────────────────────────────────────────
645
+ function isInputFocused() {
646
+ const el = document.activeElement;
647
+ return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable);
648
+ }
649
+
650
+ document.addEventListener('keydown', (e) => {
651
+ if (matchesActivate(e)) {
652
+ e.preventDefault();
653
+ if (fiActive) deactivate(); else activate();
654
+ return;
655
+ }
656
+
657
+ if (!fiActive) return;
658
+
659
+ // Option tap (keydown only — actual toggle fires on keyup)
660
+ if (e.key === 'Alt' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
661
+ optionHeld = true;
662
+ return;
663
+ }
664
+
665
+ // Cmd+C — copy
666
+ if ((e.metaKey || e.ctrlKey) && e.key === 'c' && !e.shiftKey && !e.altKey) {
667
+ e.preventDefault();
668
+ let text;
669
+ if (selectedEls.length > 0) {
670
+ text = buildMultiSelectCopyText();
671
+ } else if (measureMode && pinEl && lastHovered && lastHovered !== pinEl) {
672
+ text = buildMeasureCopyText(pinEl, lastHovered);
673
+ } else if (lastHovered) {
674
+ text = buildCopyText(lastHovered);
675
+ }
676
+ if (text) navigator.clipboard.writeText(text).catch(() => {});
677
+ return;
678
+ }
679
+
680
+ if (isInputFocused()) return;
681
+
682
+ // P — pick/unpick element (multi-select), Properties mode only
683
+ if (e.key === 'p' || e.key === 'P') {
684
+ if (measureMode || !lastHovered) return;
685
+ toggleSelection(lastHovered);
686
+ return;
687
+ }
688
+
689
+ // M — pin element for measure-from, Measure mode only
690
+ if (e.key === 'm' || e.key === 'M') {
691
+ if (!measureMode || !lastHovered) return;
692
+ if (pinEl) {
693
+ clearPin();
694
+ collapsePill();
695
+ } else {
696
+ pinEl = lastHovered;
697
+ pinHighlight = highlightEl(pinEl, '#9b59b6');
698
+ expandPill('Pinned · hover another element · Cmd+C → copy · Esc → clear');
699
+ }
700
+ return;
701
+ }
702
+
703
+ // Escape cascade: pin → selection → exit
704
+ if (e.key === 'Escape') {
705
+ if (pinEl) {
706
+ clearPin();
707
+ clearMeasureOverlay();
708
+ hideTooltip();
709
+ updatePillForSelection();
710
+ } else if (selectedEls.length > 0) {
711
+ clearSelection();
712
+ collapsePill();
713
+ } else {
714
+ deactivate();
715
+ }
716
+ return;
717
+ }
718
+ }, true);
719
+
720
+ document.addEventListener('keyup', (e) => {
721
+ if (!fiActive) return;
722
+ if (e.key === 'Alt' && optionHeld) {
723
+ optionHeld = false;
724
+ measureMode = !measureMode;
725
+ clearMeasureOverlay();
726
+ if (!measureMode) clearPin();
727
+ hideTooltip();
728
+ flashMode();
729
+ }
730
+ }, true);
731
+
732
+ document.addEventListener('mousemove', onMouseMove, { passive: true });
733
+
734
+ console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
735
+ })();