From 304f0c705727e24e56b597dfbd208b5ecb273b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 30 Jun 2026 12:22:08 +0200 Subject: [PATCH] refactor(settings): route every card header through SettingsCardHeader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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

outside the primitive, with a short allowlist for the collapsible-toggle security cards and the layout section pending its hub rebuild. --- messages/de.json | 2 + messages/en.json | 2 + messages/es.json | 2 + messages/fr.json | 2 + messages/it.json | 2 + messages/pl.json | 2 + .../advanced-account-delete.test.tsx | 11 +- .../settings-card-header-coverage.test.ts | 84 ++++++ .../settings/account-section/index.tsx | 36 ++- src/components/settings/advanced-section.tsx | 244 +++++++++--------- .../settings/dashboard-layout-section.tsx | 35 ++- .../settings/gesundheitsakte-section.tsx | 16 +- .../insights-overview-arrange-section.tsx | 20 +- .../settings/insights-pill-order-section.tsx | 45 ++-- .../settings/integrations-section.tsx | 30 ++- .../settings/notifications-section.tsx | 18 +- 16 files changed, 323 insertions(+), 228 deletions(-) create mode 100644 src/components/settings/__tests__/settings-card-header-coverage.test.ts diff --git a/messages/de.json b/messages/de.json index e25fd4009..81fd5f333 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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." }, diff --git a/messages/en.json b/messages/en.json index ab971937a..288535375 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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." }, diff --git a/messages/es.json b/messages/es.json index 61dae0946..4433313fa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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." }, diff --git a/messages/fr.json b/messages/fr.json index 1bdaa1ba6..07dcf3613 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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." }, diff --git a/messages/it.json b/messages/it.json index 35ad15289..8722951d1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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." }, diff --git a/messages/pl.json b/messages/pl.json index f5d479989..1b3521565 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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." }, diff --git a/src/components/settings/__tests__/advanced-account-delete.test.tsx b/src/components/settings/__tests__/advanced-account-delete.test.tsx index fd1c4e711..fd04efba8 100644 --- a/src/components/settings/__tests__/advanced-account-delete.test.tsx +++ b/src/components/settings/__tests__/advanced-account-delete.test.tsx @@ -53,14 +53,17 @@ describe(" 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 ``, whose `

` 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"); }); diff --git a/src/components/settings/__tests__/settings-card-header-coverage.test.ts b/src/components/settings/__tests__/settings-card-header-coverage.test.ts new file mode 100644 index 000000000..46dfcf65b --- /dev/null +++ b/src/components/settings/__tests__/settings-card-header-coverage.test.ts @@ -0,0 +1,84 @@ +/** + * Settings card-header drift guard. + * + * Every Settings card paints the same header shape — a muted icon, an + * `

` 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 …

` tag whose className carries `text-lg`. + * The primitive itself is excluded (it OWNS the `

`), 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 `

` header. +const ALLOWLIST = new Set([ + // 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 `

`. +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 ` { + it("has no hand-rolled

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 (\`

\`).\n` + + `Route them through (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([]); + }); +}); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index c51307fc6..1ca9531b9 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -473,27 +473,21 @@ export function AccountSection() { card's "Restart onboarding tour" actually broke through the right border by ~48 px). */} -
-
- -
-

- {t("settings.passwordReset")} -

-

- {t("settings.changePasswordDescription")} -

-
-
- -
+ setPasswordDialogOpen(true)} + className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto" + > + {t("settings.changePassword")} + + } + />
{/* v1.25.7 — active-session management + the security-activity feed diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 39ef7f925..eb47d5c6c 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -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. */} -
-
-

- {t("settings.dangerZone")} -

-

- {t("settings.dangerZoneDescription")} -

-
- { - // 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); - }} - > - - - - - - - {t("settings.dangerZoneConfirm")} - - - {t("settings.dangerZoneConfirmDescription")} - - - - - {t("common.cancel")} - - { + // 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); + }} + > + +
+ {t("settings.dangerZone")} + + + + + + {t("settings.dangerZoneConfirm")} + + + {t("settings.dangerZoneConfirmDescription")} + + + + + {t("common.cancel")} + + { + e.preventDefault(); + void handleDeleteAllData(); + }} + disabled={deleting} + aria-busy={deleting || undefined} + data-slot="settings-data-reset-confirm" + > + {deleting && ( + + )} + {t("settings.finalDelete")} + + + + + } + /> {msg && (

-

-
-

- {t("settings.deleteAccountCardTitle")} -

-

- {t("settings.deleteAccountCardDescription")} -

-
- { - // 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); - }} - > - - - - - - - {t("settings.deleteAccountConfirmTitle")} - - - {t("settings.deleteAccountConfirmDescription")} - - - - - {t("common.cancel")} - - { + // 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); + }} + > + +
+ {t("settings.deleteAccountCta")} + + + + + + {t("settings.deleteAccountConfirmTitle")} + + + {t("settings.deleteAccountConfirmDescription")} + + + + + {t("common.cancel")} + + { + e.preventDefault(); + void handleDeleteAccount(); + }} + disabled={deleting} + aria-busy={deleting || undefined} + data-slot="settings-account-delete-confirm" + > + {deleting && ( + + )} + {t("settings.deleteAccountFinal")} + + + + + } + /> {msg && (

-

- -

- {t("dashboard.customizeTitle")} -

-
- - + resetMutation.mutate()} + disabled={resetMutation.isPending} + className="min-h-11 self-end sm:min-h-9 sm:self-auto" + > + + {t("dashboard.layoutReset")} + + } + /> {/* v1.4.22 D / F-32 — the surrounding `` page already renders a `settings.sections.dashboard.description` diff --git a/src/components/settings/gesundheitsakte-section.tsx b/src/components/settings/gesundheitsakte-section.tsx index ea13de7bd..45d9389a2 100644 --- a/src/components/settings/gesundheitsakte-section.tsx +++ b/src/components/settings/gesundheitsakte-section.tsx @@ -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() {
-
-

- {t("settings.sections.sharing.title")} -

-

- {t("settings.sections.sharing.subtitle")} -

-
+
diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx index e73d5ec09..7c63760c4 100644 --- a/src/components/settings/insights-overview-arrange-section.tsx +++ b/src/components/settings/insights-overview-arrange-section.tsx @@ -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" > -
-

- {t("insights.settings.overviewTitle")} -

-

- {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 diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index 248e61053..59fa7eb51 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -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" > -
-
-

- {t("insights.pillOrder.title")} -

-

- {t("insights.pillOrder.description")} -

-
- -
+ 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 && ( + + )} + {t("insights.pillOrder.save")} + + } + /> {isLoading ? (

- + {/* 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. */} +

+ + +
{/* 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. */}
-
-

- {t("settings.sections.integrations.channelsHeading")} -

-

- {t("settings.sections.integrations.channelsDescription")} -

-
+
diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 5e0eba377..8ba29af03 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -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 ? (
-
-

- {t("settings.sections.notifications.remindersHeading")} -

-

- {t("settings.sections.notifications.remindersDescription")} -

-
+ {/* Mood check-in reminder — only when the mood module is enabled. */} {moodEnabled ? (