diff --git a/e2e/pwa-mobile.spec.ts b/e2e/pwa-mobile.spec.ts new file mode 100644 index 000000000..dc93574f1 --- /dev/null +++ b/e2e/pwa-mobile.spec.ts @@ -0,0 +1,239 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The installed-app behaviours a phone depends on, measured in the Pixel 5 + * project (a touch screen: `pointer: coarse`). + * + * 1. The "new version" hint stands above the bottom bar instead of on it, + * and its Reload action reloads the page. + * 2. With the on-screen keyboard open, a bottom sheet stands on the + * keyboard (its Save button stays visible) and the bottom bar steps + * aside. Playwright has no on-screen keyboard, so the visual viewport is + * replaced by a stand-in whose height the test shrinks the way a + * keyboard does; what is asserted is the page's answer to that signal. + * 3. No form field a phone can focus is set below 16 px, in portrait or + * held sideways, because iOS Safari zooms the page into such a field. + * 4. Held sideways, the sign-in card is not centred out of reach. + * + * The account is the populated mobile-sweep account; nothing here writes. + */ + +const VISUAL_VIEWPORT_STAND_IN = () => { + const stand = new EventTarget() as EventTarget & { + height: number; + width: number; + offsetTop: number; + offsetLeft: number; + pageTop: number; + pageLeft: number; + scale: number; + }; + stand.height = window.innerHeight; + stand.width = window.innerWidth; + stand.offsetTop = 0; + stand.offsetLeft = 0; + stand.pageTop = 0; + stand.pageLeft = 0; + stand.scale = 1; + Object.defineProperty(window, "visualViewport", { + configurable: true, + get: () => stand, + }); + (window as unknown as { __viewport: typeof stand }).__viewport = stand; +}; + +async function setKeyboard(page: Page, covered: number) { + await page.evaluate((px) => { + const stand = ( + window as unknown as { + __viewport: EventTarget & { height: number }; + } + ).__viewport; + stand.height = window.innerHeight - px; + stand.dispatchEvent(new Event("resize")); + }, covered); +} + +async function openCaptureForm(page: Page) { + await page.goto("/"); + await page.getByTestId("bottom-nav-capture").click(); + const picker = page.locator('[data-slot="responsive-sheet-content"]'); + await expect(picker).toBeVisible(); + await picker + .locator('[data-slot="responsive-sheet-body"] button') + .first() + .click(); + const form = page + .locator('[data-slot="responsive-sheet-content"]') + .filter({ has: page.locator('[data-slot="responsive-sheet-footer"]') }); + await expect(form).toBeVisible(); + return form; +} + +/** Every visible field a phone can focus, with its computed font size. */ +async function fieldFontSizes(page: Page) { + return page.evaluate(() => + Array.from( + document.querySelectorAll( + "input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]),textarea,select", + ), + ) + .filter((el) => { + const r = el.getBoundingClientRect(); + return r.width > 2 && r.height > 2; + }) + .map((el) => ({ + field: + el.getAttribute("name") || + el.id || + el.getAttribute("aria-label") || + el.tagName, + px: parseFloat(getComputedStyle(el).fontSize), + })), + ); +} + +test.describe("installed app on a phone", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + }); + + test("the new-version hint stands above the bottom bar and reloads", async ({ + page, + }) => { + await page.route("**/api/version", (route) => + route.fulfill({ + json: { + data: { version: "999.0.0", buildSha: "e2e" }, + error: null, + }, + }), + ); + await page.goto("/"); + const toast = page.locator("[data-sonner-toast]").filter({ + has: page.getByRole("button", { name: "Reload" }), + }); + // The poller's first check runs five seconds after mount. + await expect(toast).toBeVisible({ timeout: 15_000 }); + + const nav = page.locator('[data-slot="bottom-nav"]'); + const navBox = (await nav.boundingBox())!; + // Polled: the toast slides in from below, so an early sample is + // mid-animation. + await expect + .poll(async () => { + const b = (await toast.boundingBox())!; + return b.y + b.height; + }) + .toBeLessThanOrEqual(navBox.y); + // The bottom bar's controls are still reachable with the hint up. + await expect(page.getByTestId("bottom-nav-more")).toBeVisible(); + + const reloaded = page.waitForEvent("load"); + await toast.getByRole("button", { name: "Reload" }).click(); + await reloaded; + }); + + test("a bottom sheet stands on the on-screen keyboard and the bottom bar steps aside", async ({ + page, + }) => { + await page.addInitScript(VISUAL_VIEWPORT_STAND_IN); + const form = await openCaptureForm(page); + const save = form + .locator('[data-slot="responsive-sheet-footer"] button') + .last(); + const viewportHeight = page.viewportSize()!.height; + + const bottomEdge = async () => { + const b = (await form.boundingBox())!; + return Math.round(b.y + b.height); + }; + // Closed keyboard: the sheet sits on the bottom edge (polled: it slides + // in from below). + await expect.poll(bottomEdge).toBe(viewportHeight); + + const keyboard = 300; + await setKeyboard(page, keyboard); + await expect(page.locator("html")).toHaveAttribute("data-keyboard", "open"); + await expect.poll(bottomEdge).toBe(viewportHeight - keyboard); + const box = (await form.boundingBox())!; + expect(box.y).toBeGreaterThanOrEqual(0); + const saveBox = (await save.boundingBox())!; + expect(saveBox.y + saveBox.height).toBeLessThanOrEqual( + viewportHeight - keyboard, + ); + await expect(page.locator('[data-slot="bottom-nav"]')).toBeHidden(); + + // A toolbar-sized change is not a keyboard. + await setKeyboard(page, 60); + await expect(page.locator("html")).not.toHaveAttribute("data-keyboard"); + await expect.poll(bottomEdge).toBe(viewportHeight); + }); + + for (const viewport of [ + { width: 390, height: 844 }, + { width: 844, height: 390 }, + ]) { + test(`no focusable field is below 16 px at ${viewport.width}x${viewport.height} (iOS focus zoom)`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + const offenders: string[] = []; + for (const path of [ + "/settings", + "/documents", + "/settings/notifications", + ]) { + await page.goto(path); + await page.waitForLoadState("networkidle"); + const fields = await fieldFontSizes(page); + expect(fields.length, `${path} renders no field`).toBeGreaterThan(0); + for (const f of fields) + if (f.px < 16) offenders.push(`${path} ${f.field} ${f.px}px`); + } + if (viewport.width < 768) { + const form = await openCaptureForm(page); + await expect(form.locator("input").first()).toBeVisible(); + for (const f of await fieldFontSizes(page)) + if (f.px < 16) offenders.push(`capture ${f.field} ${f.px}px`); + } + expect(offenders).toEqual([]); + }); + } +}); + +test.describe("signed out, held sideways", () => { + test.use({ viewport: { width: 844, height: 390 } }); + + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); + }); + + test("the sign-in card stays reachable from its top edge", async ({ + page, + }) => { + await page.goto("/auth/login"); + const card = page.locator("main#main-content > *").first(); + await expect(card).toBeVisible(); + // Open the password form, the tallest state of the card. + await page + .getByRole("button", { name: /password|passwort/i }) + .first() + .click(); + await expect(page.locator("input[type=password]").first()).toBeVisible(); + const box = (await card.boundingBox())!; + expect(box.y).toBeGreaterThanOrEqual(0); + // And the document scrolls to reach whatever lies below the fold. + const scrollable = await page.evaluate( + () => + document.documentElement.scrollHeight >= window.innerHeight && + getComputedStyle(document.documentElement).overflowY !== "hidden", + ); + expect(scrollable).toBe(true); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 70e93c9dd..8ce442359 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import { MonitoringBootstrap } from "@/components/monitoring/bootstrap"; import { WebVitalsReporter } from "@/components/monitoring/web-vitals-reporter"; import { resolveInitialLocale } from "@/lib/i18n/resolve-initial-locale"; import { isKeyMismatch } from "@/lib/boot/key-mismatch-state"; +import { THEME_COLOR } from "@/lib/pwa/theme-color"; import { KeyMismatchPage } from "./key-mismatch-page"; const inter = Inter({ @@ -71,9 +72,11 @@ export const viewport: Viewport = { // the active palette. The hex values are the resolved background of // `--background` from `app/globals.css` for each theme so the bar // edge never seams against the page on cold paint. + // The client rewrites both to the app's own theme once it is known + // (`applyThemeColor`), since that need not be the operating system's. themeColor: [ - { media: "(prefers-color-scheme: light)", color: "#f3f2f5" }, - { media: "(prefers-color-scheme: dark)", color: "#282a36" }, + { media: "(prefers-color-scheme: light)", color: THEME_COLOR.light }, + { media: "(prefers-color-scheme: dark)", color: THEME_COLOR.dark }, ], }; diff --git a/src/components/providers.tsx b/src/components/providers.tsx index fcb68fad7..55e3ec559 100644 --- a/src/components/providers.tsx +++ b/src/components/providers.tsx @@ -19,6 +19,7 @@ import { I18nProvider, useTranslations } from "@/lib/i18n/context"; import type { Locale } from "@/lib/i18n/config"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; +import { applyThemeColor } from "@/lib/pwa/theme-color"; import { KeyboardInsetBridge } from "@/components/keyboard-inset-bridge"; import { VersionPoller } from "@/components/version-poller"; import { ServiceWorkerRegistrar } from "@/components/service-worker-registrar"; @@ -84,6 +85,9 @@ function getSystemTheme(): "dark" | "light" { function applyTheme(resolved: "dark" | "light") { document.documentElement.classList.remove("dark", "light"); document.documentElement.classList.add(resolved); + // The browser bar and an installed app's status bar follow the theme the + // page painted, not the operating system's scheme (see theme-color.ts). + applyThemeColor(document, resolved); } // ── Theme as an external store ─────────────────────── @@ -378,6 +382,33 @@ function OfflineMutationToaster() { // ── Root Providers ─────────────────────────────────── +/** + * The app-wide toaster. + * + * Its theme is the one the page painted, not `"system"`: the app's theme is + * its own setting (dark by default), and a phone in light mode showed light + * toasts over the dark app. + * + * Below 600 px Sonner lays toasts across the bottom edge, 16 px up, which on + * a phone is on top of the bottom bar: the "new version" hint sat over the + * tabs and the add button until it was dismissed. The mobile offset lifts + * them above the bar and the home indicator. From `md` the bar is gone and + * the desktop offset applies. + */ +function AppToaster() { + const { resolvedTheme } = useTheme(); + return ( + + ); +} + export function Providers({ children, initialLocale, @@ -405,7 +436,7 @@ export function Providers({ {children} - + diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index 7bf1e1b06..457631313 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -12,10 +12,9 @@ import { Toaster as Sonner, type ToasterProps } from "sonner"; const Toaster = ({ ...props }: ToasterProps) => { return ( ([["content", content]]); + return { + attrs, + setAttribute: (k: string, v: string) => attrs.set(k, v), + }; +} + +describe("applyThemeColor", () => { + it("rewrites both media-keyed tags to the theme the page painted", () => { + // A phone in light mode, the app in its dark default: the light tag is + // the one the browser applies, and it must carry the dark colour. + const light = fakeMeta(THEME_COLOR.light); + const dark = fakeMeta(THEME_COLOR.dark); + const doc = { + querySelectorAll: (selector: string) => { + expect(selector).toBe('meta[name="theme-color"]'); + return [light, dark]; + }, + } as unknown as Document; + + applyThemeColor(doc, "dark"); + expect(light.attrs.get("content")).toBe(THEME_COLOR.dark); + expect(dark.attrs.get("content")).toBe(THEME_COLOR.dark); + + applyThemeColor(doc, "light"); + expect(light.attrs.get("content")).toBe(THEME_COLOR.light); + expect(dark.attrs.get("content")).toBe(THEME_COLOR.light); + }); + + it("matches each theme's --background in globals.css", () => { + const css = readFileSync( + resolve(__dirname, "../../../app/globals.css"), + "utf8", + ); + // The dark palette is declared in hex; the bar colour must be it exactly. + expect(css).toMatch( + new RegExp(`--background:\\s*${THEME_COLOR.dark};`, "i"), + ); + }); +}); diff --git a/src/lib/pwa/theme-color.ts b/src/lib/pwa/theme-color.ts new file mode 100644 index 000000000..1284c852c --- /dev/null +++ b/src/lib/pwa/theme-color.ts @@ -0,0 +1,30 @@ +/** + * The browser-chrome colour of each theme: the Android address bar, the + * installed app's title bar, and the status bar of an installed iOS app. + * + * Each value is the resolved `--background` of its theme in + * `app/globals.css`, so the bar never seams against the page on a cold paint. + * The root layout declares both as `` keyed on + * the operating system's scheme, which is all a server render can know. + * + * The app's own theme is not the operating system's, though: with nothing + * stored it defaults to dark, and a person can pick light or dark by hand. A + * phone in light mode running HealthLog in dark mode then showed a light bar + * over a dark page. `applyThemeColor` rewrites both tags to the theme the page + * actually painted, whichever scheme the media query would pick. + */ +export const THEME_COLOR = { + light: "#f3f2f5", + dark: "#282a36", +} as const; + +export function applyThemeColor( + doc: Pick, + resolved: keyof typeof THEME_COLOR, +): void { + for (const meta of doc.querySelectorAll( + 'meta[name="theme-color"]', + )) { + meta.setAttribute("content", THEME_COLOR[resolved]); + } +}