commit 56e8d5b6b0b03c5e05e7d683e08ee7c4350712bf
parent 292de396ded34fb74230b48caef0e33126008101
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 21:08:14 -0400
e2e: one spec that looks at the site on a phone
Every other spec runs at 1440x1200, which is how a header that wrapped
out of its own background and a modal with a hundred pixels of cue list
both shipped. This one runs at 390: no route scrolls sideways, the menu
sheet carries the nav, the filters sheet applies and closes, the modal
is a full-screen player with a 300px cue list and a reachable Close,
and the composer is still on screen after an answer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
1 file changed, 153 insertions(+), 0 deletions(-)
diff --git a/export/e2e/responsive.spec.ts b/export/e2e/responsive.spec.ts
@@ -0,0 +1,153 @@
+import { expect, test, type Page } from "@playwright/test";
+import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data";
+import { expectModalOpen, installRoutes } from "./helpers";
+
+// The phone. Every other spec in this suite runs at the project's 1440×1200
+// desktop viewport, which is exactly why the export site could ship a header
+// that wrapped out of its own background, a footer that overflowed at 360px and
+// a transcript modal with ~100px of cue list: nothing was ever looking.
+//
+// A file-level `test.use` overrides the Desktop Chrome project for this file
+// only, so these assertions are about the narrow layout and nothing else.
+test.use({
+ viewport: { width: 390, height: 844 },
+ isMobile: true,
+ hasTouch: true,
+});
+
+// The one invariant worth asserting on every route: the page must never scroll
+// sideways. `scrollWidth > clientWidth` is what a reader feels as the layout
+// being slightly wider than their phone.
+async function expectNoHorizontalOverflow(page: Page) {
+ const over = await page.evaluate(
+ () =>
+ document.documentElement.scrollWidth -
+ document.documentElement.clientWidth,
+ );
+ expect(over).toBeLessThanOrEqual(0);
+}
+
+const CORS = {
+ "access-control-allow-origin": "*",
+ "access-control-allow-headers": "*",
+ "access-control-allow-methods": "*",
+};
+
+function sse(text: string): string {
+ return (
+ `data: ${JSON.stringify({
+ type: "content_block_delta",
+ delta: { type: "text_delta", text },
+ })}\n\n` + `data: ${JSON.stringify({ type: "message_stop" })}\n\n`
+ );
+}
+
+// A one-turn provider: no retrieval round trip (the gather step is told DONE
+// immediately), then a streamed answer. Enough to put a message on screen and
+// see where the composer ends up.
+async function mockAI(page: Page) {
+ await page.route("https://api.anthropic.com/**", async (route) => {
+ if (route.request().method() === "OPTIONS") {
+ await route.fulfill({ status: 204, headers: CORS });
+ return;
+ }
+ const body = route.request().postDataJSON() as { system?: string };
+ const answering = (body.system ?? "").includes("Markdown");
+ await route.fulfill({
+ status: 200,
+ headers: { ...CORS, "content-type": "text/event-stream" },
+ body: sse(answering ? "A short answer about alpha." : "DONE"),
+ });
+ });
+}
+
+test.describe("phone layout", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ // /duplicates.json is 404-mocked by installRoutes, so the duplicates route
+ // renders its empty state — which is still a full page of chrome to overflow.
+ for (const route of [
+ "/",
+ "/ask/",
+ "/downloads/",
+ "/duplicates/",
+ "/use-with-ai/",
+ ]) {
+ test(`no horizontal overflow on ${route}`, async ({ page }) => {
+ await page.goto(route);
+ await page.waitForLoadState("networkidle");
+ await expectNoHorizontalOverflow(page);
+ });
+ }
+
+ test("the header menu carries the nav that the wide header shows inline", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await page.getByRole("button", { name: "Open menu" }).click();
+ const menu = page.getByRole("dialog");
+ await expect(menu.getByRole("link", { name: "Ask AI" })).toBeVisible();
+ await expect(menu.getByRole("link", { name: "Search" })).toBeVisible();
+ // The theme picker is a dropdown in the wide header; here it is a plain
+ // radio list, because a popover inside a dialog is a focus-trap fight.
+ await expect(menu.getByRole("radio", { name: "Archive" })).toBeVisible();
+ });
+
+ test("the filters sheet applies a filter and the results change", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ const summary = page.getByTestId("results-summary");
+ await expect(summary).toContainText("All videos (3)");
+
+ await page.getByTestId("filters-trigger").click();
+ const sheet = page.getByRole("dialog");
+ await sheet.getByRole("checkbox", { name: "Videos" }).uncheck();
+ await sheet.getByRole("button", { name: "Apply filters" }).click();
+
+ // Apply commits and closes; nothing is left covering the results.
+ await expect(sheet).toBeHidden();
+ await expect(summary).toContainText("All videos (0)");
+ });
+
+ test("the transcript modal is a full-screen player view", async ({ page }) => {
+ await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`);
+ await expectModalOpen(page);
+
+ // The cue list is the point of the modal. It used to get ~100px at this
+ // width, under a player slot sized for a desktop card.
+ const box = await page.getByTestId("cue-list").boundingBox();
+ expect(box).not.toBeNull();
+ expect(box!.height).toBeGreaterThanOrEqual(300);
+
+ // Every control is a real button with a visible label or an icon, and the
+ // three that close the thing never scroll out of reach.
+ await expect(
+ page.getByRole("button", { name: "Close player" }),
+ ).toBeInViewport();
+ await expect(
+ page.getByRole("button", { name: "Show live chat" }),
+ ).toBeInViewport();
+ });
+
+ test("the composer stays on screen once the chat has an answer", async ({
+ page,
+ }) => {
+ await mockAI(page);
+ await page.goto("/ask/");
+ await page.getByRole("button", { name: "Scripted" }).click();
+ await page.locator('input[placeholder^="sk-ant"]').fill("sk-ant-test");
+
+ const box = page.getByPlaceholder(/Ask about the transcripts/);
+ await box.fill("what is alpha");
+ await page.getByRole("button", { name: "Ask", exact: true }).click();
+ await expect(page.getByText("A short answer about alpha.")).toBeVisible();
+
+ // The whole point of the sticky composer: after an answer arrives, the box
+ // you type the next question into has not scrolled away.
+ await expect(box).toBeInViewport();
+ await expectNoHorizontalOverflow(page);
+ });
+});