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",