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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 20:20:03 +02:00
1 parent 97e4dd4c05
commit 991764b0c8
14 files changed
+374 -209

No files matched your search

@@ -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<Record<string, unknown>>,
}));
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 }) => (
<button aria-label={props.title}>Delete</button>
),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { BiomarkerManager } from "../biomarker-manager";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<BiomarkerManager />
</I18nProvider>,
);
}
beforeEach(() => {
mocks.biomarkers = [];
});
describe("<BiomarkerManager> 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);
});
});
+13 -10
View File
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t("labs.biomarker.managerDescription")}
</p>
<Button size="sm" onClick={openNew} className="shrink-0">
<Plus className="h-4 w-4" />
{t("labs.biomarker.define")}
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }, (_, i) => (
@@ -217,6 +208,18 @@ export function BiomarkerManager() {
</div>
)}
{/* 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 ? (
<SettingsCardActions>
<Button size="sm" onClick={openNew} className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("labs.biomarker.define")}
</Button>
</SettingsCardActions>
) : null}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
+1
View File
@@ -154,6 +154,7 @@ export function LabsSettings() {
<SettingsCardHeader
icon={FlaskConical}
title={t("labs.biomarker.manageTitle")}
description={t("labs.biomarker.managerDescription")}
/>
<BiomarkerManager />
</SettingsCard>
@@ -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("<EmergencyProfileManager> Save", () => {
it("stays disabled until a field differs from the stored profile", () => {
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<EmergencyProfileManager />
</I18nProvider>,
);
const row = html.slice(html.indexOf('data-slot="settings-card-actions"'));
expect(row).toMatch(/<button[^>]*disabled=""[^>]*>[^]*?Save<\/button>/);
});
});
@@ -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)}
/>
</div>
<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"
@@ -149,39 +147,11 @@ export function AboutMeNoteManager() {
max: maxChars,
})}
</p>
<div className="flex items-center gap-2">
{hasNote && (
<ConfirmButton
slot="settings-about-me-clear"
label={t("settings.ai.aboutMe.clear")}
icon={<Trash2 className="size-4" aria-hidden />}
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: "" })}
/>
)}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-save"
disabled={save.isPending || !dirty}
onClick={() => save.mutate({ aboutMe: value })}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("settings.ai.aboutMe.save")}
</Button>
</div>
{/* What the note is for, under the field it describes; the action row
stays the last thing in the card (design standards §12). */}
<p className="text-muted-foreground text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
</div>
{pendingQuestions.length > 0 && (
@@ -218,9 +188,39 @@ export function AboutMeNoteManager() {
/>
)}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.aboutMe.hint")} {t("settings.ai.aboutMe.profileHint")}
</p>
<SettingsCardActions>
{hasNote && (
<ConfirmButton
slot="settings-about-me-clear"
label={t("settings.ai.aboutMe.clear")}
icon={<Trash2 className="size-4" aria-hidden />}
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: "" })}
/>
)}
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-about-me-save"
disabled={save.isPending || !dirty}
onClick={() => save.mutate({ aboutMe: value })}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("settings.ai.aboutMe.save")}
</Button>
</SettingsCardActions>
</div>
);
}
@@ -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() {
);
})}
</div>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="button"
size="sm"
@@ -152,7 +153,7 @@ export function AiProfileInclusionManager() {
)}
{t("records.aiInclusion.save")}
</Button>
</div>
</SettingsCardActions>
</div>
);
}
@@ -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()}
/>
) : (
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="button"
size="sm"
@@ -142,7 +143,7 @@ export function AllergyFreeTextNote() {
)}
{t("settings.ai.aboutMe.save")}
</Button>
</div>
</SettingsCardActions>
)}
</div>
);
+18 -12
View File
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t("records.allergies.description")}
</p>
{rows.length > 0 ? (
<Button size="sm" onClick={openNew} className="shrink-0">
<Plus className="h-4 w-4" />
{t("records.allergies.add")}
</Button>
) : null}
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
@@ -156,6 +145,23 @@ export function AllergyManager() {
<ul className="space-y-2">{rows.map(renderRow)}</ul>
)}
{/* 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 ? (
<SettingsCardActions>
<Button
size="sm"
variant="outline"
onClick={openNew}
className="min-h-11 sm:min-h-9"
>
<Plus className="h-4 w-4" />
{t("records.allergies.add")}
</Button>
</SettingsCardActions>
) : null}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
@@ -26,6 +26,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 { useTranslations } from "@/lib/i18n/context";
@@ -137,7 +138,7 @@ export function ConditionsManager() {
t("records.conditions.focusLabel"),
t("settings.ai.aboutMe.focusPlaceholder"),
)}
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="button"
size="sm"
@@ -154,7 +155,7 @@ export function ConditionsManager() {
)}
{t("settings.ai.aboutMe.save")}
</Button>
</div>
</SettingsCardActions>
</>
)}
</div>
@@ -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<Draft>) =>
setDraft((prev) => ({ ...(prev ?? draftFromDto(query.data!)), ...patch }));
@@ -113,7 +121,7 @@ export function EmergencyProfileManager() {
disabled={disabled}
onValueChange={(next) => set({ [key]: next } as Partial<Draft>)}
>
<SelectTrigger id={`emergency-${key}`}>
<SelectTrigger id={`emergency-${key}`} className="w-full">
<SelectValue placeholder={t("records.emergency.notRecorded")} />
</SelectTrigger>
<SelectContent>
@@ -170,12 +178,12 @@ export function EmergencyProfileManager() {
{t("records.emergency.reportHint")}
</p>
<div className="flex items-center justify-end">
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || current === null}
disabled={disabled || !dirty}
onClick={() => current && save.mutate(current)}
>
{save.isPending && (
@@ -186,7 +194,7 @@ export function EmergencyProfileManager() {
)}
{t("records.emergency.save")}
</Button>
</div>
</SettingsCardActions>
</div>
);
}
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-muted-foreground text-sm">
{t("records.family.description")}
</p>
{rows.length > 0 ? (
<Button size="sm" onClick={openNew} className="shrink-0">
<Plus className="h-4 w-4" />
{t("records.family.add")}
</Button>
) : null}
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 2 }, (_, i) => (
@@ -141,6 +130,18 @@ export function FamilyHistoryManager() {
<ul className="space-y-2">{rows.map(renderRow)}</ul>
)}
{/* 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 ? (
<SettingsCardActions>
<Button size="sm" onClick={openNew} className="min-h-11 sm:min-h-9">
<Plus className="h-4 w-4" />
{t("records.family.add")}
</Button>
</SettingsCardActions>
) : null}
<ResponsiveSheet
open={formOpen}
onOpenChange={setFormOpen}
@@ -6,6 +6,7 @@ import { Loader2, Trash2 } 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 { ConfirmButton } from "@/components/ui/confirm-button";
import { Label } from "@/components/ui/label";
@@ -289,7 +290,10 @@ export function HealthProfileFactsManager() {
disabled={disabled}
onValueChange={(next) => editDraft(kind, next)}
>
<SelectTrigger id={`health-profile-fact-${kind}`}>
<SelectTrigger
id={`health-profile-fact-${kind}`}
className="w-full"
>
<SelectValue
placeholder={
current?.unreadable
@@ -311,71 +315,53 @@ export function HealthProfileFactsManager() {
three fields no longer each stack their own grey Save slab.
When a shared Save partially fails, the kind that didn't land
says so here rather than behind the toast. */}
<div className="flex items-center justify-between gap-2 pt-1">
{failed ? (
<span
className="text-destructive text-xs"
data-slot={`health-profile-fact-error-${kind}`}
>
{t("records.profileFacts.saveKindError")}
</span>
) : (
<span aria-hidden />
)}
{current ? (
<ConfirmButton
// Short label inside the row; the full phrase stays the
// accessible name, because "Remove" on its own is
// ambiguous once three of them are on screen.
label={t("records.profileFacts.removeShort")}
ariaLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
title={t("records.profileFacts.removeConfirmTitle", {
kind: t(KIND_LABEL[kind]),
})}
body={t("records.profileFacts.removeConfirmDescription")}
confirmLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
onConfirm={() => 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={<Trash2 className="size-4" aria-hidden />}
slot={`health-profile-fact-remove-${kind}`}
/>
) : null}
</div>
{failed || current ? (
<div className="flex items-center justify-between gap-2 pt-1">
{failed ? (
<span
className="text-destructive text-xs"
data-slot={`health-profile-fact-error-${kind}`}
>
{t("records.profileFacts.saveKindError")}
</span>
) : (
<span aria-hidden />
)}
{current ? (
<ConfirmButton
// Short label inside the row; the full phrase stays the
// accessible name, because "Remove" on its own is
// ambiguous once three of them are on screen.
label={t("records.profileFacts.removeShort")}
ariaLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
title={t("records.profileFacts.removeConfirmTitle", {
kind: t(KIND_LABEL[kind]),
})}
body={t("records.profileFacts.removeConfirmDescription")}
confirmLabel={t("records.profileFacts.remove", {
kind: t(KIND_LABEL[kind]),
})}
onConfirm={() => 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={<Trash2 className="size-4" aria-hidden />}
slot={`health-profile-fact-remove-${kind}`}
/>
) : null}
</div>
) : null}
</div>
);
})}
</div>
{/* 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. */}
<div className="flex justify-end">
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || dirtyCount === 0}
onClick={() => save.mutate(collectDirtyInputs())}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
</div>
{closedHistory.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium">
@@ -412,6 +398,28 @@ export function HealthProfileFactsManager() {
</ul>
</div>
)}
{/* 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). */}
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled || dirtyCount === 0}
onClick={() => save.mutate(collectDirtyInputs())}
>
{save.isPending && (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
)}
{t("records.profileFacts.save")}
</Button>
</SettingsCardActions>
</div>
);
}
@@ -265,21 +265,25 @@ export function TotpCard({
<div>
{/* ── 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 && (
<Button
type="button"
data-testid="totp-setup-start"
className="min-h-11 sm:min-h-9"
onClick={() => beginSetup.mutate(undefined)}
disabled={beginSetup.isPending}
>
{beginSetup.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("settings.security.totp.setUp")}
</Button>
<SettingsCardActions>
<Button
type="button"
data-testid="totp-setup-start"
className="min-h-11 sm:min-h-9"
onClick={() => beginSetup.mutate(undefined)}
disabled={beginSetup.isPending}
>
{beginSetup.isPending ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("settings.security.totp.setUp")}
</Button>
</SettingsCardActions>
)}
<ExistingFactorReauthDialog
open={reauthOpen}
@@ -418,7 +422,7 @@ export function TotpCard({
{ count: recoveryCodesRemaining },
)}
</p>
<div className="flex flex-wrap gap-2">
<SettingsCardActions>
<AlertDialog
open={regenerateOpen}
onOpenChange={setRegenerateOpen}
@@ -470,8 +474,8 @@ export function TotpCard({
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
className="text-destructive min-h-11 sm:min-h-9"
variant="destructive"
className="min-h-11 sm:min-h-9"
>
{t("settings.security.totp.disable")}
</Button>
@@ -544,7 +548,7 @@ export function TotpCard({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</SettingsCardActions>
</div>
)}
+51 -47
View File
@@ -297,21 +297,6 @@ export function SourcesSection() {
icon={Layers}
title={t("settings.sections.sources.cardTitle")}
description={t("settings.sections.sources.help")}
status={
<ConfirmButton
slot="settings-sources-reset"
variant="ghost"
size="sm"
icon={<RotateCcw className="h-3.5 w-3.5" />}
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()}
/>
}
/>
<p className="text-sm">{t("settings.sections.sources.helpDetail")}</p>
@@ -346,12 +331,12 @@ export function SourcesSection() {
<span className="flex-1 text-sm">
{t(MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS[source])}
</span>
{/* 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. */}
<div className="flex flex-col gap-1 sm:flex-row sm:gap-2">
{/* 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. */}
<div className="flex shrink-0 gap-1 sm:gap-2">
<Button
type="button"
variant="ghost"
@@ -564,32 +549,51 @@ export function SourcesSection() {
</Link>
</p>
{dirty && priority && (
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate(priority)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
)}
{/* 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. */}
<SettingsCardActions>
<ConfirmButton
slot="settings-sources-reset"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
icon={<RotateCcw className="h-3.5 w-3.5" />}
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 ? (
<>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate(priority)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</>
) : null}
</SettingsCardActions>
</SettingsCard>
{/* v1.28 shipped `<NutrientIntakeCard>` here; the 2026-07-17 UX/IA