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
| Fonction | Retour | Rô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ésolu | Thè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.
| Option | Valeurs | Défaut | Effet |
|---|---|---|---|
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. |
accent | une 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 Plex | Familles 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 deux | Un 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 :
| Jeton | Clair | Sombre |
|---|---|---|
accent | La 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-hover | Luminosité moins 0,05. | Luminosité plus 0,08. |
accent-fg | Blanc. | 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() :
| Jeton | Clair | Sombre |
|---|---|---|
-soft | La couleur à 8 % sur surface. | La couleur en voile à 15 %. |
-line | La couleur à 30 % sur surface. | La couleur en voile à 30 %. |
-ink | La couleur assombrie jusqu'à 6:1 sur son fond pâle. | La couleur elle-même. |
danger-hover | La couleur assombrie de 15 %. | Idem. |
danger-solid | La 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 :
- Un
<style>qui ne contient que les jetons dont la valeur diffère des défauts decore.css. Sanstheme.js, ou avec untheme.jsqui ne porte que les défauts, il n'est pas rendu. - Le script anti-flash, qui pose la classe
darksur<html>avant le premier rendu. Il respectemodeset posedata-fraza-modessur<html>pour les bascules. Sa balise porte l'identifiantfraza-theme-initet les modes dansdata-modes, queThemeWatcherrelit 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é | Type | Rôle |
|---|---|---|
theme | 'light' | 'dark' | 'auto' | Thème actif. |
toggle | () => void | Passe au thème suivant du cycle. |
systemIsDark | boolean | État courant de la préférence système. |
canToggle | boolean | false 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.
| Valeur | Effet |
|---|---|
'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ème | Icô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 :
| Groupe | Jeton | Clair | Sombre | Rôle |
|---|---|---|---|---|
| Fonds | canvas | #f8f8f8 | #000000 | Fond de la zone de contenu. |
surface | #ffffff | #0b0b0b | Cartes, menus, fenêtres. | |
surface-muted | #f5f5f5 | #141414 | Puces et fonds gris. | |
surface-hover | #fafafa | #141414 | Survol d'une ligne ou d'une carte, en-tête de tableau en clair. | |
| Texte | fg | #171717 | #fafafa | Texte principal. |
fg-secondary | #525252 | #d4d4d4 | Texte secondaire. | |
fg-muted | #737373 | #a3a3a3 | Texte discret. | |
fg-subtle | #a3a3a3 | #737373 | Texte le plus discret. | |
| Lignes | line | #e5e5e5 | #1f1f1f | Séparations. |
line-strong | #d4d4d4 | #333333 | Bordures appuyées. | |
line-soft | #f0f0f0 | #171717 | Séparateurs de lignes de tableau, fond désactivé. | |
| Champs | field | #ffffff | #141414 | Fond des champs de saisie. |
| Contrôles | control | #ffffff | #141414 | Fond d'un bouton secondaire. Blanc en clair, gris plein en sombre. |
control-hover | #f5f5f5 | #1f1f1f | Son survol. | |
control-line | #d4d4d4 | #333333 | Bordure des boutons secondaires et des champs. | |
| Bouton principal | primary | #0a0a0a | #fafafa | Bouton principal. |
primary-hover | #2e2e2e | #e5e5e5 | Son survol. | |
primary-fg | #ffffff | #0a0a0a | Texte posé dessus. | |
primary-soft | 10 % | 10 % | Fond pâle, calculé. | |
| Accent | accent | #2457c5 | #7aa5f8 | Liens, cases cochées, élément actif. |
accent-hover | #1d4aa8 | #a3c1fa | Survol d'un lien ou d'un élément accentué. | |
accent-fg | #ffffff | #0a0a0a | Texte posé sur l'accent plein. | |
accent-soft | 8 % | 14 % | Fond de l'élément actif, calculé. | |
accent-line | 35 % | 35 % | Bordure accentuée, calculée. | |
| Focus | ring | = accent | = accent | Anneau de focus. |
| Barre et sidebar | chrome | #ffffff | #0b0b0b | Fond de la coquille, piloté par l'option chrome. |
chrome-fg | #171717 | #fafafa | Texte actif. | |
chrome-fg-muted | #525252 | #a3a3a3 | Texte des items. | |
chrome-line | #e5e5e5 | #1f1f1f | Bordures. | |
chrome-hover | #f5f5f5 | #141414 | Survol. | |
| Statuts | danger | #dc2626 | #f87171 | Erreur, suppression. |
danger-hover | #b91c1c | #ef4444 | Texte d'une action destructrice discrète. | |
danger-solid | #dc2626 | #dc2626 | Fond du bouton danger plein, le même rouge franc dans les deux modes. | |
success | #15803d | #4ade80 | Succès. | |
warning | #a16207 | #facc15 | Avertissement. | |
info | = accent | = accent | Information. | |
danger-fg | #ffffff | #ffffff | Texte du bouton danger plein, posé sur danger-solid. | |
success-fg, warning-fg, info-fg | #ffffff | #0a0a0a | Texte posé sur la couleur pleine. | |
danger-soft | #fef2f2 | rgb(239 68 68 / 0.15) | Fond pâle : pastille, alerte. | |
success-soft | #f0fdf4 | rgb(34 197 94 / 0.14) | ||
warning-soft | #fffbeb | rgb(234 179 8 / 0.14) | ||
danger-line | #fecaca | rgb(239 68 68 / 0.3) | Bordure d'une alerte, du bouton dangerSoft. | |
success-line | #bbf7d0 | rgb(34 197 94 / 0.3) | ||
warning-line | #fde68a | rgb(234 179 8 / 0.3) | ||
danger-ink | #991b1b | #f87171 | Texte posé sur le fond pâle. | |
success-ink | #166534 | #4ade80 | ||
warning-ink | #92400e | #facc15 | ||
info-soft, info-line | 8 %, 20 % | 16 %, 30 % | Fond pâle et bordure de l'info, calculés. | |
info-ink | = info | = info | Texte de l'info sur son fond pâle. | |
| Formes | rounded-item, rounded-control, rounded-card | 6 px, 8 px, 12 px | Petits éléments (items de menu, étiquettes, segments), contrôles, cartes et panneaux. | |
| Polices | font-ui, font-code | IBM Plex Sans, IBM Plex Mono | Interface, 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.