snoop-ui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/package.json +95 -0
- package/packages/adapter-vite/dist/vite.d.ts +29 -0
- package/packages/adapter-vite/dist/vite.js +281 -0
- package/packages/adapter-vite/dist/vite.js.map +1 -0
- package/packages/cli/dist/cli.d.ts +1 -0
- package/packages/cli/dist/cli.js +136 -0
- package/packages/cli/dist/cli.js.map +1 -0
- package/packages/core/dist/index.d.ts +607 -0
- package/packages/core/dist/index.js +2421 -0
- package/packages/core/dist/index.js.map +1 -0
- package/packages/runtime/dist/agent.js +751 -0
- package/packages/runtime/dist/agent.js.map +1 -0
- package/schemas/config.v1.json +99 -0
- package/schemas/diagnostics.v1.json +132 -0
- package/schemas/manifest.v1.json +207 -0
- package/schemas/styles.v1.json +231 -0
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
// ../core/src/styles/selector.ts
|
|
2
|
+
/** Split a selector list on top-level commas, ignoring commas in functions. */
|
|
3
|
+
function splitSelectorList(selector) {
|
|
4
|
+
const parts = [];
|
|
5
|
+
let current = "";
|
|
6
|
+
let quote;
|
|
7
|
+
let brackets = 0;
|
|
8
|
+
let parentheses = 0;
|
|
9
|
+
for (let index = 0; index < selector.length; index += 1) {
|
|
10
|
+
const character = selector[index];
|
|
11
|
+
if (quote) {
|
|
12
|
+
current += character;
|
|
13
|
+
if (character === "\\") {
|
|
14
|
+
const next = selector[index + 1];
|
|
15
|
+
if (next !== void 0) {
|
|
16
|
+
current += next;
|
|
17
|
+
index += 1;
|
|
18
|
+
}
|
|
19
|
+
} else if (character === quote) quote = void 0;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (character === "\"" || character === "'") {
|
|
23
|
+
quote = character;
|
|
24
|
+
current += character;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (character === "[") brackets += 1;
|
|
28
|
+
else if (character === "]") brackets = Math.max(0, brackets - 1);
|
|
29
|
+
else if (character === "(") parentheses += 1;
|
|
30
|
+
else if (character === ")") parentheses = Math.max(0, parentheses - 1);
|
|
31
|
+
if (character === "," && brackets === 0 && parentheses === 0) {
|
|
32
|
+
parts.push(current);
|
|
33
|
+
current = "";
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
current += character;
|
|
37
|
+
}
|
|
38
|
+
parts.push(current);
|
|
39
|
+
return parts;
|
|
40
|
+
}
|
|
41
|
+
// ../core/src/runtime/measure.ts
|
|
42
|
+
/**
|
|
43
|
+
* Computed style properties captured for the selected element. The list is
|
|
44
|
+
* deliberately small: the agent sends full style detail for one element at a
|
|
45
|
+
* time, and the style resolver (M4) adds provenance on top of these values.
|
|
46
|
+
*/
|
|
47
|
+
var STYLE_PROPERTIES = [
|
|
48
|
+
"display",
|
|
49
|
+
"position",
|
|
50
|
+
"top",
|
|
51
|
+
"right",
|
|
52
|
+
"bottom",
|
|
53
|
+
"left",
|
|
54
|
+
"width",
|
|
55
|
+
"height",
|
|
56
|
+
"min-width",
|
|
57
|
+
"min-height",
|
|
58
|
+
"max-width",
|
|
59
|
+
"max-height",
|
|
60
|
+
"margin-top",
|
|
61
|
+
"margin-right",
|
|
62
|
+
"margin-bottom",
|
|
63
|
+
"margin-left",
|
|
64
|
+
"padding-top",
|
|
65
|
+
"padding-right",
|
|
66
|
+
"padding-bottom",
|
|
67
|
+
"padding-left",
|
|
68
|
+
"border-top-width",
|
|
69
|
+
"border-right-width",
|
|
70
|
+
"border-bottom-width",
|
|
71
|
+
"border-left-width",
|
|
72
|
+
"border-radius",
|
|
73
|
+
"border-top-left-radius",
|
|
74
|
+
"border-top-right-radius",
|
|
75
|
+
"border-bottom-right-radius",
|
|
76
|
+
"border-bottom-left-radius",
|
|
77
|
+
"box-sizing",
|
|
78
|
+
"overflow",
|
|
79
|
+
"z-index",
|
|
80
|
+
"opacity",
|
|
81
|
+
"color",
|
|
82
|
+
"background-color",
|
|
83
|
+
"box-shadow",
|
|
84
|
+
"font-family",
|
|
85
|
+
"font-size",
|
|
86
|
+
"font-weight",
|
|
87
|
+
"line-height",
|
|
88
|
+
"letter-spacing",
|
|
89
|
+
"text-align",
|
|
90
|
+
"text-transform",
|
|
91
|
+
"white-space",
|
|
92
|
+
"flex-direction",
|
|
93
|
+
"flex-wrap",
|
|
94
|
+
"justify-content",
|
|
95
|
+
"align-items",
|
|
96
|
+
"gap",
|
|
97
|
+
"grid-template-columns",
|
|
98
|
+
"grid-template-rows",
|
|
99
|
+
"transform"
|
|
100
|
+
];
|
|
101
|
+
/**
|
|
102
|
+
* Collect the geometry of every tagged element that is currently rendered.
|
|
103
|
+
*
|
|
104
|
+
* Only visible elements are measured: the agent runs inside the host app and
|
|
105
|
+
* must stay cheap.
|
|
106
|
+
*/
|
|
107
|
+
function collectMeasuredElements(doc, view) {
|
|
108
|
+
const elements = doc.querySelectorAll("[data-snoop-src]");
|
|
109
|
+
const measured = [];
|
|
110
|
+
for (const element of elements) {
|
|
111
|
+
const src = element.getAttribute("data-snoop-src");
|
|
112
|
+
if (!src || !isVisible(element, view)) continue;
|
|
113
|
+
measured.push({
|
|
114
|
+
src,
|
|
115
|
+
tag: element.tagName.toLowerCase(),
|
|
116
|
+
rect: toRect(element.getBoundingClientRect())
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return measured;
|
|
120
|
+
}
|
|
121
|
+
/** Capture the full detail of a single element. */
|
|
122
|
+
function describeElement(element, view) {
|
|
123
|
+
const src = element.getAttribute("data-snoop-src");
|
|
124
|
+
if (!src) return;
|
|
125
|
+
const computed = view.getComputedStyle(element);
|
|
126
|
+
const style = {};
|
|
127
|
+
for (const property of STYLE_PROPERTIES) {
|
|
128
|
+
const value = computed.getPropertyValue(property);
|
|
129
|
+
if (value) style[property] = value;
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
src,
|
|
133
|
+
tag: element.tagName.toLowerCase(),
|
|
134
|
+
rect: toRect(element.getBoundingClientRect()),
|
|
135
|
+
style,
|
|
136
|
+
classes: classNames(element),
|
|
137
|
+
inlineStyle: collectInlineStyle(element),
|
|
138
|
+
matchedRules: collectMatchedRules(element, documentOf(element, view))
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Read every stylesheet rule that matches the element. The browser CSSOM is the
|
|
143
|
+
* source of truth; the static index only labels where the winning rule lives.
|
|
144
|
+
*/
|
|
145
|
+
function collectMatchedRules(element, doc) {
|
|
146
|
+
if (!doc?.styleSheets) return [];
|
|
147
|
+
const matched = [];
|
|
148
|
+
let order = 0;
|
|
149
|
+
const walk = (rules, atRules) => {
|
|
150
|
+
if (!rules) return;
|
|
151
|
+
for (const rule of Array.from(rules)) {
|
|
152
|
+
if (matched.length >= 250) return;
|
|
153
|
+
const styleRule = rule;
|
|
154
|
+
if (typeof styleRule.selectorText === "string" && styleRule.style) {
|
|
155
|
+
const currentOrder = order;
|
|
156
|
+
order += 1;
|
|
157
|
+
if (!matchesAny(element, styleRule.selectorText)) continue;
|
|
158
|
+
const declarations = matchedDeclarations(styleRule.style);
|
|
159
|
+
if (declarations.length > 0) matched.push({
|
|
160
|
+
selector: styleRule.selectorText,
|
|
161
|
+
declarations,
|
|
162
|
+
order: currentOrder,
|
|
163
|
+
atRules
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const grouping = rule;
|
|
168
|
+
if (grouping.cssRules) {
|
|
169
|
+
const label = groupingLabel(rule);
|
|
170
|
+
walk(grouping.cssRules, label ? [...atRules, label] : atRules);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
for (const sheet of Array.from(doc.styleSheets)) {
|
|
175
|
+
if (sheet.disabled) continue;
|
|
176
|
+
let rules;
|
|
177
|
+
try {
|
|
178
|
+
rules = sheet.cssRules;
|
|
179
|
+
} catch {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
walk(rules, []);
|
|
183
|
+
}
|
|
184
|
+
return matched;
|
|
185
|
+
}
|
|
186
|
+
function matchedDeclarations(style) {
|
|
187
|
+
const declarations = [];
|
|
188
|
+
for (const property of STYLE_PROPERTIES) {
|
|
189
|
+
const value = style.getPropertyValue(property);
|
|
190
|
+
if (!value) continue;
|
|
191
|
+
declarations.push({
|
|
192
|
+
property,
|
|
193
|
+
value,
|
|
194
|
+
important: style.getPropertyPriority(property) === "important"
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return declarations;
|
|
198
|
+
}
|
|
199
|
+
function collectInlineStyle(element) {
|
|
200
|
+
const style = element.style;
|
|
201
|
+
if (!style || typeof style.getPropertyValue !== "function") return {};
|
|
202
|
+
const declarations = {};
|
|
203
|
+
for (const property of STYLE_PROPERTIES) {
|
|
204
|
+
const value = style.getPropertyValue(property);
|
|
205
|
+
if (value) declarations[property] = value;
|
|
206
|
+
}
|
|
207
|
+
return declarations;
|
|
208
|
+
}
|
|
209
|
+
function matchesAny(element, selector) {
|
|
210
|
+
for (const part of splitSelectorList(selector)) {
|
|
211
|
+
const candidate = part.trim();
|
|
212
|
+
if (!candidate) continue;
|
|
213
|
+
try {
|
|
214
|
+
if (element.matches(candidate)) return true;
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
function groupingLabel(rule) {
|
|
220
|
+
const media = rule.media;
|
|
221
|
+
if (media && typeof media.mediaText === "string") return `@media ${media.mediaText}`;
|
|
222
|
+
const condition = rule.conditionText;
|
|
223
|
+
if (typeof condition === "string") return (rule.constructor?.name ?? "").includes("Container") ? `@container ${condition}` : `@supports ${condition}`;
|
|
224
|
+
const layer = rule.name;
|
|
225
|
+
if (typeof layer === "string" && layer.length > 0) return `@layer ${layer}`;
|
|
226
|
+
}
|
|
227
|
+
function classNames(element) {
|
|
228
|
+
const value = element.getAttribute("class");
|
|
229
|
+
return value ? value.split(/\s+/).filter(Boolean) : [];
|
|
230
|
+
}
|
|
231
|
+
function documentOf(element, view) {
|
|
232
|
+
return element.ownerDocument ?? view.document;
|
|
233
|
+
}
|
|
234
|
+
/** An element counts as rendered when it has a box and is not hidden. */
|
|
235
|
+
function isVisible(element, view) {
|
|
236
|
+
const rect = element.getBoundingClientRect();
|
|
237
|
+
if (rect.width <= 0 || rect.height <= 0) return false;
|
|
238
|
+
const computed = view.getComputedStyle(element);
|
|
239
|
+
if (computed.getPropertyValue("display") === "none") return false;
|
|
240
|
+
const visibility = computed.getPropertyValue("visibility");
|
|
241
|
+
if (visibility === "hidden" || visibility === "collapse") return false;
|
|
242
|
+
if (computed.getPropertyValue("opacity") === "0") return false;
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
/** Walk up from an event target to the closest tagged element. */
|
|
246
|
+
function findSourceElement(target) {
|
|
247
|
+
let current = target;
|
|
248
|
+
while (current && typeof current.getAttribute === "function") {
|
|
249
|
+
if (current.getAttribute("data-snoop-src")) return current;
|
|
250
|
+
current = current.parentElement ?? null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function toRect(rect) {
|
|
254
|
+
return {
|
|
255
|
+
x: round$1(rect.x),
|
|
256
|
+
y: round$1(rect.y),
|
|
257
|
+
width: round$1(rect.width),
|
|
258
|
+
height: round$1(rect.height),
|
|
259
|
+
top: round$1(rect.top),
|
|
260
|
+
left: round$1(rect.left),
|
|
261
|
+
right: round$1(rect.right),
|
|
262
|
+
bottom: round$1(rect.bottom)
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function round$1(value) {
|
|
266
|
+
return Math.round(value * 100) / 100;
|
|
267
|
+
}
|
|
268
|
+
// ../core/src/runtime/overlay.ts
|
|
269
|
+
var HOVER_COLOR = "#6366f1";
|
|
270
|
+
var SELECTED_COLOR = "#16a34a";
|
|
271
|
+
var FOCUS_DIM = "rgba(17, 17, 41, 0.55)";
|
|
272
|
+
var ISOLATE_BACKDROP = "#f4f2fb";
|
|
273
|
+
var SVG_NS = "http://www.w3.org/2000/svg";
|
|
274
|
+
var DIM_MASK_ID = "snoop-dim-mask";
|
|
275
|
+
/**
|
|
276
|
+
* A layer over the whole viewport with the selected rectangles cut out. A mask
|
|
277
|
+
* is used instead of per-box shadows because overlapping or nested selections
|
|
278
|
+
* must stay clear.
|
|
279
|
+
*/
|
|
280
|
+
function createDim(doc) {
|
|
281
|
+
const svg = doc.createElementNS(SVG_NS, "svg");
|
|
282
|
+
svg.setAttribute("aria-hidden", "true");
|
|
283
|
+
svg.style.cssText = [
|
|
284
|
+
"position:fixed",
|
|
285
|
+
"top:0",
|
|
286
|
+
"left:0",
|
|
287
|
+
"width:100vw",
|
|
288
|
+
"height:100vh",
|
|
289
|
+
"display:none",
|
|
290
|
+
"pointer-events:none"
|
|
291
|
+
].join(";");
|
|
292
|
+
const defs = doc.createElementNS(SVG_NS, "defs");
|
|
293
|
+
const mask = doc.createElementNS(SVG_NS, "mask");
|
|
294
|
+
mask.setAttribute("id", DIM_MASK_ID);
|
|
295
|
+
mask.setAttribute("maskUnits", "userSpaceOnUse");
|
|
296
|
+
mask.setAttribute("x", "0");
|
|
297
|
+
mask.setAttribute("y", "0");
|
|
298
|
+
mask.setAttribute("width", "100%");
|
|
299
|
+
mask.setAttribute("height", "100%");
|
|
300
|
+
const visible = doc.createElementNS(SVG_NS, "rect");
|
|
301
|
+
visible.setAttribute("width", "100%");
|
|
302
|
+
visible.setAttribute("height", "100%");
|
|
303
|
+
visible.setAttribute("fill", "#fff");
|
|
304
|
+
const holes = doc.createElementNS(SVG_NS, "g");
|
|
305
|
+
holes.setAttribute("fill", "#000");
|
|
306
|
+
mask.append(visible, holes);
|
|
307
|
+
defs.append(mask);
|
|
308
|
+
const layer = doc.createElementNS(SVG_NS, "rect");
|
|
309
|
+
layer.setAttribute("width", "100%");
|
|
310
|
+
layer.setAttribute("height", "100%");
|
|
311
|
+
layer.setAttribute("mask", `url(#${DIM_MASK_ID})`);
|
|
312
|
+
svg.append(defs, layer);
|
|
313
|
+
return {
|
|
314
|
+
svg,
|
|
315
|
+
update(rects, fill) {
|
|
316
|
+
holes.replaceChildren();
|
|
317
|
+
for (const rect of rects) {
|
|
318
|
+
const hole = doc.createElementNS(SVG_NS, "rect");
|
|
319
|
+
hole.setAttribute("x", String(rect.left));
|
|
320
|
+
hole.setAttribute("y", String(rect.top));
|
|
321
|
+
hole.setAttribute("width", String(rect.width));
|
|
322
|
+
hole.setAttribute("height", String(rect.height));
|
|
323
|
+
holes.append(hole);
|
|
324
|
+
}
|
|
325
|
+
layer.setAttribute("fill", fill);
|
|
326
|
+
svg.style.display = rects.length > 0 ? "block" : "none";
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Draw the inspection overlay inside the host app document.
|
|
332
|
+
*
|
|
333
|
+
* The overlay never receives pointer events, so the app stays usable while it
|
|
334
|
+
* is shown. Clicks are intercepted separately by the agent when inspect mode
|
|
335
|
+
* is on.
|
|
336
|
+
*/
|
|
337
|
+
function createOverlay(doc) {
|
|
338
|
+
const root = doc.createElement("div");
|
|
339
|
+
root.setAttribute("data-snoop-overlay", "");
|
|
340
|
+
root.setAttribute("aria-hidden", "true");
|
|
341
|
+
root.style.cssText = [
|
|
342
|
+
"position:fixed",
|
|
343
|
+
"top:0",
|
|
344
|
+
"left:0",
|
|
345
|
+
"width:0",
|
|
346
|
+
"height:0",
|
|
347
|
+
"z-index:2147483647",
|
|
348
|
+
"pointer-events:none",
|
|
349
|
+
"contain:layout style size"
|
|
350
|
+
].join(";");
|
|
351
|
+
const dim = createDim(doc);
|
|
352
|
+
const hover = createMarker(doc, HOVER_COLOR);
|
|
353
|
+
const selected = [];
|
|
354
|
+
let items = [];
|
|
355
|
+
let isolated = false;
|
|
356
|
+
root.append(dim.svg, hover.box, hover.label);
|
|
357
|
+
doc.body?.append(root);
|
|
358
|
+
function place(marker, rect, label) {
|
|
359
|
+
marker.box.style.top = `${rect.top}px`;
|
|
360
|
+
marker.box.style.left = `${rect.left}px`;
|
|
361
|
+
marker.box.style.width = `${rect.width}px`;
|
|
362
|
+
marker.box.style.height = `${rect.height}px`;
|
|
363
|
+
marker.box.style.display = "block";
|
|
364
|
+
marker.label.textContent = label;
|
|
365
|
+
marker.label.style.display = "block";
|
|
366
|
+
const labelTop = rect.top - 20 < 0 ? rect.top + 2 : rect.top - 20;
|
|
367
|
+
marker.label.style.top = `${labelTop}px`;
|
|
368
|
+
marker.label.style.left = `${rect.left}px`;
|
|
369
|
+
}
|
|
370
|
+
function hide(marker) {
|
|
371
|
+
marker.box.style.display = "none";
|
|
372
|
+
marker.label.style.display = "none";
|
|
373
|
+
}
|
|
374
|
+
function removeMarkers() {
|
|
375
|
+
for (const marker of selected) {
|
|
376
|
+
hide(marker);
|
|
377
|
+
marker.box.remove();
|
|
378
|
+
marker.label.remove();
|
|
379
|
+
}
|
|
380
|
+
selected.length = 0;
|
|
381
|
+
}
|
|
382
|
+
function render() {
|
|
383
|
+
removeMarkers();
|
|
384
|
+
if (!isolated) for (const item of items) {
|
|
385
|
+
const marker = createMarker(doc, SELECTED_COLOR);
|
|
386
|
+
root.append(marker.box, marker.label);
|
|
387
|
+
selected.push(marker);
|
|
388
|
+
place(marker, item.rect, item.label);
|
|
389
|
+
}
|
|
390
|
+
dim.update(items.map((item) => item.rect), isolated ? ISOLATE_BACKDROP : FOCUS_DIM);
|
|
391
|
+
}
|
|
392
|
+
function clearSelected() {
|
|
393
|
+
items = [];
|
|
394
|
+
render();
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
showHover(item) {
|
|
398
|
+
if (!item) {
|
|
399
|
+
hide(hover);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
place(hover, item.rect, item.label);
|
|
403
|
+
},
|
|
404
|
+
showSelected(next) {
|
|
405
|
+
items = next;
|
|
406
|
+
render();
|
|
407
|
+
},
|
|
408
|
+
setIsolated(next) {
|
|
409
|
+
isolated = next;
|
|
410
|
+
render();
|
|
411
|
+
},
|
|
412
|
+
clearHover() {
|
|
413
|
+
hide(hover);
|
|
414
|
+
},
|
|
415
|
+
clearSelected,
|
|
416
|
+
destroy() {
|
|
417
|
+
removeMarkers();
|
|
418
|
+
hover.box.remove();
|
|
419
|
+
hover.label.remove();
|
|
420
|
+
dim.svg.remove();
|
|
421
|
+
root.remove();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function createMarker(doc, color) {
|
|
426
|
+
const box = doc.createElement("div");
|
|
427
|
+
box.style.cssText = [
|
|
428
|
+
"position:fixed",
|
|
429
|
+
"display:none",
|
|
430
|
+
"box-sizing:border-box",
|
|
431
|
+
"pointer-events:none",
|
|
432
|
+
`border:2px solid ${color}`,
|
|
433
|
+
`background:${color}1f`,
|
|
434
|
+
"border-radius:2px"
|
|
435
|
+
].join(";");
|
|
436
|
+
const label = doc.createElement("div");
|
|
437
|
+
label.style.cssText = [
|
|
438
|
+
"position:fixed",
|
|
439
|
+
"display:none",
|
|
440
|
+
"pointer-events:none",
|
|
441
|
+
"white-space:nowrap",
|
|
442
|
+
"font:11px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace",
|
|
443
|
+
`background:${color}`,
|
|
444
|
+
"color:#ffffff",
|
|
445
|
+
"padding:2px 5px",
|
|
446
|
+
"border-radius:4px",
|
|
447
|
+
"max-width:60vw",
|
|
448
|
+
"overflow:hidden",
|
|
449
|
+
"text-overflow:ellipsis"
|
|
450
|
+
].join(";");
|
|
451
|
+
return {
|
|
452
|
+
box,
|
|
453
|
+
label
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// ../core/src/runtime/protocol.ts
|
|
457
|
+
function isDashboardMessage(value) {
|
|
458
|
+
if (!isRecord(value) || typeof value.kind !== "string") return false;
|
|
459
|
+
switch (value.kind) {
|
|
460
|
+
case "measure":
|
|
461
|
+
case "inspect":
|
|
462
|
+
case "isolate":
|
|
463
|
+
case "select":
|
|
464
|
+
case "sync": return true;
|
|
465
|
+
default: return false;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function isRecord(value) {
|
|
469
|
+
return typeof value === "object" && value !== null;
|
|
470
|
+
}
|
|
471
|
+
// src/agent.ts
|
|
472
|
+
var SESSION_ENDPOINT = "/__snoop/session";
|
|
473
|
+
var WS_ENDPOINT = "/__snoop/ws";
|
|
474
|
+
var MEASURE_DEBOUNCE = 120;
|
|
475
|
+
var RECONNECT_DELAY = 2e3;
|
|
476
|
+
/**
|
|
477
|
+
* Start the runtime agent inside the host app.
|
|
478
|
+
*
|
|
479
|
+
* The agent is injected by the Vite plugin in dev mode only. It stays idle
|
|
480
|
+
* until the dashboard asks for measurements, so a dev server without an open
|
|
481
|
+
* dashboard costs one WebSocket and nothing else.
|
|
482
|
+
*/
|
|
483
|
+
function startAgent(options = {}) {
|
|
484
|
+
if (typeof window === "undefined" || typeof document === "undefined") return;
|
|
485
|
+
run(options);
|
|
486
|
+
}
|
|
487
|
+
async function run(options) {
|
|
488
|
+
const sessionEndpoint = options.sessionEndpoint ?? SESSION_ENDPOINT;
|
|
489
|
+
const websocketEndpoint = options.websocketEndpoint ?? WS_ENDPOINT;
|
|
490
|
+
const token = await fetchToken(sessionEndpoint);
|
|
491
|
+
if (!token) return;
|
|
492
|
+
const sessionToken = token;
|
|
493
|
+
const state = {
|
|
494
|
+
socket: null,
|
|
495
|
+
overlay: null,
|
|
496
|
+
measureEnabled: false,
|
|
497
|
+
inspectEnabled: false,
|
|
498
|
+
selectedSrcs: [],
|
|
499
|
+
hoverSrc: null,
|
|
500
|
+
elements: [],
|
|
501
|
+
reconnectTimer: void 0,
|
|
502
|
+
measureTimer: void 0,
|
|
503
|
+
observer: void 0,
|
|
504
|
+
listening: false,
|
|
505
|
+
isolateEnabled: false
|
|
506
|
+
};
|
|
507
|
+
function send(message) {
|
|
508
|
+
const socket = state.socket;
|
|
509
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) return;
|
|
510
|
+
socket.send(JSON.stringify({
|
|
511
|
+
type: "relay",
|
|
512
|
+
payload: message
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
function sendElements() {
|
|
516
|
+
if (!state.measureEnabled) return;
|
|
517
|
+
const elements = collectMeasuredElements(document, window);
|
|
518
|
+
state.elements = elements;
|
|
519
|
+
send({
|
|
520
|
+
kind: "elements",
|
|
521
|
+
elements
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
function sendDetail(src) {
|
|
525
|
+
const element = findElementBySource(src);
|
|
526
|
+
if (!element) return;
|
|
527
|
+
const detail = describeElement(element, window);
|
|
528
|
+
if (detail) send({
|
|
529
|
+
kind: "detail",
|
|
530
|
+
detail
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
function ensureOverlay() {
|
|
534
|
+
if (!state.overlay) state.overlay = createOverlay(document);
|
|
535
|
+
return state.overlay;
|
|
536
|
+
}
|
|
537
|
+
function showSelected(srcs) {
|
|
538
|
+
const items = [];
|
|
539
|
+
for (const src of srcs) for (const element of findElementsBySource(src)) {
|
|
540
|
+
const rect = toOverlayRect(element);
|
|
541
|
+
if (rect.width > 0 && rect.height > 0) items.push({
|
|
542
|
+
rect,
|
|
543
|
+
label: src
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
ensureOverlay().showSelected(items);
|
|
547
|
+
}
|
|
548
|
+
function showHover(src) {
|
|
549
|
+
const overlay = ensureOverlay();
|
|
550
|
+
if (!src) {
|
|
551
|
+
overlay.showHover(void 0);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const element = findElementBySource(src);
|
|
555
|
+
if (!element) {
|
|
556
|
+
overlay.showHover(void 0);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
overlay.showHover({
|
|
560
|
+
rect: toOverlayRect(element),
|
|
561
|
+
label: src
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
function reveal(src) {
|
|
565
|
+
const element = findElementBySource(src);
|
|
566
|
+
if (!element) return;
|
|
567
|
+
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
|
|
568
|
+
element.scrollIntoView({
|
|
569
|
+
block: "center",
|
|
570
|
+
inline: "center",
|
|
571
|
+
behavior: reduceMotion ? "auto" : "smooth"
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
let refreshFrame = 0;
|
|
575
|
+
function refreshSelected() {
|
|
576
|
+
if (refreshFrame !== 0 || state.selectedSrcs.length === 0) return;
|
|
577
|
+
refreshFrame = window.requestAnimationFrame(() => {
|
|
578
|
+
refreshFrame = 0;
|
|
579
|
+
showSelected(state.selectedSrcs);
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
window.addEventListener("scroll", refreshSelected, true);
|
|
583
|
+
window.addEventListener("resize", refreshSelected);
|
|
584
|
+
function scheduleMeasure() {
|
|
585
|
+
if (state.measureTimer !== void 0) window.clearTimeout(state.measureTimer);
|
|
586
|
+
state.measureTimer = window.setTimeout(() => {
|
|
587
|
+
state.measureTimer = void 0;
|
|
588
|
+
sendElements();
|
|
589
|
+
}, MEASURE_DEBOUNCE);
|
|
590
|
+
}
|
|
591
|
+
function startMeasureListeners() {
|
|
592
|
+
if (state.listening) return;
|
|
593
|
+
state.listening = true;
|
|
594
|
+
window.addEventListener("scroll", scheduleMeasure, true);
|
|
595
|
+
window.addEventListener("resize", scheduleMeasure);
|
|
596
|
+
state.observer = new MutationObserver(scheduleMeasure);
|
|
597
|
+
state.observer.observe(document.documentElement, {
|
|
598
|
+
childList: true,
|
|
599
|
+
subtree: true
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
function stopMeasureListeners() {
|
|
603
|
+
if (!state.listening) return;
|
|
604
|
+
state.listening = false;
|
|
605
|
+
window.removeEventListener("scroll", scheduleMeasure, true);
|
|
606
|
+
window.removeEventListener("resize", scheduleMeasure);
|
|
607
|
+
state.observer?.disconnect();
|
|
608
|
+
state.observer = void 0;
|
|
609
|
+
}
|
|
610
|
+
function onClick(event) {
|
|
611
|
+
const src = findSourceElement(event.target)?.getAttribute("data-snoop-src");
|
|
612
|
+
if (!src) return;
|
|
613
|
+
event.preventDefault();
|
|
614
|
+
event.stopPropagation();
|
|
615
|
+
state.selectedSrcs = [src];
|
|
616
|
+
showSelected(state.selectedSrcs);
|
|
617
|
+
send({
|
|
618
|
+
kind: "click",
|
|
619
|
+
src
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
let hoverFrame = 0;
|
|
623
|
+
function onMouseMove(event) {
|
|
624
|
+
if (hoverFrame) return;
|
|
625
|
+
const target = event.target;
|
|
626
|
+
hoverFrame = window.requestAnimationFrame(() => {
|
|
627
|
+
hoverFrame = 0;
|
|
628
|
+
const src = findSourceElement(target)?.getAttribute("data-snoop-src") ?? null;
|
|
629
|
+
if (src === state.hoverSrc) return;
|
|
630
|
+
state.hoverSrc = src;
|
|
631
|
+
showHover(src);
|
|
632
|
+
send({
|
|
633
|
+
kind: "hover",
|
|
634
|
+
src
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
function handleDashboardMessage(message) {
|
|
639
|
+
switch (message.kind) {
|
|
640
|
+
case "measure":
|
|
641
|
+
state.measureEnabled = message.enabled;
|
|
642
|
+
if (message.enabled) {
|
|
643
|
+
startMeasureListeners();
|
|
644
|
+
sendElements();
|
|
645
|
+
} else stopMeasureListeners();
|
|
646
|
+
break;
|
|
647
|
+
case "inspect":
|
|
648
|
+
state.inspectEnabled = message.enabled;
|
|
649
|
+
if (message.enabled) {
|
|
650
|
+
document.addEventListener("click", onClick, true);
|
|
651
|
+
document.addEventListener("mousemove", onMouseMove, true);
|
|
652
|
+
} else {
|
|
653
|
+
document.removeEventListener("click", onClick, true);
|
|
654
|
+
document.removeEventListener("mousemove", onMouseMove, true);
|
|
655
|
+
state.hoverSrc = null;
|
|
656
|
+
showHover(null);
|
|
657
|
+
}
|
|
658
|
+
break;
|
|
659
|
+
case "select": {
|
|
660
|
+
state.selectedSrcs = message.srcs;
|
|
661
|
+
const first = message.srcs[0];
|
|
662
|
+
if (first) {
|
|
663
|
+
showSelected(message.srcs);
|
|
664
|
+
sendDetail(first);
|
|
665
|
+
if (message.reveal) reveal(first);
|
|
666
|
+
} else ensureOverlay().showSelected([]);
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
case "isolate": {
|
|
670
|
+
state.isolateEnabled = message.enabled;
|
|
671
|
+
ensureOverlay().setIsolated(message.enabled);
|
|
672
|
+
showSelected(state.selectedSrcs);
|
|
673
|
+
const first = state.selectedSrcs[0];
|
|
674
|
+
if (message.enabled && first) reveal(first);
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
case "sync": {
|
|
678
|
+
send({ kind: "ready" });
|
|
679
|
+
sendElements();
|
|
680
|
+
const first = state.selectedSrcs[0];
|
|
681
|
+
if (first) sendDetail(first);
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
function connect() {
|
|
687
|
+
const url = new URL(websocketEndpoint, window.location.href);
|
|
688
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
689
|
+
url.searchParams.set("token", sessionToken);
|
|
690
|
+
url.searchParams.set("role", "agent");
|
|
691
|
+
const socket = new WebSocket(url);
|
|
692
|
+
state.socket = socket;
|
|
693
|
+
socket.addEventListener("open", () => {
|
|
694
|
+
send({ kind: "ready" });
|
|
695
|
+
sendElements();
|
|
696
|
+
});
|
|
697
|
+
socket.addEventListener("message", (event) => {
|
|
698
|
+
let payload;
|
|
699
|
+
try {
|
|
700
|
+
payload = JSON.parse(String(event.data));
|
|
701
|
+
} catch {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (typeof payload === "object" && payload !== null && payload.type === "relay" && isDashboardMessage(payload.payload)) handleDashboardMessage(payload.payload);
|
|
705
|
+
});
|
|
706
|
+
socket.addEventListener("close", () => {
|
|
707
|
+
state.socket = null;
|
|
708
|
+
state.reconnectTimer = window.setTimeout(connect, RECONNECT_DELAY);
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
connect();
|
|
712
|
+
}
|
|
713
|
+
async function fetchToken(endpoint) {
|
|
714
|
+
try {
|
|
715
|
+
const response = await fetch(endpoint, {
|
|
716
|
+
headers: { Accept: "application/json" },
|
|
717
|
+
cache: "no-store"
|
|
718
|
+
});
|
|
719
|
+
if (!response.ok) return;
|
|
720
|
+
const session = await response.json();
|
|
721
|
+
return typeof session.token === "string" ? session.token : void 0;
|
|
722
|
+
} catch {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
function findElementBySource(src) {
|
|
727
|
+
const elements = document.querySelectorAll("[data-snoop-src]");
|
|
728
|
+
for (const element of elements) if (element.getAttribute("data-snoop-src") === src) return element;
|
|
729
|
+
}
|
|
730
|
+
function findElementsBySource(src) {
|
|
731
|
+
return Array.from(document.querySelectorAll("[data-snoop-src]")).filter((element) => element.getAttribute("data-snoop-src") === src);
|
|
732
|
+
}
|
|
733
|
+
function toOverlayRect(element) {
|
|
734
|
+
const rect = element.getBoundingClientRect();
|
|
735
|
+
return {
|
|
736
|
+
x: round(rect.x),
|
|
737
|
+
y: round(rect.y),
|
|
738
|
+
width: round(rect.width),
|
|
739
|
+
height: round(rect.height),
|
|
740
|
+
top: round(rect.top),
|
|
741
|
+
left: round(rect.left),
|
|
742
|
+
right: round(rect.right),
|
|
743
|
+
bottom: round(rect.bottom)
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
function round(value) {
|
|
747
|
+
return Math.round(value * 100) / 100;
|
|
748
|
+
}
|
|
749
|
+
export { startAgent };
|
|
750
|
+
|
|
751
|
+
//# sourceMappingURL=agent.js.map
|