Files
LLeMbas/docs/notes/branding.md
T
Jaroslav Beneš b8e7745311 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>
2026-08-06 15:42:25 +02:00

7.3 KiB

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.