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:
@@ -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.
|
||||
Reference in New Issue
Block a user