vite-plugin-specter 0.4.1 → 0.6.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 +15 -0
- package/dist/client.js +1187 -337
- package/dist/extension-chrome/content.js +1187 -337
- package/dist/extension-chrome/manifest.json +1 -1
- package/dist/extension-firefox/content.js +1187 -337
- package/dist/extension-firefox/manifest.json +1 -1
- package/dist/index.cjs +1189 -337
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +1189 -337
- package/extension/content.js +1187 -337
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -17,11 +17,18 @@
|
|
|
17
17
|
var MONO = "'JetBrains Mono', 'SF Mono', 'Fira Code', monospace";
|
|
18
18
|
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
19
|
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>';
|
|
20
|
+
var TRASH = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"></path><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6"></path><path d="M14 11v6"></path><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
|
|
21
|
+
var LIST = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>';
|
|
22
|
+
var CHEV = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>';
|
|
23
|
+
var COPY = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
|
24
|
+
var CHECK = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
|
20
25
|
var ACTIVATE = "ctrl+alt+z";
|
|
26
|
+
var BRIDGE = ""; // Claude MCP bridge URL, or '' if disabled
|
|
21
27
|
|
|
22
28
|
// ─── State ────────────────────────────────────────────────────────────────
|
|
23
29
|
var fiActive = false;
|
|
24
30
|
var measureMode = false;
|
|
31
|
+
var commentMode = false; // C: hide props/measure overlays for design review — Specs still capture them
|
|
25
32
|
var pinEl = null;
|
|
26
33
|
var pinHighlight = null;
|
|
27
34
|
var lastHovered = null;
|
|
@@ -34,14 +41,38 @@
|
|
|
34
41
|
var copiedTimer = null;
|
|
35
42
|
var flashTimer = null;
|
|
36
43
|
var lastMouse = { x: 0, y: 0 };
|
|
37
|
-
var
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
var
|
|
41
|
-
var
|
|
44
|
+
var lastClick = { label: '', at: 0 }; // last interactive control clicked (likely opener of a modal)
|
|
45
|
+
// Specs = the unified marks (pick + optional annotation). Each:
|
|
46
|
+
// { el, path, note, body, kind:'element'|'measure', wrap, pill, num, noteSpan }
|
|
47
|
+
var specs = [];
|
|
48
|
+
var editorEl = null; // the open Spec-editor box, or null
|
|
49
|
+
var editorSpec = null; // the Spec being edited
|
|
50
|
+
var editorCommit = null; // idempotent commit fn for the open editor
|
|
51
|
+
var rafId = null; // reflow loop handle
|
|
52
|
+
var panelOpen = false; // Specs side panel visible?
|
|
53
|
+
var panelEditSpec = null; // Spec being inline-edited in the panel, or null
|
|
54
|
+
var highlightSpec = null; // Spec whose badge is enlarged (panel-row hover)
|
|
55
|
+
var groupHover = {}; // per-cluster fan-out hover state (keyed by member ids)
|
|
56
|
+
// Per-URL reload insurance: Specs survive an accidental refresh. Zero network —
|
|
57
|
+
// localStorage only, keyed by path so different routes keep separate lists.
|
|
58
|
+
var STORAGE_KEY = '__specter_specs_' + location.pathname;
|
|
59
|
+
|
|
60
|
+
// Tag every Specter-owned node so inspect/hover logic can skip its own UI
|
|
61
|
+
// (no "Specter-ception" — never inspect our own overlays).
|
|
62
|
+
function markUI(el) { el.setAttribute('data-specter-ui', ''); return el; }
|
|
63
|
+
function isUI(el) { return !!(el && el.closest && el.closest('[data-specter-ui]')); }
|
|
64
|
+
|
|
65
|
+
// Attach a hover effect to an interactive icon/button: apply the "on" styles
|
|
66
|
+
// while hovered, restore the "off" styles on leave (keeps things clickable-feeling).
|
|
67
|
+
function hoverFx(el, on, off) {
|
|
68
|
+
el.style.transition = (el.style.transition ? el.style.transition + ', ' : '') + 'background 0.12s ease, color 0.12s ease, opacity 0.12s ease, transform 0.12s ease';
|
|
69
|
+
el.addEventListener('mouseenter', function () { Object.assign(el.style, on); });
|
|
70
|
+
el.addEventListener('mouseleave', function () { Object.assign(el.style, off); });
|
|
71
|
+
}
|
|
42
72
|
|
|
43
73
|
// ─── Tooltip ──────────────────────────────────────────────────────────────
|
|
44
74
|
var tooltip = document.createElement('div');
|
|
75
|
+
markUI(tooltip);
|
|
45
76
|
Object.assign(tooltip.style, {
|
|
46
77
|
position: 'fixed',
|
|
47
78
|
zIndex: '2147483646',
|
|
@@ -61,6 +92,7 @@
|
|
|
61
92
|
|
|
62
93
|
// Measure overlay
|
|
63
94
|
var measureOverlay = document.createElement('div');
|
|
95
|
+
markUI(measureOverlay);
|
|
64
96
|
Object.assign(measureOverlay.style, {
|
|
65
97
|
position: 'fixed',
|
|
66
98
|
inset: '0',
|
|
@@ -72,6 +104,7 @@
|
|
|
72
104
|
|
|
73
105
|
// ─── Pill ─────────────────────────────────────────────────────────────────
|
|
74
106
|
var pillWrap = document.createElement('div');
|
|
107
|
+
markUI(pillWrap);
|
|
75
108
|
Object.assign(pillWrap.style, {
|
|
76
109
|
position: 'fixed',
|
|
77
110
|
bottom: '4px',
|
|
@@ -135,6 +168,7 @@
|
|
|
135
168
|
cursor: 'pointer',
|
|
136
169
|
});
|
|
137
170
|
closeEl.addEventListener('click', function (e) { e.stopPropagation(); deactivate(); });
|
|
171
|
+
hoverFx(closeEl, { transform: 'scale(1.25)' }, { transform: 'scale(1)' });
|
|
138
172
|
|
|
139
173
|
iconBtn.appendChild(zapEl);
|
|
140
174
|
iconBtn.appendChild(closeEl);
|
|
@@ -142,6 +176,41 @@
|
|
|
142
176
|
var pillText = document.createElement('span');
|
|
143
177
|
Object.assign(pillText.style, { display: 'none', color: '#f3d9fb' });
|
|
144
178
|
|
|
179
|
+
var listBtn = document.createElement('span');
|
|
180
|
+
listBtn.innerHTML = LIST;
|
|
181
|
+
listBtn.title = 'Show Specs panel (L)';
|
|
182
|
+
Object.assign(listBtn.style, {
|
|
183
|
+
display: 'none',
|
|
184
|
+
alignItems: 'center',
|
|
185
|
+
cursor: 'pointer',
|
|
186
|
+
color: '#fff',
|
|
187
|
+
flexShrink: '0',
|
|
188
|
+
padding: '4px 6px',
|
|
189
|
+
marginLeft: '2px',
|
|
190
|
+
borderRadius: '999px',
|
|
191
|
+
background: 'rgba(255,255,255,0.16)',
|
|
192
|
+
});
|
|
193
|
+
listBtn.addEventListener('click', function (e) { e.stopPropagation(); togglePanel(); });
|
|
194
|
+
hoverFx(listBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
195
|
+
|
|
196
|
+
var clearBtn = document.createElement('span');
|
|
197
|
+
clearBtn.textContent = '✕ Delete all';
|
|
198
|
+
clearBtn.title = 'Delete all annotations';
|
|
199
|
+
Object.assign(clearBtn.style, {
|
|
200
|
+
display: 'none',
|
|
201
|
+
cursor: 'pointer',
|
|
202
|
+
color: '#fff',
|
|
203
|
+
fontSize: '11px',
|
|
204
|
+
fontWeight: '600',
|
|
205
|
+
flexShrink: '0',
|
|
206
|
+
padding: '2px 8px',
|
|
207
|
+
marginLeft: '2px',
|
|
208
|
+
borderRadius: '999px',
|
|
209
|
+
background: 'rgba(255,255,255,0.16)',
|
|
210
|
+
});
|
|
211
|
+
clearBtn.addEventListener('click', function (e) { e.stopPropagation(); removeAllSpecs(); });
|
|
212
|
+
hoverFx(clearBtn, { background: 'rgba(255,255,255,0.32)' }, { background: 'rgba(255,255,255,0.16)' });
|
|
213
|
+
|
|
145
214
|
var chevron = document.createElement('span');
|
|
146
215
|
chevron.textContent = '›';
|
|
147
216
|
chevron.title = 'Move to other side';
|
|
@@ -155,13 +224,51 @@
|
|
|
155
224
|
opacity: '0.8',
|
|
156
225
|
});
|
|
157
226
|
chevron.addEventListener('click', function (e) { e.stopPropagation(); moveSide(); });
|
|
227
|
+
hoverFx(chevron, { opacity: '1', transform: 'scale(1.2)' }, { opacity: '0.8', transform: 'scale(1)' });
|
|
228
|
+
|
|
229
|
+
// Sync status in the control bar (only when a bridge is configured): spinner while
|
|
230
|
+
// syncing, green when synced, red on error — so you always know what's staged.
|
|
231
|
+
var pillSync = document.createElement('span');
|
|
232
|
+
Object.assign(pillSync.style, {
|
|
233
|
+
display: 'none', width: '8px', height: '8px', borderRadius: '999px',
|
|
234
|
+
background: '#6B7280', flexShrink: '0', boxSizing: 'border-box',
|
|
235
|
+
});
|
|
236
|
+
pillSync.title = 'Sync status — click to re-sync';
|
|
237
|
+
pillSync.addEventListener('click', function (e) { e.stopPropagation(); if (BRIDGE) doSync(); });
|
|
158
238
|
|
|
159
239
|
pill.appendChild(iconBtn);
|
|
240
|
+
pill.appendChild(pillSync);
|
|
160
241
|
pill.appendChild(pillText);
|
|
242
|
+
pill.appendChild(listBtn);
|
|
243
|
+
pill.appendChild(clearBtn);
|
|
161
244
|
pill.appendChild(chevron);
|
|
162
245
|
pillWrap.appendChild(pill);
|
|
163
246
|
document.body.appendChild(pillWrap);
|
|
164
247
|
|
|
248
|
+
// Keyframes for the sync spinner (injected once).
|
|
249
|
+
var spinStyle = document.createElement('style');
|
|
250
|
+
spinStyle.textContent = '@keyframes __specterSpin{to{transform:rotate(360deg)}}';
|
|
251
|
+
markUI(spinStyle);
|
|
252
|
+
document.head.appendChild(spinStyle);
|
|
253
|
+
|
|
254
|
+
// Shared dot styling for the control-bar AND side-panel sync indicators, so they
|
|
255
|
+
// always match: spinner while syncing, green when synced, red on error.
|
|
256
|
+
function applyDotState(el, state) {
|
|
257
|
+
el.style.boxSizing = 'border-box';
|
|
258
|
+
if (state === 'syncing') {
|
|
259
|
+
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' });
|
|
260
|
+
} else if (state === 'synced') {
|
|
261
|
+
Object.assign(el.style, { width: '8px', height: '8px', background: GREEN, border: 'none', animation: 'none' });
|
|
262
|
+
} else { // offline / error
|
|
263
|
+
Object.assign(el.style, { width: '8px', height: '8px', background: '#F26D6D', border: 'none', animation: 'none' });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function setPillSync(state) {
|
|
267
|
+
if (!BRIDGE) { pillSync.style.display = 'none'; return; }
|
|
268
|
+
pillSync.style.display = 'inline-block';
|
|
269
|
+
applyDotState(pillSync, state);
|
|
270
|
+
}
|
|
271
|
+
|
|
165
272
|
pillWrap.addEventListener('mouseenter', function () {
|
|
166
273
|
clearMeasureOverlay();
|
|
167
274
|
clearMeasureTargetHL();
|
|
@@ -172,7 +279,7 @@
|
|
|
172
279
|
closeEl.style.opacity = '1';
|
|
173
280
|
});
|
|
174
281
|
pillWrap.addEventListener('mouseleave', function () {
|
|
175
|
-
if (
|
|
282
|
+
if (specs.length === 0 && !pinEl) collapsePill();
|
|
176
283
|
zapEl.style.transform = 'rotate(0deg)';
|
|
177
284
|
zapEl.style.opacity = '1';
|
|
178
285
|
closeEl.style.opacity = '0';
|
|
@@ -191,54 +298,74 @@
|
|
|
191
298
|
}
|
|
192
299
|
}
|
|
193
300
|
|
|
301
|
+
// The mode is shown at all times (persistent prefix), so you always know whether
|
|
302
|
+
// hovering shows properties, measurements, or nothing (Comment).
|
|
303
|
+
function modeLabel() {
|
|
304
|
+
return commentMode ? 'Comment' : (measureMode ? 'Measure' : 'Properties');
|
|
305
|
+
}
|
|
306
|
+
|
|
194
307
|
function expandPill(text) {
|
|
195
|
-
pill.style.maxWidth = '
|
|
308
|
+
pill.style.maxWidth = '820px';
|
|
196
309
|
pillText.style.display = 'inline';
|
|
197
310
|
chevron.style.display = 'inline';
|
|
311
|
+
listBtn.style.display = specs.length > 0 ? 'inline-flex' : 'none';
|
|
312
|
+
clearBtn.style.display = specs.length > 0 ? 'inline' : 'none';
|
|
198
313
|
pillExpanded = true;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
} else if (measureMode) {
|
|
203
|
-
pillText.textContent = 'Measure · hover distances · M pin · Cmd+C copy · Option toggle';
|
|
204
|
-
} else {
|
|
205
|
-
pillText.textContent = 'Properties · hover · P pick · N annotate · Cmd+C copy · Option measure';
|
|
206
|
-
}
|
|
314
|
+
// Just the mode (+ the action buttons when Specs exist). Shortcuts live in the
|
|
315
|
+
// side panel now, so the pill stays short.
|
|
316
|
+
pillText.textContent = text || modeLabel();
|
|
207
317
|
}
|
|
208
318
|
|
|
319
|
+
// "Collapsed" = the compact resting state: mode label only, no hints/buttons.
|
|
320
|
+
// Still shows the mode so it's visible at all times.
|
|
209
321
|
function collapsePill() {
|
|
210
|
-
|
|
211
|
-
pillText.style.display = '
|
|
322
|
+
pillText.textContent = modeLabel();
|
|
323
|
+
pillText.style.display = 'inline';
|
|
324
|
+
pill.style.maxWidth = '220px';
|
|
212
325
|
chevron.style.display = 'none';
|
|
326
|
+
listBtn.style.display = 'none';
|
|
327
|
+
clearBtn.style.display = 'none';
|
|
213
328
|
pillExpanded = false;
|
|
214
329
|
}
|
|
215
330
|
|
|
216
331
|
function flashMode() {
|
|
217
332
|
if (pillExpanded && pillWrap.matches(':hover')) return;
|
|
218
|
-
var text = measureMode ? '
|
|
333
|
+
var text = commentMode ? 'Comment mode' : (measureMode ? 'Measure mode' : 'Properties mode');
|
|
219
334
|
expandPill(text);
|
|
220
335
|
clearTimeout(flashTimer);
|
|
221
336
|
flashTimer = setTimeout(function () {
|
|
222
|
-
if (!pillWrap.matches(':hover') &&
|
|
337
|
+
if (!pillWrap.matches(':hover') && specs.length === 0 && !pinEl) collapsePill();
|
|
223
338
|
else expandPill();
|
|
224
339
|
}, 1200);
|
|
225
340
|
}
|
|
226
341
|
|
|
342
|
+
// Show/collapse the pill based on whether any Specs exist.
|
|
343
|
+
function updatePill() {
|
|
344
|
+
if (specs.length > 0) expandPill();
|
|
345
|
+
else if (!pillWrap.matches(':hover')) collapsePill();
|
|
346
|
+
if (panelOpen) renderPanel();
|
|
347
|
+
}
|
|
348
|
+
|
|
227
349
|
// ─── Activate / Deactivate ────────────────────────────────────────────────
|
|
350
|
+
// Esc / toggle only HIDE the plugin — Specs persist and reappear on reactivate.
|
|
228
351
|
function activate() {
|
|
229
352
|
fiActive = true;
|
|
230
353
|
measureMode = false;
|
|
354
|
+
commentMode = false;
|
|
231
355
|
pillWrap.style.display = 'block';
|
|
232
356
|
document.body.style.cursor = 'crosshair';
|
|
233
|
-
|
|
357
|
+
updatePill();
|
|
358
|
+
startLoop();
|
|
359
|
+
reflowSpecs();
|
|
360
|
+
if (BRIDGE) doSync(); // resolve the control-bar sync dot (green if reachable, red if not)
|
|
234
361
|
}
|
|
235
362
|
|
|
236
363
|
function deactivate() {
|
|
364
|
+
commitEditor();
|
|
237
365
|
fiActive = false;
|
|
238
366
|
measureMode = false;
|
|
239
367
|
optionHeld = false;
|
|
240
368
|
lastHovered = null;
|
|
241
|
-
clearSelection();
|
|
242
369
|
clearPin();
|
|
243
370
|
clearHoverOutline();
|
|
244
371
|
clearMeasureTargetHL();
|
|
@@ -246,6 +373,9 @@
|
|
|
246
373
|
document.body.style.cursor = '';
|
|
247
374
|
hideTooltip();
|
|
248
375
|
clearMeasureOverlay();
|
|
376
|
+
hidePanel();
|
|
377
|
+
stopLoop();
|
|
378
|
+
reflowSpecs(); // hides all Spec badges while inactive (data kept)
|
|
249
379
|
}
|
|
250
380
|
|
|
251
381
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|
@@ -322,81 +452,6 @@
|
|
|
322
452
|
return null;
|
|
323
453
|
}
|
|
324
454
|
|
|
325
|
-
// Collapse a rule's declarations to their shortest lossless form: drop
|
|
326
|
-
// 'initial' (= default, no signal) and always-'normal' noise, and fold
|
|
327
|
-
// side/corner longhands back into shorthands (margin/padding/radius/gap/transition).
|
|
328
|
-
function cleanDecls(style) {
|
|
329
|
-
var m = {}, order = [];
|
|
330
|
-
for (var i = 0; i < style.length; i++) {
|
|
331
|
-
var p = style[i];
|
|
332
|
-
var v = style.getPropertyValue(p);
|
|
333
|
-
if (!v) continue;
|
|
334
|
-
if (v === 'initial') continue;
|
|
335
|
-
if (p === 'transition-behavior') continue;
|
|
336
|
-
if (!(p in m)) order.push(p);
|
|
337
|
-
m[p] = v;
|
|
338
|
-
}
|
|
339
|
-
var used = {}, collapses = {};
|
|
340
|
-
function four(name, t, r, b, l) {
|
|
341
|
-
if (m[t] === undefined || m[r] === undefined || m[b] === undefined || m[l] === undefined) return;
|
|
342
|
-
used[t] = used[r] = used[b] = used[l] = 1;
|
|
343
|
-
var a = m[t], c = m[r], d = m[b], e = m[l], sh;
|
|
344
|
-
if (a === c && c === d && d === e) sh = a;
|
|
345
|
-
else if (a === d && c === e) sh = a + ' ' + c;
|
|
346
|
-
else if (c === e) sh = a + ' ' + c + ' ' + d;
|
|
347
|
-
else sh = a + ' ' + c + ' ' + d + ' ' + e;
|
|
348
|
-
collapses[t] = name + ': ' + sh;
|
|
349
|
-
}
|
|
350
|
-
four('margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left');
|
|
351
|
-
four('padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left');
|
|
352
|
-
four('border-radius', 'border-top-left-radius', 'border-top-right-radius', 'border-bottom-right-radius', 'border-bottom-left-radius');
|
|
353
|
-
if (m['row-gap'] !== undefined && m['column-gap'] !== undefined) {
|
|
354
|
-
var g = m['row-gap'] === m['column-gap'] ? m['row-gap'] : m['row-gap'] + ' ' + m['column-gap'];
|
|
355
|
-
collapses['row-gap'] = 'gap: ' + g; used['row-gap'] = used['column-gap'] = 1;
|
|
356
|
-
}
|
|
357
|
-
if (m['transition-property'] !== undefined) {
|
|
358
|
-
var tr = 'transition: ' + m['transition-property'];
|
|
359
|
-
if (m['transition-duration'] !== undefined) tr += ' ' + m['transition-duration'];
|
|
360
|
-
if (m['transition-timing-function'] !== undefined) tr += ' ' + m['transition-timing-function'];
|
|
361
|
-
if (m['transition-delay'] !== undefined && m['transition-delay'] !== '0s') tr += ' ' + m['transition-delay'];
|
|
362
|
-
collapses['transition-property'] = tr;
|
|
363
|
-
used['transition-property'] = used['transition-duration'] = used['transition-timing-function'] = used['transition-delay'] = 1;
|
|
364
|
-
}
|
|
365
|
-
var out = [];
|
|
366
|
-
order.forEach(function (p) {
|
|
367
|
-
if (collapses[p]) { out.push(' ' + collapses[p] + ';'); return; }
|
|
368
|
-
if (used[p]) return;
|
|
369
|
-
out.push(' ' + p + ': ' + m[p] + ';');
|
|
370
|
-
});
|
|
371
|
-
return out;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function getMatchingRules(el) {
|
|
375
|
-
var results = [];
|
|
376
|
-
try {
|
|
377
|
-
for (var s = 0; s < document.styleSheets.length; s++) {
|
|
378
|
-
var sheet = document.styleSheets[s];
|
|
379
|
-
var rules;
|
|
380
|
-
try { rules = sheet.cssRules; } catch (e) { continue; }
|
|
381
|
-
if (!rules) continue;
|
|
382
|
-
for (var r = 0; r < rules.length; r++) {
|
|
383
|
-
var rule = rules[r];
|
|
384
|
-
if (rule.type !== 1) continue;
|
|
385
|
-
var sel = rule.selectorText;
|
|
386
|
-
if (!sel) continue;
|
|
387
|
-
if (/^[*,]|^:root|^html|^body$|^::before|^::after/.test(sel.trim())) continue;
|
|
388
|
-
try {
|
|
389
|
-
if (el.matches(sel)) {
|
|
390
|
-
var props = cleanDecls(rule.style);
|
|
391
|
-
if (props.length) results.push(sel + ' {\n' + props.join('\n') + '\n}');
|
|
392
|
-
}
|
|
393
|
-
} catch (e) {}
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
} catch (e) {}
|
|
397
|
-
return results;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
455
|
function getSelector(el) {
|
|
401
456
|
var parts = [];
|
|
402
457
|
var cur = el;
|
|
@@ -418,12 +473,90 @@
|
|
|
418
473
|
return parts.join(' > ');
|
|
419
474
|
}
|
|
420
475
|
|
|
476
|
+
// ─── Layer A locator ──────────────────────────────────────────────────────────
|
|
477
|
+
// The single most greppable anchor for an element, so Claude jumps straight to the
|
|
478
|
+
// source instead of searching: a unique test-id → stable id → aria/name → short
|
|
479
|
+
// visible text → a unique class, falling back to the CSS path. Any stack, no build
|
|
480
|
+
// step, one line — fewer agent tool-calls + more accurate edits (the core tenet).
|
|
481
|
+
function uniqueSel(sel) { try { return !!sel && document.querySelectorAll(sel).length === 1; } catch (e) { return false; } }
|
|
482
|
+
function isHashedId(id) { return /:r[0-9a-z]+:/i.test(id) || /[a-f0-9]{8,}/i.test(id) || /__[a-zA-Z0-9]{5,}/.test(id) || /_[a-zA-Z0-9]{6,}$/.test(id); }
|
|
483
|
+
function ownText(el) {
|
|
484
|
+
if (el.children && el.children.length > 2) return '';
|
|
485
|
+
var t = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
|
486
|
+
return (t.length >= 2 && t.length <= 80) ? t : '';
|
|
487
|
+
}
|
|
488
|
+
function ownClass(el) {
|
|
489
|
+
var cls = Array.prototype.slice.call(el.classList || []).filter(function (c) { return c.indexOf('__specter') !== 0 && !/[a-f0-9]{6,}/i.test(c) && !/_[a-zA-Z0-9]{5,}$/.test(c); });
|
|
490
|
+
for (var i = 0; i < cls.length; i++) { if (uniqueSel('.' + cssEsc(cls[i]))) return cls[i]; }
|
|
491
|
+
return '';
|
|
492
|
+
}
|
|
493
|
+
// A 'text "…"' anchor is only safe if that text isn't ALSO the own-text of a
|
|
494
|
+
// DIFFERENT element (an ancestor/descendant sharing it = the same source spot,
|
|
495
|
+
// so those don't count). Prevents an ambiguous grep (e.g. a button and a modal
|
|
496
|
+
// heading both reading "Book a demo").
|
|
497
|
+
function uniqueTextAnchor(el, txt) {
|
|
498
|
+
var all = document.getElementsByTagName('*');
|
|
499
|
+
for (var i = 0; i < all.length; i++) {
|
|
500
|
+
var o = all[i];
|
|
501
|
+
if (o === el || el.contains(o) || o.contains(el)) continue;
|
|
502
|
+
if (ownText(o) === txt) return false;
|
|
503
|
+
}
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
// Which of {color, background} are altered by a :hover/:active/:focus rule that
|
|
507
|
+
// matches this element — so a value read WHILE the cursor is on it (its computed
|
|
508
|
+
// style is the hovered state) can be flagged instead of reported as the resting
|
|
509
|
+
// value. Detect-only: recovering the resting value would need a cascade parser.
|
|
510
|
+
var STATE_PSEUDO = /:(hover|active|focus|focus-visible|focus-within)\b/g;
|
|
511
|
+
function hoverAffected(el) {
|
|
512
|
+
var out = {};
|
|
513
|
+
var sheets = document.styleSheets;
|
|
514
|
+
for (var i = 0; i < sheets.length; i++) {
|
|
515
|
+
var rules;
|
|
516
|
+
try { rules = sheets[i].cssRules; } catch (e) { continue; } // cross-origin sheet
|
|
517
|
+
if (!rules) continue;
|
|
518
|
+
for (var j = 0; j < rules.length; j++) {
|
|
519
|
+
var r = rules[j];
|
|
520
|
+
if (!r.selectorText || r.selectorText.indexOf(':') < 0) continue;
|
|
521
|
+
STATE_PSEUDO.lastIndex = 0;
|
|
522
|
+
if (!STATE_PSEUDO.test(r.selectorText)) continue;
|
|
523
|
+
var sels = r.selectorText.split(',');
|
|
524
|
+
for (var k = 0; k < sels.length; k++) {
|
|
525
|
+
STATE_PSEUDO.lastIndex = 0;
|
|
526
|
+
var base = sels[k].replace(STATE_PSEUDO, '').trim();
|
|
527
|
+
if (!base) continue;
|
|
528
|
+
var m = false;
|
|
529
|
+
try { m = el.matches(base); } catch (e) { continue; }
|
|
530
|
+
if (!m) continue;
|
|
531
|
+
if (r.style.color) out.color = true;
|
|
532
|
+
if (r.style.background || r.style.backgroundColor) out.bg = true;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
function resolveLocator(el) {
|
|
539
|
+
if (!el || el.nodeType !== 1) return '';
|
|
540
|
+
var i, v;
|
|
541
|
+
var testAttrs = ['data-testid', 'data-test-id', 'data-test', 'data-cy', 'data-qa'];
|
|
542
|
+
for (i = 0; i < testAttrs.length; i++) { v = el.getAttribute(testAttrs[i]); if (v) { var ts = '[' + testAttrs[i] + '="' + cssEsc(v) + '"]'; if (uniqueSel(ts)) return ts; } }
|
|
543
|
+
if (el.id && !isHashedId(el.id) && uniqueSel('#' + cssEsc(el.id))) return '#' + el.id;
|
|
544
|
+
var attrs = ['aria-label', 'name', 'placeholder', 'alt', 'title'];
|
|
545
|
+
for (i = 0; i < attrs.length; i++) { v = el.getAttribute(attrs[i]); if (v && v.trim()) { v = v.trim(); if (uniqueSel('[' + attrs[i] + '="' + cssEsc(v) + '"]')) return attrs[i] + ' "' + v.slice(0, 60) + '"'; } }
|
|
546
|
+
var txt = ownText(el);
|
|
547
|
+
if (txt && uniqueTextAnchor(el, txt)) return 'text "' + txt.slice(0, 40) + (txt.length > 40 ? '…' : '') + '"';
|
|
548
|
+
var cls = ownClass(el);
|
|
549
|
+
if (cls) return '.' + cls;
|
|
550
|
+
return getSelector(el); // fallback: the CSS path
|
|
551
|
+
}
|
|
552
|
+
|
|
421
553
|
// ─── Structured data model ──────────────────────────────────────────────────
|
|
422
554
|
function buildInfo(el) {
|
|
423
555
|
var cs = getComputedStyle(el);
|
|
424
556
|
var rect = el.getBoundingClientRect();
|
|
557
|
+
var hv = hoverAffected(el);
|
|
425
558
|
var ff = cs.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
|
|
426
|
-
var raw = el.textContent ? el.textContent.trim() : '';
|
|
559
|
+
var raw = el.textContent ? el.textContent.replace(/\s+/g, ' ').trim() : '';
|
|
427
560
|
return {
|
|
428
561
|
el: el,
|
|
429
562
|
tag: el.tagName.toLowerCase(),
|
|
@@ -438,13 +571,14 @@
|
|
|
438
571
|
font: { family: ff, weight: cs.fontWeight, size: cs.fontSize, lineHeight: cs.lineHeight },
|
|
439
572
|
color: colorObj(cs.color),
|
|
440
573
|
bg: colorObj(cs.backgroundColor),
|
|
574
|
+
colorHover: !!hv.color,
|
|
575
|
+
bgHover: !!hv.bg,
|
|
441
576
|
padding: edge(cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft),
|
|
442
577
|
margin: edge(cs.marginTop, cs.marginRight, cs.marginBottom, cs.marginLeft),
|
|
443
578
|
radius: (cs.borderRadius && cs.borderRadius !== '0px') ? cs.borderRadius : null,
|
|
444
579
|
display: ['flex', 'grid', 'inline-flex', 'inline-grid'].indexOf(cs.display) >= 0 ? cs.display : null,
|
|
445
580
|
gap: (cs.gap && cs.gap !== 'normal') ? cs.gap : null,
|
|
446
581
|
flexDir: (cs.display.indexOf('flex') >= 0 && cs.flexDirection !== 'row') ? cs.flexDirection : null,
|
|
447
|
-
rules: getMatchingRules(el),
|
|
448
582
|
};
|
|
449
583
|
}
|
|
450
584
|
|
|
@@ -464,8 +598,8 @@
|
|
|
464
598
|
if (data.text) h += '<div style="color:' + LABEL + ';font-style:italic;margin-bottom:8px">"' + esc(data.text) + (data.textTrunc ? '…' : '') + '"</div>';
|
|
465
599
|
h += '<div style="height:8px"></div>';
|
|
466
600
|
h += row('Font', esc(data.font.family) + ' · ' + weightName(data.font.weight) + ' · ' + data.font.size + '/' + data.font.lineHeight);
|
|
467
|
-
if (data.color) h += row('Color', swatch(data.color.hex) + data.color.label);
|
|
468
|
-
if (data.bg) h += row('Bg', swatch(data.bg.hex) + data.bg.label);
|
|
601
|
+
if (data.color) h += row('Color', swatch(data.color.hex) + data.color.label + (data.colorHover ? ' <span style="color:' + LABEL + '">(hover)</span>' : ''));
|
|
602
|
+
if (data.bg) h += row('Bg', swatch(data.bg.hex) + data.bg.label + (data.bgHover ? ' <span style="color:' + LABEL + '">(hover)</span>' : ''));
|
|
469
603
|
if (data.padding) h += row('Padding', data.padding.value);
|
|
470
604
|
if (data.margin) h += row('Margin', data.margin.value);
|
|
471
605
|
if (data.radius) h += row('Radius', data.radius);
|
|
@@ -478,49 +612,64 @@
|
|
|
478
612
|
return h;
|
|
479
613
|
}
|
|
480
614
|
|
|
615
|
+
// Lean copy: just enough to fix the issue — which element, and its current
|
|
616
|
+
// key values. Deliberately NO full CSS-rule dump (an AI with the repo finds
|
|
617
|
+
// the rule from the selector; one without it can't edit source anyway).
|
|
481
618
|
function buildLLMClipboard(data) {
|
|
482
|
-
|
|
619
|
+
// Identity: tag + component/styleKey + text + dims. Classes live in the
|
|
620
|
+
// selector line, so don't repeat them here.
|
|
483
621
|
var head = '<' + data.tag + '>';
|
|
484
|
-
if (data.id) head += '#' + data.id;
|
|
485
|
-
if (data.classes.length) head += '.' + data.classes.join('.');
|
|
486
622
|
if (data.component) head += ' ' + data.component;
|
|
487
623
|
if (data.styleKey) head += ' [' + data.styleKey + ']';
|
|
624
|
+
if (data.text) head += ' "' + data.text + (data.textTrunc ? '…' : '') + '"';
|
|
488
625
|
head += ' ' + data.width + '×' + data.height;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
if (data.color)
|
|
493
|
-
if (data.bg)
|
|
494
|
-
if (data.padding)
|
|
495
|
-
if (data.margin)
|
|
496
|
-
if (data.radius)
|
|
626
|
+
|
|
627
|
+
// One compact line of the values that could be the target of the change.
|
|
628
|
+
var props = ['font: ' + data.font.family + ' ' + data.font.weight + ' ' + data.font.size + '/' + data.font.lineHeight];
|
|
629
|
+
if (data.color) props.push('color: ' + data.color.label + (data.colorHover ? ' (hover)' : ''));
|
|
630
|
+
if (data.bg) props.push('bg: ' + data.bg.label + (data.bgHover ? ' (hover)' : ''));
|
|
631
|
+
if (data.padding) props.push('padding: ' + data.padding.value);
|
|
632
|
+
if (data.margin) props.push('margin: ' + data.margin.value);
|
|
633
|
+
if (data.radius) props.push('radius: ' + data.radius);
|
|
497
634
|
if (data.display) {
|
|
498
635
|
var d = 'display: ' + data.display;
|
|
499
|
-
if (data.gap) d += '
|
|
500
|
-
if (data.flexDir) d += '
|
|
501
|
-
|
|
636
|
+
if (data.gap) d += ' gap ' + data.gap;
|
|
637
|
+
if (data.flexDir) d += ' ' + data.flexDir;
|
|
638
|
+
props.push(d);
|
|
502
639
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
if (data.rules && data.rules.length) out += '\n\n--- CSS Rules ---\n' + data.rules.join('\n\n');
|
|
506
|
-
return out;
|
|
640
|
+
|
|
641
|
+
return ['[Specter]', head, 'find: ' + resolveLocator(data.el), props.join(' · ')].join('\n');
|
|
507
642
|
}
|
|
508
643
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
644
|
+
// Group Specs on the SAME element so their identical properties aren't emitted
|
|
645
|
+
// twice — several comments on one thing share ONE property block, each with its
|
|
646
|
+
// own change note. Measure Specs always stand alone (each is a distinct reading).
|
|
647
|
+
function groupSpecs() {
|
|
648
|
+
var groups = [];
|
|
649
|
+
for (var i = 0; i < specs.length; i++) {
|
|
650
|
+
var s = specs[i], g = null;
|
|
651
|
+
if (s.kind === 'element' && s.el) {
|
|
652
|
+
for (var j = 0; j < groups.length; j++) { if (groups[j].kind === 'element' && groups[j].el === s.el) { g = groups[j]; break; } }
|
|
653
|
+
}
|
|
654
|
+
if (g) g.specs.push(s);
|
|
655
|
+
else groups.push({ kind: s.kind, el: s.el, specs: [s] });
|
|
656
|
+
}
|
|
657
|
+
return groups;
|
|
518
658
|
}
|
|
519
659
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
660
|
+
// Copy every Spec — using each Spec's body snapshotted at mark time, so Specs
|
|
661
|
+
// whose element is currently hidden (e.g. inside a closed modal) still copy.
|
|
662
|
+
// Same-element Specs collapse into one block (properties once, notes stacked).
|
|
663
|
+
function buildSpecsCopyText() {
|
|
664
|
+
var groups = groupSpecs();
|
|
665
|
+
var n = groups.length;
|
|
666
|
+
return groups.map(function (g, i) {
|
|
667
|
+
var tag = g.kind === 'measure' ? 'Specter Measure' : 'Specter';
|
|
668
|
+
var header = n > 1 ? '[' + tag + ' ' + (i + 1) + '/' + n + ']' : '[' + tag + ']';
|
|
669
|
+
var notes = g.specs.filter(function (s) { return s.note; }).map(function (s) { return '✏️ CHANGE: ' + s.note; });
|
|
670
|
+
if (notes.length) header += '\n' + notes.join('\n');
|
|
671
|
+
return g.specs[0].body.replace(/^\[Specter[^\]]*\]/, function () { return header; });
|
|
672
|
+
}).join('\n\n---\n\n');
|
|
524
673
|
}
|
|
525
674
|
|
|
526
675
|
// ─── Hover outline ──────────────────────────────────────────────────────────
|
|
@@ -554,244 +703,897 @@
|
|
|
554
703
|
}
|
|
555
704
|
|
|
556
705
|
function reRenderTooltip() {
|
|
706
|
+
if (commentMode) { hideTooltip(); return; }
|
|
557
707
|
if (measureMode) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
708
|
+
// Redraw the overlay on scroll/resize; readout box stays hidden (see hover handler).
|
|
709
|
+
if (pinEl && pinEl !== lastHovered) measureBetween(pinEl, lastHovered); else measureToNeighbor(lastHovered);
|
|
710
|
+
hideTooltip();
|
|
711
|
+
return;
|
|
562
712
|
}
|
|
713
|
+
tooltip.innerHTML = buildHumanDisplay(buildInfo(lastHovered));
|
|
563
714
|
positionTooltip(lastMouse.x, lastMouse.y);
|
|
564
715
|
}
|
|
565
716
|
|
|
566
|
-
// ───
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
});
|
|
580
|
-
var badge = document.createElement('div');
|
|
581
|
-
badge.textContent = String(index + 1);
|
|
582
|
-
Object.assign(badge.style, {
|
|
583
|
-
width: '20px',
|
|
584
|
-
height: '20px',
|
|
585
|
-
flexShrink: '0',
|
|
586
|
-
background: PURPLE,
|
|
587
|
-
color: '#fff',
|
|
588
|
-
fontSize: '11px',
|
|
589
|
-
fontWeight: '700',
|
|
590
|
-
fontFamily: MONO,
|
|
591
|
-
borderRadius: '50%',
|
|
592
|
-
display: 'flex',
|
|
593
|
-
alignItems: 'center',
|
|
594
|
-
justifyContent: 'center',
|
|
595
|
-
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
|
|
596
|
-
});
|
|
597
|
-
wrap.appendChild(badge);
|
|
598
|
-
if (note) {
|
|
599
|
-
var chip = document.createElement('div');
|
|
600
|
-
chip.textContent = '✏️ ' + (note.length > 32 ? note.slice(0, 32) + '…' : note);
|
|
601
|
-
Object.assign(chip.style, {
|
|
602
|
-
maxWidth: '240px',
|
|
603
|
-
background: TIP_BG,
|
|
604
|
-
color: '#fff',
|
|
605
|
-
fontSize: '10px',
|
|
606
|
-
lineHeight: '16px',
|
|
607
|
-
fontFamily: MONO,
|
|
608
|
-
padding: '2px 6px',
|
|
609
|
-
borderRadius: '4px',
|
|
610
|
-
whiteSpace: 'nowrap',
|
|
611
|
-
overflow: 'hidden',
|
|
612
|
-
textOverflow: 'ellipsis',
|
|
613
|
-
border: '1px solid ' + PURPLE,
|
|
614
|
-
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
|
|
615
|
-
});
|
|
616
|
-
wrap.appendChild(chip);
|
|
717
|
+
// ─── Specs (marks: pick + optional annotation) ──────────────────────────────
|
|
718
|
+
// A stable-ish CSS path so a Spec can re-find its element if the DOM node is
|
|
719
|
+
// rebuilt (e.g. a modal that recreates its contents on reopen).
|
|
720
|
+
function elementPath(el) {
|
|
721
|
+
if (!el || el.nodeType !== 1) return '';
|
|
722
|
+
var parts = [], cur = el;
|
|
723
|
+
while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < 6) {
|
|
724
|
+
if (cur.id) { try { parts.unshift('#' + CSS.escape(cur.id)); } catch (e) { parts.unshift('#' + cur.id); } break; }
|
|
725
|
+
var seg = cur.tagName.toLowerCase();
|
|
726
|
+
var parent = cur.parentElement;
|
|
727
|
+
if (parent) seg += ':nth-child(' + (Array.prototype.indexOf.call(parent.children, cur) + 1) + ')';
|
|
728
|
+
parts.unshift(seg);
|
|
729
|
+
cur = cur.parentElement;
|
|
617
730
|
}
|
|
618
|
-
|
|
619
|
-
return wrap;
|
|
731
|
+
return parts.join(' > ');
|
|
620
732
|
}
|
|
621
733
|
|
|
622
|
-
function
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
734
|
+
function safeQuery(sel) { try { return sel ? document.querySelector(sel) : null; } catch (e) { return null; } }
|
|
735
|
+
|
|
736
|
+
function isVisible(el) {
|
|
737
|
+
if (!el || !el.isConnected) return false;
|
|
738
|
+
if (el.checkVisibility) { try { if (!el.checkVisibility()) return false; } catch (e) {} }
|
|
739
|
+
var r = el.getBoundingClientRect();
|
|
740
|
+
return r.width > 0 || r.height > 0;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Is the element's center covered by something else (e.g. a modal overlay)?
|
|
744
|
+
// Uses elementsFromPoint and skips Specter's own UI so a badge over the point
|
|
745
|
+
// doesn't count as occluding its own element.
|
|
746
|
+
function isOccluded(el, r) {
|
|
747
|
+
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
|
748
|
+
if (cx < 0 || cy < 0 || cx > window.innerWidth || cy > window.innerHeight) return true;
|
|
749
|
+
var stack = document.elementsFromPoint(cx, cy);
|
|
750
|
+
var top = null;
|
|
751
|
+
for (var i = 0; i < stack.length; i++) { if (!isUI(stack[i])) { top = stack[i]; break; } }
|
|
752
|
+
if (!top) return true;
|
|
753
|
+
return !(top === el || el.contains(top) || top.contains(el));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Place one badge: wrap at the element's anchor (left/top follows scroll, no
|
|
757
|
+
// transition), offset applied via transform (transitions smoothly). The expand
|
|
758
|
+
// flag grows the circle into its note capsule. A panel-highlighted badge pops to
|
|
759
|
+
// the anchor, enlarges, and rises on top.
|
|
760
|
+
function layoutBadge(s, bx, by, dx, dy, z, expand) {
|
|
761
|
+
var hi = (s === highlightSpec);
|
|
762
|
+
s.wrap.style.display = 'block';
|
|
763
|
+
s.wrap.style.left = bx + 'px';
|
|
764
|
+
s.wrap.style.top = by + 'px';
|
|
765
|
+
s.wrap.style.transform = hi ? 'translate(0px,0px)' : ('translate(' + dx + 'px,' + dy + 'px)');
|
|
766
|
+
s.wrap.style.zIndex = hi ? '2147483645' : String(z);
|
|
767
|
+
// Panel-row hover enlarges the circle (scale 1.5 → 30px). Page hover expands the
|
|
768
|
+
// capsule to the SAME 30px height (taller, matching that enlarged size) + bigger text.
|
|
769
|
+
s.cap.style.transform = hi ? 'scale(1.5)' : '';
|
|
770
|
+
s.cap.style.maxWidth = expand ? '320px' : '20px';
|
|
771
|
+
s.cap.style.height = expand ? '30px' : '20px';
|
|
772
|
+
s.num.style.fontSize = expand ? '14px' : '12px';
|
|
773
|
+
s.num.style.width = expand ? '24px' : '16px';
|
|
774
|
+
s.noteSpan.style.fontSize = expand ? '13px' : '12px';
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// The badge whose wrap sits under the cursor right now (topmost). Drives which
|
|
778
|
+
// single badge is expanded — never more than one.
|
|
779
|
+
function badgeUnderCursor(mx, my) {
|
|
780
|
+
var at = document.elementFromPoint(mx, my);
|
|
781
|
+
if (!at) return null;
|
|
782
|
+
var w = at.closest('[data-specter-ui]');
|
|
783
|
+
return (w && w.__spec) ? w.__spec : null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Position every Spec badge on its element each frame — follows scroll/layout,
|
|
787
|
+
// hides when the element is hidden or removed (closed modal), reappears when it
|
|
788
|
+
// returns (re-found by CSS path if the node was rebuilt). Badges that land on the
|
|
789
|
+
// same spot are clustered: shown side-by-side (latest centered, the previous two
|
|
790
|
+
// tucked half-behind on either side), and fanned into a full row on hover so any
|
|
791
|
+
// one is reachable. Only the badge under the cursor expands to show its note.
|
|
792
|
+
function reflowSpecs() {
|
|
793
|
+
var mx = lastMouse.x, my = lastMouse.y;
|
|
794
|
+
var hoverBadge = fiActive ? badgeUnderCursor(mx, my) : null;
|
|
795
|
+
|
|
796
|
+
// Pass 1 — resolve visibility + base anchor for each spec.
|
|
797
|
+
var vis = [];
|
|
798
|
+
for (var i = 0; i < specs.length; i++) {
|
|
799
|
+
var s = specs[i];
|
|
800
|
+
if (!fiActive) { s.wrap.style.display = 'none'; continue; }
|
|
801
|
+
if (!s.el || !s.el.isConnected) { var f = safeQuery(s.path); if (f) s.el = f; }
|
|
802
|
+
if (!isVisible(s.el)) { s.wrap.style.display = 'none'; continue; }
|
|
803
|
+
var r = s.el.getBoundingClientRect();
|
|
804
|
+
if (isOccluded(s.el, r)) { s.wrap.style.display = 'none'; continue; } // covered (e.g. behind a modal)
|
|
805
|
+
s._bx = r.left - 10; s._by = r.top - 10; // -4 circle offset, -6 wrap padding
|
|
806
|
+
vis.push(s);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Pass 2 — cluster badges whose anchors coincide (within ~16px).
|
|
810
|
+
var clusters = [];
|
|
811
|
+
for (var a = 0; a < vis.length; a++) {
|
|
812
|
+
var sp = vis[a], placed = false;
|
|
813
|
+
for (var c = 0; c < clusters.length; c++) {
|
|
814
|
+
var m0 = clusters[c][0];
|
|
815
|
+
if (Math.abs(sp._bx - m0._bx) < 16 && Math.abs(sp._by - m0._by) < 16) { clusters[c].push(sp); placed = true; break; }
|
|
816
|
+
}
|
|
817
|
+
if (!placed) clusters.push([sp]);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Pass 3 — lay out each cluster.
|
|
821
|
+
var TOP = 2147483644, MID = 2147483643, HIDE = 2147483640;
|
|
822
|
+
for (var ci = 0; ci < clusters.length; ci++) {
|
|
823
|
+
var members = clusters[ci];
|
|
824
|
+
var bx = members[0]._bx, by = members[0]._by;
|
|
825
|
+
if (members.length === 1) { layoutBadge(members[0], bx, by, 0, 0, TOP, members[0] === hoverBadge); continue; }
|
|
826
|
+
|
|
827
|
+
var n = members.length;
|
|
828
|
+
var key = members.map(function (m) { return specs.indexOf(m); }).sort(function (x, y) { return x - y; }).join(':');
|
|
829
|
+
var wasHover = !!groupHover[key];
|
|
830
|
+
var spreadDown = by < window.innerHeight - n * 30; // room below? else fan upward
|
|
831
|
+
|
|
832
|
+
// Hysteresis: while fanned, test the tall column bbox; while stacked, the small side-by-side bbox.
|
|
833
|
+
// Fan-out is a vertical column so an expanded badge grows rightward into empty
|
|
834
|
+
// space and never covers its siblings (which sit above/below it).
|
|
835
|
+
var hovered;
|
|
836
|
+
if (wasHover) {
|
|
837
|
+
var colTop = spreadDown ? by : by - (n - 1) * 26;
|
|
838
|
+
hovered = mx >= bx - 8 && mx <= bx + 330 && my >= colTop - 8 && my <= colTop + (n - 1) * 26 + 34;
|
|
839
|
+
} else {
|
|
840
|
+
hovered = mx >= bx - 14 && mx <= bx + 46 && my >= by - 8 && my <= by + 40;
|
|
841
|
+
}
|
|
842
|
+
groupHover[key] = hovered;
|
|
843
|
+
|
|
844
|
+
for (var mi = 0; mi < n; mi++) {
|
|
845
|
+
var mem = members[mi];
|
|
846
|
+
if (hovered) {
|
|
847
|
+
// Fanned: vertical column, creation order, only the cursor's badge expands + rises.
|
|
848
|
+
var isHov = (mem === hoverBadge);
|
|
849
|
+
layoutBadge(mem, bx, by, 0, (spreadDown ? 1 : -1) * mi * 26, isHov ? TOP : MID, isHov);
|
|
850
|
+
} else {
|
|
851
|
+
// Stacked: latest centered on top; the two before it peek out half-visible.
|
|
852
|
+
var rank = (n - 1) - mi; // 0 = latest
|
|
853
|
+
if (rank === 0) layoutBadge(mem, bx, by, 0, 0, TOP, false);
|
|
854
|
+
else if (rank === 1) layoutBadge(mem, bx, by, -11, 0, MID, false);
|
|
855
|
+
else if (rank === 2) layoutBadge(mem, bx, by, 11, 0, MID, false);
|
|
856
|
+
else layoutBadge(mem, bx, by, 0, 0, HIDE, false); // extra ones hide behind center
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function startLoop() { if (rafId == null) (function loop() { reflowSpecs(); rafId = requestAnimationFrame(loop); })(); }
|
|
863
|
+
function stopLoop() { if (rafId != null) { cancelAnimationFrame(rafId); rafId = null; } }
|
|
864
|
+
|
|
865
|
+
function renumber() { for (var i = 0; i < specs.length; i++) specs[i].num.textContent = String(i + 1); }
|
|
866
|
+
|
|
867
|
+
function updateBadgeContent(spec) {
|
|
868
|
+
spec.num.textContent = String(specs.indexOf(spec) + 1);
|
|
869
|
+
if (spec.note) spec.noteSpan.textContent = spec.note;
|
|
870
|
+
else spec.noteSpan.textContent = spec.kind === 'measure' ? '⬡ measure' : '';
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// A Spec badge: a circle showing its number that expands (capsule morph) on
|
|
874
|
+
// hover to preview its note, and opens the editor when clicked.
|
|
875
|
+
function createBadge(spec) {
|
|
876
|
+
// wrap carries transparent padding → a larger hover boundary so reaching the
|
|
877
|
+
// trash icon doesn't require pixel-precise aim. reflow offsets for it.
|
|
878
|
+
var wrap = markUI(document.createElement('div'));
|
|
879
|
+
Object.assign(wrap.style, { position: 'fixed', zIndex: '2147483644', display: 'none', padding: '6px', transition: 'transform 0.15s ease' });
|
|
880
|
+
var cap2 = document.createElement('div');
|
|
881
|
+
Object.assign(cap2.style, {
|
|
882
|
+
display: 'inline-flex', alignItems: 'center', height: '20px',
|
|
883
|
+
maxWidth: '20px', overflow: 'hidden', background: PURPLE, color: '#fff',
|
|
884
|
+
border: '2px solid #fff', boxSizing: 'border-box',
|
|
885
|
+
borderRadius: '999px', fontFamily: MONO, whiteSpace: 'nowrap',
|
|
886
|
+
boxShadow: '0 2px 6px rgba(0,0,0,0.35)', cursor: 'pointer', userSelect: 'none',
|
|
887
|
+
transition: 'max-width 0.2s ease, height 0.15s ease, transform 0.15s ease',
|
|
888
|
+
transformOrigin: '10px 10px',
|
|
627
889
|
});
|
|
890
|
+
var num = document.createElement('span');
|
|
891
|
+
Object.assign(num.style, { width: '16px', flexShrink: '0', textAlign: 'center', fontSize: '12px', fontWeight: '700', lineHeight: '16px' });
|
|
892
|
+
var noteSpan = document.createElement('span');
|
|
893
|
+
Object.assign(noteSpan.style, { fontSize: '12px', paddingLeft: '3px', maxWidth: '260px', overflow: 'hidden', textOverflow: 'ellipsis' });
|
|
894
|
+
var trash = document.createElement('span');
|
|
895
|
+
trash.innerHTML = TRASH;
|
|
896
|
+
trash.title = 'Delete this Spec';
|
|
897
|
+
Object.assign(trash.style, { display: 'flex', alignItems: 'center', flexShrink: '0', padding: '0 8px 0 6px', color: '#F3B0C0', cursor: 'pointer' });
|
|
898
|
+
cap2.appendChild(num);
|
|
899
|
+
cap2.appendChild(noteSpan);
|
|
900
|
+
cap2.appendChild(trash);
|
|
901
|
+
wrap.appendChild(cap2);
|
|
902
|
+
document.body.appendChild(wrap);
|
|
903
|
+
wrap.__spec = spec; // so reflow's cursor hit-test can find which Spec a wrap is
|
|
904
|
+
spec.wrap = wrap; spec.num = num; spec.noteSpan = noteSpan; spec.cap = cap2;
|
|
905
|
+
|
|
906
|
+
// Expansion (circle → note capsule) is driven per-frame in reflowSpecs from the
|
|
907
|
+
// cursor's exact target, so only the badge under the cursor ever expands.
|
|
908
|
+
hoverFx(trash, { color: '#fff', transform: 'scale(1.15)' }, { color: '#F3B0C0', transform: 'scale(1)' });
|
|
909
|
+
trash.addEventListener('click', function (e) { e.stopPropagation(); removeSpec(spec); });
|
|
910
|
+
cap2.addEventListener('click', function (e) { e.stopPropagation(); openSpecEditor(spec); });
|
|
911
|
+
updateBadgeContent(spec);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
// If the element sits inside a dialog/modal/drawer/menu, produce a hint for how to
|
|
916
|
+
// bring it back when it's later hidden — a declarative opener (aria-controls /
|
|
917
|
+
// data-*target) if the page has one, else the control the user just clicked (the
|
|
918
|
+
// likely opener), else "reopen the <container>". Empty when there's no such container.
|
|
919
|
+
function cssEsc(s) { try { return CSS.escape(s); } catch (e) { return s; } }
|
|
920
|
+
function computeLocateHint(el) {
|
|
921
|
+
if (!el) return '';
|
|
922
|
+
var re = /(modal|dialog|drawer|sheet|popover|offcanvas|overlay|lightbox|menu|dropdown|flyout|popup|tooltip)/i;
|
|
923
|
+
var cur = el, container = null;
|
|
924
|
+
while (cur && cur !== document.body) {
|
|
925
|
+
var role = cur.getAttribute ? cur.getAttribute('role') : null;
|
|
926
|
+
if ((cur.hasAttribute && cur.hasAttribute('aria-modal')) || role === 'dialog' || role === 'menu' ||
|
|
927
|
+
re.test(cur.className || '') || re.test(cur.id || '')) { container = cur; break; }
|
|
928
|
+
cur = cur.parentElement;
|
|
929
|
+
}
|
|
930
|
+
if (!container) return '';
|
|
931
|
+
var tag = (container.className || '') + ' ' + (container.id || '');
|
|
932
|
+
var type = /drawer|offcanvas|sheet/i.test(tag) ? 'drawer' : /menu|dropdown|flyout/i.test(tag) ? 'menu' : 'dialog';
|
|
933
|
+
var opener = '';
|
|
934
|
+
if (container.id) {
|
|
935
|
+
var o = safeQuery('[aria-controls="' + cssEsc(container.id) + '"],[data-target="#' + cssEsc(container.id) + '"],[data-bs-target="#' + cssEsc(container.id) + '"],[data-modal-target="' + cssEsc(container.id) + '"]');
|
|
936
|
+
if (o) opener = (o.getAttribute('aria-label') || o.textContent || '').replace(/\s+/g, ' ').trim();
|
|
937
|
+
}
|
|
938
|
+
if (!opener && lastClick.label && (Date.now() - lastClick.at) < 120000) opener = lastClick.label;
|
|
939
|
+
if (opener) return 'Open “' + opener.slice(0, 40) + '” to reveal';
|
|
940
|
+
return 'Reopen the ' + type + ' to reveal';
|
|
628
941
|
}
|
|
629
942
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
943
|
+
// Press P → create a Spec (+ open its annotation box). Measure mode captures
|
|
944
|
+
// distances instead of properties; a pinned measurement anchors on the pin.
|
|
945
|
+
function markSpec(anchorEl) {
|
|
946
|
+
var kind = 'element', el = anchorEl, body;
|
|
947
|
+
if (measureMode) {
|
|
948
|
+
kind = 'measure';
|
|
949
|
+
if (pinEl && lastHovered && lastHovered !== pinEl) { el = pinEl; body = buildMeasureCopyText(pinEl, lastHovered); }
|
|
950
|
+
else { el = anchorEl; body = buildNeighborCopyText(anchorEl); }
|
|
636
951
|
} else {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
952
|
+
// Multiple Specs per element are allowed in every mode — clustering fans the badges
|
|
953
|
+
// out and same-element output collapses to one property block. Mode only changes
|
|
954
|
+
// what's shown on screen, never whether you can add an annotation.
|
|
955
|
+
body = buildLLMClipboard(buildInfo(anchorEl));
|
|
640
956
|
}
|
|
641
|
-
|
|
642
|
-
|
|
957
|
+
clearHoverOutline();
|
|
958
|
+
hideTooltip();
|
|
959
|
+
var spec = { el: el, path: elementPath(el), note: '', body: body, kind: kind, locate: computeLocateHint(el) };
|
|
960
|
+
specs.push(spec);
|
|
961
|
+
createBadge(spec);
|
|
962
|
+
reflowSpecs();
|
|
963
|
+
updatePill();
|
|
964
|
+
saveSpecs();
|
|
965
|
+
openSpecEditor(spec);
|
|
643
966
|
}
|
|
644
967
|
|
|
645
|
-
function
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
968
|
+
function removeSpec(spec) {
|
|
969
|
+
var i = specs.indexOf(spec);
|
|
970
|
+
if (i < 0) return;
|
|
971
|
+
if (highlightSpec === spec) highlightSpec = null;
|
|
972
|
+
if (panelEditSpec === spec) panelEditSpec = null;
|
|
973
|
+
specs.splice(i, 1);
|
|
974
|
+
if (spec.wrap) spec.wrap.remove();
|
|
975
|
+
renumber();
|
|
976
|
+
updatePill();
|
|
977
|
+
saveSpecs();
|
|
652
978
|
}
|
|
653
979
|
|
|
654
|
-
function
|
|
655
|
-
|
|
656
|
-
|
|
980
|
+
function removeAllSpecs() {
|
|
981
|
+
commitEditor();
|
|
982
|
+
highlightSpec = null;
|
|
983
|
+
panelEditSpec = null;
|
|
984
|
+
specs.forEach(function (s) { if (s.wrap) s.wrap.remove(); });
|
|
985
|
+
specs.length = 0;
|
|
986
|
+
updatePill();
|
|
987
|
+
saveSpecs();
|
|
657
988
|
}
|
|
658
989
|
|
|
659
|
-
// ───
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
990
|
+
// ─── Reload insurance (localStorage, per-URL, zero network) ──────────────────
|
|
991
|
+
// Persist only the serializable parts of each Spec; the live element ref and
|
|
992
|
+
// DOM nodes are rebuilt on restore (re-anchored best-effort via the CSS path).
|
|
993
|
+
function saveSpecs() {
|
|
994
|
+
try {
|
|
995
|
+
if (!specs.length) localStorage.removeItem(STORAGE_KEY);
|
|
996
|
+
else localStorage.setItem(STORAGE_KEY, JSON.stringify(specs.map(function (s) { return { path: s.path, note: s.note, body: s.body, kind: s.kind, locate: s.locate }; })));
|
|
997
|
+
} catch (e) {}
|
|
998
|
+
scheduleSync(); // keep the Claude bridge mirrored to the current Specs
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function restoreSpecs() {
|
|
1002
|
+
var data;
|
|
1003
|
+
try { data = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); } catch (e) { return; }
|
|
1004
|
+
if (!Array.isArray(data) || !data.length) return;
|
|
1005
|
+
data.forEach(function (d) {
|
|
1006
|
+
var spec = { el: safeQuery(d.path), path: d.path, note: d.note || '', body: d.body || '', kind: d.kind || 'element', locate: d.locate || '' };
|
|
1007
|
+
specs.push(spec);
|
|
1008
|
+
createBadge(spec);
|
|
1009
|
+
});
|
|
1010
|
+
renumber();
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
// Re-anchor if the node detached, then scroll it into view. Returns false when
|
|
1014
|
+
// the element can't be shown (removed / hidden — e.g. behind a closed modal).
|
|
1015
|
+
// The badge enlargement is handled separately via highlightSpec (sustained
|
|
1016
|
+
// while the panel row is hovered), so there's no transient ripple to miss.
|
|
1017
|
+
function revealSpec(spec) {
|
|
1018
|
+
if (!spec.el || !spec.el.isConnected) { var f = safeQuery(spec.path); if (f) spec.el = f; }
|
|
1019
|
+
if (!isVisible(spec.el)) return false;
|
|
1020
|
+
spec.el.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
|
|
1021
|
+
return true;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// "Visible" for the panel = renderable (connected, has a box, not display:none) —
|
|
1025
|
+
// NOT whether it's currently in the viewport. Off-screen is fine (that's what the
|
|
1026
|
+
// hover-scroll is for); only a removed / hidden element (e.g. a closed modal) greys out.
|
|
1027
|
+
function specVisible(spec) {
|
|
1028
|
+
if (!spec.el || !spec.el.isConnected) { var f = safeQuery(spec.path); if (f) spec.el = f; }
|
|
1029
|
+
return isVisible(spec.el);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ─── Specs side panel (in-session review) ────────────────────────────────────
|
|
1033
|
+
var panelWrap = markUI(document.createElement('div'));
|
|
1034
|
+
Object.assign(panelWrap.style, {
|
|
1035
|
+
position: 'fixed', top: '0', right: '0', height: '100vh', width: '320px',
|
|
1036
|
+
maxWidth: '86vw', zIndex: '2147483645', boxSizing: 'border-box',
|
|
1037
|
+
transform: 'translateX(100%)', transition: 'transform 0.22s ease',
|
|
1038
|
+
display: 'flex', flexDirection: 'column',
|
|
1039
|
+
background: TIP_BG, color: '#fff', fontFamily: MONO,
|
|
1040
|
+
borderLeft: '1px solid rgba(173,36,211,0.5)', boxShadow: '-8px 0 24px rgba(0,0,0,0.35)',
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
var panelHead = document.createElement('div');
|
|
1044
|
+
Object.assign(panelHead.style, {
|
|
1045
|
+
display: 'flex', alignItems: 'center', gap: '8px', flexShrink: '0',
|
|
1046
|
+
padding: '16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
|
|
1047
|
+
});
|
|
1048
|
+
var panelTitle = document.createElement('span');
|
|
1049
|
+
Object.assign(panelTitle.style, { fontSize: '13px', fontWeight: '700', letterSpacing: '0.02em' });
|
|
1050
|
+
var panelSpacer = document.createElement('span');
|
|
1051
|
+
panelSpacer.style.flex = '1';
|
|
1052
|
+
var panelClose = document.createElement('span');
|
|
1053
|
+
panelClose.textContent = '×';
|
|
1054
|
+
panelClose.title = 'Close panel (L)';
|
|
1055
|
+
Object.assign(panelClose.style, { cursor: 'pointer', fontSize: '20px', lineHeight: '1', padding: '0 4px', color: '#B9BBC2', flexShrink: '0' });
|
|
1056
|
+
panelClose.addEventListener('click', function (e) { e.stopPropagation(); hidePanel(); });
|
|
1057
|
+
hoverFx(panelClose, { color: '#fff', transform: 'scale(1.2)' }, { color: '#B9BBC2', transform: 'scale(1)' });
|
|
1058
|
+
panelHead.appendChild(panelTitle);
|
|
1059
|
+
panelHead.appendChild(panelSpacer);
|
|
1060
|
+
panelHead.appendChild(panelClose);
|
|
1061
|
+
|
|
1062
|
+
// ── Batch actions toolbar (row under the title) ──
|
|
1063
|
+
var panelTools = document.createElement('div');
|
|
1064
|
+
Object.assign(panelTools.style, {
|
|
1065
|
+
display: 'flex', gap: '8px', alignItems: 'center', flexShrink: '0',
|
|
1066
|
+
padding: '10px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
|
|
1067
|
+
});
|
|
1068
|
+
|
|
1069
|
+
// Status dot: Specs auto-sync to the Claude bridge; this shows the connection
|
|
1070
|
+
// (● synced / ○ offline). Click to force a re-sync. Only shown when a bridge is set.
|
|
1071
|
+
var syncDot = document.createElement('div');
|
|
1072
|
+
Object.assign(syncDot.style, {
|
|
1073
|
+
display: BRIDGE ? 'flex' : 'none', alignItems: 'center', gap: '7px', flex: '1',
|
|
1074
|
+
fontSize: '11px', color: LABEL, cursor: 'pointer', userSelect: 'none',
|
|
1075
|
+
});
|
|
1076
|
+
syncDot.title = 'Specs auto-sync to Claude — click to re-sync now';
|
|
1077
|
+
var dot = document.createElement('span');
|
|
1078
|
+
Object.assign(dot.style, { width: '8px', height: '8px', borderRadius: '999px', background: '#6B7280', flexShrink: '0', transition: 'background 0.2s ease' });
|
|
1079
|
+
var dotLabel = document.createElement('span');
|
|
1080
|
+
dotLabel.textContent = 'Bridge offline';
|
|
1081
|
+
syncDot.appendChild(dot);
|
|
1082
|
+
syncDot.appendChild(dotLabel);
|
|
1083
|
+
syncDot.addEventListener('click', function (e) { e.stopPropagation(); doSync(); });
|
|
1084
|
+
|
|
1085
|
+
// Icon-only round button with a native tooltip (title). No label reveal — the
|
|
1086
|
+
// hover-expanding text read as janky, so we let the browser tooltip do it.
|
|
1087
|
+
function iconPill(svg, title, idleColor, accentRGB) {
|
|
1088
|
+
var btn = document.createElement('button');
|
|
1089
|
+
btn.title = title;
|
|
1090
|
+
Object.assign(btn.style, {
|
|
1091
|
+
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
|
|
1092
|
+
color: idleColor, background: 'transparent', border: '1px solid rgba(' + accentRGB + ',0.5)',
|
|
1093
|
+
borderRadius: '999px', width: '32px', height: '32px', padding: '0', flexShrink: '0',
|
|
1094
|
+
transition: 'background 0.12s ease, color 0.12s ease',
|
|
1095
|
+
});
|
|
1096
|
+
var ic = document.createElement('span'); ic.innerHTML = svg;
|
|
1097
|
+
Object.assign(ic.style, { display: 'flex', alignItems: 'center' });
|
|
1098
|
+
btn.appendChild(ic);
|
|
1099
|
+
btn.addEventListener('mouseenter', function () { btn.style.background = 'rgba(' + accentRGB + ',0.16)'; btn.style.color = '#fff'; });
|
|
1100
|
+
btn.addEventListener('mouseleave', function () { btn.style.background = 'transparent'; btn.style.color = idleColor; });
|
|
1101
|
+
return { btn: btn, icon: ic };
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Copy all → clipboard. Icon flashes to a check on success.
|
|
1105
|
+
var copyAll = iconPill(COPY, 'Copy all', '#E0A3F5', '224,163,245');
|
|
1106
|
+
copyAll.btn.addEventListener('click', function (e) {
|
|
1107
|
+
e.stopPropagation();
|
|
1108
|
+
if (!specs.length) return;
|
|
1109
|
+
navigator.clipboard.writeText(buildSpecsCopyText()).then(function () {
|
|
1110
|
+
copyAll.icon.innerHTML = CHECK; copyAll.btn.style.color = GREEN;
|
|
1111
|
+
setTimeout(function () { copyAll.icon.innerHTML = COPY; copyAll.btn.style.color = copyAll.btn.matches(':hover') ? '#fff' : '#E0A3F5'; }, 1200);
|
|
1112
|
+
}).catch(function () {});
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// Delete all → confirm popover → removeAllSpecs.
|
|
1116
|
+
var delAll = iconPill(TRASH, 'Delete all annotations', '#ED8FA6', '237,62,97');
|
|
1117
|
+
delAll.btn.addEventListener('click', function (e) { e.stopPropagation(); confirmDeleteAll(delAll.btn); });
|
|
1118
|
+
|
|
1119
|
+
var copyAllBtn = copyAll.btn; // renderPanel toggles these by spec count
|
|
1120
|
+
var deleteAllBtn = delAll.btn;
|
|
1121
|
+
if (BRIDGE) panelTools.appendChild(syncDot);
|
|
1122
|
+
else { var sp = document.createElement('span'); sp.style.flex = '1'; panelTools.appendChild(sp); }
|
|
1123
|
+
panelTools.appendChild(copyAllBtn);
|
|
1124
|
+
panelTools.appendChild(deleteAllBtn);
|
|
1125
|
+
|
|
1126
|
+
// ── "Delete all?" confirmation popover (Specter's own UI) ──
|
|
1127
|
+
var confirmPop = null;
|
|
1128
|
+
function closeConfirm() {
|
|
1129
|
+
if (confirmPop) { confirmPop.remove(); confirmPop = null; document.removeEventListener('mousedown', onConfirmOutside, true); }
|
|
1130
|
+
}
|
|
1131
|
+
function onConfirmOutside(e) { if (confirmPop && !confirmPop.contains(e.target)) closeConfirm(); }
|
|
1132
|
+
function confirmDeleteAll(anchor) {
|
|
1133
|
+
closeConfirm();
|
|
1134
|
+
if (!specs.length) return;
|
|
1135
|
+
var pop = markUI(document.createElement('div'));
|
|
1136
|
+
Object.assign(pop.style, {
|
|
1137
|
+
position: 'fixed', zIndex: '2147483647', boxSizing: 'border-box', width: '230px',
|
|
1138
|
+
background: TIP_BG, border: '1px solid ' + PURPLE, borderRadius: '8px', padding: '14px',
|
|
1139
|
+
boxShadow: '0 6px 20px rgba(0,0,0,0.45)', fontFamily: MONO,
|
|
1140
|
+
});
|
|
1141
|
+
var msg = document.createElement('div');
|
|
1142
|
+
msg.textContent = 'Delete all ' + specs.length + (specs.length === 1 ? ' annotation?' : ' annotations?');
|
|
1143
|
+
Object.assign(msg.style, { fontSize: '12px', lineHeight: '17px', color: '#fff', marginBottom: '12px' });
|
|
1144
|
+
var btnRow = document.createElement('div');
|
|
1145
|
+
Object.assign(btnRow.style, { display: 'flex', gap: '8px', justifyContent: 'flex-end' });
|
|
1146
|
+
var cancel = document.createElement('button');
|
|
1147
|
+
cancel.textContent = 'Cancel';
|
|
1148
|
+
Object.assign(cancel.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#B9BBC2', background: 'transparent', border: 'none', borderRadius: '999px', padding: '6px 10px' });
|
|
1149
|
+
cancel.addEventListener('click', function (e) { e.stopPropagation(); closeConfirm(); });
|
|
1150
|
+
hoverFx(cancel, { color: '#fff' }, { color: '#B9BBC2' });
|
|
1151
|
+
var confirm = document.createElement('button');
|
|
1152
|
+
confirm.textContent = 'Delete all';
|
|
1153
|
+
Object.assign(confirm.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '700', color: '#fff', background: RED, border: 'none', borderRadius: '999px', padding: '6px 12px' });
|
|
1154
|
+
confirm.addEventListener('click', function (e) { e.stopPropagation(); closeConfirm(); removeAllSpecs(); });
|
|
1155
|
+
hoverFx(confirm, { background: '#C42D4E' }, { background: RED });
|
|
1156
|
+
btnRow.appendChild(cancel); btnRow.appendChild(confirm);
|
|
1157
|
+
pop.appendChild(msg); pop.appendChild(btnRow);
|
|
1158
|
+
document.body.appendChild(pop);
|
|
1159
|
+
// Anchor below the delete-all button, right-aligned, clamped to viewport.
|
|
1160
|
+
var r = anchor.getBoundingClientRect();
|
|
1161
|
+
var w = pop.offsetWidth, h = pop.offsetHeight, m = 8;
|
|
1162
|
+
var left = Math.min(Math.max(r.right - w, m), window.innerWidth - w - m);
|
|
1163
|
+
var top = Math.min(r.bottom + 6, window.innerHeight - h - m);
|
|
1164
|
+
pop.style.left = left + 'px'; pop.style.top = top + 'px';
|
|
1165
|
+
confirmPop = pop;
|
|
1166
|
+
setTimeout(function () { document.addEventListener('mousedown', onConfirmOutside, true); }, 0);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Guidance line — tells the user what to actually DO with their annotations.
|
|
1170
|
+
var panelHint = document.createElement('div');
|
|
1171
|
+
Object.assign(panelHint.style, {
|
|
1172
|
+
display: 'none', fontSize: '11px', lineHeight: '17px', color: LABEL,
|
|
1173
|
+
padding: '10px 16px', borderBottom: '1px solid rgba(255,255,255,0.08)',
|
|
1174
|
+
});
|
|
1175
|
+
function setHint() {
|
|
1176
|
+
panelHint.textContent = '';
|
|
1177
|
+
if (!specs.length) { panelHint.style.display = 'none'; return; }
|
|
1178
|
+
panelHint.style.display = 'block';
|
|
1179
|
+
var code = function (t) { var s = document.createElement('span'); s.textContent = t; Object.assign(s.style, { color: '#E0A3F5', fontWeight: '700' }); return s; };
|
|
1180
|
+
if (BRIDGE) {
|
|
1181
|
+
panelHint.appendChild(document.createTextNode('Switch to Claude Code and run '));
|
|
1182
|
+
panelHint.appendChild(code('/spectify'));
|
|
1183
|
+
panelHint.appendChild(document.createTextNode(' — it applies these annotations to your code. You can also edit or delete each one above.'));
|
|
1184
|
+
} else {
|
|
1185
|
+
panelHint.appendChild(document.createTextNode('Press '));
|
|
1186
|
+
panelHint.appendChild(code('Cmd+C'));
|
|
1187
|
+
panelHint.appendChild(document.createTextNode(' (or Copy all) and paste into Claude to apply these annotations. You can also edit or delete each one above.'));
|
|
668
1188
|
}
|
|
669
|
-
|
|
670
|
-
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
var panelList = document.createElement('div');
|
|
1192
|
+
// overscroll-behavior:contain stops the panel's scroll from chaining into the page.
|
|
1193
|
+
Object.assign(panelList.style, { flex: '1', overflowY: 'auto', overflowX: 'hidden', overscrollBehavior: 'contain' });
|
|
671
1194
|
|
|
672
|
-
|
|
1195
|
+
// Keyboard shortcuts reference — collapsible, collapsed by default, lives here (not
|
|
1196
|
+
// in the pill) so the pill stays short.
|
|
1197
|
+
var panelKeys = document.createElement('div');
|
|
1198
|
+
Object.assign(panelKeys.style, {
|
|
1199
|
+
flexShrink: '0', fontSize: '11px', lineHeight: '18px', color: LABEL,
|
|
1200
|
+
borderTop: '1px solid rgba(255,255,255,0.08)',
|
|
1201
|
+
});
|
|
1202
|
+
(function () {
|
|
1203
|
+
var rows = [
|
|
1204
|
+
['P', 'mark / comment the hovered element'],
|
|
1205
|
+
['C', 'toggle Comment mode (hide properties)'],
|
|
1206
|
+
['\u2325 Option', 'toggle Measure mode'],
|
|
1207
|
+
['M', 'pin an element to measure from'],
|
|
1208
|
+
['Cmd/Ctrl+C', 'copy all Specs'],
|
|
1209
|
+
['L', 'toggle this panel'],
|
|
1210
|
+
];
|
|
1211
|
+
var head = document.createElement('div');
|
|
1212
|
+
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' });
|
|
1213
|
+
var caret = document.createElement('span');
|
|
1214
|
+
caret.textContent = '\u203A'; // ›
|
|
1215
|
+
Object.assign(caret.style, { display: 'inline-block', fontSize: '16px', lineHeight: '1', transition: 'transform 0.15s ease', transform: 'rotate(0deg)' });
|
|
1216
|
+
var headText = document.createElement('span');
|
|
1217
|
+
headText.textContent = 'Keyboard shortcuts';
|
|
1218
|
+
head.appendChild(caret);
|
|
1219
|
+
head.appendChild(headText);
|
|
1220
|
+
var body = document.createElement('div');
|
|
1221
|
+
Object.assign(body.style, { display: 'none', padding: '0 16px 12px' }); // collapsed by default
|
|
1222
|
+
rows.forEach(function (r) {
|
|
1223
|
+
var row = document.createElement('div');
|
|
1224
|
+
Object.assign(row.style, { display: 'flex', gap: '8px', marginBottom: '2px' });
|
|
1225
|
+
var k = document.createElement('span');
|
|
1226
|
+
k.textContent = r[0];
|
|
1227
|
+
Object.assign(k.style, { color: '#E0A3F5', fontWeight: '700', minWidth: '78px', flexShrink: '0' });
|
|
1228
|
+
var d = document.createElement('span');
|
|
1229
|
+
d.textContent = r[1];
|
|
1230
|
+
row.appendChild(k); row.appendChild(d);
|
|
1231
|
+
body.appendChild(row);
|
|
1232
|
+
});
|
|
1233
|
+
var open = false;
|
|
1234
|
+
head.addEventListener('click', function (e) {
|
|
1235
|
+
e.stopPropagation();
|
|
1236
|
+
open = !open;
|
|
1237
|
+
body.style.display = open ? 'block' : 'none';
|
|
1238
|
+
caret.style.transform = open ? 'rotate(90deg)' : 'rotate(0deg)';
|
|
1239
|
+
});
|
|
1240
|
+
panelKeys.appendChild(head);
|
|
1241
|
+
panelKeys.appendChild(body);
|
|
1242
|
+
})();
|
|
1243
|
+
|
|
1244
|
+
panelWrap.appendChild(panelHead);
|
|
1245
|
+
panelWrap.appendChild(panelTools);
|
|
1246
|
+
panelWrap.appendChild(panelHint);
|
|
1247
|
+
panelWrap.appendChild(panelList);
|
|
1248
|
+
panelWrap.appendChild(panelKeys);
|
|
1249
|
+
document.body.appendChild(panelWrap);
|
|
1250
|
+
|
|
1251
|
+
// Panel scroll and page scroll are mutually exclusive: wheel over the scrollable list
|
|
1252
|
+
// scrolls it natively (overscroll-behavior:contain stops it chaining at the bounds);
|
|
1253
|
+
// wheel over any non-scrolling part of the panel never scrolls the page behind it.
|
|
1254
|
+
panelWrap.addEventListener('wheel', function (e) {
|
|
1255
|
+
var canScroll = panelList.scrollHeight > panelList.clientHeight;
|
|
1256
|
+
if (panelList.contains(e.target) && canScroll) return; // let the list scroll natively
|
|
1257
|
+
e.preventDefault();
|
|
1258
|
+
}, { passive: false });
|
|
1259
|
+
|
|
1260
|
+
// ── Auto-sync: mirror the browser's current Specs to the local bridge ──
|
|
1261
|
+
// Debounced so rapid edits/typing collapse into one POST. The bridge replaces
|
|
1262
|
+
// its snapshot for this URL, so it always reflects what's in the panel — no
|
|
1263
|
+
// button to press; Claude pulls whatever's current when you run /spectify.
|
|
1264
|
+
var syncTimer = null;
|
|
1265
|
+
function setSyncState(s) {
|
|
1266
|
+
if (!BRIDGE) return;
|
|
1267
|
+
setPillSync(s); // control-bar dot
|
|
1268
|
+
applyDotState(dot, s); // side-panel dot — same spinner/green/red
|
|
1269
|
+
if (s === 'syncing') { dotLabel.textContent = 'Syncing…'; }
|
|
1270
|
+
else if (s === 'synced') { dotLabel.textContent = specs.length ? (specs.length + (specs.length === 1 ? ' Spec synced' : ' Specs synced')) : 'Synced'; }
|
|
1271
|
+
else { dotLabel.textContent = 'Bridge offline'; }
|
|
1272
|
+
}
|
|
1273
|
+
function doSync() {
|
|
1274
|
+
if (!BRIDGE) return;
|
|
1275
|
+
setSyncState('syncing');
|
|
1276
|
+
// Keep the spinner up for at least 1s even on an instant localhost sync, so the
|
|
1277
|
+
// feedback is actually perceptible instead of flashing by.
|
|
1278
|
+
var started = Date.now();
|
|
1279
|
+
var settle = function (state) {
|
|
1280
|
+
var wait = Math.max(0, 1000 - (Date.now() - started));
|
|
1281
|
+
setTimeout(function () { setSyncState(state); }, wait);
|
|
1282
|
+
};
|
|
1283
|
+
var payload = {
|
|
1284
|
+
url: location.href,
|
|
1285
|
+
text: buildSpecsCopyText(),
|
|
1286
|
+
specs: groupSpecs().map(function (g, i) {
|
|
1287
|
+
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 };
|
|
1288
|
+
}),
|
|
1289
|
+
};
|
|
1290
|
+
fetch(BRIDGE + '/specs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
|
|
1291
|
+
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
|
|
1292
|
+
.then(function () { settle('synced'); })
|
|
1293
|
+
.catch(function () { settle('offline'); });
|
|
1294
|
+
}
|
|
1295
|
+
function scheduleSync() {
|
|
1296
|
+
if (!BRIDGE) return;
|
|
1297
|
+
clearTimeout(syncTimer);
|
|
1298
|
+
syncTimer = setTimeout(doSync, 500);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function renderPanel() {
|
|
1302
|
+
panelTitle.textContent = specs.length + (specs.length === 1 ? ' Spec' : ' Specs');
|
|
1303
|
+
panelTools.style.display = (specs.length || BRIDGE) ? 'flex' : 'none';
|
|
1304
|
+
copyAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1305
|
+
deleteAllBtn.style.display = specs.length ? 'inline-flex' : 'none';
|
|
1306
|
+
setHint();
|
|
1307
|
+
panelList.textContent = '';
|
|
1308
|
+
if (!specs.length) {
|
|
1309
|
+
var empty = document.createElement('div');
|
|
1310
|
+
empty.textContent = 'No Specs yet — hover an element and press P.';
|
|
1311
|
+
Object.assign(empty.style, { padding: '24px 16px', fontSize: '12px', lineHeight: '18px', color: LABEL });
|
|
1312
|
+
panelList.appendChild(empty);
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
var focusEditor = null, measures = [];
|
|
1316
|
+
specs.forEach(function (spec, i) {
|
|
1317
|
+
var visible = specVisible(spec);
|
|
1318
|
+
var editing = (panelEditSpec === spec);
|
|
1319
|
+
var row = document.createElement('div');
|
|
1320
|
+
Object.assign(row.style, {
|
|
1321
|
+
position: 'relative', display: 'flex', alignItems: 'flex-start', gap: '12px', boxSizing: 'border-box',
|
|
1322
|
+
padding: '12px 16px', borderBottom: '1px solid rgba(255,255,255,0.06)',
|
|
1323
|
+
opacity: '1', transition: 'background 0.12s ease',
|
|
1324
|
+
});
|
|
1325
|
+
var badge = document.createElement('span');
|
|
1326
|
+
badge.textContent = String(i + 1);
|
|
1327
|
+
Object.assign(badge.style, {
|
|
1328
|
+
flexShrink: '0', width: '20px', height: '20px', borderRadius: '999px',
|
|
1329
|
+
background: PURPLE, color: '#fff', fontSize: '11px', fontWeight: '700',
|
|
1330
|
+
border: '2px solid #fff', boxSizing: 'border-box',
|
|
1331
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
1332
|
+
});
|
|
1333
|
+
var content = document.createElement('div');
|
|
1334
|
+
Object.assign(content.style, { flex: '1', minWidth: '0', display: 'flex', flexDirection: 'column', gap: '6px', paddingRight: '30px' });
|
|
1335
|
+
|
|
1336
|
+
var selName = (spec.el && spec.el.tagName) ? getSelector(spec.el) : (spec.path.split('>').pop() || '').trim();
|
|
1337
|
+
var meta = document.createElement('div');
|
|
1338
|
+
Object.assign(meta.style, { fontSize: '11px', color: LABEL, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' });
|
|
1339
|
+
meta.textContent = selName;
|
|
1340
|
+
|
|
1341
|
+
if (editing) {
|
|
1342
|
+
// ── UPDATE: inline editor ──
|
|
1343
|
+
var ta = document.createElement('textarea');
|
|
1344
|
+
ta.value = spec.note || '';
|
|
1345
|
+
ta.placeholder = 'Describe the change…';
|
|
1346
|
+
Object.assign(ta.style, {
|
|
1347
|
+
width: '100%', boxSizing: 'border-box', background: 'rgba(255,255,255,0.06)',
|
|
1348
|
+
border: '1px solid ' + PURPLE, borderRadius: '6px', fontFamily: MONO,
|
|
1349
|
+
fontSize: '14px', lineHeight: '20px', padding: '8px', resize: 'none', outline: 'none',
|
|
1350
|
+
overflow: 'hidden', minHeight: '56px',
|
|
1351
|
+
});
|
|
1352
|
+
// Form controls are the one thing arbitrary pages style hard — force white with
|
|
1353
|
+
// !important (and -webkit-text-fill-color, which otherwise wins over the color).
|
|
1354
|
+
ta.style.setProperty('color', '#fff', 'important');
|
|
1355
|
+
ta.style.setProperty('-webkit-text-fill-color', '#fff', 'important');
|
|
1356
|
+
ta.style.setProperty('caret-color', '#fff', 'important');
|
|
1357
|
+
var grow = function () { ta.style.height = 'auto'; ta.style.height = ta.scrollHeight + 'px'; };
|
|
1358
|
+
ta.addEventListener('input', grow);
|
|
1359
|
+
var saveEdit = function () { spec.note = ta.value.trim(); updateBadgeContent(spec); panelEditSpec = null; saveSpecs(); updatePill(); };
|
|
1360
|
+
var cancelEdit = function () { panelEditSpec = null; renderPanel(); };
|
|
1361
|
+
ta.addEventListener('keydown', function (ev) {
|
|
1362
|
+
ev.stopPropagation();
|
|
1363
|
+
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); saveEdit(); }
|
|
1364
|
+
else if (ev.key === 'Escape') { ev.preventDefault(); cancelEdit(); }
|
|
1365
|
+
});
|
|
1366
|
+
var editActions = document.createElement('div');
|
|
1367
|
+
Object.assign(editActions.style, { display: 'flex', gap: '8px', alignItems: 'center' });
|
|
1368
|
+
var saveBtn = document.createElement('button');
|
|
1369
|
+
saveBtn.textContent = 'Save';
|
|
1370
|
+
Object.assign(saveBtn.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#fff', background: PURPLE, border: 'none', borderRadius: '999px', padding: '5px 12px' });
|
|
1371
|
+
saveBtn.addEventListener('click', function (e) { e.stopPropagation(); saveEdit(); });
|
|
1372
|
+
hoverFx(saveBtn, { background: '#C13AE0' }, { background: PURPLE });
|
|
1373
|
+
var cancelBtn = document.createElement('button');
|
|
1374
|
+
cancelBtn.textContent = 'Cancel';
|
|
1375
|
+
Object.assign(cancelBtn.style, { cursor: 'pointer', fontFamily: MONO, fontSize: '11px', fontWeight: '600', color: '#B9BBC2', background: 'transparent', border: 'none', borderRadius: '999px', padding: '5px 8px' });
|
|
1376
|
+
// mousedown+preventDefault so the textarea's blur-save doesn't beat the cancel
|
|
1377
|
+
cancelBtn.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); cancelEdit(); });
|
|
1378
|
+
hoverFx(cancelBtn, { color: '#fff' }, { color: '#B9BBC2' });
|
|
1379
|
+
editActions.appendChild(saveBtn);
|
|
1380
|
+
editActions.appendChild(cancelBtn);
|
|
1381
|
+
content.appendChild(ta);
|
|
1382
|
+
content.appendChild(meta);
|
|
1383
|
+
content.appendChild(editActions);
|
|
1384
|
+
row.appendChild(badge);
|
|
1385
|
+
row.appendChild(content);
|
|
1386
|
+
focusEditor = function () { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); grow(); };
|
|
1387
|
+
} else {
|
|
1388
|
+
// ── READ: note (collapsed by default, expandable) ──
|
|
1389
|
+
var expanded = !!spec._expanded;
|
|
1390
|
+
var note = document.createElement('div');
|
|
1391
|
+
// Built UNCLAMPED so its true height is measurable after it's in the DOM;
|
|
1392
|
+
// the clamp is applied in the post-render measure pass below (only when the
|
|
1393
|
+
// note actually overflows two lines — otherwise no chevron is shown).
|
|
1394
|
+
Object.assign(note.style, {
|
|
1395
|
+
fontSize: '14px', lineHeight: '20px', color: spec.note ? '#fff' : LABEL,
|
|
1396
|
+
overflow: 'hidden', overflowWrap: 'anywhere', whiteSpace: 'pre-wrap',
|
|
1397
|
+
cursor: spec.note ? 'pointer' : 'default',
|
|
1398
|
+
});
|
|
1399
|
+
note.textContent = spec.note || (spec.kind === 'measure' ? '⬡ measurement' : '— no note —');
|
|
1400
|
+
|
|
1401
|
+
// Floated into the top-right corner so they don't reserve note width. On hover
|
|
1402
|
+
// they get a background that fades in from the left, masking the note text behind
|
|
1403
|
+
// them (instead of overlapping it). paddingLeft is the fade gutter.
|
|
1404
|
+
var actions = document.createElement('div');
|
|
1405
|
+
Object.assign(actions.style, { position: 'absolute', top: '10px', right: '14px', display: 'flex', gap: '2px', alignItems: 'center', paddingLeft: '22px', borderRadius: '6px' });
|
|
1406
|
+
var mkIcon = function (svg, title, color, onClick, onHover) {
|
|
1407
|
+
var b = document.createElement('span');
|
|
1408
|
+
b.innerHTML = svg; b.title = title;
|
|
1409
|
+
Object.assign(b.style, { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '24px', height: '24px', borderRadius: '6px', cursor: 'pointer', color: color, flexShrink: '0' });
|
|
1410
|
+
hoverFx(b, onHover || { background: 'rgba(255,255,255,0.14)', color: '#fff' }, { background: 'transparent', color: color });
|
|
1411
|
+
b.addEventListener('click', function (e) { e.stopPropagation(); onClick(); });
|
|
1412
|
+
return b;
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
// Show an expand toggle only when the collapsed note actually overflows.
|
|
1416
|
+
var chev = mkIcon(CHEV, expanded ? 'Collapse' : 'Expand', '#B9BBC2', function () { spec._expanded = !spec._expanded; renderPanel(); });
|
|
1417
|
+
chev.firstChild.style.transform = expanded ? 'rotate(180deg)' : 'rotate(0deg)';
|
|
1418
|
+
var editBtn = mkIcon(PENCIL, 'Edit note', '#B9BBC2', function () { panelEditSpec = spec; spec._expanded = true; renderPanel(); });
|
|
1419
|
+
var delBtn = mkIcon(TRASH, 'Delete Spec', '#ED8FA6', function () { removeSpec(spec); }, { background: 'rgba(237,62,97,0.30)', color: '#fff' });
|
|
1420
|
+
// Edit + delete reveal only on row hover; the expand chevron stays rightmost
|
|
1421
|
+
// and fixed (edit/delete appear to its left, so it never shifts).
|
|
1422
|
+
editBtn.style.display = delBtn.style.display = 'none';
|
|
1423
|
+
actions.appendChild(editBtn);
|
|
1424
|
+
actions.appendChild(delBtn);
|
|
1425
|
+
actions.appendChild(chev);
|
|
1426
|
+
|
|
1427
|
+
content.appendChild(note);
|
|
1428
|
+
content.appendChild(meta);
|
|
1429
|
+
|
|
1430
|
+
// Hidden element (e.g. inside a closed modal): flag it and say how to reveal it,
|
|
1431
|
+
// so hidden Specs are findable in a long list instead of silently unreachable.
|
|
1432
|
+
if (!visible) {
|
|
1433
|
+
var hbar = document.createElement('div');
|
|
1434
|
+
Object.assign(hbar.style, { display: 'flex', alignItems: 'center', gap: '7px', marginTop: '2px', flexWrap: 'wrap' });
|
|
1435
|
+
var tag = document.createElement('span');
|
|
1436
|
+
tag.textContent = 'HIDDEN';
|
|
1437
|
+
Object.assign(tag.style, { fontSize: '11px', fontWeight: '700', letterSpacing: '0.05em', color: '#3A2A05', background: '#F59E0B', borderRadius: '4px', padding: '2px 6px', flexShrink: '0' });
|
|
1438
|
+
var hint = document.createElement('span');
|
|
1439
|
+
hint.textContent = spec.locate || 'Not on the page right now';
|
|
1440
|
+
Object.assign(hint.style, { fontSize: '11px', color: '#C9CBD2', overflow: 'hidden', textOverflow: 'ellipsis' });
|
|
1441
|
+
hbar.appendChild(tag);
|
|
1442
|
+
hbar.appendChild(hint);
|
|
1443
|
+
content.appendChild(hbar);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
row.appendChild(badge);
|
|
1447
|
+
row.appendChild(content);
|
|
1448
|
+
row.appendChild(actions);
|
|
1449
|
+
|
|
1450
|
+
note.addEventListener('click', function (e) { if (spec.note) { e.stopPropagation(); spec._expanded = !spec._expanded; renderPanel(); } });
|
|
1451
|
+
row.addEventListener('mouseenter', function () {
|
|
1452
|
+
row.style.background = 'rgba(255,255,255,0.05)';
|
|
1453
|
+
editBtn.style.display = delBtn.style.display = 'flex';
|
|
1454
|
+
actions.style.background = 'linear-gradient(to right, rgba(52,54,60,0) 0, rgba(52,54,60,1) 22px)';
|
|
1455
|
+
if (visible) { highlightSpec = spec; revealSpec(spec); }
|
|
1456
|
+
});
|
|
1457
|
+
row.addEventListener('mouseleave', function () {
|
|
1458
|
+
row.style.background = 'transparent';
|
|
1459
|
+
editBtn.style.display = delBtn.style.display = 'none';
|
|
1460
|
+
actions.style.background = 'transparent';
|
|
1461
|
+
if (highlightSpec === spec) highlightSpec = null;
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
if (expanded) chev.style.transform = ''; // note stays unclamped; chevron collapses it
|
|
1465
|
+
else measures.push({ note: note, chev: chev }); // measured after all rows are in the DOM
|
|
1466
|
+
}
|
|
1467
|
+
panelList.appendChild(row);
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
// Measure pass — now that rows are laid out, clamp collapsed notes that overflow
|
|
1471
|
+
// 2 lines and hide the expand chevron on notes that fit.
|
|
1472
|
+
measures.forEach(function (m) {
|
|
1473
|
+
if (m.note.scrollHeight > 42) { // > two 20px lines (+2 slack)
|
|
1474
|
+
m.note.style.display = '-webkit-box';
|
|
1475
|
+
m.note.style.whiteSpace = 'normal';
|
|
1476
|
+
m.note.style.setProperty('-webkit-box-orient', 'vertical');
|
|
1477
|
+
m.note.style.setProperty('-webkit-line-clamp', '2');
|
|
1478
|
+
} else {
|
|
1479
|
+
m.chev.style.display = 'none';
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
if (focusEditor) setTimeout(focusEditor, 0);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function showPanel() { panelOpen = true; renderPanel(); panelWrap.style.transform = 'translateX(0)'; if (BRIDGE) doSync(); }
|
|
1486
|
+
function hidePanel() { panelOpen = false; panelEditSpec = null; panelWrap.style.transform = 'translateX(100%)'; }
|
|
1487
|
+
function togglePanel() { if (panelOpen) hidePanel(); else if (fiActive) showPanel(); }
|
|
1488
|
+
|
|
1489
|
+
// ─── Spec editor (annotation box: add / edit / delete) ───────────────────────
|
|
1490
|
+
function commitEditor() { if (editorCommit) editorCommit(); }
|
|
1491
|
+
function closeEditorDom() {
|
|
1492
|
+
if (editorEl) { editorEl.remove(); editorEl = null; }
|
|
1493
|
+
editorSpec = null; editorCommit = null;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// Open a Spec's annotation box: prefilled with its note, grows as you type,
|
|
1497
|
+
// saves on Enter or blur (click-away), and carries a Delete button.
|
|
1498
|
+
function openSpecEditor(spec) {
|
|
1499
|
+
commitEditor(); // commit whatever editor is already open
|
|
1500
|
+
if (!spec || !spec.el) return;
|
|
1501
|
+
editorSpec = spec;
|
|
1502
|
+
var rect = spec.el.getBoundingClientRect();
|
|
1503
|
+
|
|
1504
|
+
var box = markUI(document.createElement('div'));
|
|
673
1505
|
Object.assign(box.style, {
|
|
674
|
-
position: 'fixed',
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
gap: '8px',
|
|
679
|
-
boxSizing: 'border-box',
|
|
680
|
-
background: TIP_BG,
|
|
681
|
-
border: '1px solid ' + PURPLE,
|
|
682
|
-
borderRadius: '8px',
|
|
683
|
-
padding: '11px 12px',
|
|
684
|
-
boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
|
|
685
|
-
fontFamily: MONO,
|
|
1506
|
+
position: 'fixed', zIndex: '2147483647', display: 'inline-flex',
|
|
1507
|
+
alignItems: 'flex-start', gap: '8px', boxSizing: 'border-box',
|
|
1508
|
+
background: TIP_BG, border: '1px solid ' + PURPLE, borderRadius: '8px',
|
|
1509
|
+
padding: '11px 12px', boxShadow: '0 4px 16px rgba(0,0,0,0.35)', fontFamily: MONO,
|
|
686
1510
|
});
|
|
687
1511
|
var pen = document.createElement('span');
|
|
688
1512
|
pen.innerHTML = PENCIL;
|
|
689
|
-
Object.assign(pen.style, {
|
|
690
|
-
display: 'flex',
|
|
691
|
-
alignItems: 'center',
|
|
692
|
-
flexShrink: '0',
|
|
693
|
-
marginTop: '2px',
|
|
694
|
-
color: '#E0A3F5',
|
|
695
|
-
});
|
|
1513
|
+
Object.assign(pen.style, { display: 'flex', alignItems: 'center', flexShrink: '0', marginTop: '2px', color: '#E0A3F5' });
|
|
696
1514
|
var input = document.createElement('textarea');
|
|
697
1515
|
input.rows = 1;
|
|
698
1516
|
input.placeholder = 'Describe the change… (Enter to save)';
|
|
699
|
-
input.value =
|
|
1517
|
+
input.value = spec.note || '';
|
|
700
1518
|
Object.assign(input.style, {
|
|
701
|
-
flexShrink: '0',
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
resize: 'none',
|
|
706
|
-
overflow: 'hidden',
|
|
707
|
-
color: '#fff',
|
|
708
|
-
fontFamily: MONO,
|
|
709
|
-
fontSize: '12px',
|
|
710
|
-
lineHeight: '18px',
|
|
711
|
-
padding: '0',
|
|
712
|
-
margin: '0',
|
|
713
|
-
whiteSpace: 'pre-wrap',
|
|
714
|
-
overflowWrap: 'anywhere',
|
|
1519
|
+
flexShrink: '0', background: 'transparent', border: 'none', outline: 'none',
|
|
1520
|
+
resize: 'none', overflow: 'hidden', color: '#fff', fontFamily: MONO,
|
|
1521
|
+
fontSize: '12px', lineHeight: '18px', padding: '0', margin: '0',
|
|
1522
|
+
whiteSpace: 'pre-wrap', overflowWrap: 'anywhere',
|
|
715
1523
|
});
|
|
1524
|
+
var del = document.createElement('button');
|
|
1525
|
+
del.innerHTML = TRASH;
|
|
1526
|
+
del.title = 'Delete this Spec';
|
|
1527
|
+
Object.assign(del.style, {
|
|
1528
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: '0',
|
|
1529
|
+
width: '24px', height: '24px', marginTop: '-2px', padding: '0',
|
|
1530
|
+
background: 'transparent', border: 'none', borderRadius: '6px',
|
|
1531
|
+
color: '#ED8FA6', cursor: 'pointer',
|
|
1532
|
+
});
|
|
1533
|
+
hoverFx(del, { color: '#fff', background: 'rgba(237,62,97,0.35)' }, { color: '#ED8FA6', background: 'transparent' });
|
|
716
1534
|
// Hidden mirror to measure single-line text width so the field grows as you type.
|
|
717
1535
|
var meas = document.createElement('span');
|
|
718
|
-
Object.assign(meas.style, {
|
|
719
|
-
position: 'absolute',
|
|
720
|
-
visibility: 'hidden',
|
|
721
|
-
whiteSpace: 'pre',
|
|
722
|
-
pointerEvents: 'none',
|
|
723
|
-
fontFamily: MONO,
|
|
724
|
-
fontSize: '12px',
|
|
725
|
-
left: '-9999px',
|
|
726
|
-
top: '0',
|
|
727
|
-
});
|
|
1536
|
+
Object.assign(meas.style, { position: 'absolute', visibility: 'hidden', whiteSpace: 'pre', pointerEvents: 'none', fontFamily: MONO, fontSize: '12px', left: '-9999px', top: '0' });
|
|
728
1537
|
box.appendChild(pen);
|
|
729
1538
|
box.appendChild(input);
|
|
1539
|
+
box.appendChild(del);
|
|
730
1540
|
box.appendChild(meas);
|
|
731
1541
|
document.body.appendChild(box);
|
|
732
|
-
|
|
1542
|
+
editorEl = box;
|
|
733
1543
|
|
|
734
1544
|
var margin = 8;
|
|
735
1545
|
function textW(t) { meas.textContent = t; return meas.offsetWidth; }
|
|
736
|
-
|
|
737
|
-
// Anchor to the element; the box is repositioned around this as it grows.
|
|
738
1546
|
var anchorL = rect.left, anchorT = rect.top, anchorB = rect.bottom;
|
|
739
1547
|
|
|
740
1548
|
function sizeAndPosition() {
|
|
741
1549
|
var vw = window.innerWidth, vh = window.innerHeight;
|
|
742
1550
|
var maxBoxW = Math.min(560, vw - margin * 2);
|
|
743
|
-
var maxTextW = Math.max(120, maxBoxW -
|
|
1551
|
+
var maxTextW = Math.max(120, maxBoxW - 90); // room for pencil + delete + gaps + padding
|
|
744
1552
|
var placeholderW = textW(input.placeholder);
|
|
745
1553
|
var minTextW = Math.min(placeholderW, maxTextW);
|
|
746
|
-
var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines
|
|
747
|
-
|
|
748
|
-
// Width: grow with content up to the max, then wrapping takes over.
|
|
1554
|
+
var maxTaH = Math.min(14 * 18, Math.max(18, (vh - margin * 2) - 24)); // cap ~14 lines
|
|
749
1555
|
var single = textW(input.value || input.placeholder) + 3;
|
|
750
1556
|
input.style.width = Math.min(Math.max(single, minTextW), maxTextW) + 'px';
|
|
751
|
-
// Height: fit wrapped content, scroll only in the extreme case.
|
|
752
1557
|
input.style.height = 'auto';
|
|
753
1558
|
var h = input.scrollHeight;
|
|
754
1559
|
if (h > maxTaH) { input.style.height = maxTaH + 'px'; input.style.overflowY = 'auto'; }
|
|
755
1560
|
else { input.style.height = h + 'px'; input.style.overflowY = 'hidden'; }
|
|
756
|
-
|
|
757
|
-
// Keep the whole box on screen.
|
|
758
1561
|
var bw = box.offsetWidth, bh = box.offsetHeight;
|
|
759
1562
|
var left = Math.min(Math.max(anchorL, margin), Math.max(margin, vw - bw - margin));
|
|
760
|
-
var top = anchorT - bh - 8;
|
|
761
|
-
if (top < margin) top = anchorB + 8;
|
|
1563
|
+
var top = anchorT - bh - 8; // prefer above the element
|
|
1564
|
+
if (top < margin) top = anchorB + 8; // otherwise below
|
|
762
1565
|
if (top + bh > vh - margin) top = Math.max(margin, vh - bh - margin);
|
|
763
1566
|
box.style.left = left + 'px';
|
|
764
1567
|
box.style.top = top + 'px';
|
|
765
1568
|
}
|
|
766
1569
|
sizeAndPosition();
|
|
767
1570
|
|
|
1571
|
+
var done = false;
|
|
1572
|
+
function commit() {
|
|
1573
|
+
if (done) return; done = true;
|
|
1574
|
+
spec.note = input.value.trim();
|
|
1575
|
+
updateBadgeContent(spec);
|
|
1576
|
+
closeEditorDom();
|
|
1577
|
+
updatePill();
|
|
1578
|
+
saveSpecs();
|
|
1579
|
+
}
|
|
1580
|
+
editorCommit = commit;
|
|
1581
|
+
|
|
1582
|
+
// Delete via mousedown+preventDefault so the textarea doesn't blur-save first.
|
|
1583
|
+
del.addEventListener('mousedown', function (ev) {
|
|
1584
|
+
ev.preventDefault(); ev.stopPropagation();
|
|
1585
|
+
done = true; closeEditorDom(); removeSpec(spec);
|
|
1586
|
+
});
|
|
768
1587
|
input.addEventListener('input', sizeAndPosition);
|
|
769
1588
|
input.addEventListener('keydown', function (ev) {
|
|
770
1589
|
ev.stopPropagation();
|
|
771
|
-
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault();
|
|
772
|
-
else if (ev.key === 'Escape') { ev.preventDefault(); closeNoteInput(); }
|
|
1590
|
+
if ((ev.key === 'Enter' && !ev.shiftKey) || ev.key === 'Escape') { ev.preventDefault(); commit(); }
|
|
773
1591
|
});
|
|
1592
|
+
input.addEventListener('blur', function () { setTimeout(commit, 0); });
|
|
774
1593
|
|
|
775
|
-
rebuildBadges();
|
|
776
|
-
updatePillForSelection();
|
|
777
1594
|
setTimeout(function () { input.focus(); }, 0);
|
|
778
1595
|
}
|
|
779
1596
|
|
|
780
|
-
function commitNote(val) {
|
|
781
|
-
val = (val || '').trim();
|
|
782
|
-
if (noteTargetEl) {
|
|
783
|
-
if (val) noteMap.set(noteTargetEl, val);
|
|
784
|
-
else noteMap.delete(noteTargetEl);
|
|
785
|
-
}
|
|
786
|
-
closeNoteInput();
|
|
787
|
-
rebuildBadges();
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
function closeNoteInput() {
|
|
791
|
-
if (noteInputEl) { noteInputEl.remove(); noteInputEl = null; }
|
|
792
|
-
noteTargetEl = null;
|
|
793
|
-
}
|
|
794
|
-
|
|
795
1597
|
// ─── Pin (measure) ──────────────────────────────────────────────────────────
|
|
796
1598
|
function setPin(el) {
|
|
797
1599
|
pinEl = el;
|
|
@@ -802,7 +1604,7 @@
|
|
|
802
1604
|
if (!pinEl) return;
|
|
803
1605
|
var r = pinEl.getBoundingClientRect();
|
|
804
1606
|
if (!pinHighlight) {
|
|
805
|
-
pinHighlight = document.createElement('div');
|
|
1607
|
+
pinHighlight = markUI(document.createElement('div'));
|
|
806
1608
|
Object.assign(pinHighlight.style, {
|
|
807
1609
|
position: 'fixed',
|
|
808
1610
|
border: '2px dashed ' + RED,
|
|
@@ -832,7 +1634,7 @@
|
|
|
832
1634
|
function showMeasureTargetHL(el) {
|
|
833
1635
|
clearMeasureTargetHL();
|
|
834
1636
|
var r = el.getBoundingClientRect();
|
|
835
|
-
measureHL = document.createElement('div');
|
|
1637
|
+
measureHL = markUI(document.createElement('div'));
|
|
836
1638
|
Object.assign(measureHL.style, {
|
|
837
1639
|
position: 'fixed',
|
|
838
1640
|
left: r.left + 'px',
|
|
@@ -896,9 +1698,9 @@
|
|
|
896
1698
|
position: 'absolute',
|
|
897
1699
|
background: RED,
|
|
898
1700
|
color: '#fff',
|
|
899
|
-
fontSize: '
|
|
1701
|
+
fontSize: '12px',
|
|
900
1702
|
fontFamily: MONO,
|
|
901
|
-
padding: '
|
|
1703
|
+
padding: '2px 5px',
|
|
902
1704
|
borderRadius: '3px',
|
|
903
1705
|
pointerEvents: 'none',
|
|
904
1706
|
whiteSpace: 'nowrap',
|
|
@@ -982,7 +1784,7 @@
|
|
|
982
1784
|
function buildNeighborCopyText(el) {
|
|
983
1785
|
var tr = el.getBoundingClientRect();
|
|
984
1786
|
var dirs = computeNeighbors(el, tr);
|
|
985
|
-
var lines = ['[Specter Measure]', '<' + el.tagName.toLowerCase() + '> ' + Math.round(tr.width) + '×' + Math.round(tr.height), '
|
|
1787
|
+
var lines = ['[Specter Measure]', '<' + el.tagName.toLowerCase() + '> ' + Math.round(tr.width) + '×' + Math.round(tr.height), 'find: ' + resolveLocator(el)];
|
|
986
1788
|
['top', 'right', 'bottom', 'left'].forEach(function (k) {
|
|
987
1789
|
if (dirs[k]) lines.push(k + ': ' + Math.round(dirs[k].gap) + 'px (to ' + dirs[k].ctx + ')');
|
|
988
1790
|
});
|
|
@@ -1046,9 +1848,9 @@
|
|
|
1046
1848
|
|
|
1047
1849
|
var lines = ['[Specter Measure]'];
|
|
1048
1850
|
lines.push('From: <' + fTag + '>' + (fComp ? ' ' + fComp : '') + ' ' + Math.round(fr.width) + '×' + Math.round(fr.height));
|
|
1049
|
-
lines.push('
|
|
1851
|
+
lines.push(' find: ' + resolveLocator(fromEl));
|
|
1050
1852
|
lines.push('To: <' + tTag + '>' + (tComp ? ' ' + tComp : '') + ' ' + Math.round(tr.width) + '×' + Math.round(tr.height));
|
|
1051
|
-
lines.push('
|
|
1853
|
+
lines.push(' find: ' + resolveLocator(toEl));
|
|
1052
1854
|
|
|
1053
1855
|
if (fromContainsTo || toContainsFrom) {
|
|
1054
1856
|
var outer = fromContainsTo ? fr : tr, inner = fromContainsTo ? tr : fr;
|
|
@@ -1086,18 +1888,36 @@
|
|
|
1086
1888
|
if (!fiActive) return;
|
|
1087
1889
|
lastMouse.x = e.clientX; lastMouse.y = e.clientY;
|
|
1088
1890
|
var target = e.target;
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
if (
|
|
1891
|
+
// Never inspect Specter's own UI — and clear the inspect overlays so the
|
|
1892
|
+
// properties box / measure lines don't block a Spec's hover pill.
|
|
1893
|
+
if (!target || isUI(target)) {
|
|
1894
|
+
hideTooltip();
|
|
1895
|
+
clearMeasureOverlay();
|
|
1896
|
+
clearMeasureTargetHL();
|
|
1897
|
+
clearHoverOutline();
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1092
1900
|
|
|
1093
1901
|
lastHovered = target;
|
|
1094
1902
|
|
|
1903
|
+
// Comment mode: outline only (so you know what you're commenting on), no
|
|
1904
|
+
// properties/measure overlay on screen. The Spec still captures everything.
|
|
1905
|
+
if (commentMode) {
|
|
1906
|
+
clearMeasureOverlay();
|
|
1907
|
+
clearMeasureTargetHL();
|
|
1908
|
+
setHoverOutline(target);
|
|
1909
|
+
hideTooltip();
|
|
1910
|
+
return;
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1095
1913
|
if (measureMode) {
|
|
1096
1914
|
clearHoverOutline();
|
|
1097
1915
|
showMeasureTargetHL(target);
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1916
|
+
// Draw the on-screen measurement overlay (the px badges + lines) but keep
|
|
1917
|
+
// the dark readout box hidden — it occludes the very measurements it reports.
|
|
1918
|
+
// The full readout still copies with Cmd+C. (Properties mode keeps its box.)
|
|
1919
|
+
if (pinEl && pinEl !== target) measureBetween(pinEl, target); else measureToNeighbor(target);
|
|
1920
|
+
hideTooltip();
|
|
1101
1921
|
if (pinEl) updatePinHL();
|
|
1102
1922
|
} else {
|
|
1103
1923
|
clearMeasureOverlay();
|
|
@@ -1123,8 +1943,9 @@
|
|
|
1123
1943
|
|
|
1124
1944
|
if (!fiActive) return;
|
|
1125
1945
|
|
|
1126
|
-
//
|
|
1127
|
-
|
|
1946
|
+
// Spec editor open (on-page OR panel inline): let the textarea own its keys
|
|
1947
|
+
// (Enter/Esc, native copy, the letter "p") — never treat them as shortcuts.
|
|
1948
|
+
if (editorEl || panelEditSpec) return;
|
|
1128
1949
|
|
|
1129
1950
|
if (e.key === 'Alt' && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
1130
1951
|
optionHeld = true;
|
|
@@ -1134,8 +1955,8 @@
|
|
|
1134
1955
|
if ((e.metaKey || e.ctrlKey) && e.key === 'c' && !e.shiftKey && !e.altKey) {
|
|
1135
1956
|
e.preventDefault();
|
|
1136
1957
|
var text;
|
|
1137
|
-
if (
|
|
1138
|
-
text =
|
|
1958
|
+
if (specs.length > 0) {
|
|
1959
|
+
text = buildSpecsCopyText();
|
|
1139
1960
|
} else if (measureMode && pinEl && lastHovered && lastHovered !== pinEl) {
|
|
1140
1961
|
text = buildMeasureCopyText(pinEl, lastHovered);
|
|
1141
1962
|
} else if (measureMode && lastHovered) {
|
|
@@ -1149,16 +1970,13 @@
|
|
|
1149
1970
|
|
|
1150
1971
|
if (isInputFocused()) return;
|
|
1151
1972
|
|
|
1973
|
+
// P — the single mark/annotate key (empty note = a plain mark).
|
|
1974
|
+
// preventDefault so the "p" keystroke can't leak into the note box that
|
|
1975
|
+
// markSpec is about to focus (was an intermittent stray-"p" race).
|
|
1152
1976
|
if (e.key === 'p' || e.key === 'P') {
|
|
1153
1977
|
if (!lastHovered) return;
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
return;
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
if (e.key === 'n' || e.key === 'N') {
|
|
1160
|
-
if (!lastHovered) return;
|
|
1161
|
-
openNoteInput(lastHovered);
|
|
1978
|
+
e.preventDefault();
|
|
1979
|
+
markSpec(lastHovered);
|
|
1162
1980
|
return;
|
|
1163
1981
|
}
|
|
1164
1982
|
|
|
@@ -1166,24 +1984,40 @@
|
|
|
1166
1984
|
if (!measureMode || !lastHovered) return;
|
|
1167
1985
|
if (pinEl) {
|
|
1168
1986
|
clearPin();
|
|
1169
|
-
|
|
1987
|
+
updatePill();
|
|
1170
1988
|
} else {
|
|
1171
1989
|
setPin(lastHovered);
|
|
1172
|
-
expandPill('Pinned · hover another element · Cmd+C copy
|
|
1990
|
+
expandPill('Pinned · hover another element · P mark · Cmd+C copy');
|
|
1173
1991
|
}
|
|
1174
1992
|
return;
|
|
1175
1993
|
}
|
|
1176
1994
|
|
|
1995
|
+
// L — toggle the Specs review panel.
|
|
1996
|
+
if (e.key === 'l' || e.key === 'L') {
|
|
1997
|
+
e.preventDefault();
|
|
1998
|
+
togglePanel();
|
|
1999
|
+
return;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// C — toggle Comment mode (outline only; props/measure hidden but still captured).
|
|
2003
|
+
// Plain c only — Cmd/Ctrl+C copy was handled above and returned.
|
|
2004
|
+
if ((e.key === 'c' || e.key === 'C') && !e.metaKey && !e.ctrlKey) {
|
|
2005
|
+
e.preventDefault();
|
|
2006
|
+
commentMode = !commentMode;
|
|
2007
|
+
if (commentMode && measureMode) { measureMode = false; clearPin(); }
|
|
2008
|
+
clearMeasureOverlay();
|
|
2009
|
+
clearMeasureTargetHL();
|
|
2010
|
+
clearHoverOutline();
|
|
2011
|
+
hideTooltip();
|
|
2012
|
+
if (lastHovered && commentMode) setHoverOutline(lastHovered);
|
|
2013
|
+
flashMode();
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
// Esc only HIDES the plugin — Specs persist and return on reactivate.
|
|
1177
2018
|
if (e.key === 'Escape') {
|
|
1178
|
-
if (
|
|
1179
|
-
|
|
1180
|
-
clearSelection();
|
|
1181
|
-
clearMeasureOverlay();
|
|
1182
|
-
hideTooltip();
|
|
1183
|
-
collapsePill();
|
|
1184
|
-
} else {
|
|
1185
|
-
deactivate();
|
|
1186
|
-
}
|
|
2019
|
+
if (panelOpen) { hidePanel(); return; }
|
|
2020
|
+
deactivate();
|
|
1187
2021
|
return;
|
|
1188
2022
|
}
|
|
1189
2023
|
}, true);
|
|
@@ -1204,6 +2038,19 @@
|
|
|
1204
2038
|
|
|
1205
2039
|
document.addEventListener('mousemove', onMouseMove, { passive: true });
|
|
1206
2040
|
|
|
2041
|
+
// Remember the last interactive control the user clicked (not Specter's own UI).
|
|
2042
|
+
// If they open a modal then annotate inside it, this is the opener — used to tell
|
|
2043
|
+
// them how to reveal a Spec whose element is later hidden.
|
|
2044
|
+
document.addEventListener('click', function (e) {
|
|
2045
|
+
try {
|
|
2046
|
+
if (isUI(e.target)) return;
|
|
2047
|
+
var b = e.target.closest && e.target.closest('button, a, [role="button"], summary, [type="button"], [type="submit"]');
|
|
2048
|
+
if (!b) return;
|
|
2049
|
+
var t = (b.getAttribute('aria-label') || b.textContent || '').replace(/\s+/g, ' ').trim();
|
|
2050
|
+
if (t) lastClick = { label: t.slice(0, 40), at: Date.now() };
|
|
2051
|
+
} catch (err) {}
|
|
2052
|
+
}, true);
|
|
2053
|
+
|
|
1207
2054
|
window.__specterToggle = function() { if (fiActive) deactivate(); else activate(); };
|
|
1208
2055
|
|
|
1209
2056
|
var _rt = (typeof browser !== 'undefined' && browser.runtime) || (typeof chrome !== 'undefined' && chrome.runtime);
|
|
@@ -1213,5 +2060,8 @@
|
|
|
1213
2060
|
});
|
|
1214
2061
|
}
|
|
1215
2062
|
|
|
2063
|
+
restoreSpecs(); // rebuild any Specs saved from a previous load of this URL
|
|
2064
|
+
scheduleSync(); // mirror restored Specs to the Claude bridge on load
|
|
2065
|
+
|
|
1216
2066
|
console.log('%c👻 Specter — Ctrl+Option+Z to toggle', 'color:#aaa;font-size:11px;');
|
|
1217
2067
|
})();
|