From 1e4ba65c6d75a6b061a1d54141f2f105b5908a96 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 17 Sep 2026 10:15:15 +0200 Subject: [PATCH] fix(website): render phone mockups correctly in Safari (#4990) * fix(website): render phone mockups correctly in Safari Replace trigonometric CSS scaling with measured numeric ratios after Safari placed phone content outside its clipped frames. Scale desktop mockup corners with the frame so they remain proportional on mobile. * test(website): expose the website browser test suite * test(website): run mockup regressions in CI --- .github/workflows/ci.yml | 6 ++ packages/website/e2e/mockups.spec.ts | 61 +++++++++++++++++++ packages/website/package.json | 3 +- packages/website/playwright.config.ts | 20 ++++++ .../website/src/components/hero-mockup.tsx | 36 +++-------- .../website/src/components/mockup/index.ts | 1 + .../src/components/mockup/mobile/frame.tsx | 18 ++---- .../src/components/mockup/scaled-mockup.tsx | 61 +++++++++++++++++++ 8 files changed, 165 insertions(+), 41 deletions(-) create mode 100644 packages/website/e2e/mockups.spec.ts create mode 100644 packages/website/playwright.config.ts create mode 100644 packages/website/src/components/mockup/scaled-mockup.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49accfc403..7f8d542370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,12 @@ jobs: - name: Typecheck all packages run: npm run typecheck + - name: Install website test browsers + run: npx playwright install --with-deps chromium webkit + + - name: Verify website mockups + run: npm run test:e2e --workspace=@getpaseo/website + - name: Verify public package contents run: | npm pack --dry-run --ignore-scripts --workspace=@getpaseo/protocol diff --git a/packages/website/e2e/mockups.spec.ts b/packages/website/e2e/mockups.spec.ts new file mode 100644 index 0000000000..8b0ba65857 --- /dev/null +++ b/packages/website/e2e/mockups.spec.ts @@ -0,0 +1,61 @@ +import { expect, test, type Locator, type Page } from "playwright/test"; + +const desktopName = + "Paseo desktop app with coding agents, a conversation, and a code diff open side by side"; + +async function openHomepage(page: Page) { + await page.setViewportSize({ width: 1512, height: 930 }); + await page.goto("/"); + await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible(); +} + +async function viewPhones(page: Page) { + await page.getByRole("img", { name: "Paseo agent chat", exact: true }).scrollIntoViewIfNeeded(); +} + +async function expectScreenContentInsidePhone(page: Page, name: string, time: string) { + const phone = page.getByRole("img", { name, exact: true }); + const clock = phone.getByText(time, { exact: true }); + await expect(clock).toBeVisible(); + await expect(async () => { + const frame = await phone.boundingBox(); + const content = await clock.boundingBox(); + expect(frame).not.toBeNull(); + expect(content).not.toBeNull(); + if (!frame || !content) throw new Error("Phone screen is not rendered"); + expect(content.x).toBeGreaterThanOrEqual(frame.x); + expect(content.y).toBeGreaterThanOrEqual(frame.y); + expect(content.x + content.width).toBeLessThanOrEqual(frame.x + frame.width); + expect(content.y + content.height).toBeLessThanOrEqual(frame.y + frame.height); + }).toPass({ timeout: 5000 }); +} + +async function cornerProportion(mockup: Locator) { + return mockup.evaluate((element) => { + const radius = getComputedStyle(element).borderTopLeftRadius.split(" ")[0]; + if (radius.endsWith("%")) return Number.parseFloat(radius) / 100; + return Number.parseFloat(radius) / element.getBoundingClientRect().width; + }); +} + +async function resizeToPhone(page: Page) { + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("img", { name: desktopName, exact: true }).scrollIntoViewIfNeeded(); +} + +test("the three phone mockups show their screen content", async ({ page }) => { + await openHomepage(page); + await viewPhones(page); + await expectScreenContentInsidePhone(page, "Paseo workspace drawer", "18:54"); + await expectScreenContentInsidePhone(page, "Paseo agent chat", "18:53"); + await expectScreenContentInsidePhone(page, "Paseo diff view", "18:55"); +}); + +test("desktop mockup corners shrink in proportion on a phone", async ({ page }) => { + await openHomepage(page); + const mockup = page.getByRole("img", { name: desktopName, exact: true }); + const desktopCorner = await cornerProportion(mockup); + expect(desktopCorner).toBeGreaterThan(0); + await resizeToPhone(page); + await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3); +}); diff --git a/packages/website/package.json b/packages/website/package.json index 0545ce6850..514e9fb2f4 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "typecheck": "tsgo --noEmit", - "deploy": "npm run build && wrangler deploy" + "deploy": "npm run build && wrangler deploy", + "test:e2e": "playwright test" }, "dependencies": { "@cloudflare/vite-plugin": "^1.29.1", diff --git a/packages/website/playwright.config.ts b/packages/website/playwright.config.ts new file mode 100644 index 0000000000..011a34e6ad --- /dev/null +++ b/packages/website/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from "playwright/test"; + +const baseURL = process.env.WEBSITE_TEST_URL ?? "http://127.0.0.1:8187"; + +export default defineConfig({ + testDir: "./e2e", + workers: 1, + use: { baseURL, screenshot: "only-on-failure", trace: "retain-on-failure" }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + ], + webServer: process.env.WEBSITE_TEST_URL + ? undefined + : { + command: "npm run dev -- --host 127.0.0.1 --port 8187 --strictPort", + url: baseURL, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/packages/website/src/components/hero-mockup.tsx b/packages/website/src/components/hero-mockup.tsx index 4204a8d036..eeffc41d79 100644 --- a/packages/website/src/components/hero-mockup.tsx +++ b/packages/website/src/components/hero-mockup.tsx @@ -7,22 +7,12 @@ import { MOCKUP_STATES, type MockupStateId, MockupWindow, + ScaledMockup, } from "~/components/mockup"; const ALT = "Paseo desktop app with coding agents, a conversation, and a code diff open side by side"; -const ASPECT_STYLE = { aspectRatio: `${DESIGN_WIDTH} / ${DESIGN_HEIGHT}` }; - -// `tan(atan2(a, b))` is the CSS way to divide one length by another, so the scale -// factor tracks the container with no JS, no layout thrash, and a correct -// server-rendered first paint. -const SCALE_STYLE = { - width: DESIGN_WIDTH, - height: DESIGN_HEIGHT, - transform: `scale(tan(atan2(100cqw, ${DESIGN_WIDTH}px)))`, -}; - const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const }; export function HeroMockup() { @@ -42,21 +32,15 @@ export function HeroMockup() { ))} -
- {/* The window is authored at DESIGN_WIDTH and scaled to fit the hero column. */} -
-
-
- -
-
-
-
+ + + ); } diff --git a/packages/website/src/components/mockup/index.ts b/packages/website/src/components/mockup/index.ts index cc4a842aa5..71ffed8b8d 100644 --- a/packages/website/src/components/mockup/index.ts +++ b/packages/website/src/components/mockup/index.ts @@ -6,3 +6,4 @@ export { MobileDiff } from "./mobile/diff"; export { MobileSidebar } from "./mobile/sidebar"; export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states"; export { MockupWindow } from "./window"; +export { ScaledMockup } from "./scaled-mockup"; diff --git a/packages/website/src/components/mockup/mobile/frame.tsx b/packages/website/src/components/mockup/mobile/frame.tsx index a4ccd4f07d..8449321037 100644 --- a/packages/website/src/components/mockup/mobile/frame.tsx +++ b/packages/website/src/components/mockup/mobile/frame.tsx @@ -1,20 +1,10 @@ -// The iPhone frame every mobile mockup sits inside. Authored at a fixed -// "design" size — iPhone 17 Pro points, 402 x 874 — and scaled to whatever -// width its column gives it. The scale is pure CSS (`tan(atan2(100cqw, …))`, -// the same trick the desktop hero uses), so the frame stays crisp at any width -// with no JS and a correct server-rendered first paint. - import type * as React from "react"; +import { ScaledMockup } from "../scaled-mockup"; export const PHONE_W = 402; export const PHONE_H = 874; const FRAME_ASPECT = { aspectRatio: `${PHONE_W} / ${PHONE_H}` }; -const SCREEN_SCALE = { - width: PHONE_W, - height: PHONE_H, - transform: `scale(tan(atan2(100cqw, ${PHONE_W}px)))`, -}; type PhoneDepth = "front" | "left" | "right"; const SLAB_STYLE: Record, React.CSSProperties> = { @@ -122,8 +112,8 @@ export function PhoneFrame({ )}
-
-
+
+
@@ -131,7 +121,7 @@ export function PhoneFrame({
-
+
diff --git a/packages/website/src/components/mockup/scaled-mockup.tsx b/packages/website/src/components/mockup/scaled-mockup.tsx new file mode 100644 index 0000000000..4a30de8917 --- /dev/null +++ b/packages/website/src/components/mockup/scaled-mockup.tsx @@ -0,0 +1,61 @@ +import { useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react"; + +interface ScaledMockupProps { + width: number; + height: number; + children: ReactNode; + label?: string; + borderRadius?: number; + className?: string; +} + +export function ScaledMockup({ + width, + height, + children, + label, + borderRadius = 0, + className = "", +}: ScaledMockupProps) { + const containerRef = useRef(null); + const [scale, setScale] = useState(0); + + useLayoutEffect(() => { + const container = containerRef.current; + if (!container) return; + + // Measure layout width, not the transformed bounds of the tilted phones. + setScale(container.clientWidth / width); + const observer = new ResizeObserver(([entry]) => { + setScale(entry.contentRect.width / width); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [width]); + + const frameStyle = useMemo( + () => ({ + aspectRatio: `${width} / ${height}`, + borderRadius: `${(borderRadius / width) * 100}% / ${(borderRadius / height) * 100}%`, + }), + [width, height, borderRadius], + ); + const surfaceStyle = useMemo( + () => ({ width, height, transform: `scale(${scale})` }), + [width, height, scale], + ); + + return ( +
+
+ {children} +
+
+ ); +}