diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index dfa955015..c2677c2d7 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -6,6 +6,7 @@ import { MessagesSquare, Search, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
+import { PageHeader } from "@/components/ui/page-header";
import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread";
import {
useCoachConversations,
@@ -122,13 +123,13 @@ function CoachConversationsBody() {
return (
-
-
- {t("insights.coach.historyTitle")}
-
+
+ {t("insights.coach.historyTitle")}
+
+ }
+ />
-
-
-
- {t("labs.title")}
-
-
- {t("labs.subtitle")}
-
-
-
- {/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
- point, left of the primary Add and linking to the Labs settings
- page (view, sort order, biomarker CRUD + reorder). Mirrors the
- medication page header's wrench glyph + slot + 44px tap floor. */}
-
- {/* v1.18.10 — the "Add" action is a CHOICE when scanning is
- available: scan a document (OCR) or add a value by hand. The scan
- option only appears when the capability probe reports a usable
- mode (vision, or local OCR opted-in for text-only providers), so
- the surface stays simple for everyone else. When scanning is
- unavailable, Add opens the manual form directly. */}
- {ocrCapability.data?.available ? (
-
-
-
-
-
- setScanOpen(true)}>
-
- {t("labs.ocr.addScan")}
-
- setDialogOpen(true)}>
-
- {t("labs.ocr.addManual")}
-
-
-
- ) : (
+ {t("labs.title")}}
+ description={t("labs.subtitle")}
+ actions={
+ <>
+ {/* v1.18.6 (MOD-01) — the wrench is the module's customize entry
+ point, left of the primary Add and linking to the Labs settings
+ page (view, sort order, biomarker CRUD + reorder). Mirrors the
+ medication page header's wrench glyph + slot + 44px tap floor. */}
- )}
-
-
+ {/* v1.18.10 — the "Add" action is a CHOICE when scanning is
+ available: scan a document (OCR) or add a value by hand. The scan
+ option only appears when the capability probe reports a usable
+ mode (vision, or local OCR opted-in for text-only providers), so
+ the surface stays simple for everyone else. When scanning is
+ unavailable, Add opens the manual form directly. */}
+ {ocrCapability.data?.available ? (
+
+
+
+
+
+ setScanOpen(true)}>
+
+ {t("labs.ocr.addScan")}
+
+ setDialogOpen(true)}>
+
+ {t("labs.ocr.addManual")}
+
+
+
+ ) : (
+
+ )}
+ >
+ }
+ />
-
-
-
+
{t("measurements.title")}
-
- {/* v1.4.34 IW-G — subtitle now stays visible on mobile so
- the H1 isn't an unframed label. `text-xs sm:text-sm`
- preserves the desktop hierarchy. */}
-
- {/* v1.4.34 IW-G — subtitle stays visible on mobile so the
- H1 isn't an unframed label. */}
-
- {t("mood.subtitle")}
-
-
-
- {/* v1.17 — the wrench is the one customize entry point: it
- links to /settings/mood, which owns the tag groups, custom
- tags, visibility, and picker order. Same glyph, slot (left
- of the add button) and responsive 44-px mobile tap floor
- as the medications header. */}
-
+ }
+ />
{/* Desktop: ring (left) + tabs (right). Single column below lg. */}
diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx
index 72d0b7540..7d686cac7 100644
--- a/src/components/dashboard/dashboard-header.tsx
+++ b/src/components/dashboard/dashboard-header.tsx
@@ -22,11 +22,11 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { PageHeader } from "@/components/ui/page-header";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { getHourForTimeZone } from "@/components/dashboard/range-display";
-import { cn } from "@/lib/utils";
import type { QuickEntryDialog } from "@/components/dashboard/quick-entry-sheets";
export function DashboardHeader({
@@ -68,106 +68,102 @@ export function DashboardHeader({
: t("dashboard.welcomeBack", { greeting: timeGreeting });
return (
- /* With the greeting the title block is two lines on narrow
- viewports: centre the buttons against it on mobile, top-align on
- sm+ (the pre-hero posture). Without the greeting the single-line
- centre alignment stands. */
-
- {/* Customize shortcut to the dashboard-customization settings
- (the tile/layout editor at /settings/dashboard). Sits to
- the left of the add button as a monochrome ghost icon, with
- a `min-h-11 min-w-11` mobile floor so it meets the 44 px
- touch-target contract the add button also honours, shrinking
- back to the 40 px icon footprint on sm+. */}
-
-
+ ) : undefined
+ }
+ actions={
+ <>
+ {/* Customize shortcut to the dashboard-customization settings
+ (the tile/layout editor at /settings/dashboard). Sits to
+ the left of the add button as a monochrome ghost icon, with
+ a `min-h-11 min-w-11` mobile floor so it meets the 44 px
+ touch-target contract the add button also honours, shrinking
+ back to the 40 px icon footprint on sm+. */}
+
-
-
-
-
-
- {/* v1.4.33 maintainer-item-7 — restore proportional sizing
- across viewports. The v1.4.27 fix pinned a `size="sm"
- min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
- contract on mobile, but `size="sm"` is h-8 (32 px) and
- the `min-h-11` override stretched the cap vertically
- while keeping the small horizontal padding — the
- button read as klobig on Pixel 5. Switch to
- `size="default"` (h-10 = 40 px) on mobile with a
- responsive `min-h-11 sm:min-h-9` so the button is
- 44 px tall under finger pressure and shrinks back to
- the desktop-friendly 36 px on `sm:` upwards. The icon
- + label keep the same visual contract. */}
-
-
- {t("common.add")}
-
-
-
- {/* Menu items must each carry a self-contained verb-phrase
- ("Log measurement", "Log mood") — the trigger above already
- says "Add", and the icon is `aria-hidden`, so the visible
- text is the only thing distinguishing the rows. v1.4.15
- phase-A3 fix #1 hardened this with a unit guard at
- `src/app/__tests__/quick-add-labels.test.ts` — both labels
- must differ from each other AND from `common.add`. */}
- onQuickEntry("measurement")}>
-
- {t("dashboard.quickAddMeasurement")}
-
- onQuickEntry("mood")}>
-
- {t("dashboard.quickAddMood")}
-
- {/* v1.4.37 W7b — third quick-add row: medication intake.
- Same Sheet-on-mobile / Dialog-on-desktop primitive as
- the other two; the menu label is a self-contained
- verb-phrase so it doesn't collide with the trigger or
- the sibling rows (cf. quick-add-labels.test.ts). */}
- onQuickEntry("medicationIntake")}>
-
- {t("dashboard.quickAddMedicationIntake")}
-
-
-
-
-
+
+
+
+
+
+ {/* v1.4.33 maintainer-item-7 — restore proportional sizing
+ across viewports. The v1.4.27 fix pinned a `size="sm"
+ min-h-11` combo to hit WCAG 2.5.5's 44 px touch-target
+ contract on mobile, but `size="sm"` is h-8 (32 px) and
+ the `min-h-11` override stretched the cap vertically
+ while keeping the small horizontal padding — the
+ button read as klobig on Pixel 5. Switch to
+ `size="default"` (h-10 = 40 px) on mobile with a
+ responsive `min-h-11 sm:min-h-9` so the button is
+ 44 px tall under finger pressure and shrinks back to
+ the desktop-friendly 36 px on `sm:` upwards. The icon
+ + label keep the same visual contract. */}
+
+
+ {t("common.add")}
+
+
+
+ {/* Menu items must each carry a self-contained verb-phrase
+ ("Log measurement", "Log mood") — the trigger above already
+ says "Add", and the icon is `aria-hidden`, so the visible
+ text is the only thing distinguishing the rows. v1.4.15
+ phase-A3 fix #1 hardened this with a unit guard at
+ `src/app/__tests__/quick-add-labels.test.ts` — both labels
+ must differ from each other AND from `common.add`. */}
+ onQuickEntry("measurement")}>
+
+ {t("dashboard.quickAddMeasurement")}
+
+ onQuickEntry("mood")}>
+
+ {t("dashboard.quickAddMood")}
+
+ {/* v1.4.37 W7b — third quick-add row: medication intake.
+ Same Sheet-on-mobile / Dialog-on-desktop primitive as
+ the other two; the menu label is a self-contained
+ verb-phrase so it doesn't collide with the trigger or
+ the sibling rows (cf. quick-add-labels.test.ts). */}
+ onQuickEntry("medicationIntake")}
+ >
+
+ {t("dashboard.quickAddMedicationIntake")}
+
+
+
+ >
+ }
+ />
);
}
diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx
index 8f8d16772..d786c6aeb 100644
--- a/src/components/illness/illness-view.tsx
+++ b/src/components/illness/illness-view.tsx
@@ -17,13 +17,7 @@
*/
import { useId, useMemo, useState } from "react";
import Link from "next/link";
-import {
- ChevronDown,
- Plus,
- RefreshCw,
- Stethoscope,
- Wrench,
-} from "lucide-react";
+import { ChevronDown, Plus, Stethoscope, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -35,6 +29,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
+import { PageHeader } from "@/components/ui/page-header";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -379,46 +375,39 @@ export function IllnessView() {
return (
-
-
-
- {t("illness.title")}
-
-
- {t("illness.subtitle")}
-
-
-
- {/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
- Illness settings page (view + reorder). */}
-
- {t("illness.title")}}
+ description={t("illness.subtitle")}
+ actions={
+ <>
+ {/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
+ Illness settings page (view + reorder). */}
+
-
-
-
- {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
- other module, not the bespoke "neue Episode". */}
- setNewOpen(true)}
- className="min-h-11 sm:min-h-9"
- >
-
- {t("common.add")}
-
-
-
+
+
+
+
+ {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every
+ other module, not the bespoke "neue Episode". */}
+ setNewOpen(true)}
+ className="min-h-11 sm:min-h-9"
+ >
+
+ {t("common.add")}
+
+ >
+ }
+ />
{/* v1.18.6 (DISC-01) — the per-page medical disclaimer line is removed;
the one-time acknowledgment now lives at onboarding and the legal
@@ -430,23 +419,12 @@ export function IllnessView() {
+ ) : isError ? (
+ // A read failure is NOT an empty list — surface the error + Retry so
+ // an outage never reads as "you have no measurements".
+ void refetch()} />
) : !data?.measurements?.length ? (
// v1.4.15 phase-C5: replaces the bare-text empty rectangle.
// Filter-aware copy distinguishes brand-new accounts ("no
diff --git a/src/components/mental-health/mental-wellbeing.tsx b/src/components/mental-health/mental-wellbeing.tsx
index 454f333dc..8f40e14cf 100644
--- a/src/components/mental-health/mental-wellbeing.tsx
+++ b/src/components/mental-health/mental-wellbeing.tsx
@@ -26,6 +26,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
+import { PageHeader } from "@/components/ui/page-header";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { AssessmentHistory } from "./assessment-history";
@@ -107,14 +108,12 @@ export function MentalWellbeing() {
{phase === "choose" && (
<>
-
- {/* Title kept for screen readers / document outline only — the
- visible heading + its tooltip were removed (§ Marc v1.25.5). */}
-
{t("mentalHealth.pageTitle")}
-
- {t("mentalHealth.pageDescription")}
-
-
+ {/* v1.26 — visible page header, consistent with every other module
+ (the title was previously sr-only; the description stays). */}
+
+ // A read failure is NOT an empty list — surface the error + Retry so
+ // an outage never reads as "you have no mood entries".
+ void refetch()}
+ />
) : !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/ui/page-header.tsx b/src/components/ui/page-header.tsx
new file mode 100644
index 000000000..66469203e
--- /dev/null
+++ b/src/components/ui/page-header.tsx
@@ -0,0 +1,43 @@
+import type { ReactNode } from "react";
+
+import { BackLink } from "@/components/ui/back-link";
+import { cn } from "@/lib/utils";
+
+/**
+ * The one canonical page header. Every module surface renders its title
+ * through this so the header vocabulary reads identically app-wide: the H1
+ * is always `text-2xl font-bold tracking-tight`, the description is always
+ * `text-muted-foreground text-sm`, and no icon sits beside the H1. An
+ * optional back-link rides above the title, and an `actions` slot holds the
+ * page's primary buttons to the right of the title block.
+ */
+export function PageHeader({
+ title,
+ description,
+ backLink,
+ actions,
+ className,
+}: {
+ title: ReactNode;
+ description?: ReactNode;
+ backLink?: { href: string; label: string; dataSlot?: string };
+ actions?: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {backLink ? : null}
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {actions ? (
+
{actions}
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/ui/query-error-card.tsx b/src/components/ui/query-error-card.tsx
new file mode 100644
index 000000000..98edbd8a3
--- /dev/null
+++ b/src/components/ui/query-error-card.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { AlertTriangle, RotateCw } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { useTranslations } from "@/lib/i18n/context";
+import { cn } from "@/lib/utils";
+
+/**
+ * Read-failure card for list / data surfaces.
+ *
+ * Mirrors ``'s shape + tone (AlertTriangle, a title line, a
+ * muted description, an optional Retry button) but renders inside a `Card` so
+ * a list page can tell "load failed" apart from an honest empty state — a
+ * failed query must never fall through to the "no data yet" copy. Defaults are
+ * the generic `common.*` strings so a caller can drop it in with just
+ * `onRetry`; a surface with its own "couldn't load X" string passes it through
+ * `title` / `description`.
+ */
+export function QueryErrorCard({
+ title,
+ description,
+ onRetry,
+ retryLabel,
+ className,
+}: {
+ title?: ReactNode;
+ description?: ReactNode;
+ onRetry?: () => void;
+ retryLabel?: string;
+ className?: string;
+}) {
+ const { t } = useTranslations();
+ return (
+
+
+
+