From d2be2b181c0baa9f33bfc1a5dcad75c98313f2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:30:49 +0200 Subject: [PATCH] fix(shell): read the home-indicator inset through one property The rail, main, the bottom bar and the Coach button each read env(safe-area-inset-bottom) on their own. They now read --safe-area-bottom, which is that env() value, so the four always agree and a test can set an inset that no desktop browser reports. The app shell's root also states the content colour itself instead of leaving it to propagate from the body. A WKWebView browser on iPadOS (Edge, Chrome, Firefox) can keep the strip over the home indicator out of the page's layout and fill it with the colour it takes from the root; that strip now meets main in the same colour. --- src/app/globals.css | 21 ++++++++++++ .../__tests__/layout-coach-fab.test.tsx | 6 ++-- src/components/insights/layout-coach-fab.tsx | 4 +-- .../layout/__tests__/shell-viewport.test.ts | 34 +++++++++++++++---- .../layout/__tests__/sidebar-nav.test.tsx | 2 +- src/components/layout/bottom-nav.tsx | 4 +-- src/components/layout/shell-metrics.ts | 2 +- src/components/layout/sidebar-nav.tsx | 2 +- 8 files changed, 57 insertions(+), 18 deletions(-) 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() {