diff --git a/replay-ui/src/__tests__/device-space.test.ts b/replay-ui/src/__tests__/device-space.test.ts new file mode 100644 index 0000000..91b7d04 --- /dev/null +++ b/replay-ui/src/__tests__/device-space.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "bun:test"; +import { deviceSpaceOf } from "../lib/device-space"; +import type { Hierarchy } from "../types"; + +function hierarchyWithRoot(right: number, bottom: number): Hierarchy { + return { + elements: [ + { + bounds: { left: 0, top: 0, right, bottom }, + }, + ], + }; +} + +describe("deviceSpaceOf", () => { + it("returns the root bounds extent", () => { + expect(deviceSpaceOf(hierarchyWithRoot(393, 852))).toEqual({ + width: 393, + height: 852, + }); + }); + + it("returns undefined without a hierarchy", () => { + expect(deviceSpaceOf(undefined)).toBeUndefined(); + }); + + it("returns undefined for an empty hierarchy", () => { + expect(deviceSpaceOf({ elements: [] })).toBeUndefined(); + }); + + it("returns undefined for non-positive root bounds", () => { + expect(deviceSpaceOf(hierarchyWithRoot(0, 852))).toBeUndefined(); + expect(deviceSpaceOf(hierarchyWithRoot(393, 0))).toBeUndefined(); + expect(deviceSpaceOf(hierarchyWithRoot(-1, -1))).toBeUndefined(); + }); +}); diff --git a/replay-ui/src/lib/device-space.ts b/replay-ui/src/lib/device-space.ts new file mode 100644 index 0000000..e94b511 --- /dev/null +++ b/replay-ui/src/lib/device-space.ts @@ -0,0 +1,16 @@ +import type { Hierarchy } from "../types"; + +// Tap points and resolved bounds in the trace share the hierarchy root's +// coordinate space (iOS points, Android pixels, web CSS px). Screenshots may +// be scaled (iOS 3x, web DPR>1), so the overlay viewBox must come from the +// root bounds, not the image's natural pixel size. +export function deviceSpaceOf( + hierarchy?: Hierarchy, +): { width: number; height: number } | undefined { + const root = hierarchy?.elements?.[0]; + if (!root) return undefined; + const width = root.bounds.right; + const height = root.bounds.bottom; + if (!(width > 0) || !(height > 0)) return undefined; + return { width, height }; +} diff --git a/replay-ui/src/routes/RunDetail.tsx b/replay-ui/src/routes/RunDetail.tsx index eab5ca9..bade4e5 100644 --- a/replay-ui/src/routes/RunDetail.tsx +++ b/replay-ui/src/routes/RunDetail.tsx @@ -14,6 +14,7 @@ import Tabs, { type TabDefinition } from "../components/Tabs"; import { useStep } from "../hooks/useStep"; import { useKeyboardNav } from "../hooks/useKeyboardNav"; import { useTheme } from "../hooks/useTheme"; +import { deviceSpaceOf } from "../lib/device-space"; function basename(specPath: string): string { const index = specPath.lastIndexOf("/"); @@ -129,12 +130,21 @@ export default function RunDetail() { const witnessesBefore = currentStep?.witnesses; const witnessesAfter = nextStep?.witnesses ?? witnessesBefore; const exceptionsForStep = currentStep?.exceptions; + const beforeSpace = deviceSpaceOf(currentStep?.hierarchy); + const afterSpace = deviceSpaceOf(nextStep?.hierarchy ?? currentStep?.hierarchy); const beforeTabs: TabDefinition[] = [ { id: "screenshot", label: "Screenshot", - content: , + content: ( + + ), }, { id: "snapshots", @@ -194,7 +204,14 @@ export default function RunDetail() { { id: "screenshot", label: "Screenshot", - content: , + content: ( + + ), }, { id: "snapshots",