Créer un thème
223 vues · 09/10/2026 00:45 Historique

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);
    }
}
  • zones déclare les zones dans l'ordre ; regions leur donne un nom stable que les gabarits emploient. tools/check-addon-contracts.php exige la clé regions sur tout thème public : c'est la fondation du chantier page-builder, et un gabarit qui rendrait zone(2) dépendrait de l'ordre.
  • Pas de jQuery : il n'est plus chargé par le cœur, et tools/check-js-sources.php refuse tout script qui l'appelle. Bootstrap 5 se charge par bootstrap.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 ; copie themes/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]) : $page est un motif (*, /, forum/*…), le plus précis l'emporte.
  • $this->widget($id) reçoit l'id d'une ligne nf_widgets fraî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-8 seul 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 dans widgets/navigation/langs/*.php, sinon il reste en français sur un site dans une autre langue. check-langs le 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-accent se 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.php mesure 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.php mesure le débordement horizontal de chaque page, de 360 à 2560 px, dans chaque thème et chaque mode ; tools/check-classes-bs4.php refuse 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-danger dans ton thème ne la fait pas revivre, le contrôle la refuse —, les attributs data-* restés sans le préfixe bs — un data-target ne 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

  1. Dépose themes/aurora/.
  2. Administration → Thèmes & Addons → Scanner le disque → coche aurora → installe.
  3. 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écute install().
  4. 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, puis check-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) et check-js-console sur 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 dans fonts/ et écrit sa feuille css/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.

Gérer mes cookies

Le site ne dépose que les cookies nécessaires à son fonctionnement : ils ne demandent pas votre accord. Les autres services appartiennent à d’autres sociétés ; ils ne se chargent que si vous les acceptez. Votre choix est gardé six mois, et vous pouvez le changer à tout moment.

Nécessaires au site Toujours actifs

  • __Host-session Garde votre connexion et protège les formulaires contre les envois frauduleux. Jusqu’à la fermeture du navigateur ; un an avec « Se souvenir de moi »
  • nf_consent Garde les choix que vous faites dans cette fenêtre. 6 mois
  • nf_fuseau Affiche les dates et les heures dans votre fuseau horaire. 1 an
  • Stockage du navigateur Garde votre choix entre le mode jour et le mode nuit, et la présentation choisie pour la liste des articles. Jusqu’à ce que vous le vidiez

Vidéos

Les vidéos intégrées aux pages : le lecteur vient de la plateforme, qui reçoit votre adresse IP et peut déposer ses cookies.

Musique

Les morceaux et listes de lecture intégrés aux pages : le lecteur vient de la plateforme, qui reçoit votre adresse IP et peut déposer ses cookies.