diff --git a/e2e/tablet-shell.spec.ts b/e2e/tablet-shell.spec.ts index 5dfd258a2..66d3fc6fe 100644 --- a/e2e/tablet-shell.spec.ts +++ b/e2e/tablet-shell.spec.ts @@ -1,5 +1,6 @@ import type { Locator, Page } from "@playwright/test"; +import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities"; import { mockDay } from "./setup/day-mock"; import { expect, test } from "./setup/test"; import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; @@ -418,3 +419,194 @@ test("a vertical swipe that starts on a tab strip is the page's", async ({ ); expect(touchAction).toBe("auto"); }); + +/** + * An iPad without a home button reports a bottom inset of about 20 px, which + * no browser under test does. The shell reads the inset through + * `--safe-area-bottom` (globals.css), so setting the property is the same + * inset to every element that owns the bottom edge. + */ +const HOME_INDICATOR = 20; + +async function setBottomInset(page: Page, px: number) { + await page.addStyleTag({ + content: `:root { --safe-area-bottom: ${px}px !important; }`, + }); +} + +/** What paints the window's last row of pixels, sampled across its width. */ +async function bottomRowOwners(page: Page) { + return page.evaluate(() => { + const y = innerHeight - 1; + const owners: string[] = []; + // 8 px in lands on the rail at its narrowest (64 px); the rest spread + // across the content column. + for (const x of [ + 8, + ...Array.from({ length: 11 }, (_, i) => (innerWidth * (i + 1)) / 12), + ]) { + const hit = document.elementFromPoint(x, y); + const owner = !hit + ? "nothing" + : hit === document.documentElement || hit === document.body + ? "page" + : hit.closest("aside") + ? "rail" + : hit.closest("#main-content") + ? "main" + : hit.closest('[data-slot="bottom-nav"]') + ? "bar" + : hit.closest('[data-slot="coach-fab"]') + ? "fab" + : `other:${hit.tagName}`; + owners.push(owner); + } + return owners; + }); +} + +const INSET_VIEWPORTS = [ + { name: "iPad mini portrait", width: 744, height: 1133 }, + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, + { name: "iPad Pro 13 landscape", width: 1366, height: 1024 }, + { name: "Split View two thirds", width: 694, height: 1024 }, +] as const; + +for (const vp of INSET_VIEWPORTS) { + test(`${vp.name}: the shell paints to the edge under a home indicator`, async ({ + page, + }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + await serveAiBlock(page, aiBlockAvailable()); + await openDashboard(page); + await setBottomInset(page, HOME_INDICATOR); + await expectShellPinned(page); + + // The last row of pixels belongs to the rail and `
` (or the bar), + // never to the page behind the shell: that was the band. + const owners = await bottomRowOwners(page); + expect(owners.filter((o) => o === "page" || o === "nothing")).toEqual([]); + + const railShown = vp.width >= 768; + const edge = await page.evaluate(() => { + const aside = document.querySelector("aside")!; + const column = aside.querySelector('[data-slot="sidebar-column"]'); + const bar = document.querySelector( + '[data-slot="bottom-nav"]', + )!; + return { + innerHeight, + asideBottom: aside.getBoundingClientRect().bottom, + lastRailRowBottom: + column?.lastElementChild?.getBoundingClientRect().bottom ?? 0, + barBottom: bar.getBoundingClientRect().bottom, + barPad: parseFloat(getComputedStyle(bar).paddingBottom), + // What a WKWebView browser paints where it keeps the page out: the + // root's own background. + root: getComputedStyle(document.documentElement).backgroundColor, + body: getComputedStyle(document.body).backgroundColor, + }; + }); + if (railShown) { + expect(owners).toContain("rail"); + expect(owners).toContain("main"); + // The rail's background runs to the edge; its account row stops + // above the inset. + expect(edge.asideBottom).toBe(edge.innerHeight); + expect(edge.lastRailRowBottom).toBeLessThanOrEqual( + edge.innerHeight - HOME_INDICATOR, + ); + expect(edge.root).toBe(edge.body); + } else { + expect(owners).toContain("bar"); + expect(edge.barBottom).toBe(edge.innerHeight); + expect(edge.barPad).toBe(HOME_INDICATOR); + expect(edge.root).toBe(edge.body); + } + + // At the end of the page, the last line stands clear of the inset, the + // bar and the Coach button. + await scrollMainToEnd(page); + const end = await measureMainEnd(page); + expect(end.atEnd).toBe(true); + expect(end.contentBottom).toBeLessThanOrEqual( + edge.innerHeight - HOME_INDICATOR, + ); + if (end.barTop !== null) { + expect(end.contentBottom).toBeLessThanOrEqual(end.barTop); + } + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible(); + const fabTop = (await fab.boundingBox())!.y; + expect(end.contentBottom).toBeLessThanOrEqual(fabTop); + }); +} + +/** + * The Coach button over the last card of a nested layout (Insights, Settings, + * a values list): during the scroll it may sit over content, at the end it + * must not. Rendered by serving an `ai` block with the Coach available, + * since the suite has no provider. + */ +const NESTED_PAGES = [ + "/insights/blood-pressure", + "/insights/values/BLOOD_PRESSURE_SYS", + "/insights", + "/settings/account", + "/settings/modules", +] as const; + +for (const vp of [ + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, +] as const) { + for (const inset of [0, HOME_INDICATOR]) { + test(`${vp.name}, inset ${inset}: the Coach button clears the last line of nested layouts`, async ({ + page, + }) => { + test.setTimeout(90_000); + await page.setViewportSize({ width: vp.width, height: vp.height }); + await serveAiBlock(page, aiBlockAvailable()); + for (const path of NESTED_PAGES) { + await page.goto(path, { waitUntil: "domcontentloaded" }); + await expect( + page.locator('#main-content [data-slot="main-content-wrapper"]'), + ).toBeVisible(); + await page.waitForLoadState("networkidle"); + if (inset) await setBottomInset(page, inset); + const fab = page.locator('[data-slot="coach-fab"]'); + await expect(fab).toBeVisible(); + await scrollMainToEnd(page); + const covered = await page.evaluate(() => { + const f = document + .querySelector('[data-slot="coach-fab"]')! + .getBoundingClientRect(); + const main = document.getElementById("main-content")!; + const under: string[] = []; + for (const el of main.querySelectorAll("*")) { + const cs = getComputedStyle(el); + if (cs.position === "fixed" || cs.visibility === "hidden") continue; + const paints = + (el.childElementCount === 0 && !!el.textContent?.trim()) || + cs.borderBottomWidth !== "0px" || + cs.backgroundColor !== "rgba(0, 0, 0, 0)"; + if (!paints) continue; + const b = el.getBoundingClientRect(); + if (!b.width || !b.height) continue; + if ( + b.right > f.left && + b.left < f.right && + b.bottom > f.top && + b.top < f.bottom + ) { + under.push(el.getAttribute("data-slot") ?? el.tagName); + } + } + return under; + }); + expect(covered, `${path}: content under the Coach button`).toEqual([]); + } + }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index c2d7b4f6d..b8dcc6df5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -626,6 +626,16 @@ body, * Scoping it to the body-scrolled branch leaves exactly one gutter per * branch. */ + /* + * The home-indicator inset, read through one property by everything that + * owns the shell's bottom edge (the rail, `
`, the bottom bar, the + * Coach button). It is `env()` everywhere it matters; a test that needs an + * inset no desktop browser reports sets it instead + * (`e2e/tablet-shell.spec.ts`). + */ + :root { + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + } html[data-scroll="body"] { scrollbar-gutter: stable; } @@ -651,6 +661,17 @@ body, overflow: hidden; overscroll-behavior: none; } + /* + * The colour of anything beyond the page. A browser built on WKWebView + * (Edge, Chrome, Firefox on iPadOS) can keep the strip over the home + * indicator out of the page's layout and fill it with the page's + * "under-page" colour, which it takes from the root. The root states the + * content colour itself rather than leaving it to propagate from the body, + * so that strip meets `
` in the same colour. + */ + html[data-scroll="shell"] { + background-color: var(--background); + } body { @apply bg-background text-foreground; } diff --git a/src/components/insights/__tests__/layout-coach-fab.test.tsx b/src/components/insights/__tests__/layout-coach-fab.test.tsx index 089094891..2de587bd6 100644 --- a/src/components/insights/__tests__/layout-coach-fab.test.tsx +++ b/src/components/insights/__tests__/layout-coach-fab.test.tsx @@ -78,13 +78,11 @@ describe("", () => { 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("bottom-[calc(var(--safe-area-bottom)+5rem)]"); // The bottom offset follows the shell, not a width: a phone held // sideways keeps the bottom nav, so the FAB keeps clearing it there. expect(html).toContain( - "shell-desktop:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]", + "shell-desktop:bottom-[calc(var(--safe-area-bottom)+2rem)]", ); expect(html).toContain('data-keyboard-hide=""'); expect(html).not.toContain("md:bottom-6"); diff --git a/src/components/insights/layout-coach-fab.tsx b/src/components/insights/layout-coach-fab.tsx index 164c4c825..f32c958de 100644 --- a/src/components/insights/layout-coach-fab.tsx +++ b/src/components/insights/layout-coach-fab.tsx @@ -240,13 +240,13 @@ export function LayoutCoachFab() { // sits symmetrically in the corner. On mobile the right inset is // `right-6` but the bottom is deliberately larger — it clears the // 64 px bottom-nav plus the home-indicator safe-area inset - // (`bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]`), so the + // (`bottom-[calc(var(--safe-area-bottom)+5rem)]`), so the // gap is asymmetric there by design, not drift. // v1.42 — the side and bottom insets apply at every width: a // phone held sideways (844 px, past `md`) has its notch on one // side, and an installed iPad its home indicator along the bottom. "fixed right-[calc(env(safe-area-inset-right,0px)+1.5rem)] z-40 size-14 rounded-full shadow-lg md:right-[calc(env(safe-area-inset-right,0px)+2rem)]", - "shell-desktop:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]", + "shell-desktop:bottom-[calc(var(--safe-area-bottom)+2rem)] bottom-[calc(var(--safe-area-bottom)+5rem)]", // Dark glyph on the purple/pink gradient — white sat at // ≈2.3:1 against the gradient midpoint; the background token // reads ≈6.5:1. diff --git a/src/components/layout/__tests__/shell-viewport.test.ts b/src/components/layout/__tests__/shell-viewport.test.ts index d658a879d..28e5ebfa7 100644 --- a/src/components/layout/__tests__/shell-viewport.test.ts +++ b/src/components/layout/__tests__/shell-viewport.test.ts @@ -43,7 +43,7 @@ describe("the shell takes the window's edges", () => { ); // `fixed` re-rasterises the whole app in its own layer and traps the // bottom bar's z-index in a stacking context. - expect(shell).not.toContain("fixed"); + expect(shell.filter((c) => /(^|:)fixed$/.test(c))).toEqual([]); expect(SHELL_ROOT_CLASS).not.toMatch( /\b(?:min-)?h-(?:dvh|svh|lvh|screen)\b/, ); @@ -64,6 +64,28 @@ describe("the shell takes the window's edges", () => { }); }); +describe("the home-indicator inset has one source", () => { + it("is env() behind one property, which every owner of the bottom edge reads", () => { + const css = readSource("src/app/globals.css", "--safe-area-bottom"); + expect(css).toMatch( + /:root\s*\{\s*--safe-area-bottom:\s*env\(safe-area-inset-bottom,\s*0px\);/, + ); + for (const [path, anchor] of [ + ["src/components/layout/sidebar-nav.tsx", "export function SidebarNav"], + ["src/components/layout/bottom-nav.tsx", 'data-slot="bottom-nav"'], + ["src/components/insights/layout-coach-fab.tsx", 'data-slot="coach-fab"'], + ] as const) { + expect(readSource(path, anchor)).toContain("--safe-area-bottom"); + } + expect(MAIN_SCROLL_CLASS).not.toContain("safe-area-inset-bottom"); + // Where a WKWebView browser keeps the page out of the strip over the + // home indicator, it fills it with the root's colour: the content's. + expect(css).toMatch( + /html\[data-scroll="shell"\]\s*\{\s*background-color:\s*var\(--background\);\s*\}/, + ); + }); +}); + describe("the bottom edge is cleared in both shells", () => { const fab = readSource( "src/components/insights/layout-coach-fab.tsx", @@ -71,12 +93,12 @@ describe("the bottom edge is cleared in both shells", () => { ); const fabSize = rem(/\bsize-(\d+)\b/.exec(fab)![0]); const phoneFabBottom = rem( - /(? { it("the desktop shell's main keeps the home indicator off the last line", () => { expect(main).not.toContain("shell-desktop:pb-0"); - expect(main).toContain( - "shell-desktop:pb-[env(safe-area-inset-bottom,0px)]", - ); + expect(main).toContain("shell-desktop:pb-(--safe-area-bottom)"); }); it("the last line scrolls clear of the Coach button in the phone shell", () => { const mainPad = Number( - /^pb-\[calc\((\d+(?:\.\d+)?)rem\+env\(safe-area-inset-bottom,0px\)\)\]$/.exec( + /^pb-\[calc\((\d+(?:\.\d+)?)rem\+var\(--safe-area-bottom\)\)\]$/.exec( main.find((c) => c.startsWith("pb-[calc("))!, )![1], ); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index b123458a2..6243311c0 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -600,7 +600,7 @@ describe(" keeps every destination reachable on a touch tablet", () it("gives the rail the home indicator's inset, so the avatar row stays clear", () => { const aside = /]*class="([^"]*)"/.exec(render())?.[1] ?? ""; - expect(aside.split(" ")).toContain("pb-[env(safe-area-inset-bottom,0px)]"); + expect(aside.split(" ")).toContain("pb-(--safe-area-bottom)"); }); it("draws the scroll cue for a finger only, outside the focus order and the accessibility tree", () => { diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index dbb11755a..40e7fa710 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -195,7 +195,7 @@ export function BottomNav() { <> {/* v1.4.33 — bar height and safe-area defence so the bar can never overlap the last viewport line of scrollable page content. - `min-h-16` floors the bar at 64px; `pb-[env(safe-area-inset-bottom)]` + `min-h-16` floors the bar at 64px; `pb-(--safe-area-bottom)` reserves the home-indicator inset on iOS / iPadOS PWA installs; `bg-card` + `border-t` give the bar a clean visual edge so page content that scrolls under the bar is occluded, not bleeding @@ -208,7 +208,7 @@ export function BottomNav() { aria-label={t("nav.mobileNavigation")} data-slot="bottom-nav" data-keyboard-hide="" - className="bg-card border-border shell-desktop:hidden fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] backdrop-blur-md" + className="bg-card border-border shell-desktop:hidden fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pr-[env(safe-area-inset-right)] pb-(--safe-area-bottom) pl-[env(safe-area-inset-left)] backdrop-blur-md" >
{primaryLeft.map(renderPrimary)} diff --git a/src/components/layout/shell-metrics.ts b/src/components/layout/shell-metrics.ts index 284c56344..fba668a0e 100644 --- a/src/components/layout/shell-metrics.ts +++ b/src/components/layout/shell-metrics.ts @@ -62,7 +62,7 @@ export const SHELL_ROOT_CLASS = * browser tab), so the last line never sits under it. */ export const MAIN_SCROLL_CLASS = - "relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] shell-desktop:pb-[env(safe-area-inset-bottom,0px)]"; + "relative flex-1 overflow-y-auto pb-[calc(4rem+var(--safe-area-bottom))] shell-desktop:pb-(--safe-area-bottom)"; /** * The app-chrome header band: one 4rem strip whose bottom border draws the diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index d23ad6c27..656656c3f 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -627,7 +627,7 @@ export function SidebarNav() {