Module 11 · Architecture de l'app

Composants & style : NativeWind + CVA

Comment ton app fabrique des briques visuelles réutilisables et cohérentes sans jamais répéter le même style à dix endroits. On va prendre tout son temps : d'abord la philosophie (qu'est-ce qu'un bon composant, pourquoi écrire le style avec des classes utilitaires), puis l'outillage (cn(), CVA), puis on décortique ligne par ligne le vrai Button de Halterofit — qui réunit absolument tout, y compris le contexte React vu ailleurs dans le guide.

💡 Les deux grandes idées du module
  • NativeWind : on écrit le style avec des classes utilitaires dans la prop className, exactement comme Tailwind sur le web. className="bg-primary px-4 rounded-md" veut dire : fond de la couleur primaire, marge intérieure horizontale, coins arrondis. Pas de fichier CSS séparé, pas de feuille de style à maintenir à côté.
  • CVA (class-variance-authority) : une petite bibliothèque pour décrire les variantes d'un composant — un bouton « primaire », « danger », « contour », en taille petite/normale/grande — et obtenir automatiquement les bonnes classes en donnant juste un nom. On écrit le style de chaque variante une seule fois.

Le fil rouge du module, c'est : le style vit à un seul endroit, et on le réutilise partout. Tout ce qui suit sert cette idée.

La composition de composants : petites briques plutôt que gros monolithe

Avant même de parler de couleurs et de classes, il faut comprendre comment on découpe une interface. C'est la question la plus importante de l'architecture front, et c'est aussi la plus facile à rater. L'instinct du débutant, c'est de fabriquer un seul gros composant SuperBouton qui accepte trente props : estPrimaire, estDanger, aUneIcône, texteEnGras, arrondi, pleineLargeur… On appelle ça un composant monolithique configurable. Au début c'est pratique. Très vite, c'est un cauchemar : chaque nouveau besoin ajoute une prop, le corps du composant se remplit de if imbriqués, et personne n'ose plus y toucher de peur de casser un cas oublié.

L'approche inverse, celle que privilégie React et que suit Halterofit, c'est la composition : on assemble une interface comme on assemble des briques Lego. On écrit beaucoup de petits composants qui font une seule chose chacun, et on les combine. Un écran n'est pas un bloc géant : c'est un <Screen> qui contient une <Card> qui contient un <Button> et du <Text>. Chaque brique reste petite, lisible, testable, et réutilisable ailleurs.

L'outil clé de la composition, c'est la prop spéciale children (« les enfants »). Quand tu écris <Card>du contenu</Card>, tout ce qui se trouve entre la balise ouvrante et la balise fermante arrive dans le composant Card sous le nom children. La carte n'a pas besoin de savoir ce qu'elle contient : elle dessine son cadre, son ombre, ses marges, et place children au milieu. C'est ce qui rend un composant vraiment générique : il fournit une forme, et tu y glisses n'importe quel contenu.

// Un composant « contenant » : il ne connaît pas son contenu, il l'emballe.
function Card({ children }) {
  return (
    // La carte impose son cadre ; children est ce qu'on a mis entre les balises.
    <View className="rounded-xl border border-border bg-card p-4">
      {children}
    </View>
  );
}

// À l'usage : on COMPOSE. La Card emballe un titre et un bouton, sans le savoir d'avance.
<Card>
  <Text>Séance du jour</Text>
  <Button variant="default">Commencer</Button>
</Card>

La carte fournit le cadre, le bouton fournit l'action, le texte fournit le contenu. Personne ne fait tout. C'est la composition : on emboîte des petites briques au lieu d'écrire un monstre paramétrable.

On distingue souvent deux familles de composants. Les composants de présentation (presentational) ne s'occupent que de l'apparence : un bouton, une carte, une étiquette. Ils reçoivent des props, ils affichent, point. Ils ne savent rien de la base de données ni de la logique métier. Les composants conteneurs (container), à l'inverse, orchestrent : ils vont chercher des données, gèrent l'état, décident quoi afficher, et délèguent le comment aux composants de présentation. Cette séparation rend l'app prévisible : tu sais où regarder. Un bug d'apparence ? C'est dans un composant de présentation. Un bug de données ? C'est dans un conteneur. Le Button qu'on va étudier est un pur composant de présentation : il ne connaît rien de tes séances, il sait juste être un beau bouton.

Quand on rassemble toutes ces petites briques de présentation cohérentes dans un même dossier, on obtient un design system : une bibliothèque maison de composants qui partagent la même langue visuelle. Dans Halterofit, ce dossier s'appelle components/ui/ — tu y trouves button.tsx, text.tsx, et leurs cousins. C'est le vocabulaire visuel de l'app. Quand tout l'app pioche dans le même jeu de briques, l'app a l'air « d'une seule main », même écrite à plusieurs.

🧭 Bon à savoir

La règle pratique : quand un composant devient difficile à lire, ce n'est presque jamais qu'il manque une prop — c'est qu'il fait trop de choses. Coupe-le en deux. « Préférer la composition à la configuration » est l'un des proverbes les plus utiles du développement React.

La philosophie utility-first : du style sans fichier CSS

Passons au style. Pendant vingt ans, la pratique standard du web a été d'écrire le style dans des fichiers CSS séparés. On inventait un nom de classe sémantique — .card, .card__titre, .bouton-principal — puis on allait dans un autre fichier écrire ce que ce nom voulait dire. C'est élégant sur le papier. En pratique, ça pose trois problèmes tenaces. D'abord, nommer est difficile : tu passes un temps fou à inventer .card-header-wrapper-inner et à te demander si ça existe déjà. Ensuite, le CSS est global : une classe écrite ici peut affecter quelque chose là-bas, et ces collisions sont la hantise des grosses bases de code. Enfin, le CSS ne meurt jamais : tu supprimes un composant, mais qui ose supprimer ses classes CSS ? « Au cas où ». La feuille de style grossit éternellement.

L'approche utility-first (« d'abord les utilitaires »), popularisée par Tailwind et portée dans le monde React Native par NativeWind, retourne le problème. Au lieu d'inventer des noms, tu composes ton style directement à partir d'un dictionnaire de petites classes utilitaires, chacune faisant une seule chose : px-4 (marge intérieure horizontale), rounded-md (coins moyennement arrondis), bg-primary (fond couleur primaire), flex-row (disposition en ligne). Tu assembles ces briques dans className, sur place, sans jamais quitter le composant.

Comparaison des deux mondes
// Approche classique « CSS séparé » : on invente un nom ici…
<View className="carte-seance" />
// …et on va dans un AUTRE fichier dire ce que ce nom veut dire. Deux endroits à tenir à jour.

// Approche utility-first : le style EST là, lisible, local. Aucun nom à inventer.
<View className="rounded-xl border border-border bg-card p-4" />

Dans la version utility-first, tu lis le composant et tu sais immédiatement à quoi il ressemble. Pas d'aller-retour entre deux fichiers, pas de nom mystérieux à décoder.

Les avantages sont concrets. La cohérence : les classes proviennent d'une échelle fixe (les marges vont par crans p-1, p-2, p-4… pas n'importe quelle valeur en pixels), donc tout l'app respire au même rythme. La vitesse : tu stylises en tapant, sans changer de fichier ni inventer de nom. La tranquillité d'esprit : supprimer un composant supprime son style avec lui, puisque le style vivait dedans — pas de CSS mort qui s'accumule. Et comme tu réutilises toujours le même petit vocabulaire, l'app reste homogène presque sans effort.

La critique la plus courante, il faut la connaître pour ne pas être surpris : c'est la verbosité. Un composant un peu riche peut avoir une longue ligne de classes (className="flex-row items-center justify-center gap-2 rounded-md px-4 py-2 bg-primary…"), ce qui pique les yeux au premier abord et fait dire « mais c'est illisible ! ». La réponse de la communauté tient en deux points. Un : on lit ces classes très vite une fois habitué, car chacune dit exactement une chose. Deux — et c'est tout l'objet de ce module — dès qu'une combinaison se répète, on ne la recopie pas : on l'extrait dans un composant réutilisable (le Button) ou dans une variante CVA. La verbosité reste alors confinée dans le design system, et le reste de l'app écrit simplement <Button variant="default">.

💡 Le lien avec React Native

Souviens-toi : en React Native, il n'y a pas de CSS du tout. Le style « natif » se fait avec des objets JavaScript (StyleSheet.create({...})) — verbeux et dispersé. NativeWind ajoute par dessus la possibilité d'écrire ce même style avec des classes Tailwind dans className, puis les traduit en style natif sous le capot. C'est donc le pont parfait pour quelqu'un qui connaît (ou apprend) Tailwind : tu écris le style « comme sur le web », et React Native l'applique « comme du natif ». On a posé ce décor dans le module sur React Native (le style sans CSS) — ce module-ci en est la suite directe.

L'outil de base : la fonction cn()

Dès qu'on veut composer des classes de façon un peu intelligente — en ajouter selon une condition, ou laisser l'extérieur en surcharger certaines — la simple concaténation de chaînes ne suffit plus. C'est le rôle d'une petite fonction utilitaire que tu verras dans presque chaque composant de l'app : cn(). Elle est minuscule mais elle fait deux choses précieuses.

apps/mobile/src/lib/utils.ts
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';

// cn = « class names ». Elle combine des classes ET résout les conflits Tailwind.
export function cn(...inputs: ClassValue[]) {
  // 1. clsx aplatit et filtre : il jette les valeurs « fausses » (false, null, undefined).
  // 2. twMerge passe derrière et, en cas de conflit Tailwind, garde la DERNIÈRE classe.
  return twMerge(clsx(inputs));
}
// Exemples de comportement :
cn('px-4 py-2', 'bg-primary')        // → 'px-4 py-2 bg-primary'   (simple assemblage)
cn('px-4', isActive && 'bg-primary') // → ajoute 'bg-primary' SEULEMENT si isActive est vrai
cn('px-4', 'px-6')                    // → 'px-6'   (conflit : px-4 vs px-6, la dernière gagne)

Deux mots de trois lettres font tout le travail : clsx pour le conditionnel, twMerge pour les conflits. cn n'est que la couture des deux.

Décortiquons les deux ingrédients, car ils résolvent deux problèmes différents. Le premier, clsx, s'occupe de la composition conditionnelle. Il prend une liste de valeurs hétéroclites — des chaînes, mais aussi des résultats d'expressions comme isActive && 'bg-primary' — et il les recolle en une seule chaîne, en ignorant tout ce qui est faux. Quand isActive vaut false, l'expression false && 'bg-primary' vaut false, et clsx le jette simplement. C'est exactement le même motif d'affichage conditionnel que tu as rencontré ailleurs dans le guide avec condition && ... — ici appliqué aux classes de style.

Le second, tailwind-merge (alias twMerge), s'occupe des conflits. Tailwind a beaucoup de classes qui touchent la même propriété : px-4 et px-6 règlent toutes deux la marge horizontale. Si tu les laisses côte à côte, le résultat dépend de subtilités d'ordre dans la feuille de style générée — fragile et imprévisible. twMerge connaît les familles de classes Tailwind : il sait que px-4 et px-6 sont en conflit, et il ne garde que la dernière mentionnée. C'est ce qui rend la surcharge possible : tu peux poser un style par défaut, puis le faire remplacer par une classe écrite plus loin dans la liste. Retiens bien cette règle — la dernière classe gagne — elle reviendra plusieurs fois et c'est la clé pour comprendre l'ordre des arguments du Button.

🧭 Bon à savoir

Sans twMerge, cn('px-4', 'px-6') donnerait la chaîne 'px-4 px-6' — les deux classes coexistent et c'est le navigateur (ou le moteur NativeWind) qui tranche, de façon non garantie. Avec twMerge, le conflit est résolu avant d'arriver au moteur : 'px-6'. La différence est invisible quand tout va bien, et salvatrice le jour où une surcharge ne « prend » pas.

CVA : un catalogue de variantes au lieu de copier-coller

Voici le problème que CVA résout, et il vaut la peine de bien le poser. Un bouton n'existe presque jamais en un seul exemplaire. Il y a le bouton principal (fond plein, couleur primaire), le bouton de danger (rouge, pour « Supprimer »), le bouton secondaire, le bouton « contour » (transparent avec une bordure), et ainsi de suite. Et chacun existe en plusieurs tailles. Sans outil, tu te retrouves à recopier de longues chaînes de classes à chaque variante, et à les ajuster une par une quand le design change. C'est de la duplication, et la duplication est le terreau des incohérences : un jour un bouton de danger est rounded-md, un autre rounded-lg, parce qu'on a oublié un copier-coller.

CVA (class-variance-authority) règle ça en te laissant décrire, une fois pour toutes, un catalogue de variantes. Tu déclares trois choses : une base (les classes communes à tous les boutons), des variants (les axes de variation, chacun avec ses options et les classes correspondantes), et des defaultVariants (les choix retenus si l'appelant ne précise rien). En échange, CVA te rend une fonction : tu l'appelles avec des noms, elle te rend la bonne chaîne de classes.

apps/mobile/src/components/ui/button.tsx (simplifié)
const buttonVariants = cva(
  // 1. LA BASE : ces classes s'appliquent à TOUS les boutons, quelle que soit la variante.
  'flex-row items-center justify-center gap-2 rounded-md',
  {
    // 2. LES AXES DE VARIATION. Chaque axe a des options ; chaque option a ses classes.
    variants: {
      variant: {
        default:     'bg-primary active:bg-primary/90',
        destructive: 'bg-destructive active:bg-destructive/90',
        outline:     'border-border bg-background border',
        // …secondary, ghost, link
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm:      'h-9 px-3',
        lg:      'h-12 px-6',
        icon:    'h-10 w-10',
      },
    },
    // 3. LES DÉFAUTS : si on ne précise ni variant ni size, on prend ceux-là.
    defaultVariants: { variant: 'default', size: 'default' },
  }
);

Une déclaration, six variantes de couleur × quatre tailles = vingt-quatre boutons possibles, tous cohérents, sans un seul copier-coller. Voilà ce que CVA t'achète.

Maintenant, comment lire un appel comme buttonVariants({ variant: 'destructive', size: 'lg' }) ? Mentalement, CVA fait une addition de chaînes : il part de la base, il y ajoute les classes de l'option destructive de l'axe variant, puis celles de l'option lg de l'axe size, et il te rend le tout assemblé. Tu as donné deux noms ; tu reçois une longue chaîne de classes prête à poser dans className. Si tu n'avais rien passé — buttonVariants({}) — CVA serait allé chercher les defaultVariants et aurait construit le bouton default / default. C'est tout le contrat : tu choisis des noms, CVA calcule les classes.

💡 L'analogie du menu

Pense à CVA comme à un menu de restaurant. La base, c'est ce que tout plat comprend (l'assiette, les couverts). Les variants, ce sont les choix : « viande ou poisson » (axe variant), « petite ou grande faim » (axe size). Les defaultVariants, c'est le « plat du jour » qu'on te sert si tu ne précises rien. Tu commandes par des mots ; la cuisine assemble. Tu n'écris jamais la recette toi-même.

Le vrai buttonVariants de Halterofit, sans simplification

La version simplifiée ci-dessus capture l'idée, mais le vrai fichier va un cran plus loin, et c'est instructif de voir pourquoi. Le vrai buttonVariants entoure ses chaînes d'un appel à cn() et à Platform.select(...). Pourquoi ? Parce qu'Halterofit vise à la fois le mobile et le web (via NativeWind), et certaines classes — survol de la souris, anneau de focus au clavier, transitions — n'ont de sens que sur le web. Platform.select({ web: '...' }) ajoute ces classes uniquement sur la plateforme web, et cn() recolle proprement le tout.

apps/mobile/src/components/ui/button.tsx
const buttonVariants = cva(
  // La base est elle-même un cn() : des classes communes + des classes réservées au web.
  cn(
    'group shrink-0 flex-row items-center justify-center gap-2 rounded-md shadow-none',
    Platform.select({
      // Survol, focus clavier, transitions… : utile sur le web, ignoré sur mobile.
      web: 'focus-visible:ring-ring/50 outline-none transition-all disabled:pointer-events-none',
    })
  ),
  {
    variants: {
      variant: {
        // Chaque variante : une classe « active: » pour mobile, une « hover: » pour le web.
        default: cn('bg-primary active:bg-primary/90 shadow-sm',
                    Platform.select({ web: 'hover:bg-primary/90' })),
        destructive: cn('bg-destructive active:bg-destructive/90',
                    Platform.select({ web: 'hover:bg-destructive/90' })),
        outline: cn('border-border bg-background active:bg-accent border',
                    Platform.select({ web: 'hover:bg-accent' })),
        // …secondary, ghost, link
      },
      size: {
        default: 'h-10 px-4 py-2 sm:h-9',
        sm:      'h-9 gap-1.5 px-3 sm:h-8',
        lg:      'h-12 px-6',
        icon:    'h-10 w-10 sm:h-9 sm:w-9',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
);

Même structure que la version simplifiée (base + variants + defaults), mais chaque chaîne est tissée avec cn() et Platform.select pour servir mobile et web à partir d'une seule source. Tu vois ici cn() et CVA travailler ensemble.

Note au passage la classe group dans la base, et les active: sur les couleurs. group marque le bouton comme un « groupe parent » : ça permettra au texte à l'intérieur de réagir quand le bouton est pressé (via des classes group-active:). On en aura besoin dans une seconde, quand on parlera du texte. Garde aussi en tête qu'il existe un second catalogue CVA juste à côté, buttonTextVariants, construit exactement de la même façon mais pour la couleur du texte du bouton. Deux catalogues parallèles : un pour le contenant, un pour le texte.

Le composant Button au complet, ligne par ligne

On a maintenant toutes les pièces. Voici le vrai composant Button, dans son intégralité, et on va le lire avec la méthode du guide : les props en entrée, le JSX en sortie.

apps/mobile/src/components/ui/button.tsx
// Le type des props : tout ce que Pressable accepte, PLUS les variantes CVA.
type ButtonProps = React.ComponentProps<typeof Pressable> &
  React.RefAttributes<typeof Pressable> &
  VariantProps<typeof buttonVariants>; // ← variant et size, déduits du catalogue CVA

function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    // 1. Le bouton DÉPOSE la bonne couleur de texte dans un contexte React.
    //    Tout <Text> à l'intérieur ira lire cette couleur tout seul.
    <TextClassContext.Provider value={buttonTextVariants({ variant, size })}>
      <Pressable
        // 2. cn() assemble TROIS sources de classes, dans un ordre qui compte :
        className={cn(
          props.disabled && 'opacity-50',  //   a) grisé SI désactivé (conditionnel)
          buttonVariants({ variant, size }), //   b) le style CVA selon variant/size
          className                          //   c) les classes de l'appelant, en DERNIER
        )}
        role="button"
        {...props}  // 3. on transmet TOUT le reste : onPress, disabled, children…
      />
    </TextClassContext.Provider>
  );
}

Quinze lignes qui réunissent tout le module : CVA (étape b), cn et son ordre (étape 2), la transmission de props (étape 3), et le contexte React (étape 1). On déplie chaque point ci-dessous.

Les props en entrée. La signature { className, variant, size, ...props } extrait trois props nommées et regroupe tout le reste dans props grâce à ...props (on y revient juste après). variant et size partent alimenter les deux catalogues CVA. className est gardé de côté pour être assemblé en dernier. Le type ButtonProps mérite un coup d'œil : il dit « ce composant accepte tout ce qu'un Pressable accepte (grâce à React.ComponentProps<typeof Pressable>), plus les variantes du catalogue (grâce à VariantProps<typeof buttonVariants>) ». Ce VariantProps est malin : il déduit automatiquement que variant peut valoir 'default' | 'destructive' | 'outline'… à partir du catalogue CVA. Tu ne maintiens pas une liste de variantes à deux endroits : tu la déclares dans cva(...), et le type suit tout seul.

Le JSX en sortie. Un <Pressable> (la brique « zone tactile » de React Native), stylé par le cn(...), et emballé dans un <TextClassContext.Provider>. C'est tout. Un composant de présentation classique : props → JSX stylé.

Le contexte : pourquoi le bouton « dépose » une couleur de texte

La ligne la plus subtile est la première du JSX. Un bouton « danger » doit avoir un fond rouge et un texte blanc lisible ; un bouton « lien » doit avoir un texte coloré et souligné. Or le texte n'est pas écrit dans le bouton lui-même — il arrive via children, depuis l'extérieur : <Button variant="destructive">Supprimer</Button>. Comment faire pour que ce <Text> « Supprimer », que le bouton ne contrôle pas, prenne quand même la bonne couleur ?

La réponse, c'est le contexte React, exactement le mécanisme expliqué dans le module sur useContext, useMemo et memo. Le bouton calcule la bonne couleur de texte avec son second catalogue (buttonTextVariants({ variant, size })) et la dépose dans TextClassContext via le Provider. Le composant <Text> de l'app, lui, est programmé pour lire ce contexte : où qu'il se trouve à l'intérieur du bouton, il récupère la couleur déposée et se l'applique, sans qu'on ait à la lui passer en prop. Le contexte sert ici exactement à ce pour quoi il existe : faire descendre une information à travers plusieurs niveaux de composants sans la passer de main en main. Revois le module sur le contexte si la mécanique Provider / lecture te semble floue — c'est le même schéma, appliqué à un cas très concret.

🏋️ Pourquoi ce design dans Halterofit

Grâce à ce Button, tout l'app écrit simplement <Button variant="destructive" size="lg">Supprimer</Button> et obtient, partout, un bouton parfaitement cohérent : la bonne couleur de fond, la bonne taille, et la bonne couleur de texte propagée par le contexte. Le style vit à un seul endroit — ce fichier — et le reste de l'app n'a qu'à nommer ce qu'il veut. Ajoute une variante un jour ? Tu la déclares ici une fois, et elle est disponible dans toute l'app, type TypeScript compris.

La transmission des props et l'art de l'ordre dans cn()

Deux détails du Button méritent qu'on s'y attarde, parce qu'ils reviennent dans presque tous les composants bien écrits : {...props} et l'ordre des arguments de cn.

La transmission, {...props}. C'est l'opérateur de « répartition » (spread). Dans la signature, ...props a collecté toutes les props non nommées dans un objet. Sur le <Pressable>, {...props} les réétale : il transmet d'un coup onPress, disabled, children, accessibilityLabel et tout le reste, sans que le Button ait à les énumérer un par un. C'est ce qui rend le composant transparent : il ajoute son style et son contexte, mais pour tout le reste il se comporte comme un vrai Pressable. Sans cette ligne, il faudrait redéclarer manuellement chaque prop de Pressable qu'on veut laisser passer — fastidieux et fragile.

L'ordre dans cn. Souviens-toi de la règle d'or de twMerge : en cas de conflit, la dernière classe gagne. Regarde alors l'ordre choisi par le Button : le style conditionnel d'abord, puis le style CVA, puis className en dernier. Ce n'est pas un hasard, c'est une décision d'ergonomie. En plaçant className tout à la fin, le composant permet à celui qui l'utilise de surcharger un défaut si besoin. Tu veux un bouton « danger » mais exceptionnellement avec une marge différente ? <Button variant="destructive" className="px-8"> : ton px-8, venu en dernier, l'emporte sur le px-4 de la variante. Le composant propose ses défauts, l'appelant peut disposer. Inverse l'ordre, et la surcharge devient impossible : les classes de l'appelant seraient écrasées par celles de CVA. L'ordre n'est pas cosmétique — c'est le contrat d'extension du composant.

💡 Collecter puis réétaler

Le même ... joue deux rôles opposés selon l'endroit. Dans la signature { variant, ...props }, il collecte ce qui reste dans un objet. Sur la balise {...props}, il réétale cet objet en props individuelles. C'est la même syntaxe « rest/spread » que tu utilises déjà sur les tableaux et les objets en JavaScript courant — ici au service du passage de props.

La cohérence imposée : des règles ESLint maison

Tout ce bel édifice — design system, cn, CVA — ne tient que si tout le monde joue le jeu. Le risque est connu : un jour, sous la pression, quelqu'un (peut-être toi) écrit une couleur « en dur » dans un écran, du genre className="bg-[#FF0000]" ou un code hexadécimal dans un style, parce que « c'est juste pour aller vite ». Une fois, ce n'est rien. Cent fois, le design system est contourné et l'app redevient un patchwork incohérent. La bonne volonté ne suffit pas à l'échelle d'un projet.

Halterofit traite ce risque par l'outillage plutôt que par la discipline. Le projet embarque des règles ESLint maison — des règles de lint écrites spécifiquement pour ce dépôt — qui interdisent d'écrire des couleurs ou des polices en dur. Si tu tapes une valeur hexadécimale de couleur dans un fichier, ESLint te le signale (souvent en rouge directement dans l'éditeur, et en erreur bloquante à la validation). Le message te pousse vers la bonne pratique : « utilise une classe du thème (bg-primary) ou un composant du design system ». La machine devient ainsi la gardienne de la cohérence : impossible de dévier sans que l'outil le remarque.

Ce que la règle ESLint refuse / accepte
// ❌ REFUSÉ : couleur en dur. ESLint signale une erreur.
<View className="bg-[#FF0000]" />
<Text style={{ color: '#1E40AF' }} />

// ✅ ACCEPTÉ : on passe par le thème / le design system.
<View className="bg-destructive" />   // couleur sémantique du thème
<Button variant="destructive" />     // mieux encore : un composant qui existe déjà

La règle ne dit pas « sois cohérent », elle force la cohérence en refusant les valeurs en dur. C'est l'outillage qui protège l'architecture.

On a rencontré cette philosophie dans le module sur l'outillage : ESLint, le formatage, les contrôles automatiques ne sont pas là pour t'embêter, mais pour encoder dans la machine les règles que l'équipe s'est données. Une règle maison comme « pas de couleur en dur » transforme une bonne intention en garantie. C'est exactement pour ça que le design system tient dans le temps.

Lire un appel de Button dans la nature

Mettons bout à bout tout ce qu'on a vu, du point de vue d'un écran qui utilise le bouton, pas de celui qui le fabrique. Voici à quoi ressemble un appel typique, et comment le lire de gauche à droite.

<Button variant="destructive" size="lg" onPress={supprimerSeance} disabled={enCours}>
  Supprimer la séance
</Button>

Un appel complet, tel qu'on en trouve dans un écran.

Décodons. variant="destructive" et size="lg" partent dans les deux catalogues CVA : ils déterminent le fond rouge, la grande taille, et — via le contexte — la couleur de texte. onPress={supprimerSeance} et disabled={enCours} ne sont pas des props nommées du Button : elles tombent donc dans ...props et sont transmises au Pressable par {...props}. Comme disabled est aussi lu à l'intérieur (props.disabled && 'opacity-50'), le bouton se grise automatiquement quand enCours est vrai. Enfin, Supprimer la séance est le children : il arrive dans le Pressable, et son <Text> lit la couleur déposée dans le contexte. Tu n'as écrit qu'une ligne lisible ; sous le capot, CVA, cn, la transmission et le contexte ont tous joué leur partition. Voilà la récompense d'un bon design system : la complexité est rangée dans la brique, et l'usage reste simple.

✍️ Exercice de lecture

Regarde l'ordre d'assemblage dans le cn du Button :

className={cn(
  props.disabled && 'opacity-50',
  buttonVariants({ variant, size }),
  className          // ◀── les classes passées de l'extérieur, en DERNIER
)}

Questions : (1) Pourquoi className est-il placé en dernier ? (2) Que produit props.disabled && 'opacity-50' quand le bouton n'est PAS désactivé ? (3) Si quelqu'un écrit <Button variant="default" className="bg-destructive">, de quelle couleur sera le fond, et pourquoi ?

Voir le corrigé

(1) Parce que dans cn (via twMerge), en cas de conflit, la dernière classe gagne. En plaçant className en dernier, on permet à l'appelant de surcharger un défaut du composant si besoin. C'est un choix d'ergonomie : le composant propose, l'appelant peut disposer.

(2) Si disabled vaut false, l'expression false && 'opacity-50' vaut false, et cn (grâce à clsx) ignore les valeurs fausses. Donc aucune classe d'opacité n'est ajoutée. C'est le même motif conditionnel condition && ... qu'on retrouve partout dans le guide.

(3) Le fond sera rouge (bg-destructive). La variante default a posé bg-primary, mais le className="bg-destructive" de l'appelant arrive après dans cn : bg-primary et bg-destructive sont en conflit (même propriété : la couleur de fond), et la dernière gagne. C'est précisément le pouvoir de surcharge décrit en (1) — utile, mais à manier avec parcimonie pour ne pas trahir l'esprit du design system.

🧠 Quiz éclair

1. Qu'est-ce que NativeWind, en une phrase ?

Une façon d'écrire le style avec des classes utilitaires (Tailwind) dans className, que NativeWind traduit en style natif pour React Native.

2. Quel problème CVA résout-il, et avec quelles trois pièces ?

Il évite de recopier le style de chaque variante. On déclare une base (classes communes), des variants (les axes, ex. variant et size) et des defaultVariants (les choix par défaut), puis on demande les classes par leur nom : buttonVariants({ variant: 'outline' }).

3. Dans cn, à quoi servent clsx et tailwind-merge, respectivement ?

clsx assemble les classes conditionnelles en jetant les valeurs fausses ; tailwind-merge résout les conflits Tailwind en gardant la dernière classe.

4. Comment le texte « Supprimer » d'un <Button variant="destructive"> obtient-il sa couleur, alors que le bouton ne l'écrit pas ?

Le bouton dépose la couleur dans TextClassContext (un contexte React, vu dans le module sur useContext/useMemo/memo) ; le composant <Text> à l'intérieur lit ce contexte et s'applique la couleur tout seul, sans qu'on la lui passe en prop.

5. Pourquoi placer className en dernier dans le cn du Button ?

Pour permettre la surcharge : comme la dernière classe gagne, les classes de l'appelant peuvent remplacer un défaut du composant. Le composant propose, l'appelant dispose.

À retenir

On compose des petites briques (un design system dans components/ui/) plutôt qu'un monolithe configurable ; children rend les contenants génériques. Le style est utility-first : des classes NativeWind dans className, pas de CSS séparé. cn() assemble (clsx, conditionnel) et résout les conflits (tailwind-merge, dernière gagne). CVA = base + variants + defaultVariants : tu nommes, il calcule les classes. Le Button réunit tout : CVA pour le style, cn pour l'assemblage, {...props} pour la transmission, et le contexte pour propager la couleur de texte. L'ordre dans cn autorise la surcharge ; des règles ESLint maison interdisent les valeurs en dur pour garder l'app cohérente.

⚠️ Piège fréquent

Deux réflexes à corriger. Un : styliser un écran « à la main », avec des couleurs ou tailles écrites en dur, au lieu de réutiliser les composants de components/ui/ et les classes du thème — ESLint te le refusera, et c'est tant mieux. Deux : recréer un composant qui existe déjà (un énième « bouton » local) parce qu'on n'a pas regardé le design system. Avant d'écrire une brique visuelle, demande-toi toujours : « existe-t-il déjà ? ». La cohérence vient de la réutilisation, pas de la réinvention.

🔄 Transférable

Rien de tout ça n'est spécifique au mobile. Tailwind, cn (clsx + tailwind-merge) et CVA sont omniprésents sur le web React moderne — souvent à l'identique, mot pour mot. Le découpage en petites briques composables et la distinction présentation/conteneur sont universels en front. Ce que tu apprends ici se transpose directement à un projet React web, et au futur apps/web de Halterofit qui partagera cette même façon de penser le style.