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

+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. */}