Instance branding: logo and favicon upload with in-browser cropping #306
Labels
No Label
area:auth
area:docs
area:export
area:ops
area:storage
area:supply-chain
auth
backend
blocked
collab
deployment
docs
effort:L
effort:M
effort:S
frontend
plugins
qa
vs-nfd
vs-nfd:blocker
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: stwaidele/dorfteich#306
Loading…
Reference in New Issue
Block a user
No description provided.
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
apps/web/index.htmlhas no<link rel="icon">andapps/web/public/holds onlyfontsandtheme-init.js. This introduces one rather than replacing one.Dorfteich(
apps/web/src/layout/TopBar.tsx:106).instance.name(
apps/api/src/settings/instance-settings.service.ts:19) is neverrendered in the running app — only in the setup wizard and the admin
settings form.
apps/web/src/layout/Sidebar.tsx:234) holds thepond 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/uploadsand./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_settingsas ordinary typed keys, e.g.instance.logo,instance.logoDark,instance.favicon. The bytes stayon 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|darkGET /api/v1/branding/faviconUnauthenticated: 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 existingnosniffheader, and putthe 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">toapps/web/index.html, plus anapple-touch-iconlink. The link isconstant; 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 thatfor the
langattribute because an attribute cannot be indirected — anicon 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 —
sharpwould have to be added tothe offline kit and the
--network nonebuild described indocs/vs-nfd/96-offline-build-protokoll.md.Generate from one source image:
.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-schemeplus thedata-themeattribute), 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 headingbelow it.
Its accessible name is
instance.name, not "logo": for a screen readerit is the link home, and its name has to say where it goes.
With no logo uploaded, render
instance.nameas text in that slot.Also in scope (decided 2026-08-01): the top bar's wordmark uses
instance.nameinstead of the hardcodedDorfteich. An operator who hasuploaded their own logo does not expect the product name to remain in the
chrome. This changes the default instance's appearance too — with
instance.namedefaulting toDorfteich, an untouched instance looksthe same as before.
Security
origin is a cross-site-scripting vector. Accept PNG, JPEG and WebP;
emit PNG.
bytes in the normal flow, but the endpoint accepts whatever a caller
posts.
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.tsand indocs/architecture/audit-events.md, with the catalogue minor versionbumped. The catalogue is at v1.5 and #303 also bumps it — whichever lands
second takes the next number.
Accessibility
given a redundant "logo" alt text.
excludes keyboard and switch users. Provide numeric inputs or arrow-key
nudging for the crop area and the zoom, with visible focus.
dimensions), not by the visual frame alone.
the form, not tooltip-only.
existing form error path so they are announced.
apps/web/e2e/a11y.spec.tsper thestanding rule (ADR 0017,
code/CLAUDE.md).Acceptance criteria
UI states beforehand that the background will not become
transparent.
/, and itsaccessible name is the instance name.
including on first paint with no flash of the wrong one.
broken.
shipped default does.
logo and favicon back — verified in a real restore.
pnpm lint,pnpm typecheck,pnpm i18n:check, api suite and thea11y spec pass.