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({