An instance that can be somebody else's

A name, a tagline, a logo, a favicon and the launcher icons derived from it; the
Middle-earth strings as data; themes as token sets; and a stylesheet for what
none of that reaches. All four are on one page, in one settings group.

The snapshot is a Jinja global over a process-level cache, because render() has
no session and four render paths never reach it at all -- the sign-in page, the
error pages, the offline page and the SSE fragments. A context value would have
had to be threaded through every one and would still have missed those. It being
a global is also what lets mark() branch on an uploaded logo without any of its
six call sites learning about branding; the macro that renders the sidebar link
is called brandlink now, because a macro imported as `brand` shadows the global
for the whole template and took out every page at once.

Defaults in code and overrides in the database, as the prompt fragments do, with
one difference stated in the module: an empty fragment means off, an empty
flavour string means the shipped wording. And blanked rather than dropped --
settings_store.update merges, so an omitted key leaves what was stored last time
and "I typed the default back in" would store something different from "I changed
nothing".

A custom theme sets a handful of tokens and inherits the rest, and the
inheritance is a CSS fact: tokens.css matches [data-base="shire"] as well as
[data-theme="shire"], so a custom light theme lands on parchment rather than four
light colours on near-black. Values are validated on read rather than on save,
because a theme written straight into the settings table still has to produce a
stylesheet that parses -- a `}` in a value ends the rule and silently breaks
every rule after it. The soft variants are derived from the accent, or a changed
accent leaves focus rings in the old hue and reads as half-working.

/branding.css is a route, not an inline block: an external stylesheet has no HTML
context to escape from. The link carries a content hash, so a save is not left to
the browser's cache, and it is deliberately outside the service worker's precache
list, which is versioned by the release.

The instance name moved off /admin/general rather than being duplicated there.
An upgrade keeps it: the general row is read as a seed exactly while the branding
row has never mentioned the name, which is `key in row` and not `row[key] is
truthy` -- the two read alike would resurrect the old name underneath a cleared
one.

The theme list stops being a hard-coded pair in five places. Every failure mode
in that area is silent, so it is driven under a DOM stub as well as tested.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-06 15:42:25 +02:00
parent e16bede85b
commit b8e7745311
70 changed files with 2051 additions and 125 deletions
+138
View File
@@ -0,0 +1,138 @@
# Branding and customization
Read this before touching `services/branding.py`, the `brand` Jinja global, the
`data-theme` / `data-base` pair, or `/branding.css`.
An instance can be somebody else's. That is four separate things — an identity,
the flavour text, themes, and arbitrary CSS — and they are separate because they
fail differently.
## Why a snapshot, and why a Jinja global
`render()` has no database session, and four render paths never reach it at all:
the sign-in page, the error pages, the offline page and the SSE fragments. A
context value would have to be threaded through every one of them, and would
still miss the ones that bypass `render()`.
So `branding.snapshot()` is a **process-level cache**, exposed as
`templates.env.globals["brand"]` through a small proxy. It has to be a proxy, not
the snapshot itself: a global is bound once at import, and the snapshot changes
when somebody saves.
`branding.forget()` is called by `api/admin_branding.py` and by nothing else. A
save that did not drop the cache would take effect at the next restart — the
"looks like it worked and did nothing" failure this codebase keeps cataloguing.
`tests/conftest.py` drops it between tests for the same reason it clears the
generation registry: otherwise the first test to render a page pins one
instance's identity against a database that has since been thrown away.
**`brand` is a global, so it works inside a macro.** That is what lets `mark()`
branch on an uploaded logo without every one of its six call sites learning about
branding. The macro that renders the sidebar brand link is called `brandlink` for
exactly this reason: a macro imported as `brand` shadows the global for the whole
template, which took out every page at once when it was called that.
## Defaults in code, overrides in the database
The prompt-fragment rule again, with **one difference that matters**. A fragment
stored empty means *off*; a flavour string stored empty means *use the shipped
wording*. A fragment being off is a state somebody wants, and a heading with no
words is not.
`stored_only` blanks anything equal to its shipped text rather than dropping the
key, and the reason is `settings_store.update`: it **merges**, so an omitted key
leaves whatever was stored last time. Dropping would make "I typed the default
back in" and "I changed nothing" store different things, and would make clearing
a box do nothing at all.
## The instance name moved
It lived in the general group before there was a branding one. Storage is
unchanged for an upgrade: `_read` seeds from the general row **when the branding
row has never said anything about the name** — `"instance_name" in row.value`,
which is why it reads the raw `Setting` rather than `get_group` (that one fills
in defaults and cannot tell absent from empty). An empty stored name is somebody
clearing the box and has to mean the default; reading the two the same way would
resurrect the old name underneath a cleared one.
`/admin/general` lost the field rather than keeping a second copy of it. Two
controls writing one value is how each becomes the answer to "why did my change
not stick?" — the same complaint the plan makes about group membership.
## Themes are token sets
`tokens.css` declares every colour under `:root[data-theme="…"]`, and no
component hard-codes one. That is what makes a third palette compose at all.
A custom theme sets a handful of tokens and **inherits the rest**, and the
inheritance is a CSS fact rather than a Python one:
- Moria's block matches bare `:root`, so it always applies.
- Shire's block matches `:root[data-theme="shire"]` **and
`:root[data-base="shire"]`**. That second selector is the whole mechanism.
- `<html>` carries both attributes. A custom light theme is
`data-theme="dusk" data-base="shire"`, so it gets the parchment palette
underneath its own four colours. Without it, four light colours would sit on
near-black surfaces.
- `/branding.css` loads after `tokens.css`, so the custom block wins on order at
equal specificity.
`--accent-soft`, `--leaf-soft` and `--danger-soft` are **derived** from the
colours above them, not asked for. They are the same hue at 14%, and an
administrator who set an accent without them would get focus rings in the old
one — which reads as the setting half-working rather than as a field they missed.
**Values are validated on read, not on save.** A theme written straight into the
settings table, or stored by an older version, still has to produce a stylesheet
that parses. A value that is not a colour is *dropped* rather than corrected: a
colour nobody can read is visible, and a mangled one is not. This is not
decoration — a `}` in a value ends the rule and silently breaks every rule after
it, and `url(…)` in a colour slot is a request to a third party from every page.
## The theme list is one list now
It used to be a hard-coded pair in five places. It is `brand.theme_ids` on the
server and `data-themes` on `<html>` in the browser — `id:base` pairs, space
separated, because both things that need it (`/theme` validating a name and
`applyTheme` setting both attributes) want a list to split rather than a document
to parse. `app.js:toggleTheme` goes round the list rather than flipping between
two names; with only the built-in pair that is byte-for-byte what it did before.
Every failure mode here is silent: `applyTheme` returning early on an unknown
name looks exactly like a button that does nothing, and
`POST /api/preferences/theme` answers a rejection with `{"ok": false}` that
nothing displays. `tests/test_branding.py` and the DOM stub cover both
directions.
## `/branding.css` is a route
A route and not an inline `<style>`, and that is a **security property** before
it is a caching one: an external stylesheet has no HTML context to escape from,
so an administrator's CSS cannot become markup however it is written. Inline, the
same text would be one `</style>` away from being a script on every page.
The link carries `?v={{ brand.revision }}`, a hash of everything the route
builds, so the URL changes exactly when the stylesheet does. It is **deliberately
not in the service worker's precache list**: that cache is versioned by the
release, and branding changes between releases, so a precached copy would outlive
every rebrand until the next version bump.
## Assets are served unauthenticated, and SVG is not accepted
`/branding/{filename}` has no auth guard, for the reason the manifest and the
offline page have none: the sign-in page needs the logo before anybody has signed
in, and a browser fetches a manifest icon outside any session.
What that exposes is a file an administrator uploaded on purpose to be shown to
everybody, under a random name, in a format that cannot execute in an `<img>`.
`uploads.ALLOWED_TYPES` is what makes the last clause true, and it is why **SVG
stays out** — the one place somebody will most want it is the one place it is
least safe.
Launcher icons are derived from the uploaded logo with Pillow at save time, not
on demand: a manifest icon has to be a real PNG at the size it declares, and
resizing on the path that serves it would be work per request. Best-effort — an
instance whose logo cannot be resized keeps the shipped icons, which is a worse
launcher tile and not a broken install. The manifest swaps the **whole set** or
none of it, because a tile that changes when the device picks a different size
reads as a bug in the install.