@treelocator/runtime 0.3.2 → 0.4.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/browserApi.d.ts +45 -0
- package/dist/browserApi.js +23 -1
- package/dist/components/RecordingOutline.d.ts +5 -0
- package/dist/components/RecordingOutline.js +53 -0
- package/dist/components/RecordingResults.d.ts +25 -0
- package/dist/components/RecordingResults.js +272 -0
- package/dist/components/Runtime.js +505 -70
- package/dist/dejitter/recorder.d.ts +91 -0
- package/dist/dejitter/recorder.js +908 -0
- package/dist/functions/enrichAncestrySourceMaps.js +9 -2
- package/dist/output.css +13 -0
- package/package.json +2 -2
- package/src/browserApi.ts +74 -1
- package/src/components/RecordingOutline.tsx +66 -0
- package/src/components/RecordingResults.tsx +287 -0
- package/src/components/Runtime.tsx +534 -80
- package/src/dejitter/recorder.ts +938 -0
- package/src/functions/enrichAncestrySourceMaps.ts +9 -2
- package/.turbo/turbo-build.log +0 -32
- package/.turbo/turbo-dev.log +0 -32
- package/.turbo/turbo-test.log +0 -14
- package/.turbo/turbo-ts.log +0 -4
- package/LICENSE +0 -22
|
@@ -3,14 +3,21 @@ import { normalizeFilePath } from "./normalizeFilePath";
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Check if any DOM element has React 19 fibers (with _debugStack instead of _debugSource).
|
|
6
|
+
* Must walk the _debugOwner chain because DOM element fibers (HostComponent) never have
|
|
7
|
+
* _debugStack — only function component fibers do.
|
|
6
8
|
*/
|
|
7
9
|
function isReact19Environment() {
|
|
8
10
|
const el = document.querySelector("[class]") || document.body;
|
|
9
11
|
if (!el) return false;
|
|
10
12
|
const fiberKey = Object.keys(el).find(k => k.startsWith("__reactFiber$"));
|
|
11
13
|
if (!fiberKey) return false;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
let fiber = el[fiberKey];
|
|
15
|
+
while (fiber) {
|
|
16
|
+
if (fiber._debugSource) return false; // React 18
|
|
17
|
+
if (fiber._debugStack) return true; // React 19
|
|
18
|
+
fiber = fiber._debugOwner || null;
|
|
19
|
+
}
|
|
20
|
+
return false;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
/**
|
package/dist/output.css
CHANGED
|
@@ -827,6 +827,10 @@ input:where([type='file']):focus {
|
|
|
827
827
|
position: relative;
|
|
828
828
|
}
|
|
829
829
|
|
|
830
|
+
.sticky {
|
|
831
|
+
position: sticky;
|
|
832
|
+
}
|
|
833
|
+
|
|
830
834
|
.-bottom-7 {
|
|
831
835
|
bottom: -1.75rem;
|
|
832
836
|
}
|
|
@@ -1758,10 +1762,19 @@ input:where([type='file']):focus {
|
|
|
1758
1762
|
--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity, 1));
|
|
1759
1763
|
}
|
|
1760
1764
|
|
|
1765
|
+
.blur {
|
|
1766
|
+
--tw-blur: blur(8px);
|
|
1767
|
+
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1761
1770
|
.filter {
|
|
1762
1771
|
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
|
1763
1772
|
}
|
|
1764
1773
|
|
|
1774
|
+
.backdrop-filter {
|
|
1775
|
+
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1765
1778
|
.transition {
|
|
1766
1779
|
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
|
1767
1780
|
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@treelocator/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "TreeLocatorJS runtime for component ancestry tracking. Alt+click any element to copy its component tree to clipboard. Exposes window.__treelocator__ API for browser automation (Playwright, Puppeteer, Selenium, Cypress).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"locator",
|
|
@@ -73,5 +73,5 @@
|
|
|
73
73
|
"directory": "packages/runtime"
|
|
74
74
|
},
|
|
75
75
|
"license": "MIT",
|
|
76
|
-
"gitHead": "
|
|
76
|
+
"gitHead": "4f74117d9076e063c072c6b172f785e5572b3be9"
|
|
77
77
|
}
|
package/src/browserApi.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
AncestryItem,
|
|
7
7
|
} from "./functions/formatAncestryChain";
|
|
8
8
|
import { enrichAncestryWithSourceMaps } from "./functions/enrichAncestrySourceMaps";
|
|
9
|
+
import type { DejitterFinding, DejitterSummary } from "./dejitter/recorder";
|
|
10
|
+
import type { InteractionEvent } from "./components/RecordingResults";
|
|
9
11
|
|
|
10
12
|
export interface LocatorJSAPI {
|
|
11
13
|
/**
|
|
@@ -107,6 +109,53 @@ export interface LocatorJSAPI {
|
|
|
107
109
|
* console.log(help);
|
|
108
110
|
*/
|
|
109
111
|
help(): string;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Replay the last recorded interaction sequence.
|
|
115
|
+
* Dispatches the recorded clicks at the original positions and timing.
|
|
116
|
+
* Must have a completed recording with interactions to replay.
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* // In browser console
|
|
120
|
+
* window.__treelocator__.replay();
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* // In Playwright
|
|
124
|
+
* await page.evaluate(() => window.__treelocator__.replay());
|
|
125
|
+
*/
|
|
126
|
+
replay(): void;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Replay the last recorded interaction sequence while recording an element's property changes.
|
|
130
|
+
* Combines replay and dejitter recording: plays back stored clicks at original timing while
|
|
131
|
+
* tracking visual changes (opacity, transform, position, size) on the target element.
|
|
132
|
+
* Returns the dejitter analysis results when replay completes.
|
|
133
|
+
*
|
|
134
|
+
* @param elementOrSelector - HTMLElement or CSS selector for the element to record during replay
|
|
135
|
+
* @returns Promise resolving to recording results with findings, summary, and interaction log
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* // Record the sliding panel while replaying user clicks
|
|
139
|
+
* const results = await window.__treelocator__.replayWithRecord('[data-locatorjs-id="SlidingPanel"]');
|
|
140
|
+
* console.log(results.findings); // anomaly analysis
|
|
141
|
+
* console.log(results.path); // component ancestry
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* // In Playwright - automated regression test
|
|
145
|
+
* const results = await page.evaluate(async () => {
|
|
146
|
+
* return await window.__treelocator__.replayWithRecord('.my-panel');
|
|
147
|
+
* });
|
|
148
|
+
* expect(results.findings.filter(f => f.severity === 'high')).toHaveLength(0);
|
|
149
|
+
*/
|
|
150
|
+
replayWithRecord(
|
|
151
|
+
elementOrSelector: HTMLElement | string
|
|
152
|
+
): Promise<{
|
|
153
|
+
path: string;
|
|
154
|
+
findings: DejitterFinding[];
|
|
155
|
+
summary: DejitterSummary | null;
|
|
156
|
+
data: any;
|
|
157
|
+
interactions: InteractionEvent[];
|
|
158
|
+
} | null>;
|
|
110
159
|
}
|
|
111
160
|
|
|
112
161
|
let adapterId: AdapterId | undefined;
|
|
@@ -179,7 +228,22 @@ METHODS:
|
|
|
179
228
|
console.log(data.path) // formatted string
|
|
180
229
|
console.log(data.ancestry) // structured array
|
|
181
230
|
|
|
182
|
-
4.
|
|
231
|
+
4. replay()
|
|
232
|
+
Replays the last recorded interaction sequence as a macro.
|
|
233
|
+
|
|
234
|
+
Usage:
|
|
235
|
+
window.__treelocator__.replay()
|
|
236
|
+
|
|
237
|
+
5. replayWithRecord(elementOrSelector)
|
|
238
|
+
Replays stored interactions while recording element changes.
|
|
239
|
+
Returns dejitter analysis when replay completes.
|
|
240
|
+
|
|
241
|
+
Usage:
|
|
242
|
+
const results = await window.__treelocator__.replayWithRecord('[data-locatorjs-id="SlidingPanel"]')
|
|
243
|
+
console.log(results.findings) // anomaly analysis
|
|
244
|
+
console.log(results.path) // component ancestry
|
|
245
|
+
|
|
246
|
+
6. help()
|
|
183
247
|
Displays this help message.
|
|
184
248
|
|
|
185
249
|
PLAYWRIGHT EXAMPLES:
|
|
@@ -280,6 +344,15 @@ export function createBrowserAPI(
|
|
|
280
344
|
help(): string {
|
|
281
345
|
return HELP_TEXT;
|
|
282
346
|
},
|
|
347
|
+
|
|
348
|
+
replay() {
|
|
349
|
+
// Replaced by Runtime component once mounted
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
replayWithRecord() {
|
|
353
|
+
// Replaced by Runtime component once mounted
|
|
354
|
+
return Promise.resolve(null);
|
|
355
|
+
},
|
|
283
356
|
};
|
|
284
357
|
}
|
|
285
358
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createSignal, onCleanup, onMount } from "solid-js";
|
|
2
|
+
|
|
3
|
+
type RecordingOutlineProps = {
|
|
4
|
+
element: HTMLElement;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function RecordingOutline(props: RecordingOutlineProps) {
|
|
8
|
+
const [box, setBox] = createSignal(props.element.getBoundingClientRect());
|
|
9
|
+
|
|
10
|
+
let rafId: number;
|
|
11
|
+
const updateBox = () => {
|
|
12
|
+
setBox(props.element.getBoundingClientRect());
|
|
13
|
+
rafId = requestAnimationFrame(updateBox);
|
|
14
|
+
};
|
|
15
|
+
onMount(() => {
|
|
16
|
+
rafId = requestAnimationFrame(updateBox);
|
|
17
|
+
});
|
|
18
|
+
onCleanup(() => cancelAnimationFrame(rafId));
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
style={{
|
|
23
|
+
position: "fixed",
|
|
24
|
+
"z-index": "2",
|
|
25
|
+
left: box().x + "px",
|
|
26
|
+
top: box().y + "px",
|
|
27
|
+
width: box().width + "px",
|
|
28
|
+
height: box().height + "px",
|
|
29
|
+
border: "2px dashed #ef4444",
|
|
30
|
+
"border-radius": "2px",
|
|
31
|
+
"pointer-events": "none",
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
<div
|
|
35
|
+
style={{
|
|
36
|
+
position: "absolute",
|
|
37
|
+
top: "-22px",
|
|
38
|
+
left: "4px",
|
|
39
|
+
display: "flex",
|
|
40
|
+
"align-items": "center",
|
|
41
|
+
gap: "4px",
|
|
42
|
+
padding: "2px 8px",
|
|
43
|
+
background: "rgba(239, 68, 68, 0.9)",
|
|
44
|
+
"border-radius": "4px",
|
|
45
|
+
color: "#fff",
|
|
46
|
+
"font-size": "10px",
|
|
47
|
+
"font-family": "system-ui, sans-serif",
|
|
48
|
+
"font-weight": "600",
|
|
49
|
+
"letter-spacing": "0.5px",
|
|
50
|
+
"white-space": "nowrap",
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
<div
|
|
54
|
+
style={{
|
|
55
|
+
width: "6px",
|
|
56
|
+
height: "6px",
|
|
57
|
+
"border-radius": "50%",
|
|
58
|
+
background: "#fff",
|
|
59
|
+
animation: "treelocator-rec-pulse 1s ease-in-out infinite",
|
|
60
|
+
}}
|
|
61
|
+
/>
|
|
62
|
+
REC
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { For, Show } from "solid-js";
|
|
2
|
+
import type { DejitterFinding, DejitterSummary } from "../dejitter/recorder";
|
|
3
|
+
|
|
4
|
+
export type InteractionEvent = {
|
|
5
|
+
t: number;
|
|
6
|
+
type: string;
|
|
7
|
+
target: string;
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type RecordingResultsProps = {
|
|
13
|
+
findings: DejitterFinding[];
|
|
14
|
+
summary: DejitterSummary | null;
|
|
15
|
+
data: any;
|
|
16
|
+
elementPath: string;
|
|
17
|
+
interactions: InteractionEvent[];
|
|
18
|
+
onDismiss: () => void;
|
|
19
|
+
onReplay?: () => void;
|
|
20
|
+
replaying?: boolean;
|
|
21
|
+
onToast?: (msg: string) => void;
|
|
22
|
+
hasPrevious?: boolean;
|
|
23
|
+
onLoadPrevious?: () => void;
|
|
24
|
+
hasNext?: boolean;
|
|
25
|
+
onLoadNext?: () => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SEVERITY_COLORS: Record<string, string> = {
|
|
29
|
+
high: "#ef4444",
|
|
30
|
+
medium: "#f59e0b",
|
|
31
|
+
low: "#eab308",
|
|
32
|
+
info: "#9ca3af",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function formatDataForClipboard(
|
|
36
|
+
data: any,
|
|
37
|
+
elementPath: string,
|
|
38
|
+
summary: DejitterSummary | null,
|
|
39
|
+
findings: DejitterFinding[],
|
|
40
|
+
interactions: InteractionEvent[]
|
|
41
|
+
): string {
|
|
42
|
+
const lines: string[] = [];
|
|
43
|
+
|
|
44
|
+
// Element ancestry path from treelocator
|
|
45
|
+
if (elementPath) {
|
|
46
|
+
lines.push(`Element: ${elementPath}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Header
|
|
50
|
+
lines.push(`Recording: ${Math.round(summary?.duration ?? 0)}ms, ${summary?.rawFrameCount ?? 0} frames`);
|
|
51
|
+
lines.push('');
|
|
52
|
+
|
|
53
|
+
// Property changes
|
|
54
|
+
if (data?.propStats?.props && data.propStats.props.length > 0) {
|
|
55
|
+
lines.push('Changed properties:');
|
|
56
|
+
for (const p of data.propStats.props) {
|
|
57
|
+
if (p.raw === 0) continue;
|
|
58
|
+
lines.push(` ${p.prop}: ${p.raw} changes (${p.mode})`);
|
|
59
|
+
}
|
|
60
|
+
lines.push('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Samples with actual values — replace dejitter element IDs with the ancestry path
|
|
64
|
+
if (data?.samples && data.samples.length > 0) {
|
|
65
|
+
lines.push('Timeline:');
|
|
66
|
+
for (const frame of data.samples) {
|
|
67
|
+
for (const change of frame.changes) {
|
|
68
|
+
const { id, ...props } = change;
|
|
69
|
+
const propEntries = Object.entries(props);
|
|
70
|
+
if (propEntries.length > 0) {
|
|
71
|
+
const vals = propEntries.map(([k, v]) => `${k}=${v}`).join(', ');
|
|
72
|
+
lines.push(` ${frame.t}ms: ${vals}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Findings — replace dejitter element IDs in descriptions
|
|
80
|
+
if (findings.length > 0) {
|
|
81
|
+
lines.push('Anomalies:');
|
|
82
|
+
for (const f of findings) {
|
|
83
|
+
lines.push(` [${f.severity}] ${f.type}: ${f.description}`);
|
|
84
|
+
}
|
|
85
|
+
lines.push('');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Interactions
|
|
89
|
+
if (interactions.length > 0) {
|
|
90
|
+
lines.push('User interactions:');
|
|
91
|
+
for (const evt of interactions) {
|
|
92
|
+
lines.push(` ${evt.t}ms ${evt.type} ${evt.target} (${evt.x},${evt.y})`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return lines.join('\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const buttonStyle = (active?: boolean) => ({
|
|
100
|
+
cursor: "pointer",
|
|
101
|
+
padding: "4px 10px",
|
|
102
|
+
"border-radius": "4px",
|
|
103
|
+
background: active ? "rgba(59, 130, 246, 0.2)" : "rgba(255, 255, 255, 0.08)",
|
|
104
|
+
color: active ? "#60a5fa" : "#9ca3af",
|
|
105
|
+
"font-size": "11px",
|
|
106
|
+
"font-weight": "600",
|
|
107
|
+
"line-height": "1.4",
|
|
108
|
+
transition: "background 0.15s, color 0.15s",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export function RecordingResults(props: RecordingResultsProps) {
|
|
112
|
+
const duration = () => props.summary?.duration ?? 0;
|
|
113
|
+
const frameCount = () => props.summary?.rawFrameCount ?? 0;
|
|
114
|
+
|
|
115
|
+
function handleCopy() {
|
|
116
|
+
const text = formatDataForClipboard(props.data, props.elementPath, props.summary, props.findings, props.interactions);
|
|
117
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
118
|
+
props.onToast?.("Copied to clipboard");
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
style={{
|
|
125
|
+
position: "fixed",
|
|
126
|
+
bottom: "84px",
|
|
127
|
+
right: "20px",
|
|
128
|
+
"z-index": "2147483646",
|
|
129
|
+
width: "340px",
|
|
130
|
+
"max-height": "400px",
|
|
131
|
+
"overflow-y": "auto",
|
|
132
|
+
background: "rgba(15, 15, 15, 0.92)",
|
|
133
|
+
"backdrop-filter": "blur(12px)",
|
|
134
|
+
"border-radius": "12px",
|
|
135
|
+
border: "1px solid rgba(255, 255, 255, 0.1)",
|
|
136
|
+
"box-shadow": "0 8px 32px rgba(0, 0, 0, 0.4)",
|
|
137
|
+
color: "#e5e5e5",
|
|
138
|
+
"font-family": "system-ui, -apple-system, sans-serif",
|
|
139
|
+
"font-size": "12px",
|
|
140
|
+
"pointer-events": "auto",
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
{/* Header */}
|
|
144
|
+
<div
|
|
145
|
+
style={{
|
|
146
|
+
display: "flex",
|
|
147
|
+
"align-items": "center",
|
|
148
|
+
"justify-content": "space-between",
|
|
149
|
+
padding: "12px 14px 8px",
|
|
150
|
+
"border-bottom": "1px solid rgba(255, 255, 255, 0.08)",
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<div>
|
|
154
|
+
<div style={{ "font-weight": "600", "font-size": "13px", color: "#fff" }}>
|
|
155
|
+
Recording Results
|
|
156
|
+
</div>
|
|
157
|
+
<div style={{ color: "#9ca3af", "margin-top": "2px" }}>
|
|
158
|
+
{Math.round(duration())}ms · {frameCount()} frames
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div style={{ display: "flex", "align-items": "center", gap: "4px" }}>
|
|
162
|
+
<div style={buttonStyle()} onClick={handleCopy}>
|
|
163
|
+
Copy
|
|
164
|
+
</div>
|
|
165
|
+
{props.onReplay && (
|
|
166
|
+
<div style={buttonStyle(props.replaying)} onClick={props.onReplay}>
|
|
167
|
+
{props.replaying ? "Replaying..." : "Replay"}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
{props.hasPrevious && props.onLoadPrevious && (
|
|
171
|
+
<div style={buttonStyle()} onClick={props.onLoadPrevious}>
|
|
172
|
+
Prev
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
{props.hasNext && props.onLoadNext && (
|
|
176
|
+
<div style={buttonStyle()} onClick={props.onLoadNext}>
|
|
177
|
+
Next
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
<div
|
|
181
|
+
style={{
|
|
182
|
+
cursor: "pointer",
|
|
183
|
+
padding: "4px 8px",
|
|
184
|
+
"border-radius": "4px",
|
|
185
|
+
color: "#9ca3af",
|
|
186
|
+
"font-size": "16px",
|
|
187
|
+
"line-height": "1",
|
|
188
|
+
}}
|
|
189
|
+
onClick={props.onDismiss}
|
|
190
|
+
>
|
|
191
|
+
×
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
{/* Findings */}
|
|
197
|
+
<div style={{ padding: "8px 14px" }}>
|
|
198
|
+
<Show
|
|
199
|
+
when={props.findings.length > 0}
|
|
200
|
+
fallback={
|
|
201
|
+
<div
|
|
202
|
+
style={{
|
|
203
|
+
display: "flex",
|
|
204
|
+
"align-items": "center",
|
|
205
|
+
gap: "6px",
|
|
206
|
+
padding: "8px 0",
|
|
207
|
+
color: "#4ade80",
|
|
208
|
+
}}
|
|
209
|
+
>
|
|
210
|
+
<span style={{ "font-size": "14px" }}>✓</span>
|
|
211
|
+
No anomalies detected
|
|
212
|
+
</div>
|
|
213
|
+
}
|
|
214
|
+
>
|
|
215
|
+
<div style={{ "margin-bottom": "4px", color: "#9ca3af", "font-size": "11px", "text-transform": "uppercase", "letter-spacing": "0.5px" }}>
|
|
216
|
+
Findings ({props.findings.length})
|
|
217
|
+
</div>
|
|
218
|
+
<For each={props.findings}>
|
|
219
|
+
{(finding) => (
|
|
220
|
+
<div
|
|
221
|
+
style={{
|
|
222
|
+
display: "flex",
|
|
223
|
+
"align-items": "flex-start",
|
|
224
|
+
gap: "8px",
|
|
225
|
+
padding: "6px 0",
|
|
226
|
+
"border-bottom": "1px solid rgba(255, 255, 255, 0.05)",
|
|
227
|
+
}}
|
|
228
|
+
>
|
|
229
|
+
<div
|
|
230
|
+
style={{
|
|
231
|
+
width: "8px",
|
|
232
|
+
height: "8px",
|
|
233
|
+
"border-radius": "50%",
|
|
234
|
+
background: SEVERITY_COLORS[finding.severity] || "#9ca3af",
|
|
235
|
+
"flex-shrink": "0",
|
|
236
|
+
"margin-top": "3px",
|
|
237
|
+
}}
|
|
238
|
+
/>
|
|
239
|
+
<div style={{ "min-width": "0" }}>
|
|
240
|
+
<div style={{ display: "flex", gap: "6px", "align-items": "center" }}>
|
|
241
|
+
<span style={{ "font-weight": "600", color: "#fff" }}>{finding.type}</span>
|
|
242
|
+
<span style={{ color: SEVERITY_COLORS[finding.severity], "font-size": "11px" }}>
|
|
243
|
+
{finding.severity}
|
|
244
|
+
</span>
|
|
245
|
+
</div>
|
|
246
|
+
<div
|
|
247
|
+
style={{
|
|
248
|
+
color: "#a1a1aa",
|
|
249
|
+
"margin-top": "2px",
|
|
250
|
+
"line-height": "1.4",
|
|
251
|
+
"word-break": "break-word",
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
{finding.description}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
)}
|
|
259
|
+
</For>
|
|
260
|
+
</Show>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
{/* Interactions */}
|
|
264
|
+
<Show when={props.interactions.length > 0}>
|
|
265
|
+
<div
|
|
266
|
+
style={{
|
|
267
|
+
padding: "8px 14px 12px",
|
|
268
|
+
"border-top": "1px solid rgba(255, 255, 255, 0.08)",
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<div style={{ "margin-bottom": "4px", color: "#9ca3af", "font-size": "11px", "text-transform": "uppercase", "letter-spacing": "0.5px" }}>
|
|
272
|
+
Interactions ({props.interactions.length})
|
|
273
|
+
</div>
|
|
274
|
+
<For each={props.interactions}>
|
|
275
|
+
{(evt) => (
|
|
276
|
+
<div style={{ padding: "3px 0", color: "#a1a1aa", "font-family": "monospace", "font-size": "11px" }}>
|
|
277
|
+
<span style={{ color: "#9ca3af" }}>{evt.t}ms</span>{" "}
|
|
278
|
+
<span style={{ color: "#60a5fa" }}>{evt.type}</span>{" "}
|
|
279
|
+
<span>{evt.target}</span>
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
</For>
|
|
283
|
+
</div>
|
|
284
|
+
</Show>
|
|
285
|
+
</div>
|
|
286
|
+
);
|
|
287
|
+
}
|