docs(knowledge): capitalisation frontend — intégration du triage local (mai-juin 2026)

Triage et intégration des propositions frontend du buffer 95_a_capitaliser.md
(lot local RL799_V2/Vue3 + app-alexandrie/RN-Expo, mai-juin 2026).

~73 entrées intégrées sur knowledge/frontend/ + 1 nouveau fichier, dont :
- patterns/state.md : race-token partagé latest-wins (fusion 3 props), capture sync anti-race,
  event bus timestamp, clé cache composite, état dérivé = computed
- risques/state.md : 9 risques Zustand/store (fetchId reset, useRef remount, re-fetch infini
  sur [], flag optimiste écrasé, cache détail/liste stale, latch sans reset, :key index)
- patterns/navigation.md : Expo Router (tab bar, useFocusEffect, Href typé, routing pur fusionné)
- patterns/general.md : helpers temps purs, composants génériques + skeleton, fail-fast, touch target
- risques/general.md : 24 risques (sweep statique, filtre client liste paginée, hooks avant return,
  a11y VoiceOver/disabled, redirection allowlist, RangeError toISOString, section i18n...)
- design-tokens (cluster theming light/dark MD3), tests, performance, react-native, nextjs
- NOUVEAU risques/responsive.md (gating par capacité d'input + checklist régressions mobile)
- READMEs patterns/risques mis à jour

Doublons inter-fichiers évités (vérifié : aucune ancre dupliquée introduite).
Rejets (doublons 91/9/87), reciblages workflow (156/257) et bloc 32 (CLAUDE projet) non intégrés ici.
Source 95_a_capitaliser.md non purgée (purge en fin de capitalisation complète).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-06-25 15:31:53 +02:00
parent f1b783407a
commit 5f5c87296e
15 changed files with 2439 additions and 12 deletions
+4 -4
View File
@@ -8,10 +8,10 @@ Avant toute proposition frontend, identifie le fichier dont le nom et la descrip
| Fichier | Domaine | Entrées clés |
|---------|---------|--------------|
| `state.md` | State management, UI states, Zustand, listes paginées, refactor monolithe Vue | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère, refactor monolithe Vue sous-lots Go/No-Go, convention `pages/<module>/`, `styles.css` partagé non-scoped, annuaire client-side TTL |
| `state.md` | State management, UI states, Zustand, listes paginées, refactor monolithe Vue | États UI loading/empty/error, séparation server/client state, refresh idempotent, UI admin légère, refactor monolithe Vue sous-lots Go/No-Go, convention `pages/<module>/`, `styles.css` partagé non-scoped, annuaire client-side TTL, race-token partagé (latest-wins), capture synchrone before async, event bus via timestamp, clé de cache composite, loadings séparés initial/pagination, flags d'état séparés par préoccupation, dérivé = `computed`, une source deux vues lecture inerte, noyau visuel générique + variants |
| `forms.md` | Formulaires, validation, Server Actions, optimistic UI | Formulaire robuste, toggle optimiste rollback, Server Action retourne entité, AppInput Outlined Material thème dark, fusion DRY composants jumeaux par prop discriminante |
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique, factorisation page mode dynamique via `meta.mode` typé |
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes |
| `navigation.md` | Navigation, routing, Expo Router, intégrations tierces | Navigation réactive post-action async, link-out page locale canonique, factorisation page mode dynamique via `meta.mode` typé, tab bar native cachée (wiring routing pur), état UI éphémère via `useFocusEffect`, routes typées `Href`, stack indépendant par tab, routing décorrélé du rendu (builder pur) |
| `design-tokens.md` | Design tokens, typographie, spacing, Tailwind, RN StyleSheet | Tokens TypeScript Expo/RN, typography sémantique, export styles composant, grilles 2 colonnes, palette light/dark MD3 + `useThemedColors` + dual export, map sémantique slug → token, migration tokens typo formalisés |
| `nextjs.md` | Next.js App Router, embeds, ESLint | Click-to-load embeds tiers, ESLint flat config Next.js |
| `tests.md` | Tests styles React Native, smoke checks, mount + mock composable | Tests de styles sans renderer JSX, smoke checks `readFileSync`, classe CSS modifier vs texte, cleanup E2E best-effort, helpers SW purs, mount + mock composable, assertions React Email, garde-fous de non-activation feature parking Later |
| `general.md` | Focus visible, inputs date HTML5, journaux/audit logs, pages admin | Focus visible interne pour overflow clip, restyle global `<input type="date">`, UI patterns journaux d'audit, structuration pages admin (eyebrows + grille filtres + variante danger) |
| `general.md` | Focus visible, inputs date HTML5, journaux/audit logs, pages admin, composants génériques | Focus visible interne pour overflow clip, restyle global `<input type="date">`, UI patterns journaux d'audit, structuration pages admin, reset défensif pointer events, modale dédiée vs partagée, fail-fast branche unreachable `__DEV__`, extension rétrocompatible vs sibling, CTA toggle async préserver l'état, helpers fail-safe data backend, prop de raccourci, compositions sémantiques utilisées, touch target 44 minWidth+minHeight, helpers temps/date purs, avatar initiale pastel, composants génériques + états de chargement (EmptyState/Skeleton) |
@@ -184,3 +184,106 @@ it('variante primary utilise colors.primary', () => {
1. `.spec.ts` (node) : tokens, valeurs, logique pure
2. `.spec.tsx` (config séparée avec renderer) : rendu visuel, interactions
---
<a id="pattern-palette-light-dark-md3-usethemedcolors"></a>
## Pattern : Palette light/dark MD3 + hook `useThemedColors` + dual export
### Synthèse
- **Objectif** : poser un theming light/dark/system complet sur une codebase qui n'avait qu'une palette unique, sans casser les imports historiques.
- **Contexte** : React Native / Expo avec préférence utilisateur light/dark/system.
- **Quand l'utiliser** : introduction d'un 2ᵉ thème sur une base existante.
- **Quand l'éviter** : thème unique sans besoin de bascule (le hook serait du sucre sans bénéfice).
### Analyse
- **Avantages** :
- compat ascendante : `export const colors = dark` garde les imports `import { colors }` compilants
- le hook `useThemedColors` rend le reste mécanique (le composant ne code jamais le scheme en dur)
- dual export pour les styles legacy : `export const fooStyles = makeStyles(colors)` (snapshot dark) reste synchronisé avec la fonction dynamique
- **Limites / vigilance** :
- anti-pattern : palette plate sans typage par mode → force à coder le scheme en dur ou à dupliquer les styleSheets
- tokens "fixed" partagés light/dark (`primaryContainer`, `primaryFixedDim`) pour la continuité du branding ; `inverse-primary` d'un mode = `primary` de l'autre
- shadow : `#000000` OK en dark, mais en light un tinted neutre (ex. `outline` à 8 %), jamais noir pur
- validation WCAG : README documentant chaque token + ratio mesuré (texte ≥ 4.5:1, UI ≥ 3:1)
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native / Expo — app-alexandrie (ux-cleanup-8, ~95 fichiers migrés)
### Implémentation
```ts
// theme/colors.ts
const dark = { background: '#131316', primary: '#cdbdff' } as const;
const light = { background: '#fbf8fe', primary: '#4c00c9' } as const;
export const colors = dark; // compat ascendante (imports historiques)
export const colorsLight = light;
export const colorsDark = dark;
// theme/use-themed-colors.ts
export function useThemedColors() {
return useEffectiveColorScheme() === 'light' ? colorsLight : colorsDark;
}
// composant
const themed = useThemedColors();
const styles = useMemo(() => makeStyles(themed), [themed]);
function makeStyles(c: ReturnType<typeof useThemedColors>) {
return StyleSheet.create({ container: { backgroundColor: c.background } });
}
// styles exportés (compat tests) — source unique
export const fooStyles = makeStyles(colors); // snapshot dark
```
> Les pièges de migration associés (composants tiers, `<Text>` sans `color`, scheme effectif, ThemeProvider) sont documentés dans `risques/design-tokens.md#risque-theming-light-dark-pieges-caches`.
---
<a id="pattern-map-semantique-slug-token-icone"></a>
## Pattern : Map sémantique slug → token + icône
### Synthèse
- **Objectif** : différencier visuellement des entités d'un même type (forums, packs, badges) via une map slug → token résolue au runtime, sans hex en dur.
- **Contexte** : entités multiples d'un même type nécessitant un code couleur/icône cohérent light/dark.
- **Quand l'utiliser** : ≥ 2 entités à différencier visuellement.
- **Quand l'éviter** : entité unique ou différenciation purement textuelle.
### Analyse
- **Structure** : `getEntityBadgeTokens(slug) → { bgColorKey, fgColorKey, icon }`, le caller résout via `useThemedColors` (suit le thème).
- **Règles** : (1) toujours des `ColorKey` de `useThemedColors`, jamais de hex ; (2) toujours un fallback neutre (un slug futur ne casse pas l'UI) ; (3) icônes outline légères ; (4) tests env node sur map + fallback + distinctness.
- **Avantages** : vs if/else inline → 0 duplication ; vs hex map → suit le thème ; vs composant → reste un helper pur testable env node.
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (`forum-badge.ts`, ux-cleanup-15)
---
<a id="pattern-migration-tokens-typo-formalises"></a>
## Pattern : Migration tokens typo formalisés
### Synthèse
- **Objectif** : formaliser et migrer les valeurs typographiques en dur (`fontSize`, `fontWeight`, `fontFamily`) vers des tokens, comme le sweep tokens couleur.
- **Contexte** : codebase avec ~100 occurrences de valeurs typo en dur sur des dizaines de fichiers.
- **Quand l'utiliser** : volumétrie significative de valeurs typo dispersées.
- **Quand l'éviter** : poignée d'usages localisés.
### Analyse
- **Méthodologie en 4 étapes** : (1) audit grep + rapport stats ; (2) étendre `typography.ts` SANS renommer les tokens existants (rétro-compat) — ajouter des aliases sémantiques ; (3) migration mécanique (perl/sed) + imports, typecheck après chaque batch ; (4) doc README (table sémantique + anti-patterns).
- **Cas dégénérés à laisser en dur** : `fontSize: 48/64` sur un emoji/hero = taille d'icône, pas de la typo → ne pas créer un token `iconLarge` (documenter l'exception).
- **Anti-patterns** : codemod auto sans review visuel (un `15 → 14` casse un layout) ; renommer un token existant (`fontSize.tab` utilisé partout → casse 50+ fichiers) ; créer `useThemedTypography()` si la typo ne varie pas light/dark (sucre sans bénéfice, garder l'import statique).
### Validation
- Validé le : 30-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-12, 110 occurrences / 33 fichiers, 0 régression)
+368
View File
@@ -375,3 +375,371 @@ Sur un écran qui mélange actions constructives et destructives (ex : saisie in
- Hint général qui répète ce que l'empty state dit déjà → 1 message, 1 niveau d'info
- Confondre "titre de page" (l'onglet actif suffit souvent) et "structure de section" (eyebrows)
- Action destructive en variant primary or → danger explicite
---
<a id="pattern-reset-defensif-pointer-events"></a>
## Pattern : Reset défensif du state pointer events au `pointerdown`
### Synthèse
- **Objectif** : éviter qu'un composant swipe/drag reste figé dans un état corrompu (`dragging = true`) quand un `pointerup` est "volé" par une modale ou une navigation post-action.
- **Contexte** : composant gérant `@pointerdown / @pointermove / @pointerup` qui émet une action ouvrant une modale au `pointerup` (la modale prend le focus → le pointer quitte la card → `pointerup` ne remonte jamais).
- **Quand l'utiliser** : tout composant à geste pointer émettant une action qui change de surface (modale, nav).
- **Quand l'éviter** : geste pur sans effet de bord de navigation.
### Analyse
- **Avantages** : robuste face aux modales/navigations/focus switches ; un seul point d'entrée à protéger ; pas de logique conditionnelle complexe.
- **Limites / vigilance** : ne couvre pas le démontage du composant pendant le drag (géré par le cycle de vie Vue). Pattern complémentaire : flag anti-click synthétique pour ne pas déclencher un `@click` natif après le geste.
### Validation
- Validé le : 05-05-2026
- Contexte technique : Vue 3 / pointer events — RL799 (`ProfaneListCard`)
### Implémentation
```typescript
const handlePointerDown = (event: PointerEvent) => {
if (event.pointerType === 'mouse' && event.button !== 0) return;
resetSwipe(); // défense en profondeur : un pointerup précédent a pu être "volé"
activePointerId.value = event.pointerId;
// …
};
// flag anti-click synthétique : posé en pointerup, consommé dans @click
const handleClick = (): void => {
if (swipeJustHandled.value) { swipeJustHandled.value = false; return; }
emit('select');
};
```
---
<a id="pattern-modale-dediee-vs-partagee"></a>
## Pattern : Modale dédiée vs partagée — décider à la 2ᵉ contrainte
### Synthèse
- **Objectif** : décider en amont s'il faut étendre une modale partagée (`v-if`/`forcedType`/`mode`) ou créer une modale dédiée quand un type métier se décline en variantes aux contraintes d'édition différentes.
- **Contexte** : type métier (Document) décliné en variantes (planche, helper) avec champs obligatoires/validations/types autorisés divergents.
- **Quand l'utiliser** : dès qu'une variante diverge d'un cas standard.
- **Quand l'éviter** : variantes ne différant que par le wording (préférer alors la factorisation par mode).
### Analyse
- **Critère de décision** : modale **dédiée dès que ≥ 2 contraintes divergent** (type figé vs éditable, champ exclusif, champs masqués, validation conditionnelle). 1 seule contrainte → prop conditionnelle sur la modale partagée.
- **Avantages dédiée** : impossible de corrompre la catégorie (type figé en TS, jamais dans le patch) ; pas d'explosion combinatoire de `v-if`.
- **Limites / vigilance** :
- une modale partagée avec select à fallback peut **silencieusement** basculer une ressource d'une catégorie à l'autre (cas vécu : `TYPE_OPTIONS` sans `'helpers'` → fallback `'planches'` au save)
- duplication ~80 % (a11y, focus trap, layout) : extraire un composable partagé pour le focus trap si identique, classes BEM dédiées sinon
- **Détection en revue** : `grep "forcedType\|forcedMode\|isVariantX"` dans une modale partagée — si > 3 occurrences, refactorer en modale dédiée.
### Validation
- Validé le : 06-05-2026
- Contexte technique : Vue 3 — RL799 (`DocumentEditModal``HelperEditModal`/`HelperUploadModal`)
---
<a id="pattern-fail-fast-branche-unreachable-dev"></a>
## Pattern : Fail-fast sur branche unreachable en `__DEV__`
### Synthèse
- **Objectif** : éviter qu'un `return;` no-op silencieux sur une branche unreachable (garantie par un early-return en amont) ne devienne un bug fantôme si un refactor casse l'invariant.
- **Contexte** : `switch`/`match` dont un case est inatteignable par construction (ex. CTA paywall jamais atteint car `SUBSCRIPTION_REQUIRED` fait un early-return avant le rendu).
- **Quand l'utiliser** : toute branche unreachable par invariant.
- **Quand l'éviter** : branche réellement atteignable (gérer le cas normalement).
### Analyse
- **Avantages** : tout refactor cassant l'invariant fait crasher l'app en dev (feedback immédiat) ; la prod reste safe (`return;` no-op) ; le commentaire documente l'invariant et la story.
- **Limites / vigilance** : anti-pattern `// no-op pour l'instant (cas marginal)` sans throw — le cas marginal devient un bug silencieux le jour où il se réalise.
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-7, CTA "Débloquer ce pack")
### Implémentation
```typescript
switch (model.action) {
case 'paywall':
if (__DEV__) throw new Error('[story-X] CTA paywall atteint — invariant cassé ?');
return; // prod : no-op safe
// …
}
```
---
<a id="pattern-extension-retrocompatible-composant"></a>
## Pattern : Extension rétrocompatible d'un composant vs création d'un sibling
### Synthèse
- **Objectif** : étendre un composant existant via une prop optionnelle plutôt que créer un sibling quasi-dupliqué, quand le composant convient à ~80 % d'un nouveau use-case.
- **Contexte** : composant UI (ex. FAB circulaire icon-only) à étendre pour un nouveau cas (porter un label).
- **Quand l'utiliser** : quand 4 critères tiennent (voir Analyse).
- **Quand l'éviter** : logique métier divergente, ou props mutuellement exclusives qui s'accumulent (signal de 2 composants déguisés).
### Analyse
- **Critères pour étendre** : (1) la partie variante (label, icon, taille) ≠ la partie invariante (positionnement, ombre, animation, a11y) ; (2) l'invariant DOIT rester partagé (un seul fichier à toucher quand la nav change) ; (3) la prop d'extension est optionnelle avec un défaut préservant le comportement actuel ; (4) pas de logique métier divergente.
- **Avantages** : 1 fichier, 1 batch de tests, call-sites existants intacts (0 régression).
- **Limites / vigilance** : si le rendu fait `if (mode === 'X') … else …` plus de 2-3 fois → deux composants déguisés, séparer. Anti-pattern : `Fab.tsx` + `FabExtended.tsx` avec ~80 % de styles dupliqués (un bug de positionnement se corrige dans un seul fichier et ressurgit sur l'autre écran).
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (FAB partagé Messages/Contenu/Forum)
### Implémentation
```typescript
type FabProps = {
icon: MaterialIconName;
onPress: () => void;
accessibilityLabel: string;
label?: string; // optionnel → bascule en mode extended si présent
bottomOffset?: number;
};
const isExtended = typeof label === 'string' && label.length > 0;
// call-site existant inchangé : <Fab icon="add" onPress={...} accessibilityLabel="Nouveau message" />
```
---
<a id="pattern-cta-toggle-async-preserver-etat"></a>
## Pattern : CTA toggle async — préserver l'état pendant le loading
### Synthèse
- **Objectif** : garder visible l'indicateur de l'état pré-toggle (ex. icône check "Suivi") pendant le chargement async, pour ne pas perdre le repère visuel.
- **Contexte** : bouton toggle d'état (Suivre/Suivi, like/unlike) avec action async.
- **Quand l'utiliser** : tout CTA toggle dont l'action est asynchrone.
- **Quand l'éviter** : action synchrone instantanée.
### Analyse
- **Règle** : ne pas remplacer la **totalité** de l'état visible par un spinner — garder au moins un indicateur de l'état pré-toggle pour la durée du loading.
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (`directory-follow-button.tsx`, ux-cleanup-9)
### Implémentation
```tsx
// ✅ l'icône check reste visible, hors du ternaire isLoading
<View style={containerStyle}>
{isFollowing && <Icon name="check" />}
{isLoading ? <Spinner /> : <Label>{label}</Label>}
</View>
```
---
<a id="pattern-helpers-fail-safe-data-backend"></a>
## Pattern : Helpers fail-safe face aux bugs data backend
### Synthèse
- **Objectif** : qu'un helper de transformation donnée → affichage traite les cas dégénérés (date future, 0, NaN, format invalide) avec un comportement neutre, sans amplifier le bug visuellement.
- **Contexte** : helpers de formatage/dérivation consommant une donnée backend potentiellement bugguée.
- **Quand l'utiliser** : tout helper qui mappe une donnée externe vers de l'UI.
- **Quand l'éviter** : donnée 100 % maîtrisée localement.
### Analyse
- **Anti-patterns** : `createdAt` futur → "tout récent" → tous les contenus affichent "Nouveau" (cascade) ; `duration === 0` → "moins d'1 min" trompeur.
- **Règle** : retour neutre (`false` / `''` / `'—'`) + `console.warn` en `__DEV__` pour remonter la régression API sans polluer la prod.
### Validation
- Validé le : 30-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-15, `isContentRecent`/`formatDuration`)
### Implémentation
```ts
export function isContentRecent(createdAt, now = new Date()) {
if (!createdAt) return false;
const created = parse(createdAt);
if (Number.isNaN(created.getTime())) return false;
const ageMs = now.getTime() - created.getTime();
if (ageMs < 0) {
if (__DEV__) console.warn('[isContentRecent] createdAt futur:', createdAt);
return false; // neutre, n'amplifie pas le bug
}
return ageMs < THRESHOLD;
}
```
---
<a id="pattern-props-raccourci-composant-generique"></a>
## Pattern : Prop de raccourci pour composant générique
### Synthèse
- **Objectif** : éviter qu'un cas d'usage dominant ne force chaque callsite à un wrapper répétitif, en ajoutant une prop de raccourci.
- **Contexte** : composant générique (`<EmptyState>`) appelé partout avec le même wrapper (`icon={<ThemedText style={{fontSize:48}}>📭</ThemedText>}`).
- **Quand l'utiliser** : wrapper identique répété sur plusieurs callsites.
- **Quand l'éviter** : variété réelle de wrappers (garder la prop générique seule).
### Analyse
- **Règle** : la prop générique (`icon?: ReactNode`) reste prioritaire pour les cas custom ; la prop raccourci (`emoji?: string`) injecte un wrapper standard.
- **Garde-fou** : 1-2 props de raccourci max. Si le cas devient complexe, exposer un sous-composant (`<EmptyState.Emoji>`) ou refactorer.
### Validation
- Validé le : 30-05-2026
- Contexte technique : React Native — app-alexandrie (`EmptyState`, ux-cleanup-15)
---
<a id="pattern-compositions-semantiques-utilisees"></a>
## Pattern : Compositions sémantiques — la migration doit les utiliser
### Synthèse
- **Objectif** : éviter de définir des compositions sémantiques (`typography.body`, `colors.actionPrimary`) que la migration n'utilise pas, laissant les tokens atomiques s'imposer et le sémantique devenir du code mort.
- **Contexte** : migration vers un design system avec compositions + tokens atomiques.
- **Quand l'utiliser** : toute migration introduisant des compositions sémantiques.
### Analyse
- **Règle** : la migration trie — composition existante pour le besoin → l'utiliser ; sinon token atomique + signaler en review qu'une composition pourrait être créée.
- **Ratio cible** : > 50 % d'usages de compositions. Si < 30 %, soit elles sont mal nommées, soit à supprimer (sur-engineering).
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-12/13)
---
<a id="pattern-touch-target-44-minwidth-minheight"></a>
## Pattern : Touch target 44pt — imposer `minWidth` ET `minHeight`
### Synthèse
- **Objectif** : garantir une zone tap 44×44 (HIG iOS), pas 44×N — `minHeight: 44` seul laisse la largeur < 44 sur un label court.
- **Contexte** : composants génériques (`<EmptyState ctaButton>`, `<Button>`) où le caller fournit le label.
- **Quand l'utiliser** : tout CTA dont le label peut être court ("OK", "Annuler", chip).
- **Quand l'éviter** : zone tap déjà large par construction.
### Analyse
- **Cas d'oubli typique** : CTA court → label étroit + padding insuffisant → zone < 44 en width malgré `minHeight: 44` (miss-tap fréquent sur iPhone SE / gants).
- **Trade-off** : `minWidth: 44` est un floor invisible, rarement contraignant pour des labels longs.
### Validation
- Validé le : 31-05-2026
- Contexte technique : React Native — app-alexandrie (ux-cleanup-14)
### Implémentation
```tsx
ctaButton: { minHeight: 44, minWidth: 44, justifyContent: 'center', alignItems: 'center' }
```
---
<a id="pattern-helpers-temps-date-purs"></a>
## Pattern : Helpers temps/date purs centralisés (formatage relatif, groupement)
### Synthèse
- **Objectif** : centraliser le formatage temporel (date relative, groupement par blocs) dans des helpers purs testables env node, pour éviter la divergence de wording entre écrans et entre projets.
- **Contexte** : écrans multiples affichant des dates (Notifications, Messages, Feed) ou un timeline de messages séquencés (chat, commentaires).
- **Quand l'utiliser** : dès que > 1 écran formate des dates relatives, ou qu'une liste temporelle sature de timestamps.
- **Quand l'éviter** : un seul affichage de date trivial.
### Analyse
- **Avantages** : helper pur, `now` injectable → tests déterministes ; 0 dépendance lib (Intl natif, -50 Ko vs date-fns/dayjs) ; le 1er dev d'une story partagée écrit le helper, les suivants consomment.
- **Décisions structurantes** :
- bornes "hier"/jours semaine = **jours calendaires** (`startOfDay(now) startOfDay(date)`), pas deltas en ms (sinon 24h pile ≠ perception humaine)
- pas de "dans N min" pour une date future (clock skew) → retomber sur "à l'instant"
- tolérance gracieuse : `string` ISO invalide → fallback, jamais de crash
- groupement chat : séparateur centré quand delta > 5 min OU changement de jour ; 1er message toujours précédé d'un séparateur ; la fonction attend l'ordre **croissant**, le caller reverse si liste `inverted` (ne pas coupler la logique à l'index FlatList)
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (`relative-time.ts`, `timestamp-grouping.ts`, ux-cleanup-10/11)
### API
```ts
formatRelativeTime(input: Date | string, now: Date = new Date()): string
// → "à l'instant" / "il y a N min" / "il y a N h" / "hier" / "lun" / "12 mars" / "12 mars 2025"
groupMessagesByTime<T>(messages: T[], now?: Date): GroupedItem<T>[]
// GroupedItem = { kind: 'timestamp' | 'message'; ... }
```
---
<a id="pattern-avatar-initiale-pastel-deterministe"></a>
## Pattern : Avatar circulaire avec initiale pastel déterministe
### Synthèse
- **Objectif** : différencier visuellement des utilisateurs sans photo (early adopters, B2B interne) via une initiale colorée déterministe.
- **Contexte** : listes d'utilisateurs où les photos de profil sont rares.
- **Quand l'utiliser** : ~quelques centaines d'utilisateurs, photos optionnelles.
- **Quand l'éviter** : photos systématiques, ou besoin d'unicité forte (collisions visuelles assumées ici).
### Analyse
- **Implémentation** : palette ~7 couleurs mappées sur tokens design system + 1 fallback grisé ; hash trivial (somme charCodes du nom trimmé modulo `palette.length - 1`) ; 1ère lettre majuscule (préserver accents) ; `null`/vide → fallback "?" grisé.
- **Avantages** : 0 dépendance, testable env node, suit light/dark, déterministe (un nom = toujours la même couleur).
- **Anti-patterns** : couleur aléatoire au mount (casse la reconnaissance) ; hash sur UUID (opaque, change au renommage — hasher le nom affiché) ; lib crypto pour le hash (surdimensionné).
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native — app-alexandrie (`avatar-initial.tsx`, ux-cleanup-11)
### API
```ts
<AvatarInitial name={"charlie" | null} size={40} />
```
---
<a id="pattern-composants-generiques-etats-chargement"></a>
## Pattern : Composants UI génériques + états de chargement (EmptyState, Skeleton)
### Synthèse
- **Objectif** : factoriser le layout des écrans liste (vide / chargement) dans des composants génériques tout en laissant le wording au caller pour respecter la voix produit.
- **Contexte** : N écrans liste partageant un layout d'état vide et un skeleton de premier chargement.
- **Quand l'utiliser** : ≥ 2 écrans liste avec états vide/chargement similaires.
- **Quand l'éviter** : écran unique sans réutilisation.
### Analyse
- **`<EmptyState>`** : API `{ icon?, emoji?, title, description?, cta? }`. Le caller fournit le wording (calme, bienveillant, non alarmiste) et un CTA **contextuel uniquement quand utile** (pas de "Voir tout" sur une liste vide qui ne pointe nulle part).
- **`<SkeletonScreen>`** : API `{ variant: 'card'|'list-item'|'text-line'|'header', count }`, shimmer animé (une seule shared value pour synchroniser les sous-blocs).
- **Règle CRITIQUE des 3 états de chargement** :
- **premier chargement** (`isLoading && items.length === 0`) → `<SkeletonScreen>`
- **refresh** (`isLoading && items.length > 0`) → `RefreshControl` natif
- **pagination** (`isLoadingMore`) → `ListFooterComponent` (ActivityIndicator)
- **Anti-patterns** : skeleton/spinner plein écran pendant un refresh (l'utilisateur perd ses items, clignotement) ; skeleton statique sans animation (signal "ça travaille" perdu) ; skeleton avec wording "Chargement…" (double signal redondant) ; wording technique ("Liste de 0 éléments").
- **Tests env node** : `react-native-reanimated` doit être mocké (hooks no-op, `Animated.View` → string).
### Validation
- Validé le : 31-05-2026
- Contexte technique : React Native — app-alexandrie (`EmptyState.tsx`, `SkeletonScreen.tsx`, ux-cleanup-13/15)
+215
View File
@@ -277,3 +277,218 @@ const handleSubmit = async () => {
- Détecter via query string (pollue URL, manipulable)
---
<a id="pattern-tab-bar-native-cachee-minimum-vital"></a>
## Pattern : Tab bar native cachée → ne garder que le wiring routing
### Synthèse
- **Objectif** : quand la tab bar native d'Expo Router est désactivée (`tabBarStyle.display: 'none'`) au profit d'une nav persistante custom, ne garder dans `(tabs)/_layout.tsx` que le minimum vital de déclaration de routes.
- **Contexte** : Expo Router avec `<PersistentTabBar />` custom remplaçant la tab bar native.
- **Quand l'utiliser** : dès que le visuel des tabs vit dans un composant custom et non dans `<Tabs>`.
- **Quand l'éviter** : tab bar native conservée (le `label`/icônes restent utiles).
### Analyse
- **Avantages** :
- les `<Tabs.Screen>` ne servent plus qu'à déclarer les routes physiques du dossier `(tabs)/` (sinon 404 sur certaines plateformes)
- suppression du dead-code visuel (`label`, `iconActive`, `tabBarBadge`…) et de ses dépendances inutiles (icons, stores de badges)
- l'intention "wiring routing pur, pas du visuel" devient explicite (`TAB_ROUTE_NAMES`)
- **Limites / vigilance** :
- un `TAB_CONFIG` riche "au cas où on réactiverait la tab bar native" masque l'intention et crée des incohérences silencieuses (ordre du config ≠ ordre visuel réel)
- **règle complémentaire** : tout hook/effet de boot alimentant un état consommé par la nav persistante (ex. `refreshUnreadCount` pour la cloche TopBar) appartient au `_layout.tsx` **racine**, pas au layout `(tabs)/` — sinon il ne s'exécute que sur les routes `(tabs)/*` et le badge ne s'hydrate pas ailleurs
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.1)
### Implémentation
```tsx
// Les <Tabs.Screen> déclarent uniquement les routes physiques.
// La nav visuelle vit dans <PersistentTabBar />.
const TAB_ROUTE_NAMES = ['explore', 'community', 'library', 'profile',
'notifications', 'achievements', 'settings'] as const;
return (
<Tabs screenOptions={{ headerShown: false, tabBarStyle: { display: 'none' } }}>
{TAB_ROUTE_NAMES.map((name) => <Tabs.Screen key={name} name={name} />)}
</Tabs>
);
```
---
<a id="pattern-etat-ui-ephemere-usefocuseffect"></a>
## Pattern : État UI éphémère lié au focus écran via `useFocusEffect` (Expo Router)
### Synthèse
- **Objectif** : nettoyer un état UI éphémère qu'un écran pose dans un composant global persistant (TopBar, BottomBar, FAB contextuel) au **blur**, pas au démontage.
- **Contexte** : Expo Router, où un écran enfant n'est pas toujours démonté à la navigation arrière (gardé en arrière-plan, surtout en web).
- **Quand l'utiliser** : dès qu'un écran pose un état consommé par un composant global persistant.
- **Quand l'éviter** : état strictement local à l'écran, sans surface globale.
### Analyse
- **Avantages** : le cleanup se déclenche à la perte de focus, garanti même si l'écran reste monté.
- **Limites / vigilance** :
- `useEffect` + cleanup au démontage laisse un titre/état fantôme sur le parent (ex. titre "Bob" qui reste après retour sur la liste Messages)
- bug souvent invisible sur natif (démontage plus systématique), visible en web → smoke multi-plateforme indispensable
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.2, `useTopBarStore`)
### Implémentation
```ts
// ❌ titre fantôme : cleanup au unmount, pas garanti au blur
useEffect(() => {
if (!peerHandle) return;
useTopBarStore.getState().setTitle(peerHandle);
return () => useTopBarStore.getState().setTitle(null);
}, [peerHandle]);
// ✅ cleanup au blur
import { useFocusEffect } from 'expo-router';
useFocusEffect(useCallback(() => {
useTopBarStore.getState().setTitle(peerHandle);
return () => useTopBarStore.getState().setTitle(null);
}, [peerHandle]));
```
---
<a id="pattern-routes-expo-router-typees-href"></a>
## Pattern : Routes Expo Router typées en `Href` au lieu de `as never`
### Synthèse
- **Objectif** : supprimer le cast `router.push('/route' as never)` répété à chaque callsite au profit de constantes `Href` centralisées.
- **Contexte** : Expo Router sans typed routes générés, qui pousse à caster en `never`.
- **Quand l'utiliser** : dès qu'une route est poussée depuis plusieurs callsites.
- **Quand l'éviter** : typed routes activés (le type généré suffit).
### Analyse
- **Avantages** :
- 1 seul cast par route au lieu d'un par callsite
- migration future vers `typedRoutes` triviale (remplacer `as Href` par le type généré sans toucher aux callsites)
- auto-complétion sur les constantes plutôt que sur des string literals
- **Limites / vigilance** : ne pas confondre avec la navigation réactive — les routes restent poussées dans des handlers, pas dans un `useEffect` (voir `risques/navigation.md`).
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.5 L4)
### Implémentation
```typescript
import { useRouter, type Href } from 'expo-router';
const CHECKOUT_HREF = '/subscription/checkout' as Href;
const PACKS_HREF = '/packs' as Href;
// callsites :
onPress={() => router.push(CHECKOUT_HREF)}
onPress={() => router.push(PACKS_HREF)}
```
---
<a id="pattern-stack-independant-par-tab-derniere-route"></a>
## Pattern : Stack indépendant par tab via mémorisation de la dernière route
### Synthèse
- **Objectif** : qu'un tap sur un onglet re-atterrisse sur sa dernière route visitée (pas sur la racine) quand la `Tabs` native est désactivée + BottomBar custom.
- **Contexte** : Expo Router, `Tabs` native off, `<PersistentTabBar />` custom.
- **Quand l'utiliser** : quick win avant un refactor archi `(app)/` complet (option a).
- **Quand l'éviter** : besoin d'un back-stack complet par tab (préférer alors un `Stack` natif par tab).
### Analyse
- **Avantages** : low-risk, sans refactor archi ; mémorise la dernière route par tab dans un store.
- **Limites / vigilance** :
- **garde-fou clé** : exclure les routes auth/onboarding du tracking, sinon le tap sur un onglet ramène vers un onboarding terminé → écran vide/crash
- limites assumées en v2 : seulement la **dernière** route (pas de back-stack), pas de persistance disque (perdu au cold start)
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (IA-v2.8 AC5)
### Implémentation
```typescript
type RememberableTab = 'explore' | 'community' | 'messages' | 'library';
const EXCLUDED = ['/(auth)', '/login', '/onboarding'];
export const useTabHistoryStore = create<{
lastRoutes: Partial<Record<RememberableTab, string>>;
setLastRoute: (tab: RememberableTab, route: string) => void;
}>((set) => ({
lastRoutes: {},
setLastRoute: (tab, route) => {
if (EXCLUDED.some((p) => route.startsWith(p))) return; // garde-fou
set((s) => ({ lastRoutes: { ...s.lastRoutes, [tab]: route } }));
},
}));
// _layout.tsx racine : observe pathname → pose la dernière route par tab
// BottomBar : au tap d'un tab non-actif, router.push(lastRoutes[tab] ?? tab.href)
```
---
<a id="pattern-routing-decorrele-rendu-fn-pure"></a>
## Pattern : Routing décorrélé du rendu (builder pur testable)
### Synthèse
- **Objectif** : extraire le calcul de la route cible (`(payload) => Href | null`) du composant qui navigue, pour le rendre pur et testable en env node.
- **Contexte** : navigation conditionnelle (route dépendant du payload, du type d'événement, de l'état utilisateur) ; Expo Router et autres routers.
- **Quand l'utiliser** : dès qu'une route est templatée inline ou qu'un `switch` de routing vit dans un composant.
- **Quand l'éviter** : navigation triviale vers une route fixe.
### Analyse
- **Avantages** :
- testable env node : 0 mock RN, 0 `NavigationContainer`, juste `expect(fn(...)).toBe('/path/x')`
- cas edge centralisés : un seul `switch`, un seul fallback `null`, un seul format d'URL
- type-safe si typed routes activés (le `pathname` est vérifié contre l'arbre réel)
- **pas de slug hardcodé** : si la source ne connaît pas le slug, on omet le param plutôt que de mentir — l'écran cible retombe sur sa résolution serveur
- **Limites / vigilance** :
- anti-pattern : `router.push(\`/community/thread/${id}?forumSlug=general\`)` quand `'general'` n'a aucune chance d'être le bon slug → fonctionne par accident (fallback serveur), illisible
- quand le caller a l'objet complet, exposer une **2ᵉ** fonction qui prend l'objet et délègue à la primitive (évite de dupliquer le `switch`)
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native / Expo Router — app-alexandrie (ux-cleanup-4 / ux-cleanup-9 / ux-cleanup-10 ; `feed-navigation.ts`, `notifications-navigation.ts`)
### Implémentation
```ts
// builder pur — forme objet { pathname, params }
export function buildThreadRoute(item: { id: string; forum?: { slug: string } | null }) {
const slug = item.forum?.slug;
return {
pathname: '/community/thread/[threadId]',
params: slug ? { threadId: item.id, forumSlug: slug } : { threadId: item.id },
} as const;
}
// primitive (bouts un par un) + dérivée (objet complet) coexistent
export function resolveNotificationRoute(type, targetId, commentId): string | null { /* switch */ }
export function routeForNotification(n: NotificationItem): string | null {
return resolveNotificationRoute(n.targetType, n.targetId, n.commentId);
}
// composant : délègue, ne décide pas
const route = resolveNotificationRoute(notif.targetType, notif.targetId, notif.commentId);
if (route) router.push(route);
```
---
+395
View File
@@ -475,3 +475,398 @@ const useEntityDirectory = (options: { ttlMs?: number } = {}) => {
return { directory, lastFetchedAt, loadDirectory, refresh };
};
```
---
<a id="pattern-race-token-partage-latest-wins"></a>
## Pattern : Race-token partagé (latest-wins) dans un store paginé
### Synthèse
- **Objectif** : garantir qu'un store paginé applique toujours le résultat de la dernière intention utilisateur, même si les réponses réseau reviennent dans le désordre.
- **Contexte** : store de domaine (Zustand, Pinia…) où plusieurs actions mutent la même liste (`fetchFeed`, `refresh`, `loadMore`) et peuvent être déclenchées en parallèle (changement de filtre pendant un load, spam de chips).
- **Quand l'utiliser** : dès qu'un changement de filtre/onglet peut survenir pendant un chargement déjà en vol.
- **Quand l'éviter** : action unique sans concurrence possible, ou actions coûteuses où il vaut mieux annuler les requêtes stale (voir vigilance).
### Analyse
- **Avantages** :
- latest-wins : la dernière intention gagne quel que soit l'ordre de résolution réseau
- un seul compteur partagé protège toutes les actions de la liste, pas seulement `fetchFeed`
- propre pour tests + HMR si le compteur vit dans la closure du `create` (par-instance)
- **Limites / vigilance** :
- un guard `if (get().isLoading) return;` sur `fetchFeed` est un **anti-pattern** : il drop silencieusement un changement de filtre survenu pendant le load initial
- race-token appliqué **uniquement** à `fetchFeed` est insuffisant : un `loadMore` in-flight peut résoudre après un `fetchFeed` plus récent et concaténer des items de l'ancien filtre
- N taps → N requêtes réseau (acceptable pour chips/toggles ; pour actions coûteuses, préférer `AbortController` côté http-client)
- `reset()` doit remettre le compteur à 0 (sinon pollution entre tests / après HMR)
### Validation
- Validé le : 20-05-2026
- Contexte technique : React Native / Expo / Zustand — app-alexandrie (stories 11.2 / 11.3)
### Implémentation
```typescript
export const useFeedStore = create((set, get) => {
let lastFetchId = 0; // partagé entre toutes les actions, vit dans la closure
return {
async fetchFeed(token, opts) {
const myId = ++lastFetchId;
set({ isLoading: true });
try {
const data = await service.getFeed(token, opts);
if (myId !== lastFetchId) return; // stale → drop
set({ items: data.items, isLoading: false });
} catch (err) {
if (myId !== lastFetchId) return;
set({ error: String(err), isLoading: false });
}
},
async refresh(token, opts) {
if (get().isRefreshing) return;
const myId = ++lastFetchId; // même compteur
// … idem, applique seulement si myId === lastFetchId
},
async loadMore(token, opts) {
const { hasMore, nextCursor, isLoadingMore } = get();
if (!hasMore || !nextCursor || isLoadingMore) return;
const myId = ++lastFetchId; // même compteur
// … append uniquement si myId === lastFetchId (sinon stale → drop)
},
reset: () => { lastFetchId = 0; set({ ...initialState }); },
};
});
```
### Documenter le design par un test (pas par un commentaire)
L'absence de guard `isLoading` ressemble à un bug en review. Le test EST la spec : il faut un test qui démontre le latest-wins, sinon la prochaine revue interprétera (à tort) la suppression du guard comme une régression.
```typescript
it('3 fetchFeed concurrents résolus dans le désordre : seul le dernier applique', async () => {
// lancer p1, p2, p3 avant qu'aucun ne résolve
// résoudre dans l'ordre p2 → p1 → p3
// assert : items reflètent uniquement p3
});
```
### Checklist
- [ ] Compteur `lastFetchId` partagé entre `fetchFeed` / `refresh` / `loadMore`
- [ ] Chaque action drop son résultat si `myId !== lastFetchId`
- [ ] `reset()` remet le compteur à 0
- [ ] Test "réponses dans le désordre → dernier gagne"
- [ ] Test "loadMore in-flight pendant fetchFeed → loadMore drop"
---
<a id="pattern-capture-synchrone-before-async-zustand"></a>
## Pattern : Capture synchrone "before" dans une action async (anti race latch)
### Synthèse
- **Objectif** : pour une transition monotone (latch) calculée à partir de l'état précédent, figer l'état observé **avant** le `await` pour éviter qu'un appel concurrent ne corrompe le calcul.
- **Contexte** : action async d'un store qui dérive un nouvel état de l'ancien (`previousIsActive`, latch d'abonnement…) et qui peut être appelée en parallèle.
- **Quand l'utiliser** : transitions monotones / latch lus depuis `get()` puis recombinés après `await`.
- **Quand l'éviter** : action sans dépendance à l'état précédent, ou action non-monotone (préférer alors le race-token latest-wins).
### Analyse
- **Avantages** :
- le calcul observe un snapshot cohérent, immunisé contre une mutation concurrente
- complémentaire du race-token (qui résout les actions non-monotones)
- **Limites / vigilance** :
- lire `get()` **après** le `await` expose à un état déjà muté par un second appel
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (`entitlements.store.ts`, fix H1 IA-v2.5)
### Implémentation
```typescript
// ❌ MAUVAIS — état lu APRÈS await, peut être muté par un appel concurrent
fetchEntitlements: async (token) => {
set({ isLoading: true });
const data = await service.getMe(token);
const wasActive = get().subscription?.isActive ?? false; // lu trop tard
set({ subscription: data.subscription, previousIsActive: wasActive ? true : null });
}
// ✅ BON — capture synchrone "before" avant l'await
fetchEntitlements: async (token) => {
const before = get();
const wasActive = before.subscription?.isActive ?? false;
const wasLatched = before.previousIsActive === true;
set({ isLoading: true });
const data = await service.getMe(token);
const isNowActive = data.subscription.isActive;
const previousIsActive = wasLatched || wasActive || isNowActive ? true : null;
set({ subscription: data.subscription, previousIsActive });
}
```
**Test associé** : `Promise.all([store.action(), store.action()])` doit produire le même état final que deux appels séquentiels.
---
<a id="pattern-event-bus-zustand-timestamp"></a>
## Pattern : Event bus via timestamp pour signaux UI inter-composants
### Synthèse
- **Objectif** : envoyer un signal d'un composant à un autre non-parent (ex. BottomBar → écran de l'onglet actif "rafraîchis-toi") sans prop drilling, sans EventEmitter à nettoyer, sans Context qui re-render tout le sous-arbre.
- **Contexte** : store minimal posant un `timestamp` par cible ; un hook consommateur déclenche un callback au changement de timestamp.
- **Quand l'utiliser** : signal fire-and-forget ponctuel entre composants découplés.
- **Quand l'éviter** : flux de données continu (préférer un state dérivé) ou parent-enfant direct (props/events suffisent).
### Analyse
- **Avantages** :
- le timestamp seul déclenche le `useEffect` (pas un state data → pas de boucle)
- le ref vivant sur le callback dispense l'appelant de `useCallback`
- testable comme un store classique (`setState`, assertions sur la map)
- **Limites / vigilance** :
- ❌ booléen + reset : race entre consommateurs, reset dur à placer
- ❌ EventEmitter Node-style : pas de garantie de re-render, cleanup à gérer
- ❌ Context React : re-render tout le sous-arbre à chaque tap
### Validation
- Validé le : 27-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (IA-v2.8 AC1)
### Implémentation
```typescript
type RefreshableTab = 'explore' | 'community' | 'messages' | 'library';
export const useTabActionStore = create<{
refreshTimestamps: Partial<Record<RefreshableTab, number>>;
requestRefresh: (tab: RefreshableTab) => void;
}>((set) => ({
refreshTimestamps: {},
requestRefresh: (tab) =>
set((s) => ({ refreshTimestamps: { ...s.refreshTimestamps, [tab]: Date.now() } })),
}));
// émetteur — fire & forget
useTabActionStore.getState().requestRefresh('explore');
// consommateur — hook réutilisable
export function useTabRefresh(tab: RefreshableTab, onRefresh: () => void) {
const timestamp = useTabActionStore((s) => s.refreshTimestamps[tab]);
const lastSeenRef = useRef<number | undefined>(undefined);
const onRefreshRef = useRef(onRefresh);
onRefreshRef.current = onRefresh; // ref vivant, pas de boucle deps
useEffect(() => {
if (timestamp === undefined || timestamp === lastSeenRef.current) return;
lastSeenRef.current = timestamp;
onRefreshRef.current();
}, [timestamp]);
}
```
---
<a id="pattern-cache-zustand-cle-composite"></a>
## Pattern : Clé de cache composite sur action async paramétrée
### Synthèse
- **Objectif** : éviter de servir un cache stale quand une action async indexe son résultat sur un seul paramètre alors que d'autres paramètres modifient le résultat.
- **Contexte** : store qui mémorise un résultat par paramètre métier (`slug`, `id`) et qui court-circuite le refetch via une égalité de clé.
- **Quand l'utiliser** : dès qu'une action de fetch prend plus d'un paramètre influençant le résultat.
- **Quand l'éviter** : action mono-paramètre, ou cache géré par une lib (React Query) qui clé déjà sur l'ensemble des args.
### Analyse
- **Avantages** :
- le cache reflète exactement les paramètres qui produisent le résultat
- pas de dépendance fragile à un `clearXxx()` externe
- **Limites / vigilance** :
- clé partielle (`packSlug` seul) → navigation A→B du même pack sert B avec l'exclusion de A toujours active
### Validation
- Validé le : 29-05-2026
- Contexte technique : Zustand — app-alexandrie (code review ux-cleanup-7)
### Implémentation
```typescript
// ❌ Cache stale si excludeId change mais pas packSlug
async fetchPackContents(token, packSlug, excludeId) {
if (get().packContentsSlug === packSlug) return;
// …
}
// ✅ Clé composite : un champ d'état par paramètre métier
async fetchPackContents(token, packSlug, excludeId) {
const normalizedExcludeId = excludeId ?? null;
const sameKey =
get().packContentsSlug === packSlug &&
get().packContentsExcludeId === normalizedExcludeId;
if (sameKey && (get().isLoading || get().items.length > 0)) return;
// …
set({ packContentsSlug: packSlug, packContentsExcludeId: normalizedExcludeId });
}
```
**Règle** : N paramètres métier influençant le résultat → N champs de clé dans l'état.
---
<a id="pattern-loadings-separes-initial-pagination"></a>
## Pattern : Loadings séparés (fetch initial vs pagination)
### Synthèse
- **Objectif** : éviter que le pull-to-refresh tourne pendant l'infinite scroll en distinguant deux flags de chargement de natures différentes.
- **Contexte** : store qui supporte à la fois fetch initial/refresh ET pagination (`fetchNextPage`).
- **Quand l'utiliser** : toute liste avec `RefreshControl` + chargement de pages.
- **Quand l'éviter** : liste sans pagination, ou sans pull-to-refresh.
### Analyse
- **Avantages** : le `RefreshControl` ne s'anime que pour le refresh ; le spinner de bas de liste pour la pagination.
- **Limites / vigilance** : un seul `isLoading` partagé → l'écran clignote, le refresh tourne pendant le scroll. Côté anti-pattern, voir aussi `risques/state.md#risque-flag-isloading-unique-nature-differente`.
### Validation
- Validé le : 29-05-2026
- Contexte technique : React Native / Zustand — app-alexandrie (ux-cleanup-10 M4, `notifications.store.ts`)
### Implémentation
```typescript
type State = {
items: T[];
isLoading: boolean; // fetch initial OU pull-to-refresh
isLoadingMore: boolean; // pagination (fetchNextPage)
};
```
```tsx
<FlatList
refreshing={isLoading} // pull-to-refresh seulement
ListFooterComponent={isLoadingMore ? <Spinner/> : null}
onEndReached={() => store.fetchNextPage(...)}
/>
```
---
<a id="pattern-flags-etat-separes-par-preoccupation"></a>
## Pattern : Flags d'état séparés par préoccupation (liste / création / mutation-par-item)
### Synthèse
- **Objectif** : éviter qu'un `isSubmitting`/`error` unique partagé entre `create`, `update(id)` et `remove(id)` ne fasse passer tous les boutons d'une liste en `loading` quand on agit sur une seule ligne.
- **Contexte** : store qui gère liste + création + mutation-par-item (agnostique Pinia/Vuex/Redux/Zustand).
- **Quand l'utiliser** : dès qu'une liste a des actions par item ET un formulaire de création.
- **Quand l'éviter** : store mono-action sans liste interactive.
### Analyse
- **Avantages** : chaque préoccupation a son flag → boutons ciblés, actions enchaînables, erreurs au bon endroit.
- **Limites / vigilance** : un `isSubmitting` unique provoque (1) tous les boutons de toutes les lignes en `loading`, (2) actions non-enchaînables, (3) erreur de mutation affichée dans un formulaire de création sans rapport. Pour le variant booléen-vs-`Set`, voir `risques/state.md#risque-flag-global-actions-paralleles`.
### Validation
- Validé le : 13-06-2026
- Contexte technique : Vue 3 / Pinia — RL799_V2 (`instructionsStore.ts`, code review)
### Implémentation
```typescript
// ❌ partagé : :loading="store.isSubmitting" sur chaque ligne
// ✅ séparé par préoccupation
isLoading / loadError // chargement de la liste
isCreating / createError // formulaire de création
submittingId: string | null // mutation par item
mutationError // erreur de mutation
// UI ligne : :loading="store.submittingId === item.id"
```
---
<a id="pattern-derive-computed-pas-ref-resync"></a>
## Pattern : État dérivé = `computed`, jamais un `ref` resynchronisé à la main
### Synthèse
- **Objectif** : supprimer les désyncs silencieuses d'un compteur/dérivé recalculé manuellement à chaque chemin de mutation.
- **Contexte** : composable Vue 3 exposant une valeur toujours dérivée d'un autre état réactif (`size`, `length`, total, booléen `isEmpty`).
- **Quand l'utiliser** : toute valeur jamais assignée indépendamment, toujours recalculée depuis une source.
- **Quand l'éviter** : valeur réellement indépendante (saisie utilisateur, état piloté en propre).
### Analyse
- **Avantages** : recalcul automatique à chaque changement de la source, zéro synchro manuelle, impossible d'oublier un chemin.
- **Limites / vigilance** :
- anti-pattern : `const count = ref(set.value.size)` + `syncCount()` rappelé dans chaque mutation → un oubli produit un compteur faux **sans erreur**
- exposer le dérivé en `ComputedRef<T>` (pas `Ref<T>`) pour signaler le read-only
- réactivité `Set`/`Map` : la mutation in-place (`set.add()`) ne déclenche rien — réassigner (`checked.value = new Set(checked.value)`) ; un `computed(() => checked.value.size)` se met alors à jour (il dépend de l'identité du Set)
- **Garde-fou de revue** : dans un composable, tout `ref` toujours recalculé depuis un autre `ref` est un `computed` déguisé.
### Validation
- Validé le : 18-06-2026
- Contexte technique : Vue 3 / Composition API — RL799_V2 (`useMcChecklist`, code review v2-2-2)
---
<a id="pattern-une-source-deux-vues-lecture-inerte"></a>
## Pattern : Une source, deux vues — la vue lecture inerte par construction
### Synthèse
- **Objectif** : présenter la même donnée en mode édition ET en mode lecture/présentation sans dupliquer la donnée ni la logique, en prouvant l'inertie du mode lecture **par construction** (aucun mutateur câblé) plutôt que par un garde runtime.
- **Contexte** : écran consultation/édition partageant un même fetch (préparation vs pédagogique, édition vs consultation).
- **Quand l'utiliser** : deux rendus d'une même donnée pilotés par un `viewMode` local.
- **Quand l'éviter** : modes aux données réellement disjointes.
### Analyse
- **Avantages** :
- un seul fetch, deux rendus via `v-if/v-else` sur un `ref<'edit'|'read'>`
- le mode lecture ne **référence** aucun mutateur → inertie garantie, pas besoin de `if (mode === 'read') return` dans chaque handler
- un composable d'état qui **lit** au mount reste inerte tant que ses mutateurs ne sont pas appelés (la lecture n'est pas un effet de bord) → il peut être instancié pour les deux modes
- **Limites / vigilance** :
- `viewMode` est un état d'affichage **local non persisté** (pas de la donnée métier)
- préférer un toggle in-place (un `viewMode` + `v-if` dans la page) à l'extraction d'un composant partagé tant qu'une autre story touche le même rendu — surface de merge minimale, factoriser à la 3ᵉ vraie divergence
- **Prouver l'inertie par test** : monter en mode lecture, spy sur `localStorage.setItem`/`fetch`, cliquer les éléments lecture, asserter aucun appel + clé localStorage restée nulle.
### Validation
- Validé le : 18-06-2026
- Contexte technique : Vue 3 — RL799_V2 (vue pédagogique MC, segmented control préparation↔pédagogique, code review v2-2-3)
---
<a id="pattern-noyau-visuel-partage-variants"></a>
## Pattern : Noyau visuel générique + comportements variants greffés
### Synthèse
- **Objectif** : faire servir un même artefact visuel positionnel (plan, diagramme, grille de placement) à deux finalités différentes sans dupliquer le rendu ni coupler les intentions via un `variant`.
- **Contexte** : un visuel (SVG, carte, plateau) doit afficher un état ET servir de sélecteur de navigation.
- **Quand l'utiliser** : à la **2ᵉ** utilisation de la même géométrie (pas avant — règle « factoriser à la Nᵉ utilisation »).
- **Quand l'éviter** : usage unique, ou intentions trop divergentes pour partager un contrat de props minimal.
### Analyse
- **Avantages** :
- le noyau ne connaît QUE l'invariant : géométrie, accessibilité clavier, contrat `nodes: {id, state}`, un seul événement neutre `select(id)`
- les consommateurs sont des **adaptateurs minces** : ils mappent leurs données métier vers `nodes` et retraduisent `select(id)`
- le 1er consommateur garde une signature publique inchangée (zéro régression, prouvée par typecheck + test de mount), le 2ᵉ usage est purement additif
- **Limites / vigilance** :
- mettre l'état « inerte » (plateau non cliquable) dans le **noyau** (garde de non-émission au clic ET au clavier), pas seulement en CSS ni dans chaque adaptateur
- dériver `nodes` d'une source unique exhaustive (ex. `RITUAL_OFFICER_ROLES`) plutôt qu'une liste en dur → un ajout futur apparaît automatiquement, pas de nœud manquant silencieux
### Validation
- Validé le : 13-06-2026
- Contexte technique : Vue 3 / SVG — RL799_V2 (`LodgeFloorPlanBase` consommé par `LodgeFloorPlan` collège + `ModuleFloorPlan` navigation, chantier surveillant-mobile-floorplan)