From a4ad8ebe927f0cfc7aba0dd6e7fe73b5816a1a92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 17:24:17 +0200 Subject: [PATCH] fix(mobile): fit the shell and sheets to safe areas and the on-screen keyboard Safe areas. The installed app draws under the status bar and beside the notch, and each 4rem header band padded the inset inside its own border-box height: on a phone with a 59 px inset the top bar kept a 5 px content box and the logo and account menu straddled its bottom border. The authenticated shell now takes the top and side insets once on its outer wrapper (`shell-safe-area`, the strip painted in the chrome colour), so the top bar, the sidebar band and any docked panel start below the status bar together and the seam between them holds. The two Coach full-height pages subtract the inset at every width, the full- height day sheet keeps its handle clear of the Dynamic Island, side sheets keep clear of the status bar and the notch, the bottom bar and the Coach button take the side insets of a phone held sideways, a footer-less responsive sheet keeps its last row above the home indicator, and the sign-in and onboarding shells pad themselves. The sign-in shell grows with its content (`min-h-dvh`), so a phone held sideways can scroll to the top of the card. Keyboard. iOS Safari and Chrome on Android shrink only the visual viewport when the keyboard opens, so a bottom sheet kept Save and the Coach composer behind it. `KeyboardInsetBridge` publishes the covered height (`--keyboard-inset`, `data-keyboard` on ); bottom sheets stand on the keyboard and give up its height to their scrolling body, and the bottom bar and the Coach button step aside while it is open. Pinch-zoom and toolbar-sized changes do not count as a keyboard. The sheet primitive names its side in `data-sheet-side`, which consumers do not override the way they replace `data-slot`. pwa-mobile-shell-guard.test.ts pins the structure no headless browser can show (no browser in CI reports a safe-area inset). --- src/__tests__/pwa-mobile-shell-guard.test.ts | 108 +++++++++++++++++ src/app/coach/conversations/page.tsx | 6 +- src/app/coach/plans/page.tsx | 6 +- src/app/globals.css | 63 ++++++++++ src/components/day/day-layer.tsx | 15 ++- .../__tests__/layout-coach-fab.test.tsx | 17 ++- .../coach-panel/conversations-panel.tsx | 9 +- src/components/insights/layout-coach-fab.tsx | 9 +- src/components/keyboard-inset-bridge.tsx | 16 +++ src/components/layout/auth-shell.tsx | 27 ++++- src/components/layout/bottom-nav.tsx | 8 +- src/components/layout/top-bar.tsx | 11 +- src/components/providers.tsx | 2 + src/components/ui/responsive-sheet.tsx | 10 +- src/components/ui/sheet.tsx | 6 + src/lib/pwa/__tests__/keyboard-inset.test.ts | 113 ++++++++++++++++++ src/lib/pwa/keyboard-inset.ts | 80 +++++++++++++ 17 files changed, 470 insertions(+), 36 deletions(-) create mode 100644 src/__tests__/pwa-mobile-shell-guard.test.ts create mode 100644 src/components/keyboard-inset-bridge.tsx create mode 100644 src/lib/pwa/__tests__/keyboard-inset.test.ts create mode 100644 src/lib/pwa/keyboard-inset.ts 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( + /(? 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/globals.css b/src/app/globals.css index 208d1ebd6..e2f619321 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -525,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/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/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. */}