From 95f21bfa4d7db574cbc579fea8ef23fe87a65e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 23:20:37 +0200 Subject: [PATCH] fix(ui): announce failures, keep focus honest and stop the last layout shifts Hidden states of the floating coach button now leave the tab order and accessibility tree, its focus ring carries an offset, its icon is dark on the gradient, an unread nudge is announced once via a live region, and it sits correctly above the fold on tablet widths and yields to the onboarding tour. Chart error cards announce themselves, share the exact height variables of the skeleton band, carry per-chart retry labels, and a failed chunk renders inside the regular card shell. The header icon cluster spaces its extended hit areas apart, the tab strip keeps horizontal paint room for focus rings, the streak row reserves its slot in every card state, the selection bar clears the bottom nav on landscape phones, and the version poller keeps an in-memory reload guard for browsers that block session storage. Mood notes render as plain paragraphs with a toggle that appears only when text is actually clamped, the remember control reveals on hover and hands focus to its confirmation, and the hypnogram names its source and paints only the lanes the night contains. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- .../__tests__/chart-empty-state.test.tsx | 11 ++ .../__tests__/chart-error-state.test.tsx | 82 +++++++++++ src/components/charts/chart-empty-state.tsx | 20 ++- src/components/charts/chart-error-state.tsx | 62 ++++++-- src/components/charts/health-chart.tsx | 1 + .../charts/medication-compliance-chart.tsx | 1 + src/components/charts/mood-chart.tsx | 1 + .../data-list/selection-action-bar.tsx | 10 +- .../__tests__/insights-tab-strip.test.tsx | 13 ++ .../__tests__/layout-coach-fab.test.tsx | 65 ++++++++- .../__tests__/sleep-hypnogram.test.tsx | 94 +++++++++++++ .../__tests__/sub-page-shell.test.tsx | 6 + .../__tests__/message-thread.test.tsx | 38 +++++ .../insights/coach-panel/message-thread.tsx | 26 +++- .../insights/insights-tab-strip.tsx | 5 +- src/components/insights/layout-coach-fab.tsx | 133 +++++++++++++----- src/components/insights/sleep-hypnogram.tsx | 75 ++++++---- src/components/insights/sub-page-shell.tsx | 9 +- .../medications/__tests__/card-parts.test.tsx | 20 +++ .../card-parts/medication-compliance-bars.tsx | 25 ++-- .../__tests__/mood-note-fulltext.test.tsx | 38 +++-- src/components/mood/mood-list.tsx | 70 +++++++-- src/components/version-poller.tsx | 17 ++- 29 files changed, 703 insertions(+), 137 deletions(-) create mode 100644 src/components/insights/__tests__/sleep-hypnogram.test.tsx diff --git a/messages/de.json b/messages/de.json index b6f30b7a7..7b5c36cd2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -2480,7 +2480,8 @@ "ariaLabel": "Schlafphasen-Verlauf der letzten Nacht: {asleep} geschlafen, {awakenings} Wachphasen", "asleepTotal": "Geschlafen {value}", "inBedTotal": "Im Bett {value}", - "awakenings": "{count} Wachphasen" + "awakenings": "{count} Wachphasen", + "source": "Quelle: {source}" }, "stages": { "deep": "Tief", diff --git a/messages/en.json b/messages/en.json index 6c71f63ce..d50aa6490 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2480,7 +2480,8 @@ "ariaLabel": "Sleep stage progression for last night: {asleep} asleep, {awakenings} awakenings", "asleepTotal": "Asleep {value}", "inBedTotal": "In bed {value}", - "awakenings": "{count} awakenings" + "awakenings": "{count} awakenings", + "source": "Source: {source}" }, "stages": { "deep": "Deep", diff --git a/messages/es.json b/messages/es.json index 9aea8196d..593d43f7b 100644 --- a/messages/es.json +++ b/messages/es.json @@ -2480,7 +2480,8 @@ "ariaLabel": "Progresión de las fases del sueño de la última noche: {asleep} dormido, {awakenings} despertares", "asleepTotal": "Dormido {value}", "inBedTotal": "En la cama {value}", - "awakenings": "{count} despertares" + "awakenings": "{count} despertares", + "source": "Fuente: {source}" }, "stages": { "deep": "Profundo", diff --git a/messages/fr.json b/messages/fr.json index ca205fd30..ea85d0e79 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -2480,7 +2480,8 @@ "ariaLabel": "Progression des phases de sommeil de la nuit dernière : {asleep} de sommeil, {awakenings} réveils", "asleepTotal": "Sommeil {value}", "inBedTotal": "Au lit {value}", - "awakenings": "{count} réveils" + "awakenings": "{count} réveils", + "source": "Source : {source}" }, "stages": { "deep": "Profond", diff --git a/messages/it.json b/messages/it.json index fb771a4db..ef90af625 100644 --- a/messages/it.json +++ b/messages/it.json @@ -2480,7 +2480,8 @@ "ariaLabel": "Andamento delle fasi del sonno dell'ultima notte: {asleep} di sonno, {awakenings} risvegli", "asleepTotal": "Sonno {value}", "inBedTotal": "A letto {value}", - "awakenings": "{count} risvegli" + "awakenings": "{count} risvegli", + "source": "Fonte: {source}" }, "stages": { "deep": "Profondo", diff --git a/messages/pl.json b/messages/pl.json index 451747634..84f135b09 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -2480,7 +2480,8 @@ "ariaLabel": "Przebieg faz snu ostatniej nocy: {asleep} snu, {awakenings} przebudzeń", "asleepTotal": "Sen {value}", "inBedTotal": "W łóżku {value}", - "awakenings": "{count} przebudzeń" + "awakenings": "{count} przebudzeń", + "source": "Źródło: {source}" }, "stages": { "deep": "Głęboki", diff --git a/src/components/charts/__tests__/chart-empty-state.test.tsx b/src/components/charts/__tests__/chart-empty-state.test.tsx index d1b1c83d9..bd4647398 100644 --- a/src/components/charts/__tests__/chart-empty-state.test.tsx +++ b/src/components/charts/__tests__/chart-empty-state.test.tsx @@ -32,5 +32,16 @@ describe("", () => { , ); expect(html).toContain("height:320px"); + expect(html).not.toContain("h-[var(--chart-height,240px)]"); + }); + + it("sizes through the chart-height variables by default", () => { + // The former hardcoded `style="height:240px"` ignored per-mount + // `--chart-height` overrides; the default now reads the same + // variables the painted chart does (240 px mobile / 280 px md+). + const html = renderToStaticMarkup(); + expect(html).toContain("h-[var(--chart-height,240px)]"); + expect(html).toContain("md:h-[var(--chart-height-md,280px)]"); + expect(html).not.toContain("height:240px"); }); }); diff --git a/src/components/charts/__tests__/chart-error-state.test.tsx b/src/components/charts/__tests__/chart-error-state.test.tsx index 258fa9c54..609ceb6d0 100644 --- a/src/components/charts/__tests__/chart-error-state.test.tsx +++ b/src/components/charts/__tests__/chart-error-state.test.tsx @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; // vi.doMock below invalidates the module registry, so the re-imported // provider gets a fresh (unprimed) locale cache — pass the DE bundle // explicitly instead of relying on the vitest.setup.ts seeding. @@ -65,6 +67,9 @@ describe("chart isError rendering — error state, not empty state", () => { expect(html).toContain("Erneut versuchen"); // Card chrome must stay mounted. expect(html).toContain("Pulse"); + // The retry button carries the chart title in its accessible name + // so several failed charts on one page stay distinguishable. + expect(html).toContain('aria-label="Erneut versuchen – Pulse"'); // The empty-state copy must NOT paint — an outage is not "no data". expect(html).not.toContain("Keine Daten in diesem Zeitraum"); @@ -113,3 +118,80 @@ describe("chart isError rendering — error state, not empty state", () => { vi.doUnmock("@tanstack/react-query"); }); }); + +describe(" — announcement, sizing, accessible name", () => { + beforeEach(() => { + vi.resetModules(); + }); + + async function renderState( + props: Partial< + import("../chart-error-state").ChartErrorStateProps + > = {}, + ) { + const { ChartErrorState } = await import("../chart-error-state"); + return renderToStaticMarkup( + undefined} + {...props} + />, + ); + } + + it("announces the failure as a status by default", async () => { + const html = await renderState(); + expect(html).toContain('role="status"'); + }); + + it("escalates to alert when the boundary asks for it", async () => { + const html = await renderState({ role: "alert" }); + expect(html).toContain('role="alert"'); + expect(html).not.toContain('role="status"'); + }); + + it("sizes through the chart-height variables by default", async () => { + // The former hardcoded `style="height:240px"` ignored per-mount + // `--chart-height` overrides; the default now reads the same + // variables the painted chart does. + const html = await renderState(); + expect(html).toContain("h-[var(--chart-height,240px)]"); + expect(html).toContain("md:h-[var(--chart-height-md,280px)]"); + expect(html).not.toContain("height:240px"); + }); + + it("keeps the explicit height prop as a per-mount override", async () => { + const html = await renderState({ height: 140 }); + expect(html).toContain("height:140px"); + expect(html).not.toContain("h-[var(--chart-height,240px)]"); + }); + + it("joins the action context into the retry button's accessible name", async () => { + const html = await renderState({ actionContext: "Pulse" }); + expect(html).toContain('aria-label="Retry – Pulse"'); + }); + + it("omits the aria-label without a context so the label is the name", async () => { + const html = await renderState(); + expect(html).not.toContain("aria-label"); + }); +}); + +describe(" — chunk-failure fallback", () => { + it("wraps the fallback in the chart-card shell and escalates to alert", () => { + // Error boundaries do not catch during static SSR, so the fallback + // wiring is pinned structurally: the fallback card must carry the + // solid chart-card shell (not a bare dashed box among solid cards) + // and pass `role="alert"` (the whole chart is gone, not just data). + const src = readFileSync( + join(process.cwd(), "src/components/charts/chart-error-state.tsx"), + "utf8", + ); + expect(src).toContain('data-slot="chart-error-boundary-card"'); + expect(src).toContain( + 'className="bg-card border-border rounded-xl border p-4 md:p-6"', + ); + expect(src).toContain('role="alert"'); + }); +}); diff --git a/src/components/charts/chart-empty-state.tsx b/src/components/charts/chart-empty-state.tsx index 066cb2826..fe71164ad 100644 --- a/src/components/charts/chart-empty-state.tsx +++ b/src/components/charts/chart-empty-state.tsx @@ -3,6 +3,8 @@ import type { ReactElement, ReactNode } from "react"; import { LineChart as LineChartIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + /** * Sparse-data empty-state for chart wrappers (B1a v1.4.16). * @@ -29,9 +31,11 @@ export interface ChartEmptyStateProps { */ description?: ReactNode; /** - * Optional explicit height in pixels to match the surrounding chart - * card. Defaults to 240 (matches `` / - * ``). + * Optional explicit height in pixels for mini / mounted-in-a-strip + * contexts. Absent, the card sizes through the same + * `--chart-height` / `--chart-height-md` variables the painted chart + * reads (240 px mobile / 280 px md+ by default), so a per-mount + * override resizes the empty state too. */ height?: number; } @@ -39,13 +43,17 @@ export interface ChartEmptyStateProps { export function ChartEmptyState({ title, description, - height = 240, + height, }: ChartEmptyStateProps): ReactElement { return (
void; /** - * Optional explicit height in pixels to match the surrounding chart - * card. Defaults to 240 (matches ``). + * Optional explicit height in pixels for mini / mounted-in-a-strip + * contexts. Absent, the card sizes through the same + * `--chart-height` / `--chart-height-md` variables the painted chart + * reads, so a per-mount override resizes the error card too. */ height?: number; + /** + * Optional translated context (usually the chart title). Joins the + * action label into the button's accessible name so several retry + * buttons on one page stay distinguishable for screen-reader users. + */ + actionContext?: string; + /** + * Live-region semantics. `status` (default) announces politely when + * a data query fails inside an otherwise healthy card; the chunk + * boundary fallback passes `alert` because the surrounding chart is + * gone entirely. + */ + role?: "status" | "alert"; } export function ChartErrorState({ title, actionLabel, onAction, - height = 240, + height, + actionContext, + role = "status", }: ChartErrorStateProps): ReactElement { return (
+
); } @@ -184,6 +189,8 @@ export function MedicationComplianceError({
+ {/* Streak-slot parity with the loaded card + skeleton. */} +
); } diff --git a/src/components/mood/__tests__/mood-note-fulltext.test.tsx b/src/components/mood/__tests__/mood-note-fulltext.test.tsx index 66320ca88..34ab6ee8f 100644 --- a/src/components/mood/__tests__/mood-note-fulltext.test.tsx +++ b/src/components/mood/__tests__/mood-note-fulltext.test.tsx @@ -83,24 +83,40 @@ function render(locale: "en" | "de" = "de") { } describe("MoodList — full-text note affordance", () => { - it("renders the note as a collapsed toggle with the COMPLETE text in the DOM", () => { + it("renders the note as a plain clamped paragraph with the COMPLETE text", () => { const html = render("de"); - // One toggle per surface (desktop table + mobile card) for the noted row; - // the note-less row renders none. - const toggles = html.match(/data-testid="mood-note-toggle"/g); - expect(toggles?.length).toBe(2); - // Collapsed by default: clamped preview + expand affordance label. - expect(html).toContain('aria-expanded="false"'); + // One note paragraph per surface (desktop table + mobile card) for the + // noted row; the note-less row renders none. The note is a PARAGRAPH + // sibling of the toggle — not button content — so the accessible name + // of the toggle never swallows the whole note. + const notes = html.match(/data-testid="mood-note-text"/g); + expect(notes?.length).toBe(2); expect(html).toContain("line-clamp-2"); - expect(html).toContain("Notiz vollständig anzeigen"); // The complete text is in the row (the clamp is visual), both paragraphs. expect(html).toContain("Erster Absatz der Notiz"); expect(html).toContain("Zweiter Absatz mit weiteren Details"); + // The toggle only mounts once the client measures an actual overflow + // (`scrollHeight > clientHeight`); static SSR has no layout, so no + // toggle — a short note never gets a dangling "show more" control. + expect(html).not.toContain('data-testid="mood-note-toggle"'); }); - it("localises the affordance label", () => { - const html = render("en"); - expect(html).toContain("Show full note"); + it("wires the toggle as a small sibling button with state + target", () => { + const src = readFileSync( + join(process.cwd(), "src/components/mood/mood-list.tsx"), + "utf8", + ); + // The toggle carries the disclosure contract: aria-expanded for the + // state, aria-controls pointing at the paragraph's id. + expect(src).toContain("aria-expanded={expanded}"); + expect(src).toContain("aria-controls={noteId}"); + expect(src).toContain('data-testid="mood-note-toggle"'); + // Clamp detection: measured overflow, re-checked on resize. + expect(src).toContain("scrollHeight > el.clientHeight"); + expect(src).toContain("new ResizeObserver(updateClamped)"); + // Rendered only when the text actually overflows the clamp (or while + // expanded, so it can collapse again). + expect(src).toContain("{(expanded || clamped) && ("); }); it("expanded branch preserves line breaks and renders plain text children", () => { diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index e16372269..39d303c7d 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -49,7 +49,7 @@ import { ChevronRight, MoreHorizontal, } from "lucide-react"; -import { useCallback, useId, useState } from "react"; +import { useCallback, useEffect, useId, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { formatDateTime } from "@/lib/format"; @@ -997,9 +997,19 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) { /** * v1.16.8 — expandable free-text note. Collapsed: a two-line clamped * preview. Expanded: the COMPLETE text, line breaks preserved, rendered as - * plain text children. The whole block is one toggle button so the full - * note is readable in place — previously the only full-text surface was - * the edit sheet (plus a hover tooltip on desktop, unreachable on touch). + * plain text children. The full note is readable in place — previously the + * only full-text surface was the edit sheet (plus a hover tooltip on + * desktop, unreachable on touch). + * + * Structure: the note is a plain paragraph with an `id`; the toggle is a + * SMALL sibling button carrying `aria-expanded` + `aria-controls`. The + * earlier cut wrapped the whole paragraph in the button, which made the + * entire note text the button's accessible name and put block content + * inside a button (invalid content model). + * + * The toggle only renders when the collapsed paragraph actually + * overflows its two-line clamp (`scrollHeight > clientHeight`, re-checked + * on resize) — a one-line note gets no dangling "show more" control. */ function MoodNoteText({ note, @@ -1011,15 +1021,36 @@ function MoodNoteText({ onToggle: () => void; }) { const { t } = useTranslations(); + const noteId = useId(); + const paragraphRef = useRef(null); + const [clamped, setClamped] = useState(false); + + const updateClamped = useCallback(() => { + const el = paragraphRef.current; + if (!el) return; + // +1 tolerates sub-pixel rounding on fractional zoom levels. + setClamped(el.scrollHeight > el.clientHeight + 1); + }, []); + + useEffect(() => { + // Only the collapsed paragraph carries the clamp; measuring the + // expanded one would always read "fits" and drop the collapse + // affordance while it is needed most. + if (expanded) return; + updateClamped(); + const el = paragraphRef.current; + if (!el) return; + const observer = new ResizeObserver(updateClamped); + observer.observe(el); + return () => observer.disconnect(); + }, [expanded, note, updateClamped]); + return ( - + {(expanded || clamped) && ( + + )} + ); } diff --git a/src/components/version-poller.tsx b/src/components/version-poller.tsx index c9baeeafe..a4514311e 100644 --- a/src/components/version-poller.tsx +++ b/src/components/version-poller.tsx @@ -77,13 +77,21 @@ async function fetchLiveVersion(signal: AbortSignal): Promise { } } +// In-memory mirror of the session guard: under strict-privacy modes +// sessionStorage throws, and without a fallback a persistent +// shell/live mismatch (misconfigured build) would reload every poll. +// Module scope survives re-renders but not the reload itself — after +// a reload the storage read is retried first, so the mirror only has +// to break the loop within one document lifetime. +let inMemoryReloadGuard: string | null = null; + async function evictAndReload(targetVersion: string): Promise { + inMemoryReloadGuard = targetVersion; try { sessionStorage.setItem(SESSION_GUARD_KEY, targetVersion); } catch { - // sessionStorage can throw under strict-privacy modes; fall - // through to the reload anyway — worst case the next deploy - // re-triggers the same heal flow. + // sessionStorage can throw under strict-privacy modes; the + // in-memory mirror above still breaks same-document loops. } if ("serviceWorker" in navigator) { @@ -120,8 +128,9 @@ export function VersionPoller(): null { try { lastReloadedFor = sessionStorage.getItem(SESSION_GUARD_KEY); } catch { - /* fall through — treat as "no attempt recorded" */ + /* storage unavailable — the in-memory mirror still applies */ } + lastReloadedFor ??= inMemoryReloadGuard; const decision = resolveVersionPollDecision( live, SHELL_VERSION,