mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Merge pull request #701 from MBombeck/fix/a11y-scan-sentinels
Wait for content the page owns before scanning it
This commit is contained in:
12 files changed
+257
-34
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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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