Thèmes

Thème du projet (defineTheme, ThemeStyle, getThemeValues), jetons de couleur, bascule clair et sombre, hook useTheme.

core/themes porte l'apparence de tout le projet : le theme.js qui la décrit, les jetons CSS que les composants écrivent, et la bascule clair / sombre. Un composant n'écrit jamais une nuance de palette, seulement un jeton : c'est ce qui permet à un seul fichier de changer l'admin, les pages publiques, les modules, les courriels et les PDF en même temps.

Import

// Déclaration, dans le theme.js du projet. Module neutre.
import { defineTheme, resolveTheme } from '@fraza/core/themes/define';

// Lecture côté serveur : layout racine, courriels, PDF.
import { ThemeStyle, getTheme, getThemeValues } from '@fraza/core/themes/server';

// Bascule clair / sombre, côté client.
import {
  getStoredTheme, applyTheme, getAllowedModes, getThemeIcon,
  ThemeWatcher, useTheme, THEME_INIT_SCRIPT,
} from '@fraza/core/themes';

@fraza/core/themes est marqué 'use client'. @fraza/core/themes/server ne s'importe que côté serveur. @fraza/core/themes/define n'a aucune dépendance et se charge partout.

API

FonctionRetourRôle
defineTheme(options)objet geléValide les choix du projet. Lève sur une valeur hors liste.
resolveTheme(theme?){ light, dark, shared, shell, chrome, modes }Calcule la valeur de chaque jeton dans les deux modes.
<ThemeStyle />éléments de <head>Injecte les jetons du thème et le script anti-flash.
getTheme()thème résoluThème enregistré par initializeFraza, ou l'apparence par défaut.
getThemeValues(mode?)Record<string, string>Valeurs hex d'un mode, pour les courriels et les PDF.
useTheme(){ theme, toggle, systemIsDark, canToggle }État de la bascule clair / sombre.
applyTheme(theme)—Applique 'light', 'dark' ou 'auto'.
getAllowedModes()string[]Modes offerts par le thème du projet.

defineTheme(options)

Toutes les options sont facultatives. Sans elles, l'apparence est celle de la charte : noir, blanc et gris, accent bleu fleuve #2457c5, bouton principal à l'encre, sidebar.

OptionValeursDéfautEffet
primary'neutral' ou une couleur hex'neutral'Bouton principal. 'neutral' est l'encre : #0a0a0a en clair, #fafafa en sombre. Une couleur hex ne change que le bouton principal, sauf quand accent est absent.
accentune couleur hex'#2457c5'Liens, focus, cases cochées, interrupteurs, élément actif, statut info. Assombri au démarrage s'il est sous 4,5:1 sur blanc, avec un avertissement. Absent avec un primary hex : tiré de primary.
neutral'neutral', 'stone', 'slate', 'zinc', 'gray''neutral'Teinte des fonds, textes et bordures, en clair comme en sombre.
radius'none', 'sm', 'md', 'lg''md'Arrondis des petits éléments, des contrôles et des cartes : 0/0/0, 4/4/6, 6/8/12, 8/10/16 px.
font{ sans?, mono? }IBM PlexFamilles de police. Le projet charge lui même une police autre qu'IBM Plex.
chrome'light', 'dark''light''dark' garde la barre supérieure et la sidebar foncées, quel que soit le mode.
shell'sidebar', 'topbar''sidebar'Coquille de l'admin. 'topbar' met la navigation dans la barre du haut.
modes['light', 'dark'], ['light'], ['dark']les deuxUn seul mode l'impose, et masque toutes les bascules.
overrides{ light?, dark?, shared? }{}Valeur exacte d'un jeton, par mode. Les clés sont limitées aux jetons connus.
// theme.js
import { defineTheme } from '@fraza/core/themes/define';

export default defineTheme({
  accent: '#0f766e',
  neutral: 'stone',
  chrome: 'dark',
  shell: 'topbar',
  overrides: { light: { canvas: '#f3f2ef' } },
});

Calcul de l'accent

Sans accent, ou avec accent: '#2457c5' écrit en toutes lettres, les jetons d'accent prennent les valeurs exactes de la charte. Sinon, le core les calcule en OKLCH à partir de la couleur donnée :

JetonClairSombre
accentLa couleur, assombrie si elle est sous 4,5:1 sur blanc.La couleur remontée à une luminosité d'au moins 0,72, puis assurée à 4,5:1 sur #0b0b0b.
accent-hoverLuminosité moins 0,05.Luminosité plus 0,08.
accent-fgBlanc.Blanc ou #0a0a0a, selon ce qui se lit le mieux.
ring, info= accent= accent
info-fg= accent-fg= accent-fg

accent-soft, accent-line, info-soft, info-line et info-ink suivent en CSS, par color-mix().

Déclinaisons des statuts

Les fonds pâles (-soft), les bordures (-line) et les encres (-ink) de danger, success et warning sont des teintes fixes de la charte, pas des mélanges. Un overrides qui change la couleur d'un de ces statuts sans donner ses déclinaisons les reçoit calculées par resolveTheme() :

JetonClairSombre
-softLa couleur à 8 % sur surface.La couleur en voile à 15 %.
-lineLa couleur à 30 % sur surface.La couleur en voile à 30 %.
-inkLa couleur assombrie jusqu'à 6:1 sur son fond pâle.La couleur elle-même.
danger-hoverLa couleur assombrie de 15 %.Idem.
danger-solidLa couleur claire donnée à danger.La couleur claire aussi : le bouton danger plein garde le même rouge franc dans les deux modes.

Une déclinaison écrite dans overrides est gardée telle quelle. Un rouge changé n'hérite donc jamais du fond rose et du texte bordeaux d'un autre rouge.

Quand la couleur donnée est trop pâle, defineTheme écrit un avertissement au démarrage, qui nomme la valeur, son contraste et la valeur retenue. Le démarrage continue :

⚠ defineTheme: `accent` #ffcc00 est trop pâle sur blanc (1.51:1, minimum 4.5:1). #907200 est utilisé en clair.

Ici, #907200 est utilisé en clair et #ffcc00 reste l'accent en sombre. L'avertissement est écrit une fois par environnement qui charge theme.js, Node et edge. Une valeur qui n'est pas une couleur hex, 'blue' par exemple, fait échouer le démarrage.

Calcul du bouton principal

Avec une couleur hex, primary donne primary, primary-hover et primary-fg : le survol, une version plus claire pour le mode sombre (une couleur foncée se perdrait sur le noir), et le texte posé dessus, blanc ou noir selon le contraste. Sans option accent, l'accent est alors tiré de primary par le calcul ci-dessus.

Une valeur hors liste lève au démarrage, avec le nom des valeurs admises. Une faute de frappe ne produit donc jamais un admin illisible en silence.

<ThemeStyle />

À placer dans le <head> du layout racine. Rend deux balises :

  1. Un <style> qui ne contient que les jetons dont la valeur diffère des défauts de core.css. Sans theme.js, ou avec un theme.js qui ne porte que les défauts, il n'est pas rendu.
  2. Le script anti-flash, qui pose la classe dark sur <html> avant le premier rendu. Il respecte modes et pose data-fraza-modes sur <html> pour les bascules. Sa balise porte l'identifiant fraza-theme-init et les modes dans data-modes, que ThemeWatcher relit quand le script n'a pas été exécuté.
// app/layout.js
import { ThemeStyle } from '@fraza/core/themes/server';

export default function RootLayout({ children }) {
  return (
    <html lang="fr" suppressHydrationWarning>
      <head>
        <ThemeStyle />
      </head>
      <body>{children}</body>
    </html>
  );
}

getTheme() et getThemeValues(mode)

Le thème est enregistré par initializeFraza({ modules, theme }), depuis instrumentation.js. getTheme() le lit. Sans thème enregistré, il renvoie l'apparence par défaut.

getThemeValues('light') renvoie les valeurs hex d'un mode, clés au nom des jetons. Il sert à tout ce qui ne lit pas les variables CSS : un PDF, un style inline de courriel.

import { getThemeValues } from '@fraza/core/themes/server';

function getPdfPalette() {
  const t = getThemeValues('light');
  return { ink: t.fg, muted: t['fg-muted'], rule: t.line, paid: t.success };
}

Les déclinaisons calculées en CSS par color-mix() y sont mélangées contre la surface, au même dosage que core.css. accent-soft vaut 8 % d'accent en clair et 14 % en sombre : #edf2fa et #1b212c avec l'accent par défaut. accent-line vaut 35 %, primary-soft 10 %. info-soft vaut 8 % en clair et 16 % en sombre, info-line 20 % et 30 %, et info-ink vaut info.

Les déclinaisons de danger, success et warning sont des jetons, renvoyés tels quels : danger-soft, danger-line, danger-ink, etc. Les voiles du mode sombre, écrits en rgb(… / 0.15), y sont aplatis sur la surface en hex.

Pour les courriels, @fraza/core/email/templates expose des raccourcis : voir Courriel.

useTheme()

Retourne { theme, toggle, systemIsDark, canToggle } et synchronise l'état avec localStorage et le système au montage.

PropriétéTypeRôle
theme'light' | 'dark' | 'auto'Thème actif.
toggle() => voidPasse au thème suivant du cycle.
systemIsDarkbooleanÉtat courant de la préférence système.
canTogglebooleanfalse quand le thème du projet n'offre qu'un mode. Une bascule ne s'affiche pas.

Le cycle dépend de la préférence système, pour que le premier basculement produise toujours un changement visible :

  • Système clair : auto → dark → light → auto
  • Système sombre : auto → light → dark → auto
import { useTheme, getThemeIcon } from '@fraza/core/themes';

export function ThemeToggle() {
  const { theme, toggle, canToggle } = useTheme();
  if (!canToggle) return null;
  const Icon = getThemeIcon(theme);
  return <button onClick={toggle}><Icon /></button>;
}

applyTheme(theme) et getStoredTheme()

getStoredTheme() retourne 'light', 'dark' ou 'auto' selon localStorage. applyTheme modifie la classe de <html> et localStorage. Il ne fait rien quand le thème du projet impose un mode.

ValeurEffet
'light'Retire la classe dark, stocke 'light'.
'dark'Ajoute la classe dark, stocke 'dark'.
'auto'Retire la valeur stockée et suit prefers-color-scheme.

getThemeIcon(theme)

ThèmeIcône
'light'Sun01Icon
'dark'Moon02Icon
'auto'ComputerIcon

L'icône du thème système ne dépend pas de la préférence système : un ordinateur dit que l'appareil décide, sans laisser croire à un troisième rendu.

ThemeWatcher

Composant sans rendu qui fait deux choses. FrazaProvider le monte déjà.

Il suit prefers-color-scheme quand aucune préférence n'est stockée, et que le thème offre les deux modes.

Il rejoue le script anti-flash quand celui-ci n'a pas tourné. Next.js rend côté client le document entier d'une page qui appelle notFound() : React insère alors le <script> de <ThemeStyle /> sans l'exécuter. Au montage, si data-fraza-modes est absent de <html>, ThemeWatcher applique la même logique que le script : il lit les modes sur la balise fraza-theme-init, pose data-fraza-modes, puis la classe dark selon le mode imposé, la préférence stockée ou le système. Il le fait avant le premier affichage, donc sans flash. Sur une page rendue par le serveur, l'attribut est déjà là et rien ne se passe.

THEME_INIT_SCRIPT

Ancien script anti-flash, sans connaissance des modes du thème. Il reste exporté pour les projets qui n'ont pas encore adopté <ThemeStyle />, que fraza-doctor signale.

Jetons

Les jetons sont déclarés dans core.css, sous le préfixe --fraza-*, et enregistrés dans Tailwind. Chacun produit les utilitaires habituels : bg-surface, text-fg-muted, border-line, bg-line-soft, bg-field, hover:bg-accent-hover, ring-ring/20, bg-danger/10.

Valeurs par défaut, en clair et en sombre :

GroupeJetonClairSombreRôle
Fondscanvas#f8f8f8#000000Fond de la zone de contenu.
surface#ffffff#0b0b0bCartes, menus, fenêtres.
surface-muted#f5f5f5#141414Puces et fonds gris.
surface-hover#fafafa#141414Survol d'une ligne ou d'une carte, en-tête de tableau en clair.
Textefg#171717#fafafaTexte principal.
fg-secondary#525252#d4d4d4Texte secondaire.
fg-muted#737373#a3a3a3Texte discret.
fg-subtle#a3a3a3#737373Texte le plus discret.
Lignesline#e5e5e5#1f1f1fSéparations.
line-strong#d4d4d4#333333Bordures appuyées.
line-soft#f0f0f0#171717Séparateurs de lignes de tableau, fond désactivé.
Champsfield#ffffff#141414Fond des champs de saisie.
Contrôlescontrol#ffffff#141414Fond d'un bouton secondaire. Blanc en clair, gris plein en sombre.
control-hover#f5f5f5#1f1f1fSon survol.
control-line#d4d4d4#333333Bordure des boutons secondaires et des champs.
Bouton principalprimary#0a0a0a#fafafaBouton principal.
primary-hover#2e2e2e#e5e5e5Son survol.
primary-fg#ffffff#0a0a0aTexte posé dessus.
primary-soft10 %10 %Fond pâle, calculé.
Accentaccent#2457c5#7aa5f8Liens, cases cochées, élément actif.
accent-hover#1d4aa8#a3c1faSurvol d'un lien ou d'un élément accentué.
accent-fg#ffffff#0a0a0aTexte posé sur l'accent plein.
accent-soft8 %14 %Fond de l'élément actif, calculé.
accent-line35 %35 %Bordure accentuée, calculée.
Focusring= accent= accentAnneau de focus.
Barre et sidebarchrome#ffffff#0b0b0bFond de la coquille, piloté par l'option chrome.
chrome-fg#171717#fafafaTexte actif.
chrome-fg-muted#525252#a3a3a3Texte des items.
chrome-line#e5e5e5#1f1f1fBordures.
chrome-hover#f5f5f5#141414Survol.
Statutsdanger#dc2626#f87171Erreur, suppression.
danger-hover#b91c1c#ef4444Texte d'une action destructrice discrète.
danger-solid#dc2626#dc2626Fond du bouton danger plein, le même rouge franc dans les deux modes.
success#15803d#4ade80Succès.
warning#a16207#facc15Avertissement.
info= accent= accentInformation.
danger-fg#ffffff#ffffffTexte du bouton danger plein, posé sur danger-solid.
success-fg, warning-fg, info-fg#ffffff#0a0a0aTexte posé sur la couleur pleine.
danger-soft#fef2f2rgb(239 68 68 / 0.15)Fond pâle : pastille, alerte.
success-soft#f0fdf4rgb(34 197 94 / 0.14)
warning-soft#fffbebrgb(234 179 8 / 0.14)
danger-line#fecacargb(239 68 68 / 0.3)Bordure d'une alerte, du bouton dangerSoft.
success-line#bbf7d0rgb(34 197 94 / 0.3)
warning-line#fde68argb(234 179 8 / 0.3)
danger-ink#991b1b#f87171Texte posé sur le fond pâle.
success-ink#166534#4ade80
warning-ink#92400e#facc15
info-soft, info-line8 %, 20 %16 %, 30 %Fond pâle et bordure de l'info, calculés.
info-ink= info= infoTexte de l'info sur son fond pâle.
Formesrounded-item, rounded-control, rounded-card6 px, 8 px, 12 pxPetits éléments (items de menu, étiquettes, segments), contrôles, cartes et panneaux.
Policesfont-ui, font-codeIBM Plex Sans, IBM Plex MonoInterface, codes et montants.

Les pourcentages sont ceux de color-mix() avec la couleur pleine. Tous les jetons de couleur, line-soft, field et accent-hover compris, sont acceptés dans overrides.light et overrides.dark. C'est aussi le cas de danger-hover, de danger-solid et des neuf déclinaisons de statut : danger-soft, success-soft, warning-soft, danger-line, success-line, warning-line, danger-ink, success-ink et warning-ink. Les déclinaisons de l'info, de l'accent et de primary ne s'écrivent pas : elles suivent leur couleur.

overrides: {
  light: { danger: '#c81e1e', 'danger-ink': '#7f1414' }, // danger-soft et danger-line calculés
}

Les autres échelles de gris, stone, slate, zinc et gray, suivent la même structure : le fond de page un cran sous le survol, line-soft entre surface-muted et line, field blanc en clair.

font-sans et font-mono, les familles par défaut de Tailwind, pointent aussi sur le thème. Un texte rendu hors de la coquille, dans un menu, une fenêtre ou une notification en portail dans <body>, reste ainsi en IBM Plex. font-ibm-plex-sans et font-ibm-plex-mono restent disponibles pour les gabarits de projets existants. Ils suivent eux aussi l'option font.

core.css pose aussi trois règles de base. color-scheme suit le mode, pour que barres de défilement, calendriers et listes natives passent au sombre. Un :focus-visible par défaut trace l'anneau de 2 px en ring, décalé de 2 px, sur tout lien ou bouton sans recette de focus. La sélection de texte prend l'accent à 22 %.

Notes

Les défauts vivent dans @layer base. Les valeurs de <ThemeStyle />, hors couche, les battent sans dépendre de l'ordre des feuilles dans <head>. Pour la même raison, une redéfinition de --fraza-* dans le CSS du projet l'emporte sur les défauts, mais pas forcément sur <ThemeStyle /> : pour changer la valeur d'un jeton, passer par overrides.

Le thème se lit au rendu, jamais au chargement d'un fichier. Le manifeste des modules lance leur register() avant qu'initializeFraza n'enregistre le thème. Une constante calculée au niveau racine d'un fichier garderait l'apparence par défaut.

Le thème sombre est une classe CSS, pas un état React partagé. La source de vérité est la classe dark sur <html>, posée par le script avant React, ou par ThemeWatcher quand le document est rendu côté client. useTheme la lit au montage plutôt que de l'imposer, ce qui évite un second flash à l'hydratation.

Pour les règles d'usage des jetons, voir Design system.

Sur cette page