Cinq multisites Wagtail et une crise du theming : comment wagtail-visual-themes est né
Après avoir livré ma cinquième application multisite Wagtail et copié-collé une nouvelle fois un enchevêtrement de surcharges de couleurs de marque, j'ai fini par extraire la couche de théming dans son propre package. Voici l'histoire de la raison pour laquelle tout projet Wagtail finit par réinventer le théming - et de la petite bibliothèque, assumée dans ses partis pris, qui m'a empêché de recommencer une sixième fois.
Cinq sites, cinq astuces de theming différentes
Ces dernières années, j'ai livré un flux régulier d'applications multisites Wagtail - une plateforme de coaching avec des portails de cours co-brandés, une plateforme de collecte de fonds desservant des dizaines d'universités, un CMS d'agence hébergeant une constellation de microsites, une base de connaissances interne avec des sections thématisées par département, et une poignée de sites marketing cousus ensemble sous un même arbre Wagtail.
Chacun d'eux a été livré avec sa propre couche de thématisation sur mesure. Chacun d'eux était un peu gênant.
- Projet un : des partials SCSS codés en dur par site, recompilés à chaque retouche de marque. « Il suffit de reconstruire le CSS » est devenu un mème Slack.
- Projet deux : un modèle
SiteSettingsavec septColorFieldet un template tag qui aspergeait des attributsstyleinline sur tous les composants. - Projet trois : configuration Tailwind générée au build par tenant. Cela fonctionnait à merveille jusqu'à ce qu'un éditeur de contenu veuille prévisualiser un changement de couleur sans redéployer.
- Projet quatre : un blob JSON sur le modèle Site de Wagtail, parsé par un context processor. Le JSON s'est mis à pousser des tentacules.
- Projet cinq : j'ai commencé à taper
class SiteTheme(models.Model)pour la cinquième fois et je me suis arrêté.
Le pattern que je répétais sans cesse
Une fois dépouillées de leurs différences cosmétiques, chacune de ces implémentations résolvait les trois mêmes problèmes :
- Où résident les tokens de marque ? Codés en dur dans le CSS, dans un modèle de paramètres, dans la config Tailwind, ou dans une ligne de base de données par tenant ?
- Comment atteignent-ils la page ? CSS recompilé, styles inline, context processors, tags de template personnalisés ?
- Comment surcharger à la bonne portée ? À l'échelle du site, c'est facile. À l'échelle d'une section, c'est plus difficile. Les surcharges par page sont l'endroit où chaque système que j'ai construit s'effondrait discrètement.
Et chaque implémentation était étroitement couplée au modèle de tenant de ce projet, à son pipeline de build, et à la bibliothèque de composants qui était à la mode ce trimestre-là. Reprendre le moindre élément dans le projet suivant relevait d'une transplantation, pas d'une instruction d'import.
Quand l'équipe Brand a demandé un mode sombre
Le point de rupture est survenu avec la plateforme de collecte de fonds. Nous avions une trentaine de tenants universitaires, chacun avec sa propre palette de marque stockée dans un snippet Branding, chacun générant son propre bundle CSS compilé.
Puis une équipe de marque a demandé un mode sombre. Pas pour un site - pour le leur. En option. Avec un interrupteur. Persistant d'un chargement de page à l'autre. Sans reconstruire notre pipeline CSS.
Je suis resté avec cette demande pendant une journée, j'ai esquissé ce qu'il faudrait pour l'intégrer a posteriori dans le système existant, et j'ai discrètement admis que l'architecture que je trimballais de projet en projet était structurellement incapable de la prendre en charge. Les couleurs étaient figées dans les feuilles de style au moment du build. Il n'y avait aucune notion de "mode" - juste une seule palette par tenant.
La correction honnête n'était pas de rapiécer le système. C'était de le jeter et de construire celui que j'aurais dû construire quatre projets plus tôt.
Extraction du snippet de thème
La première décision de conception était la plus importante : les thèmes sont des snippets Wagtail, pas des paramètres de site, pas des champs de tenant, ni rien de couplé à un modèle de données spécifique. Un thème est un objet autonome qui peut être créé, édité, prévisualisé et affecté sans rien savoir du site sur lequel il vit.
C'est ce découplage qui a rendu le package portable. Le même snippet Theme fonctionne sur un blog Wagtail mono-site, sur un SaaS à trente tenants ou sur un cluster de mini-sites - parce que rien en lui ne présuppose un modèle de multi-tenance.
@register_snippet
class Theme(models.Model):
name = models.CharField(max_length=120)
slug = models.SlugField(unique=True)
# Brand colours with auto-computed contrast pairs
brand_colors = StreamField(
[("color", BrandColorBlock())], blank=True
)
# Light + dark surface palettes
light_surface = ColorPaletteField()
dark_surface = ColorPaletteField()
typography = TypographyField()
radii = RadiusScaleField()
shadows = ShadowScaleField()
def to_css_variables(self) -> str:
"""Emit a complete set of design tokens as CSS custom properties."""
...
Résolution : page, puis ancêtres, puis site, puis valeur par défaut
La deuxième décision de conception fut la chaîne de résolution - la réponse à cette troisième question à laquelle j'échouais sans cesse : comment redéfinir au bon niveau ?
Le package parcourt une chaîne déterministe pour chaque requête :
- La page courante possède-t-elle un thème explicite ? Utilisez-le.
- Une page ancêtre quelconque possède-t-elle un thème ? Utilisez le plus proche.
- Le Site a-t-il un thème par défaut configuré ? Utilisez-le.
- Existe-t-il un snippet de thème « par défaut » à l'échelle du site ? Utilisez-le.
- Sinon : n'émettez aucune variable de thème et laissez le CSS de base faire le rendu.
C'est la partie que j'aurais dû construire cinq projets plus tôt. Elle reflète la manière dont Wagtail lui-même résout des éléments comme les menus et les promotions de recherche - s'appuyer sur l'arbre des pages, se rabattre sur le site, se rabattre sur une valeur par défaut. Les éditeurs obtiennent exactement un seul modèle mental : définissez un thème sur le conteneur le plus proche qui en a besoin.
def resolve_theme(page: Page, site: Site) -> Theme | None:
# Page-level override
if (theme := getattr(page.specific, "theme", None)):
return theme
# Walk ancestors looking for the nearest themed page
for ancestor in page.get_ancestors().specific():
if (theme := getattr(ancestor, "theme", None)):
return theme
# Site default, then global default
return SiteThemeSettings.for_site(site).default_theme \
or Theme.objects.filter(slug="default").first()
Des variables CSS, pas des composants
La troisième décision portait sur ce que le paquet ne fait pas. Il ne livre pas de boutons. Il ne livre pas de cartes. Il ne livre pas de bibliothèque de composants. Il ne livre même pas de plugins Tailwind.
Il émet des jetons de design sous forme de simples propriétés CSS personnalisées. C'est là l'intégralité de sa production. Une cinquantaine de variables pour les couleurs de surface, les états sémantiques, les échelles typographiques modulaires, les séquences d'espacement, les rayons, les ombres et les couches de z-index - ainsi qu'une unique balise de template pour les insérer dans votre <head>.
{% load wagtail_visual_themes %}
<!DOCTYPE html>
<html>
<head>
{% theme_css %} {# émet :root + [data-theme="dark"] #}
<link rel="stylesheet" href="{% static 'css/site.css' %}">
</head>
Pourquoi ne pas livrer des composants ? Parce que chaque projet que j'avais construit venait avec sa propre bibliothèque de composants — Tailwind ici, Bootstrap là, du SCSS écrit à la main ailleurs — et le système de thématisation finissait toujours par s'emmêler avec ce qui se trouvait par-dessus. Les jetons de design purs sont le donneur universel. Tailwind les lit via theme.extend.colors, le CSS écrit à la main les lit via var(--color-brand-500), et une base de code Bootstrap peut surcharger ses variables SCSS avec les mêmes jetons. Chacun obtient ce qu'il veut et personne ne se bat contre le paquet.
L'astuce du mode sombre sans clignotement
La fonctionnalité de mode sombre - celle qui a lancé toute cette extraction - s'est révélée être le plus petit morceau de code de tout le package. Deux ingrédients :
- Le thème émet à la fois les variables claires
:rootet les variables sombres[data-theme="dark"]dans la même feuille de style. - Un minuscule script inline dans l'en-tête du document lit la préférence enregistrée de l'utilisateur depuis
localStorageet définit l'attributdata-themeavant le premier rendu.
Cette deuxième étape est tout l'enjeu. Si vous différez l'affectation de data-theme jusqu'après le chargement de la page, chaque visiteur en mode sombre voit un flash de thème clair. Si vous l'inlinez de façon bloquante, dans l'en-tête, vous obtenez des rendus propres.
// Inlined in <head> by {% theme_css %} - runs before first paint
(function () {
const stored = localStorage.getItem('theme-mode');
const prefersDark = window.matchMedia(
'(prefers-color-scheme: dark)'
).matches;
const mode = stored || (prefersDark ? 'dark' : 'light');
if (mode === 'dark') {
document.documentElement.dataset.theme = 'dark';
}
})();
Ce dont le multisite avait réellement besoin
Avec le recul, la partie pénible du theming multisite de Wagtail n'a jamais été les couleurs. C'étaient les hypothèses. Chaque projet encodait son modèle de tenant dans la couche de theming, et chaque projet le payait dès que les exigences dérivaient - mode sombre, surcharges par section, aperçus pour les éditeurs de contenu, microsites en marque blanche sous une marque parente.
La solution n'était pas un modèle SiteSettings plus intelligent. C'était de sortir les thèmes entièrement de l'abstraction du tenant et de laisser l'arbre des pages et le système de snippets - des outils que Wagtail vous offre déjà - faire le travail.
Pourquoi je l'ai publié en open source
Honnêtement ? Pour ne plus jamais avoir à réécrire ce code. Le package se trouve à l'adresse github.com/ujeenet/wagtail-visual-themes. Il est petit, opiniâtre et volontairement limité dans sa portée - il fait de la thématisation, il gère le mode sombre, et il ne fait rien d'autre. La prochaine fois qu'on me confiera un cahier des charges pour un multisite Wagtail, le chapitre thématisation se résumera à un pip install et une balise de template.
Si vous en êtes à votre deuxième ou troisième projet multisite Wagtail et que vous sentez le même bricolage de thématisation se cristalliser à nouveau, épargnez-vous ce cycle. Volez le modèle de snippet, volez la chaîne de résolution, ou installez simplement le package. L'objectif est de cesser de résoudre ce problème.
Le theming n'est pas une fonctionnalité que l'on développe pour une seule application. C'est une frontière que l'on trace, une fois pour toutes, entre les décisions de marque et le code des composants. Chaque projet multisite que j'ai livré sans cette frontière a fini par en payer le prix.