Theme system (dual-shell, tokens, skins)

SPLENT separates the public website of a product from its admin/app interface, and makes the public look fully data-driven: a product becomes its own website by setting config, not by rewriting CSS. The whole system is delivered by splent_feature_theme, with optional skin features layering a concrete aesthetic on top.

Table of contents

  1. 1. The dual-shell idea
  2. 2. Design tokens → :root { --brand-* }
    1. Overriding tokens via THEME_TOKENS
  3. 3. Composable blocks + render_block
  4. 4. Hook slots in the public shell
  5. 5. Skins as light features (tokens + skin.css via layout.head)
  6. 6. Product-level config: SITE_*site.*
    1. Language switcher
  7. 7. The home is config-driven (and reusable across products)
  8. Putting it together

1. The dual-shell idea

A SPLENT product renders through two distinct shells:

  • The public shell — provided by splent_feature_theme via public_base.html. This is the brand-agnostic, marketing-facing website (landing, events, sponsors, etc.). It is built entirely from design tokens and product config, so it carries no product-specific markup of its own.
  • The admin shell — the authenticated app interface where the actual application lives.

Public pages extend the theme’s base template:

{% extends "public_base.html" %}

The public shell defines the page skeleton — <head> with brand tokens and the base stylesheet, a site-header with brand/nav/language switcher, hero and content regions, and a site-footer — plus a set of named hook slots and Jinja blocks that downstream features fill in. The shell never hardcodes a product name, palette, or font.


2. Design tokens → :root { --brand-* }

The look of the public shell is driven by a small set of design tokens defined in tokens.py. These are neutral defaults:

DEFAULT_TOKENS = {
    "primary": "#6366f1",
    "primary_contrast": "#ffffff",
    "accent": "#10b981",
    "bg": "#ffffff",
    "surface": "#f8fafc",
    "text": "#1f2933",
    "heading": "#0f172a",
    "muted": "#64748b",
    "border": "#e5e9f0",
    "radius": "14px",
    "container": "1140px",
    "font_body": "'Inter', system-ui, sans-serif",
    "font_heading": "'Inter', system-ui, sans-serif",
    "font_display": "'Inter', system-ui, sans-serif",
    "font_url": "https://fonts.googleapis.com/css2?family=Inter:...&display=swap",
}

Each token key maps to a CSS custom property under --brand-* (the font_url token is the one exception — it is a font stylesheet URL, not a CSS var):

_CSS_VAR = {
    "primary": "--brand-primary",
    "primary_contrast": "--brand-primary-contrast",
    "accent": "--brand-accent",
    "bg": "--brand-bg",
    "surface": "--brand-surface",
    ...
    "font_display": "--brand-font-display",
}

Overriding tokens via THEME_TOKENS

A product (or a skin feature) sets THEME_TOKENS in app.config. get_tokens merges those overrides over the defaults — only non-None values win:

def get_tokens(app) -> dict:
    """Merge product/skin overrides (app.config['THEME_TOKENS']) over defaults."""
    tokens = dict(DEFAULT_TOKENS)
    overrides = (app.config.get("THEME_TOKENS") if app is not None else None) or {}
    tokens.update({k: v for k, v in overrides.items() if v is not None})
    return tokens

The merged tokens are rendered into a :root block by tokens_to_css:

def tokens_to_css(tokens: dict) -> str:
    lines = [f"  {_CSS_VAR[k]}: {tokens[k]};" for k in _CSS_VAR if k in tokens]
    return ":root {\n" + "\n".join(lines) + "\n}"

The public shell emits that block inline in <head>, then loads the base stylesheet (which references the vars) and, if present, the brand font:

{% if theme_tokens and theme_tokens.font_url %}
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="{{ theme_tokens.font_url }}">
{% endif %}
<style id="brand-tokens">{{ theme_tokens_css | safe }}</style>
<link rel="stylesheet" href="{{ url_for('theme.assets', subfolder='css', filename='public.css') }}">

public.css is brand-agnostic — every colour, font, and radius is read from a --brand-* var, never a literal:

body.cms-public {
    background: var(--brand-bg);
    color: var(--brand-text);
    font-family: var(--brand-font-body);
}
.btn-primary {
    background: var(--brand-primary);
    color: var(--brand-primary-contrast);
}

Reskinning a product means changing tokens — not rewriting CSS. This is what replaces the old “one css and that’s it” approach.

The tokens, their CSS, the block renderer, and the site config are all exposed to templates by inject_context_vars:

return {
    "theme_tokens": tokens,
    "theme_tokens_css": tokens_to_css(tokens),
    "render_block": _make_render_block(),
    "site": site,
}

3. Composable blocks + render_block

Beyond the page skeleton, the theme offers reusable, self-contained UI blocks living in templates/blocks/<name>.html. They are rendered through a render_block helper injected into the template context:

def _make_render_block():
    from flask import render_template
    from markupsafe import Markup

    def render_block(name, **context):
        """Render a composable theme block: templates/blocks/<name>.html."""
        return Markup(render_template(f"blocks/{name}.html", **context))

    return render_block

Inside any template you compose blocks declaratively:

{{ render_block('hero', title=site.name, subtitle=site.tagline) }}

The return value is wrapped in Markup, so the rendered HTML is injected as-is (not escaped) inside autoescaped templates. Blocks pair with the component classes already in public.css (.block-hero, .card-grid, .feature-grid, .cta-banner, .countdown, .logo-wall, .gallery-grid, …), so the look stays consistent across products and skins.


4. Hook slots in the public shell

The public shell exposes named template hook slots, so any feature can inject markup into the shell without editing it. The shell iterates the hooks registered for each slot:

{% for hook in get_template_hooks("layout.head") %}{{ hook() | safe }}{% endfor %}

The slots provided by public_base.html:

Slot Location
layout.head end of <head> (styles, meta)
layout.nav inside the main <nav>
layout.hero before the hero block
layout.footer inside the footer social list
layout.scripts end of <body>

Each slot also coexists with a Jinja {% block %} (head, hero, content, scripts) for page-level overrides.


5. Skins as light features (tokens + skin.css via layout.head)

A skin is a deliberately light feature: it does not add domain logic, models, or routes. It only (a) sets THEME_TOKENS and (b) injects its own stylesheet through the layout.head hook. splent_feature_skin_arcade is the reference example.

It sets the palette and fonts as tokens in init_feature:

ARCADE_TOKENS = {
    "primary": "#1E63BD",
    "primary_contrast": "#FFFFFF",
    "accent": "#EAA903",
    "bg": "#FAFCE6",
    "surface": "#FFFFFF",
    "text": "#1B2430",
    "heading": "#0B1320",
    "muted": "#5B6472",
    "border": "#0B1320",
    "radius": "10px",
    "container": "1160px",
    "font_body": "'Roboto', system-ui, sans-serif",
    "font_heading": "'Pixelify Sans', system-ui, cursive",
    "font_display": "'Pixelify Sans', system-ui, cursive",
    "font_url": "https://fonts.googleapis.com/css2?family=Pixelify+Sans:...&family=Roboto:...&display=swap",
}


def init_feature(app):
    # A skin sets the theme tokens; skin.css (via the layout.head hook) adds the
    # concrete look on top of the theme's brand-agnostic base stylesheet.
    app.config["THEME_TOKENS"] = ARCADE_TOKENS

And it layers its concrete stylesheet on top of the base public.css by registering a layout.head hook (hooks.py):

from splent_framework.hooks.template_hooks import register_template_hook


def arcade_styles():
    return (
        '<link rel="stylesheet" href="'
        + url_for("skin_arcade.assets", subfolder="css", filename="skin_arcade.css")
        + '">'
    )


register_template_hook("layout.head", arcade_styles)

Because the hook is appended after public.css in the <head>, skin.css can override or extend the base look while still inheriting all the --brand-* vars the tokens set. Installing or removing a skin feature changes the entire public aesthetic with no edits to the theme or to product templates.


6. Product-level config: SITE_*site.*

The theme never hardcodes a product’s identity. Instead, inject_context_vars reads product-level config from app.config['SITE_*'] and exposes it as a single site object in templates:

site = {
    "name": app.config.get("SITE_NAME") or os.getenv("SPLENT_APP") or "Site",
    "tagline": app.config.get("SITE_TAGLINE", ""),
    "nav": app.config.get("SITE_NAV", []),
    "social": app.config.get("SITE_SOCIAL", []),
    "event": app.config.get("SITE_EVENT", {}),
    "sponsors": app.config.get("SITE_SPONSORS", []),
    "logo": app.config.get("SITE_LOGO", ""),
    "gallery": app.config.get("SITE_GALLERY", []),
    "hero_eyebrow": app.config.get("SITE_HERO_EYEBROW", ""),
    "hero_actions": app.config.get("SITE_HERO_ACTIONS", []),
    "highlights_title": app.config.get("SITE_HIGHLIGHTS_TITLE", ""),
    "highlights": app.config.get("SITE_HIGHLIGHTS", []),
    "sponsors_title": app.config.get("SITE_SPONSORS_TITLE", "Patrocinadores"),
    "gallery_title": app.config.get("SITE_GALLERY_TITLE", "Galería"),
    "cta": app.config.get("SITE_CTA", {}),
}
Config key site.* Used for
SITE_NAME site.name brand name, title, footer
SITE_TAGLINE site.tagline meta description, hero subtitle
SITE_NAV site.nav header nav links
SITE_SOCIAL site.social footer social links
SITE_LOGO site.logo brand logo <img>
SITE_EVENT site.event countdown + event eyebrow
SITE_SPONSORS site.sponsors sponsor logo wall
SITE_HIGHLIGHTS site.highlights feature/highlight grid
SITE_HERO_ACTIONS site.hero_actions hero call-to-action buttons
SITE_GALLERY site.gallery image gallery
SITE_CTA site.cta bottom call-to-action banner

The shell consumes site.* directly — brand, nav, and footer:

<a class="site-brand" href="/">
    {% if site.logo %}<img class="site-brand__logo" src="{{ url_for('static', filename=site.logo) }}" alt="{{ site.name }}">{% endif %}
    <span class="site-brand__name">{{ site.name }}</span>
</a>
<nav class="site-nav" aria-label="Main navigation">
    {% for item in site.nav %}<a href="{{ item.href }}">{{ item.label }}</a>{% endfor %}
    ...
</nav>

Language switcher

inject_context_vars also resolves the active locale via Flask-Babel and the list of supported locales:

try:
    from flask_babel import get_locale as _get_locale
    site["locale"] = str(_get_locale() or app.config.get("BABEL_DEFAULT_LOCALE", "en"))
except Exception:
    site["locale"] = app.config.get("BABEL_DEFAULT_LOCALE", "en")
site["locales"] = app.config.get("BABEL_SUPPORTED_LOCALES", ["en"])

The shell renders a switcher only when more than one locale is supported:

{% if site.locales and site.locales | length > 1 %}
<span class="lang-switch">
    {% for lc in site.locales %}<a class="lang-switch__opt {{ 'is-active' if lc == site.locale else '' }}"
        href="{{ url_for('theme.set_language', code=lc) }}">{{ lc | upper }}</a>{% endfor %}
</span>
{% endif %}

7. The home is config-driven (and reusable across products)

Because everything the landing needs lives in site.*, the home template in splent_feature_public (templates/public/index.html) is generic — it has no product-specific copy. It simply renders whatever config is present, guarding each section with an {% if %}:

{% block hero %}
<section class="block block-hero hero--home">
    <div class="container">
        {% set ev_eyebrow = ('Edición ' ~ site.event.edition ~ ' · ' ~ site.event.dates ~ ' · ' ~ site.event.venue)
                            if (site.event and site.event.iso) else site.hero_eyebrow %}
        {% if ev_eyebrow %}<p class="hero-eyebrow">{{ ev_eyebrow }}</p>{% endif %}
        <h1 class="hero-title">{{ site.name }}</h1>
        <p class="hero-subtitle">{{ site.tagline }}</p>

        {% if site.event and site.event.iso %}
        <div class="countdown" data-target="{{ site.event.iso }}"> ... </div>
        {% endif %}

        {% if site.hero_actions %}
        <p class="hero-actions">
            {% for a in site.hero_actions %}<a class="btn {{ a.class | default('btn-primary') }}"
                href="{{ a.href }}">{{ a.label }}</a>{% endfor %}
        </p>
        {% endif %}
    </div>
</section>
{% endblock %}

Highlights, sponsors, gallery, and the CTA banner all follow the same pattern — present the section only when its config exists:

{% if site.highlights %}
<section class="container section">
    {% if site.highlights_title %}<h2 class="section-title">{{ site.highlights_title }}</h2>{% endif %}
    <div class="feature-grid">
        {% for h in site.highlights %}
        <div class="feature">
            {% if h.icon %}<span class="feature__icon">{{ h.icon }}</span>{% endif %}
            <h3>{{ h.title }}</h3>
            {% if h.text %}<p>{{ h.text }}</p>{% endif %}
        </div>
        {% endfor %}
    </div>
</section>
{% endif %}

{% if site.cta and site.cta.title %}
<section class="cta-banner">
    <div class="container">
        <h2>{{ site.cta.title }}</h2>
        {% if site.cta.text %}<p>{{ site.cta.text }}</p>{% endif %}
        <a class="btn btn-primary" href="{{ site.cta.href | default('#') }}">{{ site.cta.button | default('Saber más') }}</a>
    </div>
</section>
{% endif %}

The result: the same landing template serves many products. One product ships an events site with a countdown and sponsor wall; another ships a plain product landing with highlights and a CTA — purely by changing SITE_* config and, optionally, the active skin. No template forks, no per-product CSS.


Putting it together

  1. splent_feature_theme supplies the public shell (public_base.html), the brand-agnostic base stylesheet (public.css), the design-token engine (tokens.py), the render_block helper, and the site context.
  2. A product sets its identity through SITE_* config (and optionally THEME_TOKENS), turning the shell into its website.
  3. A skin feature (e.g. splent_feature_skin_arcade) overrides the palette and fonts via THEME_TOKENS and layers skin.css through the layout.head hook — a complete reskin with no template edits.
  4. splent_feature_public ships a config-driven home that any product can reuse unchanged.

Back to top

splent. Distributed by an LGPL license v3. Contact us: drorganvidez@us.es