From 2cd68779ec18d4928c7c5f62e540695de0ff3056 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:24:30 +0200 Subject: [PATCH] fix(pwa): follow the app's theme in the browser bar and toasts, lift toasts over the bottom bar The theme-color tags followed the operating system's scheme, while the app's theme is its own setting and defaults to dark: a phone in light mode showed a light address bar or status bar over the dark app. The client now rewrites both tags to the theme the page painted. The toaster read Sonner's "system" for the same reason and now gets the resolved theme as well. Below 600 px Sonner stacks toasts 16 px above the bottom edge, which on a phone is on top of the bottom bar: the "new version" hint covered the tabs and the add button until dismissed. A mobile offset lifts toasts above the bar and the home indicator. pwa-mobile.spec.ts covers the hint's position and its Reload, a bottom sheet standing on a stand-in keyboard, 16 px fields in portrait and held sideways, and the sign-in card held sideways. --- e2e/pwa-mobile.spec.ts | 239 ++++++++++++++++++++++ src/app/layout.tsx | 7 +- src/components/providers.tsx | 33 ++- src/components/ui/sonner.tsx | 7 +- src/lib/pwa/__tests__/theme-color.test.ts | 48 +++++ src/lib/pwa/theme-color.ts | 30 +++ 6 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 e2e/pwa-mobile.spec.ts create mode 100644 src/lib/pwa/__tests__/theme-color.test.ts create mode 100644 src/lib/pwa/theme-color.ts 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]); + } +}