--- title: UX — Patterns validés : Général domain: ux bucket: patterns tags: [mobile, navigation, cta, fab, liste, etat, i18n, a11y, notifications] applies_to: [design, implementation, review] severity: medium validated_on: 2026-06-25 source_projects: [app-alexandrie] --- # UX — Patterns validés : Général > Extrait de la base de connaissance Lead_tech. Voir `knowledge/ux/patterns/README.md` pour l'index complet. --- ## Pattern : Marquage "lu" au mount de l'écran cible (mount-based, pas click-based) - Objectif : éviter les "clics accidentels qui consomment" une notification — marquer comme lu uniquement quand l'utilisateur a réellement vu le contenu. - Contexte : liste de notifications qui navigue vers un écran cible (thread, conversation, achievements). - Quand l'utiliser : tout flux notification → écran cible où le clic ne prouve pas la lecture. - Quand l'éviter : action où le clic EST l'acte de lecture (ex. dépliage inline). ### Description Mauvais pattern : `onPress(item) → markRead(item.id); navigate(target)` — un clic accidentel puis back immédiat consomme la notif sans qu'elle ait été vue ; la notif disparaît avant que l'écran cible monte. Bon pattern : déléguer le mark-read à l'écran cible, déclenché au mount via query param (`?notificationId=`). Un hook `useMarkNotificationReadOnMount()` lit le param et appelle `markRead` dans un `useEffect`, avec un guard d'idempotence (`alreadyMarkedRef`) contre les re-renders (focus refresh, hot reload). Comportements obtenus : cas nominal (tap → écran monte → lu), cas annulation (tap → back avant mount → reste non lu), deep-link analytics-friendly et testable. - Validé le : 28-05-2026 — app-alexandrie (IA-v2.8 AC4) --- ## Pattern : Fiche détail — single-scroll immersif, méta-infos en tête, chip drill-down - Objectif : structurer une fiche de contenu/produit/praticien/événement sans "rooms vides" ni dispersion des infos. - Contexte : fiche détail mobile/web avec contenu de profondeur variable. - Quand l'utiliser : fiche détail à contenu court ou partiellement livré. - Quand l'éviter : fiche à forte profondeur réelle (chapitres + avis nombreux) où les tabs sont justifiés. ### Trois patterns indépendants 1. **Single-scroll vs tabs Udemy/Masterclass** : pour une fiche courte (pas de chapitres, pas d'avis livrés), préférer un single-scroll vertical. Les tabs créent des "rooms vides" qui sapent la crédibilité (onglet "Chapitres" vide, "Avis" avec un faux 5 étoiles). Réintégrer les sections en single-scroll est plus simple que remplir artificiellement des tabs. 2. **Méta-infos groupées en tête** : regrouper toutes les méta-infos dans un bloc dense entre le hero et le contenu (overline catégorie > titre H1 > InfoChips > ligne progression compacte). Évite la dispersion ; donne un signal fort sur "ce qu'est ce contenu" avant de plonger. La progression tient sur une ligne (icon + label + date), c'est une info légère. 3. **Chip cliquable drill-down** : plutôt qu'une section vide (avis sans backend, stats peu lues), exposer l'info en chip dans la zone méta (état neutre si vide : "☆ Aucun avis") qui devient l'entrée vers une page dédiée. Évite les empty states verbeux. - Validé le : 29-05-2026 — app-alexandrie (ux-cleanup-7) --- ## Pattern : Préférer le FAB étendu au sticky bottom plein-largeur sur écran à nav persistante - Objectif : éviter le piège de positionnement à 3 couches synchronisées (sticky `bottom` + paddingBottom ScrollView + safeArea) du CTA sticky. - Contexte : écran avec nav persistante (TabBar/BottomBar en `position: absolute, bottom: 0`) ET un CTA principal contextuel. - Quand l'utiliser : écran à nav persistante avec CTA (commencer, reprendre, créer). - Quand l'éviter : écran SANS nav persistante (modal, full-screen) où le sticky devient naturel ; ou CTA à label très long ; ou page de paiement validée maquette par maquette. ### Description Un sticky bottom plein-largeur exige de re-synchroniser 3 hauteurs à chaque évolution de la nav, et un drift → le CTA chevauche la TabBar (bug observé sur device, invisible en tests Jest). Préférer un **FAB étendu Material 3** (icon + label, bottom-right) qui se positionne via UNE seule valeur (`bottom = NAV_HEIGHT + spacing + safeArea`) gérée par le composant lui-même, n'ajoute pas de surface visuelle redondante, et est immune aux évolutions de la nav. Anti-pattern : `` avec un Button plein-largeur — semble simple mais oblige à recalculer 3 hauteurs à chaque changement de nav. - Validé le : 29-05-2026 — app-alexandrie (ux-cleanup-7, le sticky a généré ~3 cycles de bugs sur device) --- ## Pattern : Ligne de contexte sous les filtres de liste - Objectif : permettre à l'utilisateur de comprendre instantanément ce qu'il regarde (combien, quel filtre) et pourquoi la liste est vide. - Contexte : toute liste filtrée (annuaire, feed, bibliothèque). - Quand l'utiliser : liste avec filtres et/ou recherche. - Quand l'éviter : liste sans filtre ni recherche. ### Description Afficher une ligne de contexte juste sous les filtres et au-dessus du premier résultat. Elle : compte les résultats (singulier/pluriel correct), mentionne le filtre actif ("Ma cohorte • 5 membres"), contextualise la query ("Aucun résultat pour « pnl »"), explicite le vide ("Aucun membre dans votre cohorte" plutôt qu'une chaîne générique), et ne s'affiche PAS pendant le 1er chargement (count=0 + isLoading) pour éviter le flash "Aucun X". Implémentation type : helper pur `formatXxxContext({filter, count, query, isLoading}) → string` (testable à 100% en node) + composant léger en `textSecondary` 13px. Anti-pattern : empty state global (centré, gros) sur une liste filtrée — il masque les filtres alors que l'utilisateur veut comprendre POURQUOI c'est vide. - Validé le : 29-05-2026 — app-alexandrie (ux-cleanup-9) --- ## Pattern : CTA toggle différencié au-delà du libellé - Objectif : rendre l'état d'un CTA toggle (Suivre/Suivi, Souscrire/Souscrit) perceptible au premier regard. - Contexte : CTA qui bascule entre deux états (action / état "fait"). - Quand l'utiliser : tout bouton à état binaire persistant. - Quand l'éviter : bouton d'action one-shot sans état. ### Description Deux éléments visuels distincts au-delà du libellé : - **État inactif (action disponible)** : ghost button (fond transparent, bordure + texte `colors.primary`, libellé verbe infinitif "Suivre"). - **État actif ("fait")** : filled tinted (fond `${colors.primary}1F` ≈ 12% d'alpha, bordure transparente, icône remplie alignée gauche, libellé participe passé "Suivi" — l'icône porte l'info, pas une coche dans le texte). **Pourquoi `${color}1F` plutôt qu'`opacity: 0.12`** : `opacity` fade TOUT l'enfant (icône + label), `${color}1F` n'applique l'alpha qu'au background — icône et label gardent leur saturation pleine. A11y obligatoire : `accessibilityState={{ selected: isActive }}` + `accessibilityLabel` explicitant état + action. Anti-pattern : différencier UNIQUEMENT par le libellé (Suivre → Suivi ✓) — le ✓ Unicode est faible visuellement et inaccessible. - Validé le : 29-05-2026 — app-alexandrie (ux-cleanup-9 AC3) --- ## Pattern : Table i18n de wordings inclusifs - Objectif : centraliser les wordings pour qu'ils soient auditables et inclusifs, et empêcher le composant d'inventer ses propres libellés. - Contexte : app francophone avec wordings genrés potentiels (notifications, messages). - Quand l'utiliser : tout domaine produisant des wordings adressés à l'utilisateur. - Quand l'éviter : — ### Structure Un fichier `i18n/.ts` par domaine exposant : un `Record` interne `WORDING_FALLBACK` (générique), une fonction `Wording(type, ctx?)` qui combine type + contexte, et un `.spec.ts` qui vérifie fallbacks, cas avec contexte, dégradation gracieuse, et la règle d'inclusivité via un garde-fou regex anti-régression (`expect(wording).not.toMatch(/mentionnée(?![\w·])/)`). ### Règles d'inclusivité française 1. **Point milieu `·e`** pour les participes : "mentionné·e", "invité·e". 2. **Nom commun neutre** quand possible : "abonné" plutôt que "follower". 3. **Passé composé impersonnel** sans acteur nommé : "Nouvelle réponse à votre fil" plutôt que "Vous avez été répondu". 4. **Nommer l'acteur quand on l'a** : "Bob a répondu à votre fil" (plus actionnable). Anti-patterns : wording inline dans le composant (inaudit­able) ; wording dans le push payload backend sans miroir dans la table mobile (divergence lockscreen vs in-app) ; test "smoke" qui vérifie juste que le wording n'est pas vide (rate les régressions de genre). - Validé le : 29-05-2026 — app-alexandrie --- ## Pattern : État read-only — cacher l'interactif plutôt que griser - Objectif : éviter le grisage avec placeholder ("Action désactivée"), un anti-pattern d'invitation qui fait essayer puis échouer l'utilisateur. - Contexte : élément interactif (composer DM, bouton "Acheter", formulaire) indisponible pour cause d'état métier (lecture seule, compte suspendu, plan inactif). - Quand l'utiliser : indisponibilité durable liée à un état métier. - Quand l'éviter : désactivation transitoire pendant un async court (préférer un loading). ### Description Ne pas griser — **cacher** l'élément et afficher en remplacement un **bandeau neutre** qui explique l'état. Pourquoi cacher est meilleur : pas d'invitation à essayer ; explicite > implicite (le bandeau explique POURQUOI) ; a11y (`accessibilityRole="alert"` annonce l'état au mount, un input `editable={false}` ne signale rien) ; visuel (1 zone au lieu de composer grisé + bandeau redondant). Pattern de code : `{isReadOnly ? : }`. Ton neutre (`textSecondary` / `surfaceContainerLow`), pas `error` (réservé aux vraies erreurs réversibles). Anti-patterns : placeholder "Envoi désactivé" dans un TextInput non éditable ; `opacity: 0.5` (reste cliquable visuellement) ; bandeau ET composer affichés ensemble. - Validé le : 29-05-2026 — app-alexandrie (ux-cleanup-11 AC4) --- ## Pattern : Avatar coloré par hash — buckets distincts du fallback - Objectif : éviter qu'un utilisateur tombant dans un bucket trop neutre soit confondu avec un compte supprimé. - Contexte : avatar coloré "hash du nom" avec un bucket fallback (compte supprimé/anonyme). - Quand l'utiliser : avatars générés par hash. - Quand l'éviter : avatars uploadés / sans fallback identitaire. ### Description Ne mettre dans le pool hashable QUE des accents colorés (`primary`/`secondary`/`tertiary` + leurs containers). Le fallback (`outlineVariant`) reste réservé aux fallbacks identitaires (compte supprimé, handle vide). Anti-pattern : inclure un token "surface" très clair (background-like) dans le pool — il ressemble au fallback grisé en light theme. Validation : tester visuellement les 8-12 tokens du pool sous light + dark ; retirer tout token proche du fallback. - Validé le : 30-05-2026 — app-alexandrie (ux-cleanup-11 M1) --- ## Pattern : Audit a11y des touch targets mobile RN - Objectif : garantir des cibles tactiles conformes (EAA 2025) sans linter a11y RN mainstream. - Contexte : app mobile React Native / Expo avec composants interactifs denses. - Quand l'utiliser : audit a11y ou story de polish touch targets. - Quand l'éviter : — ### Cible et méthode Cible : 44×44pt iOS (Apple HIG) / 48×48dp Android (Material). Méthode (grep + calcul, pas de linter) : grep tous les `Pressable`/`TouchableOpacity`/`onPress` ; pour chacun, taille effective = `max(width, iconSize + paddingH*2) × max(height, fontSize + paddingV*2)` + `hitSlop` ; marquer non conforme si une dimension < 44pt ; rapport ordonné par gravité. ### Décision hitSlop vs padding | Choisir... | Quand... | |---|---| | `hitSlop` | Composant qui DOIT rester visuellement compact (chip dense, icône + label adjacent, pill en liste serrée), réutilisé multi-écrans (changer le padding casse les callers), pas de feedback visuel attendu au-delà de la cible (chevron, lien "Voir tout") | | `padding` / `minHeight: 44` | Composant qui peut grossir sans gêne (CTA EmptyState, bouton primaire), feedback visuel attendu sur la zone élargie, composant isolé sans dépendance layout serrée | Syntaxe : `hitSlop={{ top: N, bottom: N, left: M, right: M }}` — N pour atteindre exactement 44pt en hauteur ; `left/right` plus petits (4-8) si chips côte-à-côte pour éviter l'overlap. Limite acceptée : audit visuel + grep seul (cf. risque associé dans `ux/risques/general.md`). - Validé le : 31-05-2026 — app-alexandrie (ux-cleanup-14)