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([]);
});
});
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* `<SettingsCardActions>` — the action row of a Settings or Admin card.
*
* The audit found four positions (bottom-right, bottom-left, the header's
* status slot, inline mid-card) and five variants carrying the SAME role —
* "the primary action of this card" — sometimes two of them in one file. The
* two sections that already read right (`score-section.tsx`,
* `coach-prefs-section.tsx`) agreed on one shape, and this is it.
*
* The contract (design standards §12):
* - The row is the LAST thing in the card. Status lines and results sit
* above it; a result set big enough to need a frame becomes its own card.
* - Primary action last (rightmost), `variant` default, `size="sm"` with
* the `min-h-11` mobile tap floor. One per card.
* - Secondary action `outline`, before the primary in the same row.
* - Destructive card-wide action: same row, `variant="destructive"` —
* never an `outline` hand-tinted with `text-destructive`.
*
* `align="start"` is the one documented exception: a card whose only actions
* are deep links out of it (privacy, about, export) has no primary action, so
* its links read with the text on the left rather than against it.
*/
export function SettingsCardActions({
align = "end",
className,
children,
...props
}: React.ComponentProps<"div"> & { align?: "start" | "end" }) {
return (
<div
data-slot="settings-card-actions"
className={cn(
"flex flex-wrap items-center gap-2",
align === "end" ? "justify-end" : "justify-start",
className,
)}
{...props}
>
{children}
</div>
);
}
+11 -6
View File
@@ -47,9 +47,13 @@ export interface SettingsCardHeaderProps {
* after the title (e.g. a tag chip + experimental badge). The title
* row wraps so the accessories reflow below on a narrow viewport. */
titleAccessory?: React.ReactNode;
/** Optional short description rendered as muted text below the
* title row. Either a string (paragraph) or arbitrary node
* (links, badges, extra sub-notes, etc.). */
/** Optional description rendered as muted text below the title row.
* Exactly ONE sentence, guideline ≤ 120 characters (design standards
* §3). A node is accepted so the sentence can carry an inline link,
* not so it can carry a second paragraph — the slot owns no vertical
* rhythm, so a stack of `<p>`s collapses into one run and reads
* broken. Multi-sentence prose goes in the body as
* `<p className="text-sm">` (foreground). */
description?: React.ReactNode;
/** Optional right-aligned status surface — typically an
* `<IntegrationStatusPill>` or a wrapper around badges. */
@@ -88,10 +92,11 @@ export function SettingsCardHeader({
<div className="flex shrink-0 items-center gap-2">{status}</div>
) : null}
</div>
{/* One sentence, one text run. This slot deliberately carries no
`space-y-*`: the vertical rhythm was the licence call sites used
to stack explainer paragraphs into a muted `text-xs` slot. */}
{description ? (
<div className="text-muted-foreground space-y-1 text-xs">
{description}
</div>
<div className="text-muted-foreground text-xs">{description}</div>
) : null}
</div>
</header>
+5 -6
View File
@@ -195,7 +195,6 @@ export function AboutMeSection({
as="section"
aria-labelledby="settings-ai-about-me-title"
data-testid="settings-about-me-card"
className="space-y-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<SettingsCardHeader
@@ -229,7 +228,7 @@ export function AboutMeSection({
</div>
</div>
<div className="pl-7">
<div>
{structuredField(
"allergies",
t("settings.ai.aboutMe.allergiesLabel"),
@@ -237,7 +236,7 @@ export function AboutMeSection({
)}
</div>
<div className="space-y-1.5 pl-7">
<div className="space-y-1.5">
<Label htmlFor="settings-about-me-freetext">
{t("settings.ai.aboutMe.freeTextLabel")}
</Label>
@@ -255,7 +254,7 @@ export function AboutMeSection({
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 pl-7">
<div className="flex flex-wrap items-center justify-between gap-3">
<p
data-testid="settings-about-me-count"
className="text-muted-foreground text-xs tabular-nums"
@@ -339,14 +338,14 @@ export function AboutMeSection({
</p>
)}
<p className="text-muted-foreground border-border border-t pt-3 pl-7 text-xs">
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
{/* v1.25.12 — cross-link to the Anamnese (medical-history) home, where the
chronic conditions the Coach watches now live alongside allergies +
family history as one coherent medical history. */}
<p className="text-muted-foreground pl-7 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.ai.aboutMe.recordsLink")}{" "}
<Link
href="/settings/anamnesis"
+3 -5
View File
@@ -197,18 +197,17 @@ export function AboutSection() {
icon={Info}
title="HealthLog"
description={t("settings.about.identityDescription")}
className="mb-4"
/>
{isLoading || !version ? (
<div className="flex items-center gap-2 pl-7">
<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("common.loading")}
</span>
</div>
) : (
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 pl-7 text-sm">
<dl className="flex flex-wrap items-baseline gap-x-6 gap-y-3 text-sm">
<div className="flex items-baseline gap-2">
<dt className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.about.version")}
@@ -272,9 +271,8 @@ export function AboutSection() {
icon={BookOpen}
title={t("settings.about.linksHeading")}
description={t("settings.about.linksDescription")}
className="mb-4"
/>
<div className="flex flex-col gap-2 pl-7 sm:flex-row sm:flex-wrap">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<Button
asChild
variant="outline"
@@ -148,7 +148,7 @@ export function GrantInviteCard() {
title={t("recordSharing.invite.title")}
description={t("recordSharing.invite.description")}
/>
<form onSubmit={submit} className="mt-4 space-y-3">
<form onSubmit={submit} className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="grant-invite-identifier">
{t("recordSharing.invite.identifierLabel")}
@@ -52,7 +52,7 @@ export function GrantsGivenCard() {
title={t("recordSharing.given.title")}
description={t("recordSharing.given.description")}
/>
<div className="mt-4">
<div>
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
@@ -68,7 +68,7 @@ export function GrantsReceivedCard() {
title={t("recordSharing.received.title")}
description={t("recordSharing.received.description")}
/>
<div className="mt-4">
<div>
{isLoading && (
<Loader2
className="text-muted-foreground size-5 animate-spin motion-reduce:animate-none"
@@ -75,7 +75,7 @@ export function ManagedProfileCard() {
title={t("recordSharing.managed.title")}
description={t("recordSharing.managed.description")}
/>
<div className="mt-4 space-y-4">
<div className="space-y-4">
{/* Content, not meta: this is what somebody is consenting to take on,
and UI-STANDARDS §3 reserves muted for the incidental. */}
<p data-slot="managed-profile-explainer" className="text-sm">
@@ -50,7 +50,7 @@ export function RecordActivityCard() {
title={t("recordSharing.activity.title")}
description={t("recordSharing.activity.description")}
/>
<div className="mt-4 space-y-3">
<div className="space-y-3">
{/* The window, stated. Rendered only once the server has said what it
is — a placeholder number here would be exactly the invented value
the sentence exists to avoid. */}
@@ -108,20 +108,16 @@ export function AvatarSection() {
return (
<SettingsCard>
<SettingsCardHeader
icon={ImageUp}
title={t("settings.avatar.title")}
className="mb-4"
/>
<SettingsCardHeader icon={ImageUp} title={t("settings.avatar.title")} />
{/* The accepted formats and limits belong to the sentence that explains
what this card does, not to a caption of their own under the buttons.
Two strings rather than one merged key: the same wording is the
upload's own constraint copy, and a translator keeps the shorter unit
either way. */}
<p className="text-muted-foreground mb-4 pl-7 text-sm">
<p className="text-muted-foreground mb-4 text-sm">
{t("settings.avatar.description")} {t("settings.avatar.hint")}
</p>
<div className="flex items-center gap-4 pl-7">
<div className="flex items-center gap-4">
<Avatar className="size-16">
{avatarUrl && <AvatarImage src={avatarUrl} alt={user.username} />}
<AvatarFallback className="text-base">
@@ -362,12 +362,8 @@ export function AccountSection() {
<AvatarSection />
<SettingsCard>
<SettingsCardHeader
icon={User}
title={t("settings.profile")}
className="mb-4"
/>
<form onSubmit={handleSaveProfile} className="space-y-4 pl-7">
<SettingsCardHeader icon={User} title={t("settings.profile")} />
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="username">{t("settings.username")}</Label>
+3 -3
View File
@@ -151,7 +151,7 @@ function ResearchModeCard() {
<div
role="alert"
data-slot="settings-research-mode-reprompt"
className="border-warning/40 bg-warning/10 mt-4 rounded-md border-l-4 px-3 py-2 text-sm"
className="border-warning/40 bg-warning/10 rounded-md border-l-4 px-3 py-2 text-sm"
>
<p className="text-foreground font-medium">
{t("settings.researchMode.rePromptTitle")}
@@ -171,7 +171,7 @@ function ResearchModeCard() {
</div>
)}
<div className="mt-4 flex items-start justify-between gap-3">
<div className="flex items-start justify-between gap-3">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.researchMode.toggleLabel")}
@@ -197,7 +197,7 @@ function ResearchModeCard() {
{errorMessage && (
<p
role="alert"
className="text-destructive mt-3 text-sm"
className="text-destructive text-sm"
data-slot="settings-research-mode-error"
>
{errorMessage}
@@ -140,13 +140,13 @@ export function AiInsightsCard({
}
return (
<SettingsCard className="space-y-4">
<SettingsCard>
{/* The card used to render an icon-only header row (Sparkles +
status badges, no title), which left the tile unanchored next
to its titled siblings. It now follows the shared
`SettingsCardHeader` contract: icon column, title + description
in the content column, status badges top-right — and the body
below indents to the title column (`pl-7`). */}
starts on the card edge like every other Settings card. */}
<SettingsCardHeader
icon={Sparkles}
title={t("settings.ai.providerCardTitle")}
@@ -159,7 +159,7 @@ export function AiInsightsCard({
}
/>
<div className="space-y-4 pl-7">
<div className="space-y-4">
<ActiveProviderSelect
value={selectedProvider}
onChange={pickProvider}
@@ -169,8 +169,8 @@ export function DisableCoachCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-xs"
: "text-muted-foreground mt-3 pl-7 text-xs"
? "text-destructive mt-3 text-xs"
: "text-muted-foreground mt-3 text-xs"
}
>
{msg}
@@ -44,7 +44,7 @@ export function AnamnesisSection() {
return (
<div className="space-y-6">
{(coachEnabled || insightsEnabled) && (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={EyeOff}
title={t("records.aiInclusion.cardTitle")}
@@ -55,7 +55,7 @@ export function AnamnesisSection() {
)}
{coachEnabled && (
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={HeartPulse}
title={t("records.conditions.cardTitle")}
@@ -64,7 +64,7 @@ export function AnamnesisSection() {
<ConditionsManager />
</SettingsCard>
)}
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ClipboardList}
title={t("records.profileFacts.cardTitle")}
@@ -73,7 +73,7 @@ export function AnamnesisSection() {
<HealthProfileFactsManager />
</SettingsCard>
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ShieldAlert}
title={t("records.allergies.cardTitle")}
@@ -82,7 +82,7 @@ export function AnamnesisSection() {
<AllergyManager />
</SettingsCard>
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Users}
title={t("records.family.cardTitle")}
+3 -3
View File
@@ -75,7 +75,7 @@ function ApiEndpointsCard() {
/>
{/* Desktop table — verbatim layout for md+. */}
<div className="border-border mt-4 hidden overflow-x-auto rounded-lg border md:block">
<div className="border-border hidden overflow-x-auto rounded-lg border md:block">
<table className="w-full min-w-[760px] text-xs md:min-w-0">
<thead>
<tr className="bg-muted/40 text-muted-foreground border-b">
@@ -109,7 +109,7 @@ function ApiEndpointsCard() {
{/* Mobile card list — each endpoint stacks its columns vertically
with a labelled meta row. No horizontal scroll. */}
<ul
className="mt-4 space-y-2 md:hidden"
className="space-y-2 md:hidden"
data-testid="settings-api-endpoints-mobile-list"
>
{endpoints.map((endpoint) => (
@@ -206,7 +206,7 @@ function ApiTokensCard() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{/* Tokens are no longer minted here. The generic mint issued a
`medication:ingest` token that the ingest endpoint refused (it
gates on the per-medication grant too) and that every other
@@ -141,7 +141,6 @@ export function CoachMemorySection({
as="section"
aria-labelledby="settings-ai-coach-memory-title"
data-testid="settings-coach-memory-card"
className="space-y-4"
>
<SettingsCardHeader
icon={Brain}
+4 -4
View File
@@ -225,7 +225,7 @@ export function CoachNudgeCard({
}
/>
{enabled && (
<div className="mt-4 space-y-3 pl-7">
<div className="space-y-3">
<p className="text-sm font-medium">
{t("notifications.coachNudge.groupsLabel")}
</p>
@@ -307,7 +307,7 @@ export function CoachNudgeCard({
push-nudge master switch above (a user can keep nudges but silence
the example prompts), so it sits outside the `enabled` gate and rides
the same `coach` prefs blob. */}
<div className="border-border/60 mt-4 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
<div className="border-border/60 flex min-h-11 items-center justify-between gap-3 border-t pt-4">
<div className="min-w-0">
<p className="text-sm">
{t("notifications.coachNudge.suggestionsLabel")}
@@ -331,8 +331,8 @@ export function CoachNudgeCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
@@ -120,10 +120,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
description={t("insights.coach.settingsDescription")}
/>
{isLoading ? (
<div
data-slot="coach-prefs-skeleton"
className="mt-4 flex flex-col gap-4 pl-7"
>
<div data-slot="coach-prefs-skeleton" className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-9 w-full" />
@@ -139,7 +136,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
) : (
<div className="mt-4 flex flex-col gap-4 pl-7">
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
@@ -338,7 +335,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
)}
<div className="mt-4 flex justify-end pl-7">
<div className="flex justify-end">
<Button
type="button"
size="sm"
@@ -155,7 +155,6 @@ export function CoachRemindersSection({
as="section"
aria-labelledby="settings-ai-coach-reminders-title"
data-testid="settings-coach-reminders-card"
className="space-y-4"
>
<SettingsCardHeader
icon={BellRing}
@@ -66,7 +66,7 @@ export function CycleTrackingCard({
title={t("settings.cycleTracking.title")}
description={t("settings.cycleTracking.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="cycle-tracking-enable" className="text-sm">
{t("settings.cycleTracking.enable")}
@@ -440,7 +440,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
const dirty = draft !== null && layout !== null;
return (
<SettingsCard id={id} className="scroll-mt-28 space-y-4">
<SettingsCard id={id} className="scroll-mt-28">
{/* v1.4.19 A6 — title / action row uses the same stack-on-mobile,
right-align-on-desktop contract as Account → Password +
Restart onboarding tour. Avoids the long German "Auf Standard
+1 -1
View File
@@ -120,7 +120,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
description={t("settings.emailDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="email-toggle">{t("settings.emailEnable")}</Label>
<Switch
@@ -203,9 +203,8 @@ export function EnvironmentSection() {
<SettingsCardHeader
icon={CloudSun}
title={t("settings.sections.environment.title")}
className="mb-2"
/>
<p className="text-muted-foreground pl-7 text-sm leading-relaxed">
<p className="text-muted-foreground text-sm leading-relaxed">
{t("settings.sections.environment.disabledHint")}
</p>
</SettingsCard>
@@ -227,12 +226,11 @@ export function EnvironmentSection() {
<SettingsCardHeader
icon={MapPin}
title={t("settings.sections.environment.home.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
{t("settings.sections.environment.home.description")}
</p>
<div className="space-y-3 pl-7">
<div className="space-y-3">
{home ? (
<div className="space-y-1">
<p className="text-sm" data-slot="environment-home-current">
@@ -315,12 +313,11 @@ export function EnvironmentSection() {
<SettingsCardHeader
icon={Plane}
title={t("settings.sections.environment.travel.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
{t("settings.sections.environment.travel.description")}
</p>
<div className="space-y-3 pl-7">
<div className="space-y-3">
{data && data.travel.length > 0 ? (
<ul className="divide-border divide-y rounded-md border">
{data.travel.map((tr) => (
@@ -459,12 +456,11 @@ export function EnvironmentSection() {
<SettingsCardHeader
icon={CloudSun}
title={t("settings.sections.environment.backfill.title")}
className="mb-2"
/>
<p className="text-muted-foreground mb-3 pl-7 text-sm leading-relaxed">
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
{t("settings.sections.environment.backfill.description")}
</p>
<div className="flex flex-wrap items-end gap-2 pl-7">
<div className="flex flex-wrap items-end gap-2">
<div className="space-y-1">
<Label htmlFor="env-backfill-start">
{t("settings.sections.environment.startDate")}
@@ -499,7 +495,7 @@ export function EnvironmentSection() {
</Button>
</div>
{data && (
<p className="text-muted-foreground mt-3 pl-7 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.sections.environment.storedDays", {
count: data.context.days,
})}
@@ -509,7 +505,7 @@ export function EnvironmentSection() {
<p
role="status"
data-testid="environment-fetch-failed"
className="text-destructive mt-1 pl-7 text-xs"
className="text-destructive mt-1 text-xs"
>
{tCount(
"settings.sections.environment.fetchFailed",
+2 -2
View File
@@ -144,8 +144,8 @@ function ExportCardShell({
</span>
}
/>
{children && <div className="mt-3 space-y-3">{children}</div>}
<div className="mt-4 flex flex-wrap items-center gap-3">{footer}</div>
{children && <div className="space-y-3">{children}</div>}
<div className="flex flex-wrap items-center gap-3">{footer}</div>
</SettingsCard>
);
}
@@ -70,7 +70,7 @@ export function GlucoseReferenceCard() {
title={t("settings.glucoseReference.title")}
description={t("settings.glucoseReference.description")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<Label htmlFor="glucose-reference-diabetes" className="text-sm">
{t("settings.glucoseReference.enable")}
@@ -25,7 +25,7 @@ export function ImportCardShell({
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
<div className="mt-3 flex flex-1 flex-col gap-3">{children}</div>
<div className="flex flex-1 flex-col gap-3">{children}</div>
</SettingsCard>
);
}
@@ -73,7 +73,7 @@ export function InjectionSitesCard({
title={t("settings.globalExcludedInjectionSitesLabel")}
description={t("settings.globalExcludedInjectionSitesHint")}
/>
<div className="mt-4 grid grid-cols-1 gap-2 pl-7 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{INJECTION_SITE_KEYS.map((site) => (
<label key={site} className="flex items-center gap-2 text-sm">
<Checkbox
@@ -257,7 +257,6 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
id={id}
data-slot="insights-pill-order-section"
aria-labelledby="insights-pill-order-title"
className="space-y-4"
>
<SettingsCardHeader
icon={ListOrdered}
@@ -160,12 +160,9 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{isError && (
<IntegrationErrorMessage
message={t("settings.appleHealth.status.unavailable")}
@@ -243,12 +243,9 @@ export function FitbitCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
@@ -415,12 +415,9 @@ export function GoogleHealthCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Re-consent CTA — distinct from parked/disconnected. Google expires
@@ -95,7 +95,7 @@ export function IntegrationFallbackRow({
/>
}
/>
<p className="text-muted-foreground mt-4 pl-7 text-xs">{meta}</p>
<p className="text-muted-foreground text-xs">{meta}</p>
</SettingsCard>
);
}
@@ -216,12 +216,9 @@ export function NightscoutCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{operatorApprovalRequired && (
<div
@@ -285,12 +285,9 @@ export function OAuthProviderCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && (
<div data-testid={`${provider}-error`}>
<IntegrationErrorMessage message={errorMessage} />
@@ -224,12 +224,9 @@ export function WhoopCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
state is `parked` (>24h of persistent failures). The button
@@ -236,12 +236,9 @@ export function WithingsCard({
}
/>
<hr
data-testid="integration-card-divider"
className="border-border/60 mt-4"
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
Only paints when (a) the user has an active Withings
+5 -5
View File
@@ -243,7 +243,7 @@ export function LowStockCard({
}
/>
{enabled && (
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="low-stock-days" className="text-sm font-medium">
{t("notifications.lowStock.daysLabel")}
</label>
@@ -273,7 +273,7 @@ export function LowStockCard({
users never touch. A single medication can override this on its
own supply tab. */}
{enabled && (
<div className="mt-4 pl-7">
<div>
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="low-stock-lead" className="text-sm font-medium">
{t("notifications.lowStock.leadLabel")}
@@ -309,8 +309,8 @@ export function LowStockCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
@@ -318,7 +318,7 @@ export function LowStockCard({
)}
{/* v1.16.11 — back-link to the medications list, the surface the
alert is about (the supply tab links here the same way). */}
<p className="mt-3 pl-7">
<p>
<Link
href="/medications"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
@@ -108,7 +108,7 @@ export function ManagedIntegrationStatus() {
description={t("settings.sharedRecord.integrationStatusDescription")}
/>
<ul
className="mt-4 space-y-3"
className="space-y-3"
aria-label={activeRecord?.displayName ?? undefined}
>
{statusQuery.data.integrations.map((integration) => (
@@ -1011,7 +1011,7 @@ export function ManagedRecordSettingsSection({
settings={query.data.settings}
/>
{save.isError || validationFailed ? (
<p className="text-destructive mt-3 text-sm" role="alert">
<p className="text-destructive text-sm" role="alert">
{t("common.networkError")}
</p>
) : null}
+3 -3
View File
@@ -108,7 +108,7 @@ function McpConnectionsCard() {
description={t("settings.mcp.connectionsDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{list.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm">
{t("settings.mcp.noConnections")}
@@ -223,7 +223,7 @@ function McpEnableCard() {
}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-medium">
{t("settings.mcp.enableToggleLabel")}
@@ -337,7 +337,7 @@ function McpTokensCard() {
description={t("settings.mcp.tokensDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("settings.mcp.scopeNote")}
</p>
@@ -80,7 +80,7 @@ export function MedicationsSection() {
<div className="space-y-6">
{/* View preference — cards vs table. The shared header toggle
writes optimistically, so there is no Save button here. */}
<SettingsCard id="medications-view" className="scroll-mt-28 space-y-4">
<SettingsCard id="medications-view" className="scroll-mt-28">
<SettingsCardHeader
icon={LayoutGrid}
title={t("medications.viewToggleLabel")}
@@ -98,7 +98,7 @@ export function MedicationsSection() {
</SettingsCard>
{/* Manual order — applies to both list views. */}
<SettingsCard id="medications-order" className="scroll-mt-28 space-y-4">
<SettingsCard id="medications-order" className="scroll-mt-28">
<SettingsCardHeader
icon={Pill}
title={t("medications.reorderTitle")}
+1 -1
View File
@@ -206,7 +206,7 @@ export function ModulesSection() {
title={t("settings.sections.modules.toggleable.title")}
description={t("settings.sections.modules.toggleable.description")}
/>
<div className="divide-border mt-4 divide-y pl-7">
<div className="divide-border divide-y">
{(Object.keys(MODULE_REGISTRY) as ModuleKey[])
// Modules switched off in code (pending a rebuild) carry no live
// toggle — drop the row entirely so a user can't turn one on.
@@ -202,7 +202,7 @@ export function MoodReminderCard({
}
/>
{enabled && (
<div className="mt-4 flex min-h-11 items-center gap-3 pl-7">
<div className="flex min-h-11 items-center gap-3">
<label htmlFor="mood-reminder-hour" className="text-sm font-medium">
{t("notifications.moodReminder.hourLabel")}
</label>
@@ -228,8 +228,8 @@ export function MoodReminderCard({
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 pl-7 text-sm"
: "text-muted-foreground mt-3 pl-7 text-sm"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
+3 -3
View File
@@ -46,7 +46,7 @@ export function MoodSection() {
return (
<div className="space-y-6">
{/* Groups — picker group order + own groups. */}
<SettingsCard id="mood-groups" className="scroll-mt-28 space-y-4">
<SettingsCard id="mood-groups" className="scroll-mt-28">
<SettingsCardHeader
icon={Folders}
title={t("mood.manage.groupsTitle")}
@@ -55,13 +55,13 @@ export function MoodSection() {
</SettingsCard>
{/* Tags — visibility, order, edit, move, create. */}
<SettingsCard id="mood-tags" className="scroll-mt-28 space-y-4">
<SettingsCard id="mood-tags" className="scroll-mt-28">
<SettingsCardHeader icon={Tags} title={t("mood.manage.tagsTitle")} />
{isLoading || !catalog ? loading : <TagManagerCard catalog={catalog} />}
</SettingsCard>
{/* Archived custom tags — restore / hard delete. */}
<SettingsCard id="mood-archived" className="scroll-mt-28 space-y-4">
<SettingsCard id="mood-archived" className="scroll-mt-28">
<SettingsCardHeader
icon={Archive}
title={t("mood.manage.archivedTitle")}
@@ -156,7 +156,7 @@ export function NotificationStatusCard() {
/>
<ul
className="divide-border/60 mt-4 divide-y pl-7"
className="divide-border/60 divide-y"
data-testid="notification-status-list"
>
{channels.map((ch) => (
+1 -1
View File
@@ -128,7 +128,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
description={t("settings.ntfyDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="ntfy-toggle">{t("settings.ntfyEnable")}</Label>
<Switch
@@ -77,10 +77,9 @@ export function NutrientIntakeCard() {
description={t("settings.sections.sources.nutrients.description", {
days: WINDOW_DAYS,
})}
className="mb-3"
/>
{overview.isLoading ? (
<div className="space-y-1 pl-7" aria-hidden="true">
<div className="space-y-1" aria-hidden="true">
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
<Skeleton className="h-9 w-full rounded-md" />
@@ -118,7 +117,7 @@ export function NutrientIntakeCard() {
))}
</ul>
)}
<p className="text-muted-foreground mt-3 text-xs">
<p className="text-muted-foreground text-xs">
{t("settings.sections.sources.nutrients.moduleHint")}{" "}
<Link
href="/settings/modules"
+8 -14
View File
@@ -81,9 +81,8 @@ export function PrivacySection() {
icon={ShieldCheck}
title={t("settings.privacy.encryption.title")}
description={t("settings.privacy.encryption.description")}
className="mb-4"
/>
<div className="space-y-3 pl-7">
<div className="space-y-3">
<p className="text-sm">
{summary
? t("settings.privacy.encryption.summary", {
@@ -102,9 +101,8 @@ export function PrivacySection() {
icon={Database}
title={t("settings.privacy.stored.title")}
description={t("settings.privacy.stored.description")}
className="mb-4"
/>
<ul className="text-muted-foreground list-disc space-y-1 pl-7 text-sm">
<ul className="text-muted-foreground list-disc space-y-1 text-sm">
<li>{t("settings.privacy.stored.measurements")}</li>
<li>{t("settings.privacy.stored.medications")}</li>
<li>{t("settings.privacy.stored.moodLabs")}</li>
@@ -120,20 +118,19 @@ export function PrivacySection() {
icon={Clock}
title={t("settings.privacy.retention.title")}
description={t("settings.privacy.retention.description")}
className="mb-4"
/>
{summaryLoading ? (
<div className="space-y-2 pl-7">
<div className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : summaryError || !summary ? (
<p className="text-muted-foreground pl-7 text-sm">
<p className="text-muted-foreground text-sm">
{t("settings.privacy.retention.unavailable")}
</p>
) : (
<ul className="text-muted-foreground space-y-1 pl-7 text-sm">
<ul className="text-muted-foreground space-y-1 text-sm">
<li>
{t("settings.privacy.retention.coach", {
days: summary.retention.coachMessagesDays,
@@ -159,9 +156,8 @@ export function PrivacySection() {
icon={Download}
title={t("settings.privacy.export.title")}
description={t("settings.privacy.export.description")}
className="mb-4"
/>
<div className="flex flex-wrap gap-2 pl-7">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/settings/export">
{t("settings.privacy.export.openExport")}
@@ -181,9 +177,8 @@ export function PrivacySection() {
icon={Trash2}
title={t("settings.privacy.delete.title")}
description={t("settings.privacy.delete.description")}
className="mb-4"
/>
<div className="space-y-3 pl-7">
<div className="space-y-3">
<SettingsInfoTile icon={Info} tone="warning">
{t("settings.privacy.delete.backupLag")}
</SettingsInfoTile>
@@ -201,9 +196,8 @@ export function PrivacySection() {
icon={Sparkles}
title={t("settings.privacy.posture.title")}
description={t("settings.privacy.posture.description")}
className="mb-4"
/>
<div className="flex flex-wrap gap-2 pl-7">
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/settings/ai">
{t("settings.privacy.posture.openAi")}
@@ -48,7 +48,7 @@ export function RecordAnamnesisSection() {
return (
<div className="space-y-6" data-slot="record-anamnesis-section">
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={ShieldAlert}
title={t("records.allergies.cardTitle")}
@@ -57,7 +57,7 @@ export function RecordAnamnesisSection() {
<AllergyManager />
</SettingsCard>
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Users}
title={t("records.family.cardTitle")}
@@ -184,7 +184,6 @@ export function HealthRecordExportPanel() {
data-testid="health-record-export-panel"
>
<SettingsCardHeader
className="mb-4"
icon={FileText}
titleId="health-record-export-title"
title={t("settings.healthRecord.title")}
@@ -76,7 +76,6 @@ export function ScoreChangeNotice({
data-slot="score-change-notice"
data-kind={kind}
aria-labelledby="score-change-notice-title"
className="space-y-4"
>
<SettingsCardHeader
icon={Info}
+7 -10
View File
@@ -15,11 +15,11 @@
* save all sit in the same card. A top half and a bottom half of the
* same decision is the split the settings IA rules forbid.
* - **The settings card anatomy, unmodified.** Header through
* `SettingsCardHeader`, body at `mt-4 pl-7` so every line starts on
* the title's reading edge, save bottom-right in its own row. The
* v1.35.0 cut hung the save in the header's status slot and let the
* body start at the card edge, which read as a different kind of card
* from the ones above and below it.
* `SettingsCardHeader`, the header→body step owned by the card's own
* gap, body on the card edge, save bottom-right in its own row via
* `SettingsCardActions`. The v1.35.0 cut hung the save in the header's
* status slot, which read as a different kind of card from the ones
* above and below it.
* - **Grouped by domain, and the grouping is spacing.** Blood pressure,
* glycaemia and lipids sit under one heading; the gap between groups
* does the separating. Rules between every row made a short list look
@@ -311,10 +311,7 @@ export function ScoreSection() {
description={t("settings.sections.score.card.description")}
/>
{/* The body starts on the title's reading edge — icon box plus
gap is 28 px, which is the `pl-7` every other settings card
uses. */}
<div className="mt-4 space-y-6 pl-7">
<div className="space-y-6">
{/* The sentence the whole feature exists for. Foreground, in
the body, because it is content and not a meta
description. */}
@@ -446,7 +443,7 @@ export function ScoreSection() {
{/* Bottom-right, in its own row, like every other settings card
that saves a form. */}
<div className="mt-4 flex justify-end pl-7">
<div className="flex justify-end">
<Button
size="sm"
type="button"
@@ -97,7 +97,7 @@ export function SecurityActivityCard({
/>
</div>
</button>
<div id={regionId} hidden={!open} className="space-y-4 pl-7">
<div id={regionId} hidden={!open} className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.security.activityDescription")}
</p>
@@ -185,7 +185,7 @@ export function PasskeyListSection({
description={t("settings.passkeysDescription")}
/>
<div className="mt-4">
<div>
{!passkeys || passkeys.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("settings.noPasskeys")}
@@ -137,7 +137,7 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
description={t("settings.security.keys.description")}
/>
<div className="mt-4">
<div>
{keys.length === 0 ? (
<EmptyState
variant="plain"
@@ -210,7 +210,7 @@ export function TotpCard({
}
/>
<div className="mt-4">
<div>
{/* ── Not enabled, no wizard: the enable CTA ── */}
{!enabled && !setup && !freshCodes && (
<Button
@@ -111,7 +111,7 @@ export function SecuritySessionsCard({
/>
</div>
</button>
<div id={regionId} hidden={!open} className="space-y-4 pl-7">
<div id={regionId} hidden={!open} className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.security.sessionsDescription")}
</p>
+14 -9
View File
@@ -14,17 +14,22 @@ import { cn } from "@/lib/utils";
* `sm:` (640 px) instead of `md:` (768 px), so on a ~700 px tablet Settings
* cards read denser than every other surface.
*
* The default renders the ui `<Card>` so the surface composes from one shape.
* Unlike the bare `<Card>` it does NOT impose the `flex flex-col gap` layout:
* Settings card bodies own their internal rhythm (`SettingsCardHeader` +
* `mt-4`/`space-y-*` bodies), so the container only owns the border, radius,
* background, and padding. Pass `className` for per-card extras
* (`scroll-mt-28`, `space-y-4`, `flex h-full flex-col`, …).
* The default renders the ui `<Card>` so the surface composes from one shape,
* and like `<Card>` the card owns its internal rhythm: `flex flex-col gap-4`.
* The header→body distance therefore comes from the container's gap, never
* from a margin a call site remembers to add. It used to come from three
* competing techniques at once — `mt-4` on the first body child (20 files),
* `space-y-4` on the card (9), `mb-3`/`mb-4` on the header (2) — which is
* exactly the drift that made neighbouring cards read as different systems.
* A denser card overrides the gap on the card itself (`className="gap-2"`),
* as `<Card>` documents; nothing else may re-declare the header→body step.
*
* Pass `className` for per-card extras (`scroll-mt-28`, `flex h-full`, …).
*
* `as="section"` keeps a card's semantic landmark element (a `<section>` with
* an `aria-labelledby`) while painting the same shape and padding contract.
* an `aria-labelledby`) while painting the same shape, rhythm, and padding.
*/
const SETTINGS_CARD_SHELL = "block gap-0 p-4 md:p-6";
const SETTINGS_CARD_SHELL = "flex flex-col gap-4 p-4 md:p-6";
type SettingsCardOwnProps = { className?: string };
@@ -45,7 +50,7 @@ export function SettingsCard<E extends React.ElementType = typeof Card>({
// Mirror the ui Card shell so a semantic landmark card paints
// identically to the default <Card>-backed one.
className={cn(
"bg-card text-card-foreground rounded-xl border p-4 shadow-sm md:p-6",
"bg-card text-card-foreground flex flex-col gap-4 rounded-xl border p-4 shadow-sm md:p-6",
className,
)}
{...props}
+18 -32
View File
@@ -43,6 +43,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 { useMounted } from "@/hooks/use-mounted";
@@ -533,40 +534,25 @@ export function SettingsShell({
}
: null);
// The shell paints the heading twice — above the mobile chip strip and
// inside the desktop grid row — so only the desktop copy may be a real
// `<h1>`. Both go through `PageHeader`: the block used to be hand-rolled
// here and in `admin-shell.tsx`, which is how Settings and Admin ended up
// a step off every module page (and off each other).
const headingBlock = (mobile = false) =>
resolvedHeading ? (
<div className="space-y-6">
{resolvedHeading.topSlot ? <div>{resolvedHeading.topSlot}</div> : null}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
{mobile ? (
<div
id={`${resolvedHeading.headingId}-mobile`}
role="heading"
aria-level={1}
className="text-2xl font-bold tracking-tight"
>
{resolvedHeading.title}
</div>
) : (
<h1
id={resolvedHeading.headingId}
className="text-2xl font-bold tracking-tight"
>
{resolvedHeading.title}
</h1>
)}
{resolvedHeading.subtitle ? (
<p className="text-muted-foreground text-sm">
{resolvedHeading.subtitle}
</p>
) : null}
</div>
{resolvedHeading.headingAccessory ? (
<div className="shrink-0">{resolvedHeading.headingAccessory}</div>
) : null}
</div>
</div>
<PageHeader
headingAs={mobile ? "div" : "h1"}
titleId={
mobile
? `${resolvedHeading.headingId}-mobile`
: resolvedHeading.headingId
}
title={resolvedHeading.title}
description={resolvedHeading.subtitle}
topSlot={resolvedHeading.topSlot}
actions={resolvedHeading.headingAccessory}
/>
) : null;
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
+1 -1
View File
@@ -132,7 +132,7 @@ function ShareLinksCard() {
);
return (
<SettingsCard className="space-y-6">
<SettingsCard>
<SettingsCardHeader
icon={Share2}
title={t("settings.sharing.createTitle")}
+1 -1
View File
@@ -292,7 +292,7 @@ export function SourcesSection() {
// card, below the ladder.
return (
<div className="space-y-6">
<SettingsCard className="space-y-4">
<SettingsCard>
<SettingsCardHeader
icon={Layers}
title={t("settings.sections.sources.cardTitle")}
+1 -1
View File
@@ -139,7 +139,7 @@ export function TelegramCard({
description={t("settings.telegramDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<form onSubmit={handleSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
@@ -119,7 +119,7 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
});
return (
<SettingsCard id={id} className="scroll-mt-28 space-y-4">
<SettingsCard id={id} className="scroll-mt-28">
{/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
already provides the title + description for this route, so the
card-level title + subtitle were a duplicate of the page header.
@@ -90,9 +90,8 @@ export function TrustedDevicesCard({
<SettingsCardHeader
icon={ShieldCheck}
title={t("settings.security.trustedDevices.title")}
className="mb-4"
/>
<div className="space-y-4 pl-7">
<div className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.security.trustedDevices.description")}
</p>
+1 -1
View File
@@ -214,7 +214,7 @@ export function WebPushCard() {
/>
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
{!loading && !isSupported ? (
<p className="text-muted-foreground text-sm">
{t("settings.webPushNotSupported")}
+1 -1
View File
@@ -136,7 +136,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
description={t("settings.webhookDescription")}
/>
<div className="mt-4 space-y-4 pl-7">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
<Switch