Architecture 2026-04-13·Ievgenii Svyryd

Cinco multisitios de Wagtail y una crisis de tematización: cómo nació wagtail-visual-themes

Después de lanzar mi quinta aplicación multisitio en Wagtail y copiar y pegar una vez más una maraña de sobrescrituras de colores de marca, finalmente extraje la capa de temas en su propio paquete. Esta es la historia de por qué cada proyecto de Wagtail acaba reinventando los temas, y de la pequeña biblioteca con criterio propio que me impidió hacerlo por sexta vez.

Cinco multisitios de Wagtail y una crisis de tematización: cómo nació wagtail-visual-themes

Cinco sitios, cinco trucos de tematización diferentes

Durante los últimos años he entregado un flujo constante de aplicaciones multisitio con Wagtail: una plataforma de coaching con portales de cursos con marca compartida, una plataforma de recaudación de fondos al servicio de docenas de universidades, un CMS de agencia que alojaba una constelación de micrositios, una base de conocimiento interna con secciones tematizadas por departamento y un puñado de sitios de marketing cosidos entre sí bajo un único árbol de Wagtail.

Cada uno de ellos se entregó con su propia capa de tematización a medida. Cada uno de ellos resultaba un poco embarazoso.

  • Proyecto uno: parciales SCSS codificados a mano por sitio, recompilados con cada ajuste de marca. "Solo recompila el CSS" se convirtió en un meme de Slack.
  • Proyecto dos: un modelo SiteSettings con siete ColorField y una etiqueta de plantilla que rociaba atributos style en línea por todos los componentes.
  • Proyecto tres: configuración de Tailwind generada en tiempo de compilación por inquilino. Funcionaba de maravilla hasta que un editor de contenido quiso previsualizar un cambio de color sin volver a desplegar.
  • Proyecto cuatro: un blob JSON en el modelo Site de Wagtail analizado por un procesador de contexto. El JSON echó tentáculos.
  • Proyecto cinco: empecé a teclear class SiteTheme(models.Model) por quinta vez y me detuve.

El patrón que repetía una y otra vez

Despojada de las diferencias cosméticas, cada una de esas implementaciones resolvía los mismos tres problemas:

  1. ¿Dónde viven los tokens de marca? ¿Codificados directamente en el CSS, en un modelo de configuración, en la configuración de Tailwind o en una fila de base de datos por inquilino?
  2. ¿Cómo llegan a la página? ¿CSS recompilado, estilos en línea, procesadores de contexto, etiquetas de plantilla personalizadas?
  3. ¿Cómo se sobrescribe en el ámbito correcto? A nivel de sitio es fácil. A nivel de sección es más difícil. Las sobrescrituras por página son donde cada sistema que construí se desmoronaba silenciosamente.

Y cada implementación estaba fuertemente acoplada al modelo de inquilino de ese proyecto, a su pipeline de compilación y a cualquier biblioteca de componentes que estuviera de moda ese trimestre. Llevar cualquier parte de ello al siguiente proyecto era una operación de trasplante, no una sentencia de importación.

Cuando el equipo de marca pidió el modo oscuro

El punto de inflexión llegó con la plataforma de recaudación de fondos. Teníamos una treintena de inquilinos universitarios, cada uno con su propia paleta de marca almacenada en un snippet Branding, cada uno generando su propio paquete de CSS compilado.

Entonces un equipo de marca pidió el modo oscuro. No para un sitio, sino para el suyo. De forma opcional. Con un interruptor. Que persistiera entre cargas de página. Sin reconstruir nuestra canalización de CSS.

Estuve un día dándole vueltas a esa petición, esbocé lo que costaría adaptarla al sistema existente y admití en voz baja que la arquitectura que había ido arrastrando de proyecto en proyecto era estructuralmente incapaz de soportarla. Los colores estaban horneados en las hojas de estilo en tiempo de compilación. No existía la noción de un "modo": solo una única paleta por inquilino.

La solución honesta no era parchear el sistema. Era desecharlo y construir el que debería haber construido cuatro proyectos antes.

Extracción del snippet de tema

La primera decisión de diseño fue la más importante: los temas son snippets de Wagtail, no configuraciones del sitio, ni campos de tenant, ni nada acoplado a un modelo de datos específico. Un tema es un objeto autónomo que se puede crear, editar, previsualizar y asignar sin saber nada sobre el sitio en el que reside.

Ese desacoplamiento es lo que hizo portable el paquete. El mismo snippet Theme funciona en un blog de Wagtail de un solo sitio, en un SaaS de treinta tenants o en un clúster de micrositios, porque nada en él asume un modelo de tenancy.

@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."""
        ...

Resolución: página, luego ancestros, luego sitio, luego valor por defecto

La segunda decisión de diseño fue la cadena de resolución, la respuesta a esa tercera pregunta en la que seguía fallando: ¿cómo sobrescribes en el alcance correcto?

El paquete recorre una cadena determinista en cada petición:

  1. ¿La página actual tiene un tema explícito? Úsalo.
  2. ¿Alguna página ancestro tiene un tema? Usa el más cercano.
  3. ¿El Site tiene un tema predeterminado configurado? Úsalo.
  4. ¿Existe un snippet de tema "predeterminado" a nivel de todo el sitio? Úsalo.
  5. De lo contrario: no emitas variables de tema y deja que el CSS base renderice.

Esta es la parte que debería haber construido cinco proyectos atrás. Refleja cómo el propio Wagtail resuelve cosas como los menús y las promociones de búsqueda: apóyate en el árbol de páginas, recurre al sitio, recurre a un valor predeterminado. Los editores obtienen exactamente un solo modelo mental: establece un tema en el contenedor más cercano que lo necesite.

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()

Variables CSS, no componentes

La tercera decisión fue lo que el paquete no hace. No incluye botones. No incluye tarjetas. No incluye una biblioteca de componentes. Ni siquiera incluye plugins de Tailwind.

Emite tokens de diseño como propiedades personalizadas de CSS puro. Esa es toda la salida. Unas cincuenta variables para colores de superficie, estados semánticos, escalas tipográficas modulares, secuencias de espaciado, radios, sombras y capas de z-index; y una única etiqueta de plantilla para incorporarlas a tu <head>.

{% load wagtail_visual_themes %}
<!DOCTYPE html>
<html>
<head>
  {% theme_css %}  {# emite :root + [data-theme="dark"] #}
  <link rel="stylesheet" href="{% static 'css/site.css' %}">
</head>

¿Por qué no distribuir componentes? Porque cada proyecto que había construido venía con su propia biblioteca de componentes -Tailwind por aquí, Bootstrap por allá, SCSS hecho a mano en otro sitio- y el sistema de theming se acababa enredando con lo que hubiera encima. Los design tokens puros son el donante universal. Tailwind los lee a través de theme.extend.colors, el CSS escrito a mano los lee a través de var(--color-brand-500), y una base de código de Bootstrap puede sobrescribir sus variables SCSS con los mismos tokens. Todos obtienen lo que quieren y nadie pelea con el paquete.

El truco del modo oscuro sin parpadeo

La función de modo oscuro, la que dio origen a toda esta extracción, resultó ser la pieza de código más pequeña de todo el paquete. Dos ingredientes:

  1. El tema emite tanto las variables claras de :root como las variables oscuras de [data-theme="dark"] en la misma hoja de estilos.
  2. Un pequeño script inline en el head del documento lee la preferencia guardada del usuario desde localStorage y establece el atributo data-theme antes del primer renderizado.

Ese segundo paso lo es todo. Si aplazas la asignación de data-theme hasta después de la carga de la página, cada visitante en modo oscuro verá un destello del tema claro. Si lo pones inline y bloqueante, en el head, obtienes renderizados limpios.

// Insertado en línea en <head> por {% theme_css %}: se ejecuta antes del primer pintado
(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';
  }
})();

Lo que multisitio necesitaba realmente

Mirando atrás, la parte dolorosa del theming multisitio en Wagtail nunca fueron los colores. Fueron las suposiciones. Cada proyecto codificaba su modelo de tenant en la capa de theming, y cada proyecto lo pagaba en el momento en que los requisitos se desviaban: modo oscuro, overrides por sección, previsualizaciones para editores de contenido, microsites de marca blanca bajo una marca matriz.

La solución no fue un modelo SiteSettings más inteligente. Fue sacar los temas fuera de la abstracción del tenant por completo y dejar que el árbol de páginas y el sistema de snippets - herramientas que Wagtail ya te da - hicieran el trabajo.

Por qué lo liberé como código abierto

¿Sinceramente? Para no tener que volver a escribir nunca este código. El paquete está en github.com/ujeenet/wagtail-visual-themes. Es pequeño, opinado y deliberadamente de alcance limitado: hace temas, hace modo oscuro y no hace nada más. La próxima vez que alguien me encargue un multisite de Wagtail, el capítulo de temas será un pip install y una etiqueta de plantilla.

Si vas por tu segundo o tercer proyecto multisite de Wagtail y sientes que el mismo apaño de temas vuelve a cristalizar, ahórrate el ciclo. Roba el modelo de snippet, roba la cadena de resolución, o simplemente instala el paquete. La idea es dejar de resolver este problema.

La personalización de temas no es una funcionalidad que construyes para una sola aplicación. Es una frontera que trazas, una vez, entre las decisiones de marca y el código de los componentes. Cada proyecto multisitio que entregué sin esa frontera terminó pagando el precio.

Contacto →