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.

How the theme, its tokens and the hook slots fit together TOKENS, HIGHEST WINS Admin Appearance the brand_* settings THEME_TOKENS the skin's, else the product's DEFAULT_TOKENS neutral, in tokens.py STYLESHEETS, BY ORDER theme public.css, order 0 feature styles, around 100 skin.css, order 200 IDENTITY Appearance, then SITE_* name, tagline, logo, nav public_base.html the public shell <head> --brand-* vars, stylesheets layout.head body opens layout.body_start site-header brand, nav layout.nav layout.nav.end hero block hero layout.hero home.hero content block content home.section events 20, partners 30, media 40 site-footer social links layout.footer end of body, scripts layout.scripts
Tokens and stylesheets give the shell its look, the site config gives it its identity, and features fill its slots. The home.* slots open only on the home page.

Table of contents

  1. Who does what
  2. 1. Two shells
  3. 2. Design tokens
  4. 3. Blocks
  5. 4. Hook slots in the public shell
    1. Flashed messages
  6. 5. Skins
  7. 6. Site config
    1. The main nav
  8. 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.html from 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.js drives .countdown[data-target], so anything that announces a date (the edition hero, a call for papers) gets a live timer without shipping one.
  • lightbox.js opens, over the darkened page, every image inside .prose, anything marked data-lightbox, and every image of a gallery (.gallery-grid or data-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, without target="_blank". Control labels come from data-lightbox-* attributes on <body>. A page drives it through window.splentLightbox (open(img, {play}), next(), prev(), refresh(), close()). A gallery that grows during the slideshow listens for splent:lightbox:end on the grid, appends the next page and calls refresh() then next(), as the media feature does. data-slideshow-ms sets a grid’s pace.

Back to top

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