Theme system
splent_feature_theme gives a product a public website, separate from its admin and app interface. The website’s look comes from design tokens and its identity from config, so a product becomes its own website by setting values, not by rewriting CSS. Optional skin features lay a concrete aesthetic on top.
home.* slots open only on the home page.Table of contents
- Who does what
- 1. Two shells
- 2. Design tokens
- 3. Blocks
- 4. Hook slots in the public shell
- 5. Skins
- 6. Site config
- 7. The home page
Who does what
| Part | Gives |
|---|---|
splent_feature_theme |
The public shell (public_base.html), the brand-agnostic public.css, the token engine (tokens.py), render_block, the site context and the language switcher |
| The product | Its identity through SITE_* config, and THEME_TOKENS when no skin is installed |
| A skin feature | Palette and fonts through THEME_TOKENS, and its own stylesheet at order 200 |
splent_feature_public |
A home page any product reuses unchanged |
| Content features | Their nav entry, and the sections of the home through home.hero and home.section |
| The admin panel | Appearance (name, tagline, logo, brand_* colours) and Menus, which win over config at render time |
1. Two shells
A product renders through two shells.
- The public shell,
public_base.htmlfrom the theme. The brand-agnostic website (landing, events, sponsors), built from design tokens and product config, with no product-specific markup of its own. - The admin shell, the authenticated app interface where the application itself lives.
Public pages extend the theme’s base template.
{% extends "public_base.html" %}
The public shell is the page skeleton. It has a <head> with the brand tokens and the stylesheets, a site-header with brand, nav and language switcher, hero and content regions, and a site-footer. Named hook slots and Jinja blocks let other features fill it, and it never hardcodes a product name, palette or font.
2. Design tokens
The look of the public shell comes from a small set of tokens. These are the neutral defaults in tokens.py.
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 key becomes a CSS custom property, --brand-<key> with _ as - (primary_contrast is --brand-primary-contrast). font_url is the exception, a font stylesheet URL and not a variable.
get_tokens(app) merges three sources, highest first.
| Source | Which keys | Wins when |
|---|---|---|
The admin Appearance settings brand_primary, brand_accent, brand_bg, brand_surface, brand_text, brand_heading |
Those six colours | The value is not empty |
app.config["THEME_TOKENS"] |
Any token | The value is not None |
DEFAULT_TOKENS |
All | Always, as the base |
A skin replaces THEME_TOKENS whole in its init_feature, which runs after the product’s config, so with a skin installed the product’s own THEME_TOKENS are discarded. Such a product adjusts its colours through the Appearance settings.
tokens_to_css renders the merged tokens as a :root { --brand-*: ... } block. The shell loads the brand font when there is one, emits that block inline in <head>, then every stylesheet the asset registry holds for the public shell, by order. The theme registers public.css at order 0, feature stylesheets sit around 100 and skins at 200, so the cascade is deterministic.
def init_feature(app):
from splent_framework.assets.asset_registry import register_asset
register_asset(
"css", "theme.assets", order=0, subfolder="css", filename="public.css"
)
public.css reads every colour, font and radius from a --brand-* variable, never a literal, so reskinning a product means changing tokens.
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);
}
The theme’s inject_context_vars gives every template these.
| Variable | What it is |
|---|---|
theme_tokens |
The merged tokens |
theme_tokens_css |
The :root block |
render_block(name, **context) |
Renders a block |
auto_breadcrumb(page_title, mode) |
The trail derived from the URL, the page title and the composed nav, or None when the site_breadcrumbs mode hides it there. The shell calls it only when the page passed no breadcrumb, which always wins |
site_title(page_title) |
The document title, the page’s name then the site name |
page_shows_flash |
Lets the shell skip a flashed message the page already shows |
site |
The product’s identity and nav, see section 6 |
3. Blocks
Reusable UI blocks live in templates/blocks/<name>.html, and templates compose them with render_block.
{{ render_block('hero', title=site.name, subtitle=site.tagline) }}
The output is Markup, so it is inserted without escaping. Blocks pair with the component classes in public.css (.block-hero, .card-grid, .feature-grid, .cta-banner, .countdown, .logo-wall, .gallery-grid and more), so the look stays consistent across products and skins.
4. Hook slots in the public shell
Any feature can add markup to the shell through its slots without editing it. The shell renders every callback registered for each slot.
{% for hook in get_template_hooks("layout.head") %}{{ hook() | safe }}{% endfor %}
| Slot | Location |
|---|---|
layout.head |
end of <head> (styles, meta), after the registered stylesheets |
layout.body_start |
right after <body> opens |
layout.nav |
inside the main <nav>, after the feature entries |
layout.nav.end |
far right of the <nav>, after the language switcher (the account control lives here) |
layout.hero |
before the hero block |
layout.footer |
inside the footer social list |
layout.scripts |
end of <body>, after the registered scripts |
Pages override regions through the Jinja blocks {% block %} head, hero, content and scripts.
The home page of splent_feature_public opens two more slots, so content features compose the landing.
| Slot | Behaviour | Who fills it |
|---|---|---|
home.hero |
Exclusive with fallback. When any feature renders here, it is the hero, and the shell’s SITE_* hero is not drawn |
A feature that owns the headline moment, such as editions with the current edition (dates, venue, countdown, registration) |
home.section |
Additive, by the order given at registration |
events (upcoming events, 20), partners (logo strip, 30), media (latest photos, 40) |
The fallback uses render_template_hooks(name), which returns the slot’s joined output, empty when nobody contributed or every hook declined for this page.
{% set contributed_hero = render_template_hooks("home.hero") %}
{% if contributed_hero %}{{ contributed_hero }}{% else %}…shell hero from SITE_*…{% endif %}
A hook meant for one page checks request.endpoint ("public.index" for the home) and returns "" elsewhere.
Flashed messages
The public shell shows what a feature says with Flask’s flash(), for a sign-up that ends on the home page or a form that redirects after saving. The messages sit in a container site-flashes block between the header (with its breadcrumb) and the hero, one <div class="alert alert-<kind>"> each.
Category passed to flash() |
Shown as | Announced |
|---|---|---|
success, info, warning |
that kind | politely (role="status") |
danger, error |
danger |
at once (role="alert") |
| anything else | info |
politely (role="status") |
The left border takes its colour from the tokens --brand-success, --brand-primary (info), --brand-warning and --brand-danger, with neutral fallbacks, so a skin restyles them by setting those tokens.
A page that shows its messages in a box of its own, such as a contact form or a login page on the public layout, does not get them twice. The shell renders the page content first and leaves out any message that is the whole text of an element in the page. The same words inside a sentence do not count. Pages need no change for this, and they still fill the content block.
5. Skins
A skin is a light feature with no domain logic, models or routes. It sets THEME_TOKENS and registers its own stylesheet at order 200, so it cascades last. splent_feature_skin_arcade is the reference.
from splent_framework.blueprints.base_blueprint import create_blueprint
skin_arcade_bp = create_blueprint(__name__)
# Design tokens (the real innosoftdays.com palette/fonts, cleaned up).
ARCADE_TOKENS = {
"primary": "#1E63BD",
"accent": "#EAA903",
"bg": "#FAFCE6",
# ... the other tokens
"font_heading": "'Pixelify Sans', system-ui, cursive",
"font_url": "https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap",
}
def init_feature(app):
# A skin sets the theme tokens and registers its stylesheet (order 200, so it
# cascades last) on top of the theme's brand-agnostic base public.css.
from splent_framework.assets.asset_registry import register_asset
app.config["THEME_TOKENS"] = ARCADE_TOKENS
register_asset(
"css",
"skin_arcade.assets",
order=200,
subfolder="css",
filename="skin_arcade.css",
)
skin_arcade.css lives under the feature’s assets/css/ and is served by its blueprint’s asset route. It comes after public.css and every feature stylesheet, so it can override the base look while inheriting every --brand-* variable. Installing or removing a skin changes the whole public aesthetic with no edit to the theme or the product’s templates.
6. Site config
The theme builds one site object for templates. The name, tagline and logo come from the admin Appearance settings first (site_name, site_tagline, site_logo), then from app.config["SITE_*"]. The theme’s own config.py fills SITE_NAME, SITE_TAGLINE, SITE_BRAND_MODE, SITE_LOGO_SIZE and SITE_BREADCRUMBS from the .env. The rest is read from app.config.
| Config key | site.* |
Used for |
|---|---|---|
SITE_NAME |
site.name |
Brand name, title, footer. Falls back to SPLENT_APP, then Site |
SITE_TAGLINE |
site.tagline |
Meta description, hero subtitle |
SITE_LOGO |
site.logo |
Brand logo <img> |
SITE_SOCIAL |
site.social |
Footer social links |
SITE_HERO_EYEBROW, SITE_HERO_ACTIONS |
site.hero_eyebrow, site.hero_actions |
The line above the hero title, and its buttons |
SITE_HIGHLIGHTS_TITLE, SITE_HIGHLIGHTS |
site.highlights_title, site.highlights |
Highlight grid on the home |
SITE_CTA |
site.cta |
Closing call-to-action banner |
SITE_EVENT |
site.event |
Countdown and event line in the shell hero, when no feature fills home.hero |
SITE_SPONSORS, SITE_SPONSORS_TITLE |
site.sponsors, site.sponsors_title |
Sponsor logo wall, a fallback for a product without partners |
SITE_GALLERY, SITE_GALLERY_TITLE |
site.gallery, site.gallery_title |
Image gallery, a fallback for a product without media (see MEDIA_HOME_COUNT) |
SITE_NAV |
site.nav |
Header nav, only when no feature registered an entry and no Menus override exists |
site.footer_nav has no config key. It is the row of custom links the admin Menus editor stores under site_footer_nav, drawn before the social list when not empty. site.locale and site.locales drive the language switcher, which appears only when more than one locale is supported (see i18n).
The main nav
Each content feature declares its entry in init_feature with register_nav_item(key, label, href, order) from splent_framework.nav.nav_registry, so the menu follows the product’s derivation. The admin Menus editor stores an override under site_nav that reorders, hides or relabels entries and adds custom links. compose_nav reconciles both at render time.
- The override drives order, visibility and labels.
- Entries of features that are not installed are dropped, and registered entries the override does not list are appended.
- Without an override, the registered entries render by
order. - Labels go through gettext, so they follow the active locale.
The shell marks at most one entry as current, with is-active and aria-current="page". An entry matches when the request path is its address or lies below it, the longest matching address wins, and among equal addresses the first one. The home entry (/) matches only the home page.
7. The home page
The home template of splent_feature_public (templates/public/index.html) knows no content feature. Its sections, in order, are these.
| Section | Comes from | Shown when |
|---|---|---|
| Hero | home.hero, else the shell hero from site.* (name, tagline, eyebrow or SITE_EVENT, countdown, actions) |
Always |
| Highlights | site.highlights |
Set |
| Feature sections | home.section |
A feature contributes |
| Sponsors | site.sponsors |
Set |
| Gallery | site.gallery |
Set |
| Call to action | site.cta |
It has a title |
What changes from one edition to the next belongs in a feature with an admin screen, never in config.py. Brand copy that reads the same for every edition (name, tagline, highlights, hero actions, closing CTA) belongs in SITE_*. The same template serves a conference site with a countdown, a programme and a sponsor strip, and a lab landing with highlights, projects and a CTA, by choosing features, SITE_* and a skin.
The shell’s scripts serve any feature.
countdown.jsdrives.countdown[data-target], so anything that announces a date (the edition hero, a call for papers) gets a live timer without shipping one.lightbox.jsopens, over the darkened page, every image inside.prose, anything markeddata-lightbox, and every image of a gallery (.gallery-gridordata-lightbox-gallery), with previous and next, keyboard and swipe navigation, and a slideshow inside a gallery. A photo grid only wraps each thumbnail in a link to the full-size file, withouttarget="_blank". Control labels come fromdata-lightbox-*attributes on<body>. A page drives it throughwindow.splentLightbox(open(img, {play}),next(),prev(),refresh(),close()). A gallery that grows during the slideshow listens forsplent:lightbox:endon the grid, appends the next page and callsrefresh()thennext(), as the media feature does.data-slideshow-mssets a grid’s pace.