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 ? (