diff --git a/messages/de.json b/messages/de.json
index ae466c900..2bed62c7c 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -592,6 +592,7 @@
"statusPresent": "Erfasst",
"statusAbsent": "Noch nicht erfasst",
"statusModuleOff": "Aus",
+ "loadError": "Erfassungsstatus konnte nicht geladen werden.",
"connectCta": "Quelle verbinden",
"viewCta": "Ansehen",
"ecg": {
diff --git a/messages/en.json b/messages/en.json
index ec91a82a7..a0d67346d 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -592,6 +592,7 @@
"statusPresent": "Tracked",
"statusAbsent": "Not tracked yet",
"statusModuleOff": "Off",
+ "loadError": "Could not load your tracking status.",
"connectCta": "Connect a source",
"viewCta": "View",
"ecg": {
diff --git a/messages/es.json b/messages/es.json
index e4a444d59..26d1adac0 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -592,6 +592,7 @@
"statusPresent": "Registrado",
"statusAbsent": "Aún sin registrar",
"statusModuleOff": "Desactivado",
+ "loadError": "No se pudo cargar el estado de seguimiento.",
"connectCta": "Conectar una fuente",
"viewCta": "Ver",
"ecg": {
diff --git a/messages/fr.json b/messages/fr.json
index f792f1c31..787cc81fb 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -592,6 +592,7 @@
"statusPresent": "Suivi",
"statusAbsent": "Pas encore suivi",
"statusModuleOff": "Désactivé",
+ "loadError": "Impossible de charger l'état de suivi.",
"connectCta": "Connecter une source",
"viewCta": "Voir",
"ecg": {
diff --git a/messages/it.json b/messages/it.json
index 9aa2d1297..061fc45f1 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -592,6 +592,7 @@
"statusPresent": "Monitorato",
"statusAbsent": "Non ancora monitorato",
"statusModuleOff": "Disattivato",
+ "loadError": "Impossibile caricare lo stato di monitoraggio.",
"connectCta": "Collega una fonte",
"viewCta": "Visualizza",
"ecg": {
diff --git a/messages/pl.json b/messages/pl.json
index cbc2aa405..1d1dc33b9 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -592,6 +592,7 @@
"statusPresent": "Śledzone",
"statusAbsent": "Jeszcze nieśledzone",
"statusModuleOff": "Wyłączone",
+ "loadError": "Nie udało się załadować statusu śledzenia.",
"connectCta": "Połącz źródło",
"viewCta": "Zobacz",
"ecg": {
diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx
index 6c1f7aaa9..884406d09 100644
--- a/src/app/coach/conversations/page.tsx
+++ b/src/app/coach/conversations/page.tsx
@@ -364,8 +364,11 @@ export default function CoachConversationsPage() {
data-slot="coach-conversations-page"
// Match `/coach`'s full-bleed sizing: cancel the AuthShell padding
// and claim the viewport height below the top bar (minus the
- // mobile-only BottomNav band).
- className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
+ // mobile-only BottomNav band). L4 fix
+ // (`.planning/audits/2026-07-18-qa-ui.md`) — this had dropped the
+ // `safe-area-inset-top` term `/coach` subtracts, overshooting by
+ // ~50px on notched devices; restored to match exactly.
+ className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]"
>
diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx
index 9cda95039..56ce8f971 100644
--- a/src/app/insights/catalog/page.tsx
+++ b/src/app/insights/catalog/page.tsx
@@ -21,6 +21,8 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { QueryErrorCard } from "@/components/ui/query-error-card";
import { TileHeader } from "@/components/insights/tile-header";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { useAuth } from "@/hooks/use-auth";
@@ -105,6 +107,29 @@ function StatusBadge({ present }: { present: boolean }) {
);
}
+/**
+ * Row placeholder shown while the availability probes are still
+ * in flight — same anatomy as a loaded `CatalogRow` (name line + a
+ * trailing action-sized block) so the group cards don't reflow when
+ * the real status lands. Never renders the "Not tracked yet" copy or
+ * a CTA — an in-flight probe is not the same thing as a confirmed
+ * absence (M1, `.planning/audits/2026-07-18-qa-ui.md`).
+ */
+function CatalogRowSkeleton() {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
function CatalogRow({
entry,
present,
@@ -218,6 +243,51 @@ export default function MetricCatalogPage() {
return hasMetricData(entry.metric, availability);
}
+ // M1 fix (`.planning/audits/2026-07-18-qa-ui.md`) — every probe backing
+ // `availability` is async; while any of them is still in flight,
+ // `hasMetricData()` reads an undefined summary and reports "absent" for
+ // every metric. Gate the rows on the probes' aggregate loading/error
+ // state instead of letting a cold load or a failed batch masquerade as
+ // an honest "Not tracked yet".
+ const isLoading = Boolean(
+ isAuthenticated &&
+ (analyticsQuery.isLoading ||
+ comprehensiveQuery.isLoading ||
+ workoutsProbe.isLoading ||
+ ecgQuery.isLoading ||
+ (nutrientsModuleEnabled && nutrientsProbe.isLoading)),
+ );
+
+ const isError = Boolean(
+ analyticsQuery.isError ||
+ comprehensiveQuery.isError ||
+ workoutsProbe.isError ||
+ ecgQuery.isError ||
+ (nutrientsModuleEnabled && nutrientsProbe.isError),
+ );
+
+ function retryAll() {
+ void analyticsQuery.refetch();
+ void comprehensiveQuery.refetch();
+ workoutsProbe.refetch();
+ void ecgQuery.refetch();
+ if (nutrientsModuleEnabled) void nutrientsProbe.refetch();
+ }
+
+ if (isError) {
+ return (
+
+
+
+ );
+ }
+
return (
);
}
+ if (isLoading && entry.kind !== "info") {
+ return ;
+ }
return (
` trigger with
- * an `aria-label`, keyboard- and SR-reachable via Radix) but bumps the hit
- * area to ≥44 px for touch, since the cycle sheet is a thumb-first surface.
+ * understand a term.
+ *
+ * L2 (`.planning/audits/2026-07-18-qa-ui.md`) — this used to be a
+ * self-contained hover `Tooltip`, which never opens reliably on tap; the
+ * cycle sheet is a thumb-first surface, so it now wraps the click-opened
+ * `InfoPopover` (the app's one (i) info-affordance since the v1.28.17
+ * merge) instead, keeping the `cycle-field-info` data-slot the sheet
+ * already keys off of.
*
* Copy stays descriptive-only — never diagnosis or medical advice — matching
* the phase-education honesty gate.
@@ -31,31 +27,17 @@ export function FieldInfo({
}: {
/** Accessible name for the trigger (what the icon explains). */
label: string;
- /** The explanation shown in the tooltip. */
+ /** The explanation shown in the popover. */
detail: string;
className?: string;
}) {
return (
-
-
-
-
-
-
- {detail}
-
-
-
+
);
}
diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx
index f2a223cbb..fea93dcbb 100644
--- a/src/components/insights/coach-panel/history-rail.tsx
+++ b/src/components/insights/coach-panel/history-rail.tsx
@@ -122,7 +122,9 @@ export function HistoryRail({
data-slot="coach-history-rail"
// v1.18.1 (W-COACH-UI C1/C3) — a touch more horizontal padding so
// the list rows clear the rail edge and the search field breathes.
- className={cn("flex h-full min-h-0 flex-col gap-2.5 p-3.5", className)}
+ // L5 fix (`.planning/audits/2026-07-18-qa-ui.md`) — `gap-2.5 p-3.5`
+ // was off the `1/1.5/2/3/4/6/8/10` spacing scale.
+ className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)}
>
{/* v1.4.33 — promote the rail label from a `` to a real
`
` so the drawer carries a semantic outline on desktop
@@ -239,7 +241,7 @@ export function HistoryRail({
) : null}
{c.title}
-
+
{formatRelativeTime(c.updatedAt, t)}
diff --git a/src/components/medications/card-parts/compliance-info-tip.tsx b/src/components/medications/card-parts/compliance-info-tip.tsx
index efed854c2..967391cf3 100644
--- a/src/components/medications/card-parts/compliance-info-tip.tsx
+++ b/src/components/medications/card-parts/compliance-info-tip.tsx
@@ -1,15 +1,9 @@
"use client";
-import { useState } from "react";
import { HelpCircle } from "lucide-react";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
+import { InfoPopover } from "@/components/ui/info-popover";
import { useTranslations } from "@/lib/i18n/context";
-import { cn } from "@/lib/utils";
/**
* Small `?` affordance that explains what the adherence percentage
@@ -19,45 +13,24 @@ import { cn } from "@/lib/utils";
* dashboard compliance card header and the per-medication bars
* (`medication-compliance-bars.tsx`).
*
- * Pattern follows `health-score-delta-explainer.tsx` (icon-only
- * trigger, 44 px hit target collapsed back to the row's stride via
- * negative margins). A click-opened Popover is used on every viewport
- * — unlike a hover Tooltip it stays reachable on touch devices, where
- * the medication cards see most of their use.
+ * Thin wrapper over `InfoPopover` (L1, `.planning/audits/2026-07-18-qa-ui.md`)
+ * — this used to be a verbatim structural copy of the primitive; it now
+ * only supplies the `HelpCircle` glyph, `align="start"`, and the
+ * `compliance-info-*` data-slots the two call sites already key off of.
*/
export function ComplianceInfoTip({ className }: { className?: string }) {
const { t } = useTranslations();
- const [open, setOpen] = useState(false);
return (
-
-
-
-
-
-
- {t("medications.complianceInfo")}
-
-
-
+
);
}
diff --git a/src/components/medications/detail/segmented-toggle.tsx b/src/components/medications/detail/segmented-toggle.tsx
index e5fc1cfda..bac02195d 100644
--- a/src/components/medications/detail/segmented-toggle.tsx
+++ b/src/components/medications/detail/segmented-toggle.tsx
@@ -7,8 +7,10 @@ import { cn } from "@/lib/utils";
* the icon-only `MedicationViewToggle` chrome (bordered pill group, filled
* active segment, ghost inactive) but carries text labels. `aria-pressed`
* announces the state per segment; the group is named for screen readers.
- * Segments keep the 40-px mobile tap floor, shrinking to the 32-px control
- * height on desktop.
+ * Segments keep the 44-px mobile tap floor, shrinking to the 36-px control
+ * height on desktop — matching the floor `ViewToggle` and the sibling
+ * intake / history-rail controls raised in this release window (L3,
+ * `.planning/audits/2026-07-18-qa-ui.md`).
*/
export interface SegmentedToggleOption {
value: T;
@@ -49,7 +51,7 @@ export function SegmentedToggle({
aria-pressed={active}
data-slot={`${dataSlot ?? "segment"}-${optionValue}`}
className={cn(
- "focus-visible:ring-ring inline-flex min-h-10 items-center justify-center rounded-[5px] px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:min-h-8",
+ "focus-visible:ring-ring inline-flex min-h-11 items-center justify-center rounded-[5px] px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:min-h-9",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
diff --git a/src/components/ui/info-popover.tsx b/src/components/ui/info-popover.tsx
index 549c8036a..ba067fa57 100644
--- a/src/components/ui/info-popover.tsx
+++ b/src/components/ui/info-popover.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState } from "react";
-import { Info } from "lucide-react";
+import { Info, type LucideIcon } from "lucide-react";
import {
Popover,
@@ -43,6 +43,18 @@ export interface InfoPopoverProps {
*/
label?: string;
className?: string;
+ /**
+ * Trigger glyph. Defaults to `Info` (h-3.5 w-3.5); the compliance-tip
+ * wrapper passes `HelpCircle` at `h-3 w-3` to keep its pre-merge look.
+ */
+ icon?: LucideIcon;
+ iconClassName?: string;
+ /** Popover alignment relative to the trigger. Defaults to `"end"`. */
+ align?: "start" | "center" | "end";
+ /** `data-slot` override for the trigger button. */
+ triggerDataSlot?: string;
+ /** `data-slot` override for the popover body. */
+ bodyDataSlot?: string;
}
export function InfoPopover({
@@ -50,6 +62,11 @@ export function InfoPopover({
link,
label,
className,
+ icon: Icon = Info,
+ iconClassName = "h-3.5 w-3.5",
+ align = "end",
+ triggerDataSlot = "info-popover-trigger",
+ bodyDataSlot = "info-popover-body",
}: InfoPopoverProps) {
const { t } = useTranslations();
const [open, setOpen] = useState(false);
@@ -59,7 +76,7 @@ export function InfoPopover({
diff --git a/src/components/ui/view-toggle.tsx b/src/components/ui/view-toggle.tsx
index 88c76eab9..ed5c5aec6 100644
--- a/src/components/ui/view-toggle.tsx
+++ b/src/components/ui/view-toggle.tsx
@@ -25,9 +25,10 @@ export interface ViewToggleProps {
* two-segment control: the active segment carries the filled surface, the
* inactive one stays ghost. `aria-pressed` announces the state per segment;
* the group is named for screen readers. Segments keep the 44-px mobile tap
- * floor (`size-11` via `size-10` + the `sm:size-8` desktop step), shrinking
- * to the standard 36-px control height on desktop like a neighbouring Add
- * button.
+ * floor (`size-11`), shrinking to the 36-px control height on desktop
+ * (`sm:size-9`) like a neighbouring Add button — the same floor the intake
+ * kebab and history-rail delete controls raised in this release window
+ * (L3, `.planning/audits/2026-07-18-qa-ui.md`).
*
* Previously forked between the medications module
* (`medications/medication-view-toggle.tsx`) and the Vorsorge/Illness/Labs
@@ -62,7 +63,7 @@ export function ViewToggle({
title={label}
data-slot={`${dataSlotPrefix}-${value}`}
className={cn(
- "focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8",
+ "focus-visible:ring-ring inline-flex size-11 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-9",
active
? "bg-secondary text-secondary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",