vite-plugin-specter 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,6 +60,23 @@ In Measure mode, press **M** while hovering to pin that element. Then hover any
60
60
 
61
61
  Press **P** while hovering an element to pick it. Numbered badges appear on each picked element. Press **Cmd+C** to copy all selected elements at once. Press **Esc** to clear the selection.
62
62
 
63
+ ### Annotate a change (skip the re-explaining)
64
+
65
+ Press **N** while hovering an element to attach a change note to it. A small field opens on the element — type what you want ("reduce width to 320", "tighten padding to 8px"), press **Enter** to save. The element is added to the selection and its badge shows a chip with your note. Annotate as many issues across the page as you like, then press **Cmd+C** once.
66
+
67
+ Each element's copied block now leads with your instruction:
68
+
69
+ ```
70
+ [Specter 1/3]
71
+ ✏️ CHANGE: reduce width to 320, tighten vertical padding
72
+ <button>.btn-primary Button 200×48
73
+ font: Inter 600 14/20
74
+ padding: 12px 20px
75
+ ...
76
+ ```
77
+
78
+ So when you paste into your AI assistant, it already knows both *what* to change and *which element* — no follow-up typing. Notes are ephemeral (cleared on **Esc** or when you toggle Specter off).
79
+
63
80
  ## Shortcut reference
64
81
 
65
82
  | Action | Shortcut |
@@ -70,6 +87,7 @@ Press **P** while hovering an element to pick it. Numbered badges appear on each
70
87
  | Switch to Measure mode | **Option** (tap) |
71
88
  | Pin element for measuring | **M** (in Measure mode) |
72
89
  | Pick element (multi-select) | **P** (in Properties mode) |
90
+ | Annotate a change | **N** (in Properties mode) |
73
91
  | Clear pin / clear selection | **Esc** |
74
92
  | Exit Specter | **Esc** (when nothing selected/pinned) |
75
93
 
@@ -77,11 +95,11 @@ Press **P** while hovering an element to pick it. Numbered badges appear on each
77
95
 
78
96
  1. Toggle Specter (Ctrl+Option+Z)
79
97
  2. Hover the element you want to change
80
- 3. Press **Cmd+C** to copy
81
- 4. Paste into your AI assistant's chat
82
- 5. Say what you want: "make this blue", "increase spacing", "fix the font weight"
98
+ 3. Either press **Cmd+C** to copy its properties, or press **N** to annotate the exact change you want first
99
+ 4. Repeat **N** on every element you want to change, then **Cmd+C** once to copy them all
100
+ 5. Paste into your AI assistant's chat the instructions travel with the elements, so there's nothing left to explain
83
101
 
84
- No more "which element?", no more "what's the current value?". One paste gives your assistant everything it needs.
102
+ No more "which element?", no more "what's the current value?", and now no more re-typing the change. One paste gives your assistant everything it needs.
85
103
 
86
104
  ## Configuration
87
105
 
package/dist/client.js CHANGED
@@ -10,6 +10,7 @@
10
10
  var LABEL = '#8B8D94';
11
11
  var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
12
12
  var ZAP = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>';
13
+ var PENCIL = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"></path><path d="m15 5 4 4"></path></svg>';
13
14
  var ACTIVATE = "ctrl+alt+z";
14
15
 
15
16
  // ─── State ────────────────────────────────────────────────────────────────
@@ -29,6 +30,9 @@
29
30
  var lastMouse = { x: 0, y: 0 };
30
31
  var selectedEls = [];
31
32
  var selectedBadges = [];
33
+ var noteMap = new Map();
34
+ var noteInputEl = null;
35
+ var noteTargetEl = null;
32
36
 
33
37
  // ─── Tooltip ──────────────────────────────────────────────────────────────
34
38
  var tooltip = document.createElement('div');
@@ -188,11 +192,11 @@
188
192
  pillExpanded = true;
189
193
  if (text) { pillText.textContent = text; return; }
190
194
  if (selectedEls.length > 0) {
191
- pillText.textContent = selectedEls.length + ' selected · Cmd+C copy all · Esc clear';
195
+ pillText.textContent = selectedEls.length + ' selected · N annotate · Cmd+C copy all · Esc clear';
192
196
  } else if (measureMode) {
193
197
  pillText.textContent = 'Measure · hover distances · M pin · Cmd+C copy · Option toggle';
194
198
  } else {
195
- pillText.textContent = 'Properties · hover inspect · P pick · Cmd+C copy · Option measure';
199
+ pillText.textContent = 'Properties · hover · P pick · N annotate · Cmd+C copy · Option measure';
196
200
  }
197
201
  }
198
202
 
@@ -273,17 +277,11 @@
273
277
  return { hex: toHex(m[1], m[2], m[3]), alpha: m[4] !== undefined ? parseFloat(m[4]) : 1 };
274
278
  }
275
279
 
276
- function colorLabel(str) {
277
- var c = parseColor(str);
278
- if (!c) return null;
279
- if (c.alpha < 1) return c.hex + ' (' + Math.round(c.alpha * 100) + '% opacity)';
280
- return c.hex;
281
- }
282
-
283
280
  function colorObj(str) {
284
281
  var c = parseColor(str);
285
282
  if (!c) return null;
286
- return { raw: str, hex: c.hex, alpha: c.alpha, label: colorLabel(str) };
283
+ var label = c.alpha < 1 ? c.hex + ' (' + Math.round(c.alpha * 100) + '% opacity)' : c.hex;
284
+ return { raw: str, hex: c.hex, alpha: c.alpha, label: label };
287
285
  }
288
286
 
289
287
  function edge(t, r, b, l) {
@@ -318,6 +316,55 @@
318
316
  return null;
319
317
  }
320
318
 
319
+ // Collapse a rule's declarations to their shortest lossless form: drop
320
+ // 'initial' (= default, no signal) and always-'normal' noise, and fold
321
+ // side/corner longhands back into shorthands (margin/padding/radius/gap/transition).
322
+ function cleanDecls(style) {
323
+ var m = {}, order = [];
324
+ for (var i = 0; i < style.length; i++) {
325
+ var p = style[i];
326
+ var v = style.getPropertyValue(p);
327
+ if (!v) continue;
328
+ if (v === 'initial') continue;
329
+ if (p === 'transition-behavior') continue;
330
+ if (!(p in m)) order.push(p);
331
+ m[p] = v;
332
+ }
333
+ var used = {}, collapses = {};
334
+ function four(name, t, r, b, l) {
335
+ if (m[t] === undefined || m[r] === undefined || m[b] === undefined || m[l] === undefined) return;
336
+ used[t] = used[r] = used[b] = used[l] = 1;
337
+ var a = m[t], c = m[r], d = m[b], e = m[l], sh;
338
+ if (a === c && c === d && d === e) sh = a;
339
+ else if (a === d && c === e) sh = a + ' ' + c;
340
+ else if (c === e) sh = a + ' ' + c + ' ' + d;
341
+ else sh = a + ' ' + c + ' ' + d + ' ' + e;
342
+ collapses[t] = name + ': ' + sh;
343
+ }
344
+ four('margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left');
345
+ four('padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left');
346
+ four('border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius');
347
+ if (m['row-gap'] !== undefined && m['column-gap'] !== undefined) {
348
+ var g = m['row-gap'] === m['column-gap'] ? m['row-gap'] : m['row-gap'] + ' ' + m['column-gap'];
349
+ collapses['row-gap'] = 'gap: ' + g; used['row-gap'] = used['column-gap'] = 1;
350
+ }
351
+ if (m['transition-property'] !== undefined) {
352
+ var tr = 'transition: ' + m['transition-property'];
353
+ if (m['transition-duration'] !== undefined) tr += ' ' + m['transition-duration'];
354
+ if (m['transition-timing-function'] !== undefined) tr += ' ' + m['transition-timing-function'];
355
+ if (m['transition-delay'] !== undefined && m['transition-delay'] !== '0s') tr += ' ' + m['transition-delay'];
356
+ collapses['transition-property'] = tr;
357
+ used['transition-property'] = used['transition-duration'] = used['transition-timing-function'] = used['transition-delay'] = 1;
358
+ }
359
+ var out = [];
360
+ order.forEach(function (p) {
361
+ if (collapses[p]) { out.push(' ' + collapses[p] + ';'); return; }
362
+ if (used[p]) return;
363
+ out.push(' ' + p + ': ' + m[p] + ';');
364
+ });
365
+ return out;
366
+ }
367
+
321
368
  function getMatchingRules(el) {
322
369
  var results = [];
323
370
  try {
@@ -334,12 +381,7 @@
334
381
  if (/^[*,]|^:root|^html|^body$|^::before|^::after/.test(sel.trim())) continue;
335
382
  try {
336
383
  if (el.matches(sel)) {
337
- var props = [];
338
- for (var i = 0; i < rule.style.length; i++) {
339
- var prop = rule.style[i];
340
- var val = rule.style.getPropertyValue(prop);
341
- if (val) props.push(' ' + prop + ': ' + val + ';');
342
- }
384
+ var props = cleanDecls(rule.style);
343
385
  if (props.length) results.push(sel + ' {\n' + props.join('\n') + '\n}');
344
386
  }
345
387
  } catch (e) {}
@@ -459,9 +501,13 @@
459
501
  }
460
502
 
461
503
  function buildMultiSelectCopyText() {
504
+ var n = selectedEls.length;
462
505
  return selectedEls.map(function (el, i) {
463
506
  var body = buildLLMClipboard(buildInfo(el));
464
- return body.replace('[Specter]', '[Specter ' + (i + 1) + '/' + selectedEls.length + ']');
507
+ var note = noteMap.get(el);
508
+ var header = n > 1 ? '[Specter ' + (i + 1) + '/' + n + ']' : '[Specter]';
509
+ if (note) header += '\n✏️ CHANGE: ' + note;
510
+ return body.replace('[Specter]', header);
465
511
  }).join('\n\n' + Array(41).join('─') + '\n\n');
466
512
  }
467
513
 
@@ -485,6 +531,7 @@
485
531
  function clearHoverOutline() {
486
532
  if (outlinedEl) {
487
533
  outlinedEl.style.outline = prevOutline;
534
+ outlinedEl.style.outlineOffset = '';
488
535
  outlinedEl = null;
489
536
  }
490
537
  }
@@ -511,16 +558,25 @@
511
558
  }
512
559
 
513
560
  // ─── Multi-select ─────────────────────────────────────────────────────────
514
- function makeBadge(el, index) {
561
+ function makeBadge(el, index, note) {
515
562
  var rect = el.getBoundingClientRect();
516
- var badge = document.createElement('div');
517
- badge.textContent = String(index + 1);
518
- Object.assign(badge.style, {
563
+ var wrap = document.createElement('div');
564
+ Object.assign(wrap.style, {
519
565
  position: 'fixed',
520
566
  left: (rect.left - 4) + 'px',
521
567
  top: (rect.top - 4) + 'px',
568
+ zIndex: '2147483644',
569
+ pointerEvents: 'none',
570
+ display: 'flex',
571
+ alignItems: 'flex-start',
572
+ gap: '4px',
573
+ });
574
+ var badge = document.createElement('div');
575
+ badge.textContent = String(index + 1);
576
+ Object.assign(badge.style, {
522
577
  width: '20px',
523
578
  height: '20px',
579
+ flexShrink: '0',
524
580
  background: PURPLE,
525
581
  color: '#fff',
526
582
  fontSize: '11px',
@@ -530,19 +586,38 @@
530
586
  display: 'flex',
531
587
  alignItems: 'center',
532
588
  justifyContent: 'center',
533
- zIndex: '2147483644',
534
- pointerEvents: 'none',
535
589
  boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
536
590
  });
537
- document.body.appendChild(badge);
538
- return badge;
591
+ wrap.appendChild(badge);
592
+ if (note) {
593
+ var chip = document.createElement('div');
594
+ chip.textContent = '✏️ ' + (note.length > 32 ? note.slice(0, 32) + '…' : note);
595
+ Object.assign(chip.style, {
596
+ maxWidth: '240px',
597
+ background: TIP_BG,
598
+ color: '#fff',
599
+ fontSize: '10px',
600
+ lineHeight: '16px',
601
+ fontFamily: MONO,
602
+ padding: '2px 6px',
603
+ borderRadius: '4px',
604
+ whiteSpace: 'nowrap',
605
+ overflow: 'hidden',
606
+ textOverflow: 'ellipsis',
607
+ border: '1px solid ' + PURPLE,
608
+ boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
609
+ });
610
+ wrap.appendChild(chip);
611
+ }
612
+ document.body.appendChild(wrap);
613
+ return wrap;
539
614
  }
540
615
 
541
616
  function rebuildBadges() {
542
617
  selectedBadges.forEach(function (b) { b.remove(); });
543
618
  selectedBadges.length = 0;
544
619
  selectedEls.forEach(function (el, i) {
545
- selectedBadges.push(makeBadge(el, i));
620
+ selectedBadges.push(makeBadge(el, i, noteMap.get(el)));
546
621
  });
547
622
  }
548
623
 
@@ -551,6 +626,7 @@
551
626
  if (idx >= 0) {
552
627
  selectedEls.splice(idx, 1);
553
628
  el.style.outline = '';
629
+ noteMap.delete(el);
554
630
  } else {
555
631
  selectedEls.push(el);
556
632
  el.style.outline = '2px solid ' + PURPLE;
@@ -561,10 +637,12 @@
561
637
  }
562
638
 
563
639
  function clearSelection() {
564
- selectedEls.forEach(function (el) { el.style.outline = ''; });
640
+ closeNoteInput();
641
+ selectedEls.forEach(function (el) { el.style.outline = ''; el.style.outlineOffset = ''; });
565
642
  selectedEls.length = 0;
566
643
  selectedBadges.forEach(function (b) { b.remove(); });
567
644
  selectedBadges.length = 0;
645
+ noteMap.clear();
568
646
  }
569
647
 
570
648
  function updatePillForSelection() {
@@ -572,6 +650,142 @@
572
650
  else if (!pillWrap.matches(':hover')) collapsePill();
573
651
  }
574
652
 
653
+ // ─── Annotations (inline change notes) ──────────────────────────────────────
654
+ function openNoteInput(el) {
655
+ closeNoteInput();
656
+ if (selectedEls.indexOf(el) < 0) {
657
+ clearHoverOutline();
658
+ hideTooltip();
659
+ selectedEls.push(el);
660
+ el.style.outline = '2px solid ' + PURPLE;
661
+ el.style.outlineOffset = '-1px';
662
+ }
663
+ noteTargetEl = el;
664
+ var rect = el.getBoundingClientRect();
665
+
666
+ var box = document.createElement('div');
667
+ Object.assign(box.style, {
668
+ position: 'fixed',
669
+ zIndex: '2147483647',
670
+ display: 'inline-flex',
671
+ alignItems: 'flex-start',
672
+ gap: '8px',
673
+ boxSizing: 'border-box',
674
+ background: TIP_BG,
675
+ border: '1px solid ' + PURPLE,
676
+ borderRadius: '8px',
677
+ padding: '11px 12px',
678
+ boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
679
+ fontFamily: MONO,
680
+ });
681
+ var pen = document.createElement('span');
682
+ pen.innerHTML = PENCIL;
683
+ Object.assign(pen.style, {
684
+ display: 'flex',
685
+ alignItems: 'center',
686
+ flexShrink: '0',
687
+ marginTop: '2px',
688
+ color: '#E0A3F5',
689
+ });
690
+ var input = document.createElement('textarea');
691
+ input.rows = 1;
692
+ input.placeholder = 'Describe the change… (Enter to save)';
693
+ input.value = noteMap.get(el) || '';
694
+ Object.assign(input.style, {
695
+ flexShrink: '0',
696
+ background: 'transparent',
697
+ border: 'none',
698
+ outline: 'none',
699
+ resize: 'none',
700
+ overflow: 'hidden',
701
+ color: '#fff',
702
+ fontFamily: MONO,
703
+ fontSize: '12px',
704
+ lineHeight: '18px',
705
+ padding: '0',
706
+ margin: '0',
707
+ whiteSpace: 'pre-wrap',
708
+ overflowWrap: 'anywhere',
709
+ });
710
+ // Hidden mirror to measure single-line text width so the field grows as you type.
711
+ var meas = document.createElement('span');
712
+ Object.assign(meas.style, {
713
+ position: 'absolute',
714
+ visibility: 'hidden',
715
+ whiteSpace: 'pre',
716
+ pointerEvents: 'none',
717
+ fontFamily: MONO,
718
+ fontSize: '12px',
719
+ left: '-9999px',
720
+ top: '0',
721
+ });
722
+ box.appendChild(pen);
723
+ box.appendChild(input);
724
+ box.appendChild(meas);
725
+ document.body.appendChild(box);
726
+ noteInputEl = box;
727
+
728
+ var margin = 8;
729
+ function textW(t) { meas.textContent = t; return meas.offsetWidth; }
730
+
731
+ // Anchor to the element; the box is repositioned around this as it grows.
732
+ var anchorL = rect.left, anchorT = rect.top, anchorB = rect.bottom;
733
+
734
+ function sizeAndPosition() {
735
+ var vw = window.innerWidth, vh = window.innerHeight;
736
+ var maxBoxW = Math.min(560, vw - margin * 2);
737
+ var maxTextW = Math.max(120, maxBoxW - 55); // room for icon + gaps + padding
738
+ var placeholderW = textW(input.placeholder);
739
+ var minTextW = Math.min(placeholderW, maxTextW);
740
+ var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines, never past viewport
741
+
742
+ // Width: grow with content up to the max, then wrapping takes over.
743
+ var single = textW(input.value || input.placeholder) + 3;
744
+ input.style.width = Math.min(Math.max(single, minTextW), maxTextW) + 'px';
745
+ // Height: fit wrapped content, scroll only in the extreme case.
746
+ input.style.height = 'auto';
747
+ var h = input.scrollHeight;
748
+ if (h > maxTaH) { input.style.height = maxTaH + 'px'; input.style.overflowY = 'auto'; }
749
+ else { input.style.height = h + 'px'; input.style.overflowY = 'hidden'; }
750
+
751
+ // Keep the whole box on screen.
752
+ var bw = box.offsetWidth, bh = box.offsetHeight;
753
+ var left = Math.min(Math.max(anchorL, margin), Math.max(margin, vw - bw - margin));
754
+ var top = anchorT - bh - 8; // prefer sitting above the element
755
+ if (top < margin) top = anchorB + 8; // otherwise below it
756
+ if (top + bh > vh - margin) top = Math.max(margin, vh - bh - margin);
757
+ box.style.left = left + 'px';
758
+ box.style.top = top + 'px';
759
+ }
760
+ sizeAndPosition();
761
+
762
+ input.addEventListener('input', sizeAndPosition);
763
+ input.addEventListener('keydown', function (ev) {
764
+ ev.stopPropagation();
765
+ if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); commitNote(input.value); }
766
+ else if (ev.key === 'Escape') { ev.preventDefault(); closeNoteInput(); }
767
+ });
768
+
769
+ rebuildBadges();
770
+ updatePillForSelection();
771
+ setTimeout(function () { input.focus(); }, 0);
772
+ }
773
+
774
+ function commitNote(val) {
775
+ val = (val || '').trim();
776
+ if (noteTargetEl) {
777
+ if (val) noteMap.set(noteTargetEl, val);
778
+ else noteMap.delete(noteTargetEl);
779
+ }
780
+ closeNoteInput();
781
+ rebuildBadges();
782
+ }
783
+
784
+ function closeNoteInput() {
785
+ if (noteInputEl) { noteInputEl.remove(); noteInputEl = null; }
786
+ noteTargetEl = null;
787
+ }
788
+
575
789
  // ─── Pin (measure) ──────────────────────────────────────────────────────────
576
790
  function setPin(el) {
577
791
  pinEl = el;
@@ -868,6 +1082,7 @@
868
1082
  var target = e.target;
869
1083
  if (!target || target === pillWrap || pillWrap.contains(target)) return;
870
1084
  if (target === tooltip || tooltip.contains(target)) return;
1085
+ if (noteInputEl && (target === noteInputEl || noteInputEl.contains(target))) return;
871
1086
 
872
1087
  lastHovered = target;
873
1088
 
@@ -902,6 +1117,9 @@
902
1117
 
903
1118
  if (!fiActive) return;
904
1119
 
1120
+ // Note editor open: let the field handle its own keys (Enter/Esc) and native copy/paste.
1121
+ if (noteInputEl) return;
1122
+
905
1123
  if (e.key === 'Alt' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
906
1124
  optionHeld = true;
907
1125
  return;
@@ -932,6 +1150,12 @@
932
1150
  return;
933
1151
  }
934
1152
 
1153
+ if (e.key === 'n' || e.key === 'N') {
1154
+ if (!lastHovered) return;
1155
+ openNoteInput(lastHovered);
1156
+ return;
1157
+ }
1158
+
935
1159
  if (e.key === 'm' || e.key === 'M') {
936
1160
  if (!measureMode || !lastHovered) return;
937
1161
  if (pinEl) {
@@ -976,8 +1200,9 @@
976
1200
 
977
1201
  window.__specterToggle = function() { if (fiActive) deactivate(); else activate(); };
978
1202
 
979
- if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessage) {
980
- browser.runtime.onMessage.addListener(function(msg) {
1203
+ var _rt = (typeof browser !== 'undefined' && browser.runtime) || (typeof chrome !== 'undefined' && chrome.runtime);
1204
+ if (_rt && _rt.onMessage) {
1205
+ _rt.onMessage.addListener(function(msg) {
981
1206
  if (msg && msg.type === 'specter-toggle') window.__specterToggle();
982
1207
  });
983
1208
  }
@@ -0,0 +1,4 @@
1
+ // Chrome MV3 service worker
2
+ chrome.action.onClicked.addListener((tab) => {
3
+ chrome.tabs.sendMessage(tab.id, { type: 'specter-toggle' });
4
+ });