diff --git a/src/app/globals.css b/src/app/globals.css index 5b38c8599..1caae59bd 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -612,6 +612,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; } @@ -637,6 +647,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() {