refactor(settings): one header, one action row, one integration card

The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.

Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.

Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.

Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.

Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 19:39:11 +02:00
1 parent ce9b2814e6
commit b2e1aa0cde
63 files changed
+720 -760

No files matched your search

@@ -64,7 +64,7 @@ function TruncatedCell({
/**
* v1.4.22 W5 reconcile (S-03) — the revoked / isExpired / active
* 3-way ternary is identical between the desktop table and the
* mobile card list; the only difference is `text-xs` vs `text-[10px]`.
* mobile card list; the only difference is `text-xs` vs `text-2xs`.
* Centralise so the two surfaces stay in lock-step.
*/
function TokenStatusBadge({
@@ -77,7 +77,7 @@ function TokenStatusBadge({
const { t } = useTranslations();
const expired =
token.expiresAt != null && new Date(token.expiresAt) < new Date();
const sizeClass = size === "xs" ? "text-[10px]" : "text-xs";
const sizeClass = size === "xs" ? "text-2xs" : "text-xs";
if (token.revoked) {
return (
<Badge variant="destructive" className={`shrink-0 ${sizeClass}`}>
@@ -317,7 +317,7 @@ export function ApiTokenOverviewSection() {
<Badge
key={p}
variant="secondary"
className="max-w-full truncate text-[10px]"
className="text-2xs max-w-full truncate"
title={
p === "*"
? t("admin.tokenPermissionAllTooltip")
+3 -2
View File
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -130,7 +131,7 @@ export function GlitchtipSection() {
</div>
</div>
<div className="flex justify-end gap-2">
<SettingsCardActions>
<Button
size="sm"
variant="outline"
@@ -152,7 +153,7 @@ export function GlitchtipSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
);
}
+3 -2
View File
@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -260,7 +261,7 @@ export function RemindersSection() {
</div>
</div>
<div className="flex justify-end gap-2">
<SettingsCardActions>
<Button
size="sm"
variant="outline"
@@ -315,7 +316,7 @@ export function RemindersSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
+3 -2
View File
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
import { Activity, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -131,7 +132,7 @@ export function UmamiSection() {
</div>
</div>
<div className="flex justify-end gap-2">
<SettingsCardActions>
<Button
size="sm"
variant="outline"
@@ -153,7 +154,7 @@ export function UmamiSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { BellRing, KeyRound, Loader2 } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import {
@@ -191,7 +192,7 @@ export function WebPushVapidSection() {
</div>
</div>
<div className="flex justify-end gap-2">
<SettingsCardActions>
<Button
variant="outline"
size="sm"
@@ -217,7 +218,7 @@ export function WebPushVapidSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
<AlertDialog
open={overwriteConfirmOpen}
@@ -23,6 +23,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -213,10 +214,7 @@ export function InsightsEditMode({
const allHidden = draft.sections.every((s) => !s.visible);
return (
<div
data-slot="insights-edit-mode"
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
>
<SettingsCard data-slot="insights-edit-mode">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div>
<h2
@@ -312,7 +310,7 @@ export function InsightsEditMode({
{t("insights.editMode.manageInSettings")}
</Link>
</p>
</div>
</SettingsCard>
);
}
@@ -21,7 +21,7 @@
* 3. Withings reauth: pill carries the "Error — reconnect" label.
* 4. Mood Log disconnected: pill says "Not connected", no other
* status display surfaces.
* 5. Mood Log card has the visual divider that Withings always had
* 5. no card paints a divider under its header
* — consistency the maintainer explicitly called out.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
@@ -254,7 +254,7 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
expect(html).toContain("Withings activity error: 293");
});
it("every integration card carries the visual divider (consistency)", () => {
it("no integration card paints a divider under its header", () => {
setIntegrationStatus({
threshold: 3,
integrations: [
@@ -274,10 +274,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// Every integration card includes the section divider data-testid so the
// header → body separation is visually consistent: the four legacy cards,
// Apple Health, the three OAuth cards, and Nightscout.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(9);
// The `<hr>` under the header existed only in `integrations/` — nine
// cards carrying a rule no other Settings card has. The card's own gap
// does the separating now, identically here and everywhere else.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(0);
});
// v1.17.1 — every integration card carries the same discreet "Setup guide"
+6 -1
View File
@@ -43,7 +43,10 @@ const TONE_ICON: Record<SettingsInfoTileTone, string> = {
neutral: "text-muted-foreground",
};
export interface SettingsInfoTileProps {
export interface SettingsInfoTileProps extends Omit<
React.ComponentProps<"div">,
"title"
> {
/** Lucide icon component; rendered in the tone colour. */
icon: LucideIcon;
/** Visual tone — routes through the semantic colour tokens. */
@@ -62,6 +65,7 @@ export function SettingsInfoTile({
title,
children,
className,
...props
}: SettingsInfoTileProps) {
return (
<div
@@ -70,6 +74,7 @@ export function SettingsInfoTile({
TONE_SURFACE[tone],
className,
)}
{...props}
>
<Icon
className={cn("mt-0.5 h-4 w-4 shrink-0", TONE_ICON[tone])}
+7 -11
View File
@@ -49,6 +49,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -301,18 +302,13 @@ export function AboutMeSection({
</div>
{pendingQuestions.length > 0 && (
<div
<SettingsInfoTile
icon={MessageCircleQuestion}
tone="primary"
data-testid="settings-about-me-questions"
className="border-primary/30 bg-primary/5 rounded-lg border p-4"
title={t("settings.ai.aboutMe.questionsTitle")}
>
<p className="flex items-center gap-2 text-sm font-medium">
<MessageCircleQuestion
className="text-primary size-4 shrink-0"
aria-hidden="true"
/>
{t("settings.ai.aboutMe.questionsTitle")}
</p>
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm">
<ul className="list-disc space-y-1 pl-6">
{pendingQuestions.map((q) => (
<li key={q}>{q}</li>
))}
@@ -325,7 +321,7 @@ export function AboutMeSection({
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
</div>
</SettingsInfoTile>
)}
{query.isError && (
+3 -2
View File
@@ -13,6 +13,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
@@ -272,7 +273,7 @@ export function AboutSection() {
title={t("settings.about.linksHeading")}
description={t("settings.about.linksDescription")}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<SettingsCardActions align="start">
<Button
asChild
variant="outline"
@@ -317,7 +318,7 @@ export function AboutSection() {
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
)}
@@ -82,7 +82,10 @@ describe("grant invitation lapse date", () => {
expect(onError).not.toContain("setIdentifier");
expect(onError).not.toContain("setExpiresOn");
expect(source).toContain('role="alert"');
expect(source).toContain(
// The submit stays inert while a name is missing or a request is in
// flight. Matched whitespace-tolerantly: the predicate is the contract,
// its line wrapping is not.
expect(source.replace(/\s+/g, " ")).toContain(
"invite.isPending || identifier.trim().length === 0",
);
});
@@ -4,6 +4,7 @@ import { useState } from "react";
import { UserPlus } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -318,21 +319,6 @@ export function GrantInviteCard() {
{t("recordSharing.invite.expiresHint")}
</p>
</div>
<Button
type="submit"
data-slot="grant-invite-submit"
// A narrowed invitation with nothing ticked would be a grant that
// opens nothing, which the server refuses. The button is inert and
// the sentence above says why, rather than sending a request whose
// only purpose is to come back as an error.
disabled={
invite.isPending || identifier.trim().length === 0 || choice.blocked
}
>
{invite.isPending
? t("recordSharing.invite.submitting")
: t("recordSharing.invite.submit")}
</Button>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
@@ -343,6 +329,27 @@ export function GrantInviteCard() {
{t("recordSharing.invite.sent", { name: invited })}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="grant-invite-submit"
// A narrowed invitation with nothing ticked would be a grant that
// opens nothing, which the server refuses. The button is inert and
// the sentence above says why, rather than sending a request whose
// only purpose is to come back as an error.
disabled={
invite.isPending ||
identifier.trim().length === 0 ||
choice.blocked
}
>
{invite.isPending
? t("recordSharing.invite.submitting")
: t("recordSharing.invite.submit")}
</Button>
</SettingsCardActions>
</form>
</SettingsCard>
);
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { Loader2, Save, Shield, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SeededFormDiscardDialog,
@@ -555,7 +556,7 @@ export function AccountSection() {
</p>
)}
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
className="min-h-11 sm:min-h-9"
@@ -568,7 +569,7 @@ export function AccountSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</SettingsCard>
@@ -588,31 +589,26 @@ export function AccountSection() {
reachable. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Password card. v1.4.19 A6: action-button placement contract —
on mobile (`<sm`) the action button stacks below the title +
description and renders full-width; on desktop (`>=sm`) it
right-aligns next to the title. The previous
`flex items-center justify-between` pattern overflowed the
card edge on Pixel 5 once button copy got longer than ~24 ch
(the German "Passwort ändern" already pushes it; the tour
card's "Restart onboarding tour" actually broke through the
right border by ~48 px). */}
{/* Password card. The action is this card's only one, so it sits in
the card's action row like every other primary. It stays full-width
under `sm` — the German "Passwort ändern" is long enough that a
right-hugging button used to break through the card's right border
on a narrow phone. */}
<SettingsCard>
<SettingsCardHeader
icon={Shield}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
status={
<Button
type="button"
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
}
/>
<SettingsCardActions>
<Button
type="button"
onClick={() => setPasswordDialogOpen(true)}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
</SettingsCardActions>
</SettingsCard>
{/* v1.25.7 — active-session management + the security-activity feed
+126 -127
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import { AlertTriangle, BookOpenCheck, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -16,6 +16,8 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Switch } from "@/components/ui/switch";
import { ResearchModeAcknowledgmentDialog } from "@/components/medications/research-mode-acknowledgment-dialog";
import { formatDateTime } from "@/lib/format";
@@ -148,17 +150,14 @@ function ResearchModeCard() {
/>
{showRePrompt && (
<div
<SettingsInfoTile
icon={AlertTriangle}
tone="warning"
role="alert"
data-slot="settings-research-mode-reprompt"
className="border-warning/40 bg-warning/10 rounded-md border-l-4 px-3 py-2 text-sm"
title={t("settings.researchMode.rePromptTitle")}
>
<p className="text-foreground font-medium">
{t("settings.researchMode.rePromptTitle")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.researchMode.rePromptBody")}
</p>
<p>{t("settings.researchMode.rePromptBody")}</p>
<Button
size="sm"
variant="outline"
@@ -168,7 +167,7 @@ function ResearchModeCard() {
>
{t("settings.researchMode.rePromptCta")}
</Button>
</div>
</SettingsInfoTile>
)}
<div className="flex items-start justify-between gap-3">
@@ -262,64 +261,6 @@ function DataResetCard() {
icon={Trash2}
title={t("settings.dangerZone")}
description={t("settings.dangerZoneDescription")}
status={
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the destructive mutation is in
// flight so the in-dialog pending state stays visible and a
// stray backdrop tap can't dismiss it mid-request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
{msg && (
@@ -330,6 +271,64 @@ function DataResetCard() {
{msg}
</p>
)}
<SettingsCardActions>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the destructive mutation is in
// flight so the in-dialog pending state stays visible and a
// stray backdrop tap can't dismiss it mid-request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -399,65 +398,6 @@ function AccountDeleteCard() {
icon={Trash2}
title={t("settings.deleteAccountCardTitle")}
description={t("settings.deleteAccountCardDescription")}
status={
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the irreversible delete runs so
// the pending state stays on screen and a stray dismissal
// can't fire a second request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
{msg && (
@@ -468,6 +408,65 @@ function AccountDeleteCard() {
{msg}
</p>
)}
<SettingsCardActions>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the irreversible delete runs so
// the pending state stays on screen and a stray dismissal
// can't fire a second request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
@@ -147,58 +148,6 @@ export function CoachMemorySection({
titleId="settings-ai-coach-memory-title"
title={t("settings.ai.coachMemory.title")}
description={t("settings.ai.coachMemory.description")}
status={
facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null
}
/>
{query.isError && (
@@ -276,6 +225,58 @@ export function CoachMemorySection({
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.coachMemory.summaryNote")}
</p>
<SettingsCardActions>
{facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</SettingsCardActions>
</SettingsCard>
);
}
@@ -24,6 +24,7 @@ import { Loader2, SlidersHorizontal } 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 { Switch } from "@/components/ui/switch";
import {
@@ -335,7 +336,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
)}
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="button"
size="sm"
@@ -352,7 +353,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
) : null}
{t("insights.coach.settingsSave")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -28,6 +28,7 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
@@ -441,28 +442,9 @@ export function DashboardLayoutSection({ id }: { id: string }) {
return (
<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
zurücksetzen" copy clipping the card edge at narrow widths. */}
<SettingsCardHeader
icon={LayoutDashboard}
title={t("dashboard.customizeTitle")}
status={
<ConfirmButton
slot="settings-dashboard-layout-reset"
variant="ghost"
size="sm"
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("dashboard.layoutReset")}
title={t("dashboard.layoutResetTitle")}
body={t("dashboard.layoutResetBody")}
confirmLabel={t("dashboard.layoutResetConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
}
/>
{hydrated && layout && (
<fieldset
@@ -702,30 +684,47 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</div>
)}
{dirty && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => layout && saveMutation.mutate(layout)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
)}
{/* Reset is a secondary action, so it reads in the same row as save
rather than from the header's status slot. */}
<SettingsCardActions>
<ConfirmButton
slot="settings-dashboard-layout-reset"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("dashboard.layoutReset")}
title={t("dashboard.layoutResetTitle")}
body={t("dashboard.layoutResetBody")}
confirmLabel={t("dashboard.layoutResetConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
{dirty && (
<>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => layout && saveMutation.mutate(layout)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</>
)}
</SettingsCardActions>
{!dirty && remote && (
<p className="text-muted-foreground text-xs">
+3 -2
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -162,7 +163,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/email/test"
disabled={!settings?.enabled}
@@ -177,7 +178,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
@@ -227,7 +227,7 @@ export function EnvironmentSection() {
icon={MapPin}
title={t("settings.sections.environment.home.title")}
/>
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
<p className="text-sm leading-relaxed">
{t("settings.sections.environment.home.description")}
</p>
<div className="space-y-3">
@@ -271,7 +271,9 @@ export function EnvironmentSection() {
/>
<Button
type="submit"
variant="secondary"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={homeQuery.trim().length === 0 || searchHome.isPending}
>
{t("settings.sections.environment.search")}
@@ -314,7 +316,7 @@ export function EnvironmentSection() {
icon={Plane}
title={t("settings.sections.environment.travel.title")}
/>
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
<p className="text-sm leading-relaxed">
{t("settings.sections.environment.travel.description")}
</p>
<div className="space-y-3">
@@ -377,7 +379,9 @@ export function EnvironmentSection() {
/>
<Button
type="submit"
variant="secondary"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={
travelQuery.trim().length === 0 || searchTravel.isPending
}
@@ -439,6 +443,8 @@ export function EnvironmentSection() {
</div>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={travelStart > travelEnd || addTravel.isPending}
onClick={() => addTravel.mutate()}
>
@@ -457,7 +463,7 @@ export function EnvironmentSection() {
icon={CloudSun}
title={t("settings.sections.environment.backfill.title")}
/>
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
<p className="text-sm leading-relaxed">
{t("settings.sections.environment.backfill.description")}
</p>
<div className="flex flex-wrap items-end gap-2">
@@ -483,6 +489,8 @@ export function EnvironmentSection() {
</div>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={
!home ||
effectiveBackfillStart.length === 0 ||
+9 -11
View File
@@ -34,6 +34,8 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SectionHeading } from "@/components/ui/section-heading";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
@@ -80,14 +82,13 @@ export function ExportSection() {
CSV/JSON data-out paths and the import surface. */}
<section
aria-labelledby="settings-section-export-other-title"
className="space-y-3"
className="space-y-4"
>
<h2
<SectionHeading
icon={Download}
id="settings-section-export-other-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.otherOptionsHeading")}
</h2>
title={t("settings.sections.export.otherOptionsHeading")}
/>
<div className="grid gap-4 md:grid-cols-2">
<MeasurementsCsvCard />
<MedicationsCsvCard />
@@ -113,7 +114,7 @@ interface ExportCardShellProps {
description: string;
format: ExportFormat;
children?: React.ReactNode;
/** Footer slot — typically the "Download" button + status text. */
/** The card's action row — the "Download" button, and nothing after it. */
footer: React.ReactNode;
/** Extra grid-position classes applied to the outer card div. */
outerClassName?: string;
@@ -145,7 +146,7 @@ function ExportCardShell({
}
/>
{children && <div className="space-y-3">{children}</div>}
<div className="flex flex-wrap items-center gap-3">{footer}</div>
<SettingsCardActions>{footer}</SettingsCardActions>
</SettingsCard>
);
}
@@ -258,7 +259,6 @@ function CsvCard({
footer={
<Button
data-testid={actionTestId}
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
@@ -346,7 +346,6 @@ function MedicationsCsvCard() {
footer={
<Button
data-testid="export-action-medications-csv"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
@@ -471,7 +470,6 @@ function FullBackupCard() {
footer={
<Button
data-testid="export-action-full-backup"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
@@ -27,8 +27,8 @@
import { Share2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { HealthRecordExportPanel } from "@/components/settings/report-selection/selection-panel";
import { SectionHeading } from "@/components/ui/section-heading";
import { SharingSection } from "@/components/settings/sharing-section";
export function GesundheitsakteSection() {
@@ -42,10 +42,10 @@ export function GesundheitsakteSection() {
<HealthRecordExportPanel />
<section id="sharing" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
<SectionHeading
icon={Share2}
title={t("settings.sections.sharing.title")}
description={t("settings.sections.sharing.subtitle")}
subtitle={t("settings.sections.sharing.subtitle")}
/>
<SharingSection />
</section>
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { AlertCircle, Loader2, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Progress } from "@/components/ui/progress";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
@@ -308,7 +309,7 @@ export function AppleHealthImportCard() {
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<SettingsCardActions className="mt-auto" align="start">
<Button
type="button"
variant="outline"
@@ -325,7 +326,7 @@ export function AppleHealthImportCard() {
)}
{t("settings.sections.export.import.appleHealth.choose")}
</Button>
</div>
</SettingsCardActions>
</ImportCardShell>
);
}
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
@@ -170,7 +171,7 @@ export function CsvImportCard() {
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<SettingsCardActions className="mt-auto" align="start">
<Button
type="button"
variant="outline"
@@ -184,7 +185,7 @@ export function CsvImportCard() {
</Button>
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={downloadExample}
@@ -195,7 +196,7 @@ export function CsvImportCard() {
</Button>
<Button
type="button"
variant="secondary"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
@@ -222,7 +223,7 @@ export function CsvImportCard() {
)}
{t("settings.sections.export.import.csv.import")}
</Button>
</div>
</SettingsCardActions>
</ImportCardShell>
);
}
@@ -26,6 +26,7 @@ import {
type IntakeImportResultState,
} from "@/components/medications/intake-import-result";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { apiFetchRaw } from "@/lib/api/api-fetch";
@@ -270,7 +271,7 @@ export function DoseHistoryImportCard() {
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<SettingsCardActions className="mt-auto" align="start">
<Button
type="button"
variant="outline"
@@ -284,7 +285,7 @@ export function DoseHistoryImportCard() {
</Button>
<Button
type="button"
variant="secondary"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
@@ -311,7 +312,7 @@ export function DoseHistoryImportCard() {
)}
{t("settings.sections.export.import.doseHistory.import")}
</Button>
</div>
</SettingsCardActions>
</ImportCardShell>
);
}
@@ -1,8 +1,11 @@
import type { LucideIcon } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
export interface ImportCardShellProps {
testId: string;
icon: React.ComponentType<{ className?: string }>;
icon: LucideIcon;
title: string;
description: string;
children: React.ReactNode;
@@ -17,14 +20,7 @@ export function ImportCardShell({
}: ImportCardShellProps) {
return (
<SettingsCard data-testid={testId} className="flex h-full flex-col">
<div className="flex items-center gap-2">
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
<SettingsCardHeader icon={Icon} title={title} description={description} />
<div className="flex flex-1 flex-col gap-3">{children}</div>
</SettingsCard>
);
+10 -12
View File
@@ -17,7 +17,10 @@
* announce progress to assistive tech via `aria-live` regions.
*/
import { Upload } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/ui/section-heading";
import { AppleHealthImportCard } from "./apple-health-import-card";
import { JsonImportCard } from "./json-import-card";
import { CsvImportCard } from "./csv-import-card";
@@ -35,19 +38,14 @@ export function ImportPanel() {
return (
<section
aria-labelledby="settings-section-import-title"
className="space-y-3"
className="space-y-4"
>
<div className="space-y-1">
<h2
id="settings-section-import-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.import.heading")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.description")}
</p>
</div>
<SectionHeading
icon={Upload}
id="settings-section-import-title"
title={t("settings.sections.export.import.heading")}
subtitle={t("settings.sections.export.import.description")}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<AppleHealthImportCard />
<JsonImportCard />
@@ -5,6 +5,7 @@ import Link from "next/link";
import { AlertCircle, Download, FileJson, Loader2, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "@/lib/i18n/context";
@@ -196,7 +197,7 @@ export function JsonImportCard() {
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<SettingsCardActions className="mt-auto" align="start">
<Button
type="button"
variant="outline"
@@ -210,7 +211,7 @@ export function JsonImportCard() {
</Button>
<Button
asChild
variant="ghost"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
>
@@ -238,7 +239,7 @@ export function JsonImportCard() {
)}
{t("settings.sections.export.import.json.import")}
</Button>
</div>
</SettingsCardActions>
</ImportCardShell>
);
}
@@ -5,11 +5,11 @@ import { useMemo } from "react";
import { LayoutGrid, Loader2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { InsightsEditMode } from "@/components/insights/insights-edit-mode";
import { SectionHeading } from "@/components/ui/section-heading";
import { SettingsCard } from "@/components/settings/settings-card";
import { type InsightsSectionId } from "@/lib/insights-layout";
@@ -51,11 +51,11 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
aria-labelledby="insights-overview-arrange-title"
className="space-y-3"
>
<SettingsCardHeader
<SectionHeading
icon={LayoutGrid}
titleId="insights-overview-arrange-title"
id="insights-overview-arrange-title"
title={t("insights.settings.overviewTitle")}
description={t("insights.settings.overviewDescription")}
subtitle={t("insights.settings.overviewDescription")}
/>
{/* Gate the editor mount until the layout GET settles. The editor seeds
@@ -22,6 +22,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
@@ -263,20 +264,6 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
titleId="insights-pill-order-title"
title={t("insights.pillOrder.title")}
description={t("insights.pillOrder.description")}
status={
<Button
size="sm"
onClick={() => saveMutation.mutate(draftTiles)}
disabled={busy || !dirty}
data-slot="insights-pill-order-save"
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.pillOrder.save")}
</Button>
}
/>
{isLoading ? (
@@ -350,6 +337,20 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
))}
</div>
)}
<SettingsCardActions>
<Button
size="sm"
onClick={() => saveMutation.mutate(draftTiles)}
disabled={busy || !dirty}
data-slot="insights-pill-order-save"
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.pillOrder.save")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -32,8 +32,8 @@ import Link from "next/link";
import { Link2, Send } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { SectionHeading } from "@/components/ui/section-heading";
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
import { Button } from "@/components/ui/button";
@@ -55,12 +55,9 @@ export function IntegrationsSection() {
sources. A labelled group header gives this block parity with the
delivery-channels group below it. */}
<section className="space-y-4">
<SettingsCardHeader
<SectionHeading
icon={Link2}
title={t("settings.sections.integrations.connectionsHeading")}
description={t(
"settings.sections.integrations.connectionsDescription",
)}
/>
<ConnectionsPanel />
@@ -79,10 +76,10 @@ export function IntegrationsSection() {
`/settings/channels` redirect; the per-channel anchors stay inside
the panel. */}
<section id="channels" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
<SectionHeading
icon={Send}
title={t("settings.sections.integrations.channelsHeading")}
description={t("settings.sections.integrations.channelsDescription")}
subtitle={t("settings.sections.integrations.channelsDescription")}
/>
{/* Names the per-event notification matrix so the three notification
surfaces (matrix, reminder types, channels) cross-reference. */}
@@ -146,12 +146,12 @@ describe("<AppleHealthCard>", () => {
expect(html).toContain("quiet");
});
it("carries the shared card divider like every sibling", () => {
it("carries no card divider — the card gap does that job", () => {
statusPayload = {
lastSyncedAt: null,
syncHealth: { verdict: "pending_first_sync", since: null },
};
expect(render()).toContain('data-testid="integration-card-divider"');
expect(render()).not.toContain('data-testid="integration-card-divider"');
});
it("links to the existing one-shot Apple Health import fallback", () => {
@@ -80,13 +80,13 @@ describe("NightscoutCard — parked + test + data-link + invalidation", () => {
expect(html).toContain('href="/insights/blood-glucose"');
});
it("carries the shared card divider like every sibling", () => {
it("carries no card divider — the card gap does that job", () => {
const html = render({
connected: true,
configured: true,
syncHealth: { verdict: "fresh", since: null },
});
expect(html).toContain('data-testid="integration-card-divider"');
expect(html).not.toContain('data-testid="integration-card-divider"');
});
it("renders the per-metric freshness disclosure with a stale row", () => {
@@ -160,8 +160,6 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{isError && (
<IntegrationErrorMessage
@@ -32,6 +32,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -243,8 +244,6 @@ export function FitbitCard({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
@@ -360,10 +359,7 @@ export function FitbitCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -380,7 +376,7 @@ export function FitbitCard({
)}
{t("settings.fitbitSaveCredentials")}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -503,19 +499,26 @@ export function FitbitCard({
</Link>
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/fitbit/connect";
}}
>
<Link2 className="h-4 w-4" />
{t("settings.fitbitConnect")}
</Button>
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="fitbit-connect"
onClick={() => {
window.location.href = "/api/fitbit/connect";
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.fitbitConnect")}
</Button>
</SettingsCardActions>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.fitbitNoCredentials")}
</div>
</p>
)}
</div>
</SettingsCard>
@@ -40,6 +40,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -415,8 +416,6 @@ export function GoogleHealthCard({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
@@ -553,10 +552,7 @@ export function GoogleHealthCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -573,7 +569,7 @@ export function GoogleHealthCard({
)}
{t("settings.googleHealthSaveCredentials")}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -740,19 +736,26 @@ export function GoogleHealthCard({
</Link>
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/google-health/connect";
}}
>
<Link2 className="h-4 w-4" />
{t("settings.googleHealthConnect")}
</Button>
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="googlehealth-connect"
onClick={() => {
window.location.href = "/api/google-health/connect";
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.googleHealthConnect")}
</Button>
</SettingsCardActions>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.googleHealthNoCredentials")}
</div>
</p>
)}
</div>
</SettingsCard>
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
@@ -216,8 +217,6 @@ export function NightscoutCard({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{operatorApprovalRequired && (
@@ -323,7 +322,7 @@ export function NightscoutCard({
</p>
</div>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -342,7 +341,7 @@ export function NightscoutCard({
? t("settings.nightscoutUpdate")
: t("settings.nightscoutConnect")}
</Button>
</div>
</SettingsCardActions>
{msg && (
<p
@@ -33,6 +33,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
@@ -285,8 +286,6 @@ export function OAuthProviderCard({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && (
<div data-testid={`${provider}-error`}>
@@ -387,10 +386,7 @@ export function OAuthProviderCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -407,7 +403,7 @@ export function OAuthProviderCard({
)}
{t(`${i18nPrefix}SaveCredentials`)}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -505,17 +501,19 @@ export function OAuthProviderCard({
</Link>
</>
) : (
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={serverUnavailable}
onClick={handleConnect}
data-testid={`${provider}-connect`}
>
<Link2 className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Connect`)}
</Button>
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={serverUnavailable}
onClick={handleConnect}
data-testid={`${provider}-connect`}
>
<Link2 className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Connect`)}
</Button>
</SettingsCardActions>
)}
{msg && (
@@ -102,11 +102,14 @@ export function IntegrationRedirectGuide({
}
/**
* v1.18.7 (Wave F) — the one description skeleton every integration card paints:
* a WHITE primary sentence (`${i18nPrefix}Description`), then a GREY secondary
* sentence (`${i18nPrefix}DescriptionSecondary`) that ends with the setup-guide
* link rendered INLINE at the very end of the grey copy — not a standalone
* bottom-of-card element. Single-sourced so all six cards read as one family.
* The one description every integration card paints: ONE sentence, then the
* setup-guide link inline at the end of it.
*
* It used to be two paragraphs stacked in the description slot — a white
* primary sentence and a grey secondary one whose only job was to carry the
* link. The secondary sentence was byte-identical across six of the eight
* cards, which is the tell: it said nothing about the provider it sat under.
* The link is the part that carried information, so the link is what stayed.
*/
export function IntegrationCardDescription({
i18nPrefix,
@@ -118,21 +121,18 @@ export function IntegrationCardDescription({
const { t } = useTranslations();
return (
<>
<p className="text-foreground">{t(`${i18nPrefix}Description`)}</p>
<p className="text-muted-foreground">
{t(`${i18nPrefix}DescriptionSecondary`)}{" "}
<a
href={integrationDocsHref(provider)}
target="_blank"
rel="noopener noreferrer"
data-testid={`${provider}-setup-guide`}
data-slot="integration-setup-guide"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.integrationSetupGuide")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</p>
{t(`${i18nPrefix}Description`)}{" "}
<a
href={integrationDocsHref(provider)}
target="_blank"
rel="noopener noreferrer"
data-testid={`${provider}-setup-guide`}
data-slot="integration-setup-guide"
className="hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.integrationSetupGuide")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</>
);
}
@@ -25,6 +25,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
@@ -224,8 +225,6 @@ export function WhoopCard({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
@@ -342,10 +341,7 @@ export function WhoopCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -362,7 +358,7 @@ export function WhoopCard({
)}
{t("settings.whoopSaveCredentials")}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -485,19 +481,26 @@ export function WhoopCard({
</Link>
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/whoop/connect";
}}
>
<Link2 className="h-4 w-4" />
{t("settings.whoopConnect")}
</Button>
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="whoop-connect"
onClick={() => {
window.location.href = "/api/whoop/connect";
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.whoopConnect")}
</Button>
</SettingsCardActions>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.whoopNoCredentials")}
</div>
</p>
)}
</div>
</SettingsCard>
@@ -24,6 +24,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
@@ -236,8 +237,6 @@ export function WithingsCard({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
@@ -390,10 +389,7 @@ export function WithingsCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -410,7 +406,7 @@ export function WithingsCard({
)}
{t("settings.withingsSaveCredentials")}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -534,19 +530,26 @@ export function WithingsCard({
</Link>
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/withings/connect";
}}
>
<Link2 className="h-4 w-4" />
{t("settings.withingsConnect")}
</Button>
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="withings-connect"
onClick={() => {
window.location.href = "/api/withings/connect";
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.withingsConnect")}
</Button>
</SettingsCardActions>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.withingsNoCredentials")}
</div>
</p>
)}
</div>
</SettingsCard>
+1 -1
View File
@@ -219,7 +219,7 @@ export function LowStockCard({
// (`/settings/notifications#low-stock`) — keep it stable.
id="low-stock"
aria-labelledby="settings-low-stock-title"
className="scroll-mt-20"
className="scroll-mt-28"
>
<SettingsCardHeader
icon={PackageOpen}
@@ -5,8 +5,8 @@ import { Bell } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { Button } from "@/components/ui/button";
@@ -46,35 +46,14 @@ export function NotificationsSection() {
return (
<div className="space-y-10">
{/* Cross-links to the other two notification surfaces so this screen
names them — the per-event matrix and the delivery channels. */}
<div
className="flex flex-wrap gap-2"
data-slot="notifications-crosslinks"
>
<Button asChild variant="outline" size="sm">
<Link href="/notifications">
{t("settings.sections.notifications.matrixLink")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/integrations#channels">
{t("settings.sections.notifications.channelsLink")}
</Link>
</Button>
</div>
{/* Group 1 — Reminders ("what you receive"). Rendered only when at
least one reminder-type card is visible, so a fully-disabled
account never shows an empty heading. */}
{anyReminder ? (
<section className="space-y-4">
<SettingsCardHeader
<SectionHeading
icon={Bell}
title={t("settings.sections.notifications.remindersHeading")}
description={t(
"settings.sections.notifications.remindersDescription",
)}
/>
{/* Mood check-in reminder — only when the mood module is enabled. */}
@@ -93,6 +72,24 @@ export function NotificationsSection() {
) : null}
</section>
) : null}
{/* Cross-links to the other two notification surfaces. At the END of
the page: a screen that opens with two ways off it never says what
it is for. */}
<div
className="flex flex-wrap gap-2"
data-slot="notifications-crosslinks"
>
<Button asChild variant="outline" size="sm">
<Link href="/notifications">
{t("settings.sections.notifications.matrixLink")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/integrations#channels">
{t("settings.sections.notifications.channelsLink")}
</Link>
</Button>
</div>
</div>
);
}
+3 -2
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -192,7 +193,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/ntfy/test"
disabled={!settings?.enabled}
@@ -207,7 +208,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
+10 -9
View File
@@ -25,6 +25,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Skeleton } from "@/components/ui/skeleton";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -157,7 +158,7 @@ export function PrivacySection() {
title={t("settings.privacy.export.title")}
description={t("settings.privacy.export.description")}
/>
<div className="flex flex-wrap gap-2">
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm">
<Link href="/settings/export">
{t("settings.privacy.export.openExport")}
@@ -168,7 +169,7 @@ export function PrivacySection() {
{t("settings.privacy.export.openHealthRecord")}
</Link>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
{/* Delete / reset — with the honest backup lag disclosure */}
@@ -178,16 +179,16 @@ export function PrivacySection() {
title={t("settings.privacy.delete.title")}
description={t("settings.privacy.delete.description")}
/>
<div className="space-y-3">
<SettingsInfoTile icon={Info} tone="warning">
{t("settings.privacy.delete.backupLag")}
</SettingsInfoTile>
<SettingsInfoTile icon={Info} tone="warning">
{t("settings.privacy.delete.backupLag")}
</SettingsInfoTile>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm">
<Link href="/settings/advanced">
{t("settings.privacy.delete.openAdvanced")}
</Link>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
{/* Privacy posture (AI privacy mode + research mode live on their pages) */}
@@ -197,7 +198,7 @@ export function PrivacySection() {
title={t("settings.privacy.posture.title")}
description={t("settings.privacy.posture.description")}
/>
<div className="flex flex-wrap gap-2">
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm">
<Link href="/settings/ai">
{t("settings.privacy.posture.openAi")}
@@ -208,7 +209,7 @@ export function PrivacySection() {
{t("settings.privacy.posture.openResearch")}
</Link>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
{/* Active sessions + trusted devices + security activity (embedded) */}
+3 -2
View File
@@ -50,6 +50,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { toastWrittenOutcome } from "@/components/outcome/outcome-toast";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Gauge, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -443,7 +444,7 @@ export function ScoreSection() {
{/* Bottom-right, in its own row, like every other settings card
that saves a form. */}
<div className="flex justify-end">
<SettingsCardActions>
<Button
size="sm"
type="button"
@@ -460,7 +461,7 @@ export function ScoreSection() {
) : null}
{t("settings.sections.score.save")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
)}
</div>
@@ -13,6 +13,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -72,36 +73,30 @@ export function SecurityActivityCard({
return (
<SettingsCard data-slot="settings-security-activity-card">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-activity-toggle"
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<ShieldCheck
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<h2 className="text-lg font-semibold">
<SettingsCardHeader
icon={ShieldCheck}
title={
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-activity-toggle"
className="hover:text-foreground focus-visible:ring-ring/50 -m-1 flex min-h-11 items-center gap-2 rounded-md p-1 text-left focus-visible:ring-2 focus-visible:outline-none"
>
{t("settings.security.activityTitle")}
</h2>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</div>
</button>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</button>
}
description={t("settings.security.activityDescription")}
/>
<div id={regionId} hidden={!open} className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.security.activityDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
@@ -28,7 +28,7 @@ export function PasskeyUpgradeNudge() {
return (
<div className="border-primary/30 bg-primary/5 flex items-start gap-3 rounded-lg border p-4">
<Sparkles className="text-primary mt-0.5 h-5 w-5 shrink-0" />
<Sparkles className="text-muted-foreground mt-0.5 size-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{t("settings.security.passkeyNudge.title")}
@@ -23,6 +23,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -231,7 +232,7 @@ export function TotpCard({
{/* ── Enable wizard ── */}
{setup && (
<div className="space-y-4">
<ol className="text-muted-foreground list-decimal space-y-3 pl-5 text-sm">
<ol className="text-muted-foreground list-decimal space-y-3 pl-6 text-sm">
<li>{t("settings.security.totp.step1")}</li>
<li>{t("settings.security.totp.step2")}</li>
</ol>
@@ -326,7 +327,7 @@ export function TotpCard({
{freshCodes && (
<div>
<RecoveryCodesPanel codes={freshCodes} />
<div className="mt-3 flex justify-end">
<SettingsCardActions className="mt-3">
<Button
type="button"
variant="outline"
@@ -335,7 +336,7 @@ export function TotpCard({
>
{t("settings.security.recovery.savedIt")}
</Button>
</div>
</SettingsCardActions>
</div>
)}
@@ -14,6 +14,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -86,36 +88,30 @@ export function SecuritySessionsCard({
return (
<SettingsCard data-slot="settings-security-sessions-card">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-sessions-toggle"
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<MonitorSmartphone
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<h2 className="text-lg font-semibold">
<SettingsCardHeader
icon={MonitorSmartphone}
title={
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-sessions-toggle"
className="hover:text-foreground focus-visible:ring-ring/50 -m-1 flex min-h-11 items-center gap-2 rounded-md p-1 text-left focus-visible:ring-2 focus-visible:outline-none"
>
{t("settings.security.sessionsTitle")}
</h2>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</div>
</button>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</button>
}
description={t("settings.security.sessionsDescription")}
/>
<div id={regionId} hidden={!open} className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.security.sessionsDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
@@ -188,7 +184,7 @@ export function SecuritySessionsCard({
)}
{hasOthers && (
<div className="flex justify-end">
<SettingsCardActions>
<ConfirmButton
slot="sign-out-everywhere"
className="min-h-11 sm:min-h-9"
@@ -199,7 +195,7 @@ export function SecuritySessionsCard({
onConfirm={() => revokeOthers.mutate()}
pending={revokeOthers.isPending}
/>
</div>
</SettingsCardActions>
)}
{status && (
@@ -26,6 +26,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -453,22 +454,25 @@ export function ShareLinkCreateForm({
a page with nothing on it. The scope line above says so and says
what to do about it, and describes the button so the disabled state
is not silent for a screen reader. */}
<Button
type="submit"
disabled={
createMutation.isPending ||
!label.trim() ||
(!documentOnly && selectedLeaves.size === 0)
}
aria-describedby={documentOnly ? undefined : scopeSummaryId}
data-testid="share-create-submit"
className="min-h-11 sm:min-h-9"
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sharing.create")}
</Button>
<SettingsCardActions>
<Button
type="submit"
size="sm"
disabled={
createMutation.isPending ||
!label.trim() ||
(!documentOnly && selectedLeaves.size === 0)
}
aria-describedby={documentOnly ? undefined : scopeSummaryId}
data-testid="share-create-submit"
className="min-h-11 sm:min-h-9"
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sharing.create")}
</Button>
</SettingsCardActions>
</form>
<ShareDocumentPicker
+4 -5
View File
@@ -23,7 +23,7 @@
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileText, KeyRound, Share2, Trash2 } from "lucide-react";
import { FileText, KeyRound, Link2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -134,7 +134,7 @@ function ShareLinksCard() {
return (
<SettingsCard>
<SettingsCardHeader
icon={Share2}
icon={Link2}
title={t("settings.sharing.createTitle")}
description={t("settings.sharing.createDescription")}
/>
@@ -220,9 +220,9 @@ function ShareLinksCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
variant="destructive"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full sm:min-h-9"
className="min-h-11 w-full sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
@@ -242,7 +242,6 @@ function ShareLinksCard() {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
+30 -28
View File
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
@@ -548,35 +549,9 @@ export function SourcesSection() {
</div>
)}
{/* `flex-col-reverse` mirrors the DialogFooter idiom: the primary
action stacks on top on mobile, row order stays Cancel → Save
on `sm+`. */}
{dirty && priority && (
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
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>
</div>
)}
{/* v1.18.6 (W9) — quiet pointer back to Integrationen, where the
sources this ladder ranks are connected / removed. Folded into
the card so the page top stays the standard heading + subtitle. */}
sources this ladder ranks are connected / removed. Above the
action row: nothing follows a card's actions. */}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.sections.sources.integrationsHint")}{" "}
<Link
@@ -587,6 +562,33 @@ export function SourcesSection() {
{t("settings.sections.sources.integrationsHintLink")}
</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>
)}
</SettingsCard>
{/* v1.28 shipped `<NutrientIntakeCard>` here; the 2026-07-17 UX/IA
+12 -8
View File
@@ -2,9 +2,11 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, MessageCircle, Save } from "lucide-react";
import { Info, Loader2, MessageCircle, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -190,11 +192,13 @@ export function TelegramCard({
</Label>
</div>
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-xs">
<p>{t("settings.telegramStep1")}</p>
<p>{t("settings.telegramStep2")}</p>
<p>{t("settings.telegramStep3")}</p>
</div>
<SettingsInfoTile icon={Info} tone="neutral">
<ol className="list-decimal space-y-1 pl-4">
<li>{t("settings.telegramStep1")}</li>
<li>{t("settings.telegramStep2")}</li>
<li>{t("settings.telegramStep3")}</li>
</ol>
</SettingsInfoTile>
{msg && (
<p
@@ -207,7 +211,7 @@ export function TelegramCard({
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/telegram/test"
disabled={!settings?.hasBotToken}
@@ -224,7 +228,7 @@ export function TelegramCard({
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
@@ -6,6 +6,8 @@ import { toast } from "sonner";
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
@@ -120,31 +122,14 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
return (
<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.
Keep the icon + reset action so the card still has a visible
control affordance. */}
<div className="flex items-center justify-between">
<SlidersHorizontal
className="text-muted-foreground h-5 w-5"
aria-hidden="true"
/>
{data && Object.keys(data.overrides).length > 0 && (
<ConfirmButton
slot="settings-thresholds-reset-all"
variant="ghost"
size="sm"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("thresholds.resetAllAction")}
title={t("thresholds.resetAllTitle")}
body={t("thresholds.resetAllBody")}
confirmLabel={t("thresholds.resetAllConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate(null)}
/>
)}
</div>
{/* The card carries a real title. It used to render a naked icon and
a reset button, on the grounds that the page header already said
the same thing — which left one card in the tree with no heading
at all. The page subtitle is the one that goes. */}
<SettingsCardHeader
icon={SlidersHorizontal}
title={t("settings.sections.thresholds.title")}
/>
{isLoading || !data ? (
<ThresholdsSkeletonList />
@@ -163,6 +148,23 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
))}
</div>
)}
{data && Object.keys(data.overrides).length > 0 ? (
<SettingsCardActions>
<ConfirmButton
slot="settings-thresholds-reset-all"
variant="outline"
size="sm"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("thresholds.resetAllAction")}
title={t("thresholds.resetAllTitle")}
body={t("thresholds.resetAllBody")}
confirmLabel={t("thresholds.resetAllConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate(null)}
/>
</SettingsCardActions>
) : null}
</SettingsCard>
);
}
@@ -13,6 +13,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -148,7 +149,7 @@ export function TrustedDevicesCard({
)}
{devices.length > 0 && (
<div className="flex justify-end">
<SettingsCardActions>
<ConfirmButton
slot="revoke-all-trusted-devices"
className="min-h-11 sm:min-h-9"
@@ -161,7 +162,7 @@ export function TrustedDevicesCard({
onConfirm={() => revokeAll.mutate()}
pending={revokeAll.isPending}
/>
</div>
</SettingsCardActions>
)}
{status && (
+14 -13
View File
@@ -6,6 +6,7 @@ import { BellRing, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
@@ -161,17 +162,20 @@ export function WebPushCard() {
}
}
// The primary action sits in the header row (right of the title) rather than
// bottom-left, so an inactive card does not waste a wide empty band before
// its enable button. The loading spinner rides the same slot; the
// unsupported / denied notices stay in the body.
const headerAction = loading ? (
// The subscribe / unsubscribe control is this card's primary action, so it
// sits where every other card's does: the bottom action row. It used to ride
// the header status slot as an `outline`, which made the one card in the
// channel group that carries an enable button look like the one card that
// carries a status badge.
const cardAction = loading ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
) : !isSupported || isDenied ? null : (
<div className="flex flex-wrap items-center justify-end gap-2">
<SettingsCardActions>
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
{isSubscribed ? (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleUnsubscribe}
@@ -184,7 +188,6 @@ export function WebPushCard() {
</Button>
) : (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSubscribe}
@@ -198,10 +201,7 @@ export function WebPushCard() {
{t("settings.webPushSubscribe")}
</Button>
)}
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
</div>
</SettingsCardActions>
);
return (
@@ -210,7 +210,6 @@ export function WebPushCard() {
icon={BellRing}
title={t("settings.webPush")}
description={t("settings.webPushDescription")}
status={headerAction}
/>
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
@@ -235,6 +234,8 @@ export function WebPushCard() {
)}
</div>
) : null}
{cardAction}
</SettingsCard>
);
}
+3 -2
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Webhook } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -204,7 +205,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
@@ -219,7 +220,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</div>
</SettingsCard>