feat(sdk-android): snapshot property delegate + feature-scoped files + screen fix (#34)

* rename web to inspect ui

* feat(sdk-android): add camelToSnakeCase conversion

* feat(sdk-android): add snapshot() property delegate with tests

* feat(folio): add feature-scoped sanderling snapshot objects

* refactor(folio): slim FolioApplication to snapshot object references

* fix(folio): rename route snapshot to screen, update spec.ts
This commit is contained in:
pj authored and GitHub committed 2026-04-22 19:55:45 +07:00
1 parent d5b6f6ccaf
commit 46abb28ed6
51 files changed
+297 -84

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
.action-list {
list-style: none;
padding: 0;
margin: 0;
font-family: var(--font-mono);
font-size: 11px;
}
.action-list-item {
display: block;
padding: 3px 6px;
border-left: 2px solid transparent;
cursor: pointer;
color: var(--text-primary);
outline: none;
}
.action-list-item:focus-visible {
outline: 1px solid var(--text-primary);
outline-offset: -1px;
}
.action-list-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto auto;
align-items: baseline;
column-gap: 4px;
}
.action-list-index {
text-align: left;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.action-list-body {
display: inline-flex;
gap: 6px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-list-verb {
color: var(--text-primary);
font-weight: 500;
}
.action-list-target {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.action-list-target-tag {
color: var(--text-primary);
}
.action-list-markers {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.action-list-elapsed {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
text-align: right;
padding-left: 8px;
}
.action-list-item:hover {
background: color-mix(in srgb, currentColor 6%, transparent);
}
.action-list-item:focus-visible {
background: color-mix(in srgb, currentColor 8%, transparent);
}
.action-list-item[data-active="true"] {
background: color-mix(in srgb, currentColor 10%, transparent);
border-left-color: currentColor;
}
.action-list-item[data-active="true"][data-violations="true"] {
border-left-color: var(--accent-violation);
}
.action-list-marker-violation {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-violation);
}
.action-list-marker-exception {
width: 8px;
height: 8px;
border-radius: 50%;
border: 1px solid var(--text-muted);
background: transparent;
}
.action-list-details {
display: grid;
grid-template-columns: 20px 1fr;
column-gap: 4px;
padding: 2px 0;
font-size: 10px;
}
.action-list-detail-row {
grid-column: 2;
display: grid;
grid-template-columns: 70px 1fr;
gap: 6px;
color: var(--text-muted);
}
.action-list-detail-label {
color: var(--text-muted);
}
.action-list-detail-value {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+299
View File
@@ -0,0 +1,299 @@
import { useEffect, useRef } from "react";
import type { KeyboardEvent } from "react";
import type { Step, StepSummary } from "../types";
import "./ActionList.css";
export interface ActionListProps {
steps: StepSummary[];
selectedIndex: number;
onSelect: (index: number) => void;
runStartMillis?: number;
selectedStep?: Step;
}
interface FormattedRow {
verb: string;
target: string;
targetIsTag: boolean;
}
const SELECTOR_PREFIXES = [
"id",
"text",
"textPrefix",
"desc",
"descPrefix",
"class",
"classPrefix",
"package",
];
function parseSelector(selector: string): { kind: string; value: string } | null {
const colonIndex = selector.indexOf(":");
if (colonIndex <= 0) {
return null;
}
const kind = selector.slice(0, colonIndex);
const value = selector.slice(colonIndex + 1);
if (!SELECTOR_PREFIXES.includes(kind)) {
return null;
}
return { kind, value };
}
function tagFromSelector(selector: string): string {
const parsed = parseSelector(selector);
if (!parsed) {
return selector;
}
if (parsed.kind.endsWith("Prefix")) {
return `${parsed.value}...`;
}
return parsed.value;
}
export function formatActionRow(step: StepSummary): FormattedRow {
const kind = step.action_kind;
const label = step.action_label ?? "";
if (!kind) {
if (step.screen) {
return { verb: "Observe", target: `@ ${step.screen}`, targetIsTag: false };
}
return { verb: "Observe", target: "", targetIsTag: false };
}
switch (kind) {
case "Tap": {
if (!label) {
return { verb: "Click", target: "", targetIsTag: false };
}
if (label.startsWith("(") && label.endsWith(")")) {
return { verb: "Click", target: label, targetIsTag: false };
}
if (parseSelector(label)) {
return { verb: "Click", target: tagFromSelector(label), targetIsTag: true };
}
return { verb: "Click", target: label, targetIsTag: false };
}
case "InputText":
return { verb: "Type", target: label, targetIsTag: false };
case "Swipe":
return { verb: "Swipe", target: label, targetIsTag: true };
case "PressKey":
return { verb: "Press", target: label, targetIsTag: true };
case "Wait":
return { verb: "Wait", target: label, targetIsTag: true };
default:
return { verb: kind, target: label, targetIsTag: false };
}
}
export function formatElapsed(millis: number): string {
const safe = Math.max(0, Math.floor(millis));
const totalSeconds = Math.floor(safe / 1000);
const mm = Math.floor(totalSeconds / 60);
const ss = totalSeconds % 60;
const ms = safe % 1000;
const pad2 = (n: number) => String(n).padStart(2, "0");
const pad3 = (n: number) => String(n).padStart(3, "0");
return `${pad2(mm)}:${pad2(ss)}.${pad3(ms)}`;
}
function renderTarget(target: string, isTag: boolean) {
if (!target) {
return null;
}
if (isTag) {
return <span className="action-list-target action-list-target-tag">{`<${target}/>`}</span>;
}
return <span className="action-list-target">{target}</span>;
}
function contentTextForStep(step: Step): string {
const action = step.action;
if (!action) {
return "";
}
if (typeof action.text === "string") {
return action.text;
}
return "";
}
function positionTextForStep(step: Step): string | null {
const action = step.action;
if (!action) {
return null;
}
if (action.tap_point) {
return `${action.tap_point.x.toFixed(1)}, ${action.tap_point.y.toFixed(1)}`;
}
if (typeof action.x === "number" && typeof action.y === "number") {
return `${action.x.toFixed(1)}, ${action.y.toFixed(1)}`;
}
if (
typeof action.from_x === "number" &&
typeof action.from_y === "number"
) {
return `${action.from_x.toFixed(1)}, ${action.from_y.toFixed(1)}`;
}
return null;
}
export default function ActionList({
steps,
selectedIndex,
onSelect,
runStartMillis,
selectedStep,
}: ActionListProps) {
const itemRefs = useRef<Map<number, HTMLLIElement>>(new Map());
const baseMillis =
runStartMillis ?? (steps[0] ? new Date(steps[0].timestamp).getTime() : 0);
useEffect(() => {
const node = itemRefs.current.get(selectedIndex);
if (node && typeof node.scrollIntoView === "function") {
node.scrollIntoView({ block: "nearest" });
}
}, [selectedIndex]);
const focusIndex = (index: number) => {
const node = itemRefs.current.get(index);
if (node && typeof node.focus === "function") {
node.focus();
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLLIElement>, index: number) => {
const position = steps.findIndex((entry) => entry.index === index);
switch (event.key) {
case "Enter":
case " ":
event.preventDefault();
onSelect(index);
return;
case "ArrowDown": {
if (position < 0 || position >= steps.length - 1) return;
event.preventDefault();
const next = steps[position + 1].index;
onSelect(next);
focusIndex(next);
return;
}
case "ArrowUp": {
if (position <= 0) return;
event.preventDefault();
const prev = steps[position - 1].index;
onSelect(prev);
focusIndex(prev);
return;
}
case "Home": {
event.preventDefault();
const first = steps[0]?.index;
if (first !== undefined) {
onSelect(first);
focusIndex(first);
}
return;
}
case "End": {
event.preventDefault();
const last = steps[steps.length - 1]?.index;
if (last !== undefined) {
onSelect(last);
focusIndex(last);
}
return;
}
}
};
return (
<ol className="action-list" role="listbox" aria-label="Steps">
{steps.map((step) => {
const isActive = step.index === selectedIndex;
const { verb, target, targetIsTag } = formatActionRow(step);
const elapsedMillis = new Date(step.timestamp).getTime() - baseMillis;
const elapsed = formatElapsed(elapsedMillis);
const spokenTarget = target
? targetIsTag
? `<${target}/>`
: target
: "";
const ariaLabel = spokenTarget
? `Step ${step.index} ${verb} ${spokenTarget}`
: `Step ${step.index} ${verb}`;
const showDetails = isActive && selectedStep && selectedStep.step === step.index;
const positionText = showDetails ? positionTextForStep(selectedStep) : null;
const contentText = showDetails ? contentTextForStep(selectedStep) : "";
return (
<li
key={step.index}
ref={(node) => {
if (node) {
itemRefs.current.set(step.index, node);
} else {
itemRefs.current.delete(step.index);
}
}}
className="action-list-item"
role="option"
tabIndex={isActive ? 0 : -1}
aria-selected={isActive}
aria-label={ariaLabel}
data-active={isActive ? "true" : "false"}
data-violations={step.has_violations ? "true" : "false"}
data-exceptions={step.has_exceptions ? "true" : "false"}
onClick={() => onSelect(step.index)}
onKeyDown={(event) => handleKeyDown(event, step.index)}
title={target || undefined}
>
<div className="action-list-row">
<span className="action-list-index">{step.index}.</span>
<span className="action-list-body">
<span className="action-list-verb">{verb}</span>
{renderTarget(target, targetIsTag)}
</span>
<span className="action-list-markers">
{step.has_exceptions ? (
<span
className="action-list-marker-exception"
aria-label="exceptions"
role="img"
/>
) : null}
{step.has_violations ? (
<span
className="action-list-marker-violation"
aria-label="violations"
role="img"
/>
) : null}
</span>
<span className="action-list-elapsed">{elapsed}</span>
</div>
{showDetails ? (
<div className="action-list-details">
<div className="action-list-detail-row">
<span className="action-list-detail-label">Position</span>
<span className="action-list-detail-value">
{positionText ?? ""}
</span>
</div>
<div className="action-list-detail-row">
<span className="action-list-detail-label">Content</span>
<span className="action-list-detail-value">
{contentText ? contentText : `""`}
</span>
</div>
</div>
) : null}
</li>
);
})}
</ol>
);
}
+98
View File
@@ -0,0 +1,98 @@
.exceptions-panel {
display: flex;
flex-direction: column;
gap: 8px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.exceptions-panel-toolbar {
display: flex;
justify-content: flex-end;
}
.exceptions-panel-jump {
font-family: var(--font-mono);
font-size: 11px;
}
.exceptions-panel-jump:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.exceptions-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.exceptions-row details {
border: 1px dashed var(--border-strong);
background: var(--surface);
padding: 6px 8px;
}
.exceptions-row details[open] {
border-color: var(--text-primary);
}
.exceptions-summary {
display: grid;
grid-template-columns: 8px auto auto minmax(0, 1fr) auto;
align-items: baseline;
gap: 6px;
cursor: pointer;
list-style: none;
outline: none;
}
.exceptions-summary::-webkit-details-marker {
display: none;
}
.exceptions-marker {
width: 8px;
height: 8px;
border: 1px dashed var(--text-primary);
background: transparent;
align-self: center;
}
.exceptions-class {
font-weight: 700;
white-space: nowrap;
}
.exceptions-colon {
color: var(--text-muted);
}
.exceptions-message {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exceptions-time {
color: var(--text-muted);
font-variant-numeric: tabular-nums;
margin-left: auto;
}
.exceptions-stack {
margin: 8px 0 0;
padding: 8px;
font-family: var(--font-mono);
font-size: 11px;
background: var(--background);
border: 1px solid var(--border);
max-height: 220px;
overflow-y: auto;
white-space: pre;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Exception } from "../types";
import "./ExceptionsPanel.css";
export interface ExceptionsPanelProps {
exceptions?: Exception[];
onJumpToFirstException: () => void;
hasFirstException: boolean;
}
function formatTimestamp(unixMillis: number): string {
const date = new Date(unixMillis);
const hh = String(date.getHours()).padStart(2, "0");
const mm = String(date.getMinutes()).padStart(2, "0");
const ss = String(date.getSeconds()).padStart(2, "0");
const ms = String(date.getMilliseconds()).padStart(3, "0");
return `${hh}:${mm}:${ss}.${ms}`;
}
export default function ExceptionsPanel({
exceptions,
onJumpToFirstException,
hasFirstException,
}: ExceptionsPanelProps) {
return (
<div className="exceptions-panel">
<div className="exceptions-panel-toolbar">
<button
type="button"
className="exceptions-panel-jump"
onClick={onJumpToFirstException}
disabled={!hasFirstException}
>
jump to first exception
</button>
</div>
{!exceptions || exceptions.length === 0 ? (
<div className="status-block">no exceptions</div>
) : (
<ol className="exceptions-list">
{exceptions.map((exception, index) => (
<li key={index} className="exceptions-row">
<details open={index === 0}>
<summary className="exceptions-summary">
<span className="exceptions-marker" aria-hidden="true" />
<span className="exceptions-class">{exception.class}</span>
{exception.message ? (
<>
<span className="exceptions-colon">:</span>
<span className="exceptions-message">{exception.message}</span>
</>
) : null}
{exception.unix_millis !== undefined ? (
<span className="exceptions-time">
{formatTimestamp(exception.unix_millis)}
</span>
) : null}
</summary>
{exception.stack_trace ? (
<pre className="exceptions-stack">{exception.stack_trace}</pre>
) : null}
</details>
</li>
))}
</ol>
)}
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
.metrics-chart {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-height: 0;
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
}
.metrics-lanes {
flex: 1 1 auto;
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
}
.metrics-lane {
flex: 1 1 0;
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 32px;
align-items: stretch;
min-height: 0;
border-bottom: 1px solid var(--border);
}
.metrics-lane:last-of-type {
border-bottom: none;
}
.metrics-lane-label {
display: flex;
align-items: center;
justify-content: center;
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 8px;
font-weight: 600;
letter-spacing: 0.12em;
border-right: 1px solid var(--border);
}
.metrics-lane-plot {
position: relative;
min-width: 0;
min-height: 0;
}
.metrics-lane-canvas {
position: absolute;
inset: 4px 0;
}
.metrics-lane-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.metrics-lane-path {
fill: none;
stroke: var(--text-primary);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.metrics-lane-dot {
position: absolute;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--text-primary);
transform: translate(-50%, -50%);
pointer-events: none;
}
.metrics-lane-dot[data-active="true"] {
width: 7px;
height: 7px;
}
.metrics-lane-tooltip {
position: absolute;
padding: 2px 6px;
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text-primary);
font-variant-numeric: tabular-nums;
font-size: 10px;
white-space: nowrap;
pointer-events: none;
z-index: 2;
}
.metrics-lane-tooltip[data-placement="above"] {
transform: translate(-50%, calc(-100% - 6px));
}
.metrics-lane-tooltip[data-placement="below"] {
transform: translate(-50%, 6px);
}
.metrics-lane-highlight {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--text-primary);
opacity: 0.7;
pointer-events: none;
}
.metrics-lane-ticks {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-start;
padding: 2px 6px;
border-left: 1px solid var(--border);
font-variant-numeric: tabular-nums;
font-size: 9px;
}
.metrics-hits {
position: absolute;
inset: 0 32px 0 18px;
display: block;
pointer-events: none;
}
.metrics-hit {
position: absolute;
top: 0;
bottom: 0;
background: transparent;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
pointer-events: auto;
}
.metrics-hit:hover {
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
}
.metrics-axis {
flex: 0 0 auto;
position: relative;
height: 20px;
margin: 0 32px 0 18px;
border-top: 1px solid var(--border);
}
.metrics-axis-label {
position: absolute;
top: 2px;
transform: translateX(-50%);
font-variant-numeric: tabular-nums;
font-size: 9px;
white-space: nowrap;
}
+293
View File
@@ -0,0 +1,293 @@
import { useState } from "react";
import type { Metrics } from "../types";
import "./MetricsChart.css";
export interface MetricsSample {
stepIndex: number;
timestamp: string;
metrics?: Metrics;
}
export interface MetricsChartProps {
samples: MetricsSample[];
selectedIndex: number;
onSelect: (stepIndex: number) => void;
runStartMillis?: number;
}
const MB = 1024 * 1024;
function formatHeap(bytes: number): string {
if (bytes <= 0) return "0B";
if (bytes < MB) return `${Math.round(bytes / 1024)}K`;
if (bytes < 1024 * MB) return `${Math.round(bytes / MB)}M`;
return `${(bytes / (1024 * MB)).toFixed(1)}G`;
}
function formatTime(millis: number): string {
const safe = Math.max(0, Math.floor(millis));
const seconds = Math.floor(safe / 1000);
const mm = String(Math.floor(seconds / 60)).padStart(2, "0");
const ss = String(seconds % 60).padStart(2, "0");
return `${mm}:${ss}`;
}
function fractionFor(index: number, count: number): number {
if (count <= 1) return 0.5;
return index / (count - 1);
}
function buildPath(
samples: MetricsSample[],
getValue: (sample: MetricsSample) => number | undefined,
ceiling: number,
): string {
const segments: string[] = [];
let pendingCommand = "M";
samples.forEach((sample, index) => {
const value = getValue(sample);
if (value === undefined) {
pendingCommand = "M";
return;
}
const x = fractionFor(index, samples.length);
const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1);
const y = 1 - ratio;
segments.push(`${pendingCommand}${x.toFixed(4)},${y.toFixed(4)}`);
pendingCommand = "L";
});
return segments.join(" ");
}
interface LaneDot {
key: number | string;
x: number;
y: number;
}
interface LaneHover {
fraction: number;
y: number;
label: string;
}
interface LaneProps {
label: string;
path: string;
dots: LaneDot[];
topTickLabel: string;
bottomTickLabel: string;
selectedFraction: number | null;
hover: LaneHover | null;
}
function Lane({
label,
path,
dots,
topTickLabel,
bottomTickLabel,
selectedFraction,
hover,
}: LaneProps) {
return (
<div className="metrics-lane">
<div className="metrics-lane-label">{label}</div>
<div className="metrics-lane-plot">
{selectedFraction !== null ? (
<div
className="metrics-lane-highlight"
style={{ left: `${selectedFraction * 100}%` }}
/>
) : null}
<div className="metrics-lane-canvas">
<svg
className="metrics-lane-svg"
viewBox="0 0 1 1"
preserveAspectRatio="none"
aria-hidden="true"
>
{path ? <path d={path} className="metrics-lane-path" /> : null}
</svg>
{dots.map((dot) => (
<div
key={dot.key}
className="metrics-lane-dot"
style={{ left: `${dot.x * 100}%`, top: `${dot.y * 100}%` }}
data-active={hover !== null && Math.abs(hover.fraction - dot.x) < 1e-6 ? "true" : undefined}
/>
))}
{hover ? (
<div
className="metrics-lane-tooltip"
data-placement={hover.y < 0.35 ? "below" : "above"}
style={{ left: `${hover.fraction * 100}%`, top: `${hover.y * 100}%` }}
>
{hover.label}
</div>
) : null}
</div>
</div>
<div className="metrics-lane-ticks">
<span>{topTickLabel}</span>
<span>{bottomTickLabel}</span>
</div>
</div>
);
}
function buildDots(
samples: MetricsSample[],
getValue: (sample: MetricsSample) => number | undefined,
ceiling: number,
): LaneDot[] {
const dots: LaneDot[] = [];
samples.forEach((sample, index) => {
const value = getValue(sample);
if (value === undefined) return;
const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1);
dots.push({
key: sample.stepIndex,
x: fractionFor(index, samples.length),
y: 1 - ratio,
});
});
return dots;
}
export default function MetricsChart({
samples,
selectedIndex,
onSelect,
runStartMillis,
}: MetricsChartProps) {
const [hoveredColumn, setHoveredColumn] = useState<number | null>(null);
if (samples.length === 0 || !samples.some((sample) => sample.metrics !== undefined)) {
return <div className="status-block">no metrics</div>;
}
const heapTop = samples.reduce(
(max, sample) => Math.max(max, sample.metrics?.heap_bytes ?? 0),
0,
);
const cpuObservedMax = samples.reduce(
(max, sample) => Math.max(max, sample.metrics?.cpu_percent ?? 0),
0,
);
const cpuTop = Math.max(100, cpuObservedMax);
const heapPath = buildPath(samples, (sample) => sample.metrics?.heap_bytes, heapTop);
const cpuPath = buildPath(samples, (sample) => sample.metrics?.cpu_percent, cpuTop);
const heapDots = buildDots(samples, (sample) => sample.metrics?.heap_bytes, heapTop);
const cpuDots = buildDots(samples, (sample) => sample.metrics?.cpu_percent, cpuTop);
const baseMillis = runStartMillis ?? new Date(samples[0].timestamp).getTime();
const axisCount = Math.min(5, samples.length);
const axisTicks = Array.from({ length: axisCount }, (_, i) => {
const idx =
axisCount === 1
? 0
: Math.round((i / (axisCount - 1)) * (samples.length - 1));
const sample = samples[idx];
const sampleMillis = new Date(sample.timestamp).getTime();
const elapsed =
Number.isFinite(sampleMillis) && Number.isFinite(baseMillis)
? sampleMillis - baseMillis
: NaN;
return {
key: sample.stepIndex,
fraction: fractionFor(idx, samples.length),
label: Number.isFinite(elapsed) ? formatTime(elapsed) : String(sample.stepIndex),
};
});
const selectedColumn = samples.findIndex((sample) => sample.stepIndex === selectedIndex);
const selectedFraction =
selectedColumn >= 0 ? fractionFor(selectedColumn, samples.length) : null;
const hoveredSample =
hoveredColumn !== null && hoveredColumn >= 0 && hoveredColumn < samples.length
? samples[hoveredColumn]
: null;
const hoverFraction =
hoveredColumn !== null ? fractionFor(hoveredColumn, samples.length) : null;
const heapHover: LaneHover | null =
hoveredSample && hoverFraction !== null && hoveredSample.metrics?.heap_bytes !== undefined
? {
fraction: hoverFraction,
y: 1 - (heapTop === 0 ? 0 : Math.min(hoveredSample.metrics.heap_bytes / heapTop, 1)),
label: formatHeap(hoveredSample.metrics.heap_bytes),
}
: null;
const cpuHover: LaneHover | null =
hoveredSample && hoverFraction !== null && hoveredSample.metrics?.cpu_percent !== undefined
? {
fraction: hoverFraction,
y: 1 - (cpuTop === 0 ? 0 : Math.min(hoveredSample.metrics.cpu_percent / cpuTop, 1)),
label: `${hoveredSample.metrics.cpu_percent.toFixed(1)}%`,
}
: null;
return (
<div className="metrics-chart">
<div className="metrics-lanes">
<Lane
label="HEAP"
path={heapPath}
dots={heapDots}
topTickLabel={formatHeap(heapTop)}
bottomTickLabel="0B"
selectedFraction={selectedFraction}
hover={heapHover}
/>
<Lane
label="CPU"
path={cpuPath}
dots={cpuDots}
topTickLabel={`${Math.round(cpuTop)}%`}
bottomTickLabel="0%"
selectedFraction={selectedFraction}
hover={cpuHover}
/>
<div className="metrics-hits" role="presentation">
{samples.map((sample, index) => {
const left = (() => {
if (samples.length === 1) return 0;
const half = 0.5 / (samples.length - 1);
return Math.max(0, fractionFor(index, samples.length) - half);
})();
const width = samples.length === 1 ? 1 : 1 / (samples.length - 1);
return (
<button
key={sample.stepIndex}
type="button"
className="metrics-hit"
style={{ left: `${left * 100}%`, width: `${width * 100}%` }}
onClick={() => onSelect(sample.stepIndex)}
onPointerEnter={() => setHoveredColumn(index)}
onPointerLeave={() =>
setHoveredColumn((current) => (current === index ? null : current))
}
aria-label={`select step ${sample.stepIndex}`}
/>
);
})}
</div>
</div>
<div className="metrics-axis">
{axisTicks.map((tick) => (
<span
key={tick.key}
className="metrics-axis-label"
style={{ left: `${tick.fraction * 100}%` }}
>
{tick.label}
</span>
))}
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
.screenshot-frame {
position: relative;
width: 100%;
max-width: 100%;
max-height: 100%;
margin: 0 auto;
background: var(--surface);
overflow: hidden;
}
.screenshot-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.screenshot-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.screenshot-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 240px;
background: var(--surface);
border: 1px dashed var(--border-strong);
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useId, useRef, useState } from "react";
import type { Action } from "../types";
import "./Screenshot.css";
export interface ScreenshotProps {
src?: string;
action?: Action;
deviceWidth?: number;
deviceHeight?: number;
}
const DEFAULT_WIDTH = 1080;
const DEFAULT_HEIGHT = 1920;
export default function Screenshot({ src, action, deviceWidth, deviceHeight }: ScreenshotProps) {
const [naturalSize, setNaturalSize] = useState<{ width: number; height: number } | null>(null);
const [loadFailed, setLoadFailed] = useState(false);
const previousSrc = useRef<string | undefined>(src);
const reactId = useId();
const maskId = `screenshot-spotlight-${reactId.replace(/:/g, "")}`;
useEffect(() => {
if (previousSrc.current !== src) {
previousSrc.current = src;
setLoadFailed(false);
setNaturalSize(null);
}
}, [src]);
if (!src || loadFailed) {
return (
<div className="screenshot-placeholder" data-testid="screenshot-placeholder">
no screenshot
</div>
);
}
const width = deviceWidth ?? naturalSize?.width ?? DEFAULT_WIDTH;
const height = deviceHeight ?? naturalSize?.height ?? DEFAULT_HEIGHT;
const stroke = Math.max(2, width / 200);
const tapRadius = Math.max(12, width / 60);
const arrowHeadSize = Math.max(18, width / 40);
const spotlightRadius = tapRadius * 3;
const bounds = action?.resolved_bounds;
const tap = action?.tap_point;
const isSwipe =
action?.kind === "Swipe" &&
action.from_x !== undefined &&
action.from_y !== undefined &&
action.to_x !== undefined &&
action.to_y !== undefined;
return (
<div
className="screenshot-frame"
style={{ aspectRatio: `${width} / ${height}` }}
>
<img
className="screenshot-image"
src={src}
alt="device screenshot"
onLoad={(event) => {
const img = event.currentTarget;
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setNaturalSize({ width: img.naturalWidth, height: img.naturalHeight });
}
}}
onError={() => setLoadFailed(true)}
/>
<svg
className="screenshot-overlay"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="xMidYMid meet"
aria-hidden="true"
>
{tap && (
<>
<defs>
<radialGradient
id={`${maskId}-gradient`}
cx={tap.x}
cy={tap.y}
r={spotlightRadius}
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="black" />
<stop offset="55%" stopColor="black" />
<stop offset="100%" stopColor="white" />
</radialGradient>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width={width} height={height}>
<rect x="0" y="0" width={width} height={height} fill={`url(#${maskId}-gradient)`} />
</mask>
</defs>
<rect
data-testid="screenshot-spotlight"
x="0"
y="0"
width={width}
height={height}
fill="black"
opacity="0.5"
mask={`url(#${maskId})`}
/>
</>
)}
{bounds && (
<rect
x={bounds.x}
y={bounds.y}
width={bounds.width}
height={bounds.height}
fill="none"
stroke="var(--accent-violation)"
strokeWidth={stroke}
/>
)}
{isSwipe && (
<SwipeArrow
fromX={action!.from_x!}
fromY={action!.from_y!}
toX={action!.to_x!}
toY={action!.to_y!}
stroke={stroke}
headSize={arrowHeadSize}
/>
)}
</svg>
</div>
);
}
interface SwipeArrowProps {
fromX: number;
fromY: number;
toX: number;
toY: number;
stroke: number;
headSize: number;
}
function SwipeArrow({ fromX, fromY, toX, toY, stroke, headSize }: SwipeArrowProps) {
const angle = (Math.atan2(toY - fromY, toX - fromX) * 180) / Math.PI;
const half = headSize / 2;
const points = `${toX},${toY} ${toX - headSize},${toY - half} ${toX - headSize},${toY + half}`;
return (
<g>
<line
x1={fromX}
y1={fromY}
x2={toX}
y2={toY}
stroke="var(--text-muted)"
strokeWidth={stroke}
strokeLinecap="round"
/>
<polygon
points={points}
fill="var(--text-muted)"
transform={`rotate(${angle} ${toX} ${toY})`}
/>
</g>
);
}
+44
View File
@@ -0,0 +1,44 @@
.snapshot-table {
margin: 0;
padding: 0;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
}
.snapshot-row {
display: grid;
grid-template-columns: minmax(0, 160px) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
padding: 4px 8px;
border-left: 2px solid transparent;
}
.snapshot-row[data-changed="true"] {
border-left-color: var(--border-strong);
}
.snapshot-path {
margin: 0;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.snapshot-value {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: right;
font-variant-numeric: tabular-nums;
}
.snapshot-row[data-long-value="true"] .snapshot-value {
white-space: pre-wrap;
max-height: 6em;
overflow-y: auto;
text-align: left;
}
+164
View File
@@ -0,0 +1,164 @@
import "./SnapshotTable.css";
export interface SnapshotTableProps {
snapshots?: Record<string, unknown>;
previousSnapshots?: Record<string, unknown>;
}
interface Row {
path: string;
value: unknown;
}
const INLINE_ARRAY_LIMIT = 2;
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.getPrototypeOf(value) === Object.prototype
);
}
function flatten(input: Record<string, unknown>): Row[] {
const rows: Row[] = [];
const walk = (value: unknown, path: string) => {
if (isPlainObject(value)) {
const keys = Object.keys(value).sort();
if (keys.length === 0) {
rows.push({ path, value: {} });
return;
}
for (const key of keys) {
const nextPath = path === "" ? key : `${path}.${key}`;
walk(value[key], nextPath);
}
return;
}
if (Array.isArray(value)) {
if (value.length <= INLINE_ARRAY_LIMIT) {
rows.push({ path, value });
return;
}
for (let i = 0; i < value.length; i++) {
walk(value[i], `${path}[${i}]`);
}
return;
}
rows.push({ path, value });
};
for (const key of Object.keys(input).sort()) {
walk(input[key], key);
}
return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
}
function getAtPath(source: Record<string, unknown> | undefined, path: string): unknown {
if (!source) {
return undefined;
}
if (Object.prototype.hasOwnProperty.call(source, path)) {
return source[path];
}
const segments = path.split(/\.|\[(\d+)\]/).filter((segment) => segment !== undefined && segment !== "");
let current: unknown = source;
for (const segment of segments) {
if (current === null || current === undefined) {
return undefined;
}
if (Array.isArray(current)) {
const index = Number(segment);
if (Number.isNaN(index)) {
return undefined;
}
current = current[index];
continue;
}
if (typeof current === "object") {
current = (current as Record<string, unknown>)[segment];
continue;
}
return undefined;
}
return current;
}
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalize);
}
if (isPlainObject(value)) {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value).sort()) {
out[key] = canonicalize(value[key]);
}
return out;
}
return value;
}
function stableStringify(value: unknown): string {
return JSON.stringify(canonicalize(value));
}
function formatValue(value: unknown): string {
if (value === null) {
return "null";
}
if (value === undefined) {
return "undefined";
}
if (typeof value === "string") {
return JSON.stringify(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return stableStringify(value);
}
export default function SnapshotTable({ snapshots, previousSnapshots }: SnapshotTableProps) {
if (!snapshots || Object.keys(snapshots).length === 0) {
return <div className="status-block">no snapshots</div>;
}
const rows = flatten(snapshots);
const hasPrevious = previousSnapshots !== undefined;
const previousRows = hasPrevious ? flatten(previousSnapshots) : [];
const previousByPath = new Map(previousRows.map((row) => [row.path, row.value]));
return (
<dl className="snapshot-table">
{rows.map((row) => {
const formatted = formatValue(row.value);
let changed = false;
let previousFormatted: string | undefined;
if (hasPrevious) {
const prevValue = previousByPath.has(row.path)
? previousByPath.get(row.path)
: getAtPath(previousSnapshots, row.path);
if (stableStringify(prevValue) !== stableStringify(row.value)) {
changed = true;
previousFormatted = formatValue(prevValue);
}
}
const rowProps: Record<string, string> = {};
if (changed) {
rowProps["data-changed"] = "true";
rowProps.title = `was: ${previousFormatted}`;
}
return (
<div key={row.path} className="snapshot-row" {...rowProps}>
<dt className="snapshot-path" title={row.path}>
{row.path}
</dt>
<dd className="snapshot-value" title={formatted}>
{formatted}
</dd>
</div>
);
})}
</dl>
);
}
+40
View File
@@ -0,0 +1,40 @@
.timeline {
display: block;
width: 100%;
height: auto;
font-family: var(--font-mono);
}
.timeline-label {
font-size: 11px;
fill: var(--text-muted);
}
.timeline-cell {
cursor: pointer;
outline: none;
}
.timeline-cell:hover {
stroke: var(--text-primary);
stroke-width: 1;
}
.timeline-cell:focus-visible {
stroke: var(--text-primary);
stroke-width: 1;
}
.timeline-action {
cursor: pointer;
outline: none;
}
.timeline-action:hover,
.timeline-action:focus-visible {
fill: var(--text-primary);
}
.timeline-highlight {
pointer-events: none;
}
+142
View File
@@ -0,0 +1,142 @@
import type { KeyboardEvent } from "react";
import type { StepSummary } from "../types";
import "./Timeline.css";
export type LaneStatus = "violated" | "pending" | "holds";
export interface PropertyLane {
name: string;
statuses: LaneStatus[];
}
export interface TimelineProps {
steps: StepSummary[];
lanes: PropertyLane[];
selectedIndex: number;
onSelect: (index: number) => void;
}
const LANE_HEIGHT = 22;
const LABEL_WIDTH = 140;
const CELL_GAP = 2;
const CHART_WIDTH = 1000;
const STATUS_FILL: Record<LaneStatus, string> = {
violated: "var(--accent-violation)",
pending: "color-mix(in srgb, var(--text-primary) 30%, transparent)",
holds: "color-mix(in srgb, var(--text-primary) 10%, transparent)",
};
export default function Timeline({ steps, lanes, selectedIndex, onSelect }: TimelineProps) {
if (steps.length === 0 || lanes.length === 0) {
return <div className="status-block">no timeline data</div>;
}
const cellAreaWidth = CHART_WIDTH - LABEL_WIDTH;
const cellWidth = cellAreaWidth / steps.length;
const totalRows = lanes.length + 1;
const height = totalRows * LANE_HEIGHT;
const handleKeyDown = (event: KeyboardEvent<SVGRectElement>, stepIndex: number) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(stepIndex);
}
};
const selectedColumn = steps.findIndex((step) => step.index === selectedIndex);
const highlightX =
selectedColumn >= 0 ? LABEL_WIDTH + selectedColumn * cellWidth + cellWidth / 2 : null;
return (
<svg
className="timeline"
viewBox={`0 0 ${CHART_WIDTH} ${height}`}
preserveAspectRatio="none"
role="img"
aria-label="property timeline"
>
{lanes.map((lane, laneIndex) => {
const rowY = laneIndex * LANE_HEIGHT;
return (
<g key={lane.name} className="timeline-lane" data-lane={lane.name}>
<text
className="timeline-label"
x={LABEL_WIDTH - 8}
y={rowY + LANE_HEIGHT / 2}
dominantBaseline="middle"
textAnchor="end"
>
{lane.name}
</text>
{steps.map((step, stepCol) => {
const status = lane.statuses[stepCol] ?? "pending";
const x = LABEL_WIDTH + stepCol * cellWidth + CELL_GAP / 2;
const w = Math.max(cellWidth - CELL_GAP, 1);
const y = rowY + 3;
const h = LANE_HEIGHT - 6;
return (
<rect
key={step.index}
className="timeline-cell"
data-status={status}
data-step={step.index}
x={x}
y={y}
width={w}
height={h}
fill={STATUS_FILL[status]}
tabIndex={0}
onClick={() => onSelect(step.index)}
onKeyDown={(event) => handleKeyDown(event, step.index)}
>
<title>{`step ${step.index}: ${status}`}</title>
</rect>
);
})}
</g>
);
})}
<g className="timeline-actions" data-row="actions">
{steps.map((step, stepCol) => {
const cx = LABEL_WIDTH + stepCol * cellWidth + cellWidth / 2;
const cy = lanes.length * LANE_HEIGHT + LANE_HEIGHT / 2;
return (
<circle
key={step.index}
className="timeline-action"
data-step={step.index}
cx={cx}
cy={cy}
r={3}
fill="var(--text-muted)"
tabIndex={0}
onClick={() => onSelect(step.index)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect(step.index);
}
}}
>
<title>{`step ${step.index}`}</title>
</circle>
);
})}
</g>
{highlightX !== null ? (
<line
className="timeline-highlight"
data-selected="true"
x1={highlightX}
x2={highlightX}
y1={0}
y2={height}
stroke="var(--text-primary)"
strokeWidth={2}
pointerEvents="none"
/>
) : null}
</svg>
);
}
+117
View File
@@ -0,0 +1,117 @@
.violations-panel {
display: flex;
flex-direction: column;
gap: 8px;
font-family: var(--font-mono);
color: var(--text-primary);
}
.violations-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.violations-panel-title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin: 0;
}
.violations-panel-jump {
font-family: var(--font-mono);
font-size: 11px;
text-transform: lowercase;
padding: 4px 8px;
background: var(--surface);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 3px;
cursor: pointer;
}
.violations-panel-jump:hover:not(:disabled) {
border-color: var(--border-strong);
}
.violations-panel-jump:disabled {
color: var(--text-muted);
cursor: not-allowed;
opacity: 0.6;
}
.violations-panel-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.violations-panel-row {
border: 1px solid var(--border);
border-radius: 3px;
padding: 8px;
background: var(--surface-elevated);
}
.violations-panel-row[data-status="violated"] {
border-color: var(--accent-violation);
}
.violations-panel-row-head {
display: flex;
align-items: center;
gap: 8px;
}
.violations-panel-badge {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-muted);
}
.violations-panel-badge[data-status="violated"] {
color: var(--accent-violation);
border-color: var(--accent-violation);
background: transparent;
}
.violations-panel-badge[data-status="holds"] {
color: var(--text-muted);
opacity: 0.7;
}
.violations-panel-name {
font-weight: 700;
font-size: 13px;
}
.violations-panel-residual {
margin-top: 6px;
font-size: 12px;
}
.violations-panel-residual summary {
cursor: pointer;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 2px 0;
user-select: none;
}
.violations-panel-residual[open] summary {
margin-bottom: 6px;
}
+111
View File
@@ -0,0 +1,111 @@
import { useMemo } from "react";
import type { ResidualNode } from "../types";
import ResidualNodeView from "../components/ResidualNode";
import "./ViolationsPanel.css";
export interface ViolationsPanelProps {
propertyNames: string[];
violations: string[];
residuals?: Record<string, ResidualNode>;
onJumpToFirstViolation: () => void;
hasFirstViolation: boolean;
/** When true, only render violated rows and hide the header button row. */
violationsOnly?: boolean;
}
type Status = "violated" | "pending" | "holds";
const STATUS_ORDER: Record<Status, number> = {
violated: 0,
pending: 1,
holds: 2,
};
function statusFor(
name: string,
violations: Set<string>,
residuals?: Record<string, ResidualNode>,
): Status {
if (violations.has(name)) {
return "violated";
}
const residual = residuals?.[name];
if (residual && residual.op === "true") {
return "holds";
}
return "pending";
}
export default function ViolationsPanel({
propertyNames,
violations,
residuals,
onJumpToFirstViolation,
hasFirstViolation,
violationsOnly = false,
}: ViolationsPanelProps) {
const violationSet = useMemo(() => new Set(violations), [violations]);
const rows = useMemo(() => {
const sorted = [...propertyNames].sort((a, b) => a.localeCompare(b));
const all = sorted.map((name) => ({
name,
status: statusFor(name, violationSet, residuals),
}));
const filtered = violationsOnly ? all.filter((row) => row.status === "violated") : all;
return filtered.sort((a, b) => {
const groupDelta = STATUS_ORDER[a.status] - STATUS_ORDER[b.status];
if (groupDelta !== 0) {
return groupDelta;
}
return a.name.localeCompare(b.name);
});
}, [propertyNames, violationSet, residuals, violationsOnly]);
if (violationsOnly && rows.length === 0) {
return <div className="status-block">no violations</div>;
}
return (
<section className="violations-panel">
{violationsOnly ? null : (
<header className="violations-panel-header">
<h2 className="violations-panel-title">properties</h2>
<button
type="button"
className="violations-panel-jump"
onClick={onJumpToFirstViolation}
disabled={!hasFirstViolation}
>
jump to first violation
</button>
</header>
)}
<ul className="violations-panel-list">
{rows.map(({ name, status }) => {
const residual = residuals?.[name];
return (
<li key={name} className="violations-panel-row" data-status={status}>
<div className="violations-panel-row-head">
<span
className="violations-panel-badge"
data-status={status}
aria-label={`status ${status}`}
>
{status}
</span>
<span className="violations-panel-name">{name}</span>
</div>
{residual ? (
<details className="violations-panel-residual">
<summary>residual</summary>
<ResidualNodeView node={residual} />
</details>
) : null}
</li>
);
})}
</ul>
</section>
);
}