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