From 991764b0c8c1cede79514c850e0da19c6f0be4be Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Thu, 8 Oct 2026 20:20:03 +0200
Subject: [PATCH] Give settings cards one action row at the foot
The TOTP enable, the source-priority reset, the biomarker and record add
actions, and the about-me, profile-facts, emergency and condition saves
move into SettingsCardActions, right-aligned and last in the card. The
emergency Save waits for a change like its neighbours, an empty list
leaves its add action to the empty state, and the source ladder keeps
its up/down buttons side by side on a phone.
---
.../biomarker-manager-actions.test.tsx | 82 +++++++++++
src/components/labs/biomarker-manager.tsx | 23 ++--
src/components/labs/labs-settings.tsx | 1 +
.../__tests__/emergency-profile-save.test.tsx | 45 ++++++
.../records/about-me-note-manager.tsx | 78 +++++------
.../records/ai-profile-inclusion-manager.tsx | 5 +-
.../records/allergy-free-text-note.tsx | 5 +-
src/components/records/allergy-manager.tsx | 30 ++--
src/components/records/conditions-manager.tsx | 5 +-
.../records/emergency-profile-manager.tsx | 16 ++-
.../records/family-history-manager.tsx | 25 ++--
.../records/health-profile-facts-manager.tsx | 130 ++++++++++--------
.../settings/security-section/totp-card.tsx | 40 +++---
src/components/settings/sources-section.tsx | 98 ++++++-------
14 files changed, 374 insertions(+), 209 deletions(-)
create mode 100644 src/components/labs/__tests__/biomarker-manager-actions.test.tsx
create mode 100644 src/components/records/__tests__/emergency-profile-save.test.tsx
diff --git a/src/components/labs/__tests__/biomarker-manager-actions.test.tsx b/src/components/labs/__tests__/biomarker-manager-actions.test.tsx
new file mode 100644
index 000000000..ae1836687
--- /dev/null
+++ b/src/components/labs/__tests__/biomarker-manager-actions.test.tsx
@@ -0,0 +1,82 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+// The biomarker catalog card offers its add action once: the empty state's
+// own button while the catalog is empty, otherwise one action row at the
+// foot of the card (design standards §12). It used to carry a "Define"
+// button above the list AND the empty state's "Define your first biomarker".
+
+const mocks = vi.hoisted(() => ({
+ biomarkers: [] as Array>,
+}));
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({
+ data: { biomarkers: mocks.biomarkers },
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+vi.mock("@/lib/api/api-fetch", () => ({
+ apiDelete: vi.fn(),
+ apiGet: vi.fn(),
+ apiPut: vi.fn(),
+}));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+vi.mock("@/components/ui/responsive-sheet", () => ({
+ ResponsiveSheet: () => null,
+}));
+vi.mock("@/components/data-list", () => ({
+ DeleteButton: (props: { title: string }) => (
+ Delete
+ ),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { BiomarkerManager } from "../biomarker-manager";
+
+function render() {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ mocks.biomarkers = [];
+});
+
+describe(" action grammar", () => {
+ it("leaves the add action to the empty state while the catalog is empty", () => {
+ const html = render();
+ expect(html.match(/Define your first biomarker/g)).toHaveLength(1);
+ expect(html).not.toContain('data-slot="settings-card-actions"');
+ });
+
+ it("closes a populated catalog with one add action below the list", () => {
+ mocks.biomarkers = [
+ {
+ id: "b1",
+ name: "LDL",
+ unit: "mg/dL",
+ lowerBound: null,
+ upperBound: 115,
+ panel: null,
+ hasContext: false,
+ context: null,
+ hidden: false,
+ createdAt: "2026-07-28T10:00:00.000Z",
+ updatedAt: "2026-07-28T10:00:00.000Z",
+ },
+ ];
+ const html = render();
+ expect(html).not.toContain("Define your first biomarker");
+ const row = html.indexOf('data-slot="settings-card-actions"');
+ expect(row).toBeGreaterThan(html.indexOf("LDL"));
+ expect(html.slice(row).match(/>Define<\/button>/g)).toHaveLength(1);
+ });
+});
diff --git a/src/components/labs/biomarker-manager.tsx b/src/components/labs/biomarker-manager.tsx
index 56c883b77..18cc9f5a5 100644
--- a/src/components/labs/biomarker-manager.tsx
+++ b/src/components/labs/biomarker-manager.tsx
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -166,16 +167,6 @@ export function BiomarkerManager() {
return (
-
-
- {t("labs.biomarker.managerDescription")}
-
-
-
- {t("labs.biomarker.define")}
-
-
-
{isLoading ? (
{Array.from({ length: 3 }, (_, i) => (
@@ -217,6 +208,18 @@ export function BiomarkerManager() {
)}
+ {/* The add action is the card's one action row, last in the card
+ (design standards §12). An empty catalog leaves it to the empty
+ state's own button, so the card never offers the same action twice. */}
+ {!isLoading && !isError && markers.length > 0 ? (
+
+
+
+ {t("labs.biomarker.define")}
+
+
+ ) : null}
+
diff --git a/src/components/records/__tests__/emergency-profile-save.test.tsx b/src/components/records/__tests__/emergency-profile-save.test.tsx
new file mode 100644
index 000000000..6640644ce
--- /dev/null
+++ b/src/components/records/__tests__/emergency-profile-save.test.tsx
@@ -0,0 +1,45 @@
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+// The emergency card's Save stayed enabled on an untouched, stored profile
+// while every other Save on the medical-history page waits for a change.
+// It now follows them: nothing edited, nothing to save.
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({
+ data: {
+ bloodType: "A_POS",
+ organDonor: null,
+ advanceDirective: null,
+ contacts: "A contact",
+ implants: null,
+ note: null,
+ updatedAt: "2026-07-28T10:00:00.000Z",
+ },
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ }),
+ useQueryClient: () => ({
+ invalidateQueries: vi.fn(),
+ setQueryData: vi.fn(),
+ }),
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+vi.mock("@/lib/api/api-fetch", () => ({ apiGet: vi.fn(), apiPatch: vi.fn() }));
+vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { EmergencyProfileManager } from "../emergency-profile-manager";
+
+describe("
Save", () => {
+ it("stays disabled until a field differs from the stored profile", () => {
+ const html = renderToStaticMarkup(
+
+
+ ,
+ );
+ const row = html.slice(html.indexOf('data-slot="settings-card-actions"'));
+ expect(row).toMatch(/]*disabled=""[^>]*>[^]*?Save<\/button>/);
+ });
+});
diff --git a/src/components/records/about-me-note-manager.tsx b/src/components/records/about-me-note-manager.tsx
index 66def03fc..2fa1059bf 100644
--- a/src/components/records/about-me-note-manager.tsx
+++ b/src/components/records/about-me-note-manager.tsx
@@ -32,6 +32,7 @@ import { Loader2, MessageCircleQuestion, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
@@ -137,9 +138,6 @@ export function AboutMeNoteManager() {
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
/>
-
-
-
-
- {hasNote && (
- }
- variant="outline"
- size="sm"
- className="min-h-11 sm:min-h-9"
- title={t("settings.ai.aboutMe.clearTitle")}
- body={t("settings.ai.aboutMe.clearBody")}
- confirmLabel={t("settings.ai.aboutMe.clearConfirm")}
- pending={save.isPending}
- onConfirm={() => save.mutate({ aboutMe: "" })}
- />
- )}
- save.mutate({ aboutMe: value })}
- >
- {save.isPending && (
-
- )}
- {t("settings.ai.aboutMe.save")}
-
-
+ {/* What the note is for, under the field it describes; the action row
+ stays the last thing in the card (design standards §12). */}
+
+ {t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
+
{pendingQuestions.length > 0 && (
@@ -218,9 +188,39 @@ export function AboutMeNoteManager() {
/>
)}
-
- {t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
-
+
+ {hasNote && (
+ }
+ variant="outline"
+ size="sm"
+ className="min-h-11 sm:min-h-9"
+ title={t("settings.ai.aboutMe.clearTitle")}
+ body={t("settings.ai.aboutMe.clearBody")}
+ confirmLabel={t("settings.ai.aboutMe.clearConfirm")}
+ pending={save.isPending}
+ onConfirm={() => save.mutate({ aboutMe: "" })}
+ />
+ )}
+ save.mutate({ aboutMe: value })}
+ >
+ {save.isPending && (
+
+ )}
+ {t("settings.ai.aboutMe.save")}
+
+
);
}
diff --git a/src/components/records/ai-profile-inclusion-manager.tsx b/src/components/records/ai-profile-inclusion-manager.tsx
index 1b8fadee4..8f0c3e88b 100644
--- a/src/components/records/ai-profile-inclusion-manager.tsx
+++ b/src/components/records/ai-profile-inclusion-manager.tsx
@@ -6,6 +6,7 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Switch } from "@/components/ui/switch";
@@ -136,7 +137,7 @@ export function AiProfileInclusionManager() {
);
})}
-
+
-
+
);
}
diff --git a/src/components/records/allergy-free-text-note.tsx b/src/components/records/allergy-free-text-note.tsx
index 11fd89acd..1e0d63416 100644
--- a/src/components/records/allergy-free-text-note.tsx
+++ b/src/components/records/allergy-free-text-note.tsx
@@ -27,6 +27,7 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
import { useTranslations } from "@/lib/i18n/context";
@@ -125,7 +126,7 @@ export function AllergyFreeTextNote() {
onRetry={() => query.refetch()}
/>
) : (
-
+
-
+
)}
);
diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx
index deef991e8..31de01954 100644
--- a/src/components/records/allergy-manager.tsx
+++ b/src/components/records/allergy-manager.tsx
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -120,18 +121,6 @@ export function AllergyManager() {
return (
-
-
- {t("records.allergies.description")}
-
- {rows.length > 0 ? (
-
-
- {t("records.allergies.add")}
-
- ) : null}
-
-
{isLoading ? (
{Array.from({ length: 2 }, (_, i) => (
@@ -156,6 +145,23 @@ export function AllergyManager() {
)}
+ {/* The add action closes the list (design standards §12); an empty
+ list leaves it to the empty state's own button. Outline here,
+ because the card's primary is the free-text note's Save below. */}
+ {!isLoading && !isError && rows.length > 0 ? (
+
+
+
+ {t("records.allergies.add")}
+
+
+ ) : null}
+
+
-
+
>
)}
diff --git a/src/components/records/emergency-profile-manager.tsx b/src/components/records/emergency-profile-manager.tsx
index ef27daa44..7bf147763 100644
--- a/src/components/records/emergency-profile-manager.tsx
+++ b/src/components/records/emergency-profile-manager.tsx
@@ -6,6 +6,7 @@ import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Label } from "@/components/ui/label";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Textarea } from "@/components/ui/textarea";
@@ -96,6 +97,13 @@ export function EmergencyProfileManager() {
}
const disabled = query.isLoading || save.isPending || current === null;
+ // Save stays off until a field differs from the stored profile, like every
+ // other Save on this page; a fresh draft that matches the record is not a
+ // change.
+ const dirty =
+ draft !== null &&
+ query.data != null &&
+ JSON.stringify(draft) !== JSON.stringify(draftFromDto(query.data));
const set = (patch: Partial) =>
setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch }));
@@ -113,7 +121,7 @@ export function EmergencyProfileManager() {
disabled={disabled}
onValueChange={(next) => set({ [key]: next } as Partial)}
>
-
+
@@ -170,12 +178,12 @@ export function EmergencyProfileManager() {
{t("records.emergency.reportHint")}
-
+
current && save.mutate(current)}
>
{save.isPending && (
@@ -186,7 +194,7 @@ export function EmergencyProfileManager() {
)}
{t("records.emergency.save")}
-
+
);
}
diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx
index 1b865bc8a..2f84fff10 100644
--- a/src/components/records/family-history-manager.tsx
+++ b/src/components/records/family-history-manager.tsx
@@ -7,6 +7,7 @@ import { toast } from "sonner";
import { DeleteButton } from "@/components/data-list";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
@@ -105,18 +106,6 @@ export function FamilyHistoryManager() {
return (
-
-
- {t("records.family.description")}
-
- {rows.length > 0 ? (
-
-
- {t("records.family.add")}
-
- ) : null}
-
-
{isLoading ? (
{Array.from({ length: 2 }, (_, i) => (
@@ -141,6 +130,18 @@ export function FamilyHistoryManager() {
)}
+ {/* The add action closes the list as the card's action row (design
+ standards §12); an empty list leaves it to the empty state's own
+ button. */}
+ {!isLoading && !isError && rows.length > 0 ? (
+
+
+
+ {t("records.family.add")}
+
+
+ ) : null}
+
editDraft(kind, next)}
>
-
+
- {failed ? (
-
- {t("records.profileFacts.saveKindError")}
-
- ) : (
-
- )}
- {current ? (
- remove.mutate(current)}
- pending={
- remove.isPending && remove.variables?.id === current.id
- }
- disabled={disabled}
- variant="ghost"
- size="sm"
- className="text-destructive min-h-11 sm:min-h-9"
- icon={ }
- slot={`health-profile-fact-remove-${kind}`}
- />
- ) : null}
-
+ {failed || current ? (
+
+ {failed ? (
+
+ {t("records.profileFacts.saveKindError")}
+
+ ) : (
+
+ )}
+ {current ? (
+ remove.mutate(current)}
+ pending={
+ remove.isPending && remove.variables?.id === current.id
+ }
+ disabled={disabled}
+ variant="ghost"
+ size="sm"
+ className="text-destructive min-h-11 sm:min-h-9"
+ icon={ }
+ slot={`health-profile-fact-remove-${kind}`}
+ />
+ ) : null}
+
+ ) : null}
);
})}
- {/* One Save for the whole tile. It writes only the dirty kinds and stays
- disabled until at least one field differs from its stored value. */}
-
- save.mutate(collectDirtyInputs())}
- >
- {save.isPending && (
-
- )}
- {t("records.profileFacts.save")}
-
-
-
{closedHistory.length > 0 && (
@@ -412,6 +398,28 @@ export function HealthProfileFactsManager() {
)}
+
+ {/* One Save for the whole tile. It writes only the dirty kinds and stays
+ disabled until at least one field differs from its stored value. It
+ is the last thing in the card, below the history (design standards
+ §12). */}
+
+ save.mutate(collectDirtyInputs())}
+ >
+ {save.isPending && (
+
+ )}
+ {t("records.profileFacts.save")}
+
+
);
}
diff --git a/src/components/settings/security-section/totp-card.tsx b/src/components/settings/security-section/totp-card.tsx
index 7495a9c93..e8346359a 100644
--- a/src/components/settings/security-section/totp-card.tsx
+++ b/src/components/settings/security-section/totp-card.tsx
@@ -265,21 +265,25 @@ export function TotpCard({
{/* ── Not enabled, no wizard: the enable CTA ── */}
+ {/* The enable CTA is the card's primary, right-aligned in the one
+ action row like every other Settings card (design standards §12). */}
{!enabled && !setup && !freshCodes && (
-
beginSetup.mutate(undefined)}
- disabled={beginSetup.isPending}
- >
- {beginSetup.isPending ? (
-
- ) : (
-
- )}
- {t("settings.security.totp.setUp")}
-
+
+ beginSetup.mutate(undefined)}
+ disabled={beginSetup.isPending}
+ >
+ {beginSetup.isPending ? (
+
+ ) : (
+
+ )}
+ {t("settings.security.totp.setUp")}
+
+
)}
-
+
{t("settings.security.totp.disable")}
@@ -544,7 +548,7 @@ export function TotpCard({
-
+
)}
diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx
index 254db3663..8e88cd081 100644
--- a/src/components/settings/sources-section.tsx
+++ b/src/components/settings/sources-section.tsx
@@ -297,21 +297,6 @@ export function SourcesSection() {
icon={Layers}
title={t("settings.sections.sources.cardTitle")}
description={t("settings.sections.sources.help")}
- status={
- }
- label={t("settings.sections.sources.resetDefaults")}
- title={t("settings.sections.sources.resetDefaultsTitle")}
- body={t("settings.sections.sources.resetDefaultsBody")}
- confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")}
- disabled={saveMutation.isPending}
- pending={resetMutation.isPending}
- onConfirm={() => resetMutation.mutate()}
- />
- }
/>
{t("settings.sections.sources.helpDetail")}
@@ -346,12 +331,12 @@ export function SourcesSection() {
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
- {/* v1.4.27 R3d MB2 — stack up/down vertically on
- narrow viewports so each button keeps the
- 44 px floor without crowding the row. From
- `sm:` up the two buttons sit side-by-side
- again to preserve the desktop layout. */}
-
+ {/* Up/down sit side by side at every width: two
+ 44 px buttons leave a 390 px row ~170 px for the
+ source name. Stacking them on phones (v1.4.27
+ MB2) doubled every row and pushed this page past
+ twelve thousand pixels. */}
+
- {dirty && priority && (
-
- setDraft(null)}
- disabled={saveMutation.isPending}
- >
- {t("common.cancel")}
-
- saveMutation.mutate(priority)}
- disabled={saveMutation.isPending}
- >
- {saveMutation.isPending && (
-
- )}
- {t("common.save")}
-
-
- )}
+ {/* One action row, last in the card (design standards §12). The
+ reset used to ride the header's status slot, which carries
+ status, not actions; Cancel + Save join it while a draft is open. */}
+
+ }
+ label={t("settings.sections.sources.resetDefaults")}
+ title={t("settings.sections.sources.resetDefaultsTitle")}
+ body={t("settings.sections.sources.resetDefaultsBody")}
+ confirmLabel={t("settings.sections.sources.resetDefaultsConfirm")}
+ disabled={saveMutation.isPending}
+ pending={resetMutation.isPending}
+ onConfirm={() => resetMutation.mutate()}
+ />
+ {dirty && priority ? (
+ <>
+ setDraft(null)}
+ disabled={saveMutation.isPending}
+ >
+ {t("common.cancel")}
+
+ saveMutation.mutate(priority)}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending && (
+
+ )}
+ {t("common.save")}
+
+ >
+ ) : null}
+
{/* v1.28 shipped `` here; the 2026-07-17 UX/IA