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

+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")}