diff --git a/messages/de.json b/messages/de.json index 655c12676..59742c675 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4560,7 +4560,6 @@ "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.", "matrixLink": "Benachrichtigungen je Kanal" @@ -4570,7 +4569,6 @@ "description": "Alle Erinnerungen an einem Ort — was wann erinnert wird und wohin es zugestellt wird.", "subtitle": "Wähle, welche Erinnerungen du erhältst und wohin sie zugestellt werden.", "remindersHeading": "Erinnerungen", - "remindersDescription": "Was du erhältst.", "matrixLink": "Benachrichtigungen je Kanal", "channelsLink": "Zustellkanäle" }, @@ -5550,7 +5548,6 @@ "googleHealthTag": "Fitbit & Pixel", "googleHealthBetaBadge": "Beta", "googleHealthDescription": "Verbinde Fitbit, Pixel Watch und Fitbit Air über dein Google-Konto — Gewicht, Herzfrequenz, Schlaf und Aktivität. Das ersetzt die klassische Fitbit-Verbindung, die Google im September 2026 abschaltet.", - "googleHealthDescriptionSecondary": "Du registrierst deinen eigenen Google-Cloud-Client und hinterlegst die Zugangsdaten hier. Stress und Erholung liefert die API nicht, und Google kann dich von Zeit zu Zeit erneut um Zustimmung bitten. Neu bei der Einrichtung? Folge der", "googleHealthCredentials": "API-Zugangsdaten", "googleHealthClientId": "Client-ID", "googleHealthClientSecret": "Client-Secret", @@ -5947,13 +5944,6 @@ "saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen." } }, - "withingsDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "whoopDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "fitbitDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "polarDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", - "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.", "integrationRedirectGuide": { "intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:", "step1": "Erstelle eine App im {provider}-Entwicklerportal.", @@ -6139,7 +6129,6 @@ }, "strava": "Strava", "stravaDescription": "Verbinde dein Strava-Konto, um deine Aktivitäten in deinen Trainingsverlauf zu übernehmen.", - "stravaDescriptionSecondary": "Aktivitäten fließen im Hintergrund in dein Dashboard. Neu bei der Einrichtung? Folge der", "stravaViewData": "Trainings ansehen", "stravaCredentials": "API-Zugangsdaten", "stravaClientId": "Client-ID", diff --git a/messages/en.json b/messages/en.json index 937dd96d6..49e5a9425 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4560,7 +4560,6 @@ "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.", "matrixLink": "Per-channel notifications" @@ -4570,7 +4569,6 @@ "description": "Every reminder in one place — what gets reminded and when, and where it's delivered.", "subtitle": "Choose which reminders you receive and where they're delivered.", "remindersHeading": "Reminders", - "remindersDescription": "What you receive.", "matrixLink": "Per-channel notifications", "channelsLink": "Delivery channels" }, @@ -5550,7 +5548,6 @@ "googleHealthTag": "Fitbit & Pixel", "googleHealthBetaBadge": "Beta", "googleHealthDescription": "Connect Fitbit, Pixel Watch and Fitbit Air through your Google account — weight, heart rate, sleep and activity. This replaces the classic Fitbit connection, which Google retires in September 2026.", - "googleHealthDescriptionSecondary": "You register your own Google Cloud client and bring the credentials here. Stress and readiness are not offered by the API, and Google may ask you to reconnect from time to time. New to the setup? Follow the", "googleHealthCredentials": "API Credentials", "googleHealthClientId": "Client ID", "googleHealthClientSecret": "Client Secret", @@ -5947,13 +5944,6 @@ "saveError": "Could not save the change. Please try again." } }, - "withingsDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "whoopDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "fitbitDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "polarDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", - "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.", "integrationRedirectGuide": { "intro": "This connects through your own {provider} developer app — about 5 minutes to set up:", "step1": "Create an app in the {provider} developer portal.", @@ -6139,7 +6129,6 @@ }, "strava": "Strava", "stravaDescription": "Connect your Strava account to sync your activities into your workout history.", - "stravaDescriptionSecondary": "Activities flow into your dashboard in the background. New to the setup? Follow the", "stravaViewData": "View your workouts", "stravaCredentials": "API Credentials", "stravaClientId": "Client ID", diff --git a/messages/es.json b/messages/es.json index 880809f8d..01568c5c7 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4560,7 +4560,6 @@ "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.", "matrixLink": "Notificaciones por canal" @@ -4570,7 +4569,6 @@ "description": "Todos los recordatorios en un solo lugar: qué se recuerda y cuándo, y dónde se entrega.", "subtitle": "Elige qué recordatorios recibes y dónde se entregan.", "remindersHeading": "Recordatorios", - "remindersDescription": "Lo que recibes.", "matrixLink": "Notificaciones por canal", "channelsLink": "Canales de entrega" }, @@ -5550,7 +5548,6 @@ "googleHealthTag": "Fitbit y Pixel", "googleHealthBetaBadge": "Beta", "googleHealthDescription": "Conecta Fitbit, Pixel Watch y Fitbit Air a través de tu cuenta de Google: peso, frecuencia cardíaca, sueño y actividad. Sustituye a la conexión clásica de Fitbit, que Google retira en septiembre de 2026.", - "googleHealthDescriptionSecondary": "Registras tu propio cliente de Google Cloud y traes aquí las credenciales. La API no ofrece estrés ni preparación, y Google puede pedirte que vuelvas a conectar de vez en cuando. ¿Es tu primera vez? Sigue la", "googleHealthCredentials": "Credenciales de API", "googleHealthClientId": "ID de cliente", "googleHealthClientSecret": "Secreto de cliente", @@ -5947,13 +5944,6 @@ "saveError": "No se pudo guardar el cambio. Inténtalo de nuevo." } }, - "withingsDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "whoopDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "fitbitDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "polarDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", - "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.", "integrationRedirectGuide": { "intro": "Esto se conecta mediante tu propia app de desarrollador de {provider} — unos 5 minutos de configuración:", "step1": "Crea una app en el portal de desarrolladores de {provider}.", @@ -6139,7 +6129,6 @@ }, "strava": "Strava", "stravaDescription": "Conecta tu cuenta de Strava para sincronizar tus actividades con tu historial de entrenamientos.", - "stravaDescriptionSecondary": "Las actividades llegan a tu panel en segundo plano. ¿Nuevo en la configuración? Sigue la", "stravaViewData": "Ver tus entrenamientos", "stravaCredentials": "Credenciales de API", "stravaClientId": "ID de cliente", diff --git a/messages/fr.json b/messages/fr.json index 45dfdd626..a1f9c84e2 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4560,7 +4560,6 @@ "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.", "matrixLink": "Notifications par canal" @@ -4570,7 +4569,6 @@ "description": "Tous les rappels au même endroit — quoi et quand, et où c'est envoyé.", "subtitle": "Choisissez les rappels que vous recevez et où ils sont envoyés.", "remindersHeading": "Rappels", - "remindersDescription": "Ce que vous recevez.", "matrixLink": "Notifications par canal", "channelsLink": "Canaux de diffusion" }, @@ -5550,7 +5548,6 @@ "googleHealthTag": "Fitbit et Pixel", "googleHealthBetaBadge": "Bêta", "googleHealthDescription": "Connecte Fitbit, Pixel Watch et Fitbit Air via ton compte Google — poids, fréquence cardiaque, sommeil et activité. Cela remplace la connexion Fitbit classique, que Google arrête en septembre 2026.", - "googleHealthDescriptionSecondary": "Tu enregistres ton propre client Google Cloud et saisis les identifiants ici. L'API ne fournit ni stress ni disponibilité, et Google peut te demander de te reconnecter de temps en temps. Première configuration ? Suis le", "googleHealthCredentials": "Identifiants d'API", "googleHealthClientId": "ID client", "googleHealthClientSecret": "Secret client", @@ -5947,13 +5944,6 @@ "saveError": "Impossible d enregistrer la modification. Veuillez réessayer." } }, - "withingsDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "whoopDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "fitbitDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "polarDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", - "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.", "integrationRedirectGuide": { "intro": "Cette connexion passe par votre propre application développeur {provider} — environ 5 minutes de configuration :", "step1": "Créez une application dans le portail développeur {provider}.", @@ -6139,7 +6129,6 @@ }, "strava": "Strava", "stravaDescription": "Connecte ton compte Strava pour synchroniser tes activités avec ton historique d'entraînement.", - "stravaDescriptionSecondary": "Les activités arrivent sur ton tableau de bord en arrière-plan. Nouveau dans la configuration ? Suis le", "stravaViewData": "Voir tes entraînements", "stravaCredentials": "Identifiants API", "stravaClientId": "ID client", diff --git a/messages/it.json b/messages/it.json index fdb54961b..d19756fef 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4560,7 +4560,6 @@ "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.", "matrixLink": "Notifiche per canale" @@ -4570,7 +4569,6 @@ "description": "Tutti i promemoria in un unico posto — cosa e quando, e dove vengono recapitati.", "subtitle": "Scegli quali promemoria ricevere e dove vengono recapitati.", "remindersHeading": "Promemoria", - "remindersDescription": "Ciò che ricevi.", "matrixLink": "Notifiche per canale", "channelsLink": "Canali di recapito" }, @@ -5550,7 +5548,6 @@ "googleHealthTag": "Fitbit e Pixel", "googleHealthBetaBadge": "Beta", "googleHealthDescription": "Collega Fitbit, Pixel Watch e Fitbit Air tramite il tuo account Google — peso, frequenza cardiaca, sonno e attività. Sostituisce la connessione Fitbit classica, che Google ritira a settembre 2026.", - "googleHealthDescriptionSecondary": "Registri il tuo client Google Cloud e inserisci qui le credenziali. L'API non offre stress né prontezza, e Google può chiederti di riconnetterti di tanto in tanto. Prima configurazione? Segui la", "googleHealthCredentials": "Credenziali API", "googleHealthClientId": "ID client", "googleHealthClientSecret": "Client secret", @@ -5947,13 +5944,6 @@ "saveError": "Impossibile salvare la modifica. Riprova." } }, - "withingsDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "whoopDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "fitbitDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "polarDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", - "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.", "integrationRedirectGuide": { "intro": "Questa connessione passa dalla tua app sviluppatore {provider} — circa 5 minuti di configurazione:", "step1": "Crea un'app nel portale sviluppatori {provider}.", @@ -6139,7 +6129,6 @@ }, "strava": "Strava", "stravaDescription": "Collega il tuo account Strava per sincronizzare le tue attività con la cronologia degli allenamenti.", - "stravaDescriptionSecondary": "Le attività arrivano nella tua dashboard in background. Nuovo alla configurazione? Segui la", "stravaViewData": "Vedi i tuoi allenamenti", "stravaCredentials": "Credenziali API", "stravaClientId": "ID client", diff --git a/messages/pl.json b/messages/pl.json index af9ee6bc6..5edbe577e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4560,7 +4560,6 @@ "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.", "matrixLink": "Powiadomienia wg kanału" @@ -4570,7 +4569,6 @@ "description": "Wszystkie przypomnienia w jednym miejscu — co i kiedy oraz gdzie są dostarczane.", "subtitle": "Wybierz, które przypomnienia otrzymujesz i gdzie są dostarczane.", "remindersHeading": "Przypomnienia", - "remindersDescription": "Co otrzymujesz.", "matrixLink": "Powiadomienia wg kanału", "channelsLink": "Kanały dostarczania" }, @@ -5550,7 +5548,6 @@ "googleHealthTag": "Fitbit i Pixel", "googleHealthBetaBadge": "Beta", "googleHealthDescription": "Połącz Fitbit, Pixel Watch i Fitbit Air przez konto Google — waga, tętno, sen i aktywność. Zastępuje to klasyczne połączenie Fitbit, które Google wyłącza we wrześniu 2026.", - "googleHealthDescriptionSecondary": "Rejestrujesz własnego klienta Google Cloud i wprowadzasz tutaj dane logowania. API nie udostępnia stresu ani gotowości, a Google może od czasu do czasu poprosić o ponowne połączenie. Pierwsza konfiguracja? Skorzystaj z", "googleHealthCredentials": "Dane API", "googleHealthClientId": "Identyfikator klienta", "googleHealthClientSecret": "Sekret klienta", @@ -5947,13 +5944,6 @@ "saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie." } }, - "withingsDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "whoopDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "fitbitDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "polarDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", - "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.", "integrationRedirectGuide": { "intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider} — około 5 minut konfiguracji:", "step1": "Utwórz aplikację w portalu deweloperskim {provider}.", @@ -6139,7 +6129,6 @@ }, "strava": "Strava", "stravaDescription": "Połącz konto Strava, aby synchronizować aktywności z historią treningów.", - "stravaDescriptionSecondary": "Aktywności trafiają na pulpit w tle. Nowy w konfiguracji? Skorzystaj z", "stravaViewData": "Zobacz treningi", "stravaCredentials": "Dane API", "stravaClientId": "Identyfikator klienta", diff --git a/src/components/admin/api-token-overview-section.tsx b/src/components/admin/api-token-overview-section.tsx index 4fc526403..892b6d250 100644 --- a/src/components/admin/api-token-overview-section.tsx +++ b/src/components/admin/api-token-overview-section.tsx @@ -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 ( @@ -317,7 +317,7 @@ export function ApiTokenOverviewSection() { -
+
+ ); } diff --git a/src/components/admin/reminders-section.tsx b/src/components/admin/reminders-section.tsx index 183fdc2de..b88338f78 100644 --- a/src/components/admin/reminders-section.tsx +++ b/src/components/admin/reminders-section.tsx @@ -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() { -
+
+ {testNotification.data?.results && testNotification.data.results.length > 0 && ( diff --git a/src/components/admin/umami-section.tsx b/src/components/admin/umami-section.tsx index 7c8707c6c..09f037528 100644 --- a/src/components/admin/umami-section.tsx +++ b/src/components/admin/umami-section.tsx @@ -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() { -
+
+ ); } diff --git a/src/components/admin/web-push-vapid-section.tsx b/src/components/admin/web-push-vapid-section.tsx index bd7013642..7a1e4c797 100644 --- a/src/components/admin/web-push-vapid-section.tsx +++ b/src/components/admin/web-push-vapid-section.tsx @@ -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() { -
+
+ !s.visible); return ( -
+

-

+ ); } diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx index ec5cf418c..b06e412a4 100644 --- a/src/components/settings/__tests__/integrations-section.test.tsx +++ b/src/components/settings/__tests__/integrations-section.test.tsx @@ -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 `
` 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" diff --git a/src/components/settings/_info-tile.tsx b/src/components/settings/_info-tile.tsx index 8b61224d6..317c8e88d 100644 --- a/src/components/settings/_info-tile.tsx +++ b/src/components/settings/_info-tile.tsx @@ -43,7 +43,10 @@ const TONE_ICON: Record = { 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 (
{pendingQuestions.length > 0 && ( -
-

-

-
    +
      {pendingQuestions.map((q) => (
    • {q}
    • ))} @@ -325,7 +321,7 @@ export function AboutMeSection({ {t("settings.ai.aboutMe.questionsOpenCoach")}

      -
+ )} {query.isError && ( diff --git a/src/components/settings/about-section.tsx b/src/components/settings/about-section.tsx index 1770f6711..c4ebf4af1 100644 --- a/src/components/settings/about-section.tsx +++ b/src/components/settings/about-section.tsx @@ -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")} /> -
+ -
+ )} diff --git a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx index 9d7c16213..49bb21f75 100644 --- a/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx +++ b/src/components/settings/access/__tests__/grant-invite-expiry.test.tsx @@ -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", ); }); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index e63d0acd1..2909d0711 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -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")}

- {error && (

{error} @@ -343,6 +329,27 @@ export function GrantInviteCard() { {t("recordSharing.invite.sent", { name: invited })}

)} + + + ); diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx index 9b90ab0a9..280bc5669 100644 --- a/src/components/settings/account-section/index.tsx +++ b/src/components/settings/account-section/index.tsx @@ -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() {

)} -
+
+ @@ -588,31 +589,26 @@ export function AccountSection() { reachable. */} - {/* Password card. v1.4.19 A6: action-button placement contract — - on mobile (`=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. */} 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 4fd0054f5..f0d11131b 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -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 && ( -
-

- {t("settings.researchMode.rePromptTitle")} -

-

- {t("settings.researchMode.rePromptBody")} -

+

{t("settings.researchMode.rePromptBody")}

-
+ )}
@@ -262,64 +261,6 @@ function DataResetCard() { icon={Trash2} title={t("settings.dangerZone")} description={t("settings.dangerZoneDescription")} - status={ - { - // 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")} - - { - e.preventDefault(); - void handleDeleteAllData(); - }} - disabled={deleting} - aria-busy={deleting || undefined} - data-slot="settings-data-reset-confirm" - > - {deleting && ( - - )} - {t("settings.finalDelete")} - - - - - } /> {msg && ( @@ -330,6 +271,64 @@ function DataResetCard() { {msg}

)} + + { + // 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")} + + { + e.preventDefault(); + void handleDeleteAllData(); + }} + disabled={deleting} + aria-busy={deleting || undefined} + data-slot="settings-data-reset-confirm" + > + {deleting && ( + + )} + {t("settings.finalDelete")} + + + + + ); } @@ -399,65 +398,6 @@ function AccountDeleteCard() { icon={Trash2} title={t("settings.deleteAccountCardTitle")} description={t("settings.deleteAccountCardDescription")} - status={ - { - // 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")} - - { - e.preventDefault(); - void handleDeleteAccount(); - }} - disabled={deleting} - aria-busy={deleting || undefined} - data-slot="settings-account-delete-confirm" - > - {deleting && ( - - )} - {t("settings.deleteAccountFinal")} - - - - - } /> {msg && ( @@ -468,6 +408,65 @@ function AccountDeleteCard() { {msg}

)} + + { + // 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")} + + { + e.preventDefault(); + void handleDeleteAccount(); + }} + disabled={deleting} + aria-busy={deleting || undefined} + data-slot="settings-account-delete-confirm" + > + {deleting && ( + + )} + {t("settings.deleteAccountFinal")} + + + + + ); } diff --git a/src/components/settings/coach-memory-section.tsx b/src/components/settings/coach-memory-section.tsx index 96e0ce0c8..d934abcf3 100644 --- a/src/components/settings/coach-memory-section.tsx +++ b/src/components/settings/coach-memory-section.tsx @@ -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 ? ( - - - - - - - - {t("settings.ai.coachMemory.forgetAllConfirmTitle")} - - - {t("settings.ai.coachMemory.forgetAllConfirmBody")} - - - - - {t("settings.ai.coachMemory.cancel")} - - forgetAll.mutate()} - > - {forgetAll.isPending && ( - - )} - {t("settings.ai.coachMemory.forgetAllConfirmAction")} - - - - - ) : null - } /> {query.isError && ( @@ -276,6 +225,58 @@ export function CoachMemorySection({

{t("settings.ai.coachMemory.summaryNote")}

+ + {facts.length > 0 ? ( + + + + + + + + {t("settings.ai.coachMemory.forgetAllConfirmTitle")} + + + {t("settings.ai.coachMemory.forgetAllConfirmBody")} + + + + + {t("settings.ai.coachMemory.cancel")} + + forgetAll.mutate()} + > + {forgetAll.isPending && ( + + )} + {t("settings.ai.coachMemory.forgetAllConfirmAction")} + + + + + ) : null} + ); } diff --git a/src/components/settings/coach-prefs-section.tsx b/src/components/settings/coach-prefs-section.tsx index 6afc62907..d18b2bd69 100644 --- a/src/components/settings/coach-prefs-section.tsx +++ b/src/components/settings/coach-prefs-section.tsx @@ -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) {
)} -
+
+
); } diff --git a/src/components/settings/dashboard-layout-section.tsx b/src/components/settings/dashboard-layout-section.tsx index e3cb852be..cd3618eec 100644 --- a/src/components/settings/dashboard-layout-section.tsx +++ b/src/components/settings/dashboard-layout-section.tsx @@ -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 ( - {/* 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. */} } - label={t("dashboard.layoutReset")} - title={t("dashboard.layoutResetTitle")} - body={t("dashboard.layoutResetBody")} - confirmLabel={t("dashboard.layoutResetConfirm")} - pending={resetMutation.isPending} - onConfirm={() => resetMutation.mutate()} - /> - } /> {hydrated && layout && (
)} - {dirty && ( -
- - -
- )} + {/* Reset is a secondary action, so it reads in the same row as save + rather than from the header's status slot. */} + + } + label={t("dashboard.layoutReset")} + title={t("dashboard.layoutResetTitle")} + body={t("dashboard.layoutResetBody")} + confirmLabel={t("dashboard.layoutResetConfirm")} + pending={resetMutation.isPending} + onConfirm={() => resetMutation.mutate()} + /> + {dirty && ( + <> + + + + )} + {!dirty && remote && (

diff --git a/src/components/settings/email-card.tsx b/src/components/settings/email-card.tsx index 4899f7d27..4df8ae511 100644 --- a/src/components/settings/email-card.tsx +++ b/src/components/settings/email-card.tsx @@ -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 }) {

)} -
+ -
+
diff --git a/src/components/settings/environment-section.tsx b/src/components/settings/environment-section.tsx index ec6c6cbfb..0a9de3eb6 100644 --- a/src/components/settings/environment-section.tsx +++ b/src/components/settings/environment-section.tsx @@ -227,7 +227,7 @@ export function EnvironmentSection() { icon={MapPin} title={t("settings.sections.environment.home.title")} /> -

+

{t("settings.sections.environment.home.description")}

@@ -271,7 +271,9 @@ export function EnvironmentSection() { />
-
+
+ ); } diff --git a/src/components/settings/import-panel/csv-import-card.tsx b/src/components/settings/import-panel/csv-import-card.tsx index 609b43d7a..6be0e730e 100644 --- a/src/components/settings/import-panel/csv-import-card.tsx +++ b/src/components/settings/import-panel/csv-import-card.tsx @@ -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() { )} -
+
+ ); } diff --git a/src/components/settings/import-panel/dose-history-import-card.tsx b/src/components/settings/import-panel/dose-history-import-card.tsx index 9a07dd061..a68e1e4f7 100644 --- a/src/components/settings/import-panel/dose-history-import-card.tsx +++ b/src/components/settings/import-panel/dose-history-import-card.tsx @@ -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() { )} -
+
+ ); } diff --git a/src/components/settings/import-panel/import-card-shell.tsx b/src/components/settings/import-panel/import-card-shell.tsx index 4d2076c22..6e2007cf8 100644 --- a/src/components/settings/import-panel/import-card-shell.tsx +++ b/src/components/settings/import-panel/import-card-shell.tsx @@ -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 ( -
-
-

{description}

+
{children}
); diff --git a/src/components/settings/import-panel/index.tsx b/src/components/settings/import-panel/index.tsx index 38f895df9..8e8aa81f1 100644 --- a/src/components/settings/import-panel/index.tsx +++ b/src/components/settings/import-panel/index.tsx @@ -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 (
-
-

- {t("settings.sections.export.import.heading")} -

-

- {t("settings.sections.export.import.description")} -

-
+
diff --git a/src/components/settings/import-panel/json-import-card.tsx b/src/components/settings/import-panel/json-import-card.tsx index 4a81adf4d..ec1397684 100644 --- a/src/components/settings/import-panel/json-import-card.tsx +++ b/src/components/settings/import-panel/json-import-card.tsx @@ -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() { )}
-
+ -
+ ); } diff --git a/src/components/settings/insights-overview-arrange-section.tsx b/src/components/settings/insights-overview-arrange-section.tsx index 7c63760c4..a2c8b21fc 100644 --- a/src/components/settings/insights-overview-arrange-section.tsx +++ b/src/components/settings/insights-overview-arrange-section.tsx @@ -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" > - {/* Gate the editor mount until the layout GET settles. The editor seeds diff --git a/src/components/settings/insights-pill-order-section.tsx b/src/components/settings/insights-pill-order-section.tsx index fe2dc191f..4008c4632 100644 --- a/src/components/settings/insights-pill-order-section.tsx +++ b/src/components/settings/insights-pill-order-section.tsx @@ -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={ - - } /> {isLoading ? ( @@ -350,6 +337,20 @@ export function InsightsPillOrderSection({ id }: { id?: string }) { ))} )} + + + ); } diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 05fe73b8f..1408d4698 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -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. */}
- @@ -79,10 +76,10 @@ export function IntegrationsSection() { `/settings/channels` redirect; the per-channel anchors stay inside the panel. */}
- {/* Names the per-event notification matrix so the three notification surfaces (matrix, reminder types, channels) cross-reference. */} diff --git a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx index da4083c7f..2687da692 100644 --- a/src/components/settings/integrations/__tests__/apple-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/apple-health-card.test.tsx @@ -146,12 +146,12 @@ describe("", () => { 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", () => { diff --git a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx index 60fe31c06..21668bf1d 100644 --- a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx +++ b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx @@ -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", () => { diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index fa277d268..c479f0ffe 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -160,8 +160,6 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { } /> -
-
{isError && ( -
-
{errorMessage && } {/* Parked-integration resume CTA. Surfaces only when the row @@ -360,10 +359,7 @@ export function FitbitCard({ />
-

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

-
+
+ {credsMsg && (

) : status?.configured ? ( - + + + ) : ( -

+

{t("settings.fitbitNoCredentials")} -

+

)} diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index dfd702bf0..548a258c6 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -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({ } /> -
-
{errorMessage && } @@ -553,10 +552,7 @@ export function GoogleHealthCard({ />
-

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

-
+
+ {credsMsg && (

) : status?.configured ? ( - + + + ) : ( -

+

{t("settings.googleHealthNoCredentials")} -

+

)} diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index 4506a0ab0..5e082e322 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -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({ } /> -
-
{errorMessage && } {operatorApprovalRequired && ( @@ -323,7 +322,7 @@ export function NightscoutCard({

-
+
+ {msg && (

-


-
{errorMessage && (
@@ -387,10 +386,7 @@ export function OAuthProviderCard({ />
-

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

-
+
+ {credsMsg && (

) : ( - + + + )} {msg && ( diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx index 4a1b53ad1..96ac12b49 100644 --- a/src/components/settings/integrations/setup-guide-link.tsx +++ b/src/components/settings/integrations/setup-guide-link.tsx @@ -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 ( <> -

{t(`${i18nPrefix}Description`)}

-

- {t(`${i18nPrefix}DescriptionSecondary`)}{" "} - - {t("settings.integrationSetupGuide")} - -

+ {t(`${i18nPrefix}Description`)}{" "} + + {t("settings.integrationSetupGuide")} + ); } diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx index 6f11d4233..b03f64735 100644 --- a/src/components/settings/integrations/whoop-card.tsx +++ b/src/components/settings/integrations/whoop-card.tsx @@ -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({ } /> -
-
{errorMessage && } {/* Parked-integration resume CTA. Surfaces only when the row @@ -342,10 +341,7 @@ export function WhoopCard({ />
-

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

-
+
+ {credsMsg && (

) : status?.configured ? ( - + + + ) : ( -

+

{t("settings.whoopNoCredentials")} -

+

)} diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx index 0e8768942..d3ebbc928 100644 --- a/src/components/settings/integrations/withings-card.tsx +++ b/src/components/settings/integrations/withings-card.tsx @@ -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({ } /> -
-
{errorMessage && } {/* v1.4.25 W5d — Withings activity-scope reconnect banner. @@ -390,10 +389,7 @@ export function WithingsCard({ />
-

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

-
+
+ {credsMsg && (

) : status?.configured ? ( - + + + ) : ( -

+

{t("settings.withingsNoCredentials")} -

+

)} diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx index caf65eaca..b2ba3f685 100644 --- a/src/components/settings/low-stock-card.tsx +++ b/src/components/settings/low-stock-card.tsx @@ -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" > - {/* Cross-links to the other two notification surfaces so this screen - names them — the per-event matrix and the delivery channels. */} -
- - -
- {/* 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 ? (
- {/* Mood check-in reminder — only when the mood module is enabled. */} @@ -93,6 +72,24 @@ export function NotificationsSection() { ) : null}
) : 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. */} +
+ + +
); } diff --git a/src/components/settings/ntfy-card.tsx b/src/components/settings/ntfy-card.tsx index 745963e53..c1bbe9dfa 100644 --- a/src/components/settings/ntfy-card.tsx +++ b/src/components/settings/ntfy-card.tsx @@ -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 }) {

)} -
+ -
+ diff --git a/src/components/settings/privacy-section.tsx b/src/components/settings/privacy-section.tsx index 44dc29e39..a316f0ed9 100644 --- a/src/components/settings/privacy-section.tsx +++ b/src/components/settings/privacy-section.tsx @@ -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")} /> -
+ -
+ {/* 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")} /> -
- - {t("settings.privacy.delete.backupLag")} - + + {t("settings.privacy.delete.backupLag")} + + -
+ {/* 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")} /> -
+ -
+ {/* Active sessions + trusted devices + security activity (embedded) */} diff --git a/src/components/settings/score-section.tsx b/src/components/settings/score-section.tsx index 10b71ecce..8e6f7ee07 100644 --- a/src/components/settings/score-section.tsx +++ b/src/components/settings/score-section.tsx @@ -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. */} -
+
+ )} diff --git a/src/components/settings/security-activity-card.tsx b/src/components/settings/security-activity-card.tsx index 3c6c3a1c7..6d4806d4c 100644 --- a/src/components/settings/security-activity-card.tsx +++ b/src/components/settings/security-activity-card.tsx @@ -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 ( - + ); } diff --git a/src/components/settings/webhook-card.tsx b/src/components/settings/webhook-card.tsx index 2d4b6803e..ebfe9f2bf 100644 --- a/src/components/settings/webhook-card.tsx +++ b/src/components/settings/webhook-card.tsx @@ -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 }) {

)} -
+ -
+