vite-plugin-specter 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,7 +3,13 @@ function getClientScript(options) {
3
3
  const activateShortcut = options.shortcuts?.activate ?? "ctrl+alt+z";
4
4
  return `(function() {
5
5
  'use strict';
6
- if (window.__specter) return; window.__specter = true;
6
+ // Guard against running twice on one page. Use a DOM marker (not just a
7
+ // window global) so the browser-extension build (isolated world) and the
8
+ // Vite-plugin build (page main world) can see each other and only one runs \u2014
9
+ // they share the DOM but not window globals, so a per-world flag misses.
10
+ if (window.__specter || document.documentElement.hasAttribute('data-specter')) return;
11
+ window.__specter = true;
12
+ document.documentElement.setAttribute('data-specter', '');
7
13
 
8
14
  // \u2500\u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
9
15
  var PURPLE = '#AD24D3';
@@ -13,6 +19,7 @@ function getClientScript(options) {
13
19
  var LABEL = '#8B8D94';
14
20
  var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
15
21
  var ZAP = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>';
22
+ 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>';
16
23
  var ACTIVATE = ${JSON.stringify(activateShortcut)};
17
24
 
18
25
  // \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
@@ -32,6 +39,9 @@ function getClientScript(options) {
32
39
  var lastMouse = { x: 0, y: 0 };
33
40
  var selectedEls = [];
34
41
  var selectedBadges = [];
42
+ var noteMap = new Map();
43
+ var noteInputEl = null;
44
+ var noteTargetEl = null;
35
45
 
36
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
37
47
  var tooltip = document.createElement('div');
@@ -191,11 +201,11 @@ function getClientScript(options) {
191
201
  pillExpanded = true;
192
202
  if (text) { pillText.textContent = text; return; }
193
203
  if (selectedEls.length > 0) {
194
- pillText.textContent = selectedEls.length + ' selected \xB7 Cmd+C copy all \xB7 Esc clear';
204
+ pillText.textContent = selectedEls.length + ' selected \xB7 N annotate \xB7 Cmd+C copy all \xB7 Esc clear';
195
205
  } else if (measureMode) {
196
206
  pillText.textContent = 'Measure \xB7 hover distances \xB7 M pin \xB7 Cmd+C copy \xB7 Option toggle';
197
207
  } else {
198
- pillText.textContent = 'Properties \xB7 hover inspect \xB7 P pick \xB7 Cmd+C copy \xB7 Option measure';
208
+ pillText.textContent = 'Properties \xB7 hover \xB7 P pick \xB7 N annotate \xB7 Cmd+C copy \xB7 Option measure';
199
209
  }
200
210
  }
201
211
 
@@ -315,6 +325,55 @@ function getClientScript(options) {
315
325
  return null;
316
326
  }
317
327
 
328
+ // Collapse a rule's declarations to their shortest lossless form: drop
329
+ // 'initial' (= default, no signal) and always-'normal' noise, and fold
330
+ // side/corner longhands back into shorthands (margin/padding/radius/gap/transition).
331
+ function cleanDecls(style) {
332
+ var m = {}, order = [];
333
+ for (var i = 0; i < style.length; i++) {
334
+ var p = style[i];
335
+ var v = style.getPropertyValue(p);
336
+ if (!v) continue;
337
+ if (v === 'initial') continue;
338
+ if (p === 'transition-behavior') continue;
339
+ if (!(p in m)) order.push(p);
340
+ m[p] = v;
341
+ }
342
+ var used = {}, collapses = {};
343
+ function four(name, t, r, b, l) {
344
+ if (m[t] === undefined || m[r] === undefined || m[b] === undefined || m[l] === undefined) return;
345
+ used[t] = used[r] = used[b] = used[l] = 1;
346
+ var a = m[t], c = m[r], d = m[b], e = m[l], sh;
347
+ if (a === c && c === d && d === e) sh = a;
348
+ else if (a === d && c === e) sh = a + ' ' + c;
349
+ else if (c === e) sh = a + ' ' + c + ' ' + d;
350
+ else sh = a + ' ' + c + ' ' + d + ' ' + e;
351
+ collapses[t] = name + ': ' + sh;
352
+ }
353
+ four('margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left');
354
+ four('padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left');
355
+ four('border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius');
356
+ if (m['row-gap'] !== undefined && m['column-gap'] !== undefined) {
357
+ var g = m['row-gap'] === m['column-gap'] ? m['row-gap'] : m['row-gap'] + ' ' + m['column-gap'];
358
+ collapses['row-gap'] = 'gap: ' + g; used['row-gap'] = used['column-gap'] = 1;
359
+ }
360
+ if (m['transition-property'] !== undefined) {
361
+ var tr = 'transition: ' + m['transition-property'];
362
+ if (m['transition-duration'] !== undefined) tr += ' ' + m['transition-duration'];
363
+ if (m['transition-timing-function'] !== undefined) tr += ' ' + m['transition-timing-function'];
364
+ if (m['transition-delay'] !== undefined && m['transition-delay'] !== '0s') tr += ' ' + m['transition-delay'];
365
+ collapses['transition-property'] = tr;
366
+ used['transition-property'] = used['transition-duration'] = used['transition-timing-function'] = used['transition-delay'] = 1;
367
+ }
368
+ var out = [];
369
+ order.forEach(function (p) {
370
+ if (collapses[p]) { out.push(' ' + collapses[p] + ';'); return; }
371
+ if (used[p]) return;
372
+ out.push(' ' + p + ': ' + m[p] + ';');
373
+ });
374
+ return out;
375
+ }
376
+
318
377
  function getMatchingRules(el) {
319
378
  var results = [];
320
379
  try {
@@ -331,12 +390,7 @@ function getClientScript(options) {
331
390
  if (/^[*,]|^:root|^html|^body$|^::before|^::after/.test(sel.trim())) continue;
332
391
  try {
333
392
  if (el.matches(sel)) {
334
- var props = [];
335
- for (var i = 0; i < rule.style.length; i++) {
336
- var prop = rule.style[i];
337
- var val = rule.style.getPropertyValue(prop);
338
- if (val) props.push(' ' + prop + ': ' + val + ';');
339
- }
393
+ var props = cleanDecls(rule.style);
340
394
  if (props.length) results.push(sel + ' {\\n' + props.join('\\n') + '\\n}');
341
395
  }
342
396
  } catch (e) {}
@@ -456,9 +510,13 @@ function getClientScript(options) {
456
510
  }
457
511
 
458
512
  function buildMultiSelectCopyText() {
513
+ var n = selectedEls.length;
459
514
  return selectedEls.map(function (el, i) {
460
515
  var body = buildLLMClipboard(buildInfo(el));
461
- return body.replace('[Specter]', '[Specter ' + (i + 1) + '/' + selectedEls.length + ']');
516
+ var note = noteMap.get(el);
517
+ var header = n > 1 ? '[Specter ' + (i + 1) + '/' + n + ']' : '[Specter]';
518
+ if (note) header += '\\n\u270F\uFE0F CHANGE: ' + note;
519
+ return body.replace('[Specter]', header);
462
520
  }).join('\\n\\n' + Array(41).join('\u2500') + '\\n\\n');
463
521
  }
464
522
 
@@ -509,16 +567,25 @@ function getClientScript(options) {
509
567
  }
510
568
 
511
569
  // \u2500\u2500\u2500 Multi-select \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
512
- function makeBadge(el, index) {
570
+ function makeBadge(el, index, note) {
513
571
  var rect = el.getBoundingClientRect();
514
- var badge = document.createElement('div');
515
- badge.textContent = String(index + 1);
516
- Object.assign(badge.style, {
572
+ var wrap = document.createElement('div');
573
+ Object.assign(wrap.style, {
517
574
  position: 'fixed',
518
575
  left: (rect.left - 4) + 'px',
519
576
  top: (rect.top - 4) + 'px',
577
+ zIndex: '2147483644',
578
+ pointerEvents: 'none',
579
+ display: 'flex',
580
+ alignItems: 'flex-start',
581
+ gap: '4px',
582
+ });
583
+ var badge = document.createElement('div');
584
+ badge.textContent = String(index + 1);
585
+ Object.assign(badge.style, {
520
586
  width: '20px',
521
587
  height: '20px',
588
+ flexShrink: '0',
522
589
  background: PURPLE,
523
590
  color: '#fff',
524
591
  fontSize: '11px',
@@ -528,19 +595,38 @@ function getClientScript(options) {
528
595
  display: 'flex',
529
596
  alignItems: 'center',
530
597
  justifyContent: 'center',
531
- zIndex: '2147483644',
532
- pointerEvents: 'none',
533
598
  boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
534
599
  });
535
- document.body.appendChild(badge);
536
- return badge;
600
+ wrap.appendChild(badge);
601
+ if (note) {
602
+ var chip = document.createElement('div');
603
+ chip.textContent = '\u270F\uFE0F ' + (note.length > 32 ? note.slice(0, 32) + '\u2026' : note);
604
+ Object.assign(chip.style, {
605
+ maxWidth: '240px',
606
+ background: TIP_BG,
607
+ color: '#fff',
608
+ fontSize: '10px',
609
+ lineHeight: '16px',
610
+ fontFamily: MONO,
611
+ padding: '2px 6px',
612
+ borderRadius: '4px',
613
+ whiteSpace: 'nowrap',
614
+ overflow: 'hidden',
615
+ textOverflow: 'ellipsis',
616
+ border: '1px solid ' + PURPLE,
617
+ boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
618
+ });
619
+ wrap.appendChild(chip);
620
+ }
621
+ document.body.appendChild(wrap);
622
+ return wrap;
537
623
  }
538
624
 
539
625
  function rebuildBadges() {
540
626
  selectedBadges.forEach(function (b) { b.remove(); });
541
627
  selectedBadges.length = 0;
542
628
  selectedEls.forEach(function (el, i) {
543
- selectedBadges.push(makeBadge(el, i));
629
+ selectedBadges.push(makeBadge(el, i, noteMap.get(el)));
544
630
  });
545
631
  }
546
632
 
@@ -549,6 +635,7 @@ function getClientScript(options) {
549
635
  if (idx >= 0) {
550
636
  selectedEls.splice(idx, 1);
551
637
  el.style.outline = '';
638
+ noteMap.delete(el);
552
639
  } else {
553
640
  selectedEls.push(el);
554
641
  el.style.outline = '2px solid ' + PURPLE;
@@ -559,10 +646,12 @@ function getClientScript(options) {
559
646
  }
560
647
 
561
648
  function clearSelection() {
649
+ closeNoteInput();
562
650
  selectedEls.forEach(function (el) { el.style.outline = ''; el.style.outlineOffset = ''; });
563
651
  selectedEls.length = 0;
564
652
  selectedBadges.forEach(function (b) { b.remove(); });
565
653
  selectedBadges.length = 0;
654
+ noteMap.clear();
566
655
  }
567
656
 
568
657
  function updatePillForSelection() {
@@ -570,6 +659,142 @@ function getClientScript(options) {
570
659
  else if (!pillWrap.matches(':hover')) collapsePill();
571
660
  }
572
661
 
662
+ // \u2500\u2500\u2500 Annotations (inline change notes) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
663
+ function openNoteInput(el) {
664
+ closeNoteInput();
665
+ if (selectedEls.indexOf(el) < 0) {
666
+ clearHoverOutline();
667
+ hideTooltip();
668
+ selectedEls.push(el);
669
+ el.style.outline = '2px solid ' + PURPLE;
670
+ el.style.outlineOffset = '-1px';
671
+ }
672
+ noteTargetEl = el;
673
+ var rect = el.getBoundingClientRect();
674
+
675
+ var box = document.createElement('div');
676
+ Object.assign(box.style, {
677
+ position: 'fixed',
678
+ zIndex: '2147483647',
679
+ display: 'inline-flex',
680
+ alignItems: 'flex-start',
681
+ gap: '8px',
682
+ boxSizing: 'border-box',
683
+ background: TIP_BG,
684
+ border: '1px solid ' + PURPLE,
685
+ borderRadius: '8px',
686
+ padding: '11px 12px',
687
+ boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
688
+ fontFamily: MONO,
689
+ });
690
+ var pen = document.createElement('span');
691
+ pen.innerHTML = PENCIL;
692
+ Object.assign(pen.style, {
693
+ display: 'flex',
694
+ alignItems: 'center',
695
+ flexShrink: '0',
696
+ marginTop: '2px',
697
+ color: '#E0A3F5',
698
+ });
699
+ var input = document.createElement('textarea');
700
+ input.rows = 1;
701
+ input.placeholder = 'Describe the change\u2026 (Enter to save)';
702
+ input.value = noteMap.get(el) || '';
703
+ Object.assign(input.style, {
704
+ flexShrink: '0',
705
+ background: 'transparent',
706
+ border: 'none',
707
+ outline: 'none',
708
+ resize: 'none',
709
+ overflow: 'hidden',
710
+ color: '#fff',
711
+ fontFamily: MONO,
712
+ fontSize: '12px',
713
+ lineHeight: '18px',
714
+ padding: '0',
715
+ margin: '0',
716
+ whiteSpace: 'pre-wrap',
717
+ overflowWrap: 'anywhere',
718
+ });
719
+ // Hidden mirror to measure single-line text width so the field grows as you type.
720
+ var meas = document.createElement('span');
721
+ Object.assign(meas.style, {
722
+ position: 'absolute',
723
+ visibility: 'hidden',
724
+ whiteSpace: 'pre',
725
+ pointerEvents: 'none',
726
+ fontFamily: MONO,
727
+ fontSize: '12px',
728
+ left: '-9999px',
729
+ top: '0',
730
+ });
731
+ box.appendChild(pen);
732
+ box.appendChild(input);
733
+ box.appendChild(meas);
734
+ document.body.appendChild(box);
735
+ noteInputEl = box;
736
+
737
+ var margin = 8;
738
+ function textW(t) { meas.textContent = t; return meas.offsetWidth; }
739
+
740
+ // Anchor to the element; the box is repositioned around this as it grows.
741
+ var anchorL = rect.left, anchorT = rect.top, anchorB = rect.bottom;
742
+
743
+ function sizeAndPosition() {
744
+ var vw = window.innerWidth, vh = window.innerHeight;
745
+ var maxBoxW = Math.min(560, vw - margin * 2);
746
+ var maxTextW = Math.max(120, maxBoxW - 55); // room for icon + gaps + padding
747
+ var placeholderW = textW(input.placeholder);
748
+ var minTextW = Math.min(placeholderW, maxTextW);
749
+ var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines, never past viewport
750
+
751
+ // Width: grow with content up to the max, then wrapping takes over.
752
+ var single = textW(input.value || input.placeholder) + 3;
753
+ input.style.width = Math.min(Math.max(single, minTextW), maxTextW) + 'px';
754
+ // Height: fit wrapped content, scroll only in the extreme case.
755
+ input.style.height = 'auto';
756
+ var h = input.scrollHeight;
757
+ if (h > maxTaH) { input.style.height = maxTaH + 'px'; input.style.overflowY = 'auto'; }
758
+ else { input.style.height = h + 'px'; input.style.overflowY = 'hidden'; }
759
+
760
+ // Keep the whole box on screen.
761
+ var bw = box.offsetWidth, bh = box.offsetHeight;
762
+ var left = Math.min(Math.max(anchorL, margin), Math.max(margin, vw - bw - margin));
763
+ var top = anchorT - bh - 8; // prefer sitting above the element
764
+ if (top < margin) top = anchorB + 8; // otherwise below it
765
+ if (top + bh > vh - margin) top = Math.max(margin, vh - bh - margin);
766
+ box.style.left = left + 'px';
767
+ box.style.top = top + 'px';
768
+ }
769
+ sizeAndPosition();
770
+
771
+ input.addEventListener('input', sizeAndPosition);
772
+ input.addEventListener('keydown', function (ev) {
773
+ ev.stopPropagation();
774
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commitNote(input.value); }
775
+ else if (ev.key === 'Escape') { ev.preventDefault(); closeNoteInput(); }
776
+ });
777
+
778
+ rebuildBadges();
779
+ updatePillForSelection();
780
+ setTimeout(function () { input.focus(); }, 0);
781
+ }
782
+
783
+ function commitNote(val) {
784
+ val = (val || '').trim();
785
+ if (noteTargetEl) {
786
+ if (val) noteMap.set(noteTargetEl, val);
787
+ else noteMap.delete(noteTargetEl);
788
+ }
789
+ closeNoteInput();
790
+ rebuildBadges();
791
+ }
792
+
793
+ function closeNoteInput() {
794
+ if (noteInputEl) { noteInputEl.remove(); noteInputEl = null; }
795
+ noteTargetEl = null;
796
+ }
797
+
573
798
  // \u2500\u2500\u2500 Pin (measure) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
574
799
  function setPin(el) {
575
800
  pinEl = el;
@@ -866,6 +1091,7 @@ function getClientScript(options) {
866
1091
  var target = e.target;
867
1092
  if (!target || target === pillWrap || pillWrap.contains(target)) return;
868
1093
  if (target === tooltip || tooltip.contains(target)) return;
1094
+ if (noteInputEl && (target === noteInputEl || noteInputEl.contains(target))) return;
869
1095
 
870
1096
  lastHovered = target;
871
1097
 
@@ -900,6 +1126,9 @@ function getClientScript(options) {
900
1126
 
901
1127
  if (!fiActive) return;
902
1128
 
1129
+ // Note editor open: let the field handle its own keys (Enter/Esc) and native copy/paste.
1130
+ if (noteInputEl) return;
1131
+
903
1132
  if (e.key === 'Alt' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
904
1133
  optionHeld = true;
905
1134
  return;
@@ -930,6 +1159,12 @@ function getClientScript(options) {
930
1159
  return;
931
1160
  }
932
1161
 
1162
+ if (e.key === 'n' || e.key === 'N') {
1163
+ if (!lastHovered) return;
1164
+ openNoteInput(lastHovered);
1165
+ return;
1166
+ }
1167
+
933
1168
  if (e.key === 'm' || e.key === 'M') {
934
1169
  if (!measureMode || !lastHovered) return;
935
1170
  if (pinEl) {