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 composed by features (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 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:
- Content features, through the
home.heroandhome.sectionslots (section 4). Installeventsand the next events appear; installpartnersand the logo strip appears; installeditionsand 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 inconfig.py. - 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_SPONSORSandSITE_GALLERYremain 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
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 home that any product can reuse unchanged: content features fill itshome.heroandhome.sectionslots and the product’sSITE_*config supplies the brand copy.