Créer un thème
Créer un thème
Un thème donne au site son identité visuelle complète : la mise en page (navigation, régions, pied de page), la charte (couleurs, typographies) et les dispositions par défaut (quels widgets, où).
Nous esquissons un thème aurora. Le plus simple pour démarrer est de cloner themes/nebula/ (le
thème public livré) puis d'adapter. Tout ce qui suit est vérifié contre le code.
Structure des fichiers
themes/aurora/
├── aurora.php # classe : zones, régions, assets, dispositions
├── views/
│ ├── body.tpl.php # le squelette de page (rend les régions)
│ └── live_editor/ # row.tpl.php, widget.tpl.php : styles proposés dans l'éditeur en direct
├── css/
│ ├── style.css # la charte — définit TOUT le vocabulaire --nf-*
│ └── sass/ # optionnel : SCSS compilé côté serveur
├── js/
│ ├── theme.js # mode clair/sombre du thème
│ └── aurora.js # interactions du thème
├── images/
│ └── thumbnail.jpg # vignette 960 × 600 : carte d'addon et place de marché
└── install/
└── migrations/ # évolutions des dispositions livrées (optionnel)
1. La classe — aurora.php
<?php
namespace NF\Themes\Aurora;
use NF\NeoFrag\Addons\Theme;
class Aurora extends Theme
{
protected function __info()
{
return [
'title' => 'Aurora',
'description' => $this->lang('Thème communautaire sombre et néon.'),
'author' => 'Ton Nom',
'license' => 'LGPLv3 <https://www.gnu.org/licenses/lgpl-3.0.html>',
'version' => '1.0.0',
'depends' => ['neofrag' => '1.2.50'], // le cœur qu'il demande au moins — sans lui, « Ajouter » refuse l'archive
// Déclarations de découplage — OBLIGATOIRES.
'core' => FALSE,
'presets' => [],
'requires' => [],
// Les zones, dans l'ordre, par leur TITRE (c'est ce titre qu'emploie install()) …
'zones' => ['Header', 'Avant-contenu', 'Contenu', 'Post-contenu', 'Footer'],
// … et les RÉGIONS nommées que les gabarits rendent. OBLIGATOIRE pour un thème public.
'regions' => [
'header' => 'Header',
'before_content' => 'Avant-contenu',
'content' => 'Contenu',
'after_content' => 'Post-contenu',
'footer' => 'Footer',
],
];
}
public function __init()
{
// Les assets de chaque page, dans l'ordre d'inclusion. Ceux du cœur d'abord : Bootstrap 5, le
// socle du produit (css/nf-bs5-bridge.css : ses composants et les couleurs de Bootstrap tirées
// de la palette du thème), FontAwesome ; puis la charte du thème ; puis les scripts.
$this->css('bootstrap.min')->css('nf-bs5-bridge')
->css('icons/fontawesome.min')
->css('style')
->js('bootstrap.bundle.min')
->js('modal')->js('notify')->js('confirm')
->js('theme')->js('aurora');
}
public function styles_row() { return $this->view('live_editor/row'); }
public function styles_widget() { return $this->view('live_editor/widget'); }
public function install($dispositions = [])
{
$dispositions = $this->array();
// … dispositions par défaut (voir §3) …
return parent::install($dispositions);
}
}
zonesdéclare les zones dans l'ordre ;regionsleur donne un nom stable que les gabarits emploient.tools/check-addon-contracts.phpexige la cléregionssur tout thème public : c'est la fondation du chantier page-builder, et un gabarit qui rendraitzone(2)dépendrait de l'ordre.- Pas de jQuery : il n'est plus chargé par le cœur, et
tools/check-js-sources.phprefuse tout script qui l'appelle. Bootstrap 5 se charge parbootstrap.bundle.min(Popper inclus). styles_row()/styles_widget()rendent les listes de styles de lignes et de panneaux proposées dans l'éditeur en direct ; copiethemes/nebula/views/live_editor/pour commencer.
2. Le squelette — views/body.tpl.php
Le body.tpl.php est le HTML de la page. Il rend les régions par leur nom :
<nav class="au-nav">
<a href="<?php echo url('') ?>"><?php echo nf_texte($this->config->nf_name) ?></a>
<?php echo $this->output->region('header') ?>
</nav>
<main>
<?php if ($zone = $this->output->region('before_content')): ?><section class="au-banner"><?php echo $zone ?></section><?php endif ?>
<?php if ($zone = $this->output->region('content')): ?><div class="container"><?php echo $zone ?></div><?php endif ?>
<?php if ($zone = $this->output->region('after_content')): ?><div class="container"><?php echo $zone ?></div><?php endif ?>
</main>
<footer>
<?php echo $this->output->region('footer') ?>
</footer>
Le garde if ($zone = …) dit la vérité : une région déclarée mais sans widget rend une chaîne vide
(depuis le 2026-09-17 ; avant, des blancs faisaient dessiner un bandeau décoré autour de rien). Tu peux
donc envelopper chaque région d'un conteneur stylé sans risque.
Détecter l'accueil :
((string) $this->url->request === '').
Tu es libre : soit tu rends les régions telles quelles (navigation pilotée par les widgets de la région
header), soit tu codes une barre de navigation ou un pied de page sur mesure dans le gabarit, comme
Nebula, pour une identité unique. Si tu écris un pied de page, ne pose pas aussi un widget de
copyright dans la région footer de tes dispositions : le thème Extend l'affichait deux fois.
Si tu proposes aux visiteurs de changer de thème, écris <?php echo nf_selecteur_theme() ?> dans ton pied
de page : le cœur rend le menu des thèmes installés (hors admin) et retient le choix dans un cookie propre
au site. Il ne s'affiche que s'il y a plus d'un thème public, et que l'administrateur n'a pas fermé le
choix (Préférences générales → Choix du thème).
Pour la langue, <?php echo nf_selecteur_langue() ?> rend de même le menu des langues du site, quand il en a
plusieurs : un vrai formulaire, qui ramène le visiteur sur la même page, dans la langue choisie.
Écris aussi <?php echo nf_liens_legaux() ?> dans ton pied de page, à côté de « Propulsé par » : le cœur
rend les liens vers les mentions légales et la politique de confidentialité, quand leurs pages sont publiées
(Paramètres → Confidentialité), et « Gérer mes cookies », toujours. Ce dernier lien est le seul moyen
qu'a un visiteur de revenir sur ses choix : retirer son accord doit être aussi simple que de le donner. Si ta
ligne de pied ne passe pas à la ligne (white-space: nowrap), laisse au moins ces liens le faire : au
téléphone, collés au reste, ils font déborder la page.
Ce qui colle en haut de l'écran
Trois bandeaux peuvent occuper le haut de la fenêtre : celui de la démonstration, celui de la maintenance et
celui de l'aperçu des droits. Le cœur les empile, réserve leur place en haut de la page et publie leur hauteur
dans --nf-haut. Si ton en-tête reste collé quand on fait défiler, cale-le dessous et marque-le
data-nf-entete : le cœur mesure sa hauteur et la publie dans --nf-entete, sur laquelle les colonnes
collées des modules (le menu de l'espace membre, le sommaire du wiki) se calent à leur tour.
<header class="au-tete" data-nf-entete>…</header>
<aside class="au-cote" data-nf-colle>…</aside>
.au-tete { position: sticky; top: var(--nf-haut, 0px); z-index: 1030; }
.au-cote { position: sticky; top: calc(var(--nf-haut, 0px) + var(--nf-entete, 0px) + 16px); }
Une colonne collée qui peut être plus haute que l'écran porte data-nf-colle : quand elle ne tient pas
dans la place qu'il lui reste, le cœur la décolle — collée, son bas resterait hors de l'écran jusqu'au bout
de la page. Les ancres (#section) s'arrêtent d'elles-mêmes sous les bandeaux et sous ton en-tête : ne fixe
pas de scroll-padding-top. Un élément fixé en haut (position: fixed) se cale de même sur --nf-haut ;
seul un panneau qui recouvre toute la page à dessein (une fenêtre, un tiroir) peut partir de zéro.
check-colles refuse un élément collé ou fixé en haut qui ignore les bandeaux : sur la démonstration, la
moitié de l'en-tête de quatre thèmes disparaissait sous le sien dès qu'on faisait défiler.
3. Les dispositions par défaut — install()
install() décrit, par motif de page et par titre de zone, la grille de widgets posée à
l'installation du thème :
// Toutes les pages : module principal (8 colonnes) + colonne latérale (4 colonnes).
$dispositions->set('*', 'Contenu', $this->array([
$this->row(
$this->col(
$this->widget($this->db->insert('nf_widgets', ['widget' => 'module', 'type' => 'index']))
)->size('col-12 col-lg-8'),
$this->col(
$this->widget($this->db->insert('nf_widgets', ['widget' => 'user', 'type' => 'index']))->style('panel-color')
)->size('col-12 col-lg-4')
)->style('row-default')
]));
// Accueil uniquement : un carrousel pleine largeur avant le contenu.
$dispositions->set('/', 'Avant-contenu', $this->array([
$this->row($this->col(
$this->widget($this->db->insert('nf_widgets', ['widget' => 'slider', 'type' => 'index']))
))->style('row-default')
]));
set($page, $zone, [...lignes]):$pageest un motif (*,/,forum/*…), le plus précis l'emporte.$this->widget($id)reçoit l'id d'une lignenf_widgetsfraîchement insérée (widget, type, réglages en JSON). Un widget posé sans réglages doit s'en sortir : c'est le rôle de son checker.->size('col-12 col-lg-8')— grille Bootstrap 5, toujours avec un point de rupture :col-8seul s'applique dès 0 px et écrase la page sur téléphone.->style('row-default' | 'row-dark')et->style('panel-default' | 'panel-color' | 'panel-header')stylent lignes et panneaux.- Ne pose dans les dispositions livrées que des widgets du cœur : le paquet s'installe à la carte, un widget optionnel peut ne pas être là.
- Les intitulés du menu que tu poses (widget
navigation,$this->lang('Actualités')) sont traduits à l'affichage par le widget, sous sa propre clé : chacun doit exister danswidgets/navigation/langs/*.php, sinon il reste en français sur un site dans une autre langue.check-langsle vérifie.
Une installation existante ne rejoue jamais install() : pour corriger une disposition livrée chez ceux
qui ont déjà le thème, écris une migration de thème (install/migrations/AAAA_MM_JJ_nom.up.sql,
suivie dans nf_addon_migrations, jouée à la mise à jour). Exemple réel :
themes/extend/install/migrations/.
4. La charte — css/style.css
Chaque thème définit la totalité du vocabulaire --nf-*, dans :root (et dans sa variante claire
ou sombre). Les feuilles des modules et des widgets n'emploient que ce vocabulaire ; une variable qu'un
thème oublie retombe sur un repli — des blocs blancs en thème sombre sont apparus ainsi.
tools/check-css-variables.php refuse toute variable employée par un module ou un widget que tous
les thèmes ne définissent pas. Le vocabulaire complet — vingt-sept jetons — avec des valeurs d'exemple
(Nebula, lui, fait pointer chacun vers sa palette privée --fg-*, ce qui lui permet d'avoir un mode
clair et un mode sombre en ne changeant que celle-ci) :
:root {
/* fonds et surfaces */
--nf-bg: #070a10; --nf-bg-elevated: #0d1220;
--nf-surface: rgba(255,255,255,.035); --nf-surface-2: rgba(255,255,255,.06); --nf-surface-3: rgba(255,255,255,.09);
--nf-hover: rgba(255,255,255,.06);
/* traits */
--nf-border: rgba(255,255,255,.09); --nf-border-strong: rgba(255,255,255,.18);
/* textes */
--nf-text: #e7eef6; --nf-text-strong: #ffffff; --nf-text-soft: #b7c2d0; --nf-text-muted: #8593a6;
--nf-muted: #8593a6; --nf-muted-2: #66748a;
/* accent — et la couleur qui se pose DESSUS */
--nf-accent: #2dd4bf; --nf-accent-soft: rgba(45,212,191,.15); --nf-accent-strong: #14b8a6; --nf-accent-text: #99f6e4;
--nf-on-accent: #041014;
/* états */
--nf-success: #22c55e; --nf-info: #38bdf8; --nf-warning: #f59e0b; --nf-danger: #ef4444;
--nf-danger-soft: rgba(239,68,68,.18);
/* formes */
--nf-radius: 14px; --nf-radius-sm: 8px;
--nf-font-display: "Space Grotesk", system-ui, sans-serif;
}
Deux pièges mesurés sur les thèmes livrés :
--nf-on-accentse recalcule quand l'accent change. Si ta seconde palette (mode clair, variante) remplace--nf-accent, redéfinis aussi la couleur posée dessus, sinon le texte des boutons garde celle de l'autre accent — du blanc sur du turquoise, 1,86:1.tools/check-contraste.phpmesure le contraste WCAG de tous les thèmes dans les deux modes.- Pas de
.row { margin: -15px }: c'est la gouttière de Bootstrap 4 ; Bootstrap 5 emploie −12 px via--bs-gutter-x, et les 3 px d'écart font déborder la page.tools/check-mise-en-page.phpmesure le débordement horizontal de chaque page, de 360 à 2560 px, dans chaque thème et chaque mode ;tools/check-classes-bs4.phprefuse les classes disparues de Bootstrap 3 et 4 (card-columns,float-right,panel-body,badge-danger…), dans les gabarits comme dans les feuilles de style — redéfinir.badge-dangerdans ton thème ne la fait pas revivre, le contrôle la refuse —, les attributsdata-*restés sans le préfixebs— undata-targetne fait plus rien, en silence — et les classes que le code fabrique par concaténation. Un thème doit employer les noms de Bootstrap 5 :float-end,text-start,g-0,ms-2,bg-danger-subtle text-danger-emphasis. Pour teinter ces couleurs à ta charte, redéfinis les variables de Bootstrap (--bs-danger-text-emphasis…) plutôt que les classes.
Les couleurs réglables
Un thème laisse choisir trois couleurs — l'accent, le fond, le texte — par sa page de configuration, avec un
aperçu en direct sur une vraie page du site et le refus d'une couleur illisible. Le cœur fait tout ; le
thème déclare ses couleurs d'origine dans controllers/admin.php :
class Admin extends Controller
{
const COULEURS = ['theme_color' => '#2dd4bf', 'background_color' => '#070a10', 'text_color' => '#e7eef6'];
public function index()
{
return nf_reglage_couleurs($this, $this->__caller, self::COULEURS);
}
}
Sa feuille est alors un gabarit PHP qui lit les réglages <thème>_theme_color, <thème>_background_color
et <thème>_text_color, avec les mêmes valeurs d'origine en repli :
<?php $accent = $this->config->aurora_theme_color ?: '#2dd4bf'; ?>
:root { --nf-accent: <?php echo $accent ?>; }
Un thème qui a déjà sa page de configuration y ajoute seulement l'aperçu, sous ses sélecteurs :
nf_apercu_couleurs($this->__caller, [...couleurs d'origine]). Exemple réel : themes/nebula/.
Le socle commun des thèmes — css/nf-socle-themes.css (facultatif)
Blockcraft, Chronique, Extend, Forge, Granite et Pulse s'appuient sur un socle commun : ce que leurs feuilles avaient
d'identique (le reset, les liens, les cartes, les formulaires, les listes, la barre du membre, les
pastilles de présence…), écrit une seule fois dans le cœur, avec le seul vocabulaire --nf-*. Un thème
l'adopte en le chargeant avant sa propre feuille, qui garde son identité et peut tout redéfinir :
$this->css('bootstrap.min')->css('nf-bs5-bridge')
->css('icons/fontawesome.min')
->css('nf-socle-themes') // le socle commun des thèmes
->css('style')
->css('nf-apres-theme');
Il emploie, en plus des jetons ci-dessus, ceux-ci, que le thème qui le charge définit (les six thèmes
qui le chargent les font pointer vers leur palette privée) — tools/check-css-variables.php refuse un thème qui
charge le socle sans définir chacun des jetons qu'il emploie :
:root {
--nf-radius-xs: 2px;
--nf-shadow-sm: 0 1px 3px rgba(0,0,0,.4); --nf-shadow: 0 6px 20px -8px rgba(0,0,0,.6); --nf-shadow-md: 0 12px 32px -12px rgba(0,0,0,.7);
--nf-success-soft: rgba(34,197,94,.12); --nf-warning-soft: rgba(245,158,11,.12); --nf-info-soft: rgba(56,189,248,.12);
--nf-link: var(--nf-accent-text); --nf-link-hover: var(--nf-accent);
--nf-font-mono: "JetBrains Mono", ui-monospace, monospace;
--nf-font: "Inter", system-ui, sans-serif;
}
Un thème qui ne le charge pas — Nebula, ou un thème resté à une version d'avant — n'a rien à changer. Le socle est arrivé avec la version 1.2.32 du cœur : un thème qui le charge ne s'installe que sur un cœur qui le porte (le marketplace sert à chaque site le catalogue de sa propre version).
Le CSS peut être un gabarit PHP ; le ?v= est basé sur la date du fichier, toute édition invalide
le cache. Un dossier css/sass/ est compilé côté serveur (scssphp) à l'installation et depuis
Administration → Outils.
Mode clair / sombre. Chaque thème gère le sien dans js/theme.js (attribut data-theme sur
<html>, préférence mémorisée côté navigateur sous sa propre clé, pas une clé commune). Un thème
« sombre seulement » force document.documentElement.setAttribute('data-theme', 'dark').
5. La vignette — images/thumbnail.jpg
La carte du thème dans Thèmes & Addons et sa fiche dans la place de marché affichent
images/thumbnail.jpg : un JPEG de 960 × 600 (16/10), de 200 Ko au plus. C'est une photo de
l'accueil vu par un visiteur, jamais une maquette. tools/capturer-apercus.php la prend sur un site où
le thème est installé ; avec --basculer-theme, il en fait le thème par défaut le temps de la photo, puis
rétablit l'ancien. Fais-le sur un site d'essai peuplé du contenu de démonstration
(install/cli.php --demo), jamais sur un site en service :
php tools/capturer-apercus.php --type=theme --nom=aurora --basculer-theme
check-vignettes refuse ensuite un addon sans vignette, une vignette hors format et deux vignettes
identiques ; check-marketplace refuse une entrée du catalogue sans aperçu. Le format et les quelques
exemptions motivées vivent dans tools/lib/vignettes.php. Modules et widgets suivent le même format, mais
leur vignette est une carte dessinée (tools/vignettes-cartes.php), pas une photo : un module sans données
ne montrerait qu'un cadre vide, alors qu'un thème se montre.
6. Installer, activer, éprouver
- Dépose
themes/aurora/. - Administration → Thèmes & Addons → Scanner le disque → coche
aurora→ installe. - Activer sur sa fiche (définit le thème par défaut). Si le thème a été enregistré sans dispositions,
l'activation lance
install()d'elle-même. Le bouton Réinstaller par défaut ré-exécuteinstall(). - Avant de livrer, fais passer :
check-addon-declarations,check-addon-contracts(la cléregions),check-css-variables,check-colles,check-classes-bs4,check-js-sources, puischeck-mise-en-page,check-contraste,check-bleu-bootstrap(le bleu par défaut de Bootstrap resté dans un composant que ta palette n'a pas repris) etcheck-js-consolesur une installation où le thème est actif. Regarde la page rendue : un bandeau vide, un copyright en double ou un commentaire illisible ne se voient pas dans le code.
Bonnes pratiques
- Polices : sers-les depuis le thème ou le cœur, jamais depuis Google Fonts — la politique de sécurité
n'autorise plus aucune origine de polices (
font-src 'self' data:), et un tel chargement envoyait l'adresse IP de chaque visiteur à Google.php tools/polices-locales.php --nom=…télécharge une police de Google Fonts dansfonts/et écrit sa feuillecss/fonts/<nom>.css, que le thème charge par->css('fonts/<nom>'). - Aucun script depuis un CDN : la CSP stricte (
script-src 'self' 'nonce-…') le refuserait, et le projet ne dépend d'aucun tiers. Tout JS vit dans le thème ou le cœur. - Crédite le projet d'origine (NeoFrag, Michaël BILCOT & Jérémy VALENTIN, LGPLv3) si tu pars d'un thème existant.