Merge remote-tracking branch 'origin/main' into work/sharing-routes

This commit is contained in:
Marc-André Bombeck committed 2026-08-02 20:02:50 +02:00
commit d1a9147034
18 files changed
+807 -121

No files matched your search

+93 -20
View File
@@ -11,6 +11,30 @@ type AxeViolation = Awaited<
type Theme = "light" | "dark";
/**
* One scan target: where to go, and what has to be on screen before axe is
* allowed to look.
*
* The `painted` locator is the whole contract of this file. A scan that runs
* against a page whose content has not arrived reports violations the app does
* not have — "page must have a level-one heading" against a page that has one,
* a moment later — and that reads as a product defect until somebody re-runs
* it. Two rules keep the sentinel honest, and both were learned from a case
* that broke them:
*
* 1. It must name something the PAGE renders once its data is there, never
* something the shell or a skeleton also carries. `#main-content` is the
* app frame and exists before the route paints anything; `[role="status"]`
* is on every busy placeholder; a filter rail renders next to the loading
* silhouettes it filters. Each of those is a coin flip, not a wait.
* 2. It must not be the thing the scan asserts. Waiting for `<h1>` and then
* letting axe check that an `<h1>` exists is a check that cannot fail —
* strictly worse than the flake, because a page that really lost its
* heading would wait out the timeout under a misleading name instead of
* failing the rule. Every case here gates on content that lives in a
* different component from the heading, so dropping the heading still
* fails the scan.
*/
type RouteCase = {
name: string;
path: string;
@@ -670,9 +694,7 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
// one), so that selector matched the loading tree the server sends before
// the page client has mounted anything. On a loaded runner the scan then
// landed on a document whose only heading had not rendered yet and blamed
// the app for a missing `<h1>` it does have. Same rule as the overview
// case above: a sentinel must prove the content, not the placeholder
// standing in for it.
// the app for a missing `<h1>` it does have.
name: "/insights/workouts list",
path: "/insights/workouts",
painted: (page) =>
@@ -700,29 +722,41 @@ const INSIGHTS_ROUTES: readonly RouteCase[] = [
const RECORD_ROUTES: readonly RouteCase[] = [
{
// `filter-bar` is a sibling of the loading silhouettes, not their
// successor — the rail renders above the list in every branch, so it was
// on screen while the rows were still `measurement-list-loading`
// rectangles. The rows wrapper exists only in the resolved, non-empty
// branch, and both the desktop table and the mobile card list carry it.
name: "/measurements",
path: "/measurements",
painted: (page) => page.locator('[data-slot="filter-bar"]'),
painted: (page) => page.locator('[data-slot="measurement-rows"]:visible'),
},
{
// Same shape as /measurements, and the one this was measured on: under
// an 8× CPU throttle `filter-bar` and `mood-list-loading` resolved in the
// same 40 ms poll, the rows a poll later.
name: "/mood",
path: "/mood",
painted: (page) => page.locator('[data-slot="filter-bar"]'),
painted: (page) => page.locator('[data-slot="mood-rows"]:visible'),
},
{
// Was `getByRole("heading", { level: 1 })` — the axe rule's own query
// used as its own precondition. The card carries the mocked medication's
// id; `MedicationCardSkeleton` carries no id at all.
name: "/medications",
path: "/medications",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator("[data-medication-id]").first(),
},
{
name: "/labs",
path: "/labs",
painted: (page) => page.getByText("A11y LDL").first(),
painted: (page) => page.locator('[data-slot="lab-list"]'),
},
{
name: "/documents compact panel",
path: "/documents?view=compact",
painted: (page) => page.getByText("A11y blood panel").first(),
painted: (page) =>
page.locator(`[data-document-id="${A11Y_DOCUMENT_ID}"]`).first(),
},
];
@@ -730,40 +764,65 @@ const ADMIN_AND_BASELINE_ROUTES: readonly RouteCase[] = [
{
name: "/admin/app-logs",
path: "/admin/app-logs",
painted: (page) => page.getByText("a11y.paint").first(),
painted: (page) => page.locator('[data-slot="app-log-rows"] tr').first(),
},
{
name: "/admin/backups",
path: "/admin/backups",
painted: (page) =>
page.getByText("a11y-backup-user", { exact: true }).filter({
visible: true,
}),
painted: (page) => page.locator('[data-slot="backup-rows"]:visible'),
},
{
// This one is why the whole file was swept. `#main-content` is the
// authenticated shell's `<main>`: it is in the first HTML on every route,
// it wraps whatever the segment happens to be streaming, and on `/` that
// is `app/loading.tsx` — a silhouette with no heading in it. The scan was
// therefore free to run against a document that legitimately had no `<h1>`
// yet and report the app as missing one. Measured under an 8× CPU
// throttle: `#main-content` at 759 ms, the tile strip at 4896 ms.
//
// The gate is the revealed chart cell rather than the tile strip because
// an unrevealed cell is `invisible` AND `aria-hidden` — the chart cards
// are simply not in the accessibility tree, so axe walks past the whole
// chart row without reading it. Gating on the strip alone left it a
// coin flip whether the row was scanned at all: locally the cells were
// still hidden at strip time and revealed six seconds later, while the
// runner that reported the heading-order break had already revealed
// them. The row is the dashboard's largest surface; the scan has to see
// it every time or not claim to have checked the page.
name: "/ dashboard",
path: "/",
painted: (page) => page.locator("#main-content"),
painted: (page) =>
page
.locator('[data-slot="dashboard-chart-cell"][data-revealed="true"]')
.first(),
},
{
// The settings heading belongs to `<SettingsShell>`, i.e. the chrome —
// waiting on it says nothing about the section body below. The
// connections panel is that body. This route has no data-gated content of
// its own: every provider card renders immediately and fills in its
// status pill later, so the panel's presence is the honest ceiling here.
name: "/settings/integrations",
path: "/settings/integrations",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator('[data-slot="connections-panel"]'),
},
{
// The heading comes from `<AdminShell>` and is on screen while every card
// under it is still a spinner. The audit list is the last card on the
// overview and only exists once its query has answered.
name: "/admin overview",
path: "/admin",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator('[data-slot="admin-audit-rows"]'),
},
{
name: "/admin/system-status",
path: "/admin/system-status",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator('[data-slot="system-status-grid"]'),
},
{
name: "/admin/users",
path: "/admin/users",
painted: (page) => page.getByRole("heading", { level: 1 }).first(),
painted: (page) => page.locator('[data-slot="admin-user-rows"]:visible'),
},
];
@@ -776,7 +835,11 @@ test.describe("axe-core public surfaces", () => {
page,
theme,
"/auth/login",
page.getByRole("main"),
// Not `getByRole("main")`: the public shell renders that landmark
// around whatever the segment is streaming, so it is satisfied
// before the login card itself exists. The action block is the
// page's own.
page.locator('[data-slot="login-actions"]'),
);
expectNoViolations(theme, "/auth/login", blocking);
});
@@ -836,7 +899,7 @@ test.describe("axe-core authenticated route and state matrix", () => {
await test.step(`${theme} /labs open OCR picker dialog`, async () => {
await page.goto("/labs", { waitUntil: "domcontentloaded" });
await expect(page.getByText("A11y LDL").first()).toBeVisible({
await expect(page.locator('[data-slot="lab-list"]')).toBeVisible({
timeout: 15_000,
});
await page
@@ -879,6 +942,11 @@ test.describe("axe-core authenticated route and state matrix", () => {
await test.step(`${theme} /medications open representative wizard dialog`, async () => {
await page.goto("/medications", { waitUntil: "domcontentloaded" });
// axe scans the whole document, dialog and page behind it alike, so
// the list has to have arrived before the dialog opens on top of it.
await expect(page.locator("[data-medication-id]").first()).toBeVisible({
timeout: 15_000,
});
await page
.locator('#main-content [data-slot="dropdown-menu-trigger"]')
.first()
@@ -899,6 +967,11 @@ test.describe("axe-core authenticated route and state matrix", () => {
await page.goto(`/documents?view=compact&doc=${A11Y_DOCUMENT_ID}`, {
waitUntil: "domcontentloaded",
});
// Same reason as the medication dialog: the vault behind the sheet is
// in the scan, so it has to be past `documents-loading` first.
await expect(
page.locator(`[data-document-id="${A11Y_DOCUMENT_ID}"]`).first(),
).toBeVisible({ timeout: 15_000 });
const sheet = page.locator('[data-slot="responsive-sheet-content"]');
await expect(sheet).toBeVisible({ timeout: 15_000 });
await sheet.getByRole("button", { name: /summari/i }).click();
+422
View File
@@ -0,0 +1,422 @@
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The seam again, this time with chrome above it.
*
* `chrome-header-seam.spec.ts` measures the two header borders on a shell with
* nothing above them. That is the state almost every session is in, and it is
* also the only state in which the seam cannot break: with no banner painted,
* the sidebar logo band and the top bar both start at y=0 whatever the layout
* does with them.
*
* The banners are the case that breaks it. Each one is a full-width strip that
* paints above the top bar, and while they lived INSIDE the content column they
* pushed the top bar down while the sidebar band stayed where it was — one line
* across the top of the app became two lines a banner-height apart, and three
* can stack at once. Measured before the fix, on the French locale strip: one
* banner offset the two borders by 66.5px at 768px and 61.0px at 1280px, and
* all three together by 184.5px and 143.0px. So this spec forces them on and
* measures the same two borders.
*
* ## How each banner is forced
*
* Honestly, through the condition the component actually reads — no injected
* DOM, no test-only prop:
*
* - **Maintainership** paints on a locale HealthLog does not hand-maintain.
* The context cookie is switched from `en` to `fr`, which is what a
* French-speaking user's browser carries. The swap happens with no page
* alive (`about:blank`): `I18nProvider` rewrites `healthlog-locale` from
* its own active locale on mount, so a cookie written under a live English
* page is reverted within the second.
* - **Shared record** paints from `accountAccess.active` on `/api/auth/me`.
* The real payload is captured first and served back with that block
* filled, rather than performing a real switch: the switch is stamped on
* the SESSION ROW, so switching the shared cookie jar would switch it for
* every other spec holding the same cookie (see
* `DELEGATE_STORAGE_STATE_PATH` in `global-setup.ts` for what that costs).
* A captured-and-patched payload keeps this file's reach inside this file,
* and it keeps working after the offline stage because it never touches the
* network again.
* - **Offline** paints while `navigator.onLine` is false, which
* `context.setOffline(true)` produces for real, event and all. It is armed
* last because it is the only stage a navigation cannot follow.
*
* The demo banner is the one that cannot be forced from a spec: `DEMO_MODE` is
* a server env var read by the root layout, and the suite shares one server
* whose mutations would all be blocked by turning it on. It is the same strip
* as the other three (`border-b px-3 py-2 text-xs`) in the same place in the
* stack, so the three forced here exercise its geometry; nothing in the stack
* is specific to which banner is in it.
*
* ## What is asserted
*
* Two properties, and the second is why the banners were hoisted above the
* shell row rather than nudged inside it:
*
* 1. The seam holds — the two header borders keep sharing one line at every
* stack depth, so chrome above the header cannot displace one band and
* not the other.
* 2. The shell stays inside the viewport — the banners are part of the
* `h-dvh` budget, not added on top of it, so no depth of stack introduces
* a page-level scrollbar. `<main>` remains the only vertical scroller.
*
* Property 2 runs on mobile too, where there is no sidebar and therefore no
* seam, because the scroll model is the half of the layout that the hoist could
* have broken at that breakpoint.
*/
const LOCALE_COOKIE = "healthlog-locale";
/**
* Every banner that can paint above the top bar, so a stage can assert how many
* are up rather than only that the one it armed is.
*/
const BANNER_SLOTS = [
"offline-banner",
"shared-record-banner",
"demo-banner",
"maintainership-banner",
] as const;
type Band = { bottom: number; height: number; borderBottomWidth: number };
type ShellGeometry = {
topBar: Band | null;
sidebar: Band | null;
bannerCount: number;
bannerHeight: number;
/** `scrollHeight - clientHeight` of the document's scrolling element. */
documentOverflow: number;
viewportHeight: number;
};
async function readShell(page: Page): Promise<ShellGeometry> {
return page.evaluate(
(slots: string[]) => {
const band = (slot: string) => {
const el = document.querySelector(`[data-slot="${slot}"]`);
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
bottom: rect.bottom,
height: rect.height,
borderBottomWidth: parseFloat(
getComputedStyle(el).borderBottomWidth || "0",
),
};
};
const banners = slots
.map((slot) => document.querySelector(`[data-slot="${slot}"]`))
.filter((el): el is Element => el != null);
const scroller = document.scrollingElement ?? document.documentElement;
return {
topBar: band("top-bar"),
sidebar: band("sidebar-header"),
bannerCount: banners.length,
bannerHeight: banners.reduce(
(sum, el) => sum + el.getBoundingClientRect().height,
0,
),
documentOverflow: scroller.scrollHeight - scroller.clientHeight,
viewportHeight: window.innerHeight,
};
},
BANNER_SLOTS as unknown as string[],
);
}
/**
* The live `/api/auth/me` body, read through the page so it carries the page's
* own cookies. Captured before anything else is armed, so the mock that later
* replaces it differs from the real payload in exactly one block.
*/
async function captureAccountPayload(page: Page): Promise<string> {
const raw = await page.evaluate(async () => {
const res = await fetch("/api/auth/me", { credentials: "same-origin" });
return res.ok ? await res.text() : null;
});
expect(
raw,
"could not capture /api/auth/me for the sharing mock",
).not.toBeNull();
return raw!;
}
/**
* Serve the captured payload back with the sharing block filled in, so the
* shell believes this session is inside somebody else's record. Everything else
* the shell reads (modules, role, onboarding flags) stays what the server said.
*/
async function armSharedRecord(page: Page, captured: string): Promise<void> {
const payload = JSON.parse(captured) as { data: Record<string, unknown> };
const entry = {
accountId: "seam-fixture-account",
username: "seam-owner",
displayName: null,
access: "read" as const,
canWrite: false,
};
payload.data.accountAccess = {
accounts: [entry],
active: entry,
canSwitch: true,
};
const body = JSON.stringify(payload);
await page.route("**/api/auth/me", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body }),
);
}
/**
* Swap the context's pinned locale for one HealthLog ships as an AI-initial
* translation, so the maintainership strip has a reason to paint.
*
* The page is parked on `about:blank` for the swap. `I18nProvider` mirrors its
* ACTIVE locale into this cookie from an effect, so a cookie written while an
* English page is mounted is reverted before the next navigation can read it —
* measured, not assumed: the value flipped back inside one second. With no page
* running, the next navigation sends `fr` and the provider then mirrors `fr`.
*/
async function armUnmaintainedLocale(page: Page): Promise<void> {
const host = new URL(page.url()).hostname;
await page.goto("about:blank");
await page.context().addCookies([
{
name: LOCALE_COOKIE,
value: "fr",
domain: host,
path: "/",
expires: Math.floor(Date.now() / 1000) + 3600,
httpOnly: false,
secure: false,
sameSite: "Lax",
},
]);
}
/**
* Take the context offline and make sure the banner hears about it.
*
* `setOffline` produces the real condition — `navigator.onLine` flips to false,
* which is asserted here rather than assumed. Delivery of the `offline` EVENT
* to an already-loaded page is the part that is not reliable: this spec caught
* it missing once under a fully parallel run while passing three for three on
* its own. `<OfflineBanner>` only reads `navigator.onLine` at mount and
* otherwise waits on the event, so the event is re-dispatched here. That is the
* notification, not the condition, and the condition is verified first.
*/
async function armOffline(page: Page): Promise<void> {
// Let whatever is in flight land first. The Playwright context runs with
// `serviceWorkers: "block"`, so nothing has pre-cached the app shell and a
// lazily-imported chunk cut off mid-download throws the whole tree to the
// global error boundary — "Failed to load chunk … from module …", no top bar,
// no banner. That is a property of the harness rather than of the shell (a
// real install serves those chunks from the worker cache), so it is waited
// out rather than asserted on. Best-effort: a polling surface can keep the
// network from ever going fully idle.
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {});
await page.context().setOffline(true);
await expect.poll(() => page.evaluate(() => navigator.onLine)).toBe(false);
await page.evaluate(() => window.dispatchEvent(new Event("offline")));
}
test.describe("app-chrome header seam under banners", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("the header seam survives every depth of the banner stack", async ({
page,
}) => {
test.skip(
test.info().project.name === "chromium-mobile",
"the sidebar is desktop-only (md+), so below md there is no seam to measure",
);
// Four stages measured at two widths, with a full page load per stage and
// a network settle either side of the offline one. Comfortably inside the
// suite default when it runs alone; the default 30s is not enough headroom
// for it on a loaded machine.
test.setTimeout(60_000);
await page.goto("/", { waitUntil: "domcontentloaded" });
const topBar = page.locator('[data-slot="top-bar"]');
const sidebarHeader = page.locator('[data-slot="sidebar-header"]');
await expect(topBar).toBeVisible();
await expect(sidebarHeader).toBeVisible();
const captured = await captureAccountPayload(page);
// Each stage adds one banner and leaves the previous ones up, so the last
// stage measures the whole stack rather than one banner at a time.
//
// `beforeResize` / `afterResize` exist for the offline stage alone: a
// viewport change can pull a chunk the page has not loaded yet, and a chunk
// fetch that fails with no network takes the whole shell down to the global
// error boundary. Observed once under `--repeat-each=3`, as a missing top
// bar and a "Failed to load chunk" screen. So the network comes back for
// the resize and the stage re-arms after it — the measurement is still
// taken offline, with the banner up.
type Stage = {
label: string;
arm: () => Promise<void>;
beforeResize?: () => Promise<void>;
afterResize?: () => Promise<void>;
};
const stages: Stage[] = [
{ label: "no banner", arm: async () => {} },
{
label: "1 banner (maintainership)",
arm: async () => {
await armUnmaintainedLocale(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="maintainership-banner"]'),
).toBeVisible();
},
},
{
label: "2 banners (+ shared record)",
arm: async () => {
await armSharedRecord(page, captured);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
},
},
{
label: "3 banners (+ offline)",
arm: async () => {},
beforeResize: async () => {
await page.context().setOffline(false);
await expect
.poll(() => page.evaluate(() => navigator.onLine))
.toBe(true);
},
afterResize: async () => {
await armOffline(page);
await expect(
page.locator('[data-slot="offline-banner"]'),
).toBeVisible();
},
},
];
// Stage outside, width inside: each banner is armed once and the resulting
// stack measured at both widths. 768px is the `md` breakpoint where the
// sidebar first appears — the narrowest shell that has a seam at all, and
// the width where a banner is most likely to wrap onto a second line.
for (const [index, stage] of stages.entries()) {
await stage.arm();
for (const width of [768, 1280] as const) {
await stage.beforeResize?.();
await page.setViewportSize({ width, height: 900 });
await expect
.poll(() => page.evaluate(() => window.innerWidth))
.toBe(width);
await stage.afterResize?.();
await expect(topBar).toBeVisible();
await expect(sidebarHeader).toBeVisible();
const shell = await readShell(page);
const at = `at ${width}px, ${stage.label}`;
expect(shell.topBar, `top bar missing ${at}`).not.toBeNull();
expect(shell.sidebar, `sidebar band missing ${at}`).not.toBeNull();
const top = shell.topBar!;
const side = shell.sidebar!;
// Two borders that do not exist also "line up".
expect(
top.borderBottomWidth,
`top bar has no bottom border ${at}`,
).toBeGreaterThan(0);
expect(
side.borderBottomWidth,
`sidebar band has no bottom border ${at}`,
).toBeGreaterThan(0);
// The stage armed what it said it armed. Without this a change that
// stopped every banner from painting would make the seam assertion
// below trivially true.
expect(
shell.bannerCount,
`${at}: expected ${index} banner(s) painted, found ${shell.bannerCount}`,
).toBe(index);
if (index > 0) {
expect(
shell.bannerHeight,
`${at}: the banners paint no height, so this stage proves nothing`,
).toBeGreaterThan(0);
}
// The seam. A banner that displaces one band and not the other shows
// up here as a step the size of the stack.
expect(
Math.abs(side.bottom - top.bottom),
`${at}: header seam steps by ${(side.bottom - top.bottom).toFixed(
2,
)}px — the banner stack (${shell.bannerHeight.toFixed(
2,
)}px) is displacing one band and not the other`,
).toBeLessThan(1);
// The banners come out of the viewport budget, not on top of it.
expect(
shell.documentOverflow,
`${at}: the document scrolls by ${shell.documentOverflow.toFixed(
2,
)}px — the banner stack pushed the shell past the viewport`,
).toBeLessThan(1);
}
}
});
test("the banner stack never pushes the shell past the viewport", async ({
page,
}) => {
// Mobile as well as desktop. Below `md` the shell stacks in a column and
// there is no sidebar, so there is no seam to measure — but the scroll
// model is what hoisting the banners could have broken there, and that
// half is measurable at both breakpoints.
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page.locator('[data-slot="top-bar"]')).toBeVisible();
const captured = await captureAccountPayload(page);
await armSharedRecord(page, captured);
await armUnmaintainedLocale(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(
page.locator('[data-slot="maintainership-banner"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="shared-record-banner"]'),
).toBeVisible();
await armOffline(page);
await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible();
const shell = await readShell(page);
expect(shell.bannerCount, "the stack did not arm").toBe(3);
expect(shell.bannerHeight).toBeGreaterThan(0);
expect(
shell.documentOverflow,
`the document scrolls by ${shell.documentOverflow.toFixed(
2,
)}px under a ${shell.bannerHeight.toFixed(2)}px banner stack`,
).toBeLessThan(1);
// The top bar is still fully painted below the stack rather than pushed
// off the bottom of the viewport.
const top = shell.topBar!;
expect(top.height).toBeGreaterThan(32);
expect(
top.bottom,
"the banner stack pushed the top bar out of the viewport",
).toBeLessThanOrEqual(shell.viewportHeight);
});
});
+8 -1
View File
@@ -363,7 +363,14 @@ export default function LoginPage() {
/>
</div>
) : (
<div className="mt-8 space-y-4">
<div
// The login card's own action block. `<main>` around it belongs
// to the shell and is on screen before this page renders at all,
// so it is this marker, not the landmark, that says the form has
// arrived.
data-slot="login-actions"
className="mt-8 space-y-4"
>
{oidcStatus?.enabled && (
<Button
onClick={handleOidcLogin}
@@ -229,7 +229,13 @@ export function AppLogPreviewSection() {
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{/* `app-log-rows` names the read events. The branch above it
is a spinner and the one below an empty state, so the
marker exists only once the buffer has actually answered. */}
<tbody
data-slot="app-log-rows"
className="divide-border divide-y"
>
{events.map((event, i) => {
const actionLabel =
event.action?.name ??
+7 -1
View File
@@ -658,7 +658,12 @@ export function BackupsSection() {
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{/* `backup-rows` names the read rows. The three branches above
are a spinner, a load error and an empty state, so the
marker cannot stand for a page that has not answered yet.
The narrow-layout list below carries it too, so a wait on
it holds at any viewport. */}
<tbody data-slot="backup-rows" className="divide-border divide-y">
{rows.map((row, i) => (
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{row.username}</td>
@@ -713,6 +718,7 @@ export function BackupsSection() {
stay reachable on a phone instead of scrolling off-screen. */}
<ul
className="space-y-2 md:hidden"
data-slot="backup-rows"
data-testid="admin-backups-mobile-list"
>
{rows.map((row) => (
@@ -99,7 +99,10 @@ export function RecentAuditPreview() {
title={t("admin.overview.auditEmpty")}
/>
) : (
<ul className="divide-border divide-y">
// `admin-audit-rows` names the read entries. The branches above
// are a spinner, a load error and an empty state, so the marker
// stands for a card that has actually answered.
<ul data-slot="admin-audit-rows" className="divide-border divide-y">
{entries.map((entry) => {
const isFailed = entry.action === "auth.login.failed";
// F-31 (v1.4.19): each row links into the full
@@ -141,7 +141,13 @@ export function SystemStatusSection() {
description={t("admin.systemStatusDescription")}
/>
{status ? (
<div className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4">
<div
// `system-status-grid` names the answered snapshot. The two
// branches below it are a load error and a spinner, so the
// marker cannot stand for a section still waiting on the read.
data-slot="system-status-grid"
className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4"
>
<StatusItem
icon={Database}
label={t("admin.database")}
@@ -354,7 +354,15 @@ export function UserManagementSection() {
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{/* `admin-user-rows` names the read accounts. The section
renders an empty state instead when the list is empty and
nothing at all while the query is in flight, so the marker
stands for answered data. The mobile card list below
carries it too, so a wait on it holds at any viewport. */}
<tbody
data-slot="admin-user-rows"
className="divide-border divide-y"
>
{filteredUsers.map((u, i) => (
<tr key={u.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
<td className="px-3 py-2 font-medium">{u.username}</td>
@@ -392,6 +400,7 @@ export function UserManagementSection() {
is hidden behind a horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
data-slot="admin-user-rows"
data-testid="admin-users-mobile-list"
>
{filteredUsers.map((u) => (
@@ -0,0 +1,79 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import deMessages from "../../../../messages/de.json";
/**
* Chart card heading level.
*
* The icon-less chart header (dashboard cells, /insights/bmi, the sleep
* overview) titles itself at the second level. It has to: the card is a
* sibling of the other cards around it, and those already name themselves
* there — the dashboard's "Recent unlocks" and "Recent workouts" tiles render
* `<TileHeader titleAs="h2">`, and the metric subpages' target / usual-range
* cards are `<h2>` too. At `<h3>` the card claimed to be nested inside a
* section that does not exist, and the page skipped a level straight from its
* `<h1>`: the dashboard read h1 → h3 → h3 → h3 → h2 → h2 and /insights/bmi
* read h1 → h3 → h2.
*
* The browser scan in `e2e/a11y.spec.ts` catches a regression here through
* axe's `heading-order` rule, but only in the e2e job. This is the cheap
* guard that fails in the unit run.
*/
function buildData(): unknown[] {
const out: Array<{ date: string; timestamp: number; PULSE: number }> = [];
const base = Date.UTC(2026, 0, 1, 12, 0, 0);
for (let i = 0; i < 10; i++) {
out.push({
date: `2026-01-${String(i + 1).padStart(2, "0")}`,
timestamp: base + i * 86_400_000,
PULSE: 60 + i,
});
}
return out;
}
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
isAuthenticated: true,
user: null,
isLoading: false,
}),
}));
describe("<HealthChart> — card heading level", () => {
beforeEach(() => {
vi.resetModules();
});
it("titles the icon-less chart card at the second level, never the third", async () => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
getQueryData: () => undefined,
setQueryData: () => undefined,
invalidateQueries: () => Promise.resolve(),
}),
useMutation: () => ({ mutate: () => undefined, isPending: false }),
}));
const { I18nProvider } = await import("@/lib/i18n/context");
const { HealthChart } = await import("../health-chart");
const html = renderToStaticMarkup(
<I18nProvider initialLocale="de" initialMessages={deMessages}>
<HealthChart types={["PULSE"]} title="Pulse" unit="bpm" />
</I18nProvider>,
);
// The title is a real heading carrying the real title text, not a styled
// div — assert both halves so a silent demotion to a `<span>` fails too.
expect(html).toContain('<h2 class="text-sm font-semibold">Pulse</h2>');
expect(html).not.toContain("<h3");
vi.doUnmock("@tanstack/react-query");
});
});
+15 -3
View File
@@ -246,7 +246,7 @@ interface HealthChartProps {
* v1.12.8 — optional leading glyph for the chart card's `<TileHeader>`.
* When supplied, the chart header renders the canonical icon + title row
* (same size + spacing as every other Insights tile) instead of the bare
* `<h3>`. Omitted on the dashboard / mini mounts, which keep the compact
* `<h2>`. Omitted on the dashboard / mini mounts, which keep the compact
* heading. Accepts any component that takes a `className` (the contract
* `<TileHeader>` and the stat strip's `icon` already use), so a page can
* thread the same glyph it hands the stat strip.
@@ -1691,11 +1691,23 @@ export function HealthChart({
`<TileHeader>` (icon + title at `text-base`, foreground
colour, h-5 w-5) so it matches the assessment / target /
stat tiles. The dashboard / ad-hoc mounts omit the icon and
keep the compact `<h3>`. */}
keep the compact heading.
That compact heading is an `<h2>`, not an `<h3>`. The card is
a sibling of the other cards in the same grid, and those name
themselves at the second level already — the dashboard's
"Recent unlocks" and "Recent workouts" tiles both render
`<TileHeader titleAs="h2">`, and the metric subpages' target /
usual-range cards are `<h2>` too. Sitting one level lower made
the chart card the odd one out AND skipped a level under the
page `<h1>`: the dashboard's rendered ladder read h1 → h3 →
h3 → h3 → h2 → h2, and /insights/bmi read h1 → h3 → h2. A
heading level is a claim about nesting, and the chart card is
not nested inside anything. */}
{titleIcon ? (
<TileHeader icon={titleIcon} title={title} />
) : (
<h3 className="text-sm font-semibold">{title}</h3>
<h2 className="text-sm font-semibold">{title}</h2>
)}
{activeBucket !== "day" && (
<span className="bg-muted/40 text-muted-foreground hidden rounded-md px-1.5 py-0.5 text-[10px] font-medium tracking-wide uppercase sm:inline-flex">
@@ -93,10 +93,11 @@ export function MaintainershipBanner() {
return (
<div
role="status"
data-slot="maintainership-banner"
data-testid="maintainership-banner"
className="border-border bg-muted/40 text-muted-foreground flex items-start gap-2 border-b px-4 py-2 text-xs sm:items-center"
>
<Languages className="mt-0.5 h-3.5 w-3.5 shrink-0 sm:mt-0" aria-hidden />
<Languages className="mt-0.5 size-3.5 shrink-0 sm:mt-0" aria-hidden />
<p className="flex-1 leading-snug">
{t("i18n.maintainershipBanner.notice")}{" "}
<a
@@ -117,7 +118,7 @@ export function MaintainershipBanner() {
// the surrounding hit area is what the user actually taps.
className="hover:text-foreground focus-visible:ring-ring/50 -mr-2 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-sm transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<X className="h-3.5 w-3.5" aria-hidden />
<X className="size-3.5" aria-hidden />
</button>
</div>
);
+5 -1
View File
@@ -184,7 +184,10 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// (#40); the card/tile view is the alternative the settings toggle selects.
if (prefs.view === "list") {
return (
<div className="space-y-3">
// `lab-list` marks the read results in both view shapes. The loading
// branch above carries `lab-list-loading` instead, so the marker
// cannot stand for a silhouette.
<div data-slot="lab-list" className="space-y-3">
{truncated ? (
<p className="text-muted-foreground text-xs">
{t("labs.showingLatestOf", { shown, total })}
@@ -259,6 +262,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
// control on the tile; the trash icon lives only on the value detail view.
return (
<ul
data-slot="lab-list"
className={cn(
"grid list-none gap-4 p-0",
// A lone lab spans the full row rather than orphaning half of it;
+93 -69
View File
@@ -297,67 +297,90 @@ export function AuthShell({
{t("nav.skipToContent")}
</a>
</nav>
<div className="flex h-dvh flex-col md:flex-row">
<SidebarNav />
{/* `min-w-0` lets the content column shrink below its children's
intrinsic min width (e.g. the measurements table); without it
the column widens past the viewport next to the sidebar and
the whole page gains a horizontal scrollbar. Wide children
scroll inside their own `overflow-x-auto` containers. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
{/*
v1.4.43 QoL (M5) — `<OfflineBanner>` paints only when
`navigator.onLine === false`. Sits above the maintainership
banner + top bar so the connection-status hint is always
the first chrome line a user sees in the offline branch.
*/}
<OfflineBanner />
{/* v1.36.0 — whose record is open. Sits directly under the
connection strip and above everything else: a person who has
forgotten they are switched is about to write a reading into
somebody else's record, and no other line of chrome is worth
seeing first. Renders nothing when the session is in its own
account, which is every session that has never used sharing. */}
<SharedRecordBanner />
{/* Demo instances surface a persistent "changes are not
saved" strip — the proxy blocks every mutation, and
without the banner that block only surfaced as a raw API
error after the user already filled in a form. */}
{demoMode ? <DemoBanner /> : null}
<MaintainershipBanner />
<TopBar />
<main
id="main-content"
// `scrollbar-gutter: stable` keeps the scroll viewport's content
// box a fixed width whether or not the vertical scrollbar is
// painted. Without it a route / sub-tab whose body overflows
// (e.g. the admin "Insights Quality" section) shows the bar and
// narrows the content box, while a shorter one (e.g.
// "Integrations") hides it and widens the box — the `mx-auto`
// wrapper then recentres and the whole column, including the
// admin/settings sidebar, shifts a few px sideways on every
// toggle. Reserving the gutter holds the layout still.
//
// `relative` makes this element the containing block for every
// absolutely-positioned descendant that has no nearer positioned
// ancestor. Without it those boxes resolve against the INITIAL
// containing block, which lives outside the scroll container, so
// `overflow-y-auto` never clips them: their static position sits
// wherever the flow put them and extends the DOCUMENT's
// scrollable overflow instead, painting a second vertical scroll
// surface beside `<main>` (UI-STANDARDS §9 one-scroll-floor).
// The class is not hypothetical and not confined to app code. A
// Radix `<Switch>` outside a `<form>` renders a hidden
// `position:absolute` bubble input on the first pass and drops it
// only once the button ref resolves, so every route carrying a
// switch below the fold (/settings/modules is the long one) grew
// a full-height document scrollbar for the entire pre-hydration
// window. Anchoring the scroll container closes the escape route
// for all of them at once instead of asking each new
// absolutely-positioned child to remember its own wrapper.
className="relative flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
>
{/*
{/*
The banner stack is PAGE-level, above the sidebar/content row, and that
placement is the whole point rather than a detail of the markup. A
banner is a strip about the whole window — the connection is down, this
is somebody else's record, this instance saves nothing — and none of
those statements is about the content column.
Inside the column, which is where they lived until v1.36.x, each one
pushed the top bar down while the sidebar's logo band stayed at the
top. Both bands read `SHELL_HEADER_BAND` so they are the same height
with the same border, and the line they draw together still broke: with
the French maintainership strip up, the two borders sat 66.5px apart at
768px and 61px apart at 1280px, and the full three-banner stack put them
184.5px apart. Above the row, every banner displaces both bands by the
same amount and the seam cannot come apart however many paint.
The stack comes OUT of the viewport budget rather than being added on
top of it: the outer wrapper owns the `h-dvh`, the row below takes what
the banners leave (`min-h-0 flex-1`), and `<main>` stays the one
vertical scroller. Measured in `e2e/chrome-header-seam-banners.spec.ts`
at both breakpoints.
*/}
<div className="flex h-dvh flex-col">
{/*
v1.4.43 QoL (M5) — `<OfflineBanner>` paints only when
`navigator.onLine === false`. Sits above the maintainership
banner + top bar so the connection-status hint is always
the first chrome line a user sees in the offline branch.
*/}
<OfflineBanner />
{/* v1.36.0 — whose record is open. Sits directly under the
connection strip and above everything else: a person who has
forgotten they are switched is about to write a reading into
somebody else's record, and no other line of chrome is worth
seeing first. Renders nothing when the session is in its own
account, which is every session that has never used sharing. */}
<SharedRecordBanner />
{/* Demo instances surface a persistent "changes are not
saved" strip — the proxy blocks every mutation, and
without the banner that block only surfaced as a raw API
error after the user already filled in a form. */}
{demoMode ? <DemoBanner /> : null}
<MaintainershipBanner />
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<SidebarNav />
{/* `min-w-0` lets the content column shrink below its children's
intrinsic min width (e.g. the measurements table); without it
the column widens past the viewport next to the sidebar and
the whole page gains a horizontal scrollbar. Wide children
scroll inside their own `overflow-x-auto` containers. */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<TopBar />
<main
id="main-content"
// `scrollbar-gutter: stable` keeps the scroll viewport's content
// box a fixed width whether or not the vertical scrollbar is
// painted. Without it a route / sub-tab whose body overflows
// (e.g. the admin "Insights Quality" section) shows the bar and
// narrows the content box, while a shorter one (e.g.
// "Integrations") hides it and widens the box — the `mx-auto`
// wrapper then recentres and the whole column, including the
// admin/settings sidebar, shifts a few px sideways on every
// toggle. Reserving the gutter holds the layout still.
//
// `relative` makes this element the containing block for every
// absolutely-positioned descendant that has no nearer positioned
// ancestor. Without it those boxes resolve against the INITIAL
// containing block, which lives outside the scroll container, so
// `overflow-y-auto` never clips them: their static position sits
// wherever the flow put them and extends the DOCUMENT's
// scrollable overflow instead, painting a second vertical scroll
// surface beside `<main>` (UI-STANDARDS §9 one-scroll-floor).
// The class is not hypothetical and not confined to app code. A
// Radix `<Switch>` outside a `<form>` renders a hidden
// `position:absolute` bubble input on the first pass and drops it
// only once the button ref resolves, so every route carrying a
// switch below the fold (/settings/modules is the long one) grew
// a full-height document scrollbar for the entire pre-hydration
// window. Anchoring the scroll container closes the escape route
// for all of them at once instead of asking each new
// absolutely-positioned child to remember its own wrapper.
className="relative flex-1 [scrollbar-gutter:stable] overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0"
>
{/*
v1.4.33 IW9 — container normalised on `max-w-screen-xl`
(1280 px) so dashboard / settings / admin all hit the
same content frame. Pre-v1.4.33 the dashboard
@@ -371,15 +394,16 @@ export function AuthShell({
the desktop bottom-6 anchor), so the last line of content
can always scroll out from under the floating button.
*/}
<div
data-slot="main-content-wrapper"
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
>
{outsideSharedRecord ? <SharedRecordUnavailable /> : children}
</div>
</main>
<div
data-slot="main-content-wrapper"
className="mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6"
>
{outsideSharedRecord ? <SharedRecordUnavailable /> : children}
</div>
</main>
</div>
<BottomNav />
</div>
<BottomNav />
</div>
<LayoutCoachMount />
{/* v1.18.6 — the module-tour launcher lives at the shell level so its
+1 -1
View File
@@ -31,7 +31,7 @@ export function DemoBanner() {
data-slot="demo-banner"
className="bg-primary/10 border-primary/30 text-foreground flex items-center justify-center gap-2 border-b px-3 py-2 text-xs"
>
<FlaskConical className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<FlaskConical className="size-3.5 shrink-0" aria-hidden="true" />
<span className="text-center">{t("demoBanner.message")}</span>
</div>
);
+1 -1
View File
@@ -83,7 +83,7 @@ export function OfflineBanner() {
data-slot="offline-banner"
className="bg-warning/15 border-warning/40 text-foreground flex items-center justify-center gap-2 border-b px-3 py-2 text-xs"
>
<WifiOff className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<WifiOff className="size-3.5 shrink-0" aria-hidden="true" />
<span className="text-center">
{t("offlineBanner.message")}
{hasCachedData ? ` ${t("offlineBanner.showingCached")}` : ""}
+30 -14
View File
@@ -30,7 +30,18 @@ import { accountLabel } from "@/lib/sharing/account-access-view";
*
* The name is `text-foreground` and the qualifier `text-muted-foreground`, per
* the two-tier text rule — the person's name is content, the read-only note is
* meta. Mounted for every authenticated route inside `<AuthShell>` (the
* meta.
*
* The row is the inline-action shape from UI-STANDARDS §11 rather than a
* centred wrapping stack: sentence `min-w-0 flex-1`, action `shrink-0`, so the
* way out stays beside the sentence instead of dropping onto its own line once
* the name is long. `<MaintainershipBanner>` is the sibling that already had an
* action and already had this shape; the two now read the same. The tap target
* carries the standard's floor (`min-h-11 sm:min-h-9`) — a person leaving
* somebody else's record on a phone is the one interaction in the stack, and it
* was a 32px target.
*
* Mounted for every authenticated route inside `<AuthShell>` (the
* `<DemoBanner>` pattern) so no page can forget it, and rendered from
* `accountAccess.active`, which the server resolves: the banner appears exactly
* when the server says the session is inside a record, never on a client guess.
@@ -54,25 +65,30 @@ export function SharedRecordBanner() {
role="status"
data-slot="shared-record-banner"
data-account-id={active.accountId}
className="bg-warning/15 border-warning/40 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-b px-3 py-2 text-xs"
className="bg-warning/15 border-warning/40 flex items-start gap-3 border-b px-3 py-2 text-xs sm:items-center"
>
<Eye className="text-foreground size-3.5 shrink-0" aria-hidden="true" />
<span className="text-foreground text-center font-medium">
{t("recordSharing.banner.viewing", { name })}
</span>
{/* Resolved server-side. `canWrite` is false for every grant in v1, and
the day it is not, this line stops claiming otherwise on its own. */}
{!active.canWrite && (
<span className="text-muted-foreground text-center">
{t("recordSharing.banner.readOnly")}
</span>
)}
<Eye
className="text-foreground mt-0.5 size-3.5 shrink-0 sm:mt-0"
aria-hidden="true"
/>
<p className="min-w-0 flex-1 leading-snug">
<span className="text-foreground font-medium">
{t("recordSharing.banner.viewing", { name })}
</span>{" "}
{/* Resolved server-side. `canWrite` is false for every grant in v1, and
the day it is not, this line stops claiming otherwise on its own. */}
{!active.canWrite && (
<span className="text-muted-foreground">
{t("recordSharing.banner.readOnly")}
</span>
)}
</p>
<button
type="button"
data-slot="shared-record-banner-exit"
onClick={() => switchAccount.mutate(null)}
disabled={switchAccount.isPending}
className="text-foreground hover:bg-warning/25 focus-visible:ring-ring inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50"
className="text-foreground hover:bg-warning/25 focus-visible:ring-ring inline-flex min-h-11 shrink-0 items-center gap-1.5 rounded-md px-2 py-1 font-medium underline underline-offset-2 focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 sm:min-h-9"
>
{switchAccount.isPending ? (
<Loader2
@@ -964,7 +964,16 @@ export function MeasurementList({
<>
{/* Desktop table — rendered only when not mobile (v1.28.42 H3). */}
{!isMobile && (
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
<div
// `measurement-rows` marks the READ rows, not the list frame:
// the filter rail above renders next to the loading
// silhouettes as well, so it proves nothing about the data
// having arrived. This wrapper exists only in the resolved,
// non-empty branch, and the mobile list below carries the
// same marker so a wait on it holds at any viewport.
data-slot="measurement-rows"
className="bg-card border-border hidden overflow-hidden rounded-lg border md:block"
>
<Table>
<TableHeader>
<TableRow>
@@ -1216,7 +1225,7 @@ export function MeasurementList({
{/* Mobile list — rendered only when mobile (v1.28.42 H3). */}
{isMobile && (
<div className="space-y-2 md:hidden">
<div data-slot="measurement-rows" className="space-y-2 md:hidden">
{data.measurements.map((m) => {
const Icon = TYPE_ICONS[m.type];
const isGrouped =
+11 -2
View File
@@ -629,7 +629,16 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
) : (
<>
{/* Desktop table */}
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
<div
// `mood-rows` marks the READ entries, not the list frame: the
// filter rail above renders next to the loading silhouettes
// too, so it proves nothing about the data having arrived.
// This wrapper exists only in the resolved, non-empty branch,
// and the mobile list below carries the same marker so a wait
// on it holds at any viewport.
data-slot="mood-rows"
className="bg-card border-border hidden overflow-hidden rounded-lg border md:block"
>
<Table>
<TableHeader>
<TableRow>
@@ -760,7 +769,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
`data-testid="mood-row"` hook is what the Playwright Pixel-5
guard at `e2e/mood-card-mobile.spec.ts` queries to assert
"exactly one occurrence of the score per row". */}
<div className="space-y-2 md:hidden">
<div data-slot="mood-rows" className="space-y-2 md:hidden">
{data.entries.map((entry) => {
const isSelected = selectedIds.has(entry.id);
return (