From c38271bc44ca05dcce368fd8d5e48b2a90be4e5f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 28 Jun 2026 02:53:14 +0200
Subject: [PATCH] fix(a11y): distinguish errors from empty states and close
naming gaps
Add isError branches with a retry affordance to the mood, illness, and
vorsorge lists so a failed read no longer renders as "no entries yet"
and invites duplicate re-entry, matching the labs and cycle pattern.
Make the admin backups table reachable on a phone with a card fallback,
name the admin language and timezone selects and the AI raw-data switch
for screen readers, and replace the touch-unreachable title= help on the
glucose clinical panels with a tap-capable info popover.
Route the password-strength label through the AA-tuned semantic tokens,
underline the register login link so it is not distinguished by colour
alone, give the labs load error alert semantics, and explain why the
target editor Save button is disabled with aria-invalid and a live
alert.
---
src/app/auth/register/page.tsx | 8 +-
src/components/admin/backups-section.tsx | 194 ++++++++++++------
.../admin/general-settings-section.tsx | 2 +
src/components/illness/illness-view.tsx | 33 ++-
.../glucose/glucose-advanced-disclosure.tsx | 4 +-
.../glucose/glucose-clinical-panel.tsx | 13 +-
src/components/labs/lab-list.tsx | 2 +-
.../vorsorge-section.tsx | 30 ++-
src/components/mood/mood-list.tsx | 21 +-
.../settings/ai/runtime-actions-row.tsx | 1 +
src/components/targets/target-edit-sheet.tsx | 60 ++++++
src/components/ui/info-hint.tsx | 56 +++++
src/components/ui/password-strength.tsx | 14 +-
13 files changed, 350 insertions(+), 88 deletions(-)
create mode 100644 src/components/ui/info-hint.tsx
diff --git a/src/app/auth/register/page.tsx b/src/app/auth/register/page.tsx
index 4c6173af1..73c1f10a6 100644
--- a/src/app/auth/register/page.tsx
+++ b/src/app/auth/register/page.tsx
@@ -194,7 +194,13 @@ export default function RegisterPage() {
{t("auth.hasAccount")}{" "}
-
+
{t("auth.login")}
diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx
index 1ec8bbb11..f63244557 100644
--- a/src/components/admin/backups-section.tsx
+++ b/src/components/admin/backups-section.tsx
@@ -467,74 +467,134 @@ export function BackupsSection() {
/>
) : (
-
-
-
-
-
- {t("admin.section.backups.colUser")}
-
-
- {t("admin.section.backups.colType")}
-
-
- {t("admin.section.backups.colSize")}
-
-
- {t("admin.section.backups.colCreatedAt")}
-
-
- {t("admin.section.backups.colActions")}
-
-
-
-
- {rows.map((row, i) => (
-
- {row.username}
-
-
- {formatBackupType(row.type, t)}
-
-
-
- {formatBytes(row.sizeBytes, fmt)}
-
-
- {fmt.dateTime(row.createdAt)}
-
-
-
- handleDownload(row)}
- aria-label={t("admin.section.backups.downloadAria", {
- username: row.username,
- })}
- className="min-h-11"
- >
- {downloadingId === row.id ? (
-
- ) : (
-
- )}
- {t("admin.section.backups.download")}
-
- restore.mutate(row)}
- />
-
-
+
+ {/* Wide layout: the canonical table. Hidden below `md`, where the
+ five columns push Download / Restore off-screen behind a
+ horizontal scroll; the card list below takes over there. */}
+
+
+
+
+
+ {t("admin.section.backups.colUser")}
+
+
+ {t("admin.section.backups.colType")}
+
+
+ {t("admin.section.backups.colSize")}
+
+
+ {t("admin.section.backups.colCreatedAt")}
+
+
+ {t("admin.section.backups.colActions")}
+
- ))}
-
-
+
+
+ {rows.map((row, i) => (
+
+ {row.username}
+
+
+ {formatBackupType(row.type, t)}
+
+
+
+ {formatBytes(row.sizeBytes, fmt)}
+
+
+ {fmt.dateTime(row.createdAt)}
+
+
+
+ handleDownload(row)}
+ aria-label={t("admin.section.backups.downloadAria", {
+ username: row.username,
+ })}
+ className="min-h-11"
+ >
+ {downloadingId === row.id ? (
+
+ ) : (
+
+ )}
+ {t("admin.section.backups.download")}
+
+ restore.mutate(row)}
+ />
+
+
+
+ ))}
+
+
+
+
+ {/* Narrow layout: each backup as a self-contained card with the
+ meta line + a flex-wrapping action row, so Download / Restore
+ stay reachable on a phone instead of scrolling off-screen. */}
+
+ {rows.map((row) => (
+
+
+ {row.username}
+
+ {formatBackupType(row.type, t)}
+
+
+
+ {t("admin.section.backups.colSize")}:{" "}
+ {formatBytes(row.sizeBytes, fmt)} ·{" "}
+ {fmt.dateTime(row.createdAt)}
+
+
+ handleDownload(row)}
+ aria-label={t("admin.section.backups.downloadAria", {
+ username: row.username,
+ })}
+ className="min-h-11"
+ >
+ {downloadingId === row.id ? (
+
+ ) : (
+
+ )}
+ {t("admin.section.backups.download")}
+
+ restore.mutate(row)}
+ />
+
+
+ ))}
+
+
{t("admin.section.backups.retentionLabel", {
days: data!.retentionDays,
diff --git a/src/components/admin/general-settings-section.tsx b/src/components/admin/general-settings-section.tsx
index 8e1dc1011..62cb0d438 100644
--- a/src/components/admin/general-settings-section.tsx
+++ b/src/components/admin/general-settings-section.tsx
@@ -68,6 +68,7 @@ export function GeneralSettingsSection() {
updateSettings.mutate({ defaultLocale: e.target.value })
@@ -94,6 +95,7 @@ export function GeneralSettingsSection() {
updateSettings.mutate({ defaultUserTimezone: e.target.value })
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
index 4db8ab460..a6559cc06 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -17,7 +17,13 @@
*/
import { useId, useMemo, useState } from "react";
import Link from "next/link";
-import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
+import {
+ ChevronDown,
+ Plus,
+ RefreshCw,
+ Stethoscope,
+ Wrench,
+} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -308,7 +314,12 @@ function EpisodeGroup({
export function IllnessView() {
const { t } = useTranslations();
- const { data: episodes, isLoading } = useIllnessEpisodes(true);
+ const {
+ data: episodes,
+ isLoading,
+ isError,
+ refetch,
+ } = useIllnessEpisodes(true);
const resolve = useResolveEpisode();
const { prefs } = useModuleListPrefs("illness");
@@ -418,6 +429,24 @@ export function IllnessView() {
+ ) : isError ? (
+
+ {t("illness.listLoadError")}
+ void refetch()}
+ className="gap-1.5"
+ >
+
+ {t("common.retry")}
+
+
) : hasEpisodes ? (
<>
{/* Retrospective summary sits above the list so a long history
diff --git a/src/components/insights/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx
index 362dfcc29..4392173f2 100644
--- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx
+++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx
@@ -3,6 +3,7 @@
import { useId, useState } from "react";
import { ChevronDown } from "lucide-react";
+import { InfoHint } from "@/components/ui/info-hint";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { GlucoseClinicalMetrics } from "@/lib/analytics/glucose-metrics";
@@ -146,8 +147,9 @@ function AdvancedStat({
}) {
return (
-
+
{label}
+
{value}
diff --git a/src/components/insights/glucose/glucose-clinical-panel.tsx b/src/components/insights/glucose/glucose-clinical-panel.tsx
index cbc1535fc..b27317673 100644
--- a/src/components/insights/glucose/glucose-clinical-panel.tsx
+++ b/src/components/insights/glucose/glucose-clinical-panel.tsx
@@ -5,6 +5,7 @@ import { Activity, Droplet, FlaskConical, TrendingUp } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { InfoHint } from "@/components/ui/info-hint";
import { LearningGate } from "@/components/ui/learning-gate";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { Skeleton } from "@/components/ui/skeleton";
@@ -195,11 +196,9 @@ export function GlucoseClinicalPanel() {
) : null}
{cv ? (
-
+
{t("insights.bloodGlucose.clinical.cv.label")}
+
@@ -283,12 +282,10 @@ function Metric({
}) {
return (
-
+
{icon}
{label}
+ {help ? : null}
{value}
diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx
index 1f45b1518..e0c597f9d 100644
--- a/src/components/labs/lab-list.tsx
+++ b/src/components/labs/lab-list.tsx
@@ -165,7 +165,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) {
if (isError) {
return (
-
+
{t("labs.loadError")}
);
diff --git a/src/components/measurement-reminders/vorsorge-section.tsx b/src/components/measurement-reminders/vorsorge-section.tsx
index 280702666..e43368c38 100644
--- a/src/components/measurement-reminders/vorsorge-section.tsx
+++ b/src/components/measurement-reminders/vorsorge-section.tsx
@@ -35,6 +35,7 @@ import {
CalendarClock,
CheckCircle2,
Plus,
+ RefreshCw,
Wrench,
} from "lucide-react";
@@ -219,7 +220,12 @@ export function VorsorgeSection({
variant?: "settings" | "page";
}) {
const { t } = useTranslations();
- const { data: reminders, isLoading } = useMeasurementReminders(enabled);
+ const {
+ data: reminders,
+ isLoading,
+ isError,
+ refetch,
+ } = useMeasurementReminders(enabled);
const { create, update, remove, satisfy } = useMeasurementReminderMutations();
// v1.18.6 (MOD-03) — page view (cards/list) + manual order persist
// client-side; the settings page writes them, the page reads them.
@@ -662,7 +668,27 @@ export function VorsorgeSection({
)}
- {!isLoading && (reminders?.length ?? 0) === 0 && (
+ {!isLoading && isError && (
+
+ {t("measurementReminders.loadError")}
+ void refetch()}
+ className="gap-1.5"
+ >
+
+ {t("common.retry")}
+
+
+ )}
+
+ {!isLoading && !isError && (reminders?.length ?? 0) === 0 && (
}
title={t("measurementReminders.empty.title")}
diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx
index 772f902f6..d5a90d6f4 100644
--- a/src/components/mood/mood-list.tsx
+++ b/src/components/mood/mood-list.tsx
@@ -43,6 +43,7 @@ import {
Loader2,
Pencil,
Plus,
+ RefreshCw,
Smile,
Trash2,
ChevronLeft,
@@ -219,7 +220,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const toParam = toDay || undefined;
const sourceParam = sourceFilter === "ALL" ? undefined : sourceFilter;
- const { data, isLoading } = useQuery({
+ const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.moodEntriesList({
mood: moodFilter === "ALL" ? undefined : moodFilter,
source: sourceParam,
@@ -492,6 +493,24 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
+ ) : isError ? (
+
+ {t("mood.loadError")}
+ void refetch()}
+ className="gap-1.5"
+ >
+
+ {t("common.retry")}
+
+
) : !data?.entries?.length ? (
// v1.4.15 phase-C5: replace bare-text empty rectangle with
// EmptyState. Filter-aware copy splits "no mood entries yet"
diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx
index c83a8f670..070ef3730 100644
--- a/src/components/settings/ai/runtime-actions-row.tsx
+++ b/src/components/settings/ai/runtime-actions-row.tsx
@@ -245,6 +245,7 @@ export function RuntimeActionsRow({
diff --git a/src/components/targets/target-edit-sheet.tsx b/src/components/targets/target-edit-sheet.tsx
index 27df912ae..9edd6110a 100644
--- a/src/components/targets/target-edit-sheet.tsx
+++ b/src/components/targets/target-edit-sheet.tsx
@@ -307,6 +307,52 @@ function TargetEditSheetBody({
const canSave = !isDerivedMetric && primaryValid && secondaryValid && !busy;
+ // Per-field validity so the inputs can carry `aria-invalid` and the
+ // disabled Save button gets a spoken explanation instead of greying out
+ // silently. The order check (min < max) flags both ends of the pair.
+ const primaryOrderInvalid =
+ Number.isFinite(minNum) && Number.isFinite(maxNum) && minNum >= maxNum;
+ const primaryMinInvalid =
+ primaryBounds != null &&
+ (!Number.isFinite(minNum) ||
+ minNum < primaryBounds.min ||
+ minNum > primaryBounds.max ||
+ primaryOrderInvalid);
+ const primaryMaxInvalid =
+ primaryBounds != null &&
+ (!Number.isFinite(maxNum) ||
+ maxNum < primaryBounds.min ||
+ maxNum > primaryBounds.max ||
+ primaryOrderInvalid);
+
+ const secondaryOrderInvalid =
+ isBp &&
+ Number.isFinite(diaMinNum) &&
+ Number.isFinite(diaMaxNum) &&
+ diaMinNum >= diaMaxNum;
+ const secondaryMinInvalid =
+ isBp &&
+ secondaryBounds != null &&
+ (!Number.isFinite(diaMinNum) ||
+ diaMinNum < secondaryBounds.min ||
+ diaMinNum > secondaryBounds.max ||
+ secondaryOrderInvalid);
+ const secondaryMaxInvalid =
+ isBp &&
+ secondaryBounds != null &&
+ (!Number.isFinite(diaMaxNum) ||
+ diaMaxNum < secondaryBounds.min ||
+ diaMaxNum > secondaryBounds.max ||
+ secondaryOrderInvalid);
+
+ let validationMessage: string | null = null;
+ if (!isDerivedMetric && primaryBounds && (!primaryValid || !secondaryValid)) {
+ validationMessage =
+ primaryOrderInvalid || secondaryOrderInvalid
+ ? t("targets.edit.validationOrder")
+ : t("targets.edit.validationBounds");
+ }
+
const handleSave = () => {
if (!canSave) return;
if (isBp) {
@@ -426,6 +472,7 @@ function TargetEditSheetBody({
value={displayMin}
onChange={(e) => setMinStr(e.target.value)}
disabled={busy}
+ aria-invalid={primaryMinInvalid}
data-slot="target-edit-min-input"
/>
@@ -444,6 +491,7 @@ function TargetEditSheetBody({
value={displayMax}
onChange={(e) => setMaxStr(e.target.value)}
disabled={busy}
+ aria-invalid={primaryMaxInvalid}
data-slot="target-edit-max-input"
/>
@@ -467,6 +515,7 @@ function TargetEditSheetBody({
value={displayDiaMin}
onChange={(e) => setDiaMinStr(e.target.value)}
disabled={busy}
+ aria-invalid={secondaryMinInvalid}
data-slot="target-edit-dia-min-input"
/>
@@ -483,6 +532,7 @@ function TargetEditSheetBody({
value={displayDiaMax}
onChange={(e) => setDiaMaxStr(e.target.value)}
disabled={busy}
+ aria-invalid={secondaryMaxInvalid}
data-slot="target-edit-dia-max-input"
/>
@@ -496,6 +546,16 @@ function TargetEditSheetBody({
unit: primaryBounds.unit,
})}
+
+ {validationMessage && (
+
+ {validationMessage}
+
+ )}
)}
diff --git a/src/components/ui/info-hint.tsx b/src/components/ui/info-hint.tsx
new file mode 100644
index 000000000..671b09cec
--- /dev/null
+++ b/src/components/ui/info-hint.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import { Info } from "lucide-react";
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+/**
+ * A small, tap-reachable help affordance. Replaces the bare `title=`
+ * attribute, which never surfaces on touch. The trigger is a real button
+ * with an accessible name, so keyboard and screen-reader users reach the
+ * same hint a pointer user gets on hover; tapping the button focuses it,
+ * which opens the tooltip on touch devices.
+ */
+export function InfoHint({
+ label,
+ className,
+}: {
+ /** The hint text shown inside the tooltip. */
+ label: string;
+ className?: string;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+
+
+
+
+
+
+ {label}
+
+
+
+ );
+}
diff --git a/src/components/ui/password-strength.tsx b/src/components/ui/password-strength.tsx
index 03a67e15c..32011560f 100644
--- a/src/components/ui/password-strength.tsx
+++ b/src/components/ui/password-strength.tsx
@@ -10,12 +10,16 @@ interface PasswordStrengthProps {
minLength?: number;
}
+// The bar fill stays vivid (it's a non-text indicator), but the label text
+// routes through the AA-tuned semantic tokens — the raw Tailwind 500 tones
+// failed contrast against the white card. Weakest is destructive, the middle
+// band warning, the strong band success.
const SCORE_COLORS = [
- { color: "bg-red-500", textColor: "text-red-500" },
- { color: "bg-orange-500", textColor: "text-orange-500" },
- { color: "bg-yellow-500", textColor: "text-yellow-500" },
- { color: "bg-green-500", textColor: "text-green-500" },
- { color: "bg-emerald-600", textColor: "text-emerald-600" },
+ { color: "bg-red-500", textColor: "text-destructive" },
+ { color: "bg-orange-500", textColor: "text-destructive" },
+ { color: "bg-yellow-500", textColor: "text-warning" },
+ { color: "bg-green-500", textColor: "text-success" },
+ { color: "bg-emerald-600", textColor: "text-success" },
];
export function PasswordStrength({