Instance branding: logo and favicon upload with in-browser cropping #306

Closed
opened 2026-08-01 06:37:17 +02:00 by opus-5 · 0 comments
Contributor

A site admin should be able to upload an instance logo and a favicon in
the instance settings. The logo appears at the top of the sidebar; the
favicon is served to the browser.

Per-pond overrides of both are a follow-up issue. This one establishes
the storage, serving and upload machinery they build on — keep the
resolution path (which asset applies here?) in one place from the start,
so the follow-up adds a layer rather than a parallel implementation.

Starting point

  • There is no favicon at all today: apps/web/index.html has no
    <link rel="icon"> and apps/web/public/ holds only fonts and
    theme-init.js. This introduces one rather than replacing one.
  • The top bar's wordmark is the hardcoded string Dorfteich
    (apps/web/src/layout/TopBar.tsx:106). instance.name
    (apps/api/src/settings/instance-settings.service.ts:19) is never
    rendered in the running app — only in the setup wizard and the admin
    settings form.
  • The sidebar header (apps/web/src/layout/Sidebar.tsx:234) holds the
    pond name and the sort select.

Storage — same rule as the custom fonts

Binary branding assets are data, not image content. They go into a new
./data/branding/ directory, a sibling of ./data/uploads and
./data/plugins, and must be added to the backup archive
(apps/backup/src/index.ts:53) and the restore path
(apps/backup/src/perform-restore.ts:33). archiveBase
(apps/backup/src/archive.ts:14) requires the shared parent.

This is the same constraint #303 solves for custom fonts. Coordinate
with #303
: one directory-registration mechanism for both, not two
parallel ones. Whichever lands first sets the pattern.

Metadata (present/absent, mime type, a content hash for cache busting)
belongs in instance_settings as ordinary typed keys, e.g.
instance.logo, instance.logoDark, instance.favicon. The bytes stay
on disk. Remember the settings cache is in-process — a change needs the
usual invalidation, not an api restart.

Serving

  • GET /api/v1/branding/logo?variant=light|dark
  • GET /api/v1/branding/favicon

Unauthenticated: the login screen carries the branding and the browser
fetches the favicon before anyone signs in. Branding is therefore visible
to anonymous visitors by design — worth stating in the admin UI, since an
operator may not expect their logo to be public.

Pin the Content-Type, rely on the existing nosniff header, and put
the content hash in the URL so a changed logo is picked up without
fighting caches.

Favicon without touching index.html

Add a static <link rel="icon" href="/api/v1/branding/favicon"> to
apps/web/index.html, plus an apple-touch-icon link. The link is
constant; the resource behind it is dynamic and falls back to a shipped
default when nothing was uploaded.

This deliberately avoids server-rendering index.html. #179 needs that
for the lang attribute because an attribute cannot be indirected — an
icon can. The two issues stay independent.

Image handling in the browser

Cropping, scaling and conversion to PNG happen client-side on a canvas;
the upload carries the finished bytes. No new dependency, and in
particular no native image library — sharp would have to be added to
the offline kit and the --network none build described in
docs/vs-nfd/96-offline-build-protokoll.md.

Generate from one source image:

  • Logo: PNG at a sensible bound (e.g. max 512px on the long edge).
  • Favicon: square PNG at 32×32 and 180×180 (apple-touch). No .ico
    every current browser accepts PNG.

Say what conversion can and cannot do

The UI recommends PNG with a transparent background for the logo. It
must also state the limitation plainly: a JPEG has no alpha channel, so
converting one produces a PNG with an opaque background.
Cropping and
format conversion are offered; transparency cannot be invented. Without
this sentence, an operator uploads a JPEG, reads "converted to PNG", and
gets a white box behind their logo.

Light and dark

Two logo slots: one for light, an optional one for dark. Without the dark
one, the light logo is used in both themes — the uploaded asset is
shown as it is, rather than substituting something the operator did not
choose.

Because that can look wrong on a dark surface, the settings screen shows
a warning when the dark variant is missing. It is advisory: it names the
consequence and can be ignored. No upload is blocked by it.

A dark logo on a transparent background disappears on a dark surface, and
theming is device-local (ADR 0018) — the server cannot know which applies.
Variant selection follows the existing theming cascade in CSS
(prefers-color-scheme plus the data-theme attribute), not JavaScript,
so it is correct on first paint like the rest of the theme handling.

Placement

The logo goes at the top of the sidebar, above the pond name, as a link
to / — the instance identity, with the pond name remaining the heading
below it.

Its accessible name is instance.name, not "logo": for a screen reader
it is the link home, and its name has to say where it goes.

With no logo uploaded, render instance.name as text in that slot.

Also in scope (decided 2026-08-01): the top bar's wordmark uses
instance.name instead of the hardcoded Dorfteich. An operator who has
uploaded their own logo does not expect the product name to remain in the
chrome. This changes the default instance's appearance too — with
instance.name defaulting to Dorfteich, an untouched instance looks
the same as before.

Security

  • Reject SVG. SVG can carry script, and serving it from our own
    origin is a cross-site-scripting vector. Accept PNG, JPEG and WebP;
    emit PNG.
  • Validate magic bytes server-side as well. The canvas produces the
    bytes in the normal flow, but the endpoint accepts whatever a caller
    posts.
  • Size caps per asset; reject oversized uploads before writing to disk.
  • Do not pass uploads through an image parser in the api (see #303 for
    the same reasoning on fonts).

Audit

Changing instance branding is a site-admin action and should be audited:
an entry in apps/api/src/audit/audit-actions.ts and in
docs/architecture/audit-events.md, with the catalogue minor version
bumped. The catalogue is at v1.5 and #303 also bumps it — whichever lands
second takes the next number.

Accessibility

  • The logo link's accessible name is the instance name; the image is not
    given a redundant "logo" alt text.
  • The crop control must be keyboard-operable. A drag-only cropper
    excludes keyboard and switch users. Provide numeric inputs or arrow-key
    nudging for the crop area and the zoom, with visible focus.
  • The crop preview conveys the result in text too (e.g. selected pixel
    dimensions), not by the visual frame alone.
  • Format recommendations and the JPEG/transparency limitation are text in
    the form, not tooltip-only.
  • Upload errors — wrong type, too large, rejected SVG — go through the
    existing form error path so they are announced.
  • The new admin screen goes into apps/web/e2e/a11y.spec.ts per the
    standing rule (ADR 0017, code/CLAUDE.md).
  • Check the logo does not break the sidebar at 320px — see #301.

Acceptance criteria

  • A site admin can upload, replace and remove a logo and a favicon.
  • A source JPEG can be cropped and converted in the browser, and the
    UI states beforehand that the background will not become
    transparent.
  • The logo appears at the top of the sidebar, links to /, and its
    accessible name is the instance name.
  • With a dark variant uploaded, the correct logo shows in each theme,
    including on first paint with no flash of the wrong one.
  • With no logo, the instance name renders as text and nothing looks
    broken.
  • The favicon shows in the browser tab; with none uploaded, the
    shipped default does.
  • An SVG upload is rejected with a clear message.
  • A backup taken after upload and restored into an empty stack brings
    logo and favicon back — verified in a real restore.
  • The crop control is fully operable by keyboard.
  • pnpm lint, pnpm typecheck, pnpm i18n:check, api suite and the
    a11y spec pass.
A site admin should be able to upload an instance logo and a favicon in the instance settings. The logo appears at the top of the sidebar; the favicon is served to the browser. Per-pond overrides of both are a follow-up issue. This one establishes the storage, serving and upload machinery they build on — keep the resolution path (which asset applies here?) in one place from the start, so the follow-up adds a layer rather than a parallel implementation. ## Starting point - There is **no favicon at all** today: `apps/web/index.html` has no `<link rel="icon">` and `apps/web/public/` holds only `fonts` and `theme-init.js`. This introduces one rather than replacing one. - The top bar's wordmark is the hardcoded string `Dorfteich` (`apps/web/src/layout/TopBar.tsx:106`). `instance.name` (`apps/api/src/settings/instance-settings.service.ts:19`) is never rendered in the running app — only in the setup wizard and the admin settings form. - The sidebar header (`apps/web/src/layout/Sidebar.tsx:234`) holds the **pond** name and the sort select. ## Storage — same rule as the custom fonts Binary branding assets are data, not image content. They go into a new `./data/branding/` directory, a sibling of `./data/uploads` and `./data/plugins`, and must be added to the backup archive (`apps/backup/src/index.ts:53`) and the restore path (`apps/backup/src/perform-restore.ts:33`). `archiveBase` (`apps/backup/src/archive.ts:14`) requires the shared parent. This is the same constraint #303 solves for custom fonts. **Coordinate with #303**: one directory-registration mechanism for both, not two parallel ones. Whichever lands first sets the pattern. Metadata (present/absent, mime type, a content hash for cache busting) belongs in `instance_settings` as ordinary typed keys, e.g. `instance.logo`, `instance.logoDark`, `instance.favicon`. The bytes stay on disk. Remember the settings cache is in-process — a change needs the usual invalidation, not an api restart. ## Serving - `GET /api/v1/branding/logo?variant=light|dark` - `GET /api/v1/branding/favicon` Unauthenticated: the login screen carries the branding and the browser fetches the favicon before anyone signs in. Branding is therefore visible to anonymous visitors by design — worth stating in the admin UI, since an operator may not expect their logo to be public. Pin the `Content-Type`, rely on the existing `nosniff` header, and put the content hash in the URL so a changed logo is picked up without fighting caches. ### Favicon without touching index.html Add a **static** `<link rel="icon" href="/api/v1/branding/favicon">` to `apps/web/index.html`, plus an `apple-touch-icon` link. The link is constant; the resource behind it is dynamic and falls back to a shipped default when nothing was uploaded. This deliberately avoids server-rendering `index.html`. #179 needs that for the `lang` attribute because an attribute cannot be indirected — an icon can. The two issues stay independent. ## Image handling in the browser Cropping, scaling and conversion to PNG happen client-side on a canvas; the upload carries the finished bytes. No new dependency, and in particular no native image library — `sharp` would have to be added to the offline kit and the `--network none` build described in `docs/vs-nfd/96-offline-build-protokoll.md`. Generate from one source image: - Logo: PNG at a sensible bound (e.g. max 512px on the long edge). - Favicon: square PNG at 32×32 and 180×180 (apple-touch). No `.ico` — every current browser accepts PNG. ### Say what conversion can and cannot do The UI recommends **PNG with a transparent background** for the logo. It must also state the limitation plainly: **a JPEG has no alpha channel, so converting one produces a PNG with an opaque background.** Cropping and format conversion are offered; transparency cannot be invented. Without this sentence, an operator uploads a JPEG, reads "converted to PNG", and gets a white box behind their logo. ## Light and dark Two logo slots: one for light, an optional one for dark. Without the dark one, **the light logo is used in both themes** — the uploaded asset is shown as it is, rather than substituting something the operator did not choose. Because that can look wrong on a dark surface, the settings screen shows a warning when the dark variant is missing. It is advisory: it names the consequence and can be ignored. No upload is blocked by it. A dark logo on a transparent background disappears on a dark surface, and theming is device-local (ADR 0018) — the server cannot know which applies. Variant selection follows the existing theming cascade in CSS (`prefers-color-scheme` plus the `data-theme` attribute), not JavaScript, so it is correct on first paint like the rest of the theme handling. ## Placement The logo goes at the top of the sidebar, above the pond name, as a link to `/` — the instance identity, with the pond name remaining the heading below it. Its accessible name is `instance.name`, not "logo": for a screen reader it is the link home, and its name has to say where it goes. With no logo uploaded, render `instance.name` as text in that slot. **Also in scope (decided 2026-08-01):** the top bar's wordmark uses `instance.name` instead of the hardcoded `Dorfteich`. An operator who has uploaded their own logo does not expect the product name to remain in the chrome. This changes the default instance's appearance too — with `instance.name` defaulting to `Dorfteich`, an untouched instance looks the same as before. ## Security - **Reject SVG.** SVG can carry script, and serving it from our own origin is a cross-site-scripting vector. Accept PNG, JPEG and WebP; emit PNG. - Validate magic bytes **server-side** as well. The canvas produces the bytes in the normal flow, but the endpoint accepts whatever a caller posts. - Size caps per asset; reject oversized uploads before writing to disk. - Do not pass uploads through an image parser in the api (see #303 for the same reasoning on fonts). ## Audit Changing instance branding is a site-admin action and should be audited: an entry in `apps/api/src/audit/audit-actions.ts` **and** in `docs/architecture/audit-events.md`, with the catalogue minor version bumped. The catalogue is at v1.5 and #303 also bumps it — whichever lands second takes the next number. ## Accessibility - The logo link's accessible name is the instance name; the image is not given a redundant "logo" alt text. - **The crop control must be keyboard-operable.** A drag-only cropper excludes keyboard and switch users. Provide numeric inputs or arrow-key nudging for the crop area and the zoom, with visible focus. - The crop preview conveys the result in text too (e.g. selected pixel dimensions), not by the visual frame alone. - Format recommendations and the JPEG/transparency limitation are text in the form, not tooltip-only. - Upload errors — wrong type, too large, rejected SVG — go through the existing form error path so they are announced. - The new admin screen goes into `apps/web/e2e/a11y.spec.ts` per the standing rule (ADR 0017, `code/CLAUDE.md`). - Check the logo does not break the sidebar at 320px — see #301. ## Acceptance criteria - [ ] A site admin can upload, replace and remove a logo and a favicon. - [ ] A source JPEG can be cropped and converted in the browser, and the UI states beforehand that the background will not become transparent. - [ ] The logo appears at the top of the sidebar, links to `/`, and its accessible name is the instance name. - [ ] With a dark variant uploaded, the correct logo shows in each theme, including on first paint with no flash of the wrong one. - [ ] With no logo, the instance name renders as text and nothing looks broken. - [ ] The favicon shows in the browser tab; with none uploaded, the shipped default does. - [ ] An SVG upload is rejected with a clear message. - [ ] A backup taken after upload and restored into an empty stack brings logo and favicon back — verified in a real restore. - [ ] The crop control is fully operable by keyboard. - [ ] `pnpm lint`, `pnpm typecheck`, `pnpm i18n:check`, api suite and the a11y spec pass.
opus-5 added this to the M33 — Tweaks & Feinschliff milestone 2026-08-01 06:37:17 +02:00
opus-5 added the
effort:L
backend
frontend
labels 2026-08-01 06:37:17 +02:00
Sign in to join this conversation.
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: stwaidele/dorfteich#306
No description provided.