# Frontend — Risques & vigilance : Général

> Extrait de la base de connaissance Lead_tech. Voir `knowledge/frontend/risques/README.md` pour l'index complet.

---

<a id="risque-accessibilite-oubliee"></a>
## Accessibilité oubliée (a11y)

### Risques

- App inutilisable au clavier/lecteur d'écran
- Régressions silencieuses sur focus/labels

### Symptômes

- Modales impossibles à fermer au clavier
- Inputs sans labels/erreurs non annoncées
- Focus "perdu"

### Bonnes pratiques / mitigations

- Checklist a11y minimale sur chaque écran clé
- Gestion de focus (modales, erreurs formulaire)
- Labels/aria cohérents + tests simples

---

<a id="risque-regex-globale-singleton-lastindex"></a>
## Regex globale `/g` en singleton — bug `lastIndex` stateful

### Risques

- Une regex avec flag `/g` ou `/y` définie comme constante au niveau module maintient un état `lastIndex` entre les appels
- `String.prototype.replace()` réinitialise `lastIndex`, mais `.test()` ou `.exec()` ne le font pas → bug stateful difficile à détecter, souvent introduit par un refactor ultérieur

### Symptômes

- `.test(str)` retourne alternativement `true` / `false` sur la même chaîne selon l'ordre d'appel
- Bug non reproductible en isolation, uniquement en séquence d'appels

### Bonnes pratiques / mitigations

```typescript
// ❌ RISQUÉ — regex globale partagée entre tous les appels
const LINK_PATTERN = /https?:\/\/\S+/gi;
function processLinks(content: string) {
  return content.replace(LINK_PATTERN, ...); // OK today
  // Mais si quelqu'un ajoute LINK_PATTERN.test(x) ailleurs → bug lastIndex
}

// ✅ SÛR — nouvelle instance à chaque appel, aucun état partagé
function makeLinkPattern(): RegExp {
  return /https?:\/\/\S+/gi;
}
function processLinks(content: string) {
  return content.replace(makeLinkPattern(), ...);
}
```

- **Règle** : les regex avec flag `/g` ou `/y` utilisées pour transformation de strings → toujours créer via une factory, jamais en singleton de module

- Contexte technique : TypeScript / React Native — app-alexandrie 24-03-2026

---

<a id="risque-alert-prompt-ios-only"></a>
## `Alert.prompt` iOS-only — fonctionnalité silencieusement cassée sur Android

### Risques

- `Alert.prompt` ne déclenche rien sur Android (retourne `undefined` silencieusement).
- Les tests unitaires passent (mock), mais le flux ne s'exécute jamais sur 50 % des devices en production.

### Symptômes

- Flux de saisie utilisateur qui fonctionne sur simulateur iOS mais est inactif sur Android
- Aucun message d'erreur côté dev ni côté utilisateur

### Bonnes pratiques / mitigations

1. Ne jamais utiliser `Alert.prompt` dans un projet Expo cross-platform.
2. Remplacer par une modale custom : `Modal` + `TextInput` React Native — portable, accessible, testable.
3. Wrapper le `TextInput` dans `KeyboardAvoidingView` avec `behavior={Platform.OS === 'ios' ? 'padding' : 'height'}`.

- Contexte technique : React Native / Expo cross-platform — app-alexandrie 31-03-2026

---

<a id="risque-primitive-ui-couplee-contexte-parent"></a>
## Primitive UI couplée au contexte parent (layout ou namespace métier)

### Risques

- Une primitive générique (`PageShell`, `ContentCard`, `SectionWrapper`) qui embarque des classes de surface, de largeur ou de namespace métier devient non réutilisable hors de son premier contexte
- Le couplage reste silencieux au lint et au typecheck, puis force l'ajout progressif de props `variant`, `layout`, `width` ou de classes externes contradictoires

### Symptômes

- La primitive applique directement des classes comme `.card`, `.card--dashboard`, `.dashboard__item`, `.profile__card`
- Le parent doit contourner le style natif de la primitive pour l'utiliser dans un autre écran
- Les classes `namespace__element` fuitent dans des composants supposés agnostiques du domaine

### Bonnes pratiques / mitigations

- Une primitive pose le squelette sémantique ; le parent pose la surface visuelle (card, width, background, espacement de contexte)
- Ne pas injecter de classes de namespace métier sur une primitive générique via `class`
- Si une variation réutilisable existe vraiment, l'exprimer via une API explicite et bornée (`tone`, `variant`) plutôt que par des classes métier ad hoc
- Contexte technique : Vue 3 / CSS modulaire — RL799_V2, 02-04-2026

---

<a id="risque-migration-partielle-composant-classes-legacy"></a>
## Migration partielle vers un composant standard — classes legacy conservées

### Risques

- La coexistence de classes legacy (`.primary`, `.ghost`, `.danger`) et de classes du nouveau composant (`.app-btn--primary`, `.app-btn--ghost`) crée une ambiguïté durable de convention
- Les nouveaux développements continuent d'utiliser l'ancien système faute de règle claire, ce qui ralentit la standardisation

### Symptômes

- Deux façons de produire la même affordance coexistent dans le même repo
- Un composant dédié existe, mais des liens ou boutons continuent d'utiliser les anciennes classes globales

### Bonnes pratiques / mitigations

- Lorsqu'un composant standardise une affordance, supprimer en même temps les classes CSS globales équivalentes
- Si un reliquat legacy doit rester temporairement, documenter explicitement son périmètre et sa date de sortie attendue
- En review, traiter toute nouvelle utilisation d'une classe legacy équivalente comme une régression de standardisation
- Contexte technique : Vue 3 / design system léger — RL799_V2, 02-04-2026

---

<a id="risque-aria-roles-sans-clavier"></a>
## ARIA roles sans comportement clavier associé

### Risques

- Poser `role="menu"` / `role="menuitem"` sur un composant sans implémenter le pattern clavier donne une fausse impression d'accessibilité
- Les rôles ARIA trompent les lecteurs d'écran et violent WCAG 2.1 (4.1.2 Name, Role, Value)

### Symptômes

- `role="menu"` sans fermeture via `Escape`
- Pas de navigation `ArrowUp` / `ArrowDown` ni de roving tabindex

### Bonnes pratiques / mitigations

Poser `role="menu"` / `role="menuitem"` implique obligatoirement :
- Fermeture via `Escape`
- Navigation via `ArrowUp` / `ArrowDown`
- Roving tabindex (`tabindex="0"` sur l'item actif, `-1` sur les autres)
- Focus automatique du premier item à l'ouverture

**Règle** : ne jamais poser un `role` ARIA de widget interactif sans implémenter le pattern clavier correspondant (cf. WAI-ARIA Authoring Practices)

- Contexte technique : Vue 3 / accessibilité — RL799_V2 03-04-2026

---

<a id="risque-duplication-logique-metier-monorepo"></a>
## Duplication de logique métier dans les composants UI (monorepo)

### Risques

- Dans un monorepo avec un package partagé (`shared`), les fonctions utilitaires métier (ex: conversion grade → rang) sont redéfinies localement dans les composants ou pages frontend
- Ce type de duplication silencieuse provoque des divergences à terme

### Symptômes

- Fonction `switch/case` ou mapping identique à une fonction déjà exportée par `shared`
- Même signature et même logique dans plusieurs fichiers de couches différentes (composant, page, service)

### Bonnes pratiques / mitigations

- Les fonctions utilitaires métier ne doivent jamais être redéfinies localement dans les composants ou pages frontend
- Importer systématiquement depuis le package partagé (`@monrepo/shared` ou équivalent) plutôt que de copier-coller la logique
- **Signal review** : grep des fonctions utilitaires existantes dans shared avant de valider un nouveau switch/case

- Contexte technique : Vue 3 / monorepo — RL799_V2 06-04-2026

---

<a id="risque-event-listeners-globaux-modales"></a>
## Event listeners globaux pour interactions modales

### Risques

- `window.addEventListener('keydown')` pour capturer Escape dans une modale crée un listener global qui peut confliter avec d'autres modales
- Le listener fuit si le composant est mal démonté

### Symptômes

- `window.addEventListener('keydown', handler)` dans un composant modale
- Cleanup dans `onBeforeUnmount` mais risque de fuite si le démontage échoue

### Bonnes pratiques / mitigations

- Utiliser `@keydown.escape` directement sur l'élément dialog avec `tabindex="-1"` + focus automatique à l'ouverture
- Élimine le besoin de cleanup et scope l'interaction au composant
- **Signal review** : dans tout composant modale, vérifier que les listeners clavier sont sur l'élément, pas sur `window`

- Contexte technique : Vue 3 / modales — RL799_V2 06-04-2026

---

<a id="risque-boutons-imbriques"></a>
## Boutons imbriqués dans les listes interactives

### Risques

- Un `<button>` ou `<a>` contenant un autre élément interactif (bouton, lien) est du HTML invalide
- Casse l'accessibilité et produit un comportement imprévisible selon les navigateurs

### Symptômes

- `<button>` conteneur avec un `<button>` enfant (ex: étoile favori dans une carte cliquable)
- Comportement de clic imprévisible, événements qui ne remontent pas correctement

### Bonnes pratiques / mitigations

- Utiliser un `<div>` conteneur avec des boutons séparés côte à côte
- Si toute la ligne doit être cliquable, séparer la zone de clic principale (bouton content) de l'action secondaire (bouton étoile/action)
- **Signal review** : dans tout composant liste avec actions inline, vérifier qu'aucun élément interactif n'est imbriqué dans un autre

- Contexte technique : HTML / accessibilité — RL799_V2 06-04-2026

---

<a id="risque-fire-and-forget-sans-feedback"></a>
## Fire-and-forget sans feedback sur actions non-critiques

### Risques

- Une action asynchrone non-critique (cache IndexedDB, analytics, sync) lancée en fire-and-forget sans feedback masque les échecs
- L'utilisateur croit que l'action est faite (ex: document disponible hors-ligne) alors qu'elle a échoué

### Symptômes

- `.then(...).catch(() => {})` sur une action secondaire
- `catch { /* ignore */ }` sans log ni feedback visuel

### Bonnes pratiques / mitigations

- Même si l'action est non-bloquante, afficher un feedback discret en cas d'échec (toast, badge absent)
- L'utilisateur doit pouvoir distinguer "fait" de "échoué silencieusement"
- **Signal review** : tout `.catch(() => {})` ou `catch { /* ignore */ }` mérite au minimum un log ou un feedback visuel

- Contexte technique : frontend / actions async — RL799_V2 07-04-2026

---

<a id="risque-monorepo-shim-js-desynchronise"></a>
## Monorepo ESM — shim runtime `.js` désynchronisé de l'index TypeScript

### Risques
- Le typecheck passe mais le runtime navigateur casse (`named export not found`).

### Symptômes
- Erreur Vite/browser sur export absent alors que `index.ts` est correct.

### Bonnes pratiques / mitigations
- Si un shim `.js` est maintenu, imposer une mise à jour miroir à chaque nouvel export.
- Ajouter un test/guard de cohérence exports TS vs JS shim.

- Contexte technique : monorepo / ESM shim runtime — RL799_V2 15-04-2026

---

<a id="risque-eslint-flat-tsconfigrootdir-manquant"></a>
## ESLint flat config TypeScript sans `tsconfigRootDir`

### Risques
- Erreurs de parsing massives en IDE/monorepo selon CWD d'exécution.

### Symptômes
- `No TsConfigRootDir` / `Cannot read tsconfig.json` alors que le build TS passe.

### Bonnes pratiques / mitigations
- Toujours définir `tsconfigRootDir: import.meta.dirname` quand `parserOptions.project` est utilisé.
- Redémarrer le serveur ESLint après correction.

- Contexte technique : tooling / ESLint flat config — RL799_V2 17-04-2026

---

<a id="risque-pwa-auth-cookie-cache"></a>
## PWA + auth cookie httpOnly — stratégie de cache non maîtrisée

### Risques
- Réponses sensibles servies depuis cache offline.
- Comportement d'auth incohérent entre réseau/cached.

### Symptômes
- Session/app state divergents après activation SW ou reprise réseau.

### Bonnes pratiques / mitigations
- Exclure explicitement les routes authentifiées sensibles du cache persistant.
- Définir une stratégie stricte par classe de route (auth, API privée, assets publics).

- Contexte technique : PWA / service worker / auth cookie — RL799_V2 18-04-2026

---

<a id="risque-pwa-beforeinstallprompt-tardif"></a>
## PWA install prompt — capture tardive de `beforeinstallprompt`

### Risques
- Événement perdu au cold boot, prompt jamais proposé.

### Symptômes
- Implémentation correcte en apparence mais aucun déclenchement sur Android.

### Bonnes pratiques / mitigations
- Installer l'écouteur le plus tôt possible dans le cycle d'initialisation.
- Ne pas baser la détection iOS uniquement sur l'UA (cas iPad en mode desktop).

- Contexte technique : PWA / install prompt — RL799_V2 18-04-2026

---

<a id="risque-cache-pwa-soft-delete-fuite"></a>
## Cache offline PWA + soft-delete — invalidation diff-based scopée

### Risques

- Une PWA qui cache des blobs en IndexedDB pour offline reading **ne reçoit aucun signal automatique** quand un document est soft-deleted côté serveur
- Le contenu reste lisible hors-ligne indéfiniment. Pour des données réglementaires ou sensibles, c'est un gap de sécurité non négligeable

### Symptômes

- Document soft-deleted en base, encore consultable offline par les utilisateurs qui l'ont mis en cache
- Aucun mécanisme automatique de purge

### Bonnes pratiques / mitigations

**Mitigation V1 (best-effort, diff-based)** au prochain `loadEntries` online :

1. Lire la liste serveur courante (filtrée `deletedAt IS NULL`)
2. Lire les IDs cachés localement **scopés au même périmètre** (type+grade) — sinon on supprime à tort un doc d'un autre onglet
3. Diff : `cached - server = soft-deleted` → `removeCachedDocument(id)` pour chaque

```typescript
const bustCachedIfMissing = async (candidateIds: string[], serverIds: Set<string>) => {
  for (const id of candidateIds) {
    if (!serverIds.has(id)) await removeCachedDocument(id);
  }
};
```

**Mitigation V2 (push server-initiated)** : Service Worker abonné à un canal (postMessage / WebSocket / SSE), serveur publie `{ type: 'document-soft-deleted', id }` sur soft-delete, SW intercepte et fait `caches.delete()` immédiat. Coût : infra push + gestion connectivité partielle. À garder en backlog si le risque devient critique (audit GDPR).

**Tests recommandés** :
- doc caché + recharge avec serveur qui omet ce doc → assert `removeCachedDocument` appelé
- doc caché + serveur qui retourne le doc → assert pas d'effet (non-régression)
- doc caché pour scope `rituels` + recharge sur scope `mementos` qui omet ce doc → assert pas d'effet (scope isolation)

- Contexte technique : PWA / IndexedDB — RL799_V2 20-04-2026

---

<a id="risque-duplication-focus-parent-modal"></a>
## Duplication parent ↔ modal de la capture de focus

### Risques

- Quand un composant modal implémente correctement le pattern a11y `previousActiveElement` (capture à `onMounted`, restitution à `close`/`submit`), le composant parent **ne doit PAS** stocker un `lastTrigger` en parallèle
- Code mort avec commentaire trompeur : un lecteur qui cherche à comprendre le flux focus va s'y perdre

### Symptômes

- Le parent a une ref `lastFabTrigger` / `lastTrigger` écrite au clic du trigger
- Elle n'est **jamais lue** — le commentaire prétend "pour restitution du focus par la modal", mais la modal a son propre mécanisme

### Bonnes pratiques / mitigations

- **Une seule responsabilité** pour la restitution du focus : la modal
- Le parent se contente d'ouvrir la modal (`uploadModalOpen.value = true`)
- Le parent ne capture le focus QUE si la modal ne le fait pas (cas d'un overlay maison sans pattern `previousActiveElement`)
- **Repérage en code review** : `grep -n "lastTrigger\|previousActive" components/ pages/` → s'il y a des occurrences dans BOTH un parent et une modal du même flux, c'est le signal

- Contexte technique : Vue 3 / a11y modales — RL799_V2 20-04-2026

---

<a id="risque-touch-action-none-card-mobile"></a>
## `touch-action: none` sur card mobile bloque scroll vertical

### Risques

- Une card mobile gérant un swipe horizontal avec `touch-action: none` capture **tout** le toucher, laissant le JS gérer le scroll vertical
- Le JS détecte scroll vs swipe via un seuil mais doit **libérer** l'événement après l'avoir analysé → un délai imperceptible s'installe, le scroll vertical devient saccadé et souvent ignoré
- L'utilisateur trouve la liste "inscrollable" quand son pouce touche directement les cards

### Symptômes

- Poser le pouce sur une card puis scroller ne marche qu'une fois sur 20
- Scroll OK dans les marges vides à côté
- Aucune erreur console

### Bonnes pratiques / mitigations

```css
/* AVANT — bug : scroll vertical capturé */
.member-card {
  touch-action: none;
}

/* APRÈS — scroll natif OK, swipe horizontal toujours fonctionnel */
.member-card {
  touch-action: pan-y;
}
```

Combiné avec un handler JS qui `preventDefault` uniquement sur mouvement horizontal significatif (> 10 px) :

```typescript
if (Math.abs(deltaX) > 10 && Math.abs(deltaX) > Math.abs(deltaY)) {
  event.preventDefault();
}
```

**Règle** :
- Card qui gère swipe horizontal ET scroll vertical : `touch-action: pan-y` (le navigateur gère nativement le scroll vertical, seul l'axe horizontal est laissé au JS)
- Card qui ne gère QUE du pan/zoom custom (rare) : `touch-action: none` peut se justifier
- Tous les autres cas : laisser la valeur par défaut (`auto`)

**Repérage en code review** : `grep -rn "touch-action: none" components/` → chaque occurrence est suspecte.

- Contexte technique : CSS / mobile — RL799_V2 21-04-2026

---

<a id="risque-button-wrapper-card-color-inherit"></a>
## `<button>` wrapper card — toujours `color: inherit` (reset user-agent)

### Risques

- Quand on utilise un `<button>` comme wrapper cliquable d'une card (pattern idiomatique pour l'a11y clavier), il hérite du `color` user-agent par défaut
- Sur certains setups (Safari/dark mode notamment), ce `color` peut être bleu — le texte enfant hérite et contamine titres et paragraphes qui devraient prendre la couleur du thème

### Symptômes

- Titre de card en bleu sur fond sombre alors que le thème prévoit de l'or
- Bug non visible en dev light mode sur Chrome — apparaît uniquement sur certains setups → difficile à reproduire

### Bonnes pratiques / mitigations

```css
.my-card-button {
  /* reset user-agent */
  color: inherit;
  font-family: inherit;
  border: 0;
  background: transparent;
  /* … */
}
```

**Règle** : tout `<button>` qui wrappe du contenu stylé par le thème (card, liste, ligne de tableau) doit reset `color`, `font-family`, `font-size`, `border`, `background`. C'est un bootstrap user-agent minimal à prévoir dans tout design system.

**Alternative** : `<div role="button" tabindex="0">` avec `@keydown.enter/space`. Plus verbeux, mais évite les resets. Pattern valide si l'équipe est à l'aise avec les implications a11y.

- Contexte technique : CSS / a11y — RL799_V2 21-04-2026

---

<a id="risque-erreur-silencieuse-4-etats"></a>
## Erreur silencieuse = blanc indistinguable de "aucune donnée" — 4 états distincts (loading / empty / error / forbidden)

### Risques

- Un composant qui affiche le résultat d'un fetch sans distinguer ses 4 états produit du **blanc** dans 3 cas sur 4 — l'utilisateur ne peut pas savoir si la donnée est en chargement, légitimement vide, en erreur réseau, ou refusée par RBAC
- Sur les flows critiques (rituel, opérations sensibles), un blanc silencieux est inacceptable : l'utilisateur prend des décisions sur la base de l'affichage

### Symptômes

- Plusieurs cards d'une vue qui auto-fetchent et tombent toutes en `[]` côté front quand l'API renvoie 403 — affichage indistinguable de "vide légitime"
- Toast générique "Une erreur est survenue" sans corrélation avec un retry actionnable

### Bonnes pratiques / mitigations

Tout composant qui fetch une ressource doit avoir **4 états distincts** dans son rendu :

1. **Loading** : skeleton, spinner, ou texte explicite (« Chargement… »)
2. **Empty (donnée légitimement vide)** : message explicite *« Aucune donnée enregistrée pour … »*
3. **Error (réseau / serveur)** : message + bouton retry. Ne jamais se contenter d'un blanc
4. **Forbidden (403)** : message explicite *« Vous n'avez pas accès à cette donnée »* + suggestion d'action (recharger / contacter admin)

Le frontend doit savoir **distinguer 403** des autres erreurs au niveau de son service HTTP, et propager l'info au composant. Ne pas traiter `!response.ok` en bloc avec un message générique.

```typescript
export const getXxx = async (id: string) => {
  const response = await apiFetch(`/api/xxx/${id}`);
  if (response.status === 403) throw new ForbiddenError(...);
  if (response.status === 404) throw new NotFoundError(...);
  if (!response.ok) throw new Error(await parseError(response));
  return (await response.json()).data;
};
```

Le composant catche les types d'erreur et choisit le rendu approprié.

- Contexte technique : Vue 3 / fetch — RL799_V2 27-04-2026

---

<a id="risque-service-worker-non-secure-context"></a>
## Service Worker invisible en accès non-secure (HTTP via IP réseau)

### Risques

- Les Service Workers exigent un [secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts) — HTTPS strict, OU URL `http://localhost:*` ou `http://127.0.0.1:*`
- Un accès via IP réseau en HTTP (Tailscale `100.x.x.x`, LAN `192.168.x.x`) est non-secure → `navigator.serviceWorker` est `undefined` → la PWA fonctionne en mode "navigateur classique" mais sans cache offline, sans push, sans badge, sans installation
- Les tests E2E en Tailscale loupent silencieusement les régressions SW

### Symptômes

- `navigator.serviceWorker.register('/sw.js')` lève `Cannot read properties of undefined (reading 'register')`
- DevTools > Application > Service Workers ne montre rien
- Tests "ça marche en LAN" qui ne reflètent pas la prod HTTPS

### Bonnes pratiques / mitigations

```javascript
// Détection
console.log(window.isSecureContext, location.protocol, location.hostname);
// true "https:" "..." → OK
// true "http:" "localhost" → OK
// false "http:" "192.168.1.42" → SW désactivé
```

Stratégies par contexte :

1. **Test local** : utiliser `http://localhost:<port>` strict (jamais l'IP, même en LAN)
2. **Test réseau / mobile** : reverse proxy HTTPS (Caddy/Traefik avec Let's Encrypt, ou `tailscale cert` pour le magicDNS Tailscale)
3. **Préview Vite** : `vite preview --https` avec un certificat auto-signé (acceptable en dev test)

**Préventif** :
- documenter dans le README projet que le SW exige HTTPS/localhost
- en CI E2E, toujours utiliser `localhost` (le webServer Playwright tourne sur `localhost` par défaut)
- ne PAS supposer qu'un test "ça marche en LAN" reflète la prod HTTPS

- Contexte technique : PWA / Service Worker — RL799_V2 28-04-2026

---

<a id="risque-vite-pwa-bascule-strategies-runtime-caching"></a>
## Vite-plugin-pwa : bascule `generateSW` → `injectManifest` rend `runtimeCaching` inerte

### Risques

- En mode `injectManifest`, Vite PWA n'injecte PAS de runtime workbox — il bundle le `src/sw.ts` fourni tel quel
- Toute la config `workbox.*` du `vite.config.ts` (sauf `globPatterns`/`globIgnores` déplacés sous `injectManifest.*`) est ignorée silencieusement, sans warning
- Régression directe : leak de cookies API en cache, contenu sensible en cache, 404 transformés en `index.html`

### Symptômes

- Après bascule, les routes runtime configurées dans `workbox.runtimeCaching` n'ont plus aucun effet
- Le build passe, aucune erreur visible, mais en DevTools > Application > Service Worker on ne voit pas les routes attendues

### Bonnes pratiques / mitigations

Détection : examiner `dist/sw.js` après build — chercher des strings clés (`/api/`, `uploads`, `manifest.webmanifest`, `addEventListener`). Si elles sont absentes du SW custom, c'est qu'on a perdu la protection.

**Mitigation** : RÉIMPLÉMENTER À LA MAIN dans `src/sw.ts` toutes les routes runtime, denylist, et cleanup :

```typescript
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkOnly, NetworkFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';

cleanupOutdatedCaches();
precacheAndRoute(self.__WB_MANIFEST);

registerRoute(({ url }) => url.pathname.startsWith('/api/'), new NetworkOnly());
registerRoute(({ url }) => url.pathname.startsWith('/uploads/'), new NetworkOnly());
registerRoute(
  ({ url }) => url.pathname === '/manifest.webmanifest',
  new NetworkFirst({
    cacheName: 'manifest-cache',
    plugins: [new ExpirationPlugin({ maxAgeSeconds: 300, maxEntries: 1 })],
  }),
);

registerRoute(
  new NavigationRoute(async () => { /* fallback handler */ }, {
    denylist: [/^\/api\//, /^\/uploads\//, /^\/manifest\.webmanifest$/],
  }),
);
```

**Vérifications obligatoires post-bascule** (DevTools sur build/preview) :

1. Application > Service Worker : `sw.js` activé
2. Network : `POST /api/auth/login` → pas de "(from ServiceWorker)", pas de cache
3. Network : GET `/uploads/foo.pdf` → réseau direct
4. Network mode Offline : navigation `/page` → fallback `index.html` ; `/api/foo` → erreur réseau (PAS index.html)
5. Déploiement v2 : ancien cache purgé après activation

`setCatchHandler` ne suffit PAS à remplacer `navigateFallback` — il ne se déclenche que si une route enregistrée throw. Pour le navigation fallback, utiliser `NavigationRoute` explicitement.

- Contexte technique : Vite / vite-plugin-pwa / Workbox — RL799_V2 28-04-2026

---

<a id="risque-ts-strict-uint8array-buffersource"></a>
## TS strict — `Uint8Array<ArrayBufferLike>` non assignable à `BufferSource`

### Risques

- TS 5.7+ avec lib DOM récente paramètre `Uint8Array` par défaut sur `ArrayBufferLike` (qui inclut `SharedArrayBuffer`)
- Beaucoup d'APIs DOM (Push API, WebCrypto certaines surfaces) attendent un `BufferSource` strict avec `buffer: ArrayBuffer` — d'où une erreur TS au build

### Symptômes

```
Type 'Uint8Array<ArrayBufferLike>' is not assignable to type 'BufferSource'.
  Types of property 'buffer' are incompatible.
    Type 'ArrayBufferLike' is not assignable to type 'ArrayBuffer'.
      Type 'SharedArrayBuffer' is missing the following properties from type 'ArrayBuffer'…
```

L'erreur est au build, pas au runtime (le code marche en JS).

### Bonnes pratiques / mitigations

Créer explicitement un `ArrayBuffer` strict, puis remplir via une vue `Uint8Array` :

```typescript
// ❌ Ne compile pas en TS strict
const urlBase64ToUint8Array = (s: string): Uint8Array => {
  const raw = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
  const out = new Uint8Array(raw.length);
  for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
  return out;
};

// ✅ Compile et fonctionne identiquement
const urlBase64ToArrayBuffer = (s: string): ArrayBuffer => {
  const raw = atob(s.replace(/-/g, '+').replace(/_/g, '/'));
  const buf = new ArrayBuffer(raw.length);
  const view = new Uint8Array(buf);
  for (let i = 0; i < raw.length; i++) view[i] = raw.charCodeAt(i);
  return buf;
};
```

**Alternative déconseillée** : `as ArrayBuffer` cast — masque le problème, peut rater une vraie incompatibilité si la lib DOM évolue.

- Contexte technique : TypeScript 5.x / lib DOM — RL799_V2 28-04-2026

---

<a id="risque-safe-areas-ios-viewport-fit-cover"></a>
## Safe-areas iOS — `viewport-fit=cover` indispensable

### Risques

- Sur iPhone Pro/Max (notch + home indicator + Dynamic Island), `padding-top: env(safe-area-inset-top)` ou `padding-bottom: env(safe-area-inset-bottom)` retourne **0** sans `<meta viewport content="… viewport-fit=cover">`
- Le développeur conclut à tort que le pattern ne fonctionne pas, alors qu'il manque juste l'opt-in

### Symptômes

- Header fixed rogné par le notch
- Nav bottom rognée par le home indicator
- `env(safe-area-inset-*)` retourne 0 sur iPhone Pro+

### Bonnes pratiques / mitigations

**Pattern complet (3 endroits) à appliquer ensemble** :

```html
<!-- 1) index.html — opt-in safe-areas -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
```

```css
/* 2) Header sticky : top + safe-area-inset-top */
.app-header {
  position: fixed;
  top: 0;
  padding-top: env(safe-area-inset-top);
  height: calc(var(--size-header-height) + env(safe-area-inset-top));
}

/* 3) Nav bottom : bottom + safe-area-inset-bottom + safe-area-inset-left/right */
.app-nav {
  position: fixed;
  bottom: 0;
  padding-bottom: env(safe-area-inset-bottom);
  padding-left: env(safe-area-inset-left);
  padding-right: env(safe-area-inset-right);
  height: calc(var(--size-nav-height) + env(safe-area-inset-bottom));
}

/* FAB / menu flottant : bottom au-dessus de la nav + safe-area + offset */
.fab {
  bottom: calc(var(--size-nav-height) + env(safe-area-inset-bottom) + 16px);
  /* `max()` empêche les boutons d'être collés au bord rond du device */
  right: max(16px, env(safe-area-inset-right));
}
```

**Règle de débogage** : si `env(safe-area-inset-bottom)` semble retourner 0 sur iPhone Pro+, **vérifier `<meta viewport>` AVANT de chercher ailleurs**. C'est presque toujours la cause.

`safe-area-inset-left` et `safe-area-inset-right` ne sont non-nuls qu'en mode paysage (notch latéral). Garder le `padding-left/right` quand même → no-op en portrait, fix en paysage.

- Contexte technique : CSS / iOS — RL799_V2 02-05-2026

---

<a id="risque-input-date-safari-ios-min-width"></a>
## `<input type="date">` Safari iOS — `appearance: none` + `min-width: 0` + `min-height` obligatoires

### Risques

- Sur Safari iOS (et Chrome iOS car webkit sous-jacent), un `<input type="date">` ou `datetime-local` :
  - déborde de son conteneur sur la droite (largeur intrinsèque > 100 %)
  - apparaît plus mince que les autres inputs (hauteur intrinsèque différente)
  - affiche un styling natif iOS qui casse le design system

### Symptômes

- `width: 100%` ne suffit pas — la largeur intrinsèque écrase la contrainte
- Bug non reproductible sur Chrome desktop, visible uniquement sur iPhone réel ou Safari Responsive Design Mode

### Bonnes pratiques / mitigations

```css
input[type="date"],
input[type="datetime-local"],
input[type="time"] {
  appearance: none;          /* neutralise le styling natif */
  -webkit-appearance: none;  /* Safari iOS — ne PAS oublier */
  min-width: 0;              /* permet à width: 100% de gagner */
  min-height: 48px;          /* aligne avec les autres inputs */
}
```

**Les 4 propriétés sont nécessaires** :

- `appearance: none` seul : le styling natif disparaît mais la largeur intrinsèque reste → débordement
- `min-width: 0` seul : le styling natif reste, on a juste cassé sa hauteur
- `min-height: 48px` : nécessaire pour homogénéiser avec les inputs text classiques
- `-webkit-appearance: none` : redondant en théorie avec `appearance: none` mais nécessaire en pratique sur certaines versions Safari iOS

- Contexte technique : CSS / Safari iOS — RL799_V2 01-05-2026

---

<a id="risque-fieldset-legend-flex-grid"></a>
## `<fieldset>` / `<legend>` cassent un layout flex inline

### Risques

- Pour un champ "label + valeur inline" (ex : `Grade [GradeBadge]` sur la même ligne), le réflexe sémantique est `<fieldset>` + `<legend>`
- `<legend>` a un comportement natif particulier : interrompt visuellement le `border` du fieldset, son `display` est traité spécialement, il ne se comporte pas comme un enfant flex/grid normal
- Le legend prend toute la largeur, l'input passe en dessous, impossible de les aligner sans hacks `position: absolute`

### Symptômes

- `<fieldset style="display: flex">` avec `<legend>` + `<input>` qui ne s'alignent pas sur la même ligne

### Bonnes pratiques / mitigations

Pour un groupe de champs **avec layout custom** (flex/grid inline), utiliser `<div>` + `<span class="label">` + le champ :

```html
<!-- ❌ Layout cassé : legend ne se comporte pas comme un enfant flex -->
<fieldset class="field-inline">
  <legend>Grade</legend>
  <GradeBadge :grade="grade" />
</fieldset>

<!-- ✅ Layout custom OK : div + span — perte sémantique mineure
     compensée par aria-labelledby si besoin -->
<div class="field-inline">
  <span class="field-inline__label" id="grade-label">Grade</span>
  <GradeBadge :grade="grade" aria-labelledby="grade-label" />
</div>
```

Garder `<fieldset>/<legend>` uniquement quand on accepte le rendu natif (groupe vertical avec border + legend qui chevauche le border supérieur — pattern formulaire admin classique).

**Trade-off à assumer** : `<fieldset>` apporte une sémantique a11y (groupe de champs liés). En remplaçant par `<div>`, on peut compenser via `role="group" aria-labelledby="..."` si le besoin d'a11y est fort. Pour un simple label+badge inline, c'est rarement nécessaire.

- Contexte technique : HTML / a11y / CSS — RL799_V2 02-05-2026

---

<a id="risque-type-client-non-maj-extension-payload-backend"></a>
## Type client non mis à jour lors d'une extension de payload backend

### Risques

- Quand un nouveau champ est ajouté dans un objet retourné par une Server Action, le type TS côté client n'est pas toujours mis à jour en parallèle
- Si le retour de la Server Action est casté vers un type plus étroit, TypeScript accepte l'incompatibilité **silencieusement** : le champ existe au runtime mais reste invisible pour les consommateurs TS

### Symptômes

- Le payload contient un champ supplémentaire en runtime, mais le type client ne l'expose pas
- Une tâche "mettre à jour le test si le payload expose le nouveau champ" est marquée done en vérifiant le test, sans vérifier le type source qui l'alimente
- Aucune erreur de compile car le cast masque la divergence

### Bonnes pratiques / mitigations

Pour toute extension d'un payload backend (ajout de champ), appliquer une **checklist de propagation** :

1. Le type TS côté client (ex : type d'item média, type de l'entité)
2. Le contrat Zod si présent
3. Les fixtures de test qui typent le payload
4. Les composants qui déstructurent le payload (vérifier les accès au nouveau champ manquants)

- Règle : ne pas laisser cette propagation à la décision implicite du dev — l'expliciter comme checklist dans la story / la PR.

- Contexte technique : Next.js / Server Actions / TypeScript / Zod — app-template-resto 25-06-2026

---

<a id="risque-fichier-modifie-pas-fichier-propre"></a>
## "Fichier modifié" ≠ "fichier propre"

### Risques

- Un écran ancien (avant adoption du design system) reste 100 % styles inline / hex hardcodés / magic numbers. Une story tactique qui n'y modifie que 3 lignes le fait apparaître dans la `File List` sans nettoyer le reste → faux signal "fichier traité dans la story", dette intacte

### Symptômes

- Fichier "récent" en apparence mais saturé de `style={{ … }}`, `#666`, `spacing` en dur, strings non i18n

### Bonnes pratiques / mitigations

- En review, ne pas conclure qu'un fichier est conforme parce qu'il est dans la `File List`. Vérifier explicitement : styles inline, hex hardcodés, magic numbers, strings en dur
- Si la dette dépasse le scope de la story, la **capitaliser** comme dette à refondre (story dédiée), ne pas l'absorber implicitement
- Contexte technique : React Native — app-alexandrie (`thread/[threadId].tsx`), 27-05-2026

---

<a id="risque-sweep-grep-amorce-vs-liste-finale"></a>
## Sweep statique : grep d'amorce ≠ liste finale de candidats

### Risques

- Un sweep préventif (audit, refacto large) propage la liste **brute** du grep d'amorce comme liste finale, sans appliquer le critère discriminant qui justifie le sweep
- Appliquer un fix à tous les hits de l'amorce → modifications no-op ou nuisibles sur les faux positifs

### Symptômes

- Rapport listant N "fichiers suspectés affectés" alors qu'une validation montre k vrais positifs (ex. amorce `contentContainer:` → 28 hits, vrai pattern = + `flexDirection: 'row'` → 5 fichiers)

### Bonnes pratiques / mitigations

1. Publier **deux listes distinctes** : amorce (grep brut) et finale (filtrée par critère discriminant, vérifiée fichier par fichier)
2. Intégrer au rapport le `awk`/`grep` exact qui produit la liste finale (re-vérifiable en 30 s)
3. **Stop condition** : ne pas fixer les fichiers de l'amorce absents de la liste finale
4. En review : tout écart entre liste finale du rapport et liste recalculée = finding HIGH
- Contexte technique : React Native — app-alexandrie (ux-cleanup-1), 28-05-2026

---

<a id="risque-sticky-bottom-nav-persistante-positionnement"></a>
## Sticky bottom + nav persistante : positionnement à recalculer en dynamique

### Risques

- Un sticky `position: absolute` au-dessus d'une nav persistante calcule son `bottom` depuis une constante arbitraire (`BottomTabInset = 50/80`) déconnectée de la hauteur réelle de la nav et du safe-area
- Le CTA est coupé par la nav. Reproduit sur device, invisible en tests Jest

### Symptômes

- Bouton sticky chevauché/coupé par la BottomBar

### Bonnes pratiques / mitigations

```typescript
// ✅ hauteur réelle de la nav (exportée par le composant nav) + safeArea bottom
const insets = useSafeAreaInsets();
<View style={[styles.stickyCta, { bottom: PERSISTENT_TAB_BAR_HEIGHT + insets.bottom }]} />
```

- Exposer la hauteur réelle depuis le composant nav (`export const PERSISTENT_TAB_BAR_HEIGHT = 64`), ne jamais utiliser une constante "à peu près correcte"
- Si cohabitation avec un ScrollView : `paddingBottom = STICKY_HEIGHT + NAV_HEIGHT + insets.bottom` au `contentContainerStyle`
- Contexte technique : React Native — app-alexandrie (ux-cleanup-7), 29-05-2026

---

<a id="risque-filtre-client-side-liste-paginee"></a>
## Filtrer client-side sur une liste paginée → l'écran ment

### Risques

- Un filtre/recherche via `items.filter(...)` sur une liste paginée côté serveur : les items chargés ne matchent pas, mais des pages non chargées le feraient
- L'utilisateur voit une liste vide et conclut faussement "il n'y a rien de ce type" (inversion d'attribution coûteuse en confiance)

### Symptômes

- `const filtered = items.filter(...)` + `ListEmptyComponent="Aucun résultat"` dans un contexte paginé

### Bonnes pratiques / mitigations

1. **Préférer un param API** (`type`, `category`, `tag`) dans la query de pagination — seule solution propre
2. Si non faisable court terme : message UX explicite — quand la liste filtrée tombe à 0 et `hasMore === true`, afficher "Charger plus pour ce filtre" plutôt qu'un "Aucun résultat" trompeur
3. Ne jamais filtrer client en silence sur une liste paginée
- Contexte technique : React Native — app-alexandrie (ux-cleanup-5), 29-05-2026

---

<a id="risque-bucket-defaut-par-negation"></a>
## Bucket "défaut" défini par négation des autres → zone morte invisible

### Risques

- Définir un 3ᵉ bucket par négation (`isNotStarted = !isCompleted && !isInProgress`) crée une dépendance implicite entre prédicats
- Si la sémantique d'un prédicat change (ou si un input désynchronisé arrive), un item peut tomber dans **aucun** bucket et disparaître silencieusement de l'UI (invisible en typecheck/lint/test)

### Symptômes

- Contenu existant absent de toutes les sections de la liste après un changement de sémantique

### Bonnes pratiques / mitigations

```typescript
// ✅ chaque bucket par sa propre condition affirmative
function isNotStarted(c) {
  return c.state === 'NOT_STARTED' && (c.completedAt ?? null) === null;
}
```

- Test "désync" : forcer les 3 prédicats à `false` simultanément → s'il existe un cas, l'invariant "exactement 1 bucket" est cassé (le test documente l'invariant)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-6), 29-05-2026

---

<a id="risque-code-smells-js-ts-review"></a>
## Code smells JS/TS à rechercher en review

### Risques

- Ternaire identique des deux côtés (`cond ? a : a`), `??`/`||` avec deux opérandes identiques, variable affectée puis non utilisée différemment de sa valeur initiale → dead code ou intention incomplète
- Ni le compilateur ni le lint (s'il n'est pas configuré) ne signalent ces cas

### Symptômes

- `const labelColor = isFollowing ? c.primary : c.primary;`

### Bonnes pratiques / mitigations

- Détectables via `eslint-plugin-no-constant-binary-expression` / `no-constant-condition` étendu
- Garde-fou review : grep des ternaires/coalescences à opérandes identiques
- Contexte technique : React Native — app-alexandrie (ux-cleanup-9), 29-05-2026

---

<a id="risque-pressable-disabled-accessibility-state"></a>
## `Pressable` disabled sans `accessibilityState.disabled`

### Risques

- Sur React Native, `<Pressable disabled>` empêche la touche mais le screen reader l'annonce toujours comme tappable si `accessibilityState` n'est pas déclaré

### Symptômes

- VoiceOver/TalkBack annonce "Bouton" au lieu de "Bouton estompé" sur un Pressable désactivé

### Bonnes pratiques / mitigations

- Tout `Pressable` avec `disabled` conditionnel doit passer `accessibilityState={{ disabled }}` (comme `selected` pour les chips actifs)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-9, `directory-user-item.tsx`), 29-05-2026

---

<a id="risque-redirection-controlee-par-data-backend"></a>
## Redirection contrôlée par data backend sans allowlist

### Risques

- Un helper qui transforme un champ backend en path de navigation et accepte `targetId.startsWith('/')` ouvre une redirection vers tout écran (`/auth/reset?token=x`, `/community/admin/secret`) si la table backend est corrompue
- Zéro défense en profondeur côté client face à une compromission d'un tier intermédiaire (Redis, queue, service notifs)

### Symptômes

- `if (targetId.startsWith('/')) return targetId;` dans un résolveur de route

### Bonnes pratiques / mitigations

```ts
const ALLOWED_PREFIXES = ['/notifications', '/content/', '/user/', '/messages/', '/profile'];
const isAllowed = (path: string) => ALLOWED_PREFIXES.some((p) => path === p || path.startsWith(p));
```

- Coût du fix = 1 fonction + 1 constante
- Contexte technique : React Native — app-alexandrie (ux-cleanup-10 H1, notif SYSTEM), 29-05-2026

---

<a id="risque-helper-usage-futur-code-mort"></a>
## Helper livré "pour usage futur" sans JSDoc de statut → code mort

### Risques

- Un helper testé mais importé par aucun composant (livré en avance pour une dépendance arrière non encore livrée) crée du code mort et un faux signal "AC livré" reposant en réalité sur une autre couche

### Symptômes

- Helper avec une suite de tests mais 0 import en runtime (détecté par un audit "unused exports")

### Bonnes pratiques / mitigations

- Tout helper livré "pour usage futur" doit avoir une JSDoc documentant son statut + la story qui le branchera
- Contexte technique : React Native — app-alexandrie (ux-cleanup-10 H3, `i18n/notifications.ts`), 29-05-2026

---

<a id="risque-helper-heure-courante-fige-sans-refresh"></a>
## Helper dépendant de l'heure courante figé sans mécanisme de refresh

### Risques

- Un helper basé sur l'heure (`getTimeBasedGreeting`, `formatRelativeTime`) appelé inline dans le JSX fige sa valeur tant que le composant ne re-render pas
- Sur un écran à durée de vie longue, "Bonjour ☀️" reste affiché après 18h

### Symptômes

- `<Text>{getTimeBasedGreeting()}</Text>` sans state ni effet

### Bonnes pratiques / mitigations

```tsx
const [greeting, setGreeting] = useState(() => getTimeBasedGreeting());
useFocusEffect(useCallback(() => { setGreeting(getTimeBasedGreeting()); }, []));
```

- `useFocusEffect` (rafraîchit à chaque ré-ouverture d'onglet) suffit pour des salutations ; `setInterval` pour des compteurs temps réel ("il y a 2 min" → "3 min")
- Contexte technique : React Native / Expo Router — app-alexandrie (ux-cleanup-15 H1), 30-05-2026

---

<a id="risque-migration-tokens-composant-core-oublie"></a>
## Migration de tokens : le composant CORE de référence oublié

### Risques

- Lors d'une migration de tokens (`typography.ts`, `colors.ts`), le composant CORE qui consomme ces tokens (`<ThemedText>`, `<ThemedView>`) est l'oublié — migré partiellement, ou ignoré car l'audit grep cible les "consommateurs" et pas les "définisseurs"

### Symptômes

- Le fichier qui définit les types garde des `fontWeight: 500` / `fontSize: 48` en dur alors que tout le reste est migré

### Bonnes pratiques / mitigations

```bash
grep -rnE "fontSize: [0-9]+\b|fontWeight: [0-9]+\b" src/ --include="*.tsx"
grep "from '@/theme'" src/components/themed-text.tsx   # le CORE consomme-t-il les tokens ?
```

- Auditer EN PREMIER le composant CORE
- Contexte technique : React Native — app-alexandrie (ux-cleanup-12 H1, `themed-text.tsx`), 30-05-2026

---

<a id="risque-animation-switch-binaire-clignotement"></a>
## Animation "shimmer/fade/pulse" via switch binaire → clignotement perçu comme bug

### Risques

- Un `value < 0.5 ? colorA : colorB` produit un blink on/off perçu comme un glitch d'affichage, pas comme une animation

### Symptômes

- Skeleton/placeholder qui clignote au lieu de fondre

### Bonnes pratiques / mitigations

```tsx
// ✅ interpolation = transition douce (reanimated)
backgroundColor: interpolateColor(progress.value, [0, 0.5, 1], [colorA, colorB, colorA])
```

- Contexte technique : React Native / reanimated — app-alexandrie (ux-cleanup-13 H1, `SkeletonScreen.tsx`), 31-05-2026

---

<a id="risque-as-unknown-as-bypass-typage"></a>
## `as unknown as X` — signal d'alarme, la lib a souvent le type prévu

### Risques

- Face à une erreur de typage avec une lib, `as unknown as X` bypasse complètement TypeScript et masque le vrai problème (la lib expose un type spécifique à utiliser, ex. `AnimatedStyle<ViewStyle>` de reanimated)

### Symptômes

- `const animatedStyle = useAnimatedStyle(() => ({…})) as unknown as ViewStyle;`

### Bonnes pratiques / mitigations

- Importer et propager le type strict de la lib
- Tout `as unknown as` / `as any as` doit être justifié par un commentaire démontrant que (a) la lib n'expose pas le type adéquat et (b) le contrat runtime est garanti par ailleurs
- Contexte technique : React Native / reanimated — app-alexandrie (ux-cleanup-13 M1), 31-05-2026

---

<a id="risque-hitslop-isolation-vs-layout"></a>
## `hitSlop` calculé en isolation → chevauchement des voisins

### Risques

- Un `hitSlop` calculé pour atteindre 44pt sans tenir compte du layout peut chevaucher les éléments voisins en layout dense (ScrollView horizontal avec gap)
- Taper entre deux éléments active le mauvais

### Symptômes

- Chips avec `hitSlop left/right=6` et gap=8 → cumul 12 sur 8 → chevauchement 4pt

### Bonnes pratiques / mitigations

- Pour atteindre 44pt en height : augmenter top/bottom est sûr ; pour width, plafonner left/right à `gap / 2`
- SectionHeader suivi d'une liste : `hitSlop bottom` ≤ `marginBottom` du container (ou 8pt par défaut)
- Contexte technique : React Native — app-alexandrie (ux-cleanup-14 M1/M5), 31-05-2026

---

<a id="risque-hooks-apres-early-return"></a>
## Hook appelé après un early return → "Rendered more hooks" (crash bloquant)

### Risques

- Un Hook (`useRouter`) appelé après un early return conditionnel : quand le composant transitionne vers le mode où le Hook est appelé, React crash `Rendered more hooks than during the previous render`
- Invisible aux tests Jest env=node sans renderer JSX

### Symptômes

- `useThemedColors()` … `if (isHidden) return …` … `const router = useRouter();`

### Bonnes pratiques / mitigations

- **Tous les Hooks AVANT tout early return** (règle stricte React) — le coût d'un Hook inutilisé est négligeable, le coût du crash est bloquant
- ESLint `react-hooks/rules-of-hooks` en `error` partout, jamais `warn`
- Garde-fou review : grep `const … = use[A-Z]` après un `if … return` dans la fonction
- Contexte technique : React Native — app-alexandrie (ux-cleanup-17 H1, `ThreadCard`), 31-05-2026

---

<a id="risque-useeffect-une-fois-booleen-derive"></a>
## `useEffect` "une fois" piloté par un booléen dérivé du state → re-fire au cycle reset/remplit

### Risques

- Un `useEffect` censé déclencher une action UNE FOIS, gardé par `threadsLoaded = threads.length > 0`, re-fire à chaque cycle "reset puis remplit" du state (un `fetchThreads` qui commence par `set({ threads: [] })`)
- `markForumSeen` rejoué à chaque pull-to-refresh / changement de catégorie → marque comme lus des threads jamais vus

### Symptômes

- Action idempotente (markSeen/markRead) rejouée silencieusement à chaque refetch

### Bonnes pratiques / mitigations

```typescript
// ✅ tracker l'identité de la ressource dans une ref (pas de re-render, reset au unmount)
const markedRef = useRef<Set<string>>(new Set());
useEffect(() => {
  if (!threadsLoaded || markedRef.current.has(forumSlug)) return;
  markedRef.current.add(forumSlug);
  void markForumSeen(forumSlug);
}, [threadsLoaded, forumSlug, markForumSeen]);
```

- Cas typiques : analytics (`screen_viewed`), idempotency mark, one-time API calls
- Contexte technique : React Native / Zustand — app-alexandrie (ux-cleanup-22 H1), 31-05-2026

---

<a id="risque-double-announce-voiceover-parent-enfants"></a>
## Double-announce VoiceOver : `accessibilityLabel` parent + enfants accessibles

### Risques

- Un wrapper avec un `accessibilityLabel` couvrant tout son texte ET des enfants interactifs (`Pressable`, `Text onPress`, `role="link"`) avec leurs propres labels → VoiceOver lit le parent entier puis chaque enfant (double lecture)

### Symptômes

- Bulle DM avec liens : l'URL est lue dans le texte du parent puis dans `Lien : URL` de l'enfant

### Bonnes pratiques / mitigations

```tsx
// ✅ si enfants interactifs → wrapper non-accessible, VoiceOver navigue dans les enfants
const hasInteractiveChildren = segments.some((s) => s.type === 'url');
<View accessible={!hasInteractiveChildren}
      accessibilityLabel={hasInteractiveChildren ? undefined : `Message : ${text}`} />
```

- Règle : si descendants interactifs avec labels propres → pas de `accessibilityLabel` global + `accessible={false}` sur le wrapper
- Contexte technique : React Native — app-alexandrie (ux-cleanup-19 M1, `message-bubble.tsx`), 02-06-2026

---

<a id="risque-prop-generique-usage-unique-dead-code"></a>
## Prop "générique" introduite pour un usage unique = dead code latent

### Risques

- Justifier un prop d'extension par "ça pourra resservir" crée une API orpheline dès que son unique consommateur disparaît
- Ni typecheck ni lint ne signalent un prop optionnel non utilisé (il reste valide isolément)

### Symptômes

- Prop `trailing` sur un composant + son rendu + ses tests, sans aucun appelant après suppression du seul consommateur

### Bonnes pratiques / mitigations

- Un prop introduit pour UN seul appelant disparaît avec lui : à la suppression d'un consommateur, grep l'usage du prop dans `src/` ; si zéro → retirer prop + rendu + tests
- La généricité ne se présume pas, elle se constate (≥ 2 usages)
- Contexte technique : React Native — app-alexandrie (`ContentInfoChips.trailing`), 02-06-2026

---

<a id="risque-icone-unicode-symbol-other-polychrome"></a>
## Icônes unicode navbar — éviter les caractères "Symbol, Other" (So) polychromes

### Risques

- Les caractères Unicode catégorie "So" (`⚕` U+2695, `☯`, `☢`) peuvent rendre en polychrome sur Android/iOS selon la police système, comme les emojis
- Des caractères visuellement proches ont des catégories très différentes

### Symptômes

- Icône navbar/FAB rendue en couleur au lieu de suivre `currentColor`

### Bonnes pratiques / mitigations

- Pour les items nav/FAB/chrome, rester sur les catégories sûres : "Po" (`✦ ✧ ◇ ◉`), "Sm", "Ps/Pe", ou SVG inline `stroke="currentColor"`
- Vérifier la catégorie Unicode sur unicode.org avant de choisir un caractère décoratif
- Contexte technique : Vue 3 — RL799 (`⚕` → `♡` dans `AppLayout.vue`, v2-4-1), 20-06-2026

---

<a id="risque-recherche-normalisation-filtrage-vs-alignement"></a>
## Recherche client accent-insensible : séparer normalisation de filtrage et d'alignement

### Risques

- Une seule fonction de normalisation sert au FILTRAGE (matcher) ET au SURLIGNAGE (aligner des index sur le texte original)
- Les ligatures (`œ→oe`, `æ→ae`) sont une expansion 1→N : indispensables au filtrage, mais elles cassent l'alignement d'index du surlignage

### Symptômes

- "oeuvres" ne matche pas "œuvres", ou les fragments `<mark>` sont décalés

### Bonnes pratiques / mitigations

- `normalizeForSearch` (filtrage) : minuscule + diacritiques + **ligatures expansées** (longueur peut changer)
- `normalizeForHighlight` (surlignage) : minuscule + diacritiques en mapping **strictement 1:1** (longueur préservée), pour `indexOf`/`slice` sur le texte original
- Ne jamais utiliser `normalize('NFD')` pour l'alignement (change la longueur). Surligner via segmentation `<mark>`, jamais `v-html`
- Compromis assumé : un terme sans ligature remonte l'article ligaturé mais le mot ligaturé n'est pas surligné
- Contexte technique : Vue 3 — RL799 (recherche corpus d'autorité), 22-06-2026

---

<a id="risque-deeplink-details-imbriques-racine"></a>
## Deep-link vers un arbre `<details>` imbriqués : lier `:open` à TOUS les niveaux, racine comprise

### Risques

- Un état `openPath` ouvre les nœuds intermédiaires mais pas le `<details>` RACINE → la cible reste sous un conteneur replié
- `scrollIntoView` vise alors un élément à offsetParent null → scroll silencieusement inopérant (aucune erreur)

### Symptômes

- "Rien ne se passe" après un deep-link recherche→sommaire ; les tests passent car ils vérifient les enfants ouverts, pas l'ancêtre racine

### Bonnes pratiques / mitigations

- Le prédicat d'ouverture doit couvrir le niveau racine : `openPath === key || openPath.startsWith(\`${key}::\`)` appliqué uniformément à chaque profondeur
- Test de non-régression : asserter `element.open === true` sur le `<details>` racine ET chaque ancêtre du chemin, pas seulement les feuilles
- Contexte technique : Vue 3 — RL799, 22-06-2026

---

<a id="risque-retry-form-sans-refetch-contexte"></a>
## Bouton "Réessayer" qui réaffiche un formulaire sans re-fetcher le contexte

### Risques

- Une page à machine d'état (loading→choose→error) où l'erreur vient du GET d'hydratation : un `retry()` qui fait juste `status = 'choose'` réaffiche le formulaire sans données → écran fonctionnel mais vide

### Symptômes

- Formulaire vide après "Réessayer" suite à une erreur de chargement

### Bonnes pratiques / mitigations

- `retry()` doit distinguer "erreur au chargement (contexte jamais hydraté)" de "erreur à la soumission (contexte déjà là)" via un flag `contextLoaded` : si faux → relancer le fetch de contexte ; si vrai → réafficher le formulaire
- Tester explicitement le chemin erreur-au-GET-puis-retry (souvent oublié)
- Contexte technique : Vue 3 — RL799 (page publique sondage, v2-6-4), 24-06-2026

---

<a id="risque-format-date-sans-heure-options-ambigues"></a>
## Format de date sans heure → options de créneaux ambiguës

### Risques

- Une liste de créneaux formatée en jour/mois/année seulement affiche deux options identiques si deux dates tombent le même jour à des heures différentes
- La contrainte d'unicité backend est souvent "par instant (timestamp ms)", pas "par jour" → deux créneaux le même jour sont légaux et distincts en données mais indistinguables à l'écran

### Symptômes

- Deux radios/checkboxes affichant le même libellé dans une liste de créneaux

### Bonnes pratiques / mitigations

- Formater AVEC l'heure (et le fuseau, ex. Europe/Paris) dès que l'unicité backend est à la milliseconde
- Aligner le format sur celui du canal d'origine (mail qui affichait déjà l'heure) pour la cohérence cross-surface
- Vérifier la granularité du dédoublonnage backend avant de choisir le format d'affichage
- Contexte technique : Vue 3 — RL799 (v2-6-4), 24-06-2026

---

<a id="risque-toisostring-throw-invalid-time-value"></a>
## `new Date(x).toISOString()` peut throw `RangeError` → form figé sans feedback

### Risques

- Une string de date NON VIDE mais non parsable (`<input type="datetime-local">`) donne `Invalid Date` ; `.toISOString()` **lève** une exception (contrairement à `.getTime()` qui renvoie `NaN` sans throw)
- Si la conversion est dans un `.map()` avant l'appel async et que le `@submit` n'a pas de `.catch`, la promesse rejette en silence → aucun `error` posé → formulaire figé sans message

### Symptômes

- Soumission qui ne fait "rien" sur une date invalide non-vide ; l'attribut `required` ne couvre pas ce cas

### Bonnes pratiques / mitigations

- Valider chaque date via `Number.isNaN(new Date(x).getTime())` dans la validation cliente AVANT toute conversion `toISOString()`
- Contexte technique : Vue 3 — RL799 (`InstructionForm.vue`)

---

<a id="risque-composant-icone-inheritattrs-svg-100"></a>
## Composant icône `inheritAttrs: false` + SVG `100%` : class ignorée + débordement

### Risques

- Un composant icône avec `defineOptions({ inheritAttrs: false })` n'applique pas la `class` passée (les attributs non-props ne sont pas posés sur le nœud racine) → toute règle CSS la ciblant est inerte
- SVG internes en `width/height: 100%` sans parent dimensionné → l'icône grandit sans borne

### Symptômes

- On passe `class="…"` pour dimensionner l'icône, la règle ne s'applique pas, l'icône déborde de son conteneur

### Bonnes pratiques / mitigations

- Envelopper le composant dans un élément natif (`<span>`) dimensionné explicitement (`width/height: 1.4em`) — l'élément natif reçoit bien la classe — et contraindre le SVG interne via `:deep(.classe-interne) { width:100%; height:100% }`
- Ne jamais compter sur une `class` passée directement à un composant en `inheritAttrs: false`
- Valider le rendu visuel (screenshot/app) avant de committer une intégration d'icône réutilisée
- Contexte technique : Vue 3 — RL799, 22-06-2026

---

<a id="risque-i18n-francisation-incoherente"></a>
## i18n / francisation : cohérence label / a11y / comportement / modèle de données

### Risques

- Composant "à moitié internationalisé" : `accessibilityLabel` en FR mais texte visible en EN (`SectionHeader` : a11y `Voir tout — ${title}` vs texte `See All`). Un sweep qui ne regarde que le texte visible rate l'incohérence ; le fix naïf crée une 2ᵉ chaîne FR qui peut diverger
- Langue UI non vérifiée par le typage : les strings en dur ne sont ni typées ni testées → dette d'anglais qui s'accumule écran par écran
- Label vs comportement réel d'un CTA : "GET ENROLL" appelait en réalité `markDetailConsumed` (= marquer terminé) — franciser littéralement en "Commencer" aurait livré un bouton trompeur
- Catégories/filtres UI déconnectés du modèle : chips `Vidéo/Texte/Audio` alors que l'enum backend ne connaît que `TEXT`/`VIDEO` → un chip sur un type inexistant = résultat toujours vide

### Symptômes

- Incohérence FR/EN entre texte et a11y ; CTA dont le label ment ; filtre toujours vide

### Bonnes pratiques / mitigations

- Lors de la francisation d'un composant partagé, vérifier ensemble : texte visible + `accessibilityLabel` + props de surcharge (`seeAllLabel?`), et faire converger vers une **source unique** (`{label ?? 'défaut'}`) plutôt que dupliquer
- Toujours lire le **handler** avant de traduire/relabeller un CTA, pas seulement le texte
- Valider toute taxonomie d'UI (chips, filtres) contre le schéma de données réel (contracts) avant de l'implémenter
- Filet : sweep grep périodique + revue visuelle ; grep des consommateurs avant tout fix transversal
- Contexte technique : React Native — app-alexandrie (ux-cleanup-5), 28-05-2026

---
