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} +
+
+ ); +}