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

@@ -0,0 +1,173 @@
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { Bell } from "lucide-react";
import { I18nProvider } from "@/lib/i18n/context";
import { PageHeader } from "@/components/ui/page-header";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { SettingsCardActions } from "@/components/settings/_card-actions";
/**
* Settings + Admin read as one surface. These pin the parts of that claim
* that can be checked exactly:
*
* 1. the header→body step is owned by `SettingsCard`, so a call site can
* neither re-declare it nor drift from it;
* 2. the card header's description slot carries no vertical rhythm — the
* licence call sites used to stack explainer paragraphs into a muted
* `text-xs` slot;
* 3. the action row has one shape, and its one documented exception
* (a card whose only actions are links out) is spelled as a prop;
* 4. `pl-7` — the off-scale body gutter that broke wherever two
* neighbouring cards disagreed about it — stays gone;
* 5. both consoles render their page heading through `PageHeader`, so
* Settings and Admin cannot drift a step apart from the module pages
* or from each other.
*/
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
const ROOT = process.cwd();
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "__tests__") continue;
walk(full, out);
} else if (entry.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
describe("SettingsCard — the container owns the rhythm", () => {
it("paints the gap-based shell, not a call-site margin", () => {
const html = render(<SettingsCard>body</SettingsCard>);
expect(html).toContain("flex flex-col gap-4");
expect(html).toContain("p-4");
expect(html).toContain("md:p-6");
});
it("gives the semantic-landmark variant the same rhythm", () => {
const html = render(<SettingsCard as="section">body</SettingsCard>);
expect(html).toContain("<section");
expect(html).toContain("flex flex-col gap-4");
});
it("still lets a dense card override the gap on the card itself", () => {
const html = render(<SettingsCard className="gap-2">body</SettingsCard>);
expect(html).toContain("gap-2");
});
});
describe("SettingsCardHeader — one description sentence, one text run", () => {
it("renders the description without any vertical rhythm of its own", () => {
const html = render(
<SettingsCardHeader icon={Bell} title="Channels" description="One." />,
);
// The slot is muted meta at the size floor…
expect(html).toContain("text-muted-foreground text-xs");
// …and carries no `space-y-*`, so a stack of <p>s cannot read as prose.
const slot = html.slice(html.indexOf("text-muted-foreground text-xs"));
expect(slot.slice(0, 60)).not.toMatch(/space-y-/);
});
it("keeps the title in the foreground tier at one size", () => {
const html = render(<SettingsCardHeader icon={Bell} title="Channels" />);
expect(html).toMatch(/<h2[^>]*class="text-lg font-semibold"/);
});
});
describe("SettingsCardActions — one row, one shape", () => {
it("puts the row at the trailing edge by default", () => {
const html = render(
<SettingsCardActions>
<button>Save</button>
</SettingsCardActions>,
);
expect(html).toContain('data-slot="settings-card-actions"');
expect(html).toContain("justify-end");
});
it("spells the link-row exception as a prop rather than a local class", () => {
const html = render(
<SettingsCardActions align="start">
<a href="/x">Open</a>
</SettingsCardActions>,
);
expect(html).toContain("justify-start");
expect(html).not.toContain("justify-end");
});
});
describe("PageHeader — the one page heading, including the shells", () => {
it("renders the explainer in the muted tier", () => {
const html = render(<PageHeader title="Settings" description="Yours." />);
expect(html).toMatch(/<p class="text-muted-foreground text-sm">Yours\./);
});
it("renders a second copy as a heading role, never a second <h1>", () => {
const html = render(
<PageHeader headingAs="div" title="Settings" description="Yours." />,
);
expect(html).not.toContain("<h1");
expect(html).toContain('role="heading"');
expect(html).toContain('aria-level="1"');
});
it("never truncates the explainer", () => {
const html = render(
<PageHeader title="Medications" description="Schedules and reminders." />,
);
expect(html).not.toContain("truncate");
});
});
describe("the Settings and Admin shells route their heading through PageHeader", () => {
for (const shell of [
join("src", "components", "settings", "settings-shell.tsx"),
join("src", "components", "admin", "admin-shell.tsx"),
]) {
it(`${shell} has no hand-rolled H1 block`, () => {
const source = readFileSync(join(ROOT, shell), "utf8");
expect(source).toContain("<PageHeader");
// The hand-rolled copies were a literal `<h1 className="text-2xl …">`
// plus a `role="heading"` div beside it. Both belong to the primitive.
const code = source
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
expect(code).not.toMatch(/<h1\b/);
expect(code).not.toMatch(/role="heading"/);
});
}
});
describe("the abolished body gutter stays abolished", () => {
it("has no `pl-7` left under Settings or Admin", () => {
const offenders: string[] = [];
for (const dir of ["settings", "admin"]) {
for (const file of walk(join(ROOT, "src", "components", dir))) {
const source = readFileSync(file, "utf8");
for (const [i, line] of source.split("\n").entries()) {
// Comments may name the class they retired; class strings may not.
if (line.trimStart().startsWith("*")) continue;
if (line.includes("//")) continue;
if (/\bpl-7\b/.test(line)) {
offenders.push(`${file.slice(ROOT.length + 1)}:${i + 1}`);
}
}
}
}
expect(offenders).toEqual([]);
});
});