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/e2e/pwa-offline.spec.ts b/e2e/pwa-offline.spec.ts new file mode 100644 index 000000000..05701ce0d --- /dev/null +++ b/e2e/pwa-offline.spec.ts @@ -0,0 +1,64 @@ +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * What the installed app shows without a connection, with the service worker + * actually running. + * + * Runs in the `chromium-service-worker` project, the one that lets the worker + * register (every other project blocks it; see `playwright.config.ts`), and + * like the other spec there it uses no route mocks: the claim is about what + * the worker itself serves. + * + * 1. A page that is open when the connection drops stays the app and says + * so (the offline strip). + * 2. Opening a page without a connection gets the worker's offline page: + * the word "Offline" and a working retry control, never the browser's + * error. (Signed-in pages are never cached, by design: the proxy pins + * `private, no-store` on them because some carry the record in the + * HTML. So this is what an offline launch of the installed app shows.) + * 3. When the connection returns, that page reloads into the app by + * itself. + * + * The account is the populated mobile-sweep account; nothing here writes. + */ +test.describe("offline, with the service worker", () => { + test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + + test("an open page says it is offline, a new one opens the offline page, and both recover", async ({ + page, + context, + }) => { + await page.goto("/medications"); + await page.waitForFunction( + () => navigator.serviceWorker?.controller !== null, + undefined, + { timeout: 20_000 }, + ); + await expect(page.locator("#main-content")).toBeVisible(); + await page.waitForLoadState("networkidle"); + + try { + await context.setOffline(true); + await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible(); + + const response = await page.goto("/timeline"); + expect(response?.status()).toBe(503); + await expect(page.getByText("Offline", { exact: true })).toBeVisible(); + const retry = page.getByRole("link", { name: "Reload" }); + await expect(retry).toBeVisible(); + const box = (await retry.boundingBox())!; + expect(box.width).toBeGreaterThanOrEqual(44); + expect(box.height).toBeGreaterThanOrEqual(44); + + // Back online: the offline page reloads itself into the app. + const reloaded = page.waitForEvent("load"); + await context.setOffline(false); + await reloaded; + await expect(page.locator("#main-content")).toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/timeline"); + } finally { + await context.setOffline(false); + } + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 715b38b97..ed9e0ffb8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -110,7 +110,10 @@ export default defineConfig({ name: "chromium-desktop", // The disk-layer spec needs a live service worker, which the shared // `use` block blocks for every other spec. It runs in its own project. - testIgnore: ["v137-record-session-fence-offline.spec.ts"], + testIgnore: [ + "v137-record-session-fence-offline.spec.ts", + "pwa-offline.spec.ts", + ], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, @@ -130,10 +133,14 @@ export default defineConfig({ // cannot pass is worse than no check, so the spec gets a project rather // than a caveat. // - // It uses no route mocks for exactly this reason, and it is the only - // spec in this project. + // It uses no route mocks for exactly this reason. v1.42 — the offline + // page journey (`pwa-offline.spec.ts`) joined it on the same terms: + // what the worker itself serves without a connection, no mocks. name: "chromium-service-worker", - testMatch: ["v137-record-session-fence-offline.spec.ts"], + testMatch: [ + "v137-record-session-fence-offline.spec.ts", + "pwa-offline.spec.ts", + ], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, @@ -220,6 +227,7 @@ export default defineConfig({ "doctor-report-delegate.spec.ts", // Runs only in the service-worker project. "v137-record-session-fence-offline.spec.ts", + "pwa-offline.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 diff --git a/public/sw.js b/public/sw.js index c83b525f8..784022a63 100644 --- a/public/sw.js +++ b/public/sw.js @@ -341,6 +341,14 @@ async function networkFirstApi(request) { * scores straight into the HTML and emits `no-store`; the explicit * path skip is belt-and-braces so a revoked share can never linger in * CacheStorage and render back offline. + * + * In practice rule 1 covers every signed-in page: `src/proxy.ts` pins + * `private, no-store` on all of them, because several server-prefetch one + * account's record into the HTML. Only the public pages (sign-in, legal) + * land in this cache, so a navigation to an app page without a connection + * gets the offline page below, never somebody's cached record. A page that + * is already open keeps working offline from the cached chunks and the + * allowlisted data reads. */ function isCacheableNavigation(request, response) { const cacheControl = response.headers.get("Cache-Control") || ""; @@ -568,16 +576,28 @@ async function networkFirst(event, cacheName) { // "Offline" token + a generic retry hint expressed as an icon // (the round-trip arrow), so the page does the right thing in any // locale without shipping a translation bundle into the worker. - return new Response( - 'HealthLog — Offline

HealthLog

Offline

', - { - status: 503, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }, - ); + // + // v1.42 — the arrow is a real control now (a link to the same URL, + // 64 px), the page reloads by itself as soon as the connection is + // back, it follows the device's light or dark scheme, and it keeps + // clear of the notch and home indicator of an installed app. + return new Response(OFFLINE_PAGE, { + status: 503, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }); } } +const OFFLINE_PAGE = + 'HealthLog — Offline

HealthLog

Offline

' + + ''; + /** * Cache a successful network response without making CacheStorage * availability part of the fetch contract. Quota, eviction, or trim failures diff --git a/src/__tests__/pwa-mobile-shell-guard.test.ts b/src/__tests__/pwa-mobile-shell-guard.test.ts new file mode 100644 index 000000000..22558121e --- /dev/null +++ b/src/__tests__/pwa-mobile-shell-guard.test.ts @@ -0,0 +1,108 @@ +/** + * Structural guards for the installed app on a phone — the parts no browser + * in CI can show, because neither Chromium nor WebKit under Playwright + * reports a safe-area inset or opens an on-screen keyboard. + * + * 1. The authenticated shell takes the status-bar and side insets once, + * on its outer wrapper, and no 4rem header band pads itself inside its + * own height (which on a phone with a 59 px inset left the top bar a + * 5 px content box and pushed the logo across its bottom border). + * 2. The stylesheet carries the rules the shell and the keyboard bridge + * depend on. + * 3. Every form-field primitive keeps 16 px type on a touch screen: iOS + * Safari zooms the page into a focused field below 16 px, and a width + * breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways + * and to every iPad. The small size is allowed only behind + * `pointer-fine:`. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = resolve(__dirname, "../.."); +const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8"); + +/** Drop `//` and block comments so prose about a class cannot satisfy a check. */ +function code(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:"'`])\/\/.*$/gm, "$1"); +} + +describe("the shell owns the safe area", () => { + it("the authenticated shell wrapper takes the insets", () => { + const shell = code(read("src/components/layout/auth-shell.tsx")); + expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/); + }); + + it.each([ + "src/components/layout/top-bar.tsx", + "src/components/insights/coach-panel/conversations-panel.tsx", + "src/components/day/day-layer.tsx", + ])("%s pads no header band with the status-bar inset", (path) => { + const source = code(read(path)); + expect(source).not.toMatch(/paddingTop:\s*"env\(safe-area-inset-top/); + expect(source).not.toMatch(/\bpt-\[env\(safe-area-inset-top/); + }); + + it("globals.css defines the shell band, the keyboard rules and pointer-fine", () => { + const css = read("src/app/globals.css"); + expect(css).toMatch( + /\.shell-safe-area\s*\{[^}]*padding-top:\s*env\(safe-area-inset-top/, + ); + expect(css).toMatch( + /\[data-sheet-side="bottom"\]\s*\{\s*bottom:\s*var\(--keyboard-inset/, + ); + expect(css).toMatch( + /html\[data-keyboard="open"\] \[data-keyboard-hide\]\s*\{\s*display:\s*none/, + ); + expect(css).toMatch(/@custom-variant pointer-fine/); + }); + + it("the bottom bar and the Coach button step aside for the keyboard", () => { + expect(read("src/components/layout/bottom-nav.tsx")).toContain( + 'data-keyboard-hide=""', + ); + expect(read("src/components/insights/layout-coach-fab.tsx")).toContain( + 'data-keyboard-hide=""', + ); + }); + + it("the sheet primitive names its side for those rules", () => { + expect(read("src/components/ui/sheet.tsx")).toMatch( + /data-sheet-side=\{side\}/, + ); + }); +}); + +describe("form fields keep 16 px on a touch screen", () => { + const FIELDS = [ + "src/components/ui/input.tsx", + "src/components/ui/textarea.tsx", + "src/components/ui/native-select.tsx", + "src/components/ui/date-field.tsx", + "src/components/ui/time-field.tsx", + ]; + + it.each(FIELDS)( + "%s sets text-base and shrinks only under a fine pointer", + (path) => { + // The field's own box is the class string that draws its border; the + // rest of the file (a picker's buttons, a caption) is not typed into. + const boxes = (code(read(path)).match( + /"[^"]*\bborder-input(?![\w-])[^"]*"/g, + ) ?? []) as string[]; + expect(boxes.length, `${path} has no field class`).toBeGreaterThan(0); + for (const box of boxes) { + expect(box).toMatch(/\btext-base\b/); + // Any sub-16 px size must sit behind `pointer-fine:`; `file:` styles + // the upload button, not typed text. + const bare = box.match( + /(?) => setTimeout(...args), + clearTimeout: (id: ReturnType) => clearTimeout(id), }; vm.createContext(context); vm.runInContext(SW_SOURCE, context); @@ -617,6 +621,23 @@ describe("sw.js — networkFirst offline fallback", () => { }); }); +describe("sw.js — offline page", () => { + it("offers a retry control, reloads by itself when the connection returns, and follows the device scheme", async () => { + const harness = bootServiceWorker(); + const res = await dispatchNavigationFetch(harness, "/"); + expect(res.status).toBe(503); + const html = await res.text(); + // A real control, not a decorative icon: a link back to the same URL. + expect(html).toMatch(/ { it("does not cache a navigation response that carries Cache-Control: no-store", async () => { const harness = bootServiceWorker(); diff --git a/src/app/__tests__/error-chunk-reload-guard.test.ts b/src/app/__tests__/error-chunk-reload-guard.test.ts index 5e2c5a55a..96cd5ec36 100644 --- a/src/app/__tests__/error-chunk-reload-guard.test.ts +++ b/src/app/__tests__/error-chunk-reload-guard.test.ts @@ -1,5 +1,6 @@ /** - * Unit tests for the chunk-error auto-reload guard in `src/app/error.tsx`. + * Unit tests for the chunk-error auto-reload guard in + * `src/lib/pwa/chunk-reload.ts` (both error boundaries call it). * * The guard is keyed on the running shell's build version: one reload * attempt per BROKEN SHELL, not one per session. After a successful @@ -10,7 +11,11 @@ import { describe, expect, it } from "vitest"; -import { chunkReloadGuardValue, shouldAttemptChunkReload } from "../error"; +import { + chunkReloadGuardValue, + isChunkLoadError, + shouldAttemptChunkReload, +} from "@/lib/pwa/chunk-reload"; describe("shouldAttemptChunkReload", () => { it("reloads when no attempt is recorded", () => { @@ -39,3 +44,39 @@ describe("shouldAttemptChunkReload", () => { expect(shouldAttemptChunkReload("unversioned", "")).toBe(false); }); }); + +describe("isChunkLoadError", () => { + it.each([ + [{ name: "ChunkLoadError", message: "x" }], + [{ name: "Error", message: "Loading chunk 123 failed." }], + [{ name: "Error", message: "Loading CSS chunk 7 failed." }], + [ + { + name: "Error", + message: "Failed to load chunk /_next/static/chunks/a.js", + }, + ], + [ + { + name: "TypeError", + message: "Failed to fetch dynamically imported module: /x.js", + }, + ], + // WebKit and Firefox word a failed dynamic import differently. + [{ name: "TypeError", message: "Importing a module script failed." }], + [ + { + name: "TypeError", + message: "error loading dynamically imported module: /x.js", + }, + ], + ])("recognises %j", (err) => { + expect(isChunkLoadError(err)).toBe(true); + }); + + it("leaves other errors to the error page", () => { + expect( + isChunkLoadError({ name: "TypeError", message: "x is undefined" }), + ).toBe(false); + }); +}); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 386510a8f..32f0e0ce8 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -328,8 +328,10 @@ export default function CoachConversationsPage() { // mobile-only BottomNav band). L4 fix // (`.planning/audits/2026-07-18-qa-ui.md`) — this had dropped the // `safe-area-inset-top` term `/coach` subtracts, overshooting by - // ~50px on notched devices; restored to match exactly. - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" + // ~50px on notched devices; restored to match exactly. v1.42 — the + // shell now takes that inset above the top bar at every width, so + // the desktop height subtracts it too, and `/coach/plans` matches. + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]" > diff --git a/src/app/coach/plans/page.tsx b/src/app/coach/plans/page.tsx index 0a84f528c..b02fddcdc 100644 --- a/src/app/coach/plans/page.tsx +++ b/src/app/coach/plans/page.tsx @@ -293,8 +293,10 @@ export default function CoachPlansPage() { data-slot="coach-plans-page" // Match `/coach/conversations`' full-bleed sizing: cancel the AuthShell // padding and claim the viewport height below the top bar (minus the - // mobile-only BottomNav band). - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" + // mobile-only BottomNav band). The status-bar inset of an installed + // app comes off as well, at every width: the shell takes it above + // the top bar (`shell-safe-area`). + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]" > diff --git a/src/app/error.tsx b/src/app/error.tsx index 90c65e283..f51ae9359 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -2,47 +2,7 @@ import { useEffect } from "react"; import { ErrorDetails } from "@/components/error-details"; - -const CHUNK_RELOAD_KEY = "healthlog:chunk-reload-attempted"; -const SHELL_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ""; - -function isChunkLoadError(err: Error & { digest?: string }): boolean { - if (err.name === "ChunkLoadError") return true; - const msg = err.message ?? ""; - return ( - msg.includes("Loading chunk") || - msg.includes("Loading CSS chunk") || - msg.includes("Failed to load chunk") || - msg.includes("Failed to fetch dynamically imported module") - ); -} - -/** - * Guard value recorded per auto-reload attempt — the running shell's - * build version, so the suppression is scoped to ONE shell. After a - * successful heal the reloaded page carries the NEW version and the - * guard re-arms by construction; only a shell that reloaded and is - * STILL broken (the new build is also missing the chunk) stays - * suppressed. Builds without an injected version degrade to the legacy - * once-per-session behaviour via the "unversioned" sentinel. - */ -export function chunkReloadGuardValue(shellVersion: string): string { - return shellVersion || "unversioned"; -} - -/** - * Pure reload decision so the guard semantics carry direct unit - * coverage. `stored` is the sessionStorage value from the last attempt - * (`null` when none happened); legacy guard values (the pre-v1.16.8 - * timestamp) never match a version string, so a session stuck from - * before the change heals on its next chunk error. - */ -export function shouldAttemptChunkReload( - stored: string | null, - shellVersion: string, -): boolean { - return stored !== chunkReloadGuardValue(shellVersion); -} +import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload"; export default function AppError({ error, @@ -60,30 +20,10 @@ export default function AppError({ g.__healthlog_onError?.(error); } - // v1.4.38.3 — auto-recover from a stale-shell chunk-load error. - // After a deploy the cached shell still references old chunk - // filenames; they 404 and Next.js surfaces them as `ChunkLoadError`. - // A single hard reload fetches the new shell + chunk graph. - // The sessionStorage guard is keyed on the shell version (see - // `chunkReloadGuardValue`) so it suppresses a loop within ONE - // broken shell but re-arms after every deploy — the pre-v1.16.8 - // once-per-session guard exhausted on multi-deploy days and left - // the user on the error page with no automatic way out. - if (typeof window !== "undefined" && isChunkLoadError(error)) { - try { - const stored = window.sessionStorage.getItem(CHUNK_RELOAD_KEY); - if (shouldAttemptChunkReload(stored, SHELL_VERSION)) { - window.sessionStorage.setItem( - CHUNK_RELOAD_KEY, - chunkReloadGuardValue(SHELL_VERSION), - ); - window.location.reload(); - } - } catch { - // sessionStorage can throw under strict privacy modes — fall - // through to the error UI in that case. - } - } + // v1.4.38.3 — auto-recover from a stale-shell chunk-load error with a + // single reload (`src/lib/pwa/chunk-reload.ts`, shared with the root + // boundary). + reloadOnceForChunkError(error); }, [error]); return ; diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 23170697b..536dbe0aa 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,5 +1,9 @@ "use client"; +import { useEffect } from "react"; + +import { reloadOnceForChunkError } from "@/lib/pwa/chunk-reload"; + /** * Root-level error boundary. Renders when even the root layout itself fails, * so it cannot rely on any providers (i18n, query, auth). Keep it static, @@ -12,6 +16,12 @@ export default function GlobalError({ error: Error & { digest?: string }; reset: () => void; }) { + // A chunk of the root layout's client tree missing after a deploy lands + // here rather than in `app/error.tsx`; it gets the same single reload. + useEffect(() => { + reloadOnceForChunkError(error); + }, [error]); + async function handleCopy() { // Strip query string — OAuth callback routes carry sensitive tokens. const loc = typeof window !== "undefined" ? window.location : null; diff --git a/src/app/globals.css b/src/app/globals.css index 91d72e44d..e2f619321 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -32,6 +32,20 @@ @custom-variant dark (&:is(.dark *)); +/* + * `pointer-fine:` — a mouse or trackpad is the primary pointer. Form fields + * take the compact 14 px type only under it: iOS and iPadOS Safari zoom the + * page into any focused field whose text is smaller than 16 px, and a width + * breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways + * (844 px) and to every iPad. Touch-first screens keep the 16 px base at + * every width. + */ +@custom-variant pointer-fine { + @media (hover: hover) and (pointer: fine) { + @slot; + } +} + @theme inline { /* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the default scale starts at sm (640px), which leaves a gap between an iPhone SE @@ -511,6 +525,69 @@ } } +/* + * `.shell-safe-area` — the authenticated app shell's share of the installed + * app's safe area (see the note in `auth-shell.tsx`). The shell takes the top + * and side insets as padding, and the strip behind the status bar is painted + * in the chrome colour (`--card`, the top bar and the sidebar) rather than + * the page colour, so the status bar reads as part of the bar beneath it. A + * zero inset makes the gradient zero tall: nothing changes in a browser tab. + */ +.shell-safe-area { + padding-top: env(safe-area-inset-top, 0px); + padding-right: env(safe-area-inset-right, 0px); + padding-left: env(safe-area-inset-left, 0px); + background: linear-gradient(var(--card), var(--card)) top / 100% + env(safe-area-inset-top, 0px) no-repeat; +} + +/* + * The on-screen keyboard. iOS Safari, and Chrome on Android by default, + * shrink only the visual viewport when the keyboard opens: everything + * `position: fixed` stays anchored to the layout viewport, so a bottom sheet + * keeps its footer (Save, the Coach composer) behind the keyboard and the + * bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once + * in the providers) publishes the covered height as `--keyboard-inset` and + * sets `data-keyboard` on `` while it is open. + * + * Bottom sheets then stand on the keyboard and give up the height it takes, + * so their own scroll body (every bottom sheet is a flex column with a + * scrolling middle) carries the form, and the bottom bar and the Coach + * button step aside until the keyboard is gone. + */ +[data-sheet-side="bottom"] { + bottom: var(--keyboard-inset, 0px); +} +html[data-keyboard="open"] [data-sheet-side="bottom"] { + max-height: calc( + 100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) - 0.5rem + ) !important; +} +html[data-keyboard="open"] [data-keyboard-hide] { + display: none !important; +} + +/* + * Side and top sheets run the full height of the window. In an installed + * app on a tablet the status bar sits over their first 24 px, and a phone + * held sideways has its notch over one side, so the panel keeps its header + * clear of both. A transparent border rather than padding: every consumer + * sets its own padding (often `p-0`), which would override a padding here, + * while the border adds the clearance under any of them and lets the + * panel's own background show through it. + */ +[data-sheet-side="right"], +[data-sheet-side="left"], +[data-sheet-side="top"] { + border-top: env(safe-area-inset-top, 0px) solid transparent; +} +[data-sheet-side="right"] { + border-right: env(safe-area-inset-right, 0px) solid transparent; +} +[data-sheet-side="left"] { + border-left: env(safe-area-inset-left, 0px) solid transparent; +} + /* * `.no-scrollbar` — keeps a container scrollable but hides the visible * bar. Used by the mobile section strips in `` and 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/__tests__/version-poller.test.ts b/src/components/__tests__/version-poller.test.ts index fb3a6b938..fd77fae98 100644 --- a/src/components/__tests__/version-poller.test.ts +++ b/src/components/__tests__/version-poller.test.ts @@ -52,3 +52,95 @@ describe("resolveVersionPollDecision", () => { ).toBe("reload"); }); }); + +describe("evictAndReload", () => { + /** + * The Reload action used to unregister every service worker. Unregistering + * a registration ends its Web Push subscription, and nothing re-creates it, + * so each update silently switched push reminders off in the installed app. + * The action now updates the registration and deletes only HealthLog's + * caches. + */ + function stubBrowser(cacheNames: string[]) { + const calls = { update: 0, unregister: 0, reload: 0 }; + const deleted: string[] = []; + const registration = { + update: async () => { + calls.update += 1; + }, + unregister: async () => { + calls.unregister += 1; + return true; + }, + }; + const g = globalThis as Record; + const saved = { + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), + caches: g.caches, + window: g.window, + sessionStorage: g.sessionStorage, + }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + serviceWorker: { getRegistrations: async () => [registration] }, + }, + }); + g.caches = { + keys: async () => cacheNames, + delete: async (name: string) => { + deleted.push(name); + return true; + }, + }; + g.window = { + location: { + reload: () => { + calls.reload += 1; + }, + }, + }; + g.sessionStorage = { setItem: () => {}, getItem: () => null }; + const restore = () => { + if (saved.navigator) + Object.defineProperty(globalThis, "navigator", saved.navigator); + g.caches = saved.caches; + g.window = saved.window; + g.sessionStorage = saved.sessionStorage; + }; + return { calls, deleted, restore }; + } + + it("updates the worker instead of unregistering it, so the push subscription survives", async () => { + const { evictAndReload } = await import("../version-poller"); + const browser = stubBrowser([]); + try { + await evictAndReload("9.9.9"); + } finally { + browser.restore(); + } + expect(browser.calls.unregister).toBe(0); + expect(browser.calls.update).toBe(1); + expect(browser.calls.reload).toBe(1); + }); + + it("deletes HealthLog's caches and leaves another app's on the same origin", async () => { + const { evictAndReload } = await import("../version-poller"); + const browser = stubBrowser([ + "healthlog-static-v1.41.2", + "healthlog-pages-v1.41.2", + "healthlog-data-v1.41.2", + "other-app-runtime", + ]); + try { + await evictAndReload("9.9.9"); + } finally { + browser.restore(); + } + expect(browser.deleted.sort()).toEqual([ + "healthlog-data-v1.41.2", + "healthlog-pages-v1.41.2", + "healthlog-static-v1.41.2", + ]); + }); +}); diff --git a/src/components/day/day-layer.tsx b/src/components/day/day-layer.tsx index 7d6f39751..d2a002d75 100644 --- a/src/components/day/day-layer.tsx +++ b/src/components/day/day-layer.tsx @@ -249,10 +249,9 @@ function DayLayer() { className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150" > {view(DockedTitle, { - headerClassName: cn( - SHELL_HEADER_BAND, - "border-border pt-[env(safe-area-inset-top,0px)]", - ), + // No status-bar inset here: the docked column sits inside + // the app shell, which takes it (`shell-safe-area`). + headerClassName: cn(SHELL_HEADER_BAND, "border-border"), })} @@ -436,7 +435,13 @@ function DaySheet({ bottom ? cn( "rounded-t-2xl transition-[height] duration-200 motion-reduce:transition-none", - full ? "h-[calc(100dvh-2.5rem)]" : "h-[58dvh]", + // Full height leaves a strip of the page above the + // sheet, and never less than the status bar of an + // installed app plus a margin: a 2.5rem strip put the + // handle under a 59 px Dynamic Island. + full + ? "h-[calc(100dvh-max(2.5rem,env(safe-area-inset-top,0px)+0.75rem))]" + : "h-[58dvh]", ) : "w-105 max-w-[90vw] sm:max-w-105", )} diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index 5a6b43446..32be51f50 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -69,13 +69,22 @@ describe("", () => { // bottom-nav hides (`md:hidden`) — so the FAB never floats mid-air // in the 768-1023px band. v1.18.1 (C5) — the corner inset is // symmetric: right equals bottom at each breakpoint (`right-6` - // mobile-band / `md:right-8` desktop mirrors `md:bottom-8`). - expect(html).toContain("fixed right-6"); - expect(html).toContain("md:right-8"); + // mobile-band / `md:right-8` desktop mirrors `md:bottom-8`). v1.42 — + // each offset sits on top of the matching safe-area inset (the notch + // of a phone held sideways, an installed iPad's home indicator). + expect(html).toContain( + "fixed right-[calc(env(safe-area-inset-right,0px)+1.5rem)]", + ); + expect(html).toContain( + "md:right-[calc(env(safe-area-inset-right,0px)+2rem)]", + ); expect(html).toContain( "bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]", ); - expect(html).toContain("md:bottom-8"); + expect(html).toContain( + "md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]", + ); + expect(html).toContain('data-keyboard-hide=""'); expect(html).not.toContain("md:bottom-6"); expect(html).not.toContain("lg:bottom-6"); }); diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index a6813f540..d651dd7d0 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -380,7 +380,7 @@ export function CoachInput({ autoCapitalize="sentences" rows={1} className={cn( - "order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm", + "order-1 w-full min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:pointer-fine:text-sm", "px-2 py-1.5", "max-h-[9.5rem] overflow-auto", "[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]", diff --git a/src/components/insights/coach-panel/conversations-panel.tsx b/src/components/insights/coach-panel/conversations-panel.tsx index 381d2202b..ec10a47c5 100644 --- a/src/components/insights/coach-panel/conversations-panel.tsx +++ b/src/components/insights/coach-panel/conversations-panel.tsx @@ -209,12 +209,9 @@ export function ConversationsPanel({ const header = (inSheet: boolean) => (
trackKeyboardInset(window, document.documentElement), []); + return null; +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 68a360bc8..35dc5b618 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -278,13 +278,18 @@ export function AuthShell({ if (isStandalonePublicPage) { return <>{children}; } + // `min-h-dvh`, not `h-dvh`: a phone held sideways is ~390 px tall, and + // a sign-in form that is taller than a fixed-height box centres out of + // it at the top, where nothing can scroll back to it. A minimum lets the + // box grow and the document scroll. The safe-area padding keeps the form + // off the status bar and the home indicator in an installed app. return ( -
+
{demoMode ? : null}
{children}
@@ -352,7 +357,7 @@ export function AuthShell({ {showUnlockNotifier && user?.id ? ( ) : null} -
+
{children} @@ -418,7 +423,21 @@ export function AuthShell({ vertical scroller. Measured in `e2e/chrome-header-seam-banners.spec.ts` at both breakpoints. */} -
+ {/* + The installed app draws under the status bar and beside the notch + (`viewport-fit=cover` + `black-translucent`), so the shell takes the + insets once, here, for everything inside it: the banner stack, the + top bar, the sidebar's logo band and any docked side panel all start + below the status bar together, and the seam between the top bar and + the logo band cannot come apart over an inset only one of them + reserved. The bands used to pad themselves inside their 4rem + (border-box), which on a phone with a 59 px inset left the top bar + five pixels of content box and pushed the logo and the account menu + across its bottom border. `shell-safe-area` paints the strip behind + the status bar in the chrome colour (globals.css). The bottom inset + stays with the bottom nav and `
`, which own that edge. + */} +
{/* v1.4.43 QoL (M5) — `` paints only when `navigator.onLine === false`. Sits above the maintainership diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index 22e182adf..82470be9a 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -200,9 +200,15 @@ export function BottomNav() { `bg-card` + `border-t` give the bar a clean visual edge so page content that scrolls under the bar is occluded, not bleeding through. */} + {/* The side insets keep the outer tabs clear of the notch on a phone + held sideways. `data-keyboard-hide`: while the on-screen keyboard + is open the bar steps aside (globals.css) instead of floating + over the field being typed into. */}