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. The dual-shell idea
- 2. Design tokens →
:root { --brand-* } - 3. Composable blocks +
render_block - 4. Hook slots in the public shell
- 5. Skins as light features (tokens +
skin.cssvialayout.head) - 6. Product-level config:
SITE_*→site.* - 7. The home is config-driven (and reusable across products)
- Putting it together
1. The dual-shell idea
A SPLENT product renders through two distinct shells:
- The public shell — provided by
splent_feature_themeviapublic_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
splent_feature_themesupplies the public shell (public_base.html), the brand-agnostic base stylesheet (public.css), the design-token engine (tokens.py), therender_blockhelper, and thesitecontext.- A product sets its identity through
SITE_*config (and optionallyTHEME_TOKENS), turning the shell into its website. - A skin feature (e.g.
splent_feature_skin_arcade) overrides the palette and fonts viaTHEME_TOKENSand layersskin.cssthrough thelayout.headhook — a complete reskin with no template edits. splent_feature_publicships a config-driven home that any product can reuse unchanged.