Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui

This commit is contained in:
Marc-André Bombeck committed 2026-10-23 21:12:51 +02:00
commit 43002d37e8
9 files changed
+249 -18

No files matched your search

+192
View File
@@ -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([]);
}
});
}
}
+21
View File
@@ -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");
+2 -2
View File
@@ -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", () => {
+2 -2
View File
@@ -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)}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
)}
>