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.cjs CHANGED
@@ -30,7 +30,13 @@ function getClientScript(options) {
30
30
  const activateShortcut = options.shortcuts?.activate ?? "ctrl+alt+z";
31
31
  return `(function() {
32
32
  'use strict';
33
- if (window.__specter) return; window.__specter = true;
33
+ // Guard against running twice on one page. Use a DOM marker (not just a
34
+ // window global) so the browser-extension build (isolated world) and the
35
+ // Vite-plugin build (page main world) can see each other and only one runs \u2014
36
+ // they share the DOM but not window globals, so a per-world flag misses.
37
+ if (window.__specter || document.documentElement.hasAttribute('data-specter')) return;
38
+ window.__specter = true;
39
+ document.documentElement.setAttribute('data-specter', '');
34
40
 
35
41
  // \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
36
42
  var PURPLE = '#AD24D3';
@@ -40,6 +46,7 @@ function getClientScript(options) {
40
46
  var LABEL = '#8B8D94';
41
47
  var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
42
48
  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>';
49
+ 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>';
43
50
  var ACTIVATE = ${JSON.stringify(activateShortcut)};
44
51
 
45
52
  // \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
@@ -59,6 +66,9 @@ function getClientScript(options) {
59
66
  var lastMouse = { x: 0, y: 0 };
60
67
  var selectedEls = [];
61
68
  var selectedBadges = [];
69
+ var noteMap = new Map();
70
+ var noteInputEl = null;
71
+ var noteTargetEl = null;
62
72
 
63
73
  // \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
64
74
  var tooltip = document.createElement('div');
@@ -218,11 +228,11 @@ function getClientScript(options) {
218
228
  pillExpanded = true;
219
229
  if (text) { pillText.textContent = text; return; }
220
230
  if (selectedEls.length > 0) {
221
- pillText.textContent = selectedEls.length + ' selected \xB7 Cmd+C copy all \xB7 Esc clear';
231
+ pillText.textContent = selectedEls.length + ' selected \xB7 N annotate \xB7 Cmd+C copy all \xB7 Esc clear';
222
232
  } else if (measureMode) {
223
233
  pillText.textContent = 'Measure \xB7 hover distances \xB7 M pin \xB7 Cmd+C copy \xB7 Option toggle';
224
234
  } else {
225
- pillText.textContent = 'Properties \xB7 hover inspect \xB7 P pick \xB7 Cmd+C copy \xB7 Option measure';
235
+ pillText.textContent = 'Properties \xB7 hover \xB7 P pick \xB7 N annotate \xB7 Cmd+C copy \xB7 Option measure';
226
236
  }
227
237
  }
228
238
 
@@ -342,6 +352,55 @@ function getClientScript(options) {
342
352
  return null;
343
353
  }
344
354
 
355
+ // Collapse a rule's declarations to their shortest lossless form: drop
356
+ // 'initial' (= default, no signal) and always-'normal' noise, and fold
357
+ // side/corner longhands back into shorthands (margin/padding/radius/gap/transition).
358
+ function cleanDecls(style) {
359
+ var m = {}, order = [];
360
+ for (var i = 0; i < style.length; i++) {
361
+ var p = style[i];
362
+ var v = style.getPropertyValue(p);
363
+ if (!v) continue;
364
+ if (v === 'initial') continue;
365
+ if (p === 'transition-behavior') continue;
366
+ if (!(p in m)) order.push(p);
367
+ m[p] = v;
368
+ }
369
+ var used = {}, collapses = {};
370
+ function four(name, t, r, b, l) {
371
+ if (m[t] === undefined || m[r] === undefined || m[b] === undefined || m[l] === undefined) return;
372
+ used[t] = used[r] = used[b] = used[l] = 1;
373
+ var a = m[t], c = m[r], d = m[b], e = m[l], sh;
374
+ if (a === c && c === d && d === e) sh = a;
375
+ else if (a === d && c === e) sh = a + ' ' + c;
376
+ else if (c === e) sh = a + ' ' + c + ' ' + d;
377
+ else sh = a + ' ' + c + ' ' + d + ' ' + e;
378
+ collapses[t] = name + ': ' + sh;
379
+ }
380
+ four('margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left');
381
+ four('padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left');
382
+ four('border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius');
383
+ if (m['row-gap'] !== undefined && m['column-gap'] !== undefined) {
384
+ var g = m['row-gap'] === m['column-gap'] ? m['row-gap'] : m['row-gap'] + ' ' + m['column-gap'];
385
+ collapses['row-gap'] = 'gap: ' + g; used['row-gap'] = used['column-gap'] = 1;
386
+ }
387
+ if (m['transition-property'] !== undefined) {
388
+ var tr = 'transition: ' + m['transition-property'];
389
+ if (m['transition-duration'] !== undefined) tr += ' ' + m['transition-duration'];
390
+ if (m['transition-timing-function'] !== undefined) tr += ' ' + m['transition-timing-function'];
391
+ if (m['transition-delay'] !== undefined && m['transition-delay'] !== '0s') tr += ' ' + m['transition-delay'];
392
+ collapses['transition-property'] = tr;
393
+ used['transition-property'] = used['transition-duration'] = used['transition-timing-function'] = used['transition-delay'] = 1;
394
+ }
395
+ var out = [];
396
+ order.forEach(function (p) {
397
+ if (collapses[p]) { out.push(' ' + collapses[p] + ';'); return; }
398
+ if (used[p]) return;
399
+ out.push(' ' + p + ': ' + m[p] + ';');
400
+ });
401
+ return out;
402
+ }
403
+
345
404
  function getMatchingRules(el) {
346
405
  var results = [];
347
406
  try {
@@ -358,12 +417,7 @@ function getClientScript(options) {
358
417
  if (/^[*,]|^:root|^html|^body$|^::before|^::after/.test(sel.trim())) continue;
359
418
  try {
360
419
  if (el.matches(sel)) {
361
- var props = [];
362
- for (var i = 0; i < rule.style.length; i++) {
363
- var prop = rule.style[i];
364
- var val = rule.style.getPropertyValue(prop);
365
- if (val) props.push(' ' + prop + ': ' + val + ';');
366
- }
420
+ var props = cleanDecls(rule.style);
367
421
  if (props.length) results.push(sel + ' {\\n' + props.join('\\n') + '\\n}');
368
422
  }
369
423
  } catch (e) {}
@@ -483,9 +537,13 @@ function getClientScript(options) {
483
537
  }
484
538
 
485
539
  function buildMultiSelectCopyText() {
540
+ var n = selectedEls.length;
486
541
  return selectedEls.map(function (el, i) {
487
542
  var body = buildLLMClipboard(buildInfo(el));
488
- return body.replace('[Specter]', '[Specter ' + (i + 1) + '/' + selectedEls.length + ']');
543
+ var note = noteMap.get(el);
544
+ var header = n > 1 ? '[Specter ' + (i + 1) + '/' + n + ']' : '[Specter]';
545
+ if (note) header += '\\n\u270F\uFE0F CHANGE: ' + note;
546
+ return body.replace('[Specter]', header);
489
547
  }).join('\\n\\n' + Array(41).join('\u2500') + '\\n\\n');
490
548
  }
491
549
 
@@ -536,16 +594,25 @@ function getClientScript(options) {
536
594
  }
537
595
 
538
596
  // \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
539
- function makeBadge(el, index) {
597
+ function makeBadge(el, index, note) {
540
598
  var rect = el.getBoundingClientRect();
541
- var badge = document.createElement('div');
542
- badge.textContent = String(index + 1);
543
- Object.assign(badge.style, {
599
+ var wrap = document.createElement('div');
600
+ Object.assign(wrap.style, {
544
601
  position: 'fixed',
545
602
  left: (rect.left - 4) + 'px',
546
603
  top: (rect.top - 4) + 'px',
604
+ zIndex: '2147483644',
605
+ pointerEvents: 'none',
606
+ display: 'flex',
607
+ alignItems: 'flex-start',
608
+ gap: '4px',
609
+ });
610
+ var badge = document.createElement('div');
611
+ badge.textContent = String(index + 1);
612
+ Object.assign(badge.style, {
547
613
  width: '20px',
548
614
  height: '20px',
615
+ flexShrink: '0',
549
616
  background: PURPLE,
550
617
  color: '#fff',
551
618
  fontSize: '11px',
@@ -555,19 +622,38 @@ function getClientScript(options) {
555
622
  display: 'flex',
556
623
  alignItems: 'center',
557
624
  justifyContent: 'center',
558
- zIndex: '2147483644',
559
- pointerEvents: 'none',
560
625
  boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
561
626
  });
562
- document.body.appendChild(badge);
563
- return badge;
627
+ wrap.appendChild(badge);
628
+ if (note) {
629
+ var chip = document.createElement('div');
630
+ chip.textContent = '\u270F\uFE0F ' + (note.length > 32 ? note.slice(0, 32) + '\u2026' : note);
631
+ Object.assign(chip.style, {
632
+ maxWidth: '240px',
633
+ background: TIP_BG,
634
+ color: '#fff',
635
+ fontSize: '10px',
636
+ lineHeight: '16px',
637
+ fontFamily: MONO,
638
+ padding: '2px 6px',
639
+ borderRadius: '4px',
640
+ whiteSpace: 'nowrap',
641
+ overflow: 'hidden',
642
+ textOverflow: 'ellipsis',
643
+ border: '1px solid ' + PURPLE,
644
+ boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
645
+ });
646
+ wrap.appendChild(chip);
647
+ }
648
+ document.body.appendChild(wrap);
649
+ return wrap;
564
650
  }
565
651
 
566
652
  function rebuildBadges() {
567
653
  selectedBadges.forEach(function (b) { b.remove(); });
568
654
  selectedBadges.length = 0;
569
655
  selectedEls.forEach(function (el, i) {
570
- selectedBadges.push(makeBadge(el, i));
656
+ selectedBadges.push(makeBadge(el, i, noteMap.get(el)));
571
657
  });
572
658
  }
573
659
 
@@ -576,6 +662,7 @@ function getClientScript(options) {
576
662
  if (idx >= 0) {
577
663
  selectedEls.splice(idx, 1);
578
664
  el.style.outline = '';
665
+ noteMap.delete(el);
579
666
  } else {
580
667
  selectedEls.push(el);
581
668
  el.style.outline = '2px solid ' + PURPLE;
@@ -586,10 +673,12 @@ function getClientScript(options) {
586
673
  }
587
674
 
588
675
  function clearSelection() {
676
+ closeNoteInput();
589
677
  selectedEls.forEach(function (el) { el.style.outline = ''; el.style.outlineOffset = ''; });
590
678
  selectedEls.length = 0;
591
679
  selectedBadges.forEach(function (b) { b.remove(); });
592
680
  selectedBadges.length = 0;
681
+ noteMap.clear();
593
682
  }
594
683
 
595
684
  function updatePillForSelection() {
@@ -597,6 +686,142 @@ function getClientScript(options) {
597
686
  else if (!pillWrap.matches(':hover')) collapsePill();
598
687
  }
599
688
 
689
+ // \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
690
+ function openNoteInput(el) {
691
+ closeNoteInput();
692
+ if (selectedEls.indexOf(el) < 0) {
693
+ clearHoverOutline();
694
+ hideTooltip();
695
+ selectedEls.push(el);
696
+ el.style.outline = '2px solid ' + PURPLE;
697
+ el.style.outlineOffset = '-1px';
698
+ }
699
+ noteTargetEl = el;
700
+ var rect = el.getBoundingClientRect();
701
+
702
+ var box = document.createElement('div');
703
+ Object.assign(box.style, {
704
+ position: 'fixed',
705
+ zIndex: '2147483647',
706
+ display: 'inline-flex',
707
+ alignItems: 'flex-start',
708
+ gap: '8px',
709
+ boxSizing: 'border-box',
710
+ background: TIP_BG,
711
+ border: '1px solid ' + PURPLE,
712
+ borderRadius: '8px',
713
+ padding: '11px 12px',
714
+ boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
715
+ fontFamily: MONO,
716
+ });
717
+ var pen = document.createElement('span');
718
+ pen.innerHTML = PENCIL;
719
+ Object.assign(pen.style, {
720
+ display: 'flex',
721
+ alignItems: 'center',
722
+ flexShrink: '0',
723
+ marginTop: '2px',
724
+ color: '#E0A3F5',
725
+ });
726
+ var input = document.createElement('textarea');
727
+ input.rows = 1;
728
+ input.placeholder = 'Describe the change\u2026 (Enter to save)';
729
+ input.value = noteMap.get(el) || '';
730
+ Object.assign(input.style, {
731
+ flexShrink: '0',
732
+ background: 'transparent',
733
+ border: 'none',
734
+ outline: 'none',
735
+ resize: 'none',
736
+ overflow: 'hidden',
737
+ color: '#fff',
738
+ fontFamily: MONO,
739
+ fontSize: '12px',
740
+ lineHeight: '18px',
741
+ padding: '0',
742
+ margin: '0',
743
+ whiteSpace: 'pre-wrap',
744
+ overflowWrap: 'anywhere',
745
+ });
746
+ // Hidden mirror to measure single-line text width so the field grows as you type.
747
+ var meas = document.createElement('span');
748
+ Object.assign(meas.style, {
749
+ position: 'absolute',
750
+ visibility: 'hidden',
751
+ whiteSpace: 'pre',
752
+ pointerEvents: 'none',
753
+ fontFamily: MONO,
754
+ fontSize: '12px',
755
+ left: '-9999px',
756
+ top: '0',
757
+ });
758
+ box.appendChild(pen);
759
+ box.appendChild(input);
760
+ box.appendChild(meas);
761
+ document.body.appendChild(box);
762
+ noteInputEl = box;
763
+
764
+ var margin = 8;
765
+ function textW(t) { meas.textContent = t; return meas.offsetWidth; }
766
+
767
+ // Anchor to the element; the box is repositioned around this as it grows.
768
+ var anchorL = rect.left, anchorT = rect.top, anchorB = rect.bottom;
769
+
770
+ function sizeAndPosition() {
771
+ var vw = window.innerWidth, vh = window.innerHeight;
772
+ var maxBoxW = Math.min(560, vw - margin * 2);
773
+ var maxTextW = Math.max(120, maxBoxW - 55); // room for icon + gaps + padding
774
+ var placeholderW = textW(input.placeholder);
775
+ var minTextW = Math.min(placeholderW, maxTextW);
776
+ var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines, never past viewport
777
+
778
+ // Width: grow with content up to the max, then wrapping takes over.
779
+ var single = textW(input.value || input.placeholder) + 3;
780
+ input.style.width = Math.min(Math.max(single, minTextW), maxTextW) + 'px';
781
+ // Height: fit wrapped content, scroll only in the extreme case.
782
+ input.style.height = 'auto';
783
+ var h = input.scrollHeight;
784
+ if (h > maxTaH) { input.style.height = maxTaH + 'px'; input.style.overflowY = 'auto'; }
785
+ else { input.style.height = h + 'px'; input.style.overflowY = 'hidden'; }
786
+
787
+ // Keep the whole box on screen.
788
+ var bw = box.offsetWidth, bh = box.offsetHeight;
789
+ var left = Math.min(Math.max(anchorL, margin), Math.max(margin, vw - bw - margin));
790
+ var top = anchorT - bh - 8; // prefer sitting above the element
791
+ if (top < margin) top = anchorB + 8; // otherwise below it
792
+ if (top + bh > vh - margin) top = Math.max(margin, vh - bh - margin);
793
+ box.style.left = left + 'px';
794
+ box.style.top = top + 'px';
795
+ }
796
+ sizeAndPosition();
797
+
798
+ input.addEventListener('input', sizeAndPosition);
799
+ input.addEventListener('keydown', function (ev) {
800
+ ev.stopPropagation();
801
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commitNote(input.value); }
802
+ else if (ev.key === 'Escape') { ev.preventDefault(); closeNoteInput(); }
803
+ });
804
+
805
+ rebuildBadges();
806
+ updatePillForSelection();
807
+ setTimeout(function () { input.focus(); }, 0);
808
+ }
809
+
810
+ function commitNote(val) {
811
+ val = (val || '').trim();
812
+ if (noteTargetEl) {
813
+ if (val) noteMap.set(noteTargetEl, val);
814
+ else noteMap.delete(noteTargetEl);
815
+ }
816
+ closeNoteInput();
817
+ rebuildBadges();
818
+ }
819
+
820
+ function closeNoteInput() {
821
+ if (noteInputEl) { noteInputEl.remove(); noteInputEl = null; }
822
+ noteTargetEl = null;
823
+ }
824
+
600
825
  // \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
601
826
  function setPin(el) {
602
827
  pinEl = el;
@@ -893,6 +1118,7 @@ function getClientScript(options) {
893
1118
  var target = e.target;
894
1119
  if (!target || target === pillWrap || pillWrap.contains(target)) return;
895
1120
  if (target === tooltip || tooltip.contains(target)) return;
1121
+ if (noteInputEl && (target === noteInputEl || noteInputEl.contains(target))) return;
896
1122
 
897
1123
  lastHovered = target;
898
1124
 
@@ -927,6 +1153,9 @@ function getClientScript(options) {
927
1153
 
928
1154
  if (!fiActive) return;
929
1155
 
1156
+ // Note editor open: let the field handle its own keys (Enter/Esc) and native copy/paste.
1157
+ if (noteInputEl) return;
1158
+
930
1159
  if (e.key === 'Alt' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
931
1160
  optionHeld = true;
932
1161
  return;
@@ -957,6 +1186,12 @@ function getClientScript(options) {
957
1186
  return;
958
1187
  }
959
1188
 
1189
+ if (e.key === 'n' || e.key === 'N') {
1190
+ if (!lastHovered) return;
1191
+ openNoteInput(lastHovered);
1192
+ return;
1193
+ }
1194
+
960
1195
  if (e.key === 'm' || e.key === 'M') {
961
1196
  if (!measureMode || !lastHovered) return;
962
1197
  if (pinEl) {