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

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