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() { /> ) : ( -
- - - - - - - - - - - - {rows.map((row, i) => ( - - - - - - +
+ {/* 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")} -
{row.username} - - {formatBackupType(row.type, t)} - - - {formatBytes(row.sizeBytes, fmt)} - - {fmt.dateTime(row.createdAt)} - -
- - restore.mutate(row)} - /> -
-
+ + + + + + + - ))} - -
+ {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)} + + +
+ + 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. */} + +

{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")} + +
) : 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")} + +
+ )} + + {!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")} + +
) : !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({