Les styles
Tailwind CSS 4 configuré dans la feuille de style, des couleurs de thème portées par des variables, le thème clair ou sombre choisi par le visiteur, les anciennes couleurs codées en dur, et le style des pages que les administrateurs règlent eux-mêmes.
Précédemment. Un fichier marqué « use client » fait passer ses imports côté navigateur. Ce qu'un composant serveur transmet à un composant client doit être sérialisable, et aucune donnée sensible ne traverse la frontière. Le thème du visiteur en a donné un exemple : la classe
darkest posée dès le HTML serveur, d'après un cookie.
Ce chapitre suit le chemin d'une couleur et d'un espacement, de leur déclaration jusqu'à l'écran. Tout repose sur un même outil : les variables CSS. Elles portent les couleurs du thème, ce qui permet de basculer en mode sombre sans réécrire un composant ; elles portent aussi la typographie des pages, ce qui permet à un administrateur de la régler depuis le tableau de bord.
Tailwind 4 : la configuration est dans la feuille de style#
Tailwind fournit des classes utilitaires (flex, px-4, text-sm) que l'on compose directement dans le JSX. Sa version 4 se configure en CSS, dans le fichier qui l'importe, et non plus dans un fichier JavaScript :
@import "tailwindcss";@import "tw-animate-css";@plugin "@tailwindcss/typography";@custom-variant dark (&:is(.dark *));La première ligne charge Tailwind ; la deuxième, une bibliothèque d'animations ; @plugin ajoute le plugin de typographie, qui fournit la classe prose utilisée pour les articles et les documents. Tailwind 4 trouve seul les fichiers à analyser : aucune liste de chemins n'est à tenir à jour. Côté outillage, une seule ligne de PostCSS suffit (@tailwindcss/postcss dans apps/web/postcss.config.mjs).
Avoir la configuration dans la feuille de style permet de voir au même endroit les réglages de Tailwind, les variables du thème et les styles du projet. Il n'y a donc pas de fichier tailwind.config.ts dans le dépôt : Tailwind 4 ne le lirait pas sans une directive @config explicite.
La quatrième ligne redéfinit la variante dark:. Par défaut, Tailwind 4 l'associe à la préférence du système d'exploitation (prefers-color-scheme). Ici, dark:bg-white/10 s'applique quand l'élément se trouve sous un élément portant la classe dark. C'est le site qui décide du thème, d'après le choix du visiteur ; nous y venons plus bas.
Les couleurs du thème sont des variables#
Une couleur n'est jamais écrite dans un composant récent. Elle est désignée par son rôle : le fond (background), le texte (foreground), une carte (card), un texte discret (muted-foreground), une bordure (border). Chaque rôle est une variable CSS, avec une valeur pour le thème clair et une autre pour le thème sombre :
:root { --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.13 0.028 261.692); --card: oklch(1 0 0); /* … */}.dark { --background: oklch(0.24 0.012 260); --foreground: oklch(0.985 0.002 247.839); --card: oklch(0.285 0.013 260); /* … */}Les valeurs sont en OKLCH : luminosité, chroma (intensité de la couleur), teinte. Contrairement au RVB, la luminosité y correspond à ce que perçoit l'œil. Éclaircir une surface d'un cran revient à augmenter le premier nombre, sans toucher à la teinte : c'est ainsi que les surfaces sombres (0.24 pour le fond, 0.285 pour les cartes, 0.34 pour les zones atténuées) restent de la même famille.
Le bloc @theme inline relie ces variables aux classes de Tailwind :
@theme inline { --color-background: var(--background); --color-foreground: var(--foreground); /* … */ --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); /* … */ --color-card: var(--card); /* … */}Chaque --color-xxx déclaré dans @theme crée les classes bg-xxx, text-xxx, border-xxx. Le mot inline demande à Tailwind d'écrire la référence à la variable dans la classe générée : bg-background devient background-color: var(--background). La variable est donc résolue sur l'élément qui porte la classe, et sa valeur change dès qu'un ancêtre porte .dark.
Une seule classe, bg-card, sert les deux thèmes. Basculer en mode sombre ne demande aucune classe dark: dans les composants qui utilisent les rôles du thème : c'est la valeur de la variable qui change.
Le thème du visiteur : un cookie lu par le serveur#
Le site s'affiche en clair par défaut. Le visiteur peut passer en sombre avec l'interrupteur du header, et son choix doit lui être servi dès la page suivante.
Le choix est gardé dans un cookie, que le layout racine lit pendant le rendu serveur :
const isDark = (await cookies()).get(THEME_COOKIE)?.value === "dark";// …<html lang="fr" className={isDark ? "dark" : undefined} suppressHydrationWarning>La page arrive donc déjà dans le bon thème. Un localStorage ne serait lisible qu'une fois le JavaScript chargé : la page s'afficherait d'abord en clair, puis basculerait (le « flash » bien connu), ou il faudrait un script bloquant avant le rendu. Le cookie évite les deux.
L'interrupteur change la classe de <html> et réécrit le cookie :
export function applyTheme(theme: Theme): void { document.documentElement.classList.toggle("dark", theme === "dark"); // Un an ; lisible par le serveur (pas HttpOnly : c'est une préférence d'affichage, pas un secret). document.cookie = `${THEME_COOKIE}=${theme}; path=/; max-age=31536000; samesite=lax`;}Le composant ThemeToggle ne garde pas l'état du thème dans un useState. Il le lit directement sur <html>, avec useSyncExternalStore et un MutationObserver qui le prévient quand la classe change :
const theme = useSyncExternalStore<Theme>(subscribeTheme, readTheme, () => "light");<html> reste ainsi la seule source de vérité. Le header et la documentation affichent chacun un interrupteur, et les deux restent d'accord sans état partagé à synchroniser. Le troisième argument est la valeur utilisée pendant l'hydratation : React rend d'abord l'icône du thème clair, puis relit <html> et corrige l'icône au premier rendu client, sans erreur d'hydratation.
Les couleurs codées en dur#
Les composants les plus anciens fixent encore leurs couleurs : bg-white, text-gray-700, border-gray-200. Le dépôt en compte plusieurs centaines, à peu près autant que de classes de thème. En mode sombre, elles donneraient une carte blanche sur fond sombre, ou un texte gris foncé sur ce même fond.
Plutôt que de modifier tous ces composants d'un coup, un fichier les réoriente vers les variables du thème, en mode sombre seulement :
/* Fonds clairs → surfaces du thème */.dark .bg-white { background-color: var(--card); }.dark .bg-gray-50,.dark .bg-gray-100,.dark .bg-slate-50,.dark .bg-slate-100 { background-color: var(--muted); }/* … *//* Textes sombres → textes du thème */.dark .text-gray-900,.dark .text-gray-800,.dark .text-gray-700,.dark .text-slate-900,.dark .text-slate-800,.dark .text-slate-700 { color: var(--foreground); }Ce fichier est importé par le layout racine hors de toute couche (@layer). C'est ce qui lui donne la priorité : Tailwind range ses utilitaires dans @layer utilities, et une règle sans couche l'emporte sur toute règle en couche, quelle que soit sa spécificité. Aucun !important n'est nécessaire.
Le même fichier traite la classe prose. Le plugin de typographie fixe ses couleurs pour un fond clair. En mode sombre, ses variables --tw-prose-* sont remplacées par les valeurs « inversées » que le plugin fournit, avec le corps du texte sur --foreground.
La règle pour le code neuf : les rôles du thème (bg-card, text-muted-foreground, border-border). La variante dark: est réservée aux exceptions, comme les chevrons du carrousel posés sur une photo. Chaque composant qui passe aux rôles du thème rend une règle de dark-legacy.css inutile.
Le rendu des pages : les variables --akfc-*#
Les pages construites au constructeur (l'accueil, « L'association », les fiches de discipline) partagent un second jeu de variables. Celles-ci règlent la typographie et la mise en page plutôt que les couleurs : l'interlignage, l'écart entre paragraphes, la taille des titres, la largeur de la colonne de texte, la gouttière entre deux colonnes.
:root { --akfc-leading: 1.65; --akfc-para-gap: 1em; --akfc-heading-gap: 1.8em; /* … */ --akfc-h1-min: 1.6rem; --akfc-h1-max: 2.75rem; /* … */ --akfc-measure: 68ch; /* … */}Quelques choix de valeurs :
- la largeur du texte (
--akfc-measure) est ench, la largeur d'un caractère : la ligne garde environ 68 caractères quelle que soit la taille de la police, dans la plage de lecture confortable (45 à 75) ; - les trois premiers niveaux de titre sont fluides : la taille grandit avec la fenêtre, entre un minimum et un maximum lisibles ;
- les blocs à deux colonnes interrogent leur propre largeur avec des container queries (
@container), et non celle de la fenêtre. Un même bloc s'affiche en effet sur toute la largeur du site public et, dans le tableau de bord, à côté d'une barre latérale.
Pour les titres, seules les bornes sont des variables. Le clamp(minimum, valeur préférée, maximum) est écrit sur la règle du titre :
.akfc-prose h1 { font-size: clamp(var(--akfc-h1-min), 1.1rem + 2.2vw, var(--akfc-h1-max)); }Le laboratoire règle ainsi la taille sur téléphone et sur grand écran, sans perdre la progression entre les deux.
Une variable calculée à partir d'une autre est déclarée sur la classe qui l'utilise, et non sur :root :
.akfc-prose { --akfc-base-size: clamp(1rem, 0.9rem + 0.5vw, var(--akfc-base-max)); font-size: var(--akfc-base-size); line-height: var(--akfc-leading);}Une variable composée est calculée sur l'élément où elle est déclarée. Déclarée sur :root, --akfc-base-size serait calculée sur <html> et ne verrait pas une surcharge de --akfc-base-max posée plus bas dans la page. Déclarée sur .akfc-prose, elle est calculée là où le texte s'affiche et prend en compte toute surcharge d'un ancêtre. C'est précisément ce que fait le laboratoire de rendu.
Le style réglable : de la base à la feuille de style#
Les valeurs de globals.css sont des valeurs de repli. Un administrateur peut les remplacer depuis le laboratoire de rendu (/dashboard/design-lab) : des curseurs à gauche, un aperçu de blocs réels à droite, à plusieurs largeurs d'écran.
L'aperçu : les mêmes variables, surchargées en ligne#
Les curseurs n'écrivent pas dans une feuille parallèle. Ils surchargent les vraies variables, en style inline, sur le conteneur de l'aperçu :
const styleOverrides = { ...Object.fromEntries(KNOBS.map((k) => [k.key, `${values[k.key]}${k.unit}`])), "--akfc-media-col": ratio.media, "--akfc-text-col": ratio.text,} as CSSProperties;Les blocs de l'aperçu sont les composants du site. Ils héritent des variables de leur conteneur, et ce que montre le laboratoire est donc ce que rendra la page publique.
Le ratio entre l'image et le texte d'un bloc média-texte est un préréglage en douzièmes (1/3, 5/12, 1/2, 7/12, 2/3), et non un pourcentage libre. Une grille commune garde les pages alignées les unes sur les autres.
L'enregistrement : une ligne en base, réservée aux administrateurs#
« Appliquer au site » enregistre le jeu complet dans une ligne unique, SiteStyle. Les variables y sont stockées en JSON : ajouter un curseur au laboratoire ne demande pas de migration.
Ces valeurs finiront dans une balise <style> servie à tous les visiteurs. Le routeur contrôle donc strictement ce qui peut y entrer :
const variableName = z .string() .regex(/^--akfc-[a-z0-9-]{1,40}$/, "Nom de variable non conforme");const variableValue = z .string() .max(64) .regex(/^[a-zA-Z0-9 .,%()#/_-]*$/, "Valeur de variable non conforme");Le nom porte obligatoirement le préfixe --akfc-. La valeur ne peut contenir ni ;, ni }, ni < : aucune valeur ne peut fermer la déclaration ou la balise pour y glisser autre chose.
L'enregistrement est réservé aux administrateurs, comme celui du texte d'accueil et des pages de contenu :
/** Le style vaut pour le site entier : seul un administrateur l'enregistre. */const adminProcedure = protectedProcedure.use(isAdmin);Le tableau de bord ne s'affiche qu'aux administrateurs, mais une procédure tRPC peut être appelée directement, sans passer par aucune page. Le contrôle doit donc se trouver dans la procédure elle-même. C'est la règle déjà appliquée aux server actions dans le chapitre sur la frontière.
L'injection : une feuille calculée au rendu serveur#
Le layout racine lit la ligne et la transforme en une déclaration CSS :
function serializeSiteStyle(variables: Record<string, string>): string { const body = Object.entries(variables) .map(([key, value]) => `${key}:${value};`) .join(""); return `html:root{${body}}`;}Le sélecteur html:root pèse plus lourd que :root (spécificité 0,0,2 contre 0,0,1). La surcharge l'emporte donc sur les valeurs de repli quel que soit l'ordre des feuilles dans la page, que Next peut modifier au gré du découpage du CSS.
La déclaration est rendue dans une balise <style> à laquelle React 19 donne un traitement particulier :
{styleOverride && ( <style href={styleKey} precedence="high"> {styleOverride} </style>)}Avec href et precedence, React hisse la balise dans le <head>, même rendue depuis <body>, et ne l'insère qu'une fois par valeur de href. Cette valeur est construite à partir de updatedAt. Elle change donc à chaque enregistrement, et React insère la nouvelle feuille au lieu de considérer qu'il l'a déjà.
Après l'enregistrement, le laboratoire appelle /api/admin/revalidate-style. Cette route, réservée elle aussi aux administrateurs, invalide le rendu du layout racine (revalidatePath("/", "layout")). Le laboratoire recharge ensuite la page sur une URL neuve, qu'aucun cache du navigateur ne peut servir.
Trois niveaux, une seule priorité : les valeurs de repli de globals.css (:root), le réglage enregistré (html:root, injecté par le layout), et l'aperçu du laboratoire (style inline sur son conteneur). Chaque niveau surcharge le précédent par la cascade, sans !important.
Récapitulatif#
Question | Réponse du projet |
|---|---|
Où se configure Tailwind ? | Dans globals.css : @import, @plugin, @custom-variant, @theme. Aucun fichier de configuration JavaScript. |
Comment une couleur suit-elle le thème ? | Elle est désignée par son rôle (bg-card, text-foreground) ; le rôle est une variable redéfinie sous .dark. |
Où vit le choix du visiteur ? | Dans un cookie lu par le layout racine : la classe dark est posée dès le HTML serveur. |
Et les couleurs codées en dur ? | dark-legacy.css les réoriente vers les variables du thème, en mode sombre, hors couche CSS. Le code neuf utilise les rôles du thème. |
Qu’est-ce qui règle la typographie des pages ? | Les variables --akfc-*, déclarées sur :root ; leurs compositions sur la classe qui les utilise. |
Comment un administrateur règle-t-il le style ? | Au laboratoire de rendu : aperçu en style inline, enregistrement dans SiteStyle (procédure réservée aux administrateurs, noms et valeurs filtrés), injection par le layout en html:root. |
Showing entries1to6entries
À suivre#
Le front est en place : des routes et des layouts, une frontière nette entre serveur et client, une mise en forme pilotée par des variables. Toutes ses pages lisent pourtant des données, et le réglage de style lui-même est une ligne en base.
Chapitre suivant : les données avec Prisma. Le schéma comme source de vérité, ce qui se passe à la suppression d'une ligne, les migrations, et les transactions qui gardent plusieurs écritures cohérentes.