An instance had no way to look like itself: the top bar said "Dorfteich"
whatever the operator called their instance, `instance.name` was never
rendered in the running app at all, and there was no favicon anywhere —
`index.html` had no `<link rel="icon">` and `public/` held only fonts and
theme-init.js.
Where the line is drawn, and why:
- **The api never decodes an image.** Cropping, scaling and the conversion
to PNG happen on a canvas in the browser; the api checks the PNG
signature, reads the IHDR dimensions at their fixed offsets and enforces
the caps. An image library would put a decoder in front of
attacker-supplied bytes AND would have to be carried through the
`--network none` offline build. Reading two big-endian integers is not
decoding.
- **SVG is refused**, with its own error message rather than a generic
"not a PNG": it can carry script, and serving it from our own origin
would be a cross-site-scripting vector. An operator who tried one should
learn that it is deliberate.
- **The crop is driven by number inputs, not by dragging.** A drag-only
cropper excludes keyboard and switch users outright; a number input is
arrow-key operable and screen-reader readable without any custom aria.
The resulting pixel size is stated in text, not only drawn as a frame.
- **The variant is chosen by CSS, not JavaScript.** `theme-init.js` has
already resolved `data-theme` before first paint, so the correct logo is
the one painted rather than the one that appears after a flash. Without a
dark variant the LIGHT logo carries both themes — the operator's own
asset shown unchanged beats one they did not choose (the rule #307
extends to ponds). The settings screen warns; it never blocks.
- **The favicon link is static, its resource dynamic.** index.html stays a
static file and the api answers with the uploaded icon or a shipped
default — that route must never 404, or the browser keeps its generic
icon for good. The default is generated by a script from Node's own zlib
(`gen-default-favicon.mjs`), for the same offline-build reason.
- Both favicon sizes are uploaded together: one source, one crop, so the
tab icon and the home-screen icon can never disagree.
- Branding is served WITHOUT a session, because the login screen carries it
and the browser fetches the favicon before anyone signs in. The admin
screen says so — an operator may not expect their logo to be public.
- The metadata is not writable through the settings endpoint: it describes
bytes on disk, and hand-writing it would claim an asset that is not
there.
`./data/branding` follows the three-step rule #303 paid for: env default +
`data-dirs.ts` entry, compose volume (repo AND the stages on ONE), and the
`mkdir`/`chown` line in the api Dockerfile. `data-dirs.test.ts` is new and
closes the hole that made #303's variant invisible: the nightly archive
skips a missing directory WORDLESSLY, so the fence now demands that every
`*_DIR` the backup env declares actually travels in the archive. Verified
against the real defect — removing the line fails it by name.
Audit catalogue v1.7 (`branding.changed`), carrying `scope` from the start
so #307 is the same event with a different scope, not a second id.
Verified: api suite 103 files green (a lone `public-api` ECONNRESET under
local parallel load, green in isolation — the documented local flake);
branding suite 12 tests against a real directory; crop arithmetic unit
tests; a11y pack 11/11 in both schemes; /admin measured at 320px with the
new section (overflow 0); and the whole flow walked in the browser: upload
→ crop 780×180 → stored as 512×118 → logo in the sidebar linking home with
the instance name as its accessible name → topbar wordmark following
`instance.name` → light logo still shown under `data-theme="dark"`.