vite-plugin-specter 0.5.0 → 0.6.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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # Specter
2
2
 
3
- **Hover any element on your dev site. See its styles. Copy to AI.**
3
+ **Point at any element on your dev site. Mark what to change — with exact values and a source anchor — and hand it to your AI.**
4
4
 
5
- Specter is a Vite plugin that adds an element inspector overlay to your dev server. Toggle it on, hover any element to see its styles, component name, and spacing. Copy with one shortcut and paste into your AI assistant for one-shot edits. Specter is automatically stripped from production builds.
5
+ Specter is a Vite plugin that overlays an element inspector on your dev server. Hover any element to see its styles, component name, and spacing; measure gaps Figma-style; mark elements as **Specs** with a plain-language note; then copy them all to your AI assistant each one carries the exact current values and a greppable source anchor, so there's no "which element?" and no "what's the current value?". Optionally, push your Specs straight into Claude Code with `/spectify`. Specter is automatically stripped from production builds.
6
6
 
7
7
  ## Requirements
8
8
 
@@ -27,55 +27,90 @@ export default defineConfig({
27
27
 
28
28
  Start your dev server and you're done. Specter is stripped from production builds automatically.
29
29
 
30
+ ## ⚠️ Dev-only — never ship Specter to production (note for AI agents)
31
+
32
+ **Specter is a development-only tool. It must never end up in a customer-facing / production bundle.**
33
+
34
+ As a Vite plugin it is already safe by default: `specter()` runs with `apply: 'serve'`, so it is active on the dev server (`vite`) only and is completely excluded from `vite build`. Adding the plugin does **not** ship anything to your users.
35
+
36
+ If you are an AI agent (or automating a build), do **not** work around this guard:
37
+
38
+ - Do **not** call `getClientScript()` and inline the result into your app's HTML, templates, or components.
39
+ - Do **not** change `apply: 'serve'`, or otherwise force the plugin to run during `vite build`, `vite preview`, or SSR/production output.
40
+ - Do **not** copy the injected `<script>` from a dev page into the shipped app.
41
+ - Keep it in `devDependencies` (installed with `-D`); never move it to `dependencies`.
42
+
43
+ If you ever need Specter gone entirely, remove `specter()` from the Vite config (see [Uninstall](#uninstall)).
44
+
30
45
  ## How to use
31
46
 
32
47
  ### Toggle on/off
33
48
 
34
- Press **Ctrl+Option+Z** to enter Specter mode. A small zap icon appears at the bottom-left. Press again (or Esc) to exit.
49
+ Press **Ctrl+Option+Z** to activate Specter. A small zap icon appears at the bottom-left. Press again (or **Esc**) to hide it — your Specs are kept and reappear when you reactivate.
50
+
51
+ ### Three modes
35
52
 
36
- ### Inspect an element
53
+ Specter has three modes; switching mode only changes *what's shown on screen* — you can mark a Spec in any of them.
37
54
 
38
- Once active, just **hover** any element. A tooltip shows:
55
+ | Mode | Enter | Shows |
56
+ |------|-------|-------|
57
+ | **Properties** (default) | — | Styles tooltip on hover |
58
+ | **Measure** | tap **Option** | Figma-style spacing to neighbors |
59
+ | **Comment** | **C** | Just an outline — for clean design review |
60
+
61
+ ### Inspect an element (Properties mode)
62
+
63
+ **Hover** any element. A tooltip shows:
39
64
 
40
65
  - HTML tag + React/Vue component name + dimensions
41
66
  - Text content (truncated)
42
67
  - Font: family, weight, size, line-height
43
- - Color (with hex) and background
68
+ - Color (with hex) and background — flagged `(hover)` if the value comes from a `:hover` rule
44
69
  - Padding, margin, border-radius (if non-zero)
45
70
  - Display + gap + flex direction
46
71
 
47
- ### Copy to clipboard
72
+ ### Measure spacing (Measure mode)
48
73
 
49
- Press **Cmd+C** while hovering. The element info is copied in an AI-optimized format. Paste it into Claude, Cursor, or any AI assistant and say what you want changed.
74
+ Tap **Option** to switch to Measure mode. Hover any element to see distances to its surrounding neighbors. Press **M** while hovering to **pin** that element, then hover another to measure the gap or inset between the two; **M** again unpins. Tap **Option** again to return to Properties.
50
75
 
51
- ### Measure mode
76
+ ### Mark a Spec (and optionally annotate)
52
77
 
53
- Tap **Option** to switch to Measure mode. Hover any element to see distance measurements to its surrounding neighbors (like Figma's spacing view). Tap **Option** again to return to Properties mode.
78
+ Press **P** while hovering an element to mark it as a **Spec**. A note box opens on the element — type the change you want ("reduce width to 320", "tighten padding to 8px") and press **Enter**, or just leave it empty for a plain mark. Numbered badges appear on each marked element. Mark as many as you like across the page.
54
79
 
55
- ### Pin an element
80
+ ### Comment mode
56
81
 
57
- In Measure mode, press **M** while hovering to pin that element. Then hover any other element to measure the gap or inset between the two. Press **Cmd+C** to copy the measurement. Press **Esc** to clear the pin.
82
+ Press **C** for Comment mode: the props/measure overlays hide and you get just a hover outline good for talking through a design without visual noise. Specs still capture full properties underneath, so a Comment-mode Spec copies exactly like any other.
58
83
 
59
- ### Multi-select
84
+ ### Specs side panel
60
85
 
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.
86
+ Press **L** to open the Specs review panel. It lists every Spec; hover a row to scroll to and highlight its element; edit or delete notes inline. Specs survive a reload (saved to `localStorage` per URL).
62
87
 
63
- ### Annotate a change (skip the re-explaining)
88
+ ### Copy to your AI
64
89
 
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.
90
+ Press **Cmd+C**:
66
91
 
67
- Each element's copied block now leads with your instruction:
92
+ - **With Specs marked** → copies **all** of them at once. Each block leads with your `✏️ CHANGE:` note (if any).
93
+ - **With nothing marked** → copies the hovered element's properties (or, in Measure mode, the measurement).
94
+
95
+ The copied format is lean and AI-ready — the current values plus a `find:` line that anchors the element in your source (test-id → id → aria-label → unique text → class → CSS path), so the assistant greps straight to the code:
68
96
 
69
97
  ```
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
- ...
98
+ [Specter 1/2]
99
+ ✏️ CHANGE: tighten the vertical padding to 8px
100
+ <button> .btn-primary "Book a demo" 200×48
101
+ find: .btn-primary
102
+ font: Inter 600 14/20 · color: #fff · bg: #4f46e5 · padding: 12px 20px · radius: 10px · display: inline-flex
103
+
104
+ ---
105
+
106
+ [Specter 2/2]
107
+ ✏️ CHANGE: make this text darker — it fails contrast
108
+ <p> .hero-sub "Analytics that…" 560×57
109
+ find: .hero-sub
110
+ font: Inter 400 19/28.5 · color: #c7cbd1
76
111
  ```
77
112
 
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).
113
+ Paste into Claude, Cursor, or any AI assistant — the instructions travel with the elements, so there's nothing left to explain.
79
114
 
80
115
  ## Shortcut reference
81
116
 
@@ -83,36 +118,39 @@ So when you paste into your AI assistant, it already knows both *what* to change
83
118
  |--------|----------|
84
119
  | Toggle Specter | **Ctrl+Option+Z** |
85
120
  | Inspect element | Hover (while active) |
86
- | Copy properties | **Cmd+C** |
87
- | Switch to Measure mode | **Option** (tap) |
88
- | Pin element for measuring | **M** (in Measure mode) |
89
- | Pick element (multi-select) | **P** (in Properties mode) |
90
- | Annotate a change | **N** (in Properties mode) |
91
- | Clear pin / clear selection | **Esc** |
92
- | Exit Specter | **Esc** (when nothing selected/pinned) |
121
+ | Mark a Spec (+ optional note) | **P** |
122
+ | Copy (all Specs, or hovered element) | **Cmd+C** |
123
+ | Measure mode (toggle) | **Option** (tap) |
124
+ | Pin / unpin for measuring | **M** (in Measure mode) |
125
+ | Comment mode (toggle) | **C** |
126
+ | Specs panel (toggle) | **L** |
127
+ | Close panel / hide Specter | **Esc** |
128
+
129
+ ## Optional: push Specs straight to Claude Code (`/spectify`)
93
130
 
94
- ## Workflow with AI assistants
131
+ Instead of copy-paste, Specter can auto-sync your Specs to a local bridge that Claude Code reads on demand. Enable it in your Vite config:
132
+
133
+ ```ts
134
+ specter({ claudeBridge: true })
135
+ ```
95
136
 
96
- 1. Toggle Specter (Ctrl+Option+Z)
97
- 2. Hover the element you want to change
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
137
+ With it on, every Spec you mark auto-syncs (watch for the sync dot in the panel) to a local bridge at `http://127.0.0.1:8787`. Run the bridge (`node node_modules/vite-plugin-specter/mcp-bridge/server.mjs`, or register it in your project's `.mcp.json` so your IDE launches it), then in Claude Code run **`/spectify`** — it pulls whatever's currently synced and implements it. See [`mcp-bridge/`](https://github.com/setugk/vite-plugin-specter/tree/main/mcp-bridge) for setup. One bridge can serve several projects; `/spectify <port>` scopes to one.
101
138
 
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.
139
+ `claudeBridge` is **off by default** the shipped plugin never opens a connection unless you enable it.
103
140
 
104
141
  ## Configuration
105
142
 
106
143
  ```ts
107
- specter() // Default shortcuts
108
- specter({ shortcuts: { activate: 'ctrl+shift+i' } }) // Custom activate shortcut
144
+ specter() // defaults
145
+ specter({ shortcuts: { activate: 'ctrl+shift+i' } }) // custom activate shortcut
146
+ specter({ claudeBridge: true }) // enable the Claude Code bridge
109
147
  ```
110
148
 
111
149
  The `activate` shortcut accepts any combination of `ctrl`, `alt`, `shift`, `meta`/`cmd`, and a key. Default is `ctrl+alt+z`.
112
150
 
113
151
  ## Optional: `data-style` attribute
114
152
 
115
- Tag elements with `data-style="keyName"` to show the style object key in the inspector:
153
+ Tag elements with `data-style="keyName"` to surface the style-object key in the inspector and the copied output:
116
154
 
117
155
  ```jsx
118
156
  <h1 data-style="pageTitle">Welcome</h1>
package/dist/client.js CHANGED
@@ -14,6 +14,7 @@
14
14
  var TIP_BG = '#292B32';
15
15
  var GREEN = '#22C55E';
16
16
  var LABEL = '#8B8D94';
17
+ var BADGE_IDLE = '#6B6D75'; // panel row-number badges sit greyscale; the hovered row's badge colors to PURPLE
17
18
  var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
18
19
  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>';
19
20
  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>';
@@ -28,6 +29,7 @@
28
29
  // ─── State ────────────────────────────────────────────────────────────────
29
30
  var fiActive = false;
30
31
  var measureMode = false;
32
+ var commentMode = false; // C: hide props/measure overlays for design review — Specs still capture them
31
33
  var pinEl = null;
32
34
  var pinHighlight = null;
33
35
  var lastHovered = null;
@@ -193,7 +195,7 @@
193
195
  hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
194
196
 
195
197
  var clearBtn = document.createElement('span');
196
- clearBtn.textContent = '✕ Delete all annotations';
198
+ clearBtn.textContent = '✕ Delete all';
197
199
  clearBtn.title = 'Delete all annotations';
198
200
  Object.assign(clearBtn.style, {
199
201
  display: 'none',
@@ -225,7 +227,18 @@
225
227
  chevron.addEventListener('click', function (e) { e.stopPropagation(); moveSide(); });
226
228
  hoverFx(chevron, { opacity: '1', transform: 'scale(1.2)' }, { opacity: '0.8', transform: 'scale(1)' });
227
229
 
230
+ // Sync status in the control bar (only when a bridge is configured): spinner while
231
+ // syncing, green when synced, red on error — so you always know what's staged.
232
+ var pillSync = document.createElement('span');
233
+ Object.assign(pillSync.style, {
234
+ display: 'none', width: '8px', height: '8px', borderRadius: '999px',
235
+ background: '#6B7280', flexShrink: '0', boxSizing: 'border-box',
236
+ });
237
+ pillSync.title = 'Sync status — click to re-sync';
238
+ pillSync.addEventListener('click', function (e) { e.stopPropagation(); if (BRIDGE) doSync(); });
239
+
228
240
  pill.appendChild(iconBtn);
241
+ pill.appendChild(pillSync);
229
242
  pill.appendChild(pillText);
230
243
  pill.appendChild(listBtn);
231
244
  pill.appendChild(clearBtn);
@@ -233,6 +246,30 @@
233
246
  pillWrap.appendChild(pill);
234
247
  document.body.appendChild(pillWrap);
235
248
 
249
+ // Keyframes for the sync spinner (injected once).
250
+ var spinStyle = document.createElement('style');
251
+ spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
252
+ markUI(spinStyle);
253
+ document.head.appendChild(spinStyle);
254
+
255
+ // Shared dot styling for the control-bar AND side-panel sync indicators, so they
256
+ // always match: spinner while syncing, green when synced, red on error.
257
+ function applyDotState(el, state) {
258
+ el.style.boxSizing = 'border-box';
259
+ if (state === 'syncing') {
260
+ Object.assign(el.style, { width: '10px', height: '10px', background: 'transparent', border: '2px solid rgba(255,255,255,0.35)', borderTopColor: '#fff', animation: '__specterSpin 0.6s linear infinite' });
261
+ } else if (state === 'synced') {
262
+ Object.assign(el.style, { width: '8px', height: '8px', background: GREEN, border: 'none', animation: 'none' });
263
+ } else { // offline / error
264
+ Object.assign(el.style, { width: '8px', height: '8px', background: '#F26D6D', border: 'none', animation: 'none' });
265
+ }
266
+ }
267
+ function setPillSync(state) {
268
+ if (!BRIDGE) { pillSync.style.display = 'none'; return; }
269
+ pillSync.style.display = 'inline-block';
270
+ applyDotState(pillSync, state);
271
+ }
272
+
236
273
  pillWrap.addEventListener('mouseenter', function () {
237
274
  clearMeasureOverlay();
238
275
  clearMeasureTargetHL();
@@ -262,6 +299,12 @@
262
299
  }
263
300
  }
264
301
 
302
+ // The mode is shown at all times (persistent prefix), so you always know whether
303
+ // hovering shows properties, measurements, or nothing (Comment).
304
+ function modeLabel() {
305
+ return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
306
+ }
307
+
265
308
  function expandPill(text) {
266
309
  pill.style.maxWidth = '820px';
267
310
  pillText.style.display = 'inline';
@@ -269,19 +312,17 @@
269
312
  listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
270
313
  clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
271
314
  pillExpanded = true;
272
- if (text) { pillText.textContent = text; return; }
273
- if (specs.length > 0) {
274
- pillText.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs') + ' · P add · L panel · Cmd+C copy';
275
- } else if (measureMode) {
276
- pillText.textContent = 'Measure · hover distances · P mark · M pin · Cmd+C copy · Option toggle';
277
- } else {
278
- pillText.textContent = 'Properties · P mark / annotate · Cmd+C copy · Option measure';
279
- }
315
+ // Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
316
+ // side panel now, so the pill stays short.
317
+ pillText.textContent = text || modeLabel();
280
318
  }
281
319
 
320
+ // "Collapsed" = the compact resting state: mode label only, no hints/buttons.
321
+ // Still shows the mode so it's visible at all times.
282
322
  function collapsePill() {
283
- pill.style.maxWidth = '32px';
284
- pillText.style.display = 'none';
323
+ pillText.textContent = modeLabel();
324
+ pillText.style.display = 'inline';
325
+ pill.style.maxWidth = '220px';
285
326
  chevron.style.display = 'none';
286
327
  listBtn.style.display = 'none';
287
328
  clearBtn.style.display = 'none';
@@ -290,7 +331,7 @@
290
331
 
291
332
  function flashMode() {
292
333
  if (pillExpanded && pillWrap.matches(':hover')) return;
293
- var text = measureMode ? 'Measure mode' : 'Properties mode';
334
+ var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
294
335
  expandPill(text);
295
336
  clearTimeout(flashTimer);
296
337
  flashTimer = setTimeout(function () {
@@ -311,11 +352,13 @@
311
352
  function activate() {
312
353
  fiActive = true;
313
354
  measureMode = false;
355
+ commentMode = false;
314
356
  pillWrap.style.display = 'block';
315
357
  document.body.style.cursor = 'crosshair';
316
358
  updatePill();
317
359
  startLoop();
318
360
  reflowSpecs();
361
+ if (BRIDGE) doSync(); // resolve the control-bar sync dot (green if reachable, red if not)
319
362
  }
320
363
 
321
364
  function deactivate() {
@@ -599,24 +642,37 @@
599
642
  return ['[Specter]', head, 'find: ' + resolveLocator(data.el), props.join(' · ')].join('\n');
600
643
  }
601
644
 
645
+ // Group Specs on the SAME element so their identical properties aren't emitted
646
+ // twice — several comments on one thing share ONE property block, each with its
647
+ // own change note. Measure Specs always stand alone (each is a distinct reading).
648
+ function groupSpecs() {
649
+ var groups = [];
650
+ for (var i = 0; i < specs.length; i++) {
651
+ var s = specs[i], g = null;
652
+ if (s.kind === 'element' && s.el) {
653
+ for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
654
+ }
655
+ if (g) g.specs.push(s);
656
+ else groups.push({ kind: s.kind, el: s.el, specs: [s] });
657
+ }
658
+ return groups;
659
+ }
660
+
602
661
  // Copy every Spec — using each Spec's body snapshotted at mark time, so Specs
603
662
  // whose element is currently hidden (e.g. inside a closed modal) still copy.
663
+ // Same-element Specs collapse into one block (properties once, notes stacked).
604
664
  function buildSpecsCopyText() {
605
- var n = specs.length;
606
- return specs.map(function (spec, i) {
607
- var tag = spec.kind === 'measure' ? 'Specter Measure' : 'Specter';
665
+ var groups = groupSpecs();
666
+ var n = groups.length;
667
+ return groups.map(function (g, i) {
668
+ var tag = g.kind === 'measure' ? 'Specter Measure' : 'Specter';
608
669
  var header = n > 1 ? '[' + tag + ' ' + (i + 1) + '/' + n + ']' : '[' + tag + ']';
609
- if (spec.note) header += '\n✏️ CHANGE: ' + spec.note;
610
- return spec.body.replace(/^\[Specter[^\]]*\]/, function () { return header; });
670
+ var notes = g.specs.filter(function (s) { return s.note; }).map(function (s) { return '✏️ CHANGE: ' + s.note; });
671
+ if (notes.length) header += '\n' + notes.join('\n');
672
+ return g.specs[0].body.replace(/^\[Specter[^\]]*\]/, function () { return header; });
611
673
  }).join('\n\n---\n\n');
612
674
  }
613
675
 
614
- function linesToHTML(str) {
615
- return str.split('\n').map(function (l) {
616
- return '<div style="margin-bottom:4px;color:#fff">' + esc(l) + '</div>';
617
- }).join('');
618
- }
619
-
620
676
  // ─── Hover outline ──────────────────────────────────────────────────────────
621
677
  function setHoverOutline(el) {
622
678
  if (outlinedEl === el) return;
@@ -648,12 +704,14 @@
648
704
  }
649
705
 
650
706
  function reRenderTooltip() {
707
+ if (commentMode) { hideTooltip(); return; }
651
708
  if (measureMode) {
652
- var text = (pinEl && pinEl !== lastHovered) ? measureBetween(pinEl, lastHovered) : measureToNeighbor(lastHovered);
653
- tooltip.innerHTML = linesToHTML(text);
654
- } else {
655
- tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
709
+ // Redraw the overlay on scroll/resize; readout box stays hidden (see hover handler).
710
+ if (pinEl && pinEl !== lastHovered) measureBetween(pinEl, lastHovered); else measureToNeighbor(lastHovered);
711
+ hideTooltip();
712
+ return;
656
713
  }
714
+ tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
657
715
  positionTooltip(lastMouse.x, lastMouse.y);
658
716
  }
659
717
 
@@ -809,7 +867,7 @@
809
867
 
810
868
  function updateBadgeContent(spec) {
811
869
  spec.num.textContent = String(specs.indexOf(spec) + 1);
812
- if (spec.note) spec.noteSpan.textContent = '✏️ ' + spec.note;
870
+ if (spec.note) spec.noteSpan.textContent = spec.note;
813
871
  else spec.noteSpan.textContent = spec.kind === 'measure' ? '⬡ measure' : '';
814
872
  }
815
873
 
@@ -854,10 +912,6 @@
854
912
  updateBadgeContent(spec);
855
913
  }
856
914
 
857
- function findElementSpec(el) {
858
- for (var i = 0; i < specs.length; i++) if (specs[i].kind === 'element' && specs[i].el === el) return specs[i];
859
- return null;
860
- }
861
915
 
862
916
  // If the element sits inside a dialog/modal/drawer/menu, produce a hint for how to
863
917
  // bring it back when it's later hidden — a declarative opener (aria-controls /
@@ -896,7 +950,9 @@
896
950
  if (pinEl && lastHovered && lastHovered !== pinEl) { el = pinEl; body = buildMeasureCopyText(pinEl, lastHovered); }
897
951
  else { el = anchorEl; body = buildNeighborCopyText(anchorEl); }
898
952
  } else {
899
- if (findElementSpec(anchorEl)) return; // P over an already-Spec'd element nothing
953
+ // Multiple Specs per element are allowed in every mode — clustering fans the badges
954
+ // out and same-element output collapses to one property block. Mode only changes
955
+ // what's shown on screen, never whether you can add an annotation.
900
956
  body = buildLLMClipboard(buildInfo(anchorEl));
901
957
  }
902
958
  clearHoverOutline();
@@ -1134,14 +1190,74 @@
1134
1190
  }
1135
1191
 
1136
1192
  var panelList = document.createElement('div');
1137
- Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden' });
1193
+ // overscroll-behavior:contain stops the panel's scroll from chaining into the page.
1194
+ Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain' });
1195
+
1196
+ // Keyboard shortcuts reference — collapsible, collapsed by default, lives here (not
1197
+ // in the pill) so the pill stays short.
1198
+ var panelKeys = document.createElement('div');
1199
+ Object.assign(panelKeys.style, {
1200
+ flexShrink: '0', fontSize: '11px', lineHeight: '18px', color: LABEL,
1201
+ borderTop: '1px solid rgba(255,255,255,0.08)',
1202
+ });
1203
+ (function () {
1204
+ var rows = [
1205
+ ['P', 'mark / comment the hovered element'],
1206
+ ['C', 'toggle Comment mode (hide properties)'],
1207
+ ['\u2325 Option', 'toggle Measure mode'],
1208
+ ['M', 'pin an element to measure from'],
1209
+ ['Cmd/Ctrl+C', 'copy all Specs'],
1210
+ ['L', 'toggle this panel'],
1211
+ ];
1212
+ var head = document.createElement('div');
1213
+ Object.assign(head.style, { display: 'flex', alignItems: 'center', gap: '6px', color: '#8A8D96', fontWeight: '700', letterSpacing: '0.04em', textTransform: 'uppercase', fontSize: '11px', padding: '10px 16px', cursor: 'pointer', userSelect: 'none' });
1214
+ var caret = document.createElement('span');
1215
+ caret.textContent = '\u203A'; // ›
1216
+ Object.assign(caret.style, { display: 'inline-block', fontSize: '16px', lineHeight: '1', transition: 'transform 0.15s ease', transform: 'rotate(0deg)' });
1217
+ var headText = document.createElement('span');
1218
+ headText.textContent = 'Keyboard shortcuts';
1219
+ head.appendChild(caret);
1220
+ head.appendChild(headText);
1221
+ var body = document.createElement('div');
1222
+ Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
1223
+ rows.forEach(function (r) {
1224
+ var row = document.createElement('div');
1225
+ Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
1226
+ var k = document.createElement('span');
1227
+ k.textContent = r[0];
1228
+ Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
1229
+ var d = document.createElement('span');
1230
+ d.textContent = r[1];
1231
+ row.appendChild(k); row.appendChild(d);
1232
+ body.appendChild(row);
1233
+ });
1234
+ var open = false;
1235
+ head.addEventListener('click', function (e) {
1236
+ e.stopPropagation();
1237
+ open = !open;
1238
+ body.style.display = open ? 'block' : 'none';
1239
+ caret.style.transform = open ? 'rotate(90deg)' : 'rotate(0deg)';
1240
+ });
1241
+ panelKeys.appendChild(head);
1242
+ panelKeys.appendChild(body);
1243
+ })();
1138
1244
 
1139
1245
  panelWrap.appendChild(panelHead);
1140
1246
  panelWrap.appendChild(panelTools);
1141
1247
  panelWrap.appendChild(panelHint);
1142
1248
  panelWrap.appendChild(panelList);
1249
+ panelWrap.appendChild(panelKeys);
1143
1250
  document.body.appendChild(panelWrap);
1144
1251
 
1252
+ // Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
1253
+ // scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
1254
+ // wheel over any non-scrolling part of the panel never scrolls the page behind it.
1255
+ panelWrap.addEventListener('wheel', function (e) {
1256
+ var canScroll = panelList.scrollHeight > panelList.clientHeight;
1257
+ if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
1258
+ e.preventDefault();
1259
+ }, { passive: false });
1260
+
1145
1261
  // ── Auto-sync: mirror the browser's current Specs to the local bridge ──
1146
1262
  // Debounced so rapid edits/typing collapse into one POST. The bridge replaces
1147
1263
  // its snapshot for this URL, so it always reflects what's in the panel — no
@@ -1149,24 +1265,33 @@
1149
1265
  var syncTimer = null;
1150
1266
  function setSyncState(s) {
1151
1267
  if (!BRIDGE) return;
1152
- if (s === 'syncing') { dot.style.background = '#F59E0B'; dotLabel.textContent = 'Syncing…'; }
1153
- else if (s === 'synced') { dot.style.background = GREEN; dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1154
- else { dot.style.background = '#6B7280'; dotLabel.textContent = 'Bridge offline'; }
1268
+ setPillSync(s); // control-bar dot
1269
+ applyDotState(dot, s); // side-panel dot same spinner/green/red
1270
+ if (s === 'syncing') { dotLabel.textContent = 'Syncing…'; }
1271
+ else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
1272
+ else { dotLabel.textContent = 'Bridge offline'; }
1155
1273
  }
1156
1274
  function doSync() {
1157
1275
  if (!BRIDGE) return;
1158
1276
  setSyncState('syncing');
1277
+ // Keep the spinner up for at least 1s even on an instant localhost sync, so the
1278
+ // feedback is actually perceptible instead of flashing by.
1279
+ var started = Date.now();
1280
+ var settle = function (state) {
1281
+ var wait = Math.max(0, 1000 - (Date.now() - started));
1282
+ setTimeout(function () { setSyncState(state); }, wait);
1283
+ };
1159
1284
  var payload = {
1160
1285
  url: location.href,
1161
1286
  text: buildSpecsCopyText(),
1162
- specs: specs.map(function (s, i) {
1163
- return { num: i + 1, note: s.note || '', kind: s.kind, body: s.body };
1287
+ specs: groupSpecs().map(function (g, i) {
1288
+ return { num: i + 1, note: g.specs.map(function (s) { return s.note; }).filter(Boolean).join('\n'), kind: g.kind, body: g.specs[0].body };
1164
1289
  }),
1165
1290
  };
1166
1291
  fetch(BRIDGE + '/specs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
1167
1292
  .then(function (r) { if (!r.ok) throw 0; return r.json(); })
1168
- .then(function () { setSyncState('synced'); })
1169
- .catch(function () { setSyncState('offline'); });
1293
+ .then(function () { settle('synced'); })
1294
+ .catch(function () { settle('offline'); });
1170
1295
  }
1171
1296
  function scheduleSync() {
1172
1297
  if (!BRIDGE) return;
@@ -1202,9 +1327,10 @@
1202
1327
  badge.textContent = String(i + 1);
1203
1328
  Object.assign(badge.style, {
1204
1329
  flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
1205
- background: PURPLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1206
- border: '2px solid #fff', boxSizing: 'border-box',
1330
+ background: BADGE_IDLE, color: '#fff', fontSize: '11px', fontWeight: '700',
1331
+ border: '1px solid #fff', boxSizing: 'border-box',
1207
1332
  display: 'flex', alignItems: 'center', justifyContent: 'center',
1333
+ transition: 'background 0.12s ease',
1208
1334
  });
1209
1335
  var content = document.createElement('div');
1210
1336
  Object.assign(content.style, { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '6px', paddingRight: '30px' });
@@ -1310,7 +1436,7 @@
1310
1436
  Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
1311
1437
  var tag = document.createElement('span');
1312
1438
  tag.textContent = 'HIDDEN';
1313
- Object.assign(tag.style, { fontSize: '9px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1439
+ Object.assign(tag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
1314
1440
  var hint = document.createElement('span');
1315
1441
  hint.textContent = spec.locate || 'Not on the page right now';
1316
1442
  Object.assign(hint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
@@ -1326,12 +1452,14 @@
1326
1452
  note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
1327
1453
  row.addEventListener('mouseenter', function () {
1328
1454
  row.style.background = 'rgba(255,255,255,0.05)';
1455
+ badge.style.background = PURPLE;
1329
1456
  editBtn.style.display = delBtn.style.display = 'flex';
1330
1457
  actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
1331
1458
  if (visible) { highlightSpec = spec; revealSpec(spec); }
1332
1459
  });
1333
1460
  row.addEventListener('mouseleave', function () {
1334
1461
  row.style.background = 'transparent';
1462
+ badge.style.background = BADGE_IDLE;
1335
1463
  editBtn.style.display = delBtn.style.display = 'none';
1336
1464
  actions.style.background = 'transparent';
1337
1465
  if (highlightSpec === spec) highlightSpec = null;
@@ -1574,9 +1702,9 @@
1574
1702
  position: 'absolute',
1575
1703
  background: RED,
1576
1704
  color: '#fff',
1577
- fontSize: '10px',
1705
+ fontSize: '12px',
1578
1706
  fontFamily: MONO,
1579
- padding: '1px 4px',
1707
+ padding: '2px 5px',
1580
1708
  borderRadius: '3px',
1581
1709
  pointerEvents: 'none',
1582
1710
  whiteSpace: 'nowrap',
@@ -1776,12 +1904,24 @@
1776
1904
 
1777
1905
  lastHovered = target;
1778
1906
 
1907
+ // Comment mode: outline only (so you know what you're commenting on), no
1908
+ // properties/measure overlay on screen. The Spec still captures everything.
1909
+ if (commentMode) {
1910
+ clearMeasureOverlay();
1911
+ clearMeasureTargetHL();
1912
+ setHoverOutline(target);
1913
+ hideTooltip();
1914
+ return;
1915
+ }
1916
+
1779
1917
  if (measureMode) {
1780
1918
  clearHoverOutline();
1781
1919
  showMeasureTargetHL(target);
1782
- var text = (pinEl && pinEl !== target) ? measureBetween(pinEl, target) : measureToNeighbor(target);
1783
- tooltip.innerHTML = linesToHTML(text);
1784
- positionTooltip(e.clientX, e.clientY);
1920
+ // Draw the on-screen measurement overlay (the px badges + lines) but keep
1921
+ // the dark readout box hidden — it occludes the very measurements it reports.
1922
+ // The full readout still copies with Cmd+C. (Properties mode keeps its box.)
1923
+ if (pinEl && pinEl !== target) measureBetween(pinEl, target); else measureToNeighbor(target);
1924
+ hideTooltip();
1785
1925
  if (pinEl) updatePinHL();
1786
1926
  } else {
1787
1927
  clearMeasureOverlay();
@@ -1863,6 +2003,21 @@
1863
2003
  return;
1864
2004
  }
1865
2005
 
2006
+ // C — toggle Comment mode (outline only; props/measure hidden but still captured).
2007
+ // Plain c only — Cmd/Ctrl+C copy was handled above and returned.
2008
+ if ((e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey) {
2009
+ e.preventDefault();
2010
+ commentMode = !commentMode;
2011
+ if (commentMode && measureMode) { measureMode = false; clearPin(); }
2012
+ clearMeasureOverlay();
2013
+ clearMeasureTargetHL();
2014
+ clearHoverOutline();
2015
+ hideTooltip();
2016
+ if (lastHovered && commentMode) setHoverOutline(lastHovered);
2017
+ flashMode();
2018
+ return;
2019
+ }
2020
+
1866
2021
  // Esc only HIDES the plugin — Specs persist and return on reactivate.
1867
2022
  if (e.key === 'Escape') {
1868
2023
  if (panelOpen) { hidePanel(); return; }