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