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

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