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