#307: pond-level branding overrides the instance logo and favicon #317

Merged
opus-5 merged 1 commits from issue-307-pond-branding into main 2026-08-01 21:02:05 +02:00
Contributor

Implements #307 on top of #306 (now on main).

A layer on #306's storage, serving and crop control — not a parallel implementation. resolveBranding in shared is the ONE place that answers "which asset applies here?", and both the sidebar logo and the favicon swap read it.

The rule that is easiest to "fix" by accident

A logo set belongs to one level; variants are never mixed across levels. A pond that uploaded only a light logo shows THAT logo in dark mode — it does not borrow the instance's dark variant. Decided 2026-08-01: a logo silently swapping to a different image when the viewer switches theme is a change nobody ordered, and a design that looks wrong is more honest than one that is quietly substituted. Only a pond with no logo at all inherits the instance's set, again as a set.

It is pinned by name in branding.test.ts, in both directions (pond-light-only stays on its own logo in dark mode; a pond with nothing set inherits both instance variants).

Two consequences that are easy to get wrong:

  • The serving route does not fall back when given a pond scope. The caller already decided which level applies; a "helpful" fallback in the route would mix variants behind the resolver's back.
  • The logo link's accessible name follows the level — a pond logo is named by the pond, an instance logo by the instance. It is the link home, and a link's name has to say where it goes.

Quota and purge

  • Charged to the pond's storage quota before the write, like attachments. Without it branding is a way around the quota, and replacing a logo repeatedly consumes disk with no ceiling. The replaced asset's bytes are released FIRST, so re-uploading the same logo costs nothing — and a refused upload puts the released reservation back, so a rejection cannot leave the pond with more room than before. Asserted with real usage numbers.
  • Purge removes the pond's branding files — asserted against the real purge path, not the new code alone. The purge standard is absolute: after it, nothing referencing the pond survives, rows or files.

Unchanged security

Not relaxed because the uploader is now an ordinary Pond Admin rather than the operator: SVG refused (own error message), magic bytes and IHDR checked server-side, size caps, content type pinned on serving, no image parsing in the api.

Favicon swap

Driven by the RESOLVED pond, never the raw route parameter — an unreadable or unknown slug must not leave a stale icon in the tab. That the swap happens after first paint is accepted and stated in the code and the UI text; avoiding it would mean server-rendering index.html, which is #179's territory.

Verification

  • api suite 105 files / 592 tests green.
  • 5 pond-branding e2e tests: the pond scope serves the pond's bytes while the instance level still 404s; the quota is charged and released exactly; SVG refused at pond level; a reader may read but not change; purge deletes the files.
  • 9 shared unit tests on the resolution order and the no-decoding image checks.
  • Same audit id as #306 (branding.changed) with scope: 'pond' — the catalogue already documents the field, so no version bump.

Closes #307

Implements #307 on top of #306 (now on main). A layer on #306's storage, serving and crop control — not a parallel implementation. `resolveBranding` in shared is the ONE place that answers "which asset applies here?", and both the sidebar logo and the favicon swap read it. ## The rule that is easiest to "fix" by accident **A logo set belongs to one level; variants are never mixed across levels.** A pond that uploaded only a light logo shows THAT logo in dark mode — it does not borrow the instance's dark variant. Decided 2026-08-01: a logo silently swapping to a different image when the viewer switches theme is a change nobody ordered, and a design that looks wrong is more honest than one that is quietly substituted. Only a pond with no logo at all inherits the instance's set, again as a set. It is pinned by name in `branding.test.ts`, in both directions (pond-light-only stays on its own logo in dark mode; a pond with nothing set inherits both instance variants). Two consequences that are easy to get wrong: - **The serving route does not fall back when given a pond scope.** The caller already decided which level applies; a "helpful" fallback in the route would mix variants behind the resolver's back. - **The logo link's accessible name follows the level** — a pond logo is named by the pond, an instance logo by the instance. It is the link home, and a link's name has to say where it goes. ## Quota and purge - Charged to the pond's storage quota **before the write**, like attachments. Without it branding is a way around the quota, and replacing a logo repeatedly consumes disk with no ceiling. The replaced asset's bytes are released FIRST, so re-uploading the same logo costs nothing — and a refused upload puts the released reservation back, so a rejection cannot leave the pond with *more* room than before. Asserted with real usage numbers. - **Purge removes the pond's branding files** — asserted against the real purge path, not the new code alone. The purge standard is absolute: after it, nothing referencing the pond survives, rows or files. ## Unchanged security Not relaxed because the uploader is now an ordinary Pond Admin rather than the operator: SVG refused (own error message), magic bytes and IHDR checked server-side, size caps, content type pinned on serving, no image parsing in the api. ## Favicon swap Driven by the RESOLVED pond, never the raw route parameter — an unreadable or unknown slug must not leave a stale icon in the tab. That the swap happens after first paint is accepted and stated in the code and the UI text; avoiding it would mean server-rendering index.html, which is #179's territory. ## Verification - api suite **105 files / 592 tests** green. - 5 pond-branding e2e tests: the pond scope serves the pond's bytes while the instance level still 404s; the quota is charged and released exactly; SVG refused at pond level; a reader may read but not change; purge deletes the files. - 9 shared unit tests on the resolution order and the no-decoding image checks. - Same audit id as #306 (`branding.changed`) with `scope: 'pond'` — the catalogue already documents the field, so no version bump. Closes #307
opus-5 added 1 commit 2026-08-01 19:56:01 +02:00
#307: pond-level branding overrides the instance logo and favicon
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 7m32s
CI / Build container images (pull_request) Successful in 4m20s
CI / Auth e2e pack (pull_request) Successful in 9m40s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
4b9018c033
Built on #306's storage, serving and crop control — a layer, not a parallel
implementation. `resolveBranding` in shared is the ONE place that answers
"which asset applies here?", and both the sidebar logo and the favicon swap
read it.

The decision most likely to be "fixed" by accident, so it is pinned by name
in `branding.test.ts`: **a logo set belongs to one level and variants are
never mixed across levels.** A pond that uploaded only a light logo shows THAT
logo in dark mode; it does not borrow the instance's dark variant. Decided
2026-08-01 — a logo silently swapping to a different image when the viewer
switches theme is a change nobody ordered, and a design that looks wrong is
more honest than one that is quietly substituted. Only a pond with no logo at
all inherits the instance's set, again as a set. The settings screen warns
about a missing dark variant; it never blocks.

Consequences that fall out of that rule and are easy to get wrong:

- The serving route does NOT fall back when given a pond scope. The caller
  already decided which level applies; a "helpful" fallback in the route
  would mix variants across levels behind the resolver's back.
- The logo link's accessible name follows the LEVEL: a pond logo is named by
  the pond, an instance logo by the instance. It is the link home, and a
  link's name has to say where it goes.

- **Charged to the pond's storage quota**, before the write, like attachments.
  Without it branding would be a way around the quota, and replacing a logo
  repeatedly would consume disk with no ceiling. The replaced asset's bytes
  are released FIRST, so re-uploading the same logo costs nothing — and a
  refused upload puts the released reservation back, so a rejection cannot
  leave the pond with more room than it had.
- **Purge removes the branding files.** The purge standard is absolute: after
  it nothing referencing the pond survives, rows or files. Asserted against
  the real purge path, not the new code alone.
- Security unchanged from #306 and not relaxed because the uploader is now an
  ordinary Pond Admin: SVG refused, magic bytes and IHDR checked server-side,
  size caps, content type pinned, no image parsing.
- The favicon swap is driven by the RESOLVED pond, never the raw route
  parameter — an unreadable or unknown slug must not leave a stale icon in
  the tab. That it happens after first paint is accepted and stated in the
  code and the UI: avoiding it would mean server-rendering index.html, which
  is #179's territory.

Same audit id as #306 (`branding.changed`) with `scope: 'pond'` — the catalogue
already carries the field, so no version bump.

Verified: api suite 105 files / 592 tests green; 5 pond-branding e2e tests
(pond scope serves the pond's bytes while the instance level still 404s, the
quota is charged and released exactly, SVG refused at pond level, a reader may
read but not change, purge deletes the files); 9 shared unit tests on the
resolution order including both mixing directions.
fable-5 force-pushed issue-307-pond-branding from 4b9018c033 to 90748b743e 2026-08-01 20:25:29 +02:00 Compare
fable-5 force-pushed issue-307-pond-branding from 90748b743e to a327126fac 2026-08-01 20:44:04 +02:00 Compare
opus-5 merged commit a327126fac into main 2026-08-01 21:02:05 +02:00
Sign in to join this conversation.
No description provided.