diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3710d15fc..165aca665 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -62,7 +62,7 @@ jobs: AUTH_RP_ORIGIN: http://localhost:3000 SESSION_COOKIE_SECURE: "false" NODE_ENV: production - # The WebKit spec runs in the `webkit` job below. + # The WebKit specs run in the `webkit` job below. E2E_SKIP_WEBKIT: "1" steps: @@ -146,8 +146,9 @@ jobs: if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi echo "version=$version" >> "$GITHUB_OUTPUT" - # The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the - # same image as the shards. + # The WebKit specs, in the same image as the shards: the bottom sheet on an + # iPhone (`bottom-sheet.spec.ts`, project `webkit-iphone`) and the app shell + # on an iPad (`tablet-shell.spec.ts`, project `webkit-tablet`). webkit: runs-on: ubuntu-24.04 needs: playwright-version @@ -202,7 +203,7 @@ jobs: - run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build - - run: pnpm exec playwright test --project=webkit-iphone + - run: pnpm exec playwright test --project=webkit-iphone --project=webkit-tablet env: E2E_BASE_URL: http://localhost:3000 diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index fe3074434..ffed43557 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -32,10 +32,10 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm" ## 2. Spacing scale -> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. +> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `
`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `
` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. - **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13). -- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. +- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. - **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`. - **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding. - **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`. @@ -148,7 +148,7 @@ Forbidden on any Insights card: a hand-rolled icon+title row, a card title centr ## 9. One scroll floor; sub-shells never re-declare bottom-space -Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `
` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `
`; a nested reserve stacks on the shell budget and scrolls past the last card. +Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `
` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `
`; a nested reserve stacks on the shell budget and scrolls past the last card. A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget. @@ -156,6 +156,8 @@ A two-column sub-shell that needs a "don't jump while loading" floor derives it Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen. +The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`. + ## 11. Inline action row; the PageHeader pattern A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`). diff --git a/e2e/tablet-shell.spec.ts b/e2e/tablet-shell.spec.ts new file mode 100644 index 000000000..5dfd258a2 --- /dev/null +++ b/e2e/tablet-shell.spec.ts @@ -0,0 +1,420 @@ +import type { Locator, Page } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The app shell on a tablet, under a finger, at every iPad size in both + * orientations: the rail reaches every destination, `
` scrolls to its + * last line, and closing a sheet or a dialog hands the scroll back. + * + * Runs in `webkit-tablet` (iPad, the engine an iPad runs; CI's WebKit job) + * and `chromium-tablet` (an Android tablet, in the Chromium shards). Both + * report `pointer: coarse`, so the rail keeps its 44 px rows and its list + * overflows in landscape, which is the case that broke. + * + * What a browser under test cannot show is the iPadOS toolbar moving the + * visible height away from `100dvh`; `shell-viewport.test.ts` holds the + * decisions that take that disagreement out, and this file measures that the + * shell they produce fills the window exactly and leaves the document + * nothing to scroll or bounce. + * + * Stable hooks only: `sidebar-nav-region`, `sidebar-nav-scroll-cue`, + * `sidebar-footer`, `main-content-wrapper`, `bottom-nav`, + * `bottom-nav-more` (test id), `command-palette-trigger`, + * `command-palette-input`, `day-panel`. + */ + +/** iPad and Android tablet sizes, CSS px, with the browser's own bars where they take room. */ +const VIEWPORTS = [ + { name: "iPad mini portrait", width: 744, height: 1133 }, + { name: "iPad mini landscape", width: 1133, height: 744 }, + { name: "iPad mini landscape, Safari bars", width: 1133, height: 670 }, + { name: "iPad landscape, Safari bars", width: 1000, height: 690 }, + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, + { name: "iPad Pro 11 portrait", width: 834, height: 1194 }, + { name: "iPad Pro 11 landscape", width: 1194, height: 834 }, + { name: "iPad Pro 13 portrait", width: 1024, height: 1366 }, + { name: "iPad Pro 13 landscape", width: 1366, height: 1024 }, + { name: "Split View two thirds", width: 694, height: 1024 }, + { name: "Split View half, landscape", width: 590, height: 820 }, + { name: "Slide Over", width: 375, height: 1024 }, + { name: "Android tablet landscape", width: 1138, height: 712 }, +] as const; + +test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + +async function openDashboard(page: Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect( + page.locator('#main-content [data-slot="main-content-wrapper"]'), + ).toBeVisible(); + // The module-gated destinations join the rail once `/api/auth/me` lands. + await expect(page.locator('aside nav a[href="/medications"]')).toHaveCount(1); +} + +/** The shell fills the window exactly and the document has nothing to scroll. */ +async function expectShellPinned(page: Page) { + const m = await page.evaluate(() => { + const shell = document.querySelector(".shell-safe-area")!; + const rect = shell.getBoundingClientRect(); + const root = document.scrollingElement!; + const html = getComputedStyle(document.documentElement); + return { + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + innerHeight, + innerWidth, + docScroll: root.scrollHeight - root.clientHeight, + overflowY: html.overflowY, + overscroll: html.overscrollBehaviorY, + }; + }); + expect(m.top).toBe(0); + expect(m.left).toBe(0); + expect(Math.abs(m.bottom - m.innerHeight)).toBeLessThanOrEqual(0.5); + expect(Math.abs(m.right - m.innerWidth)).toBeLessThanOrEqual(0.5); + expect(m.docScroll).toBeLessThanOrEqual(0); + expect(m.overflowY).toBe("hidden"); + // No rubber band on the document: on iOS it held the next swipe. + expect(m.overscroll).toBe("none"); +} + +function railNav(page: Page): Locator { + return page.locator('aside [data-slot="sidebar-nav-region"] > nav'); +} + +/** Scrolls `
` to its end, waiting out content that grows on the way. */ +async function scrollMainToEnd(page: Page) { + let last = -1; + for (let i = 0; i < 8; i += 1) { + const height = await page.evaluate(() => { + const main = document.getElementById("main-content")!; + main.scrollTop = main.scrollHeight; + return main.scrollHeight; + }); + if (height === last) return; + last = height; + await page.waitForTimeout(250); + } +} + +/** Where the page's lowest content ends, and what covers the window's bottom edge. */ +async function measureMainEnd(page: Page) { + return page.evaluate(() => { + const main = document.getElementById("main-content")!; + const wrapper = main.querySelector( + '[data-slot="main-content-wrapper"]', + )!; + let contentBottom = 0; + for (const el of wrapper.querySelectorAll("*")) { + const cs = getComputedStyle(el); + if (cs.position === "fixed" || cs.visibility === "hidden") continue; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + let p = el.parentElement; + let nested = false; + while (p && p !== main) { + const ps = getComputedStyle(p); + if ( + /(auto|scroll|hidden)/.test(ps.overflowY) && + p.scrollHeight > p.clientHeight + 1 + ) { + nested = true; + break; + } + p = p.parentElement; + } + if (nested) continue; + contentBottom = Math.max(contentBottom, rect.bottom); + } + const visible = (el: Element | null): el is HTMLElement => + !!el && + getComputedStyle(el).display !== "none" && + el.getBoundingClientRect().height > 0; + const bar = document.querySelector('[data-slot="bottom-nav"]'); + const mainRect = main.getBoundingClientRect(); + return { + atEnd: main.scrollTop + main.clientHeight >= main.scrollHeight - 1, + scrolls: main.scrollHeight > main.clientHeight, + contentBottom, + mainBottom: mainRect.bottom, + barTop: visible(bar) ? bar.getBoundingClientRect().top : null, + }; + }); +} + +for (const vp of VIEWPORTS) { + test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + }); + + test("every rail destination scrolls into view and takes a tap", async ({ + page, + }) => { + await openDashboard(page); + await expectShellPinned(page); + + const aside = page.locator("aside"); + const railShown = vp.width >= 768; + if (!railShown) { + // Split View and Slide Over get the phone shell: the bar, no rail. + await expect(aside).toBeHidden(); + await expect(page.locator('[data-slot="bottom-nav"]')).toBeVisible(); + return; + } + await expect(aside).toBeVisible(); + + // Nothing in the rail is clipped by the rail: the logo band, the list + // region, the pinned footer and the account row all sit inside it, in + // that order, and the list region alone gives up height. + const parts = await page.evaluate(() => { + const asideEl = document.querySelector("aside")!; + const box = asideEl.getBoundingClientRect(); + const pick = (sel: string) => + asideEl.querySelector(sel)!.getBoundingClientRect(); + const column = asideEl.querySelector('[data-slot="sidebar-column"]')!; + const user = column.lastElementChild!.getBoundingClientRect(); + return { + clipped: asideEl.scrollHeight - asideEl.clientHeight, + asideTop: box.top, + asideBottom: box.bottom, + header: pick('[data-slot="sidebar-header"]'), + region: pick('[data-slot="sidebar-nav-region"]'), + footer: pick('[data-slot="sidebar-footer"]'), + user, + overscroll: getComputedStyle( + asideEl.querySelector('[data-slot="sidebar-nav-region"] > nav')!, + ).overscrollBehaviorY, + }; + }); + expect(parts.clipped).toBeLessThanOrEqual(0); + expect(parts.header.top).toBeGreaterThanOrEqual(parts.asideTop - 0.5); + expect(parts.region.top).toBeGreaterThanOrEqual( + parts.header.bottom - 0.5, + ); + expect(parts.footer.top).toBeGreaterThanOrEqual( + parts.region.bottom - 0.5, + ); + expect(parts.user.top).toBeGreaterThanOrEqual(parts.footer.bottom - 0.5); + expect(parts.user.bottom).toBeLessThanOrEqual(parts.asideBottom + 0.5); + expect(parts.overscroll).toBe("contain"); + await expect( + aside.locator('[data-slot="sidebar-collapse-toggle"]'), + ).toBeInViewport({ ratio: 1 }); + await expect( + aside.locator('[data-slot="nav-settings-link"]'), + ).toBeInViewport({ ratio: 1 }); + + // Every destination: scrolled into the list's view, whole, on top + // (not under the cue, the footer or anything else), and actionable. + const nav = railNav(page); + const links = nav.locator("a[href]"); + const count = await links.count(); + expect(count).toBeGreaterThanOrEqual(10); + for (let i = 0; i < count; i += 1) { + const link = links.nth(i); + await link.scrollIntoViewIfNeeded(); + await expect + .poll(() => + link.evaluate((el) => { + const navEl = el.closest("nav")!; + const r = el.getBoundingClientRect(); + const n = navEl.getBoundingClientRect(); + const hit = document.elementFromPoint( + r.left + r.width / 2, + r.top + r.height / 2, + ); + return ( + r.top >= n.top - 0.5 && + r.bottom <= n.bottom + 0.5 && + !!hit && + el.contains(hit) + ); + }), + ) + .toBe(true); + await link.click({ trial: true }); + } + + // A list taller than its region shows the touch cue at the edge that + // has more, and a tap on it is a way there. + const overflows = await nav.evaluate( + (el) => el.scrollHeight > el.clientHeight + 1, + ); + const down = page.locator( + '[data-slot="sidebar-nav-scroll-cue"][data-direction="down"]', + ); + const up = page.locator( + '[data-slot="sidebar-nav-scroll-cue"][data-direction="up"]', + ); + await nav.evaluate((el) => (el.scrollTop = 0)); + if (overflows) { + await expect(down).toBeVisible(); + await expect(up).toHaveCount(0); + await down.tap(); + await expect + .poll(() => nav.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + await expect(up).toBeVisible(); + await nav.evaluate((el) => (el.scrollTop = el.scrollHeight)); + await expect(down).toHaveCount(0); + } else { + await expect(down).toHaveCount(0); + await expect(up).toHaveCount(0); + } + + // And the last destination is a real tap away. + const lastLink = links.nth(count - 1); + const href = await lastLink.getAttribute("href"); + await lastLink.scrollIntoViewIfNeeded(); + await lastLink.tap(); + await expect(page).toHaveURL(new RegExp(`${href}$`)); + }); + + // The Coach button's clearance is held by `shell-viewport.test.ts`: the + // button renders only with an AI provider, which the suite's accounts + // do not have, so a check here would never run. + test("main scrolls to its last line, clear of the bottom bar", async ({ + page, + }) => { + await openDashboard(page); + await scrollMainToEnd(page); + const end = await measureMainEnd(page); + expect(end.scrolls).toBe(true); + expect(end.atEnd).toBe(true); + expect(end.contentBottom).toBeLessThanOrEqual(end.mainBottom + 0.5); + if (end.barTop !== null) { + expect(end.contentBottom).toBeLessThanOrEqual(end.barTop); + } + await expectShellPinned(page); + }); + }); +} + +/** After a modal closes, the page behind takes pointer and wheel again and scrolls. */ +async function expectPageScrollsAgain(page: Page, browserName: string) { + await expect + .poll(() => + page.evaluate(() => ({ + locked: document.body.hasAttribute("data-scroll-locked"), + pointer: getComputedStyle(document.body).pointerEvents, + })), + ) + .toEqual({ locked: false, pointer: "auto" }); + const before = await page.evaluate(() => { + const main = document.getElementById("main-content")!; + main.scrollTop = 0; + const r = main.getBoundingClientRect(); + const x = r.left + r.width / 2; + const y = r.top + r.height / 2; + const hit = document.elementFromPoint(x, y); + const wheel = new WheelEvent("wheel", { + deltaY: 120, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }); + hit?.dispatchEvent(wheel); + return { + onMain: !!hit && main.contains(hit), + blocked: wheel.defaultPrevented, + x, + y, + }; + }); + // Nothing invisible lies over `
`, and no scroll lock swallows the + // wheel (react-remove-scroll cancels it outside its own layer). + expect(before.onMain).toBe(true); + expect(before.blocked).toBe(false); + // A real wheel where the engine has one. Mobile WebKit under Playwright + // has none (and no native touch scroll), so there the two checks above + // are the proof: nothing covers `
` and nothing cancels its events. + if (browserName === "webkit") return; + await page.mouse.move(before.x, before.y); + await page.mouse.wheel(0, 600); + await expect + .poll(() => + page.evaluate(() => document.getElementById("main-content")!.scrollTop), + ) + .toBeGreaterThan(0); +} + +const MODAL_VIEWPORTS = [ + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "Split View two thirds", width: 694, height: 1024 }, +] as const; + +for (const vp of MODAL_VIEWPORTS) { + test.describe(`${vp.name}: scroll after a modal`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + }); + + test("the page scrolls after the command palette dialog closes", async ({ + page, + browserName, + }) => { + await openDashboard(page); + await page.locator('[data-slot="command-palette-trigger"]:visible').tap(); + const input = page.locator('[data-slot="command-palette-input"]'); + await expect(input).toBeVisible(); + // The scroll lock makes the body `position: relative`; the shell + // behind the dialog keeps the window's box all the same. + await expectShellPinned(page); + await page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + await expectPageScrollsAgain(page, browserName); + }); + + test("the page scrolls after a sheet closes", async ({ + page, + browserName, + }) => { + await openDashboard(page); + if (vp.width < 768) { + // The phone shell's More sheet: the draggable bottom sheet. + await page.locator('[data-testid="bottom-nav-more"]').tap(); + const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]'); + await expect(sheet).toBeVisible(); + await expectShellPinned(page); + await sheet.locator('[data-slot="sheet-close-button"]').tap(); + await expect(sheet).toBeHidden(); + } else { + // The day, a sheet from the right below 1280 px. + await mockDay(page); + const day = new Date(Date.now() - 86_400_000) + .toISOString() + .slice(0, 10); + await page.goto(`/?day=${day}`, { waitUntil: "domcontentloaded" }); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(panel).toBeVisible(); + await expectShellPinned(page); + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden(); + } + await expectPageScrollsAgain(page, browserName); + }); + }); +} + +test("a vertical swipe that starts on a tab strip is the page's", async ({ + page, +}) => { + await page.setViewportSize({ width: 1180, height: 820 }); + await page.goto("/checkups", { waitUntil: "domcontentloaded" }); + const strip = page.locator('#main-content [data-slot="tabs-list"]').first(); + await expect(strip).toBeVisible(); + // `pan-x` here refused the vertical pan, so the page did not move. + const touchAction = await strip.evaluate( + (el) => getComputedStyle(el).touchAction, + ); + expect(touchAction).toBe("auto"); +}); diff --git a/playwright.config.ts b/playwright.config.ts index cd8a69347..4fbee7e5f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -123,6 +123,8 @@ export default defineConfig({ "pwa-offline.spec.ts", // Runs only in the WebKit phone project. "bottom-sheet.spec.ts", + // Runs only in the two tablet projects. + "tablet-shell.spec.ts", ], use: { ...devices["Desktop Chrome"], @@ -240,6 +242,8 @@ export default defineConfig({ "pwa-offline.spec.ts", // Runs only in the WebKit phone project. "bottom-sheet.spec.ts", + // Runs only in the two tablet projects. + "tablet-shell.spec.ts", // The Apple Health import journey uploads an archive into the one // shared account and its refusal control asserts that the account's // Apple-Health row count did not move. A second project running the @@ -333,7 +337,28 @@ export default defineConfig({ colorScheme: "dark" as const, }, }, + // The app shell on an iPad (`tablet-shell.spec.ts`): the rail + // under a finger, `
` to its last line, scroll after a modal. + // In CI's WebKit job beside `webkit-iphone`. The spec sets each + // iPad size itself; the profile supplies touch and `isMobile`. + { + name: "webkit-tablet", + testMatch: ["tablet-shell.spec.ts"], + use: { + ...devices["iPad Pro 11 landscape"], + colorScheme: "dark" as const, + }, + }, ]), + // The same spec on an Android tablet, in the Chromium shards. + { + name: "chromium-tablet", + testMatch: ["tablet-shell.spec.ts"], + use: { + ...devices["Galaxy Tab S4 landscape"], + colorScheme: "dark" as const, + }, + }, ], // Spin up the production build for E2E. `pnpm build` is run