# 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. - `` 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 `` 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 `` 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 ``. `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.