# Architecture frontend — Projet Batch-cooking

> Documentation de l'organisation d'`apps/web` : structure des dossiers, routing,
> gestion des erreurs, et conventions de style (SCSS/theming).

---

## Structure des dossiers

```
apps/web/src/
├── api/
│   └── client.ts            # ApiClient — appels fetch vers l'API (voir error-handling.md)
├── i18n/
│   └── i18n.ts               # config i18next, importé une fois (main.tsx) pour son effet de bord
├── locales/
│   └── fr/translation.json   # libellés français (common.*, errors.*, auth.*, layout.*, planning.*, recipes.*, shoppingList.*, onboarding.*, household.*, account.*, preferences.*, userPreferences.*, credits.*, catalog.*)
├── services/
│   └── error-message.service.ts  # ErrorMessageService — code d'erreur → clé i18next
├── components/
│   └── ui/                   # primitives réutilisables partout, voir plus bas
│       ├── Dialog.tsx + dialog.scss       # modale (élément <dialog> natif)
│       ├── Checkbox.tsx / Radio.tsx        # "carte sélectionnable" (CheckboxOption/RadioOption)
│       └── Tooltip.tsx + tooltip.scss      # infobulle CSS-only
├── features/
│   ├── auth/                # authentification
│   │   ├── AuthContext.tsx       # état global (profil connecté, login/signup/logout, refreshUser, deleteAccount)
│   │   ├── RequireAuth.tsx / RedirectIfAuthenticated.tsx  # gardes de route
│   │   └── auth-form.scss        # styles partagés par LoginPage et SignupPage
│   ├── theme/
│   │   └── ThemeContext.tsx      # clair/sombre/système, voir plus bas
│   ├── profile/              # champs du parcours régime/allergènes/goûts (personnels)
│   │   ├── HouseNameField.tsx / DietSelect.tsx / AllergySelect.tsx / DislikedIngredientsField.tsx
│   │   └── profile-forms.scss
│   ├── house/                # préférences propres au foyer
│   │   ├── SourceSelect.tsx      # quelles sources externes le foyer voit
│   │   └── house-forms.scss
│   ├── planning/
│   │   ├── RecipePickerDialog.tsx  # dialogue "ajouter au planning" — parcourir/prévisualiser/importer, voir plus bas
│   │   ├── recipe-picker-dialog.scss
│   │   └── WeekNavigator.tsx + week-navigator.scss  # arrows + calendrier de sélection de semaine — partagé par PlanningPage et ShoppingListPage, voir plus bas
│   └── recipes/               # catalogue, import, édition — voir plus bas ; sous-dossiers par sous-domaine, pas de fichiers à plat
│       ├── RecipeTable.tsx / RecipeTabs.tsx / RecipeDetailPanel.tsx   # racine : composants transverses au sous-domaine (utilisés par plusieurs des sous-dossiers ci-dessous)
│       ├── recipes.scss                                              # feuille de style partagée, importée depuis chaque sous-dossier via ../recipes.scss
│       ├── badges/
│       │   └── DietTagSelect.tsx / DietBadges.tsx / AllergenBadges.tsx / ReproducibleBadge.tsx / FavoriteStarButton.tsx
│       ├── ingredients/
│       │   └── IngredientPicker.tsx / IngredientRow.tsx / ingredient-icons.tsx
│       ├── steps/
│       │   └── StepListEditor.tsx / StepDescription.tsx / highlight-tech-steps.ts
│       └── sources/
│           └── RecipeSourcesPanel.tsx / SourceItemTable.tsx / RecipeImportForm.tsx / recipe-import-draft.ts / useEnabledSources.ts
├── layouts/
│   ├── AppLayout.tsx + .scss  # sidebar (nav, sous-menu Paramètres, menu compte) commune à tout l'espace connecté, voir plus bas
│   └── nav-icons.tsx          # ré-export nommé des icônes lucide-react utilisées par la sidebar
├── pages/                      # un sous-dossier par section routée — jamais tous les fichiers à plat, un seul composant par sous-dossier n'est pas un problème (cohérence de rangement avant tout)
│   ├── auth/
│   │   └── LoginPage.tsx / SignupPage.tsx (via features/auth/auth-form.scss, partagé)
│   ├── planning/
│   │   └── PlanningPage.tsx + planning-page.scss   # grille de la semaine (routée sur "/"), voir plus bas
│   ├── recipes/
│   │   ├── RecipesPage.tsx                      # vue maître-détail du catalogue (routée sur /recettes, /recettes/:id, /recettes/sources/:sourceKey/:externalId)
│   │   ├── RecipeFormPage.tsx                   # création/édition manuelle (/recettes/nouvelle, /recettes/:id/modifier)
│   │   └── ImportRecipePage.tsx                 # route de secours autonome pour un import (/recettes/importer/:sourceKey/:externalId)
│   ├── shopping-list/
│   │   ├── ShoppingListPage.tsx + shopping-list-page.scss  # liste agrégée (GET /shopping-list), groupée par rayon, voir plus bas
│   │   └── shopping-list.ts                     # logique pure (groupement/tri/formatage) extraite du composant, voir plus bas
│   ├── settings/                                # ancienne HouseholdPage éclatée en 5 pages, voir plus bas
│   │   ├── AccountSettingsPage.tsx / HouseholdSettingsPage.tsx / PreferencesPage.tsx
│   │   ├── UserPreferencesPage.tsx / CreditsPage.tsx
│   │   └── settings-pages.scss
│   └── onboarding/            # wizard d'inscription (régime → foyer → [sources] → allergènes), voir plus bas
│       ├── OnboardingDietPage.tsx / OnboardingHouseholdPage.tsx / OnboardingSourcesPage.tsx / OnboardingAllergensPage.tsx
│       └── onboarding.scss
├── styles/
│   ├── _theme.scss          # tokens de design (couleurs, espacements, typographie) — variantes clair/sombre
│   └── global.scss          # reset minimal + import du theme — importé une seule fois (main.tsx)
├── lib/
│   ├── zod-errors.ts        # utilitaire : erreurs zod → { champ: message }
│   └── client-key.ts        # makeClientKey() — identité React locale/éphémère pour une ligne de brouillon (ingrédient/étape en cours d'édition), jamais envoyée au serveur ; volontairement pas crypto.randomUUID() (indisponible hors contexte sécurisé, ex. Capacitor)
├── App.tsx                  # table de routes
└── main.tsx                 # point d'entrée : providers (Router, AuthProvider, ThemeProvider) + imports i18n/CSS globaux
```

**Règle de placement des styles** : un style spécifique à un seul composant/page vit
dans un fichier `.scss` au même niveau que ce composant (`PlanningPage.tsx` +
`planning-page.scss`). Un style partagé par plusieurs composants d'une même feature vit
dans le dossier de la feature (`features/auth/auth-form.scss`, utilisé par
`LoginPage` et `SignupPage`). Seuls le reset et les tokens globaux vivent dans
`styles/`.

---

## Routing et gardes d'authentification

```mermaid
flowchart TB
  START(("Visite de l'app"))
  CHECK{"AuthProvider :<br/>GET /auth/me"}
  START --> CHECK

  CHECK -->|"200 (session valide)"| AUTHED["user défini"]
  CHECK -->|"401 (pas de session)"| ANON["user = null"]

  AUTHED --> LAYOUT["RequireAuth → AppLayout (sidebar)"]
  LAYOUT --> ROUTE_HOME["/ → PlanningPage"]
  LAYOUT --> ROUTE_RECIPES["/recettes, /recettes/:id,<br/>/recettes/sources/:sourceKey/:externalId → RecipesPage"]
  LAYOUT --> ROUTE_RECIPE_FORM["/recettes/nouvelle,<br/>/recettes/:id/modifier → RecipeFormPage"]
  LAYOUT --> ROUTE_IMPORT["/recettes/importer/:sourceKey/:externalId → ImportRecipePage"]
  LAYOUT --> ROUTE_SHOPPING["/liste-de-courses → ShoppingListPage"]
  LAYOUT --> ROUTE_SETTINGS["/parametres/* → pages de paramètres"]
  ROUTE_OLD["/foyer"] -->|"redirige"| ROUTE_SETTINGS
  AUTHED --> ROUTE_LOGIN_A["/login ou /signup"]
  ROUTE_LOGIN_A -->|"RedirectIfAuthenticated"| ROUTE_HOME

  ANON --> ROUTE_HOME_A["/, /recettes, ..."]
  ROUTE_HOME_A -->|"RequireAuth"| ROUTE_LOGIN["/login"]
  ANON --> ROUTE_LOGIN2["/login ou /signup → rendu normal"]
```

- `AuthContext` (`features/auth/AuthContext.tsx`) appelle `GET /auth/me` une seule
  fois au montage pour restaurer la session depuis le cookie httpOnly — c'est ce qui
  permet à un rechargement de page de garder l'utilisateur connecté.
- `RequireAuth` et `RedirectIfAuthenticated` sont deux gardes de route
  (`react-router-dom`) qui lisent cet état : la première protège tout l'espace
  connecté (voir `AppLayout` ci-dessous), la seconde protège `/login` et `/signup`
  (redirige un utilisateur déjà connecté vers `/`). Les deux affichent `null` tant
  que la vérification initiale est en cours, pour éviter un flash de contenu suivi
  d'une redirection.
- **`RedirectIfAuthenticated` verrouille sa décision une seule fois** (au moment où
  `isLoading` passe à `false`), au lieu de réagir à chaque changement de `user` —
  bug trouvé en construisant le wizard d'inscription (voir plus bas) : un
  `navigate()` explicite dans le formulaire qu'elle protège entrait en course avec
  son propre `<Navigate>`, invisible tant que les deux ciblaient "/".
- **Table de routes complète** (`App.tsx`) : sous l'unique parent
  `RequireAuth`+`AppLayout` — `/` (`PlanningPage`), `/recettes` /
  `/recettes/:id` / `/recettes/sources/:sourceKey/:externalId` (les trois
  rendent **le même composant** `RecipesPage`, voir plus bas), `/recettes/nouvelle`
  / `/recettes/:id/modifier` (`RecipeFormPage`),
  `/recettes/importer/:sourceKey/:externalId` (`ImportRecipePage`, route de
  secours autonome), `/liste-de-courses` (`ShoppingListPage`),
  et les cinq pages `/parametres/*` (compte, préférences, foyer,
  préférences-utilisateur, crédits — voir plus bas). `/foyer` (l'URL de
  l'ancienne page combinée) redirige vers `/parametres/foyer` pour ne pas casser
  un lien existant.
- **`/onboarding/*`** reste son propre groupe de routes top-level (pas nichées
  sous `AppLayout` — wizard plein écran sans sidebar) : `regime` → `foyer` →
  `sources` (conditionnelle — seulement si l'étape `foyer` a créé/rejoint un
  foyer, sinon on saute directement à l'étape suivante) → `allergenes`.

---

## `AppLayout` — sidebar commune à l'espace connecté

`App.tsx` monte **un seul** `RequireAuth` + `AppLayout` comme route parente de
toutes les routes authentifiées (routes imbriquées `react-router-dom`, voir la
table complète plus haut). `AppLayout` (`layouts/AppLayout.tsx`) rend une
sidebar et un `<main>` qui affiche la route enfant matchée via `<Outlet />` —
la garde d'auth et le chrome de navigation ne sont donc écrits qu'une fois, pas
dupliqués par page comme `RequireAuth` l'était individuellement avant cette
feature. En dessous de 640px la sidebar devient une barre horizontale (voir
`AppLayout.scss`) — pertinent tôt puisque l'app est prévue pour être embarquée
par Capacitor plus tard (voir le README racine).

La sidebar a grandi avec l'app :

- **Icônes** (`layouts/nav-icons.tsx`) — ré-export nommé d'icônes
  `lucide-react` (remplace un ancien jeu de SVG dessinés à la main) :
  `PlanningIcon`, `RecipesIcon`, `ShoppingListIcon`, `SettingsIcon`,
  `AccountIcon`, `DietPreferencesIcon`, `HouseholdIcon`,
  `UserPreferencesIcon`, `CreditsIcon`, `FavoriteIcon`, `PublicIcon`,
  `SourcesIcon`, `SourceLinkIcon`, `ChevronLeftIcon`. Toujours accompagnée d'un
  libellé/tooltip, donc `aria-hidden="true"` est posé par chaque appelant.
- **Structure** : bloc marque ("batchCooking" complet, réduit à "bC" en mode
  replié), nav principale (Planning `/`, Recettes `/recettes`, Liste de
  courses `/liste-de-courses`), un `SettingsMenu` repliable, un `AccountMenu`,
  un pied de page avec le numéro de version.
- **Rail repliable** — `isCollapsed` persisté dans `localStorage`
  (`batchcooking:sidebarCollapsed`) : un simple toggle de classe
  (`.app-sidebar.collapsed`) masque les `.label` en CSS ; chaque item garde son
  icône et gagne un `title` en tooltip.
- **`SettingsMenu`** — révèle Compte (`/parametres/compte`), Préférences
  (`/parametres/preferences`), Foyer (`/parametres/foyer`), Préférences
  utilisateur (`/parametres/preferences-utilisateur`), Crédits
  (`/parametres/credits`). Ouvert par défaut si la route courante est déjà
  sous `/parametres`, replié sinon — indépendant du repli de toute la sidebar.
- **`AccountMenu`** — remplace l'ancien simple "bonjour + déconnexion" : un
  menu déroulant depuis l'avatar/nom en pied de sidebar, avec un raccourci vers
  `/parametres/compte` et la déconnexion ; se referme après l'une ou l'autre
  action.
- La correspondance route↔nav utilise les clés i18n `layout.nav.<clé>` /
  `layout.settings.nav.<clé>` — ajouter une entrée de nav est un item de
  tableau + une clé de locale, rien d'autre.

### Sections sans backend

Plus aucune section de la sidebar ne rend un placeholder générique — `Liste
de courses` (`ShoppingListPage`) a désormais un vrai backend (voir la section
dédiée plus bas), et `Recettes` en a un depuis plus longtemps (catalogue,
import depuis des sources externes, favoris — voir plus bas). Le composant
`ComingSoonPage` (`components/ui/`) qui servait de stub pour ces deux pages a
été retiré une fois son dernier appelant (`ShoppingListPage`) migré vers un
vrai rendu.

---

## Parcours profil — onboarding et pages de paramètres

Deux surfaces partagent les mêmes composants de champ (`features/profile/` +
`features/house/`) : le wizard d'inscription (une fois) et les pages
`/parametres/*` (à tout moment).

```mermaid
flowchart LR
  SIGNUP["SignupPage<br/>(POST /auth/signup)"] --> OB1["/onboarding/regime"]
  OB1 --> OB2["/onboarding/foyer"]
  OB2 -->|"foyer créé/rejoint"| OB3["/onboarding/sources"]
  OB2 -->|"pas de foyer"| OB4["/onboarding/allergenes"]
  OB3 --> OB4
  OB4 --> HOME["/ (PlanningPage)"]

  SETTINGSMENU["Sidebar : Paramètres"] --> S1["/parametres/compte"]
  SETTINGSMENU --> S2["/parametres/preferences"]
  SETTINGSMENU --> S3["/parametres/foyer"]
  SETTINGSMENU --> S4["/parametres/preferences-utilisateur"]
  SETTINGSMENU --> S5["/parametres/credits"]
```

- **`pages/onboarding/`** — wizard de **4 écrans** (régime → foyer → sources →
  allergènes), lancé une seule fois juste après l'inscription. Routes
  top-level `RequireAuth`, **pas** nichées sous `AppLayout` : wizard plein
  écran sans sidebar (`onboarding.scss`, même langage visuel que
  `/login`/`/signup`). Chaque étape a un unique bouton "Continuer" qui envoie
  la valeur courante — pas de bouton "Passer" séparé, une valeur
  "aucune"/vide *est* le skip. `/onboarding/sources` est **conditionnelle** :
  seulement atteinte si l'étape `foyer` vient de créer/rejoindre un foyer
  (`OnboardingHouseholdPage`'s `goToNextStep`) — sautée sinon, directement vers
  `/onboarding/allergenes`. `OnboardingSourcesPage` elle-même redirige en
  silence vers l'étape suivante si `GET /reference/sources` revient vide ou en
  erreur, plutôt que d'afficher une étape sans rien à choisir.
- **Pages `/parametres/*`** (`pages/settings/`, dans `AppLayout`) — l'ancienne
  `HouseholdPage` combinée a été **éclatée en 5 pages** dédiées (voir la
  section suivante pour le détail de chacune), toutes en **hot saving** (pas
  de bouton "Enregistrer" — chaque section sauvegarde peu après la dernière
  modification, déclenché depuis le handler `onChange` de chaque champ, jamais
  un `useEffect` générique sur la valeur : un tel effect se déclencherait
  aussi au chargement initial quand le `GET` peuple le même state, sans
  distinction propre entre "vient d'être chargé" et "vient d'être modifié").
- **`features/profile/`** — `HouseNameField`, `DietSelect`, `AllergySelect`,
  et **`DislikedIngredientsField`** (nouveau) : champs contrôlés, "dumb"
  (reçoivent leurs données en props plutôt que de les fetcher).
  `AllergySelect` est un `<fieldset>`/`<legend>` + grille de cases à cocher,
  pas un `<select multiple>` — bien plus repérable/tapable, notamment sur
  mobile. Prend un `legend` en prop : chaque page consommatrice le rend
  **deux fois** (allergies / intolérances, filtrées côté client via
  `AllergyView.kind`), mais la sélection reste une seule liste d'IDs partagée
  entre les deux groupes. `DislikedIngredientsField` — recherche + ajout +
  puces retirables pour la liste personnelle d'ingrédients "pas aimés" ;
  réutilise `IngredientPicker` (voir plus bas) tel quel. **Explicitement
  distinct d'`AllergySelect`** : une préférence de **goût**, jamais une
  contrainte médicale — ne déclenche jamais un avertissement de sécurité,
  juste un badge 🚫 discret sur la fiche recette (`RecipeDetailPanel`).
  Sauvegardé via `GET`/`PATCH /profile/disliked-ingredients` (remplacement
  complet, pas de fusion).

### Deux bugs de state trouvés en testant dans le navigateur

1. **Course entre `navigate()` et `RedirectIfAuthenticated`** — voir la note sur
   `RedirectIfAuthenticated` plus haut. `SignupPage` doit maintenant rediriger vers
   `/onboarding/regime`, pas `/`, ce qui a rendu visible une course de state
   auparavant invisible.
2. **`user.dietId` périmé sur les pages de paramètres** — l'ancienne page
   combinée initialisait le régime affiché depuis `useAuth().user.dietId`, un
   instantané d'`AuthContext` jamais rafraîchi après une modification faite
   directement via `apiClient` (qui ne touche pas le contexte). Une navigation
   SPA aller-retour sans rechargement complet ré-affichait donc l'ancienne
   valeur après une sauvegarde. Fix, toujours en place dans `PreferencesPage` :
   la page fetch son propre profil frais (`apiClient.me()`) au montage plutôt
   que de dépendre du contexte, et `AuthContext.refreshUser()` (re-fetch
   `GET /auth/me`) est appelée après une sauvegarde réussie du régime — pour
   que le reste de l'app (pas seulement cette page) reste cohérent.

---

## Thème (clair/sombre/système)

`features/theme/ThemeContext.tsx` — ce que la note "future switch de thème"
plus bas (SCSS et theming) anticipait est maintenant réel.

- `ThemePreference` = `"LIGHT" | "DARK" | "SYSTEM"` (`packages/shared`'s
  `THEME_PREFERENCES`). `ThemeProvider` doit être imbriqué **dans**
  `AuthProvider` (il lit `useAuth().user`).
- L'effet qui charge la préférence est indexé sur **`user?.id`**, pas sur
  `user` lui-même — `AuthContext`'s `user` change de référence à chaque
  `refreshUser()`, ce qui ne doit pas redéclencher un fetch des préférences
  (documenté par un commentaire `biome-ignore
  lint/correctness/useExhaustiveDependencies`).
- Sans utilisateur : retombe sur `"SYSTEM"`. Avec un utilisateur :
  `apiClient.getPreferences()` (`GET /preferences`), échec avalé
  silencieusement — même posture "non fatale" que `AuthContext`'s propre
  `me()`.
- **`applyTheme(theme)`** : `SYSTEM` **supprime** l'attribut
  `document.documentElement.dataset.theme` plutôt que d'y écrire la chaîne
  littérale `"system"` — sans attribut `data-theme`, la media query
  `prefers-color-scheme` de `_theme.scss` reprend la main naturellement.
  `LIGHT`/`DARK` posent `data-theme="light"`/`"dark"`.
- **`setTheme(newTheme)`** : appelle `apiClient.updatePreferences(theme)`
  (`PATCH /preferences`) **avant** de mettre à jour l'état local — la
  persistance est **côté serveur** (via l'API/la base), contrairement au repli
  de la sidebar qui, lui, ne vit que dans `localStorage`.
- Consommé par `UserPreferencesPage` (`/parametres/preferences-utilisateur`)
  via `useTheme()` — un simple groupe de boutons radio (`RadioOption` ×
  `THEME_PREFERENCES`).

---

## Pages de paramètres (`/parametres/*`)

L'ancienne `HouseholdPage` combinée est éclatée en 5 pages dédiées
(`pages/settings/*.tsx`, styles partagés `settings-pages.scss`) :

- **`AccountSettingsPage`** (`/compte`) — identité en **lecture seule**
  (prénom/nom/email, éditer n'est pas encore une fonctionnalité demandée) plus
  une "zone dangereuse" : suppression du compte après re-saisie du mot de
  passe (`useAuth().deleteAccount(password)` → `DELETE /auth/me`).
- **`PreferencesPage`** (`/preferences`, "Préférences alimentaires") — le
  volet **personnel** : régime (`DietSelect`, sauvegarde immédiate +
  `refreshUser()`), allergies/intolérances (deux `AllergySelect`, debounce
  500ms), et `DislikedIngredientsField` (debounce 500ms) — le pendant
  "toujours modifiable" des étapes régime/allergènes du wizard, mêmes
  composants de champ.
- **`HouseholdSettingsPage`** (`/foyer`) — le volet **foyer**. Deux layouts
  selon `useAuth().user.houseId` :
  - **Sans foyer** : créer (`POST /house`) ou rejoindre par code d'invitation
    (`POST /house/join`).
  - **Avec foyer** : nom renommable (debounce 600ms, `PATCH /house/current`),
    code d'invitation affiché + copie presse-papier, liste des membres avec
    badge admin, bouton de retrait réservé à l'admin (`DELETE
    /house/members/:id`), une section **Sources** (`SourceSelect` — n'importe
    quel membre, pas seulement l'admin, peut activer/désactiver quelles
    sources externes le foyer voit, debounce 500ms, `PATCH
    /house/current/sources`), et soit la suppression du foyer (admin,
    confirmation en deux temps, `DELETE /house/current`) soit le départ
    (non-admin, sans confirmation puisque ça n'affecte que le partant, `POST
    /house/leave`). Recharge `GET /house/current` après chaque mutation
    plutôt qu'un patch optimiste — délibéré, ce sont des actions peu
    fréquentes/réfléchies.
- **`UserPreferencesPage`** (`/preferences-utilisateur`) — juste le thème
  (voir la section précédente). Nom volontairement distinct de `/preferences`
  malgré la collision terminologique : "comment l'app se présente" vs "les
  contraintes alimentaires du foyer".
- **`CreditsPage`** (`/credits`) — page d'attribution pure, existe pour
  satisfaire la licence CC BY 4.0 du jeu d'icônes d'ingrédients
  (foodiconpack.com, voir plus bas).

---

## Planning (`/`)

`pages/planning/PlanningPage.tsx` (+ `planning-page.scss`) — remplace l'ancienne
`HomePage`. Grille complète de la semaine, pas un simple tableau du jour :
7 colonnes (jours) × 5 lignes (`petit-dejeuner`, `collation`, `dejeuner`,
`gouter`, `diner` — `WEEK_DAYS`/`MEALS` de `packages/shared`), avec un
regroupement visuel "moments de la journée" (matin/midi/après-midi/soir) via
une bordure appuyée après `collation`/`dejeuner`/`gouter`.

- **Navigation de semaine** — `WeekNavigator` (`features/planning/WeekNavigator.tsx` +
  `week-navigator.scss` — extrait de `PlanningPage` une fois `ShoppingListPage`
  devenue une deuxième consommatrice, voir plus bas) : flèches précédent/suivant
  (`addWeeks(weekStart, ±1)` de `@batch-cooking/date-tools`) + un libellé
  cliquable ouvrant un `CalendarPopover` (grille mensuelle via
  `buildCalendarMonth`, cliquer un jour saute à sa semaine, lundi-first). Un
  badge "aujourd'hui" s'affiche quand la semaine visible est la semaine
  courante. Ses propres libellés viennent de `common.weekNav.*`/
  `common.calendar.*`/`common.days.*` (pas `planning.*`) — assez génériques
  ("Semaine précédente", noms de jours) pour ne pas paraître hors-sujet
  depuis une page qui n'est pas la grille de planning.
- `GET /planning?date=YYYY-MM-DD` renvoie `PlanningView | null` — `null` est
  un état normal (rien à afficher pour cette semaine), pas un message
  d'erreur séparé : les boutons "+" de chaque case suffisent à communiquer
  l'état vide.
- **Ajouter une recette** — le "+" d'une case ouvre `RecipePickerDialog` pour
  ce créneau `(date, weekDay, meal)`, monté **conditionnellement** (seulement
  tant que la case est ouverte) pour que son état interne reparte à zéro à
  chaque ouverture, sans reset manuel.
- **Mise à jour locale** — après ajout/retrait, `planning.items` est patché
  côté client plutôt que refetché (un objet `Planning` factice `id: -1` est
  construit si aucun n'existait encore, puisque seul `.items` est jamais lu
  sur cette page). Le retrait est **optimiste** (retiré localement
  immédiatement, `DELETE /planning/items/:id`, restauré en cas d'échec).
- Chaque recette planifiée s'affiche en pastille `"{nom} · ×{portions}"` avec
  un bouton de retrait (✕).

### `RecipePickerDialog` — parcourir, prévisualiser, importer

`features/planning/RecipePickerDialog.tsx` (+ `recipe-picker-dialog.scss`) —
un seul dialogue, jusqu'à 3 étapes, conçu autour d'un principe : **on
prévisualise avant de confirmer**, rien n'est engagé par un simple clic de
ligne.

1. **Parcourir** (étape par défaut) — embarque `RecipeTabs` +
   `RecipeTable`/`RecipeDetailPanel` (onglets réguliers) ou
   `RecipeSourcesPanel` (onglet d'une source) : exactement l'UI du catalogue
   `/recettes`, avec en plus trois filtres propres au contexte "je cherche
   quoi cuisiner" (pas juste "je consulte") : un filtre multi-ingrédients
   (`IngredientPicker`), un filtre multi-régimes (`DietTagSelect`), et une
   case "convient à tout le foyer" (affichée seulement si le viewer a un
   foyer) — tous branchés sur les query params de `GET /recipes`. Cliquer une
   ligne **sélectionne/prévisualise seulement**, jamais ne valide — un pied de
   dialogue épinglé ("Confirmer", actif dès qu'une prévisualisation existe)
   est ce qui agit réellement.
2. **Confirmer les portions** (une vraie recette est prévisualisée) — petit
   formulaire "combien de portions ?" (pré-rempli depuis
   `RecipeSummaryView.portions`), puis `POST /planning/items`.
3. **Revue intégrée** (un item de source *pas encore importé* est prévisualisé
   et nécessite une intervention humaine) — rend `RecipeImportForm`
   directement à l'intérieur du même `Dialog` (`planningSlot` transmis pour
   qu'un import réussi ajoute aussi au planning en un seul submit) : évite de
   naviguer vers `ImportRecipePage` et de perdre la recherche/les filtres/le
   créneau du picker.

**L'import transparent** — décrit dans le composant comme "la seule action de
toute l'app qui importe vraiment un item de source... puisqu'un item de
source ne devient une vraie Recipe sauvegardée qu'en conséquence du fait que
quelqu'un l'ajoute à son planning" :
1. `GET /sources/:sourceKey/preview/:externalId` → `RecipeImportDraftView`.
2. `tryBuildCompleteImport(draft)` (`recipe-import-draft.ts`) tente de
   construire un `CreateRecipeInput` soumissible **sans aucun formulaire, sans
   personne impliquée** — renvoie `null` dès qu'un jugement humain est
   nécessaire (une ligne d'ingrédient non résolue, `portions` manquant).
3. Si non-null : `POST /sources/:sourceKey/import/:externalId` puis `POST
   /planning/items` — ajouté au créneau **sans écran supplémentaire**, comme
   n'importe quelle autre recette.
4. Si `tryBuildCompleteImport` renvoie `null`, ou si l'un des deux appels
   échoue : bascule sur l'étape 3 ci-dessus (revue intégrée).
5. Cas limite géré explicitement : si l'import réussit mais que l'ajout au
   planning échoue ensuite, la recette est **déjà sauvegardée** — plutôt que
   de retenter tout l'import, navigation vers `/recettes/:id` (même repli que
   `RecipeImportForm`'s propre submit, voir plus bas).

---

## Liste de courses (`/liste-de-courses`)

`pages/shopping-list/ShoppingListPage.tsx` (+ `shopping-list-page.scss`,
`shopping-list.ts`) — même `WeekNavigator` que `PlanningPage` (semaine
sélectionnable), mais un rendu bien plus simple en dessous : une liste,
volontairement pas une grille. Délibérément **en lecture seule** — pas de
case à cocher/état "acheté" à faire persister : la source de vérité de ce
qu'il faut acheter reste le planning lui-même, pas une liste de courses
séparée qu'il faudrait garder synchronisée avec lui lorsqu'une recette est
ajoutée/retirée après coup.

- `GET /shopping-list?date=YYYY-MM-DD` renvoie toujours un `ShoppingListView`
  (jamais `null`, contrairement à `GET /planning`) — chaque ingrédient de
  chaque recette planifiée cette semaine, déjà sommé côté serveur
  (`RecipeIngredient.quantity × PlanningItem.portions / Recipe.portions`,
  additionné par paire `(ingredientId, unitId)` — voir
  [backend-architecture.md](./backend-architecture.md#liste-de-courses--agrégation-des-ingrédients-planifiés)).
  Aucun état foyer/semaine vide n'est un cas d'erreur séparé : les deux
  redescendent en un `items: []` normal, affiché via `shoppingList.empty`.
- **Groupement par rayon** — `shopping-list.ts`'s `groupShoppingListItems`
  (logique pure, extraite du composant, testable sans monter i18next) trie
  les lignes par `IngredientCategory` (même catalogue "rayon de
  supermarché" que `IngredientPicker`, ordre canonique
  `INGREDIENT_CATEGORIES` de `packages/shared`), puis alphabétiquement à
  l'intérieur d'un rayon — sur le libellé **déjà traduit** (pas la `key`
  anglaise), pour un tri qui se lit correctement en français. Chaque
  en-tête de groupe réutilise `CategoryIcon`/`recipes.form.category.<clé>`
  (`ingredient-icons.tsx`), déjà utilisés par `IngredientPicker` — pas de
  nouveau jeu d'icônes/libellés pour cette page.
- **Formatage des quantités** — `shopping-list.ts`'s
  `formatShoppingListQuantity` (`Number.prototype.toLocaleString("fr-FR",
  {maximumFractionDigits: 2})`) — évite qu'une somme de plusieurs recettes
  (addition flottante côté serveur) affiche un résidu du type
  `"149.99999999999997"`.
- Aucune conversion d'unité — deux lignes du même ingrédient dans deux
  unités différentes (ex. "tomate" en grammes sur une recette, en
  kilogrammes sur une autre) restent deux lignes séparées plutôt que d'être
  fusionnées par une conversion devinée ; voir `UnitView.toBaseFactor`'s doc
  comment (`packages/shared`) — la conversion inter-unités reste posée
  comme fondation pour plus tard, pas construite.

---

## Recettes — catalogue, favoris, import depuis une source externe

`pages/recipes/RecipesPage.tsx` — routée sur `/recettes`, `/recettes/:id` **et**
`/recettes/sources/:sourceKey/:externalId` (le **même composant** pour les
trois) : une vue **maître-détail**, pas une navigation vers une page séparée —
la barre d'onglets + le tableau restent montés, seul le panneau de détail
change avec le paramètre d'URL.

### Onglets — `RecipeTabs.tsx`

Quatre onglets réels, en base — `favoris` / `perso` / `foyer` / `publique` —
pas de "toutes" : toute recette tombe sous exactement un des trois derniers
via sa propre `visibility`, `favoris` est un filtre transverse orthogonal.
**Plus un onglet par source externe activée pour le foyer** (chaque source
activée devient sa propre tab) — valeur `"source:<key>"`, icône propre à la
source (`iconUrl` si elle en a un, sinon `SourcesIcon` générique), nom non
traduit (nom propre). Concept **propre au web** : absent du type `RecipeTab`
partagé, l'API n'a pas de valeur `tab=source:...` — parcourir une source est
un endpoint entièrement différent (`GET /sources/:sourceKey/browse`).
`useEnabledSources.ts` (`Promise.all([getSources(), getHouseSourceIds()])`)
calcule la liste des sources activées, partagé par `RecipesPage` et
`RecipePickerDialog`.

### Parcourir une source — `RecipeSourcesPanel.tsx`

Contenu de l'onglet d'une source : sa propre paire maître-détail —
`SourceItemTable` (liste paginée, `GET /sources/:sourceKey/browse?query=&cursor=`,
`nextCursor`) + `RecipeDetailPanel` pour la prévisualisation. Scopé à un seul
`sourceKey` (prop fixe) — remonté avec `key={sourceKey}` en changeant de
source, même convention "monté seulement tant que pertinent" que `Dialog`.

Pagination en scroll infini, pas de bouton "voir plus" : une ligne
sentinelle invisible en fin de liste (`SourceItemTable`, un
`IntersectionObserver` scopé à son propre conteneur scrollable) déclenche le
chargement de la page suivante dès qu'elle approche du bas. `RecipeSourcesPanel`
précharge en plus la page suivante dès que la page courante s'affiche (avant
même que la sentinelle soit visible), pour qu'un défilement rapide tombe le
plus souvent sur une réponse déjà arrivée. Pendant un chargement (préchargé
ou non), des lignes squelettes qui pulsent s'ajoutent en bas de la liste
plutôt que de laisser un vide ; un échec affiche un message avec un lien
"Réessayer" sans effacer les lignes déjà chargées.

Clic sur une ligne :
- **Déjà importé** (`alreadyImported && recipeId !== null`) : `GET
  /recipes/:id`, prévisualisé en état `"loaded"`.
- **Pas encore importé** : `GET /sources/:sourceKey/preview/:externalId`,
  prévisualisé en état `"loaded-draft"`.

### Flux d'import complet (parcourir → prévisualiser → revue → sauvegarder)

- **Prévisualisation** — `RecipeDetailPanel`'s état `"loaded-draft"` : photo,
  icône de lien vers la source (`SourceLinkIcon`, ouvre l'URL d'origine dans
  un nouvel onglet), nom, portions, description, étapes (avec surlignage des
  techniques, voir plus bas) — **pas** d'actions favori/modifier/supprimer, et
  volontairement pas de bouton "importer" manuel : un item de source n'est
  sauvegardé qu'en conséquence de son ajout au planning (voir
  `RecipePickerDialog` plus haut) ou d'une soumission de revue explicite.
- **Formulaire de revue — `RecipeImportForm.tsx`** — pré-rempli depuis le
  brouillon, structurellement identique à `RecipeFormPage` (mêmes
  `IngredientRow`/`IngredientPicker`/`StepListEditor`/`DietTagSelect`, même
  forme de payload `CreateRecipeInput`), plus une section **"à compléter"**
  pour les lignes d'ingrédient non résolues automatiquement
  (`ingredient-matcher.ts`, côté API) : chaque ligne montre son texte brut, un
  bouton "Choisir un ingrédient" (ouvre `IngredientPicker`, la quantité brute
  est conservée) ou un bouton pour l'écarter. `canSubmit` exige zéro ligne non
  résolue restante — **aucune recette invalide n'est jamais silencieusement
  devinée/abandonnée** (décision produit explicite). Soumet vers `POST
  /sources/:sourceKey/import/:externalId` au lieu de `POST /recipes`.
  Prop optionnelle `planningSlot` : en cas de succès, appelle aussi `POST
  /planning/items` avec les portions du formulaire.
- **`RecipeImportForm` a été extrait d'`ImportRecipePage`** pour que
  `RecipePickerDialog` puisse l'embarquer directement comme une de ses étapes
  — `pages/recipes/ImportRecipePage.tsx` (`/recettes/importer/:sourceKey/:externalId`)
  n'en est plus que le wrapper d'une **route de secours autonome et
  partageable** (favori enregistré, page rechargée en plein milieu du flux),
  plus le chemin principal. Elle décode toujours défensivement
  `?planningDate=&planningWeekDay=&planningMeal=` (validés contre
  `WEEK_DAYS`/`MEALS`) pour ce chemin historique.

### Surlignage des techniques et infobulle

- `highlight-tech-steps.ts`'s `splitDescriptionByTechSteps(description,
  techSteps)` découpe une description d'étape en segments texte/technique à
  partir des offsets `start`/`end` de chaque `StepTechStepView` (calculés
  côté API par `matchTechStepSpans`, voir
  [backend-architecture.md](./backend-architecture.md#détection-des-techniques--tech-step-matcherts)).
  Trie défensivement par `start` et élimine silencieusement tout span aux
  bornes invalides (négatif, hors texte, chevauchant un span déjà accepté) —
  dégrade en "ne pas surligner celui-ci" plutôt que de planter/déformer
  l'affichage.
- `StepDescription.tsx` rend les segments : texte brut tel quel, technique
  entourée d'un vrai `<button type="button">` (pas un `<mark>` — nativement
  focusable au clavier/lecteur d'écran) dans un `Tooltip`
  (`components/ui/Tooltip.tsx`) dont le contenu est
  `t(\`catalog.techSteps.${techStep.key}\`)`.

### Icônes d'ingrédients et badge "reproductible"

- `ingredient-icons.tsx` — ~20 pictogrammes génériques (remplace un ancien
  schéma à un emoji par ingrédient, 437 cas, jugé peu professionnel/incohérent
  en revue produit), groupés par **type de chose** (légume, bouteille,
  fromage…) plutôt que par ingrédient précis. La plupart viennent du pack CC
  BY 4.0 de foodiconpack.com (voir `CreditsPage`) via un wrapper `FilledIcon`
  (glyphes pleins) ; trois (`BreadIcon`, `DoughIcon`, `SproutIcon`) sans bon
  équivalent dans ce pack restent dessinés à la main (wrapper `Icon`,
  traits) — dimensionnés en CSS pour que le mélange se lise comme un seul jeu
  cohérent. `CATEGORY_ICON`/`SUBCATEGORY_ICON` donnent une icône
  *représentative* par catégorie/sous-catégorie pour les lignes de
  `IngredientPicker` (un choix éditorial, pas une donnée dérivée).
- `ReproducibleBadge.tsx` — rien si `!reproducible`
  (`IngredientView.reproducible`, "raisonnablement faisable maison"). Pastille
  simple dans la grille du picker, ou (avec `searchLabel`, dans
  `IngredientRow`) un lien `<a>` classique (pas un `<Link>` router,
  volontairement, pour ne jamais faire quitter un formulaire de recette en
  cours) ouvrant `/recettes?search=<nom>` dans un nouvel onglet.

### Badges régime/allergènes et autres pièces

- `AllergenBadges.tsx` / `DietBadges.tsx` — listes de pastilles simples,
  rendent `null` sur un tableau vide. `DietBadges` utilise le token
  `--color-tag` (jamais `--color-allergen`) pour rester visuellement distinct
  d'un avertissement de sécurité.
- `DietTagSelect.tsx` — fieldset multi-sélection (via `CheckboxOption`) pour
  taguer manuellement le(s) régime(s) associé(s) d'une recette — utilisé dans
  `RecipeFormPage`, `RecipeImportForm`, et les filtres de `RecipePickerDialog`.
- `RecipeTable.tsx` — tableau du catalogue (photo/nom+marque favori/allergènes/
  régimes), clic sur une ligne = sélection (pas de navigation, le détail
  s'affiche à côté dans `RecipeDetailPanel`).
- `RecipeDetailPanel.tsx` — union discriminée `"empty" | "loading" | "loaded" |
  "loaded-draft" | "not-found" | "error"`. Croise les ingrédients de la recette
  avec `dislikedIngredientIds` (préférence de goût du viewer) pour n'afficher
  un badge 🚫 que sur les ingrédients concernés. Prop `showActions` (défaut
  `true`) masque l'étoile favori + les boutons Modifier/Supprimer dans les
  contextes de prévisualisation seule (`RecipePickerDialog`,
  `RecipeSourcesPanel`).
- `FavoriteStarButton.tsx` — bascule optimiste (`POST`/`DELETE
  /recipes/:id/favorite`, restaurée en cas d'échec).
- `IngredientPicker.tsx` — parcours à deux niveaux catégorie→sous-catégorie
  (rayons de supermarché) + recherche + grille de cartes, remplace un ancien
  dropdown autocomplete plat (400+ ingrédients, la recherche seule ne suffit
  pas). Un menu "options d'affichage" bascule les badges
  allergène/régime/reproductible par carte (préférence UI locale, pas
  persistée). Réutilisé par `RecipeFormPage`, `RecipeImportForm`, le filtre
  ingrédients de `RecipePickerDialog`, et `DislikedIngredientsField`. Prop
  optionnel `onAddPlaceholder` : quand la recherche ne renvoie rien, un
  bouton "Ajouter « … » comme ingrédient à compléter" ajoute une ligne
  **hors-catalogue** (texte libre, voir `placeholder-ingredient.ts` +
  `backend-architecture.md`) plutôt que de bloquer l'utilisateur — présent
  seulement pour `RecipeFormPage`/`RecipeImportForm`.
- `IngredientRow.tsx` / `StepListEditor.tsx` — ligne d'ingrédient sélectionnée
  (icône, nom, quantité, unité, badges, retrait) et éditeur d'étapes ordonné
  (boutons monter/descendre, pas de drag-and-drop) du formulaire recette. Une
  ligne placeholder affiche un badge "à compléter" et aucun badge
  allergène/régime.
- `ingredient-label.ts` — `ingredientLabel(ingredient, t)` =
  `displayName ?? t(\`catalog.ingredients.${key}\`)`. **Tout** rendu du libellé
  d'un ingrédient passe par là (picker, ligne, liste de courses, filtre du
  picker de planning, fiche recette, popover de correction) pour qu'un
  placeholder (clé `placeholder:<uuid>`, sans libellé i18n) affiche son
  `displayName` et pas la clé brute. `placeholder-ingredient.ts` fabrique la
  `IngredientView` synthétique d'une ligne placeholder pas encore
  enregistrée (`id` 0, soumise en `placeholderName`).
- `SourceItemTable.tsx` — tableau de parcours d'une source (photo+nom, badge
  "déjà importé" au lieu des colonnes allergènes/régime — un item de source
  n'est résolu contre les catalogues qu'à la prévisualisation).

---

## Foyer — sources externes activées

- **`features/house/SourceSelect.tsx`** — grille de cases à cocher pour quelles
  sources un foyer voit, partagée telle quelle par `OnboardingSourcesPage` et
  `HouseholdSettingsPage`'s section Sources. Chaque ligne : logo optionnel
  (`iconUrl`), nom propre (non traduit), badge officiel/non-officiel
  (`household.sources.official`/`unofficial`) pour juger la fiabilité d'une
  source scrapée vs une API officielle. Sélection vide = état de départ
  normal (aucune ligne `HouseSource` = masqué).

---

## Composants UI partagés (`components/ui/`)

- **`Dialog.tsx`** (+ `dialog.scss`) — primitive de modale bâtie sur l'élément
  **`<dialog>` natif** (`showModal()`), pas une div `role="dialog"` — piège de
  focus, fermeture sur Échap et arrière-plan obtenus gratuitement. Première
  modale de l'app (chaque confirmation avant, ex. les zones dangereuses des
  pages de paramètres, était une révélation en deux temps inline). Montée
  seulement pendant qu'elle est ouverte. Le clic sur l'arrière-plan pour
  fermer compare les coordonnées du clic au rectangle du panneau
  (`getBoundingClientRect()` — un clic sur l'élément `<dialog>` lui-même se
  produit à la fois pour un vrai clic d'arrière-plan *et* pour son propre
  padding non rempli, seule la comparaison de coordonnées distingue les deux),
  attaché impérativement plutôt que via `onClick` JSX (Échap couvre déjà le
  cas clavier). Prop `footer` optionnelle pour un bandeau d'action épinglé
  sous le corps défilant — utilisé par `RecipePickerDialog`.
- **`Checkbox.tsx`** (`CheckboxOption`) — factorise le balisage "carte
  sélectionnable" (input natif caché + coche + texte) auparavant dupliqué
  entre `AllergySelect`, `DietTagSelect`, le menu d'affichage
  d'`IngredientPicker`. La classe `is-selected` est posée en JS depuis le
  booléen `checked` (un chaînage CSS `:has(:checked)` s'est révélé peu fiable
  entre navigateurs).
- **`Radio.tsx`** (`RadioOption<T extends string>`) — pendant `type="radio"`
  de `CheckboxOption`, même balisage/apparence, sémantique radio native pour
  l'exclusion mutuelle via un `name` partagé — utilisé par le sélecteur de
  thème d'`UserPreferencesPage`.
- **`Tooltip.tsx`** (+ `tooltip.scss`) — infobulle **CSS-only** (pas de
  librairie de positionnement) : wrapper `position: relative`, affichée via
  `:hover`/`:focus-within` (aucun état JS). `children` doit être un seul
  élément focusable ; cloné pour y attacher `aria-describedby` (lecteurs
  d'écran). Utilisé par `StepDescription.tsx` pour l'infobulle des techniques.

---

## Client API et gestion des erreurs

Voir [error-handling.md](./error-handling.md) pour le détail du contrat d'erreurs
partagé avec l'API. En résumé côté frontend :

- `ApiClient` (`api/client.ts`) — classe avec instance unique exportée
  (`apiClient`), enveloppe `fetch` avec `credentials: "include"` (requis pour que
  le cookie de session httpOnly parte/revienne, l'API et le web étant sur des
  origines différentes). Lève `ApiError` (porteuse du `code` d'erreur) pour toute
  réponse non-2xx.
- `ErrorMessageService` (`services/error-message.service.ts`) — convertit un `code`
  d'erreur numérique en clé de traduction, résolue via i18next.

---

## i18n (internationalisation)

**i18next** + **react-i18next** — pas de solution maison : tout le texte affiché
(libellés de formulaire, boutons, messages d'erreur) vient de fichiers de locale
JSON, jamais codé en dur dans un composant.

- `i18n/i18n.ts` — initialise l'instance i18next (langue par défaut `fr`), importé
  une seule fois pour son effet de bord dans `main.tsx`, avant le premier rendu.
- `locales/fr/translation.json` — toutes les chaînes françaises, organisées par
  namespace de premier niveau : `common` (libellés génériques réutilisés
  partout — dont `common.weekNav.*`/`common.calendar.*`/`common.days.*`,
  partagés par `WeekNavigator` entre `PlanningPage` et `ShoppingListPage`),
  `errors` (voir [error-handling.md](./error-handling.md)), `auth`
  (`auth.login.*`/`auth.signup.*`), `layout` (nav de la sidebar dont
  `layout.settings.nav.*` pour le sous-menu Paramètres — `AppLayout`),
  `planning` (grille de la semaine, `RecipePickerDialog`), `recipes`
  (catalogue, tabs, import), `shoppingList` (page `/liste-de-courses`, voir
  plus haut), `onboarding` (wizard d'inscription), `household` (titre +
  `form.*`, champs partagés par le wizard et `/parametres/foyer`, plus
  `household.sources.*` pour le badge officiel/non-officiel), `account` /
  `preferences` / `userPreferences` / `credits` (les quatre autres pages de
  paramètres), `catalog` (libellés des tables de référence —
  `catalog.diets.<key>`, `catalog.allergens.<key>`, `catalog.ingredients.<key>`,
  `catalog.units.<key>`, `catalog.techSteps.<key>` — un slug `key` de
  `schema.prisma` par entrée, jamais le libellé lui-même stocké en base, voir
  [batch-cooking-modele.md](./batch-cooking-modele.md)).
- Dans un composant : `const { t } = useTranslation(); t("auth.login.title")`.
- Ajouter une langue : créer `locales/<lng>/translation.json` avec les mêmes clés,
  ajouter `resources.<lng>` dans `i18n/i18n.ts` — aucun composant à toucher.

---

## Note sur les fichiers `.d.ts`

Aucun fichier `.d.ts` écrit à la main dans `apps/web` : le
`/// <reference types="vite/client" />` généré par défaut par Vite (habituellement
`vite-env.d.ts`) est remplacé par `"types": ["vite/client"]` dans
`tsconfig.app.json` — même effet (typage de `import.meta.env`, imports d'assets),
sans fichier dédié.

Côté `apps/api`, aucune augmentation de type globale n'est utilisée du tout — voir
[backend-architecture.md](./backend-architecture.md#auth--reslocals-pas-daugmentation-du-namespace-express)
: le profil authentifié passe par `res.locals` (mécanisme natif d'Express), pas
par un `declare global` sur `Express.Request`.

---

## SCSS et theming

- **`sass`** (Dart Sass) est utilisé via le support natif de Vite — aucune config
  supplémentaire needed au-delà d'avoir le package installé (`vite.config.ts` fixe
  juste l'API moderne de Sass pour éviter un warning de dépréciation).
- **`styles/_theme.scss`** — tokens de design exposés en **custom properties CSS**
  sur `:root` (`--color-primary`, `--space-md`, etc.), pas en simples variables
  SCSS : ça les rend disponibles au runtime, pas seulement à la compilation —
  ce qui a permis le switch de thème clair/sombre/système (voir
  [Thème](#thème-clairsombresystème) plus haut) en redéfinissant juste ces
  variables sous `[data-theme="dark"]` (et sous `prefers-color-scheme: dark`
  quand aucun `data-theme` n'est posé, cas `SYSTEM`), sans reconstruire les
  feuilles de style. Toute nouvelle règle CSS doit référencer `var(--token)`,
  jamais une couleur/valeur en dur.
- **`styles/global.scss`** — importé une seule fois, dans `main.tsx`. Contient
  uniquement le reset minimal et l'import du thème (`@use "./theme"`). Rien de
  spécifique à une page/un composant n'y va.
- Les tokens étant des **custom properties CSS** (pas des variables Sass), ils sont
  disponibles globalement au runtime dès que `global.scss` a été chargé une fois —
  un fichier `.scss` de composant/page les consomme directement via `var(--token)`,
  sans avoir besoin de `@use` le partiel theme (ce serait un import sans effet,
  puisqu'aucun symbole Sass n'en est consommé). Chaque fichier documente en
  commentaire à quoi correspond chaque règle un peu non-triviale.

---

## Tests (Cypress + Cucumber)

`apps/web/cypress.config.ts` déclare deux "testing types" indépendants :

- **`e2e`** — `specPattern` couvre à la fois les specs Cypress classiques
  (`cypress/e2e/**/*.cy.ts`) **et** des fichiers Gherkin
  (`cypress/e2e/**/*.feature`), via `@badeball/cypress-cucumber-preprocessor`
  (+ un bundler esbuild).
- **`component`** (nouveau) — `cypress/component/**/*.cy.tsx`, monte un seul
  composant UI générique à la fois (`components/ui/*`), sans routeur ni
  backend — lancé via le script `cy:run:component` (nouveau, à côté de
  `cy:open`/`cy:run`/`e2e`). Premier test de ce type dans le repo : mounter
  `CheckboxOption` isolément, sans jamais visiter une page routée complète.

### Motif Gherkin

Pour chaque `.feature` (ex. `planning.feature`), un fichier de steps `.ts` du
même nom dans le même dossier (`planning.ts`) porte les fixtures/steps
**propres à cette feature** (mocks `cy.intercept`, interactions DOM
spécifiques à ce parcours) — délibérément **pas** partagés entre features, le
lookup de steps du préprocesseur Cucumber n'étant pas global à tout
`cypress/e2e/`. Features présentes : `account`, `auth`, `household-settings`,
`onboarding`, `planning`, `preferences`, `recipe-form`, `recipes`,
`recipe-sources`, `user-preferences`.

Les steps réellement partagés (par **toutes** les features) vivent dans
`cypress/support/step_definitions/` : `common.steps.ts` (connexion, navigation
générique — ex. `"I am signed in as {string} {string}"`), plus
`household-mutations.steps.ts`, `profile-mutations.steps.ts`,
`reference-data.steps.ts`. `common.steps.ts` évite délibérément un hook
Cucumber `Before()` : l'enregistrer ferait lire par le runtime navigateur du
préprocesseur un membre d'enum (`messages.HookType.BEFORE_TEST_CASE`) absent
de la version CommonJS de `@cucumber/messages` sur laquelle ce repo est pinné
(`pnpm.overrides`) — chaque scénario planterait avec "Cannot read properties
of undefined". La réinitialisation du profil se fait donc en ligne, dans le
step "I am signed in as..." par lequel commence de toute façon chaque chaîne
de scénario.

### Migration en cours — `.cy.ts` et `.feature` coexistent

Les fichiers `.cy.ts` classiques restants (`account`, `household-settings`,
`layout`, `planning-page`, `preferences`, `recipes`, `sidebar`, `smoke`,
`user-preferences`) ne sont **pas** un découpage définitif voulu — c'est une
migration en cours vers Cucumber. Certains parcours (bascule favori,
suppression de recette) ont déjà été migrés vers un couple `.feature`+`.ts`
dédié, laissant dans le `.cy.ts` d'origine ce qui n'est pas encore migré
(parcours de navigation/affichage plus larges, contrôles structurels de layout,
le check global "redirection si non authentifié" de `smoke.cy.ts`). Les deux
styles tournent dans la même commande `cy:run` puisqu'ils partagent le même
`specPattern`.

Toujours vrai par ailleurs (hérité de l'état précédent) : les tests mockent
l'API via `cy.intercept` plutôt que de dépendre d'un vrai backend — le job e2e
de la CI ne provisionne pas de Postgres/API, seulement le serveur de dev Vite.
Le comportement réel de l'API est couvert par la suite Mocha d'`apps/api` (voir
[backend-architecture.md](./backend-architecture.md), contre une vraie base).

> **Cypress ne peut pas tourner en local dans un environnement Windows
> sandboxé** : Chromium/Electron headless plante au lancement du process GPU
> (`GPU process isn't usable`) — pas un problème introduit par une
> modification du code. `pnpm --filter web e2e` fonctionne normalement en CI
> et sur une machine de dev classique ; dans cet environnement précis, vérifier
> manuellement via le serveur de dev (`pnpm dev:web` + `pnpm dev:api`, pas le
> conteneur Docker qui sert le frontend buildé, pas le serveur Vite).
