mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -132,7 +132,7 @@ function ShareLinksCard() {
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsCard className="space-y-6">
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Share2}
|
||||
title={t("settings.sharing.createTitle")}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user