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 composed by features (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 are listed below.

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.

The home page (splent_feature_public) opens two more slots of its own, so content features compose the landing instead of the product hardcoding it:

Slot Behaviour Who fills it
home.hero exclusive with fallback: when any feature renders something here, it is the hero and the shell’s own SITE_* hero is not drawn a feature that owns the headline moment, e.g. editions renders the current edition (dates, venue, countdown, registration)
home.section additive, ordered by the order given at registration events (upcoming events, order 20), partners (logo strip, 30), media (latest photos, 40)…

The fallback shape uses render_template_hooks(name), a Jinja global that returns the joined output of a slot as one Markup string. An empty result means nobody contributed (or every hook declined for this page), which is what lets a template keep a built-in fallback:

{% set contributed_hero = render_template_hooks("home.hero") %}
{% if contributed_hero %}{{ contributed_hero }}{% else %}…shell hero from SITE_*…{% endif %}

Hooks that apply to one page only check request.endpoint ("public.index" for the home) and return "" elsewhere.


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 (fallback; the editions feature owns this through home.hero)
SITE_SPONSORS site.sponsors sponsor logo wall (fallback; the partners feature owns this through home.section)
SITE_HIGHLIGHTS site.highlights feature/highlight grid
SITE_HERO_ACTIONS site.hero_actions hero call-to-action buttons
SITE_GALLERY site.gallery image gallery (fallback; the media feature owns this through home.section, see MEDIA_HOME_COUNT)
SITE_CTA site.cta bottom call-to-action banner

The shell consumes site.* directly for 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 composed by features (and reusable across products)

The home template in splent_feature_public (templates/public/index.html) is generic and knows no content feature. Two things fill it:

  1. Content features, through the home.hero and home.section slots (section 4). Install events and the next events appear; install partners and the logo strip appears; install editions and the hero becomes the current edition with its countdown. Remove a feature and its section disappears on its own. Anything editorial that changes from one edition to the next lives in a feature with an admin screen, never in config.py.
  2. Product brand copy from site.* (SITE_* config): name, tagline, highlights, hero actions, the closing CTA. These read the same for every edition, so config is the right place. SITE_EVENT, SITE_SPONSORS and SITE_GALLERY remain as zero-feature fallbacks and are ignored the moment a feature contributes the equivalent section.

Each config-driven section is guarded with an {% if %}, and the shell hero is drawn only when no feature rendered home.hero:

{% block hero %}
{% set contributed_hero = render_template_hooks("home.hero") %}
{% if contributed_hero %}
{{ contributed_hero }}
{% else %}
<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>
{% endif %}
{% endblock %}

The countdown markup (.countdown[data-target]) is driven by the theme’s countdown.js, a shell asset registered like lightbox.js, so any feature that announces a date (the edition hero, a call for papers) reuses it without shipping a timer.

The same goes for pictures: lightbox.js opens every image inside .prose, anything marked data-lightbox, and every image inside a gallery (.gallery-grid or any element marked data-lightbox-gallery) large over the darkened page, with previous/next, keyboard and swipe navigation and a slideshow inside a gallery. A feature that renders a photo grid only has to wrap each thumbnail in a link to the full-size file (no target="_blank"; the link is what the lightbox opens and what remains without JavaScript) inside a .gallery-grid. Control labels are translated by the shell through data-lightbox-* attributes on <body>. Pages that drive it themselves use window.splentLightbox (open(img, {play}), next(), prev(), refresh(), close()); a gallery that grows while the slideshow runs (infinite scroll) listens for splent:lightbox:end on the grid, appends the next page and calls refresh() then next(), which is what the media feature’s gallery does. A grid may set its own pace with data-slideshow-ms.

Highlights and the CTA banner follow the same pattern, presenting the section only when its config exists, and the feature sections render in between:

{% for hook in get_template_hooks("home.section") %}{{ hook() | safe }}{% endfor %}
{% 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 is that the same landing template serves many products. One product ships a conference site with the current edition’s countdown, its programme and a sponsor strip; another ships a lab landing with highlights, projects and a CTA, purely by choosing features and setting SITE_* config, plus 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 home that any product can reuse unchanged: content features fill its home.hero and home.section slots and the product’s SITE_* config supplies the brand copy.

Back to top

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