Title the chart card at the level it actually sits on

A dashboard chart card called itself an h3. Nothing contained it. The cards
beside it in the same grid — "Recent unlocks", "Recent workouts" — are h2,
and so are the target and usual-range cards on the metric subpages, so the
chart card was both the odd one out and a level skipped straight from the
page h1. The rendered dashboard ladder was h1 → h3 → h3 → h3 → h2 → h2, and
/insights/bmi was h1 → h3 → h2.

A heading level is a claim about nesting. Someone moving through the page by
headings hears a subsection announced where there is no section, and screen
reader rotors build their outline from exactly this. Promoting the title
keeps the same text, the same classes and the same pixels; only the claim
changes.

The unit guard fails in the fast run; the browser scan catches the same
regression through axe, but only in the e2e job.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 19:44:45 +02:00
1 parent 002a82106a
commit ef9d66b7f9
2 files changed
+94 -3

No files matched your search

@@ -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">