refactor(settings): route every card header through SettingsCardHeader

Hand-rolled section headers had drifted into a dozen variants — some
without an icon, some with a text-sm description instead of the shared
text-xs, the data-reset title once even tinted red. Route the remaining
ad-hoc headers through the SettingsCardHeader primitive so icon sizing,
title weight and description size live in one place.

Give the integrations connections group (the data sources) its own
labelled header for parity with the delivery-channels group below it.

A coverage test now globs every settings *-section.tsx / *-card.tsx and
fails on any hand-rolled <h2 text-lg> outside the primitive, with a short
allowlist for the collapsible-toggle security cards and the layout
section pending its hub rebuild.
This commit is contained in:
Marc-André Bombeck committed 2026-06-30 12:22:08 +02:00
1 parent f207f1a7d9
commit 304f0c7057
16 files changed
+323 -228

No files matched your search

+2
View File
@@ -4039,6 +4039,8 @@
"title": "Integrationen",
"description": "Verbundene Dienste, Zustellkanäle und die Quellen-Priorität an einem Ort.",
"subtitle": "Datenquellen und Geräte verbinden und verwalten.",
"connectionsHeading": "Verbindungen",
"connectionsDescription": "Datenquellen und Geräte verbinden und verwalten.",
"channelsHeading": "Zustellkanäle",
"channelsDescription": "Wohin Erinnerungen und Hinweise zugestellt werden."
},
+2
View File
@@ -4039,6 +4039,8 @@
"title": "Integrations",
"description": "Connected services, delivery channels, and source priority in one place.",
"subtitle": "Connect and manage your data sources and devices.",
"connectionsHeading": "Connections",
"connectionsDescription": "Connect and manage your data sources and devices.",
"channelsHeading": "Delivery channels",
"channelsDescription": "Where reminders and alerts are delivered."
},
+2
View File
@@ -4039,6 +4039,8 @@
"title": "Integraciones",
"description": "Servicios conectados, canales de entrega y prioridad de fuentes en un solo lugar.",
"subtitle": "Conecta y gestiona tus fuentes de datos y dispositivos.",
"connectionsHeading": "Conexiones",
"connectionsDescription": "Conecta y gestiona tus fuentes de datos y dispositivos.",
"channelsHeading": "Canales de entrega",
"channelsDescription": "Dónde se entregan los recordatorios y avisos."
},
+2
View File
@@ -4039,6 +4039,8 @@
"title": "Intégrations",
"description": "Services connectés, canaux de diffusion et priorité des sources au même endroit.",
"subtitle": "Connectez et gérez vos sources de données et appareils.",
"connectionsHeading": "Connexions",
"connectionsDescription": "Connectez et gérez vos sources de données et appareils.",
"channelsHeading": "Canaux de diffusion",
"channelsDescription": "Où sont envoyés les rappels et alertes."
},
+2
View File
@@ -4039,6 +4039,8 @@
"title": "Integrazioni",
"description": "Servizi collegati, canali di consegna e priorità delle fonti in un unico posto.",
"subtitle": "Collega e gestisci le tue fonti di dati e i dispositivi.",
"connectionsHeading": "Connessioni",
"connectionsDescription": "Collega e gestisci le tue fonti di dati e i dispositivi.",
"channelsHeading": "Canali di recapito",
"channelsDescription": "Dove vengono recapitati promemoria e avvisi."
},
+2
View File
@@ -4039,6 +4039,8 @@
"title": "Integracje",
"description": "Połączone usługi, kanały dostarczania i priorytet źródeł w jednym miejscu.",
"subtitle": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
"connectionsHeading": "Połączenia",
"connectionsDescription": "Połącz i zarządzaj źródłami danych oraz urządzeniami.",
"channelsHeading": "Kanały dostarczania",
"channelsDescription": "Gdzie dostarczane są przypomnienia i powiadomienia."
},
@@ -53,14 +53,17 @@ describe("<AdvancedSection> danger-zone shaping (v1.4.43 M3 + L5)", () => {
it("L5: the data-reset card title is neutral foreground (no destructive red)", () => {
const html = render();
// The reset card's title used to ship with `text-destructive`; the
// v1.4.43 fix pins it on `text-foreground`. We don't care if other
// strings in the section happen to use `text-destructive` (the CTA
// button does), only that the specific card title slot does NOT.
// v1.4.43 fix neutralised it. The header now routes through the
// canonical `<SettingsCardHeader>`, whose `<h2>` carries no colour
// class at all and so inherits the neutral foreground. We don't care
// if other strings in the section happen to use `text-destructive`
// (the CTA button does), only that the specific card title slot does
// NOT paint red.
const cardMatch = html.match(
/data-slot="settings-data-reset-card"[\s\S]*?<\/h2>/,
);
expect(cardMatch).not.toBeNull();
expect(cardMatch![0]).toContain("text-foreground");
expect(cardMatch![0]).toContain("text-lg font-semibold");
expect(cardMatch![0]).not.toContain("text-destructive");
});
@@ -0,0 +1,84 @@
/**
* Settings card-header drift guard.
*
* Every Settings card paints the same header shape — a muted icon, an
* `<h2 class="text-lg font-semibold">` title, and an optional muted
* description. The canonical primitive `SettingsCardHeader`
* (`_card-header.tsx`) owns that shape; routing every card header through
* it keeps the typography, the icon sizing, and the description size in
* one place instead of drifting across hand-rolled `flex … <h2 text-lg`
* permutations (the audit found five distinct variants once).
*
* This test globs every `*-section.tsx` / `*-card.tsx` under
* `src/components/settings/` and flags any file that still hand-rolls a
* top-level card header — a `<h2>` tag whose className carries `text-lg`.
* The primitive itself is excluded (it OWNS the `<h2 text-lg>`), and a
* short, explicitly-documented allowlist covers the handful of headers
* that legitimately cannot use the primitive yet.
*
* Mirrors the call-site coverage guards under `src/__tests__/`: walk the
* tree, match a mechanical pattern, assert the offending set is empty.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const ROOT = join(__dirname, "../../../..");
const SETTINGS_DIR = join(ROOT, "src/components/settings");
// Files allowed to retain a hand-rolled `<h2 text-lg>` header.
const ALLOWLIST = new Set<string>([
// TODO #148: rebuilt into hub+subpages, will use SettingsCardHeader then
"layout-section.tsx",
// allowed: collapsible-toggle header, button-wrapped, typography already canonical
"security-activity-card.tsx",
// allowed: collapsible-toggle header, button-wrapped, typography already canonical
"security-sessions-card.tsx",
]);
// The primitive itself owns the canonical `<h2 text-lg>`.
const PRIMITIVE = "_card-header.tsx";
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
if (name === "__tests__" || name === "node_modules") continue;
const p = join(dir, name);
const stat = statSync(p);
if (stat.isDirectory()) {
walk(p, out);
} else if (name.endsWith("-section.tsx") || name.endsWith("-card.tsx")) {
out.push(p);
}
}
return out;
}
// A hand-rolled card header: a `<h2` tag whose className (within a small
// window, so a multi-line opening tag still matches) carries `text-lg`.
const AD_HOC_HEADER = /<h2\b[\s\S]{0,160}?text-lg/;
describe("Settings card headers route through SettingsCardHeader", () => {
it("has no hand-rolled <h2 text-lg> outside the primitive + allowlist", () => {
const offenders: string[] = [];
for (const file of walk(SETTINGS_DIR)) {
const base = file.slice(file.lastIndexOf("/") + 1);
if (base === PRIMITIVE) continue;
if (ALLOWLIST.has(base)) continue;
const source = readFileSync(file, "utf8");
if (AD_HOC_HEADER.test(source)) {
offenders.push(relative(ROOT, file));
}
}
expect(
offenders,
`These Settings files still hand-roll a card header (\`<h2 ... text-lg>\`).\n` +
`Route them through <SettingsCardHeader> (src/components/settings/_card-header.tsx),\n` +
`or, if a header genuinely cannot use the primitive, add it to the ALLOWLIST\n` +
`in this test with a one-line justification.\n\n` +
offenders.map((f) => ` - ${f}`).join("\n"),
).toEqual([]);
});
});
@@ -473,27 +473,21 @@ export function AccountSection() {
card's "Restart onboarding tour" actually broke through the
right border by ~48 px). */}
<SettingsCard>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="flex items-start gap-2">
<Shield className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0" />
<div className="space-y-1">
<h2 className="text-lg font-semibold">
{t("settings.passwordReset")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.changePasswordDescription")}
</p>
</div>
</div>
<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>
</div>
<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>
}
/>
</SettingsCard>
{/* v1.25.7 — active-session management + the security-activity feed
+119 -125
View File
@@ -258,72 +258,69 @@ function DataResetCard() {
GitHub-style (red CTA only) rather than red-on-red-on-red.
The protective gate (confirmation dialog) is unchanged; this
is purely a visual-tone fix per the v1.4.43 audit. */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.dangerZoneDescription")}
</p>
</div>
<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
<SettingsCardHeader
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"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
size="sm"
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{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.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{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 && (
<p
@@ -398,73 +395,70 @@ function AccountDeleteCard() {
return (
<SettingsCard data-slot="settings-account-delete-card">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
<div className="space-y-1">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.deleteAccountCardTitle")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.deleteAccountCardDescription")}
</p>
</div>
<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
<SettingsCardHeader
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"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
size="sm"
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
{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.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{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 && (
<p
@@ -52,6 +52,7 @@ import { apiDelete, apiGet, apiPut } from "@/lib/api/api-fetch";
import { useAuth } from "@/hooks/use-auth";
import { WIDGET_MODULE_BY_ID } from "@/lib/dashboard/widget-modules";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
/**
* v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the
@@ -336,24 +337,22 @@ export function DashboardLayoutSection({ id }: { id: string }) {
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. */}
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="flex items-center gap-2">
<LayoutDashboard className="text-muted-foreground h-5 w-5" />
<h2 className="text-lg font-semibold">
{t("dashboard.customizeTitle")}
</h2>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
>
<RotateCcw className="h-3.5 w-3.5" />
{t("dashboard.layoutReset")}
</Button>
</div>
<SettingsCardHeader
icon={LayoutDashboard}
title={t("dashboard.customizeTitle")}
status={
<Button
variant="ghost"
size="sm"
onClick={() => resetMutation.mutate()}
disabled={resetMutation.isPending}
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
>
<RotateCcw className="h-3.5 w-3.5" />
{t("dashboard.layoutReset")}
</Button>
}
/>
{/*
v1.4.22 D / F-32 — the surrounding `<DashboardSection>` page
already renders a `settings.sections.dashboard.description`
@@ -24,7 +24,10 @@
* `/api/share-links` routes, and the public `/c/[token]` view are unchanged.
*/
import { Share2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { HealthRecordExportPanel } from "@/components/settings/health-record-export-panel";
import { SharingSection } from "@/components/settings/sharing-section";
@@ -39,14 +42,11 @@ export function GesundheitsakteSection() {
<HealthRecordExportPanel />
<section id="sharing" className="scroll-mt-28 space-y-4">
<div className="space-y-0.5">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.sections.sharing.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("settings.sections.sharing.subtitle")}
</p>
</div>
<SettingsCardHeader
icon={Share2}
title={t("settings.sections.sharing.title")}
description={t("settings.sections.sharing.subtitle")}
/>
<SharingSection />
</section>
</div>
@@ -2,9 +2,10 @@
import { useMemo } from "react";
import { Loader2 } from "lucide-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";
@@ -50,17 +51,12 @@ export function InsightsOverviewArrangeSection({ id }: { id?: string }) {
aria-labelledby="insights-overview-arrange-title"
className="space-y-3"
>
<header className="space-y-1">
<h2
id="insights-overview-arrange-title"
className="text-lg font-semibold"
>
{t("insights.settings.overviewTitle")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.settings.overviewDescription")}
</p>
</header>
<SettingsCardHeader
icon={LayoutGrid}
titleId="insights-overview-arrange-title"
title={t("insights.settings.overviewTitle")}
description={t("insights.settings.overviewDescription")}
/>
{/* Gate the editor mount until the layout GET settles. The editor seeds
its draft once from `layout` on mount, which is the canonical default
@@ -3,7 +3,7 @@
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Eye, EyeOff, GripVertical, Loader2 } from "lucide-react";
import { Eye, EyeOff, GripVertical, ListOrdered, Loader2 } from "lucide-react";
import {
DndContext,
KeyboardSensor,
@@ -44,6 +44,7 @@ import {
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
import { apiPut } from "@/lib/api/api-fetch";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
/**
* v1.15.18 — dedicated pill-sort control for the Insights settings section.
@@ -210,28 +211,26 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
aria-labelledby="insights-pill-order-title"
className="space-y-4"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="space-y-1">
<h2 id="insights-pill-order-title" className="text-lg font-semibold">
{t("insights.pillOrder.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("insights.pillOrder.description")}
</p>
</div>
<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>
</div>
<SettingsCardHeader
icon={ListOrdered}
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 ? (
<p
@@ -21,7 +21,10 @@
* `connections-panel.tsx` so existing unit-test imports keep resolving.
*/
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 { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
@@ -38,21 +41,30 @@ export function IntegrationsSection() {
// primary surface, with the delivery-channels group sequenced below it.
return (
<div className="space-y-10">
<ConnectionsPanel />
{/* Connections ("where the data comes from"). The OAuth + device data
sources. A labelled group header gives this block parity with the
delivery-channels group below it. */}
<section className="space-y-4">
<SettingsCardHeader
icon={Link2}
title={t("settings.sections.integrations.connectionsHeading")}
description={t(
"settings.sections.integrations.connectionsDescription",
)}
/>
<ConnectionsPanel />
</section>
{/* Delivery channels ("where it's delivered"). The existing channels
panel, unchanged. `id="channels"` anchors the group for the
`/settings/channels` redirect; the per-channel anchors stay inside
the panel. */}
<section id="channels" className="scroll-mt-28 space-y-4">
<div className="space-y-0.5">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.sections.integrations.channelsHeading")}
</h2>
<p className="text-muted-foreground text-sm">
{t("settings.sections.integrations.channelsDescription")}
</p>
</div>
<SettingsCardHeader
icon={Send}
title={t("settings.sections.integrations.channelsHeading")}
description={t("settings.sections.integrations.channelsDescription")}
/>
<NotificationChannelsPanel />
</section>
</div>
@@ -1,7 +1,10 @@
"use client";
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 { MoodReminderCard } from "@/components/settings/mood-reminder-card";
@@ -46,14 +49,13 @@ export function NotificationsSection() {
account never shows an empty heading. */}
{anyReminder ? (
<section className="space-y-4">
<div className="space-y-0.5">
<h2 className="text-foreground text-lg font-semibold">
{t("settings.sections.notifications.remindersHeading")}
</h2>
<p className="text-muted-foreground text-sm">
{t("settings.sections.notifications.remindersDescription")}
</p>
</div>
<SettingsCardHeader
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. */}
{moodEnabled ? (