mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 02:15:02 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 sm:rounded-2xl">
|
||||
{/* The window is authored at DESIGN_WIDTH and scaled to fit the hero column. */}
|
||||
<div className="w-full [container-type:inline-size]">
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-xl sm:rounded-2xl"
|
||||
style={ASPECT_STYLE}
|
||||
role="img"
|
||||
aria-label={ALT}
|
||||
>
|
||||
<div className="absolute top-0 left-0 origin-top-left" style={SCALE_STYLE}>
|
||||
<MockupWindow state={state} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScaledMockup
|
||||
width={DESIGN_WIDTH}
|
||||
height={DESIGN_HEIGHT}
|
||||
borderRadius={18}
|
||||
label={ALT}
|
||||
className="ring-1 ring-white/10"
|
||||
>
|
||||
<MockupWindow state={state} />
|
||||
</ScaledMockup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<Exclude<PhoneDepth, "front">, React.CSSProperties> = {
|
||||
@@ -122,8 +112,8 @@ export function PhoneFrame({
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 overflow-hidden rounded-[13.5%/6.2%] border-[3px] border-black bg-black shadow-[inset_0_0_0_1px_rgba(255,255,255,0.14)] outline outline-[1px] outline-white/25">
|
||||
<div className="absolute -inset-[3px] [container-type:inline-size]">
|
||||
<div className="absolute top-0 left-0 origin-top-left" style={SCREEN_SCALE}>
|
||||
<div className="absolute -inset-[3px]">
|
||||
<ScaledMockup width={PHONE_W} height={PHONE_H}>
|
||||
<div className="relative flex h-[874px] w-[402px] flex-col overflow-hidden bg-mock-surface0 text-mock-fg antialiased">
|
||||
<StatusBar time={time} />
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
@@ -131,7 +121,7 @@ export function PhoneFrame({
|
||||
</div>
|
||||
<HomeIndicator />
|
||||
</div>
|
||||
</div>
|
||||
</ScaledMockup>
|
||||
</div>
|
||||
<div className="pointer-events-none absolute inset-0 z-20" style={GLASS_STYLE} />
|
||||
</div>
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role={label ? "img" : undefined}
|
||||
aria-label={label}
|
||||
className={`relative w-full overflow-hidden ${className}`}
|
||||
style={frameStyle}
|
||||
>
|
||||
<div className="absolute top-0 left-0 origin-top-left" style={surfaceStyle}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user