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. */}