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