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,