From d84dc74b0bb2c2f2408fae24d1d29dbab7f70e1e Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 00:31:10 +0530 Subject: [PATCH] fix(replay-ui): size overlay viewBox from hierarchy root bounds Tap points are recorded in the hierarchy's coordinate space (iOS points, Android pixels, web CSS px) while screenshots are device pixels, so the overlay rendered at 1/3 position on iOS 3x screens. Derive the viewBox from the root element bounds; natural image size stays the fallback. --- replay-ui/src/__tests__/device-space.test.ts | 36 ++++++++++++++++++++ replay-ui/src/lib/device-space.ts | 16 +++++++++ replay-ui/src/routes/RunDetail.tsx | 21 ++++++++++-- 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 replay-ui/src/__tests__/device-space.test.ts create mode 100644 replay-ui/src/lib/device-space.ts 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",