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 && (
-
-
-
- {t("settings.ai.aboutMe.questionsTitle")}
-
-
+
{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")}
)}
+
+
+
);
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({
@@ -387,10 +386,7 @@ export function OAuthProviderCard({
/>
-
- {t("settings.integrationCredentialsHint")}
-
-
+
-
+
{credsMsg && (
>
) : (
-
-
- {t(`${i18nPrefix}Connect`)}
-
+
+
+
+ {t(`${i18nPrefix}Connect`)}
+
+
)}
{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 (
<>
-
+
)}
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. */}
+
+
)}
{status && (
diff --git a/src/components/settings/share-link-create-form.tsx b/src/components/settings/share-link-create-form.tsx
index a5974c185..1db55ba00 100644
--- a/src/components/settings/share-link-create-form.tsx
+++ b/src/components/settings/share-link-create-form.tsx
@@ -26,6 +26,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Copy, FileText, Loader2, ScanLine, X } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
@@ -453,22 +454,25 @@ export function ShareLinkCreateForm({
a page with nothing on it. The scope line above says so and says
what to do about it, and describes the button so the disabled state
is not silent for a screen reader. */}
-
- {createMutation.isPending && (
-
- )}
- {t("settings.sharing.create")}
-
+
+
+ {createMutation.isPending && (
+
+ )}
+ {t("settings.sharing.create")}
+
+
@@ -220,9 +220,9 @@ function ShareLinksCard() {
{t("settings.sharing.revoke")}
@@ -242,7 +242,6 @@ function ShareLinksCard() {
{t("common.cancel")}
revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
diff --git a/src/components/settings/sources-section.tsx b/src/components/settings/sources-section.tsx
index 15f637d25..cb2185f36 100644
--- a/src/components/settings/sources-section.tsx
+++ b/src/components/settings/sources-section.tsx
@@ -41,6 +41,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
@@ -548,35 +549,9 @@ export function SourcesSection() {
)}
- {/* `flex-col-reverse` mirrors the DialogFooter idiom: the primary
- action stacks on top on mobile, row order stays Cancel → Save
- on `sm+`. */}
- {dirty && priority && (
-
- )}
-
{/* v1.18.6 (W9) — quiet pointer back to Integrationen, where the
- sources this ladder ranks are connected / removed. Folded into
- the card so the page top stays the standard heading + subtitle. */}
+ sources this ladder ranks are connected / removed. Above the
+ action row: nothing follows a card's actions. */}
+
+ {dirty && priority && (
+
+ setDraft(null)}
+ disabled={saveMutation.isPending}
+ >
+ {t("common.cancel")}
+
+ saveMutation.mutate(priority)}
+ disabled={saveMutation.isPending}
+ >
+ {saveMutation.isPending && (
+
+ )}
+ {t("common.save")}
+
+
+ )}
{/* v1.28 shipped `` here; the 2026-07-17 UX/IA
diff --git a/src/components/settings/telegram-card.tsx b/src/components/settings/telegram-card.tsx
index ec357099d..fc7d251d9 100644
--- a/src/components/settings/telegram-card.tsx
+++ b/src/components/settings/telegram-card.tsx
@@ -2,9 +2,11 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Loader2, MessageCircle, Save } from "lucide-react";
+import { Info, Loader2, MessageCircle, Save } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { SettingsInfoTile } from "@/components/settings/_info-tile";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
@@ -190,11 +192,13 @@ export function TelegramCard({
-
-
{t("settings.telegramStep1")}
-
{t("settings.telegramStep2")}
-
{t("settings.telegramStep3")}
-
+
+
+
{t("settings.telegramStep1")}
+
{t("settings.telegramStep2")}
+
{t("settings.telegramStep3")}
+
+
{msg && (
)}
-
+
-
+
diff --git a/src/components/settings/thresholds-editor-section.tsx b/src/components/settings/thresholds-editor-section.tsx
index c969bea92..3df0feeed 100644
--- a/src/components/settings/thresholds-editor-section.tsx
+++ b/src/components/settings/thresholds-editor-section.tsx
@@ -6,6 +6,8 @@ import { toast } from "sonner";
import { SlidersHorizontal, RotateCcw, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmButton, ConfirmDialog } from "@/components/ui/confirm-button";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
@@ -120,31 +122,14 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
return (
- {/* v1.4.19 A8 / F-07: page header `settings.sections.thresholds.*`
- already provides the title + description for this route, so the
- card-level title + subtitle were a duplicate of the page header.
- Keep the icon + reset action so the card still has a visible
- control affordance. */}
-
+ {/* The card carries a real title. It used to render a naked icon and
+ a reset button, on the grounds that the page header already said
+ the same thing — which left one card in the tree with no heading
+ at all. The page subtitle is the one that goes. */}
+
{isLoading || !data ? (
@@ -163,6 +148,23 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
))}
+
)}
{status && (
diff --git a/src/components/settings/web-push-card.tsx b/src/components/settings/web-push-card.tsx
index 27d03b331..53fed1acf 100644
--- a/src/components/settings/web-push-card.tsx
+++ b/src/components/settings/web-push-card.tsx
@@ -6,6 +6,7 @@ import { BellRing, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TestConnectionButton } from "@/components/settings/test-connection-button";
import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { apiFetchRaw } from "@/lib/api/api-fetch";
@@ -161,17 +162,20 @@ export function WebPushCard() {
}
}
- // The primary action sits in the header row (right of the title) rather than
- // bottom-left, so an inactive card does not waste a wide empty band before
- // its enable button. The loading spinner rides the same slot; the
- // unsupported / denied notices stay in the body.
- const headerAction = loading ? (
+ // The subscribe / unsubscribe control is this card's primary action, so it
+ // sits where every other card's does: the bottom action row. It used to ride
+ // the header status slot as an `outline`, which made the one card in the
+ // channel group that carries an enable button look like the one card that
+ // carries a status badge.
+ const cardAction = loading ? (
) : !isSupported || isDenied ? null : (
-