mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge remote-tracking branch 'origin/main' into work/sharing-routes
This commit is contained in:
commit
d1a9147034
18 files changed
+807
-121
No files matched your search
+93
-20
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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 =
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in new issue
Block a user