refactor(admin): the console speaks the Settings vocabulary without an accent

Twenty-six of twenty-eight admin files already used the Settings card and
header. What made the console read as its own system was everything on top of
that: three hand-rolled cards, a header slot that meant five different things,
and a heading the frame took but never painted.

- `SectionFrame` drops `title` and `subtitle`. Seventeen call sites passed
  both into a component that has rendered neither since v1.18.6.1 — the shell
  reads the same keys itself. A prop contract nothing honours is a promise the
  next reader believes.
- The invite card, the four host-metric headers, and the `/admin` welcome
  card stop hand-rolling `bg-card border rounded-xl` shells with their own
  padding steps and their own heading weights. `text-sm` as a card title on
  the first card of a page, directly above a correct one, was the first thing
  the eye landed on.
- The header status slot carries status. The backups run, the invite create,
  the danger-zone wipe, and the user filters come out of it; the app-log
  refresh stays but reads as the secondary it is.
- Loading states paint their card and header. Encryption returned a bare
  spinner row and then a card, so the section jumped when the status landed —
  the fix `coach-feedback-section.tsx` already carried, applied where it was
  missing. Same for the notification-status card.
- One label/value tile. `StatusItem`, the encryption section's private `Stat`,
  the user-management inline panels and the backups upload box were four
  fills and three paddings for one role.
- The overview stopped printing the running version, build SHA and build time
  twice, thirty pixels apart. The version tile owns build identity — it is the
  one that checks for a newer release; the snapshot owns what is running.
- `ai-server-key` had three paragraphs stacked in a muted `text-xs` slot;
  one sentence stays there and the two that carry information move into the
  body as foreground. The central-codex consent copy moves the same way, and
  its disconnect is `variant="destructive"` — which `ConfirmButton` refused to
  accept, so every destructive confirm in the tree had been hand-tinting an
  outline instead.
- Leftovers: the `-mt-2` correcting the card's own rhythm, two alpha hovers on
  text, `text-[10px]` badges, the redundant `space-y-6` wrapper, the empty
  state repeating the header's CTA, and the `mt-6` a card added inside a
  frame that already spaces its children.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 19:55:47 +02:00
1 parent b2e1aa0cde
commit 330f60115a
27 files changed
+472 -569

No files matched your search

+66 -56
View File
@@ -27,6 +27,8 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { StatTile } from "@/components/admin/_stat-tile";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -99,20 +101,31 @@ export function EncryptionSection() {
},
});
if (statusQuery.isLoading) {
// The card + header stay rendered across every query state so the heading
// keeps a constant Y-offset — the loading branch used to return a bare
// spinner row and the error branch a bare paragraph, so the section jumped
// downward the moment the status resolved. Same shape as
// `coach-feedback-section.tsx`, which fixed this for itself and never had
// the fix carried across.
if (statusQuery.isLoading || statusQuery.isError || !statusQuery.data) {
return (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
);
}
if (statusQuery.isError || !statusQuery.data) {
return (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
<SettingsCard>
<SettingsCardHeader
icon={ShieldCheck}
title={t("admin.section.encryption.coverageTitle")}
description={t("admin.section.encryption.coverageDescription")}
/>
{statusQuery.isLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
{t("admin.section.encryption.loading")}
</div>
) : (
<p role="alert" className="text-destructive text-sm">
{t("admin.section.encryption.loadError")}
</p>
)}
</SettingsCard>
);
}
@@ -187,46 +200,48 @@ export function EncryptionSection() {
)}
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={running}
data-testid="admin-encryption-rotate"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("admin.section.encryption.rotateNow")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.encryption.rotateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.encryption.rotateConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.encryption.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => rotate.mutate()}>
{t("admin.section.encryption.rotateConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<p className="text-muted-foreground text-xs">
{t("admin.section.encryption.cliNote")}
</p>
<SettingsCardActions>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={running}
data-testid="admin-encryption-rotate"
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)}
{t("admin.section.encryption.rotateNow")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("admin.section.encryption.rotateConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("admin.section.encryption.rotateConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("admin.section.encryption.cancel")}
</AlertDialogCancel>
<AlertDialogAction onClick={() => rotate.mutate()}>
{t("admin.section.encryption.rotateConfirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
{/* ── Per-column coverage table ──────────────────────────────── */}
@@ -286,10 +301,5 @@ export function EncryptionSection() {
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="bg-muted/40 rounded-lg px-3 py-2">
<p className="text-muted-foreground text-xs">{label}</p>
<p className="text-foreground font-mono text-sm font-medium">{value}</p>
</div>
);
return <StatTile label={label} value={value} valueClassName="font-mono" />;
}