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 +79 -41
- package/dist/client.js +204 -49
- package/dist/extension-chrome/content.js +204 -49
- package/dist/extension-chrome/manifest.json +1 -1
- package/dist/extension-firefox/content.js +204 -49
- package/dist/extension-firefox/manifest.json +1 -1
- package/dist/index.cjs +204 -49
- package/dist/index.js +204 -49
- package/extension/content.js +204 -49
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Specter
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
72
|
+
### Measure spacing (Measure mode)
|
|
48
73
|
|
|
49
|
-
|
|
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
|
-
###
|
|
76
|
+
### Mark a Spec (and optionally annotate)
|
|
52
77
|
|
|
53
|
-
|
|
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
|
-
###
|
|
80
|
+
### Comment mode
|
|
56
81
|
|
|
57
|
-
|
|
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
|
-
###
|
|
84
|
+
### Specs side panel
|
|
60
85
|
|
|
61
|
-
Press **
|
|
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
|
-
###
|
|
88
|
+
### Copy to your AI
|
|
64
89
|
|
|
65
|
-
Press **
|
|
90
|
+
Press **Cmd+C**:
|
|
66
91
|
|
|
67
|
-
|
|
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/
|
|
71
|
-
✏️ CHANGE:
|
|
72
|
-
<button
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
87
|
-
|
|
|
88
|
-
|
|
|
89
|
-
|
|
|
90
|
-
|
|
|
91
|
-
|
|
|
92
|
-
|
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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()
|
|
108
|
-
specter({ shortcuts: { activate: 'ctrl+shift+i' } })
|
|
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
|
|
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
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
284
|
-
pillText.style.display = '
|
|
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 ? '
|
|
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
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
610
|
-
|
|
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
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
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:
|
|
1163
|
-
return { num: i + 1, note: s.note
|
|
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 () {
|
|
1169
|
-
.catch(function () {
|
|
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:
|
|
1206
|
-
border: '
|
|
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: '
|
|
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: '
|
|
1705
|
+
fontSize: '12px',
|
|
1578
1706
|
fontFamily: MONO,
|
|
1579
|
-
padding: '
|
|
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
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
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; }
|