mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(shell): take the window's edges and keep the document still
The authenticated shell was sized by h-dvh. On iPadOS Safari that value and the area the browser lays out disagree after a rotation and under the floating toolbars: a short value left a strip of page colour under the shell with the last card cut off above it, a tall one let the document scroll by the difference and take the swipes meant for main. The shell now takes the window's edges (absolute inset-0 against the initial containing block), and the document under it gets no scroll and no rubber band (data-scroll="shell": overflow hidden, overscroll none, height 100% so the box survives a dialog's scroll lock making the body relative). Not fixed: a fixed box re-rasterises every page in a layer of its own and traps the bottom bar's z-index. The desktop shell's main now clears the home indicator of an installed app, and the page frame clears the Coach button there with pb-24: the button sits 5.5rem up, so pb-20 left the last 8 px under it. The sub-shell grid floor follows the new padding.
This commit is contained in:
1 parent
551fbbbdec
commit
6ad61cdd1f
8 files changed
+249
-33
No files matched your search
@@ -8,7 +8,7 @@ import { settleBeforeMeasure } from "./utils/settle";
|
||||
* v1.25.11 settings fix): the scroll viewport must never extend far past
|
||||
* the last content element. The rule the guard pins is structural — only
|
||||
* the AuthShell owns the scroll height and the bottom padding
|
||||
* (`pt-6 pb-20` on the wrapper, plus the `<md` bottom-nav clearance on
|
||||
* (`pt-6 pb-20` on the wrapper, `pb-24` in the desktop shell, plus the `<md` bottom-nav clearance on
|
||||
* `<main>`); a page/section must never add its own viewport-height
|
||||
* reserve or bottom gutter, because every such nested reserve stacks on
|
||||
* the shell's own budget and reopens the dark-band-below-the-last-card
|
||||
@@ -45,7 +45,7 @@ const ROUTES = [
|
||||
] as const;
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20
|
||||
{ name: "desktop", width: 1440, height: 900, expectedPad: 96 }, // wrapper shell-desktop:pb-24
|
||||
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -845,9 +845,9 @@ test("the timeline page ends where its content does, at every desktop width", as
|
||||
expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1);
|
||||
expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1);
|
||||
expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1);
|
||||
// The shell's own buffer below the last card (pb-20 and the top
|
||||
// padding), nothing more.
|
||||
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2);
|
||||
// The shell's own buffer below the last card (the desktop shell's
|
||||
// pb-24 and the top padding), nothing more.
|
||||
expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 120 + 2);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -33,7 +33,10 @@ function code(source: string): string {
|
||||
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"/);
|
||||
expect(shell).toMatch(/<div className=\{SHELL_ROOT_CLASS\}>/);
|
||||
expect(read("src/components/layout/shell-metrics.ts")).toMatch(
|
||||
/SHELL_ROOT_CLASS =\s*"shell-safe-area absolute inset-0 flex flex-col"/,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -615,6 +615,28 @@ body,
|
||||
html[data-scroll="body"] {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
/*
|
||||
* The app shell (`data-scroll="shell"`, also set by `AuthShell`) takes the
|
||||
* window's edges and scrolls inside `<main>`, so the document has nothing
|
||||
* to scroll and must not bounce either. On iOS a swipe that reached the end
|
||||
* of `<main>` or of the rail chained to the document, the whole page
|
||||
* rubber-banded and showed the page colour under the shell, and the next
|
||||
* swipe went to that bounce instead of the list under the finger: the
|
||||
* "sometimes it does not scroll" a tablet reported.
|
||||
*
|
||||
* `height: 100%` gives the body the window's box. The shell is
|
||||
* `absolute inset-0`, which resolves against the initial containing block
|
||||
* while the body is static, and against the body once a dialog's scroll
|
||||
* lock makes it `position: relative` (react-remove-scroll does); an
|
||||
* auto-height body is 0 px tall and the shell would collapse behind every
|
||||
* dialog.
|
||||
*/
|
||||
html[data-scroll="shell"],
|
||||
html[data-scroll="shell"] body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* The authenticated shell's geometry decisions, which no browser under test
|
||||
* can show going wrong: Playwright's WebKit and Chromium have no floating
|
||||
* toolbar, no rotation and no home indicator, so `100dvh` always equals the
|
||||
* window there. On an iPad it does not, and that disagreement was the strip
|
||||
* under the shell and the cut-off card. These hold the decisions that take
|
||||
* the disagreement out; `e2e/tablet-shell.spec.ts` measures the result.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import {
|
||||
MAIN_SCROLL_CLASS,
|
||||
SHELL_ROOT_CLASS,
|
||||
SUB_SHELL_GRID_FLOOR,
|
||||
} from "../shell-metrics";
|
||||
|
||||
const shellSource = () =>
|
||||
readSource("src/components/layout/auth-shell.tsx", [
|
||||
"SHELL_ROOT_CLASS",
|
||||
'data-slot="main-content-wrapper"',
|
||||
]);
|
||||
/** The page frame's class list: the one `mx-auto max-w-screen-xl` literal. */
|
||||
const MAIN_CONTENT_WRAPPER_CLASS =
|
||||
/"([^"]*\bmx-auto max-w-screen-xl\b[^"]*)"/.exec(shellSource())![1];
|
||||
const classes = (value: string) => value.split(/\s+/).filter(Boolean);
|
||||
|
||||
/** Tailwind spacing step or `[calc(env(...)+Nrem)]` offset, in rem. */
|
||||
function rem(token: string): number {
|
||||
const calc = /\+(\d+(?:\.\d+)?)rem\)\]$/.exec(token);
|
||||
if (calc) return Number(calc[1]);
|
||||
const step = /-(\d+(?:\.\d+)?)$/.exec(token);
|
||||
if (step) return Number(step[1]) / 4;
|
||||
throw new Error(`no length in ${token}`);
|
||||
}
|
||||
|
||||
describe("the shell takes the window's edges", () => {
|
||||
it("takes its height from the initial containing block, never from a viewport unit", () => {
|
||||
const shell = classes(SHELL_ROOT_CLASS);
|
||||
expect(shell).toEqual(
|
||||
expect.arrayContaining(["shell-safe-area", "absolute", "inset-0"]),
|
||||
);
|
||||
// `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_ROOT_CLASS).not.toMatch(
|
||||
/\b(?:min-)?h-(?:dvh|svh|lvh|screen)\b/,
|
||||
);
|
||||
expect(SHELL_ROOT_CLASS).not.toMatch(/\d+(?:d|s|l)?vh/);
|
||||
});
|
||||
|
||||
it("AuthShell renders that box and flags the document for it", () => {
|
||||
const source = shellSource();
|
||||
expect(source).toContain("<div className={SHELL_ROOT_CLASS}>");
|
||||
expect(source).not.toMatch(/shell-safe-area flex h-dvh/);
|
||||
expect(source).toMatch(
|
||||
/useDocumentScrollMode\(\s*isBodyScrolled \? "body" : rendersAppShell \? "shell" : null/,
|
||||
);
|
||||
const css = readSource("src/app/globals.css", '[data-scroll="shell"]');
|
||||
expect(css).toMatch(
|
||||
/html\[data-scroll="shell"\],\s*html\[data-scroll="shell"\] body\s*\{\s*height:\s*100%;\s*overflow:\s*hidden;\s*overscroll-behavior:\s*none;/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the bottom edge is cleared in both shells", () => {
|
||||
const fab = readSource(
|
||||
"src/components/insights/layout-coach-fab.tsx",
|
||||
'data-slot="coach-fab"',
|
||||
);
|
||||
const fabSize = rem(/\bsize-(\d+)\b/.exec(fab)![0]);
|
||||
const phoneFabBottom = rem(
|
||||
/(?<![\w:-])bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
|
||||
fab,
|
||||
)![0],
|
||||
);
|
||||
const desktopFabBottom = rem(
|
||||
/shell-desktop:bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
|
||||
fab,
|
||||
)![0],
|
||||
);
|
||||
const wrapper = classes(MAIN_CONTENT_WRAPPER_CLASS);
|
||||
const main = classes(MAIN_SCROLL_CLASS);
|
||||
|
||||
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)]",
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
main.find((c) => c.startsWith("pb-[calc("))!,
|
||||
)![1],
|
||||
);
|
||||
const wrapperPad = rem(wrapper.find((c) => /^pb-\d+$/.test(c))!);
|
||||
expect(mainPad + wrapperPad).toBeGreaterThan(phoneFabBottom + fabSize);
|
||||
});
|
||||
|
||||
it("the last line scrolls clear of the Coach button in the desktop shell", () => {
|
||||
const wrapperPad = rem(
|
||||
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
|
||||
);
|
||||
expect(wrapperPad).toBeGreaterThan(desktopFabBottom + fabSize);
|
||||
});
|
||||
|
||||
it("the sub-shell floor follows the desktop shell's padding", () => {
|
||||
// Top bar 4rem + pt-6 + the wrapper's bottom padding.
|
||||
const desktopPad = rem(
|
||||
wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!,
|
||||
);
|
||||
expect(SUB_SHELL_GRID_FLOOR).toContain(
|
||||
`shell-desktop:min-h-[calc(100dvh-${4 + 1.5 + desktopPad}rem)]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
} from "@/components/layout/module-page-gate";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isFullBleedPage } from "./full-bleed";
|
||||
import { useDocumentScrollMode } from "./document-scroll-mode";
|
||||
import { MAIN_SCROLL_CLASS, SHELL_ROOT_CLASS } from "./shell-metrics";
|
||||
import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps";
|
||||
import { RecordScopeHydrationGate } from "./record-scope-hydration-gate";
|
||||
import { SidebarNav } from "./sidebar-nav";
|
||||
@@ -166,25 +168,18 @@ export function AuthShell({
|
||||
const isOnboardingPage = isOnboardingPathname(pathname);
|
||||
const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id;
|
||||
|
||||
// v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved
|
||||
// only for the body-scrolled shells (login / register / onboarding /
|
||||
// standalone legal pages). The authenticated branch is height-locked and
|
||||
// scrolls inside its own `<main>`, which reserves its own gutter; applying
|
||||
// the document gutter there too produced a second, never-painted gutter on
|
||||
// classic-scrollbar platforms. Flag the body-scrolled branches on `<html>`
|
||||
// so the CSS rule scopes to them.
|
||||
// Who scrolls the page, on `<html>` (`document-scroll-mode.ts`).
|
||||
const isBodyScrolled = isPublicPage || isOnboardingPage;
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isBodyScrolled) {
|
||||
root.setAttribute("data-scroll", "body");
|
||||
} else {
|
||||
root.removeAttribute("data-scroll");
|
||||
}
|
||||
return () => {
|
||||
root.removeAttribute("data-scroll");
|
||||
};
|
||||
}, [isBodyScrolled]);
|
||||
const rendersAppShell =
|
||||
!isBodyScrolled &&
|
||||
!accessRefused &&
|
||||
!isLoading &&
|
||||
isAuthenticated &&
|
||||
recordSessionTransition.phase === "ready" &&
|
||||
!recordSessionPending;
|
||||
useDocumentScrollMode(
|
||||
isBodyScrolled ? "body" : rendersAppShell ? "shell" : null,
|
||||
);
|
||||
|
||||
// v1.25.1 — the authenticated shell keeps `<main id="main-content">`
|
||||
// mounted across every route change, so its scroll offset survives
|
||||
@@ -438,9 +433,13 @@ export function AuthShell({
|
||||
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 `<main>`, which own that edge.
|
||||
stays with the bottom nav, `<main>` and the rail, which own that edge.
|
||||
|
||||
The shell takes the window's edges rather than `h-dvh`, and the
|
||||
document under it neither scrolls nor bounces: `SHELL_ROOT_CLASS` in
|
||||
`shell-metrics.ts` says why.
|
||||
*/}
|
||||
<div className="shell-safe-area flex h-dvh flex-col">
|
||||
<div className={SHELL_ROOT_CLASS}>
|
||||
{/*
|
||||
v1.4.43 — `<OfflineBanner>` paints only when
|
||||
`navigator.onLine === false`. Sits above the maintainership
|
||||
@@ -500,7 +499,7 @@ export function AuthShell({
|
||||
// for all of them at once instead of asking each new
|
||||
// absolutely-positioned child to remember its own wrapper.
|
||||
className={cn(
|
||||
"shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]",
|
||||
MAIN_SCROLL_CLASS,
|
||||
fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]",
|
||||
)}
|
||||
>
|
||||
@@ -516,7 +515,8 @@ export function AuthShell({
|
||||
v1.16.8 — the bottom padding clears the always-on Coach
|
||||
FAB (48 px button + its offset above the bottom-nav band /
|
||||
the desktop bottom-6 anchor), so the last line of content
|
||||
can always scroll out from under the floating button.
|
||||
can always scroll out from under the floating button; the
|
||||
desktop shell's sits 5.5rem up, so `pb-24` there.
|
||||
*/}
|
||||
<div
|
||||
data-slot="main-content-wrapper"
|
||||
@@ -524,7 +524,7 @@ export function AuthShell({
|
||||
className={
|
||||
fullBleed
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
: "shell-desktop:pb-24 mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
|
||||
}
|
||||
>
|
||||
{outsideSharedRecord ? (
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Who scrolls the page, published on `<html>` as `data-scroll` for the rules
|
||||
* in `globals.css`.
|
||||
*
|
||||
* - `"body"`: the body-scrolled shells (login / register / onboarding /
|
||||
* standalone legal pages), where the document itself overflows. They get
|
||||
* the document-level `scrollbar-gutter`. The authenticated branch
|
||||
* scrolls inside its own `<main>`, which reserves its own gutter; the
|
||||
* document gutter there too produced a second, never-painted gutter on
|
||||
* classic-scrollbar platforms.
|
||||
* - `"shell"`: the app shell. It takes the window's edges and `<main>` is its
|
||||
* one vertical scroller, so the document gets no scroll and no rubber band
|
||||
* of its own. On iOS a bounce of the document held the next swipe, and the
|
||||
* list under the finger did not move.
|
||||
* - `null`: anything else (the refusal panel, the loading states) keeps the
|
||||
* browser's defaults.
|
||||
*/
|
||||
export type DocumentScrollMode = "body" | "shell" | null;
|
||||
|
||||
export function useDocumentScrollMode(mode: DocumentScrollMode): void {
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (mode) {
|
||||
root.setAttribute("data-scroll", mode);
|
||||
} else {
|
||||
root.removeAttribute("data-scroll");
|
||||
}
|
||||
return () => {
|
||||
root.removeAttribute("data-scroll");
|
||||
};
|
||||
}, [mode]);
|
||||
}
|
||||
@@ -18,15 +18,51 @@
|
||||
* never drift — Admin previously carried a stale, all-breakpoint
|
||||
* `min-h-[calc(100dvh-12rem)]` column reserve that this de-duplication retires.
|
||||
*
|
||||
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + `pb-20`
|
||||
* (5rem) = 10.5rem. Retune here — and only here — if those shell paddings
|
||||
* change. (Latent follow-up: this constant assumes zero banners above `<main>`;
|
||||
* Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + its bottom
|
||||
* padding: `pb-20` (5rem) = 10.5rem in the phone shell, `pb-24` (6rem) =
|
||||
* 11.5rem in the desktop shell (the page frame in `auth-shell.tsx`). Retune here —
|
||||
* and only here — if those shell paddings change. (Latent follow-up: this constant assumes zero banners above `<main>`;
|
||||
* a locale/offline/demo banner or an iOS PWA safe-area inset makes it
|
||||
* over-reserve. A fully self-accounting `min-h-full` chain would need the
|
||||
* AuthShell content wrapper to pass a definite height through, which is out of
|
||||
* scope for the shell-only over-scroll fix.)
|
||||
*/
|
||||
export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]";
|
||||
export const SUB_SHELL_GRID_FLOOR =
|
||||
"md:min-h-[calc(100dvh-10.5rem)] shell-desktop:min-h-[calc(100dvh-11.5rem)]";
|
||||
|
||||
/**
|
||||
* The authenticated shell's outer box. It takes the window's edges
|
||||
* (`absolute inset-0` against the initial containing block) rather than
|
||||
* `h-dvh`, and the top and side safe-area insets (`shell-safe-area`,
|
||||
* globals.css); the bottom inset belongs to whatever owns the bottom edge in
|
||||
* each shell.
|
||||
*
|
||||
* Not a viewport unit: `h-dvh` is the browser's idea of the visible height,
|
||||
* and on iPadOS Safari that idea and the area it lays out disagree after a
|
||||
* rotation and under the floating toolbars. A short value left a strip of
|
||||
* page colour under the shell with the last card cut off above it; a tall one
|
||||
* made the document scroll by the difference, and that document scroll took
|
||||
* the swipes meant for `<main>`. The initial containing block is the layout
|
||||
* itself, recomputed with every rotation and resize, and the document never
|
||||
* scrolls (`data-scroll="shell"`, `document-scroll-mode.ts`), so the box
|
||||
* cannot drift from the window either.
|
||||
*
|
||||
* Not `fixed`: a fixed box gets a compositing layer of its own, which
|
||||
* re-rasterised the text of every page (the antialiasing moved on every
|
||||
* screen), and forms a stacking context that would trap the bottom bar's
|
||||
* z-index under the Coach button and the toasts.
|
||||
*/
|
||||
export const SHELL_ROOT_CLASS =
|
||||
"shell-safe-area absolute inset-0 flex flex-col";
|
||||
|
||||
/**
|
||||
* `<main>`, the shell's one vertical scroller. Phone shell: it clears the
|
||||
* bottom nav and the home indicator. Desktop shell: there is no bar, and it
|
||||
* clears the home indicator of an installed app on a tablet (zero in a
|
||||
* 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)]";
|
||||
|
||||
/**
|
||||
* The app-chrome header band: one 4rem strip whose bottom border draws the
|
||||
|
||||
Reference in new issue
Block a user