Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 additions & 0 deletions docs/prs/PR-settings-locale-select-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# PR — un seul contrôle de langue, un seul écrivain

**Date** : 26 juillet 2026
**Auteur** : @cc-ankora
**Branche** : `fix/settings-locale-select`
**Revue de plan** : `plan-reviewer` — 🟡 ×2 (v1 puis v2), 13 corrections au total, toutes intégrées
**Audits** : `i18n-auditor` ✅ GO · `ui-auditor` (cf. §7)
**Décision produit** : @thierry, 26/07 — « juste FR - EN point, pas de précision spécifique, cela complique trop les choses pour rien »

---

## 1. Le défaut

Le sélecteur de langue de Réglages › Profil était cassé de deux façons cumulées, et
préexistait à tout ce qui a été livré cette semaine.

**Il ne pouvait rien enregistrer d'autre que le français.** Le `<Select>` proposait `fr-BE`,
`fr-FR` et `en-GB`. Le schéma serveur validait `z.enum(LOCALES)` avec
`LOCALES = ['fr-BE','nl-BE','en','es-ES','de-DE']` : `fr-FR` et `en-GB` n'y figurent pas, donc
toute sélection autre que le français belge échouait en validation et affichait un toast
d'erreur générique.

**Et même un `fr-BE` accepté ne changeait rien à l'écran.** `updateProfileAction` écrivait
`users.locale` sans toucher au cookie `NEXT_LOCALE` ni revalider le layout racine. Or depuis
#258 la locale rendue vient **exclusivement du préfixe d'URL** : la colonne changeait, l'app
restait dans la langue précédente.

La cause commune : **deux écrivains divergents** de la même préférence. `setLocaleAction`
faisait le travail complet (cookie + DB + `revalidatePath('/', 'layout')` + navigation),
`updateProfileAction` en faisait un tiers, mal.

## 2. Le correctif

`ProfileCard` rend désormais le **`<LocaleSwitcher />` existant** — segmented control FR | EN
basé sur `LOCALES_VISIBLE`, déjà audité a11y (radiogroup, cibles 44 px, `aria-busy`), déjà
branché sur `setLocaleAction` puis `router.replace`.

`setLocaleAction` devient le **seul** écrivain : `locale` sort de `profileUpdateSchema` et de
`updateProfileAction`, qui ne met plus à jour que `display_name`.

C'est la suggestion que `plan-reviewer` m'a faite et que la décision de @thierry a rendue
évidente : ma v1 prévoyait de réparer le `<Select>` maison, c'est-à-dire d'écrire une
**troisième** implémentation du même contrôle. Réutiliser celui qui existe supprime d'un coup
le double `onSubmit`, les clés `localeOptions`, et la question de la valeur initiale.

### Le contrôle sort du formulaire

Il ne s'agit pas d'un brouillon qu'on soumet : le switcher persiste immédiatement puis navigue
vers l'URL localisée, ce qui remonte la carte. Le laisser dans le `<form>` aurait suggéré que
« Enregistrer » s'y applique — et une saisie de nom en cours aurait disparu au changement de
langue, sans explication. Il est donc sur sa propre ligne, séparée par une bordure, avec la
grammaire libellé-gauche / contrôle-droite du toggle de thème du `MoreSheet`.

### Un `<span>`, et un nom accessible distinct — correction issue de l'audit UI

`LocaleSwitcher` est un `radiogroup` sans contrôle labelable unique : un `htmlFor` pendrait
dans le vide, d'où le `<span>`.

Ma première version laissait le switcher garder son propre `aria-label`. **`ui-auditor` a
relevé que c'était une régression que j'introduisais** : à ≥1024px, `/app/settings` monte
aussi le switcher du `HeaderNav` (son bloc `hidden lg:flex` est bien dans le DOM et
focusable). Deux `radiogroup` annoncés « Changer de langue » deviennent indiscernables dans
la liste des éléments d'un lecteur d'écran. Avant ce diff, un seul exemplaire coexistait par
viewport.

Corrigé plutôt que laissé en arbitrage : `LocaleSwitcher` accepte désormais un
`labelledById` optionnel, et le champ des Réglages nomme le groupe par son libellé visible
« Langue ». Les instances du header et du `MoreSheet` sont inchangées — sans la prop, le
comportement d'origine est conservé. Bénéfice secondaire : le nom accessible **égale**
désormais le texte visible au lieu de simplement le contenir (WCAG 2.5.3), ce que l'audit
signalait comme conforme mais fragile et non testé.

### Le piège des deux clés d'erreur quasi identiques

`settings.locale.invalid` (émise par le champ retiré du schéma) devenait orpheline et a été
supprimée des 5 fichiers. `errors.locale.invalid`, elle, est émise par `setLocaleAction` et a
été **préservée** — les confondre aurait cassé le chemin d'erreur du switcher.
`i18n-auditor` a vérifié les deux : orpheline absente partout, celle du switcher intacte et
toujours atteignable, parité des 5 locales conservée.

Le groupe `app.settings.profile.localeOptions` a été supprimé : c'était un doublon de
`ui.localeSwitcher.options.*`, et ce doublon est exactement ce qui avait permis à `fr-FR` et
`en-GB` de diverger de l'enum serveur.

## 3. Ce qui n'a délibérément pas bougé

- **`users.locale` reste écrit** par `setLocaleAction` et **reste lu** par `src/i18n/request.ts`
quand `requestLocale` est absent. Je m'étais trompé en écrivant que la colonne ne pilotait
plus grand-chose : c'est le filet cross-device / cookie perdu.
- **`profileUpdateSchema` reste un `z.object` nu, jamais `.strict()`.** Pendant un déploiement,
les onglets sur l'ancien bundle continuent d'envoyer `{ displayName, locale }` ; Zod strippe
les clés inconnues, donc ils dégradent proprement. `.strict()` les rejetterait sèchement,
pour une propreté que personne ne demande. Un test verrouille ce comportement.
- **Les trois éditions tiennent dans un seul commit.** Le schéma portait `.default('fr-BE')` :
si le client avait cessé d'envoyer `locale` avant que le schéma et l'action ne changent,
chaque sauvegarde de profil aurait silencieusement réinitialisé la langue de l'utilisateur.

## 4. Preuve

### Smoke en direct, session connectée

Joué sur la page Réglages réelle avec le fixture seedé :

```
label : Langue
options : ["FR","EN"]
ancien <Select> : 0 occurrence
```

Il a aussi **confirmé une prédiction de la revue** : la page porte **2 instances** du
`LocaleSwitcher` (le `Header variant="app"` en rend une dans un bloc `hidden lg:flex`, présent
dans le DOM à tout viewport ; le `MoreSheet` en rend une autre sur mobile). Les
`data-testid` internes du switcher sont donc ambigus sur cette page. D'où le conteneur
`data-testid="settings-locale-field"` : tout locator doit passer par lui, sinon il déclenche
une strict-mode violation. Les testids internes du switcher n'ont **pas** été touchés — ils
sont consommés par `e2e/i18n/locale-switcher.spec.ts` et `locale-detection-off.spec.ts`.

### Tests

| | |
| ------------------- | ----------------------------------------------------------------------- |
| `npm run test` | **1669 / 1669** |
| `npm run typecheck` | 0 erreur |
| `npm run lint` | 0 erreur, 8 warnings — **niveau préexistant**, aucun ajouté par ce diff |

`tests/actions/settings-profile.test.ts` — **nouveau**. Il épingle le payload réellement envoyé
à Supabase, seul endroit où la régression pourrait revenir sans bruit : le schéma ne type même
plus `locale`, donc un simple typecheck n'attraperait pas un `update()` écrit à la main.

**Falsifiabilité vérifiée** : en réintroduisant `locale: 'fr-BE'` dans l'`update()`, deux specs
passent au rouge avec `+ "locale": "fr-BE"`. Restauré → vert.

`e2e/mobile-ios/settings-locale-field.spec.ts` — **nouveau**, 3 specs : options exactement
FR|EN et ancien `<Select>` disparu ; nom accessible du groupe distinct de celui du header ;
et sauvegarder le nom ne touche pas à la langue (la séparation d'avec le formulaire). Joué en
local : **3/3 sur iPhone 14**. Il s'auto-skippe en CI, c'est écrit en tête du fichier.

`tests/schemas/settings.test.ts` — les deux specs qui asservissaient l'acceptation de `locale`
et le défaut `fr-BE` ont été **réécrites**, pas supprimées : elles asserent désormais le
contrat inverse. Les effacer aurait fait disparaître la seule trace que ce contrat a changé
volontairement.

### Ce que la CI ne prouvera pas

Aucun spec Playwright ne couvre ce contrôle : `e2e/smoke.spec.ts` ne teste que la redirection
vers `/login`, et les parcours authentifiés s'auto-skippent en CI faute de
`SUPABASE_SERVICE_ROLE_KEY` (un seul projet Supabase, la clé `service_role` ne doit pas y
atteindre). **Une CI verte ne vaut donc pas validation de ce correctif** — d'où le smoke seedé
local ci-dessus, exigé par la revue.

À consigner aussi : `setLocaleAction` n'a **aucun rate limit** là où `updateProfileAction`
consomme un jeton `rateLimit('mutation')`. Inchangé par rapport au `LocaleSwitcher` du header,
donc pas une régression de cette PR, mais le déplacer dans les Réglages en fait un chemin de
plus vers une action non limitée.

## 5. Note d'i18n consignée pour ne pas être re-débattue

Le libellé **visible** est « FR » / « EN », conforme à la décision de @thierry. Le **nom
accessible** des options vient de `ui.localeSwitcher.options.*`, où `fr-BE` vaut
« Français (BE) » — la mention régionale subsiste donc pour les technologies d'assistance.
`i18n-auditor` le qualifie de conforme : un sélecteur de langue affiche l'endonyme, et le code
`fr-BE` est précisément la valeur envoyée à `setLocaleAction`.

## 6. Definition of DONE

| # | Critère | Preuve |
| --- | ---------------------------------------------- | --------------------------------- |
| 1 | CI verte | cf. checks de la PR |
| 2 | Sourcery muet sur le dernier commit | `gh api …/comments` → sortie vide |
| 3 | Approbation humaine @thierry + threads résolus | à la revue |
| 4 | Pas de conflit avec `main` | `mergeStateStatus: CLEAN` |
| 5 | Rapport livré | ce fichier |

## 7. Audits

- **`i18n-auditor`** : ✅ **GO**. Parité vérifiée sur les 5 fichiers, clés supprimées sans
consommateur résiduel, `errors.locale.invalid` intacte et distincte de l'orpheline, aucun
résidu `fr-FR` / `en-GB`, aucun lecteur de `users.locale` cassé.
- **`ui-auditor`** : ✅ **GO conditionnel**, avec un P1 réel — la duplication du contrôle sur
desktop, corrigée dans cette PR (cf. §2), et un P1 « aucun test de non-régression sur le
nouveau champ », corrigé par `e2e/mobile-ios/settings-locale-field.spec.ts`.
Contraste, cible tactile, ordre de tabulation et parité i18n : conformes.

**Restent en P2, non traités ici et volontairement** :
- Pas d'`aria-describedby` « s'applique immédiatement » sur le switcher. Le signal est
visuel (bordure de séparation). Risque faible — convention établie pour les sélecteurs de
langue — mais réel pour un utilisateur non-voyant, dans ce nouveau contexte de carte de
formulaire.
- La relation « libellé visible ⊂ nom accessible » n'est verrouillée par aucun test unitaire
sur les 5 locales. Sans objet pour le champ des Réglages depuis la correction ci-dessus
(les deux sont désormais la même chaîne), mais toujours vrai pour les autres instances.
- `CardTitle` rend un `<div>` et jamais un vrai titre : ça concerne la hiérarchie de titres
de toute l'app, pas cette carte. À tracer séparément, hors périmètre.
83 changes: 83 additions & 0 deletions e2e/mobile-ios/settings-locale-field.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect } from '@playwright/test';

import { test } from './fixtures/mobile-test';

/**
* Settings › Profile — the language field.
*
* The control it replaced was broken twice over: it offered `fr-FR` and
* `en-GB`, neither of which exists in `LOCALES`, so every choice but `fr-BE`
* failed server validation with a generic toast; and `updateProfileAction`
* wrote `users.locale` without touching the NEXT_LOCALE cookie or revalidating,
* so even an accepted `fr-BE` changed nothing on screen — the rendered locale
* comes from the URL prefix alone. Two divergent writers for one preference.
*
* ⚠️ This spec is `seededUser`-gated and AUTO-SKIPS in CI (no
* `SUPABASE_SERVICE_ROLE_KEY` there — one Supabase project, the service_role
* key must not reach CI). A green pipeline does NOT mean these assertions ran.
* Run locally: load `.env.local` into the environment, start a server, then
* `E2E_BASE_URL=<url> npx playwright test e2e/mobile-ios/settings-locale-field.spec.ts`.
*/
test.describe('Settings — language field', () => {
test.beforeEach(async ({ page, seededUser }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(seededUser.email);
await page.getByLabel('Mot de passe').fill(seededUser.password);
await page.getByRole('button', { name: /^se connecter$/i }).click();
await page.waitForURL(/\/app\b/, { timeout: 30_000 });
await page.goto('/app/settings', { waitUntil: 'networkidle' });
});

test('offers exactly FR and EN, and nothing else', async ({ page }) => {
// Locators MUST be scoped through the wrapper: `Header variant="app"` also
// renders a LocaleSwitcher (its `hidden lg:flex` block is in the DOM at
// every viewport), so the switcher's own testids are ambiguous here.
const field = page.getByTestId('settings-locale-field');
await expect(field).toBeVisible();

await expect(field.getByRole('radio')).toHaveCount(2);
expect((await field.getByRole('radio').allTextContents()).map((t) => t.trim())).toEqual([
'FR',
'EN',
]);

// The old <Select> — with its fr-FR / en-GB options the server rejected —
// must be gone, not merely hidden.
await expect(page.getByRole('combobox')).toHaveCount(0);
});

test('names the group after its visible label, not the header switcher', async ({ page }) => {
// Two radiogroups are focusable on this page at ≥1024px. Sharing the
// accessible name "Changer de langue" would make them indistinguishable in
// a screen reader's element list, so this one is named by its visible
// "Langue" text — which also makes accessible name and visible label match
// exactly (WCAG 2.5.3) rather than merely overlap.
const field = page.getByTestId('settings-locale-field');
await expect(field.getByRole('radiogroup')).toHaveAccessibleName('Langue');
});

test('is not part of the profile form — saving the name leaves the language alone', async ({
page,
}) => {
// The switcher persists immediately and navigates; the name is a draft
// submitted with a button. Keeping them in one form implied "Save" applied
// to both, and made an unsaved name vanish on a language switch.
const field = page.getByTestId('settings-locale-field');
await expect(field.getByRole('radio', { name: /français/i })).toHaveAttribute(
'aria-checked',
'true',
);

await page.getByLabel(/nom d'affichage/i).fill('Thierry QA');
await page.getByRole('button', { name: /^enregistrer$/i }).click();

// Still French, still on the unprefixed URL: submitting the profile form
// must not touch the language.
await expect(page.locator('html')).toHaveAttribute('lang', 'fr-BE');
expect(page.url()).not.toMatch(/\/en(\/|$)/);
await expect(field.getByRole('radio', { name: /français/i })).toHaveAttribute(
'aria-checked',
'true',
);
});
});
8 changes: 0 additions & 8 deletions messages/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -904,11 +904,6 @@
"emailLabel": "E-Mail",
"displayNameLabel": "Anzeigename",
"localeLabel": "Sprache",
"localeOptions": {
"fr-BE": "Français (Belgique)",
"fr-FR": "Français (France)",
"en-GB": "English"
},
"submit": "Speichern",
"submitting": "Speichern…",
"toastSaved": "Profil aktualisiert"
Expand Down Expand Up @@ -1346,9 +1341,6 @@
"required": "Name erforderlich.",
"tooLong": "Maximal 80 Zeichen."
},
"locale": {
"invalid": "Ungültige Sprache."
},
"factorId": {
"invalid": "Ungültige Kennung."
},
Expand Down
8 changes: 0 additions & 8 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -904,11 +904,6 @@
"emailLabel": "Email",
"displayNameLabel": "Display name",
"localeLabel": "Language",
"localeOptions": {
"fr-BE": "Français (Belgique)",
"fr-FR": "Français (France)",
"en-GB": "English"
},
"submit": "Save",
"submitting": "Saving…",
"toastSaved": "Profile updated"
Expand Down Expand Up @@ -1346,9 +1341,6 @@
"required": "Name required.",
"tooLong": "Maximum 80 characters."
},
"locale": {
"invalid": "Invalid language."
},
"factorId": {
"invalid": "Invalid identifier."
},
Expand Down
8 changes: 0 additions & 8 deletions messages/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -904,11 +904,6 @@
"emailLabel": "Email",
"displayNameLabel": "Nombre para mostrar",
"localeLabel": "Idioma",
"localeOptions": {
"fr-BE": "Français (Belgique)",
"fr-FR": "Français (France)",
"en-GB": "English"
},
"submit": "Guardar",
"submitting": "Guardando…",
"toastSaved": "Perfil actualizado"
Expand Down Expand Up @@ -1346,9 +1341,6 @@
"required": "Nombre requerido.",
"tooLong": "Máximo 80 caracteres."
},
"locale": {
"invalid": "Idioma inválido."
},
"factorId": {
"invalid": "Identificador inválido."
},
Expand Down
8 changes: 0 additions & 8 deletions messages/fr-BE.json
Original file line number Diff line number Diff line change
Expand Up @@ -904,11 +904,6 @@
"emailLabel": "Email",
"displayNameLabel": "Nom d'affichage",
"localeLabel": "Langue",
"localeOptions": {
"fr-BE": "Français (Belgique)",
"fr-FR": "Français (France)",
"en-GB": "English"
},
"submit": "Enregistrer",
"submitting": "Enregistrement…",
"toastSaved": "Profil mis à jour"
Expand Down Expand Up @@ -1346,9 +1341,6 @@
"required": "Nom requis.",
"tooLong": "Maximum 80 caractères."
},
"locale": {
"invalid": "Langue invalide."
},
"factorId": {
"invalid": "Identifiant invalide."
},
Expand Down
8 changes: 0 additions & 8 deletions messages/nl-BE.json
Original file line number Diff line number Diff line change
Expand Up @@ -904,11 +904,6 @@
"emailLabel": "E-mail",
"displayNameLabel": "Weergavenaam",
"localeLabel": "Taal",
"localeOptions": {
"fr-BE": "Français (Belgique)",
"fr-FR": "Français (France)",
"en-GB": "English"
},
"submit": "Opslaan",
"submitting": "Opslaan…",
"toastSaved": "Profiel bijgewerkt"
Expand Down Expand Up @@ -1346,9 +1341,6 @@
"required": "Naam verplicht.",
"tooLong": "Maximum 80 tekens."
},
"locale": {
"invalid": "Taal niet geldig."
},
"factorId": {
"invalid": "Identificator niet geldig."
},
Expand Down
Loading
Loading