refactor(settings): one header, one action row, one integration card

The sweep that follows from the primitives. Nothing here is new behaviour;
every change removes a second way of saying something the surface already
said one way somewhere else.

Headers. The two collapsible security cards stop rebuilding
`SettingsCardHeader` by hand to carry a chevron — the chevron rides inside
the title button, and the description moves out of the collapsed region, so
the first paint is no longer a bare title. The import card shell, the export
and import section headings, and the four free-standing group headers stop
inventing their own type scale: a heading above a GROUP of cards is
`SectionHeading` (`text-base`), a card title is `SettingsCardHeader`
(`text-lg`), and the step between them is what says which is which. The
targets card gets a title instead of a naked icon.

Actions. Every card-level row is `SettingsCardActions`. The primary action
comes out of the header's status slot in six cards (password, web push,
pillar order, coach memory, and the two danger-zone triggers) and out of the
middle of the card in three more; the reset that hung in the dashboard-layout
header joins save as a secondary in the same row. Result and status lines
move ABOVE the row — sources' cross-link, the invite form's two status lines
— because nothing follows a card's actions. Revoking a share link is
`variant="destructive"` rather than an `outline` hand-tinted red in two
different ways. The import row had four variants for four buttons; it has
two. Export downloads are the card's primary, not an outline link.

Integrations. One description sentence with the setup-guide link inline: the
second paragraph was byte-identical across six of eight cards, which is the
tell that it said nothing about the provider it sat under. The `<hr>` under
the header — which existed only in this folder — is gone. So is the hint that
pointed at a guide 40 px above it, and the second format for "not
configured". Connect is one button everywhere: primary, `sm`, in the action
row, instead of primary-and-small in three cards and outline-and-large in
four.

Plus the standards leftovers the audit listed: two `pl-5` list gutters, one
`scroll-mt-20` anchor, a purple header icon, `text-[10px]` badges, four
hand-rolled callout shells that `SettingsInfoTile` already paints, and the
two inline buttons that never had the mobile tap floor at all.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 19:39:11 +02:00
1 parent ce9b2814e6
commit b2e1aa0cde
63 files changed
+720 -760

No files matched your search

-11
View File
@@ -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",
-11
View File
@@ -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",
-11
View File
@@ -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",
-11
View File
@@ -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",
-11
View File
@@ -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",
-11
View File
@@ -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",
@@ -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 (
<Badge variant="destructive" className={`shrink-0 ${sizeClass}`}>
@@ -317,7 +317,7 @@ export function ApiTokenOverviewSection() {
<Badge
key={p}
variant="secondary"
className="max-w-full truncate text-[10px]"
className="text-2xs max-w-full truncate"
title={
p === "*"
? t("admin.tokenPermissionAllTooltip")
+3 -2
View File
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
import { AlertTriangle, 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";
@@ -130,7 +131,7 @@ export function GlitchtipSection() {
</div>
</div>
<div className="flex justify-end gap-2">
<SettingsCardActions>
<Button
size="sm"
variant="outline"
@@ -152,7 +153,7 @@ export function GlitchtipSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
);
}
+3 -2
View File
@@ -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() {
</div>
</div>
<div className="flex justify-end gap-2">
<SettingsCardActions>
<Button
size="sm"
variant="outline"
@@ -315,7 +316,7 @@ export function RemindersSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
{testNotification.data?.results &&
testNotification.data.results.length > 0 && (
+3 -2
View File
@@ -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() {
</div>
</div>
<div className="flex justify-end gap-2">
<SettingsCardActions>
<Button
size="sm"
variant="outline"
@@ -153,7 +154,7 @@ export function UmamiSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -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() {
</div>
</div>
<div className="flex justify-end gap-2">
<SettingsCardActions>
<Button
variant="outline"
size="sm"
@@ -217,7 +218,7 @@ export function WebPushVapidSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
<AlertDialog
open={overwriteConfirmOpen}
@@ -23,6 +23,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
@@ -213,10 +214,7 @@ export function InsightsEditMode({
const allHidden = draft.sections.every((s) => !s.visible);
return (
<div
data-slot="insights-edit-mode"
className="bg-card border-border space-y-4 rounded-xl border p-4 sm:p-6"
>
<SettingsCard data-slot="insights-edit-mode">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div>
<h2
@@ -312,7 +310,7 @@ export function InsightsEditMode({
{t("insights.editMode.manageInSettings")}
</Link>
</p>
</div>
</SettingsCard>
);
}
@@ -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 `<hr>` 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"
+6 -1
View File
@@ -43,7 +43,10 @@ const TONE_ICON: Record<SettingsInfoTileTone, string> = {
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 (
<div
@@ -70,6 +74,7 @@ export function SettingsInfoTile({
TONE_SURFACE[tone],
className,
)}
{...props}
>
<Icon
className={cn("mt-0.5 h-4 w-4 shrink-0", TONE_ICON[tone])}
+7 -11
View File
@@ -49,6 +49,7 @@ import {
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCard } from "@/components/settings/settings-card";
@@ -301,18 +302,13 @@ export function AboutMeSection({
</div>
{pendingQuestions.length > 0 && (
<div
<SettingsInfoTile
icon={MessageCircleQuestion}
tone="primary"
data-testid="settings-about-me-questions"
className="border-primary/30 bg-primary/5 rounded-lg border p-4"
title={t("settings.ai.aboutMe.questionsTitle")}
>
<p className="flex items-center gap-2 text-sm font-medium">
<MessageCircleQuestion
className="text-primary size-4 shrink-0"
aria-hidden="true"
/>
{t("settings.ai.aboutMe.questionsTitle")}
</p>
<ul className="text-muted-foreground mt-2 list-disc space-y-1 pl-5 text-sm">
<ul className="list-disc space-y-1 pl-6">
{pendingQuestions.map((q) => (
<li key={q}>{q}</li>
))}
@@ -325,7 +321,7 @@ export function AboutMeSection({
{t("settings.ai.aboutMe.questionsOpenCoach")}
</Link>
</p>
</div>
</SettingsInfoTile>
)}
{query.isError && (
+3 -2
View File
@@ -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")}
/>
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<SettingsCardActions align="start">
<Button
asChild
variant="outline"
@@ -317,7 +318,7 @@ export function AboutSection() {
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
)}
@@ -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",
);
});
@@ -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")}
</p>
</div>
<Button
type="submit"
data-slot="grant-invite-submit"
// A narrowed invitation with nothing ticked would be a grant that
// opens nothing, which the server refuses. The button is inert and
// the sentence above says why, rather than sending a request whose
// only purpose is to come back as an error.
disabled={
invite.isPending || identifier.trim().length === 0 || choice.blocked
}
>
{invite.isPending
? t("recordSharing.invite.submitting")
: t("recordSharing.invite.submit")}
</Button>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
@@ -343,6 +329,27 @@ export function GrantInviteCard() {
{t("recordSharing.invite.sent", { name: invited })}
</p>
)}
<SettingsCardActions>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="grant-invite-submit"
// A narrowed invitation with nothing ticked would be a grant that
// opens nothing, which the server refuses. The button is inert and
// the sentence above says why, rather than sending a request whose
// only purpose is to come back as an error.
disabled={
invite.isPending ||
identifier.trim().length === 0 ||
choice.blocked
}
>
{invite.isPending
? t("recordSharing.invite.submitting")
: t("recordSharing.invite.submit")}
</Button>
</SettingsCardActions>
</form>
</SettingsCard>
);
@@ -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() {
</p>
)}
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
className="min-h-11 sm:min-h-9"
@@ -568,7 +569,7 @@ export function AccountSection() {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</SettingsCard>
@@ -588,31 +589,26 @@ export function AccountSection() {
reachable. */}
<CycleTrackingCard isAuthenticated={isAuthenticated} />
{/* Password card. v1.4.19 A6: action-button placement contract —
on mobile (`<sm`) the action button stacks below the title +
description and renders full-width; on desktop (`>=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. */}
<SettingsCard>
<SettingsCardHeader
icon={Shield}
title={t("settings.passwordReset")}
description={t("settings.changePasswordDescription")}
status={
<Button
type="button"
variant="outline"
onClick={() => setPasswordDialogOpen(true)}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
}
/>
<SettingsCardActions>
<Button
type="button"
onClick={() => setPasswordDialogOpen(true)}
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
>
{t("settings.changePassword")}
</Button>
</SettingsCardActions>
</SettingsCard>
{/* v1.25.7 — active-session management + the security-activity feed
+126 -127
View File
@@ -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 && (
<div
<SettingsInfoTile
icon={AlertTriangle}
tone="warning"
role="alert"
data-slot="settings-research-mode-reprompt"
className="border-warning/40 bg-warning/10 rounded-md border-l-4 px-3 py-2 text-sm"
title={t("settings.researchMode.rePromptTitle")}
>
<p className="text-foreground font-medium">
{t("settings.researchMode.rePromptTitle")}
</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.researchMode.rePromptBody")}
</p>
<p>{t("settings.researchMode.rePromptBody")}</p>
<Button
size="sm"
variant="outline"
@@ -168,7 +167,7 @@ function ResearchModeCard() {
>
{t("settings.researchMode.rePromptCta")}
</Button>
</div>
</SettingsInfoTile>
)}
<div className="flex items-start justify-between gap-3">
@@ -262,64 +261,6 @@ function DataResetCard() {
icon={Trash2}
title={t("settings.dangerZone")}
description={t("settings.dangerZoneDescription")}
status={
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the destructive mutation is in
// flight so the in-dialog pending state stays visible and a
// stray backdrop tap can't dismiss it mid-request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
{msg && (
@@ -330,6 +271,64 @@ function DataResetCard() {
{msg}
</p>
)}
<SettingsCardActions>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the destructive mutation is in
// flight so the in-dialog pending state stays visible and a
// stray backdrop tap can't dismiss it mid-request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-data-reset-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -399,65 +398,6 @@ function AccountDeleteCard() {
icon={Trash2}
title={t("settings.deleteAccountCardTitle")}
description={t("settings.deleteAccountCardDescription")}
status={
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the irreversible delete runs so
// the pending state stays on screen and a stray dismissal
// can't fire a second request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
{msg && (
@@ -468,6 +408,65 @@ function AccountDeleteCard() {
{msg}
</p>
)}
<SettingsCardActions>
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
// Hold the dialog open while the irreversible delete runs so
// the pending state stays on screen and a stray dismissal
// can't fire a second request.
if (deleting) return;
setConfirmOpen(open);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
disabled={deleting}
className="min-h-11 w-full shrink-0 sm:min-h-9 sm:w-auto"
data-slot="settings-account-delete-trigger"
>
{deleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
{t("settings.deleteAccountCta")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.deleteAccountConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.deleteAccountConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
}}
disabled={deleting}
aria-busy={deleting || undefined}
data-slot="settings-account-delete-confirm"
>
{deleting && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.deleteAccountFinal")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -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 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null
}
/>
{query.isError && (
@@ -276,6 +225,58 @@ export function CoachMemorySection({
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.ai.coachMemory.summaryNote")}
</p>
<SettingsCardActions>
{facts.length > 0 ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
data-testid="settings-coach-memory-forget-all"
disabled={!isAuthenticated || forgetAll.isPending}
>
{forgetAll.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : (
<Trash2 className="size-4" aria-hidden />
)}
{t("settings.ai.coachMemory.forgetAll")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.ai.coachMemory.forgetAllConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.ai.coachMemory.forgetAllConfirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("settings.ai.coachMemory.cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</SettingsCardActions>
</SettingsCard>
);
}
@@ -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) {
</div>
</div>
)}
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="button"
size="sm"
@@ -352,7 +353,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
) : null}
{t("insights.coach.settingsSave")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -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 (
<SettingsCard id={id} className="scroll-mt-28">
{/* 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. */}
<SettingsCardHeader
icon={LayoutDashboard}
title={t("dashboard.customizeTitle")}
status={
<ConfirmButton
slot="settings-dashboard-layout-reset"
variant="ghost"
size="sm"
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("dashboard.layoutReset")}
title={t("dashboard.layoutResetTitle")}
body={t("dashboard.layoutResetBody")}
confirmLabel={t("dashboard.layoutResetConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
}
/>
{hydrated && layout && (
<fieldset
@@ -702,30 +684,47 @@ export function DashboardLayoutSection({ id }: { id: string }) {
</div>
)}
{dirty && (
<div className="flex items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => layout && saveMutation.mutate(layout)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
)}
{/* Reset is a secondary action, so it reads in the same row as save
rather than from the header's status slot. */}
<SettingsCardActions>
<ConfirmButton
slot="settings-dashboard-layout-reset"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("dashboard.layoutReset")}
title={t("dashboard.layoutResetTitle")}
body={t("dashboard.layoutResetBody")}
confirmLabel={t("dashboard.layoutResetConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate()}
/>
{dirty && (
<>
<Button
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => layout && saveMutation.mutate(layout)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</>
)}
</SettingsCardActions>
{!dirty && remote && (
<p className="text-muted-foreground text-xs">
+3 -2
View File
@@ -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 }) {
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/email/test"
disabled={!settings?.enabled}
@@ -177,7 +178,7 @@ export function EmailCard({ isAuthenticated }: { isAuthenticated: boolean }) {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
@@ -227,7 +227,7 @@ export function EnvironmentSection() {
icon={MapPin}
title={t("settings.sections.environment.home.title")}
/>
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
<p className="text-sm leading-relaxed">
{t("settings.sections.environment.home.description")}
</p>
<div className="space-y-3">
@@ -271,7 +271,9 @@ export function EnvironmentSection() {
/>
<Button
type="submit"
variant="secondary"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={homeQuery.trim().length === 0 || searchHome.isPending}
>
{t("settings.sections.environment.search")}
@@ -314,7 +316,7 @@ export function EnvironmentSection() {
icon={Plane}
title={t("settings.sections.environment.travel.title")}
/>
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
<p className="text-sm leading-relaxed">
{t("settings.sections.environment.travel.description")}
</p>
<div className="space-y-3">
@@ -377,7 +379,9 @@ export function EnvironmentSection() {
/>
<Button
type="submit"
variant="secondary"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={
travelQuery.trim().length === 0 || searchTravel.isPending
}
@@ -439,6 +443,8 @@ export function EnvironmentSection() {
</div>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={travelStart > travelEnd || addTravel.isPending}
onClick={() => addTravel.mutate()}
>
@@ -457,7 +463,7 @@ export function EnvironmentSection() {
icon={CloudSun}
title={t("settings.sections.environment.backfill.title")}
/>
<p className="text-muted-foreground mb-3 text-sm leading-relaxed">
<p className="text-sm leading-relaxed">
{t("settings.sections.environment.backfill.description")}
</p>
<div className="flex flex-wrap items-end gap-2">
@@ -483,6 +489,8 @@ export function EnvironmentSection() {
</div>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={
!home ||
effectiveBackfillStart.length === 0 ||
+9 -11
View File
@@ -34,6 +34,8 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SectionHeading } from "@/components/ui/section-heading";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { PasswordInput } from "@/components/ui/password-input";
@@ -80,14 +82,13 @@ export function ExportSection() {
CSV/JSON data-out paths and the import surface. */}
<section
aria-labelledby="settings-section-export-other-title"
className="space-y-3"
className="space-y-4"
>
<h2
<SectionHeading
icon={Download}
id="settings-section-export-other-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.otherOptionsHeading")}
</h2>
title={t("settings.sections.export.otherOptionsHeading")}
/>
<div className="grid gap-4 md:grid-cols-2">
<MeasurementsCsvCard />
<MedicationsCsvCard />
@@ -113,7 +114,7 @@ interface ExportCardShellProps {
description: string;
format: ExportFormat;
children?: React.ReactNode;
/** Footer slot — typically the "Download" button + status text. */
/** The card's action row — the "Download" button, and nothing after it. */
footer: React.ReactNode;
/** Extra grid-position classes applied to the outer card div. */
outerClassName?: string;
@@ -145,7 +146,7 @@ function ExportCardShell({
}
/>
{children && <div className="space-y-3">{children}</div>}
<div className="flex flex-wrap items-center gap-3">{footer}</div>
<SettingsCardActions>{footer}</SettingsCardActions>
</SettingsCard>
);
}
@@ -258,7 +259,6 @@ function CsvCard({
footer={
<Button
data-testid={actionTestId}
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
@@ -346,7 +346,6 @@ function MedicationsCsvCard() {
footer={
<Button
data-testid="export-action-medications-csv"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
@@ -471,7 +470,6 @@ function FullBackupCard() {
footer={
<Button
data-testid="export-action-full-backup"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={handleDownload}
@@ -27,8 +27,8 @@
import { Share2 } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { HealthRecordExportPanel } from "@/components/settings/report-selection/selection-panel";
import { SectionHeading } from "@/components/ui/section-heading";
import { SharingSection } from "@/components/settings/sharing-section";
export function GesundheitsakteSection() {
@@ -42,10 +42,10 @@ export function GesundheitsakteSection() {
<HealthRecordExportPanel />
<section id="sharing" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
<SectionHeading
icon={Share2}
title={t("settings.sections.sharing.title")}
description={t("settings.sections.sharing.subtitle")}
subtitle={t("settings.sections.sharing.subtitle")}
/>
<SharingSection />
</section>
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { AlertCircle, Loader2, Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Progress } from "@/components/ui/progress";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
@@ -308,7 +309,7 @@ export function AppleHealthImportCard() {
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<SettingsCardActions className="mt-auto" align="start">
<Button
type="button"
variant="outline"
@@ -325,7 +326,7 @@ export function AppleHealthImportCard() {
)}
{t("settings.sections.export.import.appleHealth.choose")}
</Button>
</div>
</SettingsCardActions>
</ImportCardShell>
);
}
@@ -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() {
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<SettingsCardActions className="mt-auto" align="start">
<Button
type="button"
variant="outline"
@@ -184,7 +185,7 @@ export function CsvImportCard() {
</Button>
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={downloadExample}
@@ -195,7 +196,7 @@ export function CsvImportCard() {
</Button>
<Button
type="button"
variant="secondary"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
@@ -222,7 +223,7 @@ export function CsvImportCard() {
)}
{t("settings.sections.export.import.csv.import")}
</Button>
</div>
</SettingsCardActions>
</ImportCardShell>
);
}
@@ -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() {
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<SettingsCardActions className="mt-auto" align="start">
<Button
type="button"
variant="outline"
@@ -284,7 +285,7 @@ export function DoseHistoryImportCard() {
</Button>
<Button
type="button"
variant="secondary"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={busy || text.trim().length === 0}
@@ -311,7 +312,7 @@ export function DoseHistoryImportCard() {
)}
{t("settings.sections.export.import.doseHistory.import")}
</Button>
</div>
</SettingsCardActions>
</ImportCardShell>
);
}
@@ -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 (
<SettingsCard data-testid={testId} className="flex h-full flex-col">
<div className="flex items-center gap-2">
<Icon
className="text-muted-foreground h-5 w-5 shrink-0"
aria-hidden="true"
/>
<h3 className="text-base font-semibold">{title}</h3>
</div>
<p className="text-muted-foreground mt-1 text-xs">{description}</p>
<SettingsCardHeader icon={Icon} title={title} description={description} />
<div className="flex flex-1 flex-col gap-3">{children}</div>
</SettingsCard>
);
+10 -12
View File
@@ -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 (
<section
aria-labelledby="settings-section-import-title"
className="space-y-3"
className="space-y-4"
>
<div className="space-y-1">
<h2
id="settings-section-import-title"
className="text-base font-semibold tracking-tight"
>
{t("settings.sections.export.import.heading")}
</h2>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.import.description")}
</p>
</div>
<SectionHeading
icon={Upload}
id="settings-section-import-title"
title={t("settings.sections.export.import.heading")}
subtitle={t("settings.sections.export.import.description")}
/>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<AppleHealthImportCard />
<JsonImportCard />
@@ -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() {
)}
</div>
<div className="mt-auto flex flex-wrap items-center gap-3 pt-1">
<SettingsCardActions className="mt-auto" align="start">
<Button
type="button"
variant="outline"
@@ -210,7 +211,7 @@ export function JsonImportCard() {
</Button>
<Button
asChild
variant="ghost"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
>
@@ -238,7 +239,7 @@ export function JsonImportCard() {
)}
{t("settings.sections.export.import.json.import")}
</Button>
</div>
</SettingsCardActions>
</ImportCardShell>
);
}
@@ -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"
>
<SettingsCardHeader
<SectionHeading
icon={LayoutGrid}
titleId="insights-overview-arrange-title"
id="insights-overview-arrange-title"
title={t("insights.settings.overviewTitle")}
description={t("insights.settings.overviewDescription")}
subtitle={t("insights.settings.overviewDescription")}
/>
{/* Gate the editor mount until the layout GET settles. The editor seeds
@@ -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={
<Button
size="sm"
onClick={() => saveMutation.mutate(draftTiles)}
disabled={busy || !dirty}
data-slot="insights-pill-order-save"
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.pillOrder.save")}
</Button>
}
/>
{isLoading ? (
@@ -350,6 +337,20 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
))}
</div>
)}
<SettingsCardActions>
<Button
size="sm"
onClick={() => saveMutation.mutate(draftTiles)}
disabled={busy || !dirty}
data-slot="insights-pill-order-save"
className="min-h-11 self-end sm:min-h-9 sm:self-auto"
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("insights.pillOrder.save")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}
@@ -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. */}
<section className="space-y-4">
<SettingsCardHeader
<SectionHeading
icon={Link2}
title={t("settings.sections.integrations.connectionsHeading")}
description={t(
"settings.sections.integrations.connectionsDescription",
)}
/>
<ConnectionsPanel />
@@ -79,10 +76,10 @@ export function IntegrationsSection() {
`/settings/channels` redirect; the per-channel anchors stay inside
the panel. */}
<section id="channels" className="scroll-mt-28 space-y-4">
<SettingsCardHeader
<SectionHeading
icon={Send}
title={t("settings.sections.integrations.channelsHeading")}
description={t("settings.sections.integrations.channelsDescription")}
subtitle={t("settings.sections.integrations.channelsDescription")}
/>
{/* Names the per-event notification matrix so the three notification
surfaces (matrix, reminder types, channels) cross-reference. */}
@@ -146,12 +146,12 @@ describe("<AppleHealthCard>", () => {
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", () => {
@@ -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", () => {
@@ -160,8 +160,6 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{isError && (
<IntegrationErrorMessage
@@ -32,6 +32,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";
@@ -243,8 +244,6 @@ export function FitbitCard({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
@@ -360,10 +359,7 @@ export function FitbitCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -380,7 +376,7 @@ export function FitbitCard({
)}
{t("settings.fitbitSaveCredentials")}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -503,19 +499,26 @@ export function FitbitCard({
</Link>
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/fitbit/connect";
}}
>
<Link2 className="h-4 w-4" />
{t("settings.fitbitConnect")}
</Button>
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="fitbit-connect"
onClick={() => {
window.location.href = "/api/fitbit/connect";
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.fitbitConnect")}
</Button>
</SettingsCardActions>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.fitbitNoCredentials")}
</div>
</p>
)}
</div>
</SettingsCard>
@@ -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({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
@@ -553,10 +552,7 @@ export function GoogleHealthCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -573,7 +569,7 @@ export function GoogleHealthCard({
)}
{t("settings.googleHealthSaveCredentials")}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -740,19 +736,26 @@ export function GoogleHealthCard({
</Link>
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/google-health/connect";
}}
>
<Link2 className="h-4 w-4" />
{t("settings.googleHealthConnect")}
</Button>
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="googlehealth-connect"
onClick={() => {
window.location.href = "/api/google-health/connect";
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.googleHealthConnect")}
</Button>
</SettingsCardActions>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.googleHealthNoCredentials")}
</div>
</p>
)}
</div>
</SettingsCard>
@@ -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({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{operatorApprovalRequired && (
@@ -323,7 +322,7 @@ export function NightscoutCard({
</p>
</div>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -342,7 +341,7 @@ export function NightscoutCard({
? t("settings.nightscoutUpdate")
: t("settings.nightscoutConnect")}
</Button>
</div>
</SettingsCardActions>
{msg && (
<p
@@ -33,6 +33,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";
@@ -285,8 +286,6 @@ export function OAuthProviderCard({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && (
<div data-testid={`${provider}-error`}>
@@ -387,10 +386,7 @@ export function OAuthProviderCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -407,7 +403,7 @@ export function OAuthProviderCard({
)}
{t(`${i18nPrefix}SaveCredentials`)}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -505,17 +501,19 @@ export function OAuthProviderCard({
</Link>
</>
) : (
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={serverUnavailable}
onClick={handleConnect}
data-testid={`${provider}-connect`}
>
<Link2 className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Connect`)}
</Button>
<SettingsCardActions>
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={serverUnavailable}
onClick={handleConnect}
data-testid={`${provider}-connect`}
>
<Link2 className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Connect`)}
</Button>
</SettingsCardActions>
)}
{msg && (
@@ -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 (
<>
<p className="text-foreground">{t(`${i18nPrefix}Description`)}</p>
<p className="text-muted-foreground">
{t(`${i18nPrefix}DescriptionSecondary`)}{" "}
<a
href={integrationDocsHref(provider)}
target="_blank"
rel="noopener noreferrer"
data-testid={`${provider}-setup-guide`}
data-slot="integration-setup-guide"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.integrationSetupGuide")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</p>
{t(`${i18nPrefix}Description`)}{" "}
<a
href={integrationDocsHref(provider)}
target="_blank"
rel="noopener noreferrer"
data-testid={`${provider}-setup-guide`}
data-slot="integration-setup-guide"
className="hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.integrationSetupGuide")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</>
);
}
@@ -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({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* Parked-integration resume CTA. Surfaces only when the row
@@ -342,10 +341,7 @@ export function WhoopCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -362,7 +358,7 @@ export function WhoopCard({
)}
{t("settings.whoopSaveCredentials")}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -485,19 +481,26 @@ export function WhoopCard({
</Link>
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/whoop/connect";
}}
>
<Link2 className="h-4 w-4" />
{t("settings.whoopConnect")}
</Button>
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="whoop-connect"
onClick={() => {
window.location.href = "/api/whoop/connect";
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.whoopConnect")}
</Button>
</SettingsCardActions>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.whoopNoCredentials")}
</div>
</p>
)}
</div>
</SettingsCard>
@@ -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({
}
/>
<hr data-testid="integration-card-divider" className="border-border/60" />
<div className="space-y-4">
{errorMessage && <IntegrationErrorMessage message={errorMessage} />}
{/* v1.4.25 W5d — Withings activity-scope reconnect banner.
@@ -390,10 +389,7 @@ export function WithingsCard({
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("settings.integrationCredentialsHint")}
</p>
<div className="flex justify-end">
<SettingsCardActions>
<Button
type="submit"
variant="outline"
@@ -410,7 +406,7 @@ export function WithingsCard({
)}
{t("settings.withingsSaveCredentials")}
</Button>
</div>
</SettingsCardActions>
{credsMsg && (
<p
role="alert"
@@ -534,19 +530,26 @@ export function WithingsCard({
</Link>
</>
) : status?.configured ? (
<Button
variant="outline"
onClick={() => {
window.location.href = "/api/withings/connect";
}}
>
<Link2 className="h-4 w-4" />
{t("settings.withingsConnect")}
</Button>
<SettingsCardActions>
<Button
size="sm"
className="min-h-11 w-full sm:w-auto"
data-testid="withings-connect"
onClick={() => {
window.location.href = "/api/withings/connect";
}}
>
<Link2 className="h-3.5 w-3.5" />
{t("settings.withingsConnect")}
</Button>
</SettingsCardActions>
) : (
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-sm">
<p
className="text-muted-foreground text-xs"
data-testid="integration-unavailable"
>
{t("settings.withingsNoCredentials")}
</div>
</p>
)}
</div>
</SettingsCard>
+1 -1
View File
@@ -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"
>
<SettingsCardHeader
icon={PackageOpen}
@@ -5,8 +5,8 @@ import { Bell } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { SectionHeading } from "@/components/ui/section-heading";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { Button } from "@/components/ui/button";
@@ -46,35 +46,14 @@ export function NotificationsSection() {
return (
<div className="space-y-10">
{/* Cross-links to the other two notification surfaces so this screen
names them — the per-event matrix and the delivery channels. */}
<div
className="flex flex-wrap gap-2"
data-slot="notifications-crosslinks"
>
<Button asChild variant="outline" size="sm">
<Link href="/notifications">
{t("settings.sections.notifications.matrixLink")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/integrations#channels">
{t("settings.sections.notifications.channelsLink")}
</Link>
</Button>
</div>
{/* 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 ? (
<section className="space-y-4">
<SettingsCardHeader
<SectionHeading
icon={Bell}
title={t("settings.sections.notifications.remindersHeading")}
description={t(
"settings.sections.notifications.remindersDescription",
)}
/>
{/* Mood check-in reminder — only when the mood module is enabled. */}
@@ -93,6 +72,24 @@ export function NotificationsSection() {
) : null}
</section>
) : 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. */}
<div
className="flex flex-wrap gap-2"
data-slot="notifications-crosslinks"
>
<Button asChild variant="outline" size="sm">
<Link href="/notifications">
{t("settings.sections.notifications.matrixLink")}
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="/settings/integrations#channels">
{t("settings.sections.notifications.channelsLink")}
</Link>
</Button>
</div>
</div>
);
}
+3 -2
View File
@@ -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 }) {
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/ntfy/test"
disabled={!settings?.enabled}
@@ -207,7 +208,7 @@ export function NtfyCard({ isAuthenticated }: { isAuthenticated: boolean }) {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
+10 -9
View File
@@ -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")}
/>
<div className="flex flex-wrap gap-2">
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm">
<Link href="/settings/export">
{t("settings.privacy.export.openExport")}
@@ -168,7 +169,7 @@ export function PrivacySection() {
{t("settings.privacy.export.openHealthRecord")}
</Link>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
{/* 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")}
/>
<div className="space-y-3">
<SettingsInfoTile icon={Info} tone="warning">
{t("settings.privacy.delete.backupLag")}
</SettingsInfoTile>
<SettingsInfoTile icon={Info} tone="warning">
{t("settings.privacy.delete.backupLag")}
</SettingsInfoTile>
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm">
<Link href="/settings/advanced">
{t("settings.privacy.delete.openAdvanced")}
</Link>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
{/* 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")}
/>
<div className="flex flex-wrap gap-2">
<SettingsCardActions align="start">
<Button asChild variant="outline" size="sm">
<Link href="/settings/ai">
{t("settings.privacy.posture.openAi")}
@@ -208,7 +209,7 @@ export function PrivacySection() {
{t("settings.privacy.posture.openResearch")}
</Link>
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
{/* Active sessions + trusted devices + security activity (embedded) */}
+3 -2
View File
@@ -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. */}
<div className="flex justify-end">
<SettingsCardActions>
<Button
size="sm"
type="button"
@@ -460,7 +461,7 @@ export function ScoreSection() {
) : null}
{t("settings.sections.score.save")}
</Button>
</div>
</SettingsCardActions>
</SettingsCard>
)}
</div>
@@ -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 (
<SettingsCard data-slot="settings-security-activity-card">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-activity-toggle"
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<ShieldCheck
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<h2 className="text-lg font-semibold">
<SettingsCardHeader
icon={ShieldCheck}
title={
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-activity-toggle"
className="hover:text-foreground focus-visible:ring-ring/50 -m-1 flex min-h-11 items-center gap-2 rounded-md p-1 text-left focus-visible:ring-2 focus-visible:outline-none"
>
{t("settings.security.activityTitle")}
</h2>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</div>
</button>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</button>
}
description={t("settings.security.activityDescription")}
/>
<div id={regionId} hidden={!open} className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.security.activityDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
@@ -28,7 +28,7 @@ export function PasskeyUpgradeNudge() {
return (
<div className="border-primary/30 bg-primary/5 flex items-start gap-3 rounded-lg border p-4">
<Sparkles className="text-primary mt-0.5 h-5 w-5 shrink-0" />
<Sparkles className="text-muted-foreground mt-0.5 size-5 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{t("settings.security.passkeyNudge.title")}
@@ -23,6 +23,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";
@@ -231,7 +232,7 @@ export function TotpCard({
{/* ── Enable wizard ── */}
{setup && (
<div className="space-y-4">
<ol className="text-muted-foreground list-decimal space-y-3 pl-5 text-sm">
<ol className="text-muted-foreground list-decimal space-y-3 pl-6 text-sm">
<li>{t("settings.security.totp.step1")}</li>
<li>{t("settings.security.totp.step2")}</li>
</ol>
@@ -326,7 +327,7 @@ export function TotpCard({
{freshCodes && (
<div>
<RecoveryCodesPanel codes={freshCodes} />
<div className="mt-3 flex justify-end">
<SettingsCardActions className="mt-3">
<Button
type="button"
variant="outline"
@@ -335,7 +336,7 @@ export function TotpCard({
>
{t("settings.security.recovery.savedIt")}
</Button>
</div>
</SettingsCardActions>
</div>
)}
@@ -14,6 +14,8 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, Loader2, MonitorSmartphone } 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 { ConfirmButton } from "@/components/ui/confirm-button";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -86,36 +88,30 @@ export function SecuritySessionsCard({
return (
<SettingsCard data-slot="settings-security-sessions-card">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-sessions-toggle"
className="hover:bg-muted/40 focus-visible:ring-ring/50 -m-2 mb-2 flex w-full items-start gap-2 rounded-md p-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<MonitorSmartphone
className="text-muted-foreground mt-0.5 h-5 w-5 shrink-0"
aria-hidden="true"
/>
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<h2 className="text-lg font-semibold">
<SettingsCardHeader
icon={MonitorSmartphone}
title={
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={regionId}
data-slot="settings-security-sessions-toggle"
className="hover:text-foreground focus-visible:ring-ring/50 -m-1 flex min-h-11 items-center gap-2 rounded-md p-1 text-left focus-visible:ring-2 focus-visible:outline-none"
>
{t("settings.security.sessionsTitle")}
</h2>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground h-4 w-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</div>
</button>
<ChevronDown
aria-hidden="true"
className={cn(
"text-muted-foreground size-4 shrink-0 transition-transform",
open && "rotate-180",
)}
/>
</button>
}
description={t("settings.security.sessionsDescription")}
/>
<div id={regionId} hidden={!open} className="space-y-4">
<p className="text-muted-foreground text-xs">
{t("settings.security.sessionsDescription")}
</p>
{isLoading && (
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin motion-reduce:animate-none" />
)}
@@ -188,7 +184,7 @@ export function SecuritySessionsCard({
)}
{hasOthers && (
<div className="flex justify-end">
<SettingsCardActions>
<ConfirmButton
slot="sign-out-everywhere"
className="min-h-11 sm:min-h-9"
@@ -199,7 +195,7 @@ export function SecuritySessionsCard({
onConfirm={() => revokeOthers.mutate()}
pending={revokeOthers.isPending}
/>
</div>
</SettingsCardActions>
)}
{status && (
@@ -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. */}
<Button
type="submit"
disabled={
createMutation.isPending ||
!label.trim() ||
(!documentOnly && selectedLeaves.size === 0)
}
aria-describedby={documentOnly ? undefined : scopeSummaryId}
data-testid="share-create-submit"
className="min-h-11 sm:min-h-9"
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sharing.create")}
</Button>
<SettingsCardActions>
<Button
type="submit"
size="sm"
disabled={
createMutation.isPending ||
!label.trim() ||
(!documentOnly && selectedLeaves.size === 0)
}
aria-describedby={documentOnly ? undefined : scopeSummaryId}
data-testid="share-create-submit"
className="min-h-11 sm:min-h-9"
>
{createMutation.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.sharing.create")}
</Button>
</SettingsCardActions>
</form>
<ShareDocumentPicker
+4 -5
View File
@@ -23,7 +23,7 @@
*/
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FileText, KeyRound, Share2, Trash2 } from "lucide-react";
import { FileText, KeyRound, Link2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -134,7 +134,7 @@ function ShareLinksCard() {
return (
<SettingsCard>
<SettingsCardHeader
icon={Share2}
icon={Link2}
title={t("settings.sharing.createTitle")}
description={t("settings.sharing.createDescription")}
/>
@@ -220,9 +220,9 @@ function ShareLinksCard() {
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
variant="destructive"
size="sm"
className="text-destructive border-destructive/30 min-h-11 w-full sm:min-h-9"
className="min-h-11 w-full sm:min-h-9"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
{t("settings.sharing.revoke")}
@@ -242,7 +242,6 @@ function ShareLinksCard() {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
onClick={() => revokeMutation.mutate(link.id)}
>
{t("settings.sharing.revoke")}
+30 -28
View File
@@ -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() {
</div>
)}
{/* `flex-col-reverse` mirrors the DialogFooter idiom: the primary
action stacks on top on mobile, row order stays Cancel → Save
on `sm+`. */}
{dirty && priority && (
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="ghost"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={() => saveMutation.mutate(priority)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</div>
)}
{/* 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. */}
<p className="text-muted-foreground border-border border-t pt-3 text-xs">
{t("settings.sections.sources.integrationsHint")}{" "}
<Link
@@ -587,6 +562,33 @@ export function SourcesSection() {
{t("settings.sections.sources.integrationsHintLink")}
</Link>
</p>
{dirty && priority && (
<SettingsCardActions>
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => setDraft(null)}
disabled={saveMutation.isPending}
>
{t("common.cancel")}
</Button>
<Button
type="button"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => saveMutation.mutate(priority)}
disabled={saveMutation.isPending}
>
{saveMutation.isPending && (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.save")}
</Button>
</SettingsCardActions>
)}
</SettingsCard>
{/* v1.28 shipped `<NutrientIntakeCard>` here; the 2026-07-17 UX/IA
+12 -8
View File
@@ -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({
</Label>
</div>
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-xs">
<p>{t("settings.telegramStep1")}</p>
<p>{t("settings.telegramStep2")}</p>
<p>{t("settings.telegramStep3")}</p>
</div>
<SettingsInfoTile icon={Info} tone="neutral">
<ol className="list-decimal space-y-1 pl-4">
<li>{t("settings.telegramStep1")}</li>
<li>{t("settings.telegramStep2")}</li>
<li>{t("settings.telegramStep3")}</li>
</ol>
</SettingsInfoTile>
{msg && (
<p
@@ -207,7 +211,7 @@ export function TelegramCard({
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/telegram/test"
disabled={!settings?.hasBotToken}
@@ -224,7 +228,7 @@ export function TelegramCard({
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</div>
</SettingsCard>
@@ -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 (
<SettingsCard id={id} className="scroll-mt-28">
{/* 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. */}
<div className="flex items-center justify-between">
<SlidersHorizontal
className="text-muted-foreground h-5 w-5"
aria-hidden="true"
/>
{data && Object.keys(data.overrides).length > 0 && (
<ConfirmButton
slot="settings-thresholds-reset-all"
variant="ghost"
size="sm"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("thresholds.resetAllAction")}
title={t("thresholds.resetAllTitle")}
body={t("thresholds.resetAllBody")}
confirmLabel={t("thresholds.resetAllConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate(null)}
/>
)}
</div>
{/* 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. */}
<SettingsCardHeader
icon={SlidersHorizontal}
title={t("settings.sections.thresholds.title")}
/>
{isLoading || !data ? (
<ThresholdsSkeletonList />
@@ -163,6 +148,23 @@ export function ThresholdsEditorSection({ id }: { id: string }) {
))}
</div>
)}
{data && Object.keys(data.overrides).length > 0 ? (
<SettingsCardActions>
<ConfirmButton
slot="settings-thresholds-reset-all"
variant="outline"
size="sm"
icon={<RotateCcw className="h-3.5 w-3.5" />}
label={t("thresholds.resetAllAction")}
title={t("thresholds.resetAllTitle")}
body={t("thresholds.resetAllBody")}
confirmLabel={t("thresholds.resetAllConfirm")}
pending={resetMutation.isPending}
onConfirm={() => resetMutation.mutate(null)}
/>
</SettingsCardActions>
) : null}
</SettingsCard>
);
}
@@ -13,6 +13,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { ConfirmButton } from "@/components/ui/confirm-button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
@@ -148,7 +149,7 @@ export function TrustedDevicesCard({
)}
{devices.length > 0 && (
<div className="flex justify-end">
<SettingsCardActions>
<ConfirmButton
slot="revoke-all-trusted-devices"
className="min-h-11 sm:min-h-9"
@@ -161,7 +162,7 @@ export function TrustedDevicesCard({
onConfirm={() => revokeAll.mutate()}
pending={revokeAll.isPending}
/>
</div>
</SettingsCardActions>
)}
{status && (
+14 -13
View File
@@ -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 ? (
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
) : !isSupported || isDenied ? null : (
<div className="flex flex-wrap items-center justify-end gap-2">
<SettingsCardActions>
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
{isSubscribed ? (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleUnsubscribe}
@@ -184,7 +188,6 @@ export function WebPushCard() {
</Button>
) : (
<Button
variant="outline"
size="sm"
className="min-h-11"
onClick={handleSubscribe}
@@ -198,10 +201,7 @@ export function WebPushCard() {
{t("settings.webPushSubscribe")}
</Button>
)}
{isSubscribed && (
<TestConnectionButton endpoint="/api/notifications/web-push/test" />
)}
</div>
</SettingsCardActions>
);
return (
@@ -210,7 +210,6 @@ export function WebPushCard() {
icon={BellRing}
title={t("settings.webPush")}
description={t("settings.webPushDescription")}
status={headerAction}
/>
{(!loading && !isSupported) || (!loading && isDenied) || msg ? (
@@ -235,6 +234,8 @@ export function WebPushCard() {
)}
</div>
) : null}
{cardAction}
</SettingsCard>
);
}
+3 -2
View File
@@ -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 }) {
</p>
)}
<div className="flex flex-wrap items-start justify-end gap-2">
<SettingsCardActions>
<TestConnectionButton
endpoint="/api/settings/webhook/test"
disabled={!settings?.enabled}
@@ -219,7 +220,7 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
)}
{t("common.save")}
</Button>
</div>
</SettingsCardActions>
</form>
</div>
</SettingsCard>