refactor(ui): give Settings and Admin one card rhythm and one page header

The two consoles used the same primitives and still read as two systems,
because everything without a primitive had drifted: the header-to-body
step existed in three competing techniques at once (`mt-4` on the first
body child, `space-y-4` on the card, `mb-4` on the header), the body
started either at the card edge or 28 px in, and both shells hand-rolled
the page title instead of calling `PageHeader`.

Move the decisions into the primitives so a call site cannot re-open them:

- `SettingsCard` is gap-based like `Card` (`flex flex-col gap-4`). The
  header-to-body step is the container's, not a margin somebody remembers.
  A dense card still overrides the gap on the card itself.
- The body starts at the card edge. `pl-7` is gone — off the spacing scale,
  and it mis-aligned action rows against their own forms wherever two
  neighbouring cards disagreed about it.
- The card header's description slot loses its `space-y-1`. That was the
  licence call sites used to stack explainer paragraphs into a muted
  `text-xs` slot; the slot holds one sentence.
- New `SettingsCardActions`: one action row, trailing edge, last thing in
  the card. `align="start"` spells the one exception, a card whose only
  actions are links out.
- New `StatTile` replaces four hand-rolled label/value boxes in Admin.
- `SectionHeading` moves from `insights/` to `ui/`. It is the heading above
  a group of cards, which is what Settings needs; Settings had been using a
  card header with no card, which reads as a card that failed to paint.

Both shells now render their heading through `PageHeader`, which gained
`headingAs="div"` for the second copy each shell paints (mobile above the
nav strip, desktop inside the grid) so only one is a real `<h1>`. The
page explainer is muted everywhere: the primitive said foreground, the
standards and every hand-rolled copy said muted, so pages disagreed with
each other. Medications, Vorsorge, the labs / custom-metric / illness
detail pages and the clinician view drop their hand-rolled title blocks
too, including the mid-word truncation on the medications subtitle.

Tests pin the parts that can be checked exactly: the card's gap, the
description slot's missing rhythm, the action row's two alignments, the
`<h1>`-free shells, and a sweep asserting `pl-7` stays gone.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 19:05:15 +02:00
1 parent 00e8d05235
commit 5445bfc1f1
121 files changed
+832 -620

No files matched your search

+66 -76
View File
@@ -33,6 +33,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 {
@@ -359,14 +360,10 @@ export default function MedicationsPageClient() {
if (!isAuthenticated) {
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
{t("medications.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("medications.loginRequired")}
</p>
</div>
<PageHeader
title={t("medications.title")}
description={t("medications.loginRequired")}
/>
</div>
);
}
@@ -407,90 +404,83 @@ export default function MedicationsPageClient() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h1
data-tour-id="medications-hero"
className="text-2xl font-bold tracking-tight"
>
{t("medications.title")}
</h1>
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
H1 isn't an unframed label. */}
<p className="text-muted-foreground truncate text-xs sm:text-sm">
{t("medications.subtitle")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
<PageHeader
title={
<span data-tour-id="medications-hero">{t("medications.title")}</span>
}
description={t("medications.subtitle")}
actions={
<>
{/* v1.16.11 (#316) — "Alle fälligen einnehmen". Contextual, not a
permanent fixture: it renders only while at least TWO
medications are currently due (a single due dose is the
card's own one-tap job). Calm outline variant so the primary
Add button keeps the visual lead; same responsive 44-px
mobile tap floor as its neighbours. */}
{canAdd && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
className="min-h-11 sm:min-h-9"
>
<CheckCheck className="h-4 w-4" aria-hidden="true" />
{t("medications.takeAllDue.button")}
</Button>
)}
{/* v1.16.11 — the wrench is the one customize entry point:
{canAdd && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
className="min-h-11 sm:min-h-9"
>
<CheckCheck className="h-4 w-4" aria-hidden="true" />
{t("medications.takeAllDue.button")}
</Button>
)}
{/* v1.16.11 — the wrench is the one customize entry point:
it links to /settings/medications, which owns the view
preference (cards ⇄ table) and the manual-order editor.
Same glyph, slot (left of the add button) and responsive
44-px mobile tap floor as the dashboard and insights
headers. */}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
{canManage && (
<Button
asChild
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.14.0 — the "Add" button is now a choice: log an intake
<Link
href="/settings/layout/medications"
aria-label={t("medications.customize")}
title={t("medications.customize")}
>
<Wrench className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
)}
{/* v1.14.0 — the "Add" button is now a choice: log an intake
(incl. a backdated one) against an existing medication, or
create a new medication. v1.12.2 — match the dashboard Add
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
{canAdd && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{canAdd && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => setLogIntakeOpen(true)}
disabled={activeMeds.length === 0}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
{t("medications.addChoice.logIntake")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={openCreate}>
<Pill className="mr-2 h-4 w-4" />
{t("medications.addChoice.newMedication")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</>
}
/>
{isLoading || isLayoutLoading ? (
// v1.11.3 C4 — skeletons instead of a bare centred spinner so the
+43
View File
@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* `<StatTile>` — the one label/value mini tile in the Admin console.
*
* Four hand-rolled variants of this box existed side by side: `StatusItem`
* (`bg-muted/50 p-3`), the encryption section's local `Stat`
* (`bg-muted/40 px-3 py-2`), the user-management inline panels
* (`bg-muted/80 p-4`), and the backups upload box (`bg-muted/30 p-3`). Four
* fills, three paddings, one role. This is the role.
*
* `icon` is optional — a tile with a glyph and one without still share the
* frame, so a grid can mix them without a step in tile height.
*/
export function StatTile({
icon: Icon,
label,
value,
valueClassName,
className,
}: {
icon?: React.ComponentType<{ className?: string }>;
label: React.ReactNode;
value: React.ReactNode;
/** Extra classes on the value line — a status colour, or `font-mono`. */
valueClassName?: string;
className?: string;
}) {
return (
<div
data-slot="stat-tile"
className={cn("bg-muted/50 space-y-1 rounded-lg p-3", className)}
>
<div className="text-muted-foreground flex items-center gap-1.5 text-xs">
{Icon ? <Icon className="size-3.5 shrink-0" /> : null}
{label}
</div>
<p className={cn("text-sm font-semibold", valueClassName)}>{value}</p>
</div>
);
}
+9 -14
View File
@@ -37,6 +37,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { PageHeader } from "@/components/ui/page-header";
import { SUB_SHELL_GRID_FLOOR } from "@/components/layout/shell-metrics";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -253,21 +254,15 @@ export function AdminShell({ active, children }: AdminShellProps) {
? t("admin.subtitle")
: t(`admin.section.${activeSlug}.subtitle`);
// Same two-copy heading as the Settings shell (mobile above the chip strip,
// desktop inside the grid row) and the same primitive, so the two consoles
// cannot drift a step apart again.
const headingBlock = (mobile = false) => (
<div>
{mobile ? (
<div
role="heading"
aria-level={1}
className="text-2xl font-bold tracking-tight"
>
{headingTitle}
</div>
) : (
<h1 className="text-2xl font-bold tracking-tight">{headingTitle}</h1>
)}
<p className="text-muted-foreground text-sm">{headingSubtitle}</p>
</div>
<PageHeader
headingAs={mobile ? "div" : "h1"}
title={headingTitle}
description={headingSubtitle}
/>
);
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
+1 -1
View File
@@ -76,7 +76,7 @@ export function AiQualitySection() {
// resolves. Mirrors <CoachFeedbackSection> (header outside the
// fetch-state branch).
return (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Sparkles}
title={t("admin.aiQuality.title")}
@@ -114,7 +114,7 @@ export function AiServerKeySection() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-server-key-input">
{t("admin.aiServerKey.keyLabel")}
@@ -160,7 +160,7 @@ export function ApiTokenOverviewSection() {
description={t("admin.apiTokensDescription")}
/>
<div className="mt-4">
<div>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
@@ -116,7 +116,7 @@ export function AppLogPreviewSection() {
const events = data?.events ?? [];
return (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={FileText}
title={t("admin.section.app-logs.title")}
@@ -280,7 +280,7 @@ export function AppLogPreviewSection() {
</table>
</div>
<div
className="text-muted-foreground mt-3 flex items-center justify-between text-xs"
className="text-muted-foreground flex items-center justify-between text-xs"
data-testid="app-log-preview-summary"
>
<span>
+1 -1
View File
@@ -103,7 +103,7 @@ export function AssistantSection() {
title={t("admin.assistant.title")}
description={t("admin.assistant.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<SettingsToggle
label={t("admin.assistant.master.title")}
description={t("admin.assistant.master.description")}
+7 -12
View File
@@ -493,13 +493,8 @@ export function BackupsSection() {
return (
<SettingsCard>
{/* v1.18.1 E3 — the snapshot count leads (numbers first), the
"Run now" button follows, and the explainer + docs link move
into the header's description slot. Previously the description
sat in a separate `mt-1 pl-7` paragraph that crowded the button
directly above it; routing it through the header's `space-y-1`
stack restores breathing room and left-aligns it under the
title. */}
{/* v1.18.1 E3 — the snapshot count leads (numbers first) and the
"Run now" button follows. */}
<SettingsCardHeader
icon={Database}
title={t("admin.section.backups.title")}
@@ -547,7 +542,7 @@ export function BackupsSection() {
backup file independently of any existing rows. The visible
button proxies a hidden file input so the layout stays clean
while keyboard / screen-reader users keep a labelled control. */}
<div className="bg-muted/30 border-border mt-4 flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="bg-muted/30 border-border flex flex-col gap-2 rounded-md border p-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-sm font-medium">
{t("admin.section.backups.uploadTitle")}
@@ -596,7 +591,7 @@ export function BackupsSection() {
) : null}
{isLoading ? (
<div className="mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.section.backups.loading")}
@@ -605,7 +600,7 @@ export function BackupsSection() {
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.section.backups.loadError")}
</div>
@@ -614,7 +609,7 @@ export function BackupsSection() {
// primitive. The header already exposes "Backup now" but a
// brand-new admin lands inside the card and benefits from a
// duplicate CTA right next to the explanation.
<div className="mt-4">
<div>
<EmptyState
icon={<Database className="size-6" />}
title={t("admin.section.backups.emptyTitle")}
@@ -637,7 +632,7 @@ export function BackupsSection() {
/>
</div>
) : (
<div className="mt-4">
<div>
{/* 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. */}
@@ -166,7 +166,7 @@ export function CentralCodexSection() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="text-muted-foreground flex items-start gap-2 text-xs">
<ShieldAlert className="mt-0.5 size-3.5 shrink-0" aria-hidden />
<p className="min-w-0">{t("admin.centralCodex.honesty")}</p>
@@ -71,7 +71,7 @@ export function CoachFeedbackSection() {
// /admin/coach-feedback. Mirrors the canonical structure used by
// <SystemStatusSection> (header outside the fetch-state branch).
return (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Sparkles}
title={t("admin.coachFeedback.title")}
+1 -1
View File
@@ -170,7 +170,7 @@ export function DangerZoneSection() {
/>
{wipeMsg && (
<p
className={`mt-3 pl-7 text-sm ${
className={`mt-3 text-sm ${
wipeAllData.isError ? "text-destructive" : "text-success"
}`}
>
+3 -3
View File
@@ -125,7 +125,7 @@ export function EncryptionSection() {
return (
<div className="space-y-6">
{/* ── Coverage summary ───────────────────────────────────────── */}
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
@@ -163,7 +163,7 @@ export function EncryptionSection() {
</SettingsCard>
{/* ── Rotation status + trigger ──────────────────────────────── */}
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={KeyRound}
title={t("admin.section.encryption.rotationTitle")}
@@ -230,7 +230,7 @@ export function EncryptionSection() {
</SettingsCard>
{/* ── Per-column coverage table ──────────────────────────────── */}
<SettingsCard className="space-y-3">
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.columnsTitle")}
@@ -85,7 +85,7 @@ export function GeneralSettingsSection() {
title={t("admin.appSettings")}
description={t("admin.appSettingsDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<SettingsToggle
label={t("admin.registrationEnabled")}
description={t("admin.registrationEnabledDescription")}
+2 -2
View File
@@ -78,7 +78,7 @@ export function GlitchtipSection() {
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<SettingsToggle
label={t("admin.glitchtipEnabled")}
icon={AlertTriangle}
@@ -130,7 +130,7 @@ export function GlitchtipSection() {
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
+1 -1
View File
@@ -185,7 +185,7 @@ export function HostMetricsChart() {
</h2>
<p
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-3 rounded-md border px-3 py-2 text-xs"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-xs"
>
{t("admin.hostMetrics.loadError")}
</p>
@@ -322,10 +322,10 @@ export function InviteTokensSection() {
{t("admin.invites.title")}
</h2>
</div>
<p className="text-muted-foreground mt-1 pl-7 text-xs">
<p className="text-muted-foreground mt-1 text-xs">
{t("admin.invites.description")}
</p>
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 pl-7 text-xs">
<p className="text-muted-foreground mt-1 flex items-center gap-1.5 text-xs">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden="true" />
{t("admin.invites.adminOnlyHint")}
</p>
@@ -227,7 +227,7 @@ export function LoginOverviewSection() {
description={t("admin.loginOverviewDescription")}
/>
<div className="mt-4 space-y-3">
<div className="space-y-3">
{/* Quick-filter pills. Failed-only is the most common
admin "is something wrong" question — keep the one-tap shortcut. */}
<div className="flex gap-1">
@@ -94,7 +94,7 @@ export function ModuleAvailabilitySection() {
title={t("admin.modules.title")}
description={t("admin.modules.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{MODULE_KEYS
// A module switched off in code (pending a rebuild) is hard-off
// server-wide; drop its row so the operator toggle can't mislead.
@@ -70,7 +70,7 @@ export function RecentAuditPreview() {
}
/>
<div className="mt-4">
<div>
{isLoading ? (
<div className="flex items-center gap-2 py-2">
<Loader2
+5 -5
View File
@@ -61,7 +61,7 @@ function NotificationHealthPanel() {
description={t("admin.notificationHealth.description")}
/>
<div className="mt-4 pl-7">
<div>
{isLoading && (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
)}
@@ -205,7 +205,7 @@ export function RemindersSection() {
description={t("admin.medicationRemindersDescription")}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-reminder-late" className="text-xs">
@@ -260,7 +260,7 @@ export function RemindersSection() {
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
@@ -319,7 +319,7 @@ export function RemindersSection() {
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
<div className="mt-4 space-y-1">
<div className="space-y-1">
{testNotification.data.results.map((r, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs">
{r.success ? (
@@ -338,7 +338,7 @@ export function RemindersSection() {
{reminderCheck.data?.medications &&
reminderCheck.data.medications.length > 0 && (
<div className="mt-4 space-y-2">
<div className="space-y-2">
{/* v1.4.33 IW9 — h4 -> h2 so the page outline stays
sequential (parent admin SectionFrame ships <h1>). */}
<h2 className="text-sm font-medium">
+1 -1
View File
@@ -18,7 +18,7 @@ export function ServicesSection() {
title={t("admin.servicesGlobal")}
description={t("admin.servicesGlobalDescription")}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<SettingsToggle
label="Telegram"
description={t("admin.telegramGlobal")}
@@ -74,7 +74,7 @@ export function FailingJobsCard({
title={t("admin.failingJobs.title")}
description={t("admin.failingJobs.description")}
/>
<p className="text-muted-foreground mt-4 pl-7 text-sm">
<p className="text-muted-foreground text-sm">
{t("admin.failingJobs.unavailable")}
</p>
</SettingsCard>
@@ -91,12 +91,12 @@ export function FailingJobsCard({
{failingJobs.queues.length === 0 ? (
<p
data-testid="failing-jobs-none"
className="text-muted-foreground mt-4 pl-7 text-sm"
className="text-muted-foreground text-sm"
>
{t("admin.failingJobs.none", { hours: failingJobs.windowHours })}
</p>
) : (
<ul data-testid="failing-jobs-list" className="mt-4 space-y-3 pl-7">
<ul data-testid="failing-jobs-list" className="space-y-3">
{failingJobs.queues.map((queue) => (
<li key={queue.queue} className="text-sm">
<p className="text-foreground font-medium">
@@ -146,7 +146,7 @@ export function SystemStatusSection() {
// branches below it are a load error and a spinner, so the
// marker cannot stand for a section still waiting on the read.
data-slot="system-status-grid"
className="mt-4 grid gap-4 pl-7 sm:grid-cols-2 lg:grid-cols-4"
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
>
<StatusItem
icon={Database}
@@ -275,7 +275,7 @@ export function SystemStatusSection() {
// full page reload to recover.
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
className="text-destructive bg-destructive/10 border-destructive/30 flex flex-col items-start gap-2 rounded-md border px-3 py-2 text-sm sm:flex-row sm:items-center sm:justify-between"
>
<span>{t("admin.systemStatusLoadError")}</span>
<Button
@@ -296,7 +296,7 @@ export function SystemStatusSection() {
</Button>
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingStatus")}
@@ -45,7 +45,7 @@ export function SystemStatusSummary() {
/>
{status ? (
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.snapshotVersion")}
@@ -120,12 +120,12 @@ export function SystemStatusSummary() {
) : isError ? (
<div
role="alert"
className="text-destructive bg-destructive/10 border-destructive/30 mt-4 rounded-md border px-3 py-2 text-sm"
className="text-destructive bg-destructive/10 border-destructive/30 rounded-md border px-3 py-2 text-sm"
>
{t("admin.overview.snapshotLoadError")}
</div>
) : (
<div className="mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Loader2
className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
+2 -2
View File
@@ -81,7 +81,7 @@ export function UmamiSection() {
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<SettingsToggle
label={t("admin.umamiEnabled")}
icon={Activity}
@@ -131,7 +131,7 @@ export function UmamiSection() {
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outline"
@@ -306,7 +306,7 @@ export function UserManagementSection() {
// rendered an empty `<tbody>` so an admin filter that returned
// no rows produced a blank rectangle; the icon + filter-aware
// copy + "Show all users" CTA make the state explicit.
<div className="mt-4">
<div>
<EmptyState
icon={<Users className="size-6" />}
title={t("admin.section.users.emptyTitle")}
@@ -330,7 +330,7 @@ export function UserManagementSection() {
`md:block hidden` swaps it out for the card-list below at
`< md`. Hiding the table-only wrapper (instead of just
the cells) saves DOM weight on mobile too. */}
<div className="mt-4 hidden overflow-x-auto md:block">
<div className="hidden overflow-x-auto md:block">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-xs">
@@ -399,7 +399,7 @@ export function UserManagementSection() {
All four actions stay visible and tap-targetable; nothing
is hidden behind a horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
className="space-y-2 md:hidden"
data-slot="admin-user-rows"
data-testid="admin-users-mobile-list"
>
@@ -444,7 +444,7 @@ export function UserManagementSection() {
</>
)
) : (
<div className="mt-4 flex items-center gap-2">
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.loadingUsers")}
@@ -454,7 +454,7 @@ export function UserManagementSection() {
{/* Edit Dialog */}
{editingUser && (
<div className="bg-muted/80 mt-4 rounded-lg p-4">
<div className="bg-muted/80 rounded-lg p-4">
<h3 className="mb-3 text-sm font-semibold">
{t("admin.editUserTitle", { name: editingUser.username })}
</h3>
@@ -567,7 +567,7 @@ export function UserManagementSection() {
{/* Password Reset Dialog */}
{resetUser && (
<div className="bg-muted/80 mt-4 rounded-lg p-4">
<div className="bg-muted/80 rounded-lg p-4">
<h3 className="mb-3 text-sm font-semibold">
{t("admin.resetPasswordTitle", { name: resetUser.username })}
</h3>
@@ -114,7 +114,7 @@ export function VersionTileSection() {
}
/>
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2 lg:grid-cols-3">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<StatusItem
icon={Tag}
label={t("admin.overview.versionTileVersion")}
@@ -128,7 +128,7 @@ export function WebPushVapidSection() {
status={configured ? <ConfiguredBadge /> : null}
/>
<div className="mt-4 grid gap-3 pl-7 sm:grid-cols-2">
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="admin-web-push-public-key" className="text-xs">
{t("admin.webPushVapidPublicKey")}
@@ -191,7 +191,7 @@ export function WebPushVapidSection() {
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
+3 -7
View File
@@ -19,6 +19,7 @@ import { makeFormatters } from "@/lib/format-locale";
import type { Locale } from "@/lib/i18n/config";
import type { ShareViewDocument } from "@/lib/clinician-share/share-view-data";
import type { ReportSelection } from "@/lib/report-selection/selection";
import { PageHeader } from "@/components/ui/page-header";
import { DocumentEntry } from "./documents-list";
import { ShareDownloadActions } from "./download-actions";
import {
@@ -98,13 +99,8 @@ export function ClinicianView({
className="mx-auto min-h-dvh w-full max-w-3xl px-4 py-8"
>
{/* ── Provenance header ───────────────────────────────────────── */}
<header className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">
{t("clinicianView.title")}
</h1>
{label ? (
<p className="text-muted-foreground mt-1 text-sm">{label}</p>
) : null}
<header className="mb-6 space-y-1.5">
<PageHeader title={t("clinicianView.title")} description={label} />
{report && !documentOnly ? (
<p className="text-muted-foreground mt-3 text-sm">
{t("clinicianView.period", {
@@ -18,6 +18,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { EmptyState } from "@/components/ui/empty-state";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
@@ -171,14 +172,10 @@ export function CustomMetricDetail({
if (metricError || listError) {
return (
<div className="space-y-6">
<header className="space-y-1.5">
<h1 className="text-2xl font-bold tracking-tight">
{metric?.name ?? t("customMetrics.detail.title")}
</h1>
<p className="text-foreground text-sm leading-relaxed">
{description}
</p>
</header>
<PageHeader
title={metric?.name ?? t("customMetrics.detail.title")}
description={description}
/>
<QueryErrorCard
description={t("customMetrics.loadError")}
onRetry={() => {
@@ -192,70 +189,69 @@ export function CustomMetricDetail({
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{metric?.name ?? <Skeleton className="h-7 w-32" />}
</h1>
{metric?.unit ? (
<p className="text-muted-foreground text-xs sm:text-sm">
{metric.unit}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
<DeleteButton
onConfirm={() => deleteMetric.mutate()}
title={t("customMetrics.deleteConfirmTitle")}
description={t("customMetrics.deleteConfirmDescription")}
confirmLabel={t("customMetrics.delete")}
triggerTitle={t("customMetrics.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!metric}
aria-label={t("customMetrics.edit")}
title={t("customMetrics.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
{entries.length > 0 ? (
<PageHeader
title={metric?.name ?? <Skeleton className="h-7 w-32" />}
description={description}
actions={
<>
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
<DeleteButton
onConfirm={() => deleteMetric.mutate()}
title={t("customMetrics.deleteConfirmTitle")}
description={t("customMetrics.deleteConfirmDescription")}
confirmLabel={t("customMetrics.delete")}
triggerTitle={t("customMetrics.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
<Button
asChild
variant="ghost"
size="icon"
data-slot="custom-metric-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!metric}
aria-label={t("customMetrics.edit")}
title={t("customMetrics.edit")}
>
<Link
href={`/custom-metrics/${customMetricId}/values`}
aria-label={t("customMetrics.showAllValues")}
title={t("customMetrics.showAllValues")}
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
<Pencil className="h-4 w-4" />
</Button>
) : null}
<Button
onClick={() => setAddOpen(true)}
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("customMetrics.addValue")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">
{t("customMetrics.addValue")}
</span>
</Button>
</div>
</div>
{entries.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="custom-metric-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href={`/custom-metrics/${customMetricId}/values`}
aria-label={t("customMetrics.showAllValues")}
title={t("customMetrics.showAllValues")}
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : null}
<Button
onClick={() => setAddOpen(true)}
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("customMetrics.addValue")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">
{t("customMetrics.addValue")}
</span>
</Button>
</>
}
/>
<p className="text-foreground text-sm leading-relaxed">{description}</p>
{/* The unit is meta about the metric, not its explainer, so it reads
below the header rather than competing for the one description line
every page header carries. */}
{metric?.unit ? (
<p className="text-muted-foreground text-sm">{metric.unit}</p>
) : null}
{isLoading ? (
<Card>
@@ -5,7 +5,7 @@ import Link from "next/link";
import { ArrowRight, Droplets } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { CyclePhaseHeadline } from "./cycle-phase-crosstab";
import { PHASE_HUE } from "./phase-tokens";
import { localYmd, useCycleCalendar, useCycleInsights } from "./use-cycle";
@@ -16,6 +16,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -67,13 +68,11 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="truncate text-2xl font-bold tracking-tight">
{episode?.label ?? <Skeleton className="h-7 w-40" />}
</h1>
{episode ? (
<div className="mt-1.5 flex flex-wrap items-center gap-2">
<PageHeader
title={episode?.label ?? <Skeleton className="h-7 w-40" />}
description={
episode ? (
<span className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{t(`illness.type.${episode.type}`)}
</Badge>
@@ -94,32 +93,34 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
})}`
: ""}
</span>
</div>
) : null}
</div>
{canManage && (
<div className="flex shrink-0 items-center gap-1">
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
<EpisodeMenu
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
) : null}
</div>
)}
</div>
</span>
) : undefined
}
actions={
canManage ? (
<>
<Button
onClick={() => setLogOpen(true)}
className="min-h-11 sm:min-h-9"
>
{t("illness.logDay")}
</Button>
{episode ? (
<EpisodeMenu
episode={episode}
onEdit={() => setEditOpen(true)}
onResolve={
active && !isChronic
? () => resolve.mutate(episode.id)
: undefined
}
resolving={resolve.isPending}
/>
) : null}
</>
) : undefined
}
/>
{episode?.note ? (
<Card>
+2 -2
View File
@@ -48,7 +48,7 @@ export function IllnessSettings() {
return (
<div className="space-y-6">
<SettingsCard id="illness-view" className="scroll-mt-28 space-y-4">
<SettingsCard id="illness-view" className="scroll-mt-28">
<SettingsCardHeader
icon={Eye}
title={t("moduleList.viewHeading")}
@@ -57,7 +57,7 @@ export function IllnessSettings() {
/>
</SettingsCard>
<SettingsCard id="illness-order" className="scroll-mt-28 space-y-4">
<SettingsCard id="illness-order" className="scroll-mt-28">
<SettingsCardHeader
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import type {
BreathingClassification,
BreathingTrend,
@@ -4,7 +4,7 @@ import { AlertTriangle, Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { CoverageMeter } from "@/components/insights/derived/coverage-meter";
import { ProvenanceExplainer } from "@/components/insights/derived/provenance-explainer";
+1 -1
View File
@@ -23,7 +23,7 @@ import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import {
useTranslations,
useFormatters,
@@ -13,7 +13,7 @@ import {
MEASUREMENT_TYPE_LABEL_KEYS,
} from "@/components/measurements/measurement-list-meta";
import { TileHeader } from "@/components/insights/tile-header";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { SparklineDeltaTile } from "./sparkline-delta-tile";
import { LearnMoreLink } from "@/components/ui/learn-more-link";
import { CoverageMeter } from "./coverage-meter";
@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { ScoreRing } from "./score-ring";
import { TILE_HUE, type RingHue } from "./ring-hues";
import type { DerivedBatchRead } from "./use-derived-metric";
@@ -11,7 +11,7 @@ import type { DataSummary } from "@/lib/analytics/trends";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { LearningGate } from "@/components/ui/learning-gate";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { TileHeader } from "@/components/insights/tile-header";
/**
+1 -1
View File
@@ -9,7 +9,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { EcgWaveform } from "@/components/insights/ecg-waveform";
import { InsightSectionCard } from "./insight-section-card";
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import type {
HealthStatusDeviation,
@@ -8,7 +8,7 @@ import { useAuth } from "@/hooks/use-auth";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import type { LabChange } from "@/lib/insights/labs-changes";
import { InsightSectionCard } from "./insight-section-card";
@@ -13,7 +13,7 @@ import { formatUpdatedLabel } from "@/lib/i18n/relative-time";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { AskCoachIconButton } from "@/components/insights/ask-coach-action";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { apiGet } from "@/lib/api/api-fetch";
@@ -9,7 +9,7 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { InsightSectionCard } from "./insight-section-card";
/**
@@ -5,7 +5,7 @@ import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
+1 -1
View File
@@ -6,7 +6,7 @@ import { ArrowRight, TrendingUp } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { HealthChartDynamicMini } from "@/components/charts/health-chart-dynamic";
import type { DailyBriefing } from "@/lib/ai/schema";
import {
@@ -5,7 +5,7 @@ import Link from "next/link";
import { ArrowRight, CalendarDays, Moon, Smile } from "lucide-react";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { SectionHeading } from "@/components/ui/section-heading";
import { Card, CardContent } from "@/components/ui/card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
+76 -82
View File
@@ -27,6 +27,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { MetricStatStrip } from "@/components/insights/metric-stat-strip";
@@ -303,14 +304,10 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
if (markerError || listError) {
return (
<div className="space-y-6">
<header className="space-y-1.5">
<h1 className="text-2xl font-bold tracking-tight">
{marker?.name ?? t("labs.detail.title")}
</h1>
<p className="text-foreground text-sm leading-relaxed">
{description}
</p>
</header>
<PageHeader
title={marker?.name ?? t("labs.detail.title")}
description={description}
/>
<QueryErrorCard
title={t("labs.loadError")}
onRetry={() => {
@@ -324,91 +321,88 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-2xl font-bold tracking-tight">
{marker?.name ?? <Skeleton className="h-7 w-32" />}
</h1>
{marker?.unit ? (
<p className="text-muted-foreground text-xs sm:text-sm">
{marker.unit}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{/* v1.25.3 — header control cluster, left → right:
<PageHeader
title={marker?.name ?? <Skeleton className="h-7 w-32" />}
description={description}
actions={
<>
{/* v1.25.3 — header control cluster, left → right:
Delete · Edit · Show-all-values · Add. The icon controls share one
ghost-icon treatment (the target-range editing lives inside the
full marker editor behind the pencil, so the separate range
control was dropped). */}
<DeleteButton
onConfirm={() => deleteMarker.mutate()}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t("labs.biomarker.deleteConfirmDescription")}
confirmLabel={t("labs.biomarker.delete")}
triggerTitle={t("labs.biomarker.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
iconClassName="h-4 w-4"
/>
{canManage && (
<Button
variant="ghost"
size="icon"
<DeleteButton
onConfirm={() => deleteMarker.mutate()}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t("labs.biomarker.deleteConfirmDescription")}
confirmLabel={t("labs.biomarker.delete")}
triggerTitle={t("labs.biomarker.delete")}
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
iconClassName="h-4 w-4"
/>
{canManage && (
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
onClick={() => setEditOpen(true)}
disabled={!marker}
aria-label={t("labs.biomarker.edit")}
title={t("labs.biomarker.edit")}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{/* "Show all readings" mirrors the metric sub-pages' `<SubPageShell>`
control. The full reading feed lives on
`/labs/[biomarkerId]/values`; the detail page keeps the
numbers-first spine. */}
{readings.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="lab-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<Link
href={`/labs/${biomarkerId}/values`}
aria-label={t("insights.subPage.showAllValues")}
title={t("insights.subPage.showAllValues")}
{readings.length > 0 ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="lab-show-all-values"
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : null}
{/* v1.36.x — entering a result is an admitted delegated write;
<Link
href={`/labs/${biomarkerId}/values`}
aria-label={t("insights.subPage.showAllValues")}
title={t("insights.subPage.showAllValues")}
>
<ListOrdered className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
) : null}
{/* v1.36.x — entering a result is an admitted delegated write;
renaming or removing the marker is not. */}
{canAdd && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
// text "Add" button crowd the row and truncate the title hard.
// Drop the Add button to icon-only under `sm` (label kept for
// screen readers via `aria-label`); the full text returns at `sm+`.
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("labs.addResult")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("labs.addResult")}</span>
</Button>
)}
</div>
</div>
{canAdd && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
// text "Add" button crowd the row and truncate the title hard.
// Drop the Add button to icon-only under `sm` (label kept for
// screen readers via `aria-label`); the full text returns at `sm+`.
className="min-h-11 min-w-11 shrink-0 sm:min-h-9 sm:min-w-0"
aria-label={t("labs.addResult")}
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">{t("labs.addResult")}</span>
</Button>
)}
</>
}
/>
{/* v1.25.1 — the static description leads the page, directly beneath the
heading, mirroring the metric sub-pages' explainer caption. Computed
once above (catalog desc → user `context` → generic fallback) so a
catalog-less marker still carries a description. */}
<p className="text-foreground text-sm leading-relaxed">{description}</p>
{/* Unit + panel are meta about the marker, not its explainer, so they
read below the header block rather than competing with it for the
one description line every page header carries. */}
{marker?.unit ? (
<p className="text-muted-foreground text-sm">
{marker.unit}
{marker.panel ? ` · ${marker.panel}` : ""}
</p>
) : null}
{/* Bridge to the source report PDFs. There is no per-value document
relation in the data model yet, so this links to the vault filtered
+5 -5
View File
@@ -79,7 +79,7 @@ export function LabsSettings() {
return (
<div className="space-y-6">
{/* View — card vs compact list. */}
<SettingsCard id="labs-view" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-view" className="scroll-mt-28">
<SettingsCardHeader
icon={LayoutGrid}
title={t("moduleList.viewHeading")}
@@ -89,7 +89,7 @@ export function LabsSettings() {
</SettingsCard>
{/* Sort order — recency, alphabetical, or manual. */}
<SettingsCard id="labs-sort-card" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-sort-card" className="scroll-mt-28">
<SettingsCardHeader
icon={ArrowDownUp}
title={t("labs.sort.heading")}
@@ -112,7 +112,7 @@ export function LabsSettings() {
</SettingsCard>
{manual ? (
<SettingsCard id="labs-order" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-order" className="scroll-mt-28">
<SettingsCardHeader
icon={ListOrdered}
title={t("moduleList.reorder.heading")}
@@ -130,7 +130,7 @@ export function LabsSettings() {
) : null}
{/* v1.18.10 — local (in-browser) OCR opt-in for lab scans. */}
<SettingsCard id="labs-local-ocr" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-local-ocr" className="scroll-mt-28">
<SettingsCardHeader
icon={ScanText}
title={t("labs.localOcr.heading")}
@@ -147,7 +147,7 @@ export function LabsSettings() {
</SettingsCard>
{/* Biomarker catalog — define / edit / delete. */}
<SettingsCard id="labs-biomarkers" className="scroll-mt-28 space-y-4">
<SettingsCard id="labs-biomarkers" className="scroll-mt-28">
<SettingsCardHeader
icon={FlaskConical}
title={t("labs.biomarker.manageTitle")}
@@ -62,6 +62,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { PageHeader } from "@/components/ui/page-header";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
@@ -400,24 +401,21 @@ export function VorsorgeSection({
return (
<section aria-labelledby="vorsorge-section-title" className="space-y-4">
{variant === "page" ? (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h1
id="vorsorge-section-title"
data-tour-id="vorsorge-hero"
className="text-2xl font-bold tracking-tight"
>
<PageHeader
titleId="vorsorge-section-title"
title={
<span data-tour-id="vorsorge-hero">
{t("measurementReminders.sectionTitle")}
</h1>
<p className="text-muted-foreground text-xs sm:text-sm">
{t("measurementReminders.sectionDescription")}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
{wrenchButton}
{addButton}
</div>
</div>
</span>
}
description={t("measurementReminders.sectionDescription")}
actions={
<>
{wrenchButton}
{addButton}
</>
}
/>
) : (
<SettingsCardHeader
icon={CalendarClock}
@@ -81,7 +81,7 @@ export function VorsorgeSettings() {
title={t("moduleList.reorder.heading")}
description={t("moduleList.reorder.description")}
/>
<div className="mt-4 pl-7">
<div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
@@ -100,7 +100,7 @@ export function VorsorgeSettings() {
title={t("measurementReminders.manage.title")}
description={t("measurementReminders.manage.description")}
/>
<div className="mt-4 pl-7">
<div>
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : (reminders?.length ?? 0) === 0 ? (
@@ -0,0 +1,173 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { Bell } from "lucide-react";
import { I18nProvider } from "@/lib/i18n/context";
import { PageHeader } from "@/components/ui/page-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
/**
* Settings + Admin read as one surface. These pin the parts of that claim
* that can be checked exactly:
*
* 1. the header→body step is owned by `SettingsCard`, so a call site can
* neither re-declare it nor drift from it;
* 2. the card header's description slot carries no vertical rhythm — the
* licence call sites used to stack explainer paragraphs into a muted
* `text-xs` slot;
* 3. the action row has one shape, and its one documented exception
* (a card whose only actions are links out) is spelled as a prop;
* 4. `pl-7` — the off-scale body gutter that broke wherever two
* neighbouring cards disagreed about it — stays gone;
* 5. both consoles render their page heading through `PageHeader`, so
* Settings and Admin cannot drift a step apart from the module pages
* or from each other.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const ROOT = process.cwd();
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
walk(full, out);
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
describe("SettingsCard — the container owns the rhythm", () => {
it("paints the gap-based shell, not a call-site margin", () => {
const html = render(<SettingsCard>body</SettingsCard>);
expect(html).toContain("flex flex-col gap-4");
expect(html).toContain("p-4");
expect(html).toContain("md:p-6");
});
it("gives the semantic-landmark variant the same rhythm", () => {
const html = render(<SettingsCard as="section">body</SettingsCard>);
expect(html).toContain("<section");
expect(html).toContain("flex flex-col gap-4");
});
it("still lets a dense card override the gap on the card itself", () => {
const html = render(<SettingsCard className="gap-2">body</SettingsCard>);
expect(html).toContain("gap-2");
});
});
describe("SettingsCardHeader — one description sentence, one text run", () => {
it("renders the description without any vertical rhythm of its own", () => {
const html = render(
<SettingsCardHeader icon={Bell} title="Channels" description="One." />,
);
// The slot is muted meta at the size floor…
expect(html).toContain("text-muted-foreground text-xs");
// …and carries no `space-y-*`, so a stack of <p>s cannot read as prose.
const slot = html.slice(html.indexOf("text-muted-foreground text-xs"));
expect(slot.slice(0, 60)).not.toMatch(/space-y-/);
});
it("keeps the title in the foreground tier at one size", () => {
const html = render(<SettingsCardHeader icon={Bell} title="Channels" />);
expect(html).toMatch(/<h2[^>]*class="text-lg font-semibold"/);
});
});
describe("SettingsCardActions — one row, one shape", () => {
it("puts the row at the trailing edge by default", () => {
const html = render(
<SettingsCardActions>
<button>Save</button>
</SettingsCardActions>,
);
expect(html).toContain('data-slot="settings-card-actions"');
expect(html).toContain("justify-end");
});
it("spells the link-row exception as a prop rather than a local class", () => {
const html = render(
<SettingsCardActions align="start">
<a href="/x">Open</a>
</SettingsCardActions>,
);
expect(html).toContain("justify-start");
expect(html).not.toContain("justify-end");
});
});
describe("PageHeader — the one page heading, including the shells", () => {
it("renders the explainer in the muted tier", () => {
const html = render(<PageHeader title="Settings" description="Yours." />);
expect(html).toMatch(/<p class="text-muted-foreground text-sm">Yours\./);
});
it("renders a second copy as a heading role, never a second <h1>", () => {
const html = render(
<PageHeader headingAs="div" title="Settings" description="Yours." />,
);
expect(html).not.toContain("<h1");
expect(html).toContain('role="heading"');
expect(html).toContain('aria-level="1"');
});
it("never truncates the explainer", () => {
const html = render(
<PageHeader title="Medications" description="Schedules and reminders." />,
);
expect(html).not.toContain("truncate");
});
});
describe("the Settings and Admin shells route their heading through PageHeader", () => {
for (const shell of [
join("src", "components", "settings", "settings-shell.tsx"),
join("src", "components", "admin", "admin-shell.tsx"),
]) {
it(`${shell} has no hand-rolled H1 block`, () => {
const source = readFileSync(join(ROOT, shell), "utf8");
expect(source).toContain("<PageHeader");
// The hand-rolled copies were a literal `<h1 className="text-2xl …">`
// plus a `role="heading"` div beside it. Both belong to the primitive.
const code = source
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
expect(code).not.toMatch(/<h1\b/);
expect(code).not.toMatch(/role="heading"/);
});
}
});
describe("the abolished body gutter stays abolished", () => {
it("has no `pl-7` left under Settings or Admin", () => {
const offenders: string[] = [];
for (const dir of ["settings", "admin"]) {
for (const file of walk(join(ROOT, "src", "components", dir))) {
const source = readFileSync(file, "utf8");
for (const [i, line] of source.split("\n").entries()) {
// Comments may name the class they retired; class strings may not.
if (line.trimStart().startsWith("*")) continue;
if (line.includes("//")) continue;
if (/\bpl-7\b/.test(line)) {
offenders.push(`${file.slice(ROOT.length + 1)}:${i + 1}`);
}
}
}
}
expect(offenders).toEqual([]);
});
});
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* `<SettingsCardActions>` — the action row of a Settings or Admin card.
*
* The audit found four positions (bottom-right, bottom-left, the header's
* status slot, inline mid-card) and five variants carrying the SAME role —
* "the primary action of this card" — sometimes two of them in one file. The
* two sections that already read right (`score-section.tsx`,
* `coach-prefs-section.tsx`) agreed on one shape, and this is it.
*
* The contract (design standards §12):
* - The row is the LAST thing in the card. Status lines and results sit
* above it; a result set big enough to need a frame becomes its own card.
* - Primary action last (rightmost), `variant` default, `size="sm"` with
* the `min-h-11` mobile tap floor. One per card.
* - Secondary action `outline`, before the primary in the same row.
* - Destructive card-wide action: same row, `variant="destructive"` —
* never an `outline` hand-tinted with `text-destructive`.
*
* `align="start"` is the one documented exception: a card whose only actions
* are deep links out of it (privacy, about, export) has no primary action, so
* its links read with the text on the left rather than against it.
*/
export function SettingsCardActions({
align = "end",
className,
children,
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="settings-card-actions"
className={cn(
"flex flex-wrap items-center gap-2",
align === "end" ? "justify-end" : "justify-start",
className,
)}
{...props}
>
{children}
</div>
);
}
+11 -6
View File
@@ -47,9 +47,13 @@ export interface SettingsCardHeaderProps {
* after the title (e.g. a tag chip + experimental badge). The title
* row wraps so the accessories reflow below on a narrow viewport. */
titleAccessory?: React.ReactNode;
/** Optional short description rendered as muted text below the
* title row. Either a string (paragraph) or arbitrary node
* (links, badges, extra sub-notes, etc.). */
/** Optional description rendered as muted text below the title row.
* Exactly ONE sentence, guideline ≤ 120 characters (design standards
* §3). A node is accepted so the sentence can carry an inline link,
* not so it can carry a second paragraph — the slot owns no vertical
* rhythm, so a stack of `<p>`s collapses into one run and reads
* broken. Multi-sentence prose goes in the body as
* `<p className="text-sm">` (foreground). */
description?: React.ReactNode;
/** Optional right-aligned status surface — typically an
* `<IntegrationStatusPill>` or a wrapper around badges. */
@@ -88,10 +92,11 @@ export function SettingsCardHeader({
<div className="flex shrink-0 items-center gap-2">{status}</div>
) : null}
</div>
{/* One sentence, one text run. This slot deliberately carries no
`space-y-*`: the vertical rhythm was the licence call sites used
to stack explainer paragraphs into a muted `text-xs` slot. */}
{description ? (
<div className="text-muted-foreground space-y-1 text-xs">
{description}
</div>
<div className="text-muted-foreground text-xs">{description}</div>
) : null}
</div>
</header>
+5 -6
View File
@@ -195,7 +195,6 @@ export function AboutMeSection({
as="section"
aria-labelledby="settings-ai-about-me-title"
data-testid="settings-about-me-card"
className="space-y-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<SettingsCardHeader
@@ -229,7 +228,7 @@ export function AboutMeSection({
</div>
</div>
<div className="pl-7">
<div>
{structuredField(
"allergies",
t("settings.ai.aboutMe.allergiesLabel"),
@@ -237,7 +236,7 @@ export function AboutMeSection({
)}
</div>
<div className="space-y-1.5 pl-7">
<div className="space-y-1.5">
<Label htmlFor="settings-about-me-freetext">
{t("settings.ai.aboutMe.freeTextLabel")}
</Label>
@@ -255,7 +254,7 @@ export function AboutMeSection({
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 pl-7">
<div className="flex flex-wrap items-center justify-between gap-3">
<p
data-testid="settings-about-me-count"
className="text-muted-foreground text-xs tabular-nums"
@@ -339,14 +338,14 @@ export function AboutMeSection({
</p>
)}
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
chronic conditions the Coach watches now live alongside allergies +
family history as one coherent medical history. */}
<p className="text-muted-foreground pl-7 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
href="/settings/anamnesis"
+3 -5
View File
@@ -197,18 +197,17 @@ export function AboutSection() {
icon={Info}
title="HealthLog"
description={t("settings.about.identityDescription")}
className="mb-4"
/>
{isLoading || !version ? (
<div className="flex items-center gap-2 pl-7">
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("common.loading")}
</span>
</div>
) : (
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 pl-7 text-sm">
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.version")}
@@ -272,9 +271,8 @@ export function AboutSection() {
icon={BookOpen}
title={t("settings.about.linksHeading")}
description={t("settings.about.linksDescription")}
className="mb-4"
/>
<div className="flex flex-col gap-2 pl-7 sm:flex-row sm:flex-wrap">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button
asChild
variant="outline"
@@ -148,7 +148,7 @@ export function GrantInviteCard() {
title={t("recordSharing.invite.title")}
description={t("recordSharing.invite.description")}
/>
<form onSubmit={submit} className="mt-4 space-y-3">
<form onSubmit={submit} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="grant-invite-identifier">
{t("recordSharing.invite.identifierLabel")}
@@ -52,7 +52,7 @@ export function GrantsGivenCard() {
title={t("recordSharing.given.title")}
description={t("recordSharing.given.description")}
/>
<div className="mt-4">
<div>
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
@@ -68,7 +68,7 @@ export function GrantsReceivedCard() {
title={t("recordSharing.received.title")}
description={t("recordSharing.received.description")}
/>
<div className="mt-4">
<div>
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
@@ -75,7 +75,7 @@ export function ManagedProfileCard() {
title={t("recordSharing.managed.title")}
description={t("recordSharing.managed.description")}
/>
<div className="mt-4 space-y-4">
<div className="space-y-4">
{/* Content, not meta: this is what somebody is consenting to take on,
and UI-STANDARDS §3 reserves muted for the incidental. */}
<p data-slot="managed-profile-explainer" className="text-sm">
@@ -50,7 +50,7 @@ export function RecordActivityCard() {
title={t("recordSharing.activity.title")}
description={t("recordSharing.activity.description")}
/>
<div className="mt-4 space-y-3">
<div className="space-y-3">
{/* The window, stated. Rendered only once the server has said what it
is — a placeholder number here would be exactly the invented value
the sentence exists to avoid. */}
@@ -108,20 +108,16 @@ export function AvatarSection() {
return (
<SettingsCard>
<SettingsCardHeader
icon={ImageUp}
title={t("settings.avatar.title")}
className="mb-4"
/>
<SettingsCardHeader icon={ImageUp} title={t("settings.avatar.title")} />
{/* The accepted formats and limits belong to the sentence that explains
what this card does, not to a caption of their own under the buttons.
Two strings rather than one merged key: the same wording is the
upload's own constraint copy, and a translator keeps the shorter unit
either way. */}
<p className="text-muted-foreground mb-4 pl-7 text-sm">
<p className="text-muted-foreground mb-4 text-sm">
{t("settings.avatar.description")} {t("settings.avatar.hint")}
</p>
<div className="flex items-center gap-4 pl-7">
<div className="flex items-center gap-4">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="text-base">
@@ -362,12 +362,8 @@ export function AccountSection() {
<AvatarSection />
<SettingsCard>
<SettingsCardHeader
icon={User}
title={t("settings.profile")}
className="mb-4"
/>
<form onSubmit={handleSaveProfile} className="space-y-4 pl-7">
<SettingsCardHeader icon={User} title={t("settings.profile")} />
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
+3 -3
View File
@@ -151,7 +151,7 @@ function ResearchModeCard() {
<div
role="alert"
data-slot="settings-research-mode-reprompt"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
className="border-warning/40 bg-warning/10 rounded-md border-l-4 px-3 py-2 text-sm"
>
<p className="text-foreground font-medium">
{t("settings.researchMode.rePromptTitle")}
@@ -171,7 +171,7 @@ function ResearchModeCard() {
</div>
)}
<div className="mt-4 flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.researchMode.toggleLabel")}
@@ -197,7 +197,7 @@ function ResearchModeCard() {
{errorMessage && (
<p
role="alert"
className="text-destructive mt-3 text-sm"
className="text-destructive text-sm"
data-slot="settings-research-mode-error"
>
{errorMessage}
@@ -140,13 +140,13 @@ export function AiInsightsCard({
}
return (
<SettingsCard className="space-y-4">
<SettingsCard>
{/* The card used to render an icon-only header row (Sparkles +
status badges, no title), which left the tile unanchored next
to its titled siblings. It now follows the shared
`SettingsCardHeader` contract: icon column, title + description
in the content column, status badges top-right — and the body
below indents to the title column (`pl-7`). */}
starts on the card edge like every other Settings card. */}
<SettingsCardHeader
icon={Sparkles}
title={t("settings.ai.providerCardTitle")}
@@ -159,7 +159,7 @@ export function AiInsightsCard({
}
/>
<div className="space-y-4 pl-7">
<div className="space-y-4">
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
@@ -169,8 +169,8 @@ export function DisableCoachCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-xs"
: "text-muted-foreground mt-3 pl-7 text-xs"
? "text-destructive mt-3 text-xs"
: "text-muted-foreground mt-3 text-xs"
}
>
{msg}
@@ -44,7 +44,7 @@ export function AnamnesisSection() {
return (
<div className="space-y-6">
{(coachEnabled || insightsEnabled) && (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={EyeOff}
title={t("records.aiInclusion.cardTitle")}
@@ -55,7 +55,7 @@ export function AnamnesisSection() {
)}
{coachEnabled && (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={HeartPulse}
title={t("records.conditions.cardTitle")}
@@ -64,7 +64,7 @@ export function AnamnesisSection() {
<ConditionsManager />
</SettingsCard>
)}
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ClipboardList}
title={t("records.profileFacts.cardTitle")}
@@ -73,7 +73,7 @@ export function AnamnesisSection() {
<HealthProfileFactsManager />
</SettingsCard>
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ShieldAlert}
title={t("records.allergies.cardTitle")}
@@ -82,7 +82,7 @@ export function AnamnesisSection() {
<AllergyManager />
</SettingsCard>
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Users}
title={t("records.family.cardTitle")}
+3 -3
View File
@@ -75,7 +75,7 @@ function ApiEndpointsCard() {
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border mt-4 hidden overflow-x-auto rounded-lg border md:block">
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
@@ -109,7 +109,7 @@ function ApiEndpointsCard() {
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
@@ -206,7 +206,7 @@ function ApiTokensCard() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{/* Tokens are no longer minted here. The generic mint issued a
`medication:ingest` token that the ingest endpoint refused (it
gates on the per-medication grant too) and that every other
@@ -141,7 +141,6 @@ export function CoachMemorySection({
as="section"
aria-labelledby="settings-ai-coach-memory-title"
data-testid="settings-coach-memory-card"
className="space-y-4"
>
<SettingsCardHeader
icon={Brain}
+4 -4
View File
@@ -225,7 +225,7 @@ export function CoachNudgeCard({
}
/>
{enabled && (
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<p className="text-sm font-medium">
{t("notifications.coachNudge.groupsLabel")}
</p>
@@ -307,7 +307,7 @@ export function CoachNudgeCard({
push-nudge master switch above (a user can keep nudges but silence
the example prompts), so it sits outside the `enabled` gate and rides
the same `coach` prefs blob. */}
<div className="border-border/60 mt-4 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<p className="text-sm">
{t("notifications.coachNudge.suggestionsLabel")}
@@ -331,8 +331,8 @@ export function CoachNudgeCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
@@ -120,10 +120,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
description={t("insights.coach.settingsDescription")}
/>
{isLoading ? (
<div
data-slot="coach-prefs-skeleton"
className="mt-4 flex flex-col gap-4 pl-7"
>
<div data-slot="coach-prefs-skeleton" className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-9 w-full" />
@@ -139,7 +136,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
) : (
<div className="mt-4 flex flex-col gap-4 pl-7">
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
@@ -338,7 +335,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
)}
<div className="mt-4 flex justify-end pl-7">
<div className="flex justify-end">
<Button
type="button"
size="sm"
@@ -155,7 +155,6 @@ export function CoachRemindersSection({
as="section"
aria-labelledby="settings-ai-coach-reminders-title"
data-testid="settings-coach-reminders-card"
className="space-y-4"
>
<SettingsCardHeader
icon={BellRing}
@@ -66,7 +66,7 @@ export function CycleTrackingCard({
title={t("settings.cycleTracking.title")}
description={t("settings.cycleTracking.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="cycle-tracking-enable" className="text-sm">
{t("settings.cycleTracking.enable")}
@@ -440,7 +440,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const dirty = draft !== null && layout !== null;
return (
<SettingsCard id={id} className="scroll-mt-28 space-y-4">
<SettingsCard id={id} className="scroll-mt-28">
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
right-align-on-desktop contract as Account → Password +
Restart onboarding tour. Avoids the long German "Auf Standard
+1 -1
View File
@@ -120,7 +120,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
description={t("settings.emailDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
<Switch
@@ -203,9 +203,8 @@ export function EnvironmentSection() {
<SettingsCardHeader
icon={CloudSun}
title={t("settings.sections.environment.title")}
className="mb-2"
/>
<p className="text-muted-foreground pl-7 text-sm leading-relaxed">
<p className="text-muted-foreground text-sm leading-relaxed">
{t("settings.sections.environment.disabledHint")}
</p>
</SettingsCard>
@@ -227,12 +226,11 @@ export function EnvironmentSection() {
<SettingsCardHeader
icon={MapPin}
title={t("settings.sections.environment.home.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
{t("settings.sections.environment.home.description")}
</p>
<div className="space-y-3 pl-7">
<div className="space-y-3">
{home ? (
<div className="space-y-1">
<p className="text-sm" data-slot="environment-home-current">
@@ -315,12 +313,11 @@ export function EnvironmentSection() {
<SettingsCardHeader
icon={Plane}
title={t("settings.sections.environment.travel.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
{t("settings.sections.environment.travel.description")}
</p>
<div className="space-y-3 pl-7">
<div className="space-y-3">
{data && data.travel.length > 0 ? (
<ul className="divide-border divide-y rounded-md border">
{data.travel.map((tr) => (
@@ -459,12 +456,11 @@ export function EnvironmentSection() {
<SettingsCardHeader
icon={CloudSun}
title={t("settings.sections.environment.backfill.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
{t("settings.sections.environment.backfill.description")}
</p>
<div className="flex flex-wrap items-end gap-2 pl-7">
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label htmlFor="env-backfill-start">
{t("settings.sections.environment.startDate")}
@@ -499,7 +495,7 @@ export function EnvironmentSection() {
</Button>
</div>
{data && (
<p className="text-muted-foreground mt-3 pl-7 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.sections.environment.storedDays", {
count: data.context.days,
})}
@@ -509,7 +505,7 @@ export function EnvironmentSection() {
<p
role="status"
data-testid="environment-fetch-failed"
className="text-destructive mt-1 pl-7 text-xs"
className="text-destructive mt-1 text-xs"
>
{tCount(
"settings.sections.environment.fetchFailed",
+2 -2
View File
@@ -144,8 +144,8 @@ function ExportCardShell({
</span>
}
/>
{children && <div className="mt-3 space-y-3">{children}</div>}
<div className="mt-4 flex flex-wrap items-center gap-3">{footer}</div>
{children && <div className="space-y-3">{children}</div>}
<div className="flex flex-wrap items-center gap-3">{footer}</div>
</SettingsCard>
);
}
@@ -70,7 +70,7 @@ export function GlucoseReferenceCard() {
title={t("settings.glucoseReference.title")}
description={t("settings.glucoseReference.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="glucose-reference-diabetes" className="text-sm">
{t("settings.glucoseReference.enable")}
@@ -25,7 +25,7 @@ export function ImportCardShell({
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
<div className="flex flex-1 flex-col gap-3">{children}</div>
</SettingsCard>
);
}
@@ -73,7 +73,7 @@ export function InjectionSitesCard({
title={t("settings.globalExcludedInjectionSitesLabel")}
description={t("settings.globalExcludedInjectionSitesHint")}
/>
<div className="mt-4 grid grid-cols-1 gap-2 pl-7 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{INJECTION_SITE_KEYS.map((site) => (
<label key={site} className="flex items-center gap-2 text-sm">
<Checkbox
@@ -257,7 +257,6 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
id={id}
data-slot="insights-pill-order-section"
aria-labelledby="insights-pill-order-title"
className="space-y-4"
>
<SettingsCardHeader
icon={ListOrdered}
@@ -160,12 +160,9 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{isError && (
<IntegrationErrorMessage
message={t("settings.appleHealth.status.unavailable")}
@@ -243,12 +243,9 @@ export function FitbitCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
@@ -415,12 +415,9 @@ export function GoogleHealthCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Re-consent CTA — distinct from parked/disconnected. Google expires
@@ -95,7 +95,7 @@ export function IntegrationFallbackRow({
/>
}
/>
<p className="text-muted-foreground mt-4 pl-7 text-xs">{meta}</p>
<p className="text-muted-foreground text-xs">{meta}</p>
</SettingsCard>
);
}
@@ -216,12 +216,9 @@ export function NightscoutCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{operatorApprovalRequired && (
<div
@@ -285,12 +285,9 @@ export function OAuthProviderCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && (
<div data-testid={`${provider}-error`}>
<IntegrationErrorMessage message={errorMessage} />
@@ -224,12 +224,9 @@ export function WhoopCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
@@ -236,12 +236,9 @@ export function WithingsCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
Only paints when (a) the user has an active Withings
+5 -5
View File
@@ -243,7 +243,7 @@ export function LowStockCard({
}
/>
{enabled && (
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="low-stock-days" className="text-sm font-medium">
{t("notifications.lowStock.daysLabel")}
</label>
@@ -273,7 +273,7 @@ export function LowStockCard({
users never touch. A single medication can override this on its
own supply tab. */}
{enabled && (
<div className="mt-4 pl-7">
<div>
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="low-stock-lead" className="text-sm font-medium">
{t("notifications.lowStock.leadLabel")}
@@ -309,8 +309,8 @@ export function LowStockCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
@@ -318,7 +318,7 @@ export function LowStockCard({
)}
{/* v1.16.11 — back-link to the medications list, the surface the
alert is about (the supply tab links here the same way). */}
<p className="mt-3 pl-7">
<p>
<Link
href="/medications"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
@@ -108,7 +108,7 @@ export function ManagedIntegrationStatus() {
description={t("settings.sharedRecord.integrationStatusDescription")}
/>
<ul
className="mt-4 space-y-3"
className="space-y-3"
aria-label={activeRecord?.displayName ?? undefined}
>
{statusQuery.data.integrations.map((integration) => (
@@ -1011,7 +1011,7 @@ export function ManagedRecordSettingsSection({
settings={query.data.settings}
/>
{save.isError || validationFailed ? (
<p className="text-destructive mt-3 text-sm" role="alert">
<p className="text-destructive text-sm" role="alert">
{t("common.networkError")}
</p>
) : null}
+3 -3
View File
@@ -108,7 +108,7 @@ function McpConnectionsCard() {
description={t("settings.mcp.connectionsDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{list.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
{t("settings.mcp.noConnections")}
@@ -223,7 +223,7 @@ function McpEnableCard() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{t("settings.mcp.enableToggleLabel")}
@@ -337,7 +337,7 @@ function McpTokensCard() {
description={t("settings.mcp.tokensDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("settings.mcp.scopeNote")}
</p>
@@ -80,7 +80,7 @@ export function MedicationsSection() {
<div className="space-y-6">
{/* View preference — cards vs table. The shared header toggle
writes optimistically, so there is no Save button here. */}
<SettingsCard id="medications-view" className="scroll-mt-28 space-y-4">
<SettingsCard id="medications-view" className="scroll-mt-28">
<SettingsCardHeader
icon={LayoutGrid}
title={t("medications.viewToggleLabel")}
@@ -98,7 +98,7 @@ export function MedicationsSection() {
</SettingsCard>
{/* Manual order — applies to both list views. */}
<SettingsCard id="medications-order" className="scroll-mt-28 space-y-4">
<SettingsCard id="medications-order" className="scroll-mt-28">
<SettingsCardHeader
icon={Pill}
title={t("medications.reorderTitle")}
+1 -1
View File
@@ -206,7 +206,7 @@ export function ModulesSection() {
title={t("settings.sections.modules.toggleable.title")}
description={t("settings.sections.modules.toggleable.description")}
/>
<div className="divide-border mt-4 divide-y pl-7">
<div className="divide-border divide-y">
{(Object.keys(MODULE_REGISTRY) as ModuleKey[])
// Modules switched off in code (pending a rebuild) carry no live
// toggle — drop the row entirely so a user can't turn one on.
@@ -202,7 +202,7 @@ export function MoodReminderCard({
}
/>
{enabled && (
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
{t("notifications.moodReminder.hourLabel")}
</label>
@@ -228,8 +228,8 @@ export function MoodReminderCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
+3 -3
View File
@@ -46,7 +46,7 @@ export function MoodSection() {
return (
<div className="space-y-6">
{/* Groups — picker group order + own groups. */}
<SettingsCard id="mood-groups" className="scroll-mt-28 space-y-4">
<SettingsCard id="mood-groups" className="scroll-mt-28">
<SettingsCardHeader
icon={Folders}
title={t("mood.manage.groupsTitle")}
@@ -55,13 +55,13 @@ export function MoodSection() {
</SettingsCard>
{/* Tags — visibility, order, edit, move, create. */}
<SettingsCard id="mood-tags" className="scroll-mt-28 space-y-4">
<SettingsCard id="mood-tags" className="scroll-mt-28">
<SettingsCardHeader icon={Tags} title={t("mood.manage.tagsTitle")} />
{isLoading || !catalog ? loading : <TagManagerCard catalog={catalog} />}
</SettingsCard>
{/* Archived custom tags — restore / hard delete. */}
<SettingsCard id="mood-archived" className="scroll-mt-28 space-y-4">
<SettingsCard id="mood-archived" className="scroll-mt-28">
<SettingsCardHeader
icon={Archive}
title={t("mood.manage.archivedTitle")}
@@ -156,7 +156,7 @@ export function NotificationStatusCard() {
/>
<ul
className="divide-border/60 mt-4 divide-y pl-7"
className="divide-border/60 divide-y"
data-testid="notification-status-list"
>
{channels.map((ch) => (
+1 -1
View File
@@ -128,7 +128,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
description={t("settings.ntfyDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
<Switch
@@ -77,10 +77,9 @@ export function NutrientIntakeCard() {
description={t("settings.sections.sources.nutrients.description", {
days: WINDOW_DAYS,
})}
className="mb-3"
/>
{overview.isLoading ? (
<div className="space-y-1 pl-7" aria-hidden="true">
<div className="space-y-1" aria-hidden="true">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
@@ -118,7 +117,7 @@ export function NutrientIntakeCard() {
))}
</ul>
)}
<p className="text-muted-foreground mt-3 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.nutrients.moduleHint")}{" "}
<Link
href="/settings/modules"
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more