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 isdata-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.cssloads aftertokens.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.