fix(insights): promote coach-rail labels to semantic h3 headings

The history and sources rails mount inline next to the message thread
on lg+ / xl+ viewports, where no SheetTitle wrapper covers them.
Both rails previously rendered their section labels as <span> with
uppercase styling, so the drawer's semantic outline disappeared above
the message thread on desktop. Screen-reader users navigating by
heading skipped straight from the drawer's <h2> title to the
message-thread region with no intermediate landmark.

Promote both labels to <h3> while keeping the existing visual
treatment intact. The mobile rail-tray (Sheet-wrapped) still surfaces
its SheetTitle <h2>, so the rail's own <h3> simply nests below it on
small screens. The desktop layout now carries a coherent h2 (drawer)
> h3 (rail) hierarchy.

Regression tests assert that each rail renders its label inside a
real <h3> element so the semantic outline cannot silently drop again.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 17:46:21 +02:00
1 parent 87e4cdcb1b
commit bfb1b43af2
4 files changed
+52 -10

No files matched your search

@@ -66,6 +66,21 @@ describe("<HistoryRail>", () => {
expect(html).toContain("Conversations");
});
it("renders the rail label as a real `<h3>` heading (v1.4.33)", () => {
// The rail mounts inline on `lg+` desktop where no `SheetTitle`
// wrapper covers it. Without a real heading element the drawer
// lost its semantic outline above the message thread — fixed in
// v1.4.33 by promoting the rail label to `<h3>`.
const client = makeClientWithConversations(samplePage);
const html = render(
<HistoryRail activeId={null} onSelect={() => {}} />,
client,
);
expect(html).toMatch(
/<h3[^>]*data-slot="coach-history-rail-heading"[^>]*>[\s\S]*Conversations[\s\S]*<\/h3>/,
);
});
it("renders one row per conversation", () => {
const client = makeClientWithConversations(samplePage);
const html = render(
@@ -17,6 +17,17 @@ describe("<SourcesRail>", () => {
expect(html).toContain("What I can see");
});
it("renders the rail label as a real `<h3>` heading (v1.4.33)", () => {
// The rail mounts inline on `xl+` desktop where no `SheetTitle`
// wrapper covers it. Promoting the label to `<h3>` restores the
// drawer's semantic outline so screen-reader users can navigate
// by heading.
const html = render(<SourcesRail />);
expect(html).toMatch(
/<h3[^>]*data-slot="coach-sources-rail-heading"[^>]*>[\s\S]*What I can see[\s\S]*<\/h3>/,
);
});
it("lists exactly five rows (BP, weight, pulse, mood, compliance)", () => {
const html = render(<SourcesRail />);
const rows = html.match(/data-slot="coach-sources-row"/g) ?? [];
@@ -69,15 +69,23 @@ export function HistoryRail({
data-slot="coach-history-rail"
className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the drawer carries a semantic outline on desktop
where the rail is mounted inline (no `SheetTitle` wrapper).
The mobile rail-tray still wraps the rail inside its own
`SheetTitle`, so screen-reader users hear the section twice
when the tray is open — that's intentional Radix behaviour
(the rail still has to stand alone on desktop). */}
<h3
data-slot="coach-history-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
>
<MessagesSquare
className="text-muted-foreground size-3.5"
aria-hidden="true"
/>
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.historyTitle")}
</span>
</div>
{t("insights.coach.historyTitle")}
</h3>
<div className="relative">
<Search
aria-hidden="true"
@@ -172,12 +172,20 @@ export function SourcesRail({
data-slot="coach-sources-rail"
className={cn("flex h-full min-h-0 flex-col gap-3 p-3", className)}
>
<div className="flex items-center gap-1.5 px-1">
{/* v1.4.33 — promote the rail label from a `<span>` to a real
`<h3>` so the rail carries a semantic outline on desktop
where it mounts inline next to the message thread. The
mobile rail-tray wraps the rail inside its own `SheetTitle`
(an `<h2>`), so the rail's own `<h3>` simply nests beneath
it — the heading hierarchy stays consistent across
viewports. */}
<h3
data-slot="coach-sources-rail-heading"
className="text-muted-foreground flex items-center gap-1.5 px-1 text-[11px] font-medium tracking-wide uppercase"
>
<Eye className="text-muted-foreground size-3.5" aria-hidden="true" />
<span className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
{t("insights.coach.sourcesTitle")}
</span>
</div>
{t("insights.coach.sourcesTitle")}
</h3>
{/* Window selector — every row below is filtered to the picked
window when the next Coach message goes out. 36px touch