diff --git a/common/messages/de.json b/common/messages/de.json
index a4e56494..edda4fb0 100644
--- a/common/messages/de.json
+++ b/common/messages/de.json
@@ -1436,6 +1436,7 @@
"security.seo.title": "Sicherheit",
"security.title": "Sicherheit",
"send_message.button_label": "Nachricht",
+ "settings.account.card_description": "Wie Sie sich anmelden und wie wir Sie erreichen.",
"settings.action.cancel": "Abbrechen",
"settings.action.save": "Speichern",
"settings.connection_preferences.description": "Kontrollieren Sie, wie andere sich mit Ihnen verbinden können.",
@@ -1447,16 +1448,20 @@
"settings.connection_preferences.update_failed": "Fehler beim Aktualisieren der Einstellungen",
"settings.connection_preferences.updated": "Einstellungen aktualisiert",
"settings.danger_zone": "Gefahrenzone",
+ "settings.danger_zone.description": "Beim Löschen Ihres Kontos werden Profil, Nachrichten und Antworten entfernt. Das lässt sich nicht rückgängig machen.",
+ "settings.data_privacy.card_description": "Alles, was Compass über Sie speichert, gehört Ihnen.",
"settings.data_privacy.description": "Laden Sie eine JSON-Datei mit allen Ihren Informationen herunter: Profil, Konto, Nachrichten, Kompatibilitätsantworten, markierte Profile, Stimmen, Empfehlungen, Such-Lesezeichen usw.",
"settings.data_privacy.download": "Alle meine Daten herunterladen (JSON)",
"settings.data_privacy.download.error": "Fehler beim Herunterladen Ihrer Daten. Bitte versuchen Sie es erneut.",
"settings.data_privacy.download.success": "Ihr Daten-Export wurde heruntergeladen.",
"settings.data_privacy.downloading": "Herunterladen...",
+ "settings.data_privacy.export": "Ihre Daten exportieren",
"settings.data_privacy.title": "Daten & Datenschutz",
"settings.delete.error": "Löschen des Kontos fehlgeschlagen.",
"settings.delete.loading": "Konto wird gelöscht..",
"settings.delete.success": "Ihr Konto wurde gelöscht.",
"settings.delete_account": "Konto löschen",
+ "settings.delete_account.description": "Wir fragen Sie nach dem Grund, bevor etwas entfernt wird.",
"settings.delete_confirm": "Möchten Sie Ihr Profil wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
"settings.email.change": "E-Mail-Adresse ändern",
"settings.email.invalid": "Ungültige E-Mail-Adresse",
@@ -1470,19 +1475,27 @@
"settings.email.send_verification": "Bestätigungs-E-Mail senden",
"settings.email.sending": "Bestätigungs-E-Mail wird gesendet...",
"settings.email.too_many_requests": "Sie können nicht mehr als eine E-Mail pro Minute anfordern. Bitte warten Sie, bevor Sie eine weitere Anfrage senden.",
+ "settings.email.unverified_short": "Nicht bestätigt",
"settings.email.update_failed": "Aktualisierung der E-Mail-Adresse fehlgeschlagen",
"settings.email.updated_success": "E-Mail-Adresse erfolgreich aktualisiert",
"settings.email.verification_failed": "Senden der Bestätigungs-E-Mail fehlgeschlagen.",
"settings.email.verification_sent": "Bestätigungs-E-Mail gesendet – prüfen Sie Ihren Posteingang und Spam-Ordner.",
"settings.email.verified": "E-Mail bestätigt ✔️",
+ "settings.email.verified_short": "Bestätigt",
+ "settings.email.verify": "E-Mail-Adresse bestätigen",
+ "settings.email.verify_description": "Einige Funktionen bleiben gesperrt, bis Ihre Adresse bestätigt ist.",
"settings.general.account": "Konto",
"settings.general.email": "E-Mail",
"settings.general.font": "Schriftart",
+ "settings.general.font_description": "Atkinson Hyperlegible ist auf leichtere Lesbarkeit ausgelegt.",
"settings.general.language": "Sprache",
+ "settings.general.language_description": "Die Sprache der Benutzeroberfläche.",
"settings.general.measurement": "Maßeinheitensystem",
+ "settings.general.measurement_description": "Wird für Größen und Entfernungen verwendet.",
"settings.general.password": "Passwort",
"settings.general.people": "Personen",
"settings.general.theme": "Design",
+ "settings.general.theme_description": "„Automatisch“ folgt der Hell-/Dunkel-Einstellung Ihres Geräts.",
"settings.hidden_profiles.description": "Diese Personen erscheinen nicht in Ihren Suchergebnissen.",
"settings.hidden_profiles.empty": "Sie haben keine Profile ausgeblendet.",
"settings.hidden_profiles.load_error": "Fehler beim Laden ausgeblendeter Profile.",
@@ -1495,7 +1508,12 @@
"settings.hidden_profiles.unhiding": "Einblenden...",
"settings.measurement.imperial": "Imperial",
"settings.measurement.metric": "Metrisch",
+ "settings.password.description": "Wir senden Ihnen einen Link, um ein neues zu wählen.",
"settings.password.send_reset": "Passwort-Zurücksetzungs-E-Mail senden",
+ "settings.people.card_description": "Profile, die Sie nicht sehen möchten.",
+ "settings.saved": "Gespeichert",
+ "settings.section.appearance": "Darstellung & Sprache",
+ "settings.section.appearance_description": "Wie Compass auf diesem Gerät aussieht und sich liest.",
"settings.tabs.about": "Über",
"settings.tabs.general": "Allgemein",
"settings.tabs.notifications": "Benachrichtigungen",
diff --git a/common/messages/fr.json b/common/messages/fr.json
index 52428752..902ed14a 100644
--- a/common/messages/fr.json
+++ b/common/messages/fr.json
@@ -1435,6 +1435,7 @@
"security.seo.title": "Sécurité",
"security.title": "Sécurité",
"send_message.button_label": "Contacter",
+ "settings.account.card_description": "Comment vous vous connectez et comment nous vous contactons.",
"settings.action.cancel": "Annuler",
"settings.action.save": "Enregistrer",
"settings.connection_preferences.description": "Contrôlez comment les autres peuvent se connecter avec vous.",
@@ -1446,16 +1447,20 @@
"settings.connection_preferences.update_failed": "Échec de la mise à jour des préférences",
"settings.connection_preferences.updated": "Préférences mises à jour",
"settings.danger_zone": "Zone dangereuse",
+ "settings.danger_zone.description": "La suppression de votre compte efface votre profil, vos messages et vos réponses. Cette action est irréversible.",
+ "settings.data_privacy.card_description": "Tout ce que Compass conserve à votre sujet vous appartient.",
"settings.data_privacy.description": "Téléchargez un fichier JSON contenant toutes vos informations : profil, compte, messages, réponses de compatibilité, profils en favoris, votes, recommandations, recherches enregistrées, etc.",
"settings.data_privacy.download": "Télécharger toutes mes données (JSON)",
"settings.data_privacy.download.error": "Échec du téléchargement de vos données. Veuillez réessayer.",
"settings.data_privacy.download.success": "Votre export de données a été téléchargé.",
"settings.data_privacy.downloading": "Téléchargement...",
+ "settings.data_privacy.export": "Exporter vos données",
"settings.data_privacy.title": "Données & Confidentialité",
"settings.delete.error": "Échec de la suppression du compte.",
"settings.delete.loading": "Suppression du compte..",
"settings.delete.success": "Votre compte a été supprimé.",
"settings.delete_account": "Supprimer le compte",
+ "settings.delete_account.description": "Nous vous demanderons pourquoi avant de supprimer quoi que ce soit.",
"settings.delete_confirm": "Êtes-vous sûr de vouloir supprimer votre profil ? Cela ne peut pas être annulé.",
"settings.email.change": "Modifier l'adresse e‑mail",
"settings.email.invalid": "Adresse e‑mail invalide",
@@ -1469,19 +1474,27 @@
"settings.email.send_verification": "Envoyer un e-mail de vérification",
"settings.email.sending": "Envoi de l'e-mail de vérification...",
"settings.email.too_many_requests": "Vous ne pouvez pas demander plus d'un e-mail par minute. Veuillez attendre avant d'envoyer une autre demande.",
+ "settings.email.unverified_short": "Non vérifiée",
"settings.email.update_failed": "Échec de la mise à jour de l'adresse e‑mail",
"settings.email.updated_success": "Adresse e‑mail mise à jour avec succès",
"settings.email.verification_failed": "Échec de l'envoi de l'e-mail de vérification.",
"settings.email.verification_sent": "E-mail de vérification envoyé — vérifiez votre boîte de réception et les spams.",
"settings.email.verified": "E-mail vérifié ✔️",
+ "settings.email.verified_short": "Vérifiée",
+ "settings.email.verify": "Vérifier votre e-mail",
+ "settings.email.verify_description": "Certaines fonctionnalités restent bloquées tant que votre adresse n’est pas confirmée.",
"settings.general.account": "Compte",
"settings.general.email": "E-mail",
"settings.general.font": "Police",
+ "settings.general.font_description": "Atkinson Hyperlegible est conçue pour une lecture plus facile.",
"settings.general.language": "Langue",
+ "settings.general.language_description": "La langue de l’interface.",
"settings.general.measurement": "Système de mesure",
+ "settings.general.measurement_description": "Utilisé pour les tailles et les distances.",
"settings.general.password": "Mot de passe",
"settings.general.people": "Personnes",
"settings.general.theme": "Thème",
+ "settings.general.theme_description": "« Auto » suit le réglage clair ou sombre de votre appareil.",
"settings.hidden_profiles.description": "Ces personnes n'apparaissent pas dans vos résultats de recherche.",
"settings.hidden_profiles.empty": "Vous n'avez masqué aucun profil.",
"settings.hidden_profiles.load_error": "Échec du chargement des profils masqués.",
@@ -1494,7 +1507,12 @@
"settings.hidden_profiles.unhiding": "Affichage...",
"settings.measurement.imperial": "Impérial",
"settings.measurement.metric": "Métrique",
+ "settings.password.description": "Nous vous enverrons un lien pour en choisir un nouveau.",
"settings.password.send_reset": "Envoyer un e‑mail de réinitialisation de mot de passe",
+ "settings.people.card_description": "Les profils que vous avez choisi de ne pas voir.",
+ "settings.saved": "Enregistré",
+ "settings.section.appearance": "Apparence et langue",
+ "settings.section.appearance_description": "L’apparence et la lisibilité de Compass sur cet appareil.",
"settings.tabs.about": "À propos",
"settings.tabs.general": "Général",
"settings.tabs.notifications": "Notifications",
diff --git a/tests/e2e/web/pages/settingsPage.ts b/tests/e2e/web/pages/settingsPage.ts
index 5b71eda3..d5499653 100644
--- a/tests/e2e/web/pages/settingsPage.ts
+++ b/tests/e2e/web/pages/settingsPage.ts
@@ -58,9 +58,25 @@ export class SettingsPage {
await this.measurementSystemToggle.click()
}
+ /**
+ * The theme control is three radio pills (Light / Dark / Auto), not the single cycling button it
+ * used to be, so "toggle" means advancing to the next option rather than clicking one element.
+ */
async toggleDisplayTheme() {
await expect(this.themeToggle).toBeVisible()
- await this.themeToggle.click()
+ const options = this.themeToggle.getByRole('radio')
+ const count = await options.count()
+ let checked = 0
+ for (let i = 0; i < count; i++) {
+ if ((await options.nth(i).getAttribute('aria-checked')) === 'true') checked = i
+ }
+ await options.nth((checked + 1) % count).click()
+ }
+
+ async setDisplayTheme(theme: 'light' | 'dark' | 'auto') {
+ const option = this.page.getByTestId(`settings-theme-${theme}`)
+ await expect(option).toBeVisible()
+ await option.click()
}
async setFont(font: FontsTuple) {
diff --git a/web/components/buttons/button.tsx b/web/components/buttons/button.tsx
index 4fcf0a20..f48f4634 100644
--- a/web/components/buttons/button.tsx
+++ b/web/components/buttons/button.tsx
@@ -49,7 +49,13 @@ export function buttonClass(size: SizeType, color: ColorType) {
color === 'green' && [solid, 'bg-teal-500 hover:bg-teal-600'],
color === 'green-outline' && [outline, 'text-teal-500 hover:bg-teal-500'],
color === 'red' && [solid, 'bg-red-500 hover:bg-red-600'],
- color === 'red-outline' && [outline, 'text-scarlet-500 hover:bg-scarlet-500'],
+ // `scarlet` is this theme's *grey* "no" token (`--color-no-500` is 140 140 140), so the red
+ // outline button rendered as a grey one — indistinguishable from `gray-outline` beside it. The
+ // red ramp is the actual red. Hover also flips the label to white, or red-on-red was unreadable.
+ color === 'red-outline' && [
+ outline,
+ 'text-red-500 !ring-red-500/40 hover:bg-red-500 hover:text-white',
+ ],
color === 'yellow' && [solid, 'bg-yellow-400 hover:bg-yellow-500'],
color === 'yellow-outline' && [outline, 'text-yellow-500 hover:bg-yellow-500'],
color === 'blue' && [solid, 'bg-blue-400 hover:bg-blue-500'],
diff --git a/web/components/connection-preferences-settings.tsx b/web/components/connection-preferences-settings.tsx
index 3da3a643..77a4cfae 100644
--- a/web/components/connection-preferences-settings.tsx
+++ b/web/components/connection-preferences-settings.tsx
@@ -1,12 +1,19 @@
-import {useState} from 'react'
+import {CheckIcon} from '@heroicons/react/24/outline'
+import clsx from 'clsx'
+import {useEffect, useState} from 'react'
import toast from 'react-hot-toast'
-import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
+import {SettingsRow} from 'web/components/settings/settings-card'
import {SwitchSetting} from 'web/components/switch-setting'
import {useProfile} from 'web/hooks/use-profile'
import {updateProfile} from 'web/lib/api'
import {useT} from 'web/lib/locale'
+/**
+ * Renders as bare `SettingsRow`s so it can sit inside the Connections card on the settings page —
+ * the card header now carries the title and the "control how others can connect with you" line that
+ * this component used to print itself.
+ */
export function ConnectionPreferencesSettings() {
const t = useT()
const profile = useProfile()
@@ -19,12 +26,17 @@ export function ConnectionPreferencesSettings() {
)
const [isUpdating, setIsUpdating] = useState(false)
+ // A fresh object per save, not just the field name: flipping the same toggle twice must show the
+ // confirmation twice, and a state value that compares equal to itself would not re-fire the effect
+ // that shows it.
+ const [saved, setSaved] = useState<{field: string; at: number} | null>(null)
const handleUpdate = async (field: string, value: boolean) => {
setIsUpdating(true)
try {
await updateProfile({[field]: value})
// toast.success(t('settings.connection_preferences.updated', 'Preferences updated'))
+ setSaved({field, at: Date.now()})
} catch (error) {
console.error('Error updating preferences:', error)
toast.error(
@@ -46,55 +58,76 @@ export function ConnectionPreferencesSettings() {
}
return (
-
-
- {t(
- 'settings.connection_preferences.description',
- 'Control how others can connect with you.',
+ <>
+
+ >
+
+
+
+
+
-
-
- {t(
- 'settings.connection_preferences.indicator_description',
- 'Allow people to privately signal interest. You are only notified if the interest is mutual.',
- )}
-
-
-
-
-
+
+
+
+
+
+
+ >
+ )
+}
+
+/**
+ * These toggles write straight through with no confirmation of any kind — the success toast has been
+ * commented out for a while — so flipping one and being told nothing is indistinguishable from
+ * flipping one and having it fail silently. A checkmark that fades after two seconds is the smallest
+ * thing that closes that loop without a toast covering the page on every tap.
+ */
+function SavedFlash(props: {field: string; saved: {field: string; at: number} | null}) {
+ const {field, saved} = props
+ const [visible, setVisible] = useState(false)
+ const t = useT()
+
+ useEffect(() => {
+ if (saved?.field !== field) return
+ setVisible(true)
+ const timer = setTimeout(() => setVisible(false), 2000)
+ return () => clearTimeout(timer)
+ }, [saved, field])
+
+ return (
+
+
+ {t('settings.saved', 'Saved')}
+
)
}
diff --git a/web/components/font-picker.tsx b/web/components/font-picker.tsx
index 6412ed45..8fe3a4aa 100644
--- a/web/components/font-picker.tsx
+++ b/web/components/font-picker.tsx
@@ -24,7 +24,7 @@ export function FontPicker(props: {className?: string} = {}) {
value={font}
onChange={(e: React.ChangeEvent) => setFont(e.target.value as FontOption)}
className={clsx(
- 'rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50',
+ 'rounded-lg border border-canvas-300 bg-canvas-50 px-3 py-2 text-sm text-ink-1000 transition-colors hover:border-primary-400 focus:border-primary-500 focus:outline-none',
className,
)}
>
diff --git a/web/components/language/language-picker.tsx b/web/components/language/language-picker.tsx
index ebf2e457..ab269fc3 100644
--- a/web/components/language/language-picker.tsx
+++ b/web/components/language/language-picker.tsx
@@ -15,7 +15,7 @@ export function LanguagePicker(props: {className?: string} = {}) {
value={locale}
onChange={(e: React.ChangeEvent) => setLocale(e.target.value)}
className={clsx(
- 'rounded-md border border-gray-300 px-2 py-1 text-sm bg-canvas-50',
+ 'rounded-lg border border-canvas-300 bg-canvas-50 px-3 py-2 text-sm text-ink-1000 transition-colors hover:border-primary-400 focus:border-primary-500 focus:outline-none',
className,
)}
>
diff --git a/web/components/layout/tabs.tsx b/web/components/layout/tabs.tsx
index 5719e28b..b93ccfe1 100644
--- a/web/components/layout/tabs.tsx
+++ b/web/components/layout/tabs.tsx
@@ -168,8 +168,18 @@ export function ControlledTabs(props: TabProps & {activeIndex: number}) {
)
}
-export function UncontrolledTabs(props: TabProps & {defaultIndex?: number}) {
- const {defaultIndex, onClick, ...rest} = props
+export function UncontrolledTabs(
+ props: TabProps & {
+ defaultIndex?: number
+ /**
+ * Fires with the active index, including the one restored from cache on mount — `onClick` only
+ * ever reports a change the user just made, which is not enough for a caller that has to render
+ * something else alongside the active tab (the settings page's section index).
+ */
+ onActiveIndexChange?: (index: number) => void
+ },
+) {
+ const {defaultIndex, onClick, onActiveIndexChange, ...rest} = props
const [activeIndex, setActiveIndex] = usePersistentInMemoryState(
defaultIndex ?? 0,
`tab-${props.trackingName}-${props.name ?? props.tabs[0]?.title}`,
@@ -185,6 +195,10 @@ export function UncontrolledTabs(props: TabProps & {defaultIndex?: number}) {
}
}, [])
+ useEffect(() => {
+ onActiveIndexChange?.(activeIndex)
+ }, [activeIndex])
+
return (
-