Metadata-Version: 2.4
Name: splent_feature_theme
Version: 0.16.0
License-Expression: LGPL-3.0-or-later
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: COPYING
License-File: COPYING.LESSER
Requires-Dist: splent_framework>=1.28.0
Dynamic: license-file

# splent_feature_theme

The public shell of a [SPLENT](https://splent.io) product (`public_base.html`),
the design tokens it is drawn with, a library of composable blocks, and the
Appearance and Menus editors of the admin panel.

The theme never hardcodes a product name, palette or font. The product gives
its identity through configuration, and its palette through `THEME_TOKENS` or
a skin feature; the admin panel can change both at runtime. What this feature
provides and requires is listed in the `[tool.splent.contract]` section of
`pyproject.toml`, which `splent feature:contract --write` keeps in sync with
the source.

## The public shell

`public_base.html` is the website's page skeleton. A page names its shell
rather than the file, `{% extends shell('public') %}`, which resolves to
`public_base.html` unless the product's `SPLENT_SHELL_PUBLIC` names another
layout. In order, the shell draws:

- a `<head>` with the brand font, the tokens as an inline `:root` block,
  then every stylesheet the asset registry holds for the public shell, by
  `order`;
- the `site-header`: the brand (logo, name or both), the main nav, the
  language switcher, and the far right of the nav;
- the breadcrumb trail, the flashed messages, then the hero and the content;
- the `site-footer`, with the footer links and the social links;
- the registered scripts.

It never hardcodes a product name, palette or font. Pages fill the Jinja
blocks `title` (the page's own name: the shell writes
"<page> · <site name>" into `<title>` and `og:title`), `head`, `hero`,
`content` and `scripts`, and any feature adds markup through these slots
(`get_template_hooks`), with no edit to the shell:

| Slot | Where |
|---|---|
| `layout.head` | End of `<head>`, 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's social list |
| `layout.scripts` | End of `<body>`, after the registered scripts |

The theme fills none of the public slots itself. A hook meant for one page
checks `request.endpoint` and returns `""` elsewhere, and a page that steps
aside only when a feature contributes uses `render_template_hooks(name)`,
which returns the slot's joined output, empty when nobody contributed (the
home's `home.hero` works this way, see splent_feature_public).

The shell links whichever icons the product ships in its static folder
(`favicon.ico`, `favicon.svg`, `favicon-32.png`, `favicon-192.png`,
`favicon-512.png`, `apple-touch-icon.png`), and emits `og:image` only when
the product ships `static/og.png`.

## Design tokens

The look of the public shell comes from a small set of tokens, whose
neutral defaults are `DEFAULT_TOKENS` in `tokens.py`: `primary`,
`primary_contrast`, `accent`, `bg`, `surface`, `text`, `heading`, `muted`,
`border`, `radius`, `container`, `font_body`, `font_heading`,
`font_display` and `font_url`. Each becomes a CSS custom property,
`--brand-<key>` with `_` as `-` (`primary_contrast` is
`--brand-primary-contrast`). `font_url` is the exception: the address of a
font stylesheet, not a variable.

The default `font_url` is `theme:inter`: Inter, at the weights 400 to 700,
served by the theme itself. `frontend/package.json` takes the font from
npm (`@fontsource-variable/inter`, OFL-1.1), and the build writes the
stylesheet of the `fonts` entry (`assets/css/inter.css`) and the font
files, with the licence beside them (`assets/inter.LICENSE.txt`), into
`assets/dist`. The rules and the files are the ones Google Fonts serves
for `family=Inter:wght@400;500;600;700`, byte for byte, so the public
shell looks the same and no page asks another site for its face.

`get_tokens(app)` merges three sources, highest first:

| Source | Which tokens | Wins when |
|---|---|---|
| The Appearance editor's `brand_primary`, `brand_accent`, `brand_bg`, `brand_surface`, `brand_text`, `brand_heading` | Those six colours | The value is not empty |
| `app.config["THEME_TOKENS"]`, the product's or its skin's | Any token | The value is not `None` |
| `DEFAULT_TOKENS` | All | Always, as the base |

`tokens_to_css` renders the result as the `:root { --brand-*: ... }` block
of the shell's `<head>`. The theme registers `public.css` at order 0,
feature stylesheets sit around 100 and skins at 200, so the cascade is
deterministic. `public.css` reads every colour, font and radius from a
`--brand-*` variable, never a literal, so reskinning a product is a change
of tokens, not of CSS. A few tokens have no default and only a skin sets
them: the flashed messages' borders read `--brand-success`,
`--brand-warning` and `--brand-danger`, with neutral fallbacks.

## Skins

A skin is a light feature with no domain logic, models or routes. It sets
`THEME_TOKENS` in its `init_feature` and registers its own stylesheet at
order 200, so it cascades last and inherits every `--brand-*` variable:

```python
def init_feature(app):
    from splent_framework.assets.asset_registry import register_asset

    app.config["THEME_TOKENS"] = OCEAN_TOKENS
    register_asset(
        "css", "skin_ocean.assets", order=200, subfolder="css", filename="skin_ocean.css"
    )
```

`init_feature` runs after the product's configuration, so with a skin
installed the product's own `THEME_TOKENS` are replaced whole; such a
product adjusts its colours in the Appearance editor. Installing or
removing a skin changes the whole public aesthetic with no edit to the
theme or the product's templates. A skin may also override a block
template through `[tool.splent.refinement]`.

## What templates get

`inject_context_vars` gives every template:

| Variable | What it is |
|---|---|
| `theme_tokens` | The merged tokens |
| `theme_tokens_css` | Their `:root` block |
| `render_block(name, **context)` | Renders `templates/blocks/<name>.html` as `Markup`, so it is inserted unescaped |
| `auto_breadcrumb(page_title, mode)` | The trail derived from the address, the page title and the composed nav, or `None` where the mode hides it |
| `site_title(page_title)` | The document title, the page's name then the site's |
| `page_shows_flash(content, message)` | Whether the page already shows a flashed message (below) |
| `site` | The product's identity, nav and locales (below) |

The blocks are `hero` and `breadcrumb`, and they pair with the component
classes of `public.css` (`.block-hero`, `.card-grid`, `.feature-grid`,
`.cta-banner`, `.countdown`, `.logo-wall`, `.gallery-grid`...), so the look
stays the same across products and skins.

```jinja
{{ render_block('hero', title=site.name, subtitle=site.tagline) }}
```

## The site object

The theme builds one `site` object for templates. Its name, tagline and
logo come from the Appearance editor first (`site_name`, `site_tagline`,
`site_logo`), then from `app.config`; the rest from `app.config` alone, so a
product sets them in its `[tool.splent.config]` (or a `config.py` of its
own, which wins over the environment).

| Config key | `site.*` | Used for |
|---|---|---|
| `SITE_NAME` | `name` | The brand name, the title, the footer. Falls back to `SPLENT_APP`, then `Site` |
| `SITE_TAGLINE` | `tagline` | The meta description, the hero's subtitle |
| `SITE_LOGO` | `logo` | The brand logo, a path inside the product's static folder |
| `SITE_SOCIAL` | `social` | The footer's social links, `{network, href}` each |
| `SITE_HERO_EYEBROW`, `SITE_HERO_ACTIONS` | `hero_eyebrow`, `hero_actions` | The line above the home's hero title, and its buttons (`{label, href, class}`) |
| `SITE_HIGHLIGHTS_TITLE`, `SITE_HIGHLIGHTS` | `highlights_title`, `highlights` | The highlight grid of the home (`{icon, title, text}` each) |
| `SITE_CTA` | `cta` | The home's closing call to action (`{title, text, href, button}`) |
| `SITE_EVENT` | `event` | The countdown and event line of the home's hero (`{edition, dates, venue, iso}`), when no feature fills `home.hero` |
| `SITE_SPONSORS`, `SITE_SPONSORS_TITLE` | `sponsors`, `sponsors_title` | A sponsor logo wall, for a product without a partners feature |
| `SITE_GALLERY`, `SITE_GALLERY_TITLE` | `gallery`, `gallery_title` | An image gallery of static paths, for a product without media |
| `SITE_NAV` | `nav` | The header nav, only when no feature registered an entry and no Menus override exists |

`SITE_SPONSORS_TITLE` and `SITE_GALLERY_TITLE` default to "Patrocinadores"
and "Galería". `site.footer_nav` has no config key: it is the row of custom
links the Menus editor stores. `site.locale` and `site.locales`
(`BABEL_SUPPORTED_LOCALES`) drive the language switcher, which appears only
when more than one locale is supported.

## The main nav and the footer links

Each content feature declares its public nav 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 Menus editor (`/admin/menus`) stores an override in the
settings store, key `site_nav`, a JSON list in menu order that reorders,
hides or relabels those entries and adds custom links:

```json
[
  {"type": "feature", "key": "projects", "visible": true},
  {"type": "feature", "key": "team", "label": "Our team", "visible": false},
  {"type": "custom", "label": "Old site", "href": "https://example.org"}
]
```

`compose_nav` reconciles both at render time (`nav.py`):

- the override drives order, visibility and labels; the entries of
  features no longer installed are dropped, and registered entries the
  override does not list are appended;
- without an override the registered entries render by `order`, and
  without any, `SITE_NAV`;
- labels go through gettext, so they follow the active locale;
- a custom link keeps only an `http`, `https`, `mailto`, `/` or `#`
  address, so no `javascript:` or `data:` link renders;
- an entry with `children` is drawn as a dropdown, and stays a link itself.

The shell marks at most one entry current, with `is-active` and
`aria-current="page"`: an entry matches when the request's path is its
address or lies below it, the longest address wins, and the home entry
(`/`) matches only the home.

The footer has its own row of custom links from the same editor, under
`site_footer_nav` (`{label, href}` in order, at most 20), drawn before the
social links when not empty.

## Flashed messages

The shell shows what a feature says with Flask's `flash()` in a
`container site-flashes` block between the header 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 |

A page that shows its messages in a box of its own (a contact form, a
login page on the public layout) does not get them twice: the shell renders
the page first and leaves out any message that is the whole text of an
element of it. The same words inside a sentence do not count.

## Breadcrumbs

The shell draws a trail under the header on its own: Home, then every
level of the address, named by the nav entry that points at it (so a rename
in the Menus editor renames the crumb) or by the segment itself, a slug
turned into words, then the page's name. `SITE_BREADCRUMBS`, which the
Appearance editor can change, says where: `deep` (the default) only on
pages two levels down or more, `all` on every page but the home, `off`
never. A page that passes an explicit `breadcrumb` list gets exactly that
list.

## Scripts every page gets

The theme registers them at order 0, so no content feature ships its own:

- `countdown.js` drives `<div class="countdown" data-target="<ISO date>">`
  with `data-d`, `data-h`, `data-m` and `data-s` spans, so anything that
  announces a date gets a live timer.
- `lightbox.js` publishes the sticky header's height as
  `--site-header-offset`, and opens over the darkened page every image of
  rich text (`.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
  (`data-slideshow-ms` sets its pace). A thumbnail wrapped in a link to the
  full-size file opens that file, never in a new tab, so it degrades to a
  plain link. The control labels come from the `data-lightbox-*` attributes
  of `<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 its grid, appends the next page and calls `refresh()` then `next()`.
- `code_blocks.js` labels each `<pre><code class="language-...">` with its
  language and adds a copy button.

## The 404 page, robots.txt and the language

The theme answers the 404 itself, in the public shell with the product's
skin and the language in force: its handler is registered from
`init_feature`, after the framework's, so it replaces the bare `404.html`
older products ship. The Appearance editor edits its title, text and button
(`site_404_title`, `site_404_text`, `site_404_cta`), and a product that
wants a page of its own shadows `errors/404.html` in its templates.

`/robots.txt` lets every crawler in. `/lang/<code>` stores the reader's
language in the session and sends them back to the page they came from,
when it is on this site, else to the home.

## Menu entries

The editors register two entries in the Administration section of the
application shell's menu registry. Neither names roles: each takes the ones
its screen asks for (`routes.PANEL_ROLES`, administrators), so nobody is
offered a door that answers 403.

| Key | Label | Order | Icon | Opens |
|---|---|---|---|---|
| `theme_appearance` | Appearance | 40 | `sliders` | `theme.admin_appearance` |
| `theme_menus` | Menus | 50 | `menu` | `theme.admin_menus` |

A product hides or reorders them by key in `[tool.splent.layout.menu]`.

```toml
# the product's pyproject.toml
[tool.splent.layout.menu]
hide = ["theme_menus"]
```

The `layout.authenticated_sidebar` hooks that drew the same two entries stay
for one minor, for the shells that still render slots. A shell draws the
registry or the slots, never both.

## The Appearance editor

`/admin/appearance`, for administrators, edits the site's name, tagline,
logo, header brand, logo height, breadcrumbs, the 404 page's copy and the
six colours of the tokens. Each field is stored in the settings store under
its key in small letters (`site_name` for `SITE_NAME`), the `runtime`
parameters of the theme, so `param('theme', 'SITE_NAME')` returns what the
editor stored too (framework 1.23 and later).

A Save stores only what the administrator changed, as the settings panel
does: a field posted as it was shown writes nothing, and one set back to
what the product says clears the override that was there. The form shows
what is in force for a field nobody set in the panel (the skin's colours,
the header and breadcrumb choices of the product's environment), so
pressing Save does not freeze them into the database, and a later release
of the skin can still change the colours nobody chose.

## Configuration

Declared in `[tool.splent.config]`, with their types in
`[tool.splent.config_schema]`. A product overrides them in its own
`[tool.splent.config]`. The `runtime` ones are also the Appearance
editor's, which wins over all of them where it has a value.

| Key | Type | Bind | Default | What it does |
|---|---|---|---|---|
| `SITE_NAME` | str | runtime | the package name | What the site calls itself in the header, the browser tab and the footer. |
| `SITE_TAGLINE` | str | runtime | empty | The line under the name. |
| `SITE_LOGO` | str | runtime | empty | The header logo, a path inside the product's static folder. Empty draws no logo. |
| `SITE_BRAND_MODE` | enum | runtime | `both` | How the header brand renders: `logo`, `text` or `both`. |
| `SITE_LOGO_SIZE` | int, `16` to `200` | runtime | `40` | The logo height in pixels. |
| `SITE_BREADCRUMBS` | enum | runtime | `deep` | Where the automatic breadcrumb trail appears: `deep`, `all` or `off`. |
| `THEME_TOKENS` | map | derivation | the theme's defaults | The palette and fonts, a JSON object of token name to CSS value. |

The keys of [The site object](#the-site-object) other than these are read
from `app.config` and have no schema.

`THEME_TOKENS` takes the names of `DEFAULT_TOKENS` in `tokens.py` and goes
over them, so a product writes only what it changes. An empty `font_url`
loads no web font, and the fonts named in `font_body`, `font_heading` and
`font_display` then come from the reader's system. Any other address is
linked as it is: a skin that names a Google Fonts stylesheet loads it from
there.

```toml
# the product's pyproject.toml
[tool.splent.config]
SITE_NAME = "uvlhub"
SITE_LOGO = "media/logos/default.svg"
SITE_BRAND_MODE = "logo"
THEME_TOKENS = '{"primary": "#287FB9", "font_url": "", "font_body": "system-ui, sans-serif"}'
```

A `THEME_TOKENS` that is not valid JSON, is not an object, names a token the
theme does not have or gives one a value that is not a string stops the boot
with a message that says which, instead of leaving the default palette with
nothing in the log. A skin that sets `THEME_TOKENS` in its `init_feature`
replaces the product's whole.

`SITE_LOGO` and `THEME_TOKENS` are read from the environment from theme
0.14.0; before it only a product's `config.py` could set them.

It requires settings, whose store keeps the editors' values.

## Use it in a product

```bash
splent feature:add splent_feature_theme
```

## Develop

```bash
splent feature:test splent_feature_theme
splent feature:contract splent_feature_theme
```
