mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui
This commit is contained in:
commit
43002d37e8
9 files changed
+249
-18
No files matched your search
@@ -1,5 +1,6 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
import { mockDay } from "./setup/day-mock";
|
||||
import { expect, test } from "./setup/test";
|
||||
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
@@ -418,3 +419,194 @@ test("a vertical swipe that starts on a tab strip is the page's", async ({
|
||||
);
|
||||
expect(touchAction).toBe("auto");
|
||||
});
|
||||
|
||||
/**
|
||||
* An iPad without a home button reports a bottom inset of about 20 px, which
|
||||
* no browser under test does. The shell reads the inset through
|
||||
* `--safe-area-bottom` (globals.css), so setting the property is the same
|
||||
* inset to every element that owns the bottom edge.
|
||||
*/
|
||||
const HOME_INDICATOR = 20;
|
||||
|
||||
async function setBottomInset(page: Page, px: number) {
|
||||
await page.addStyleTag({
|
||||
content: `:root { --safe-area-bottom: ${px}px !important; }`,
|
||||
});
|
||||
}
|
||||
|
||||
/** What paints the window's last row of pixels, sampled across its width. */
|
||||
async function bottomRowOwners(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const y = innerHeight - 1;
|
||||
const owners: string[] = [];
|
||||
// 8 px in lands on the rail at its narrowest (64 px); the rest spread
|
||||
// across the content column.
|
||||
for (const x of [
|
||||
8,
|
||||
...Array.from({ length: 11 }, (_, i) => (innerWidth * (i + 1)) / 12),
|
||||
]) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const owner = !hit
|
||||
? "nothing"
|
||||
: hit === document.documentElement || hit === document.body
|
||||
? "page"
|
||||
: hit.closest("aside")
|
||||
? "rail"
|
||||
: hit.closest("#main-content")
|
||||
? "main"
|
||||
: hit.closest('[data-slot="bottom-nav"]')
|
||||
? "bar"
|
||||
: hit.closest('[data-slot="coach-fab"]')
|
||||
? "fab"
|
||||
: `other:${hit.tagName}`;
|
||||
owners.push(owner);
|
||||
}
|
||||
return owners;
|
||||
});
|
||||
}
|
||||
|
||||
const INSET_VIEWPORTS = [
|
||||
{ name: "iPad mini portrait", width: 744, height: 1133 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
|
||||
{ name: "Split View two thirds", width: 694, height: 1024 },
|
||||
] as const;
|
||||
|
||||
for (const vp of INSET_VIEWPORTS) {
|
||||
test(`${vp.name}: the shell paints to the edge under a home indicator`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
await openDashboard(page);
|
||||
await setBottomInset(page, HOME_INDICATOR);
|
||||
await expectShellPinned(page);
|
||||
|
||||
// The last row of pixels belongs to the rail and `<main>` (or the bar),
|
||||
// never to the page behind the shell: that was the band.
|
||||
const owners = await bottomRowOwners(page);
|
||||
expect(owners.filter((o) => o === "page" || o === "nothing")).toEqual([]);
|
||||
|
||||
const railShown = vp.width >= 768;
|
||||
const edge = await page.evaluate(() => {
|
||||
const aside = document.querySelector("aside")!;
|
||||
const column = aside.querySelector('[data-slot="sidebar-column"]');
|
||||
const bar = document.querySelector<HTMLElement>(
|
||||
'[data-slot="bottom-nav"]',
|
||||
)!;
|
||||
return {
|
||||
innerHeight,
|
||||
asideBottom: aside.getBoundingClientRect().bottom,
|
||||
lastRailRowBottom:
|
||||
column?.lastElementChild?.getBoundingClientRect().bottom ?? 0,
|
||||
barBottom: bar.getBoundingClientRect().bottom,
|
||||
barPad: parseFloat(getComputedStyle(bar).paddingBottom),
|
||||
// What a WKWebView browser paints where it keeps the page out: the
|
||||
// root's own background.
|
||||
root: getComputedStyle(document.documentElement).backgroundColor,
|
||||
body: getComputedStyle(document.body).backgroundColor,
|
||||
};
|
||||
});
|
||||
if (railShown) {
|
||||
expect(owners).toContain("rail");
|
||||
expect(owners).toContain("main");
|
||||
// The rail's background runs to the edge; its account row stops
|
||||
// above the inset.
|
||||
expect(edge.asideBottom).toBe(edge.innerHeight);
|
||||
expect(edge.lastRailRowBottom).toBeLessThanOrEqual(
|
||||
edge.innerHeight - HOME_INDICATOR,
|
||||
);
|
||||
expect(edge.root).toBe(edge.body);
|
||||
} else {
|
||||
expect(owners).toContain("bar");
|
||||
expect(edge.barBottom).toBe(edge.innerHeight);
|
||||
expect(edge.barPad).toBe(HOME_INDICATOR);
|
||||
expect(edge.root).toBe(edge.body);
|
||||
}
|
||||
|
||||
// At the end of the page, the last line stands clear of the inset, the
|
||||
// bar and the Coach button.
|
||||
await scrollMainToEnd(page);
|
||||
const end = await measureMainEnd(page);
|
||||
expect(end.atEnd).toBe(true);
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(
|
||||
edge.innerHeight - HOME_INDICATOR,
|
||||
);
|
||||
if (end.barTop !== null) {
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
|
||||
}
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible();
|
||||
const fabTop = (await fab.boundingBox())!.y;
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(fabTop);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The Coach button over the last card of a nested layout (Insights, Settings,
|
||||
* a values list): during the scroll it may sit over content, at the end it
|
||||
* must not. Rendered by serving an `ai` block with the Coach available,
|
||||
* since the suite has no provider.
|
||||
*/
|
||||
const NESTED_PAGES = [
|
||||
"/insights/blood-pressure",
|
||||
"/insights/values/BLOOD_PRESSURE_SYS",
|
||||
"/insights",
|
||||
"/settings/account",
|
||||
"/settings/modules",
|
||||
] as const;
|
||||
|
||||
for (const vp of [
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
] as const) {
|
||||
for (const inset of [0, HOME_INDICATOR]) {
|
||||
test(`${vp.name}, inset ${inset}: the Coach button clears the last line of nested layouts`, async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
for (const path of NESTED_PAGES) {
|
||||
await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('#main-content [data-slot="main-content-wrapper"]'),
|
||||
).toBeVisible();
|
||||
await page.waitForLoadState("networkidle");
|
||||
if (inset) await setBottomInset(page, inset);
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible();
|
||||
await scrollMainToEnd(page);
|
||||
const covered = await page.evaluate(() => {
|
||||
const f = document
|
||||
.querySelector('[data-slot="coach-fab"]')!
|
||||
.getBoundingClientRect();
|
||||
const main = document.getElementById("main-content")!;
|
||||
const under: string[] = [];
|
||||
for (const el of main.querySelectorAll<HTMLElement>("*")) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.position === "fixed" || cs.visibility === "hidden") continue;
|
||||
const paints =
|
||||
(el.childElementCount === 0 && !!el.textContent?.trim()) ||
|
||||
cs.borderBottomWidth !== "0px" ||
|
||||
cs.backgroundColor !== "rgba(0, 0, 0, 0)";
|
||||
if (!paints) continue;
|
||||
const b = el.getBoundingClientRect();
|
||||
if (!b.width || !b.height) continue;
|
||||
if (
|
||||
b.right > f.left &&
|
||||
b.left < f.right &&
|
||||
b.bottom > f.top &&
|
||||
b.top < f.bottom
|
||||
) {
|
||||
under.push(el.getAttribute("data-slot") ?? el.tagName);
|
||||
}
|
||||
}
|
||||
return under;
|
||||
});
|
||||
expect(covered, `${path}: content under the Coach button`).toEqual([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -626,6 +626,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, `<main>`, 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;
|
||||
}
|
||||
@@ -651,6 +661,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 `<main>` in the same colour.
|
||||
*/
|
||||
html[data-scroll="shell"] {
|
||||
background-color: var(--background);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
@@ -78,13 +78,11 @@ describe("<LayoutCoachFab>", () => {
|
||||
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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
/(?<![\w:-])bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
|
||||
/(?<![\w:-])bottom-\[calc\(var\(--safe-area-bottom\)\+[\d.]+rem\)\]/.exec(
|
||||
fab,
|
||||
)![0],
|
||||
);
|
||||
const desktopFabBottom = rem(
|
||||
/shell-desktop:bottom-\[calc\(env\(safe-area-inset-bottom,0px\)\+[\d.]+rem\)\]/.exec(
|
||||
/shell-desktop:bottom-\[calc\(var\(--safe-area-bottom\)\+[\d.]+rem\)\]/.exec(
|
||||
fab,
|
||||
)![0],
|
||||
);
|
||||
@@ -85,14 +107,12 @@ describe("the bottom edge is cleared in both shells", () => {
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -600,7 +600,7 @@ describe("<SidebarNav> 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 = /<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", () => {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
|
||||
{primaryLeft.map(renderPrimary)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -627,7 +627,7 @@ export function SidebarNav() {
|
||||
<aside
|
||||
aria-label={t("nav.sidebar")}
|
||||
className={cn(
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-[env(safe-area-inset-bottom,0px)] transition-[width] duration-200 motion-reduce:transition-none",
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) transition-[width] duration-200 motion-reduce:transition-none",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user