Skip to content

ADR-080 — Responsive image size ladder + srcset delivery (mobile · web · Google TV)

Status · Accepted Date · 2026-07-08 Gates · #383 (tracking) · #379 (image weight) · builds on ADR-079 (assetUrl spine + source/derived + WebP) · mobile epic #149 · Google TV target Design · RFC-030

Gating sentence: display images ship WebP-only as a size ladder — an unsuffixed base NN.webp (the canonical, capped ≤ 3072 px q80) plus smaller width-suffixed NN-<w>.webp rungs — that DOM <img> selects via srcset/sizes (viewport × DPR — phone, desktop, and 4K TV each fetch what they render) and 3D textures select by quality-tier; the full-resolution original is kept permanently in a GitHub-LFS masters/ store (so any rung/format regenerates losslessly). hotspots/ (zoom tiers) and posters/ (downloadable art) stay full-res JPEG; the Google-TV target is a Capacitor Android build that streams like mobile. (The auto-derive validate-data invariant is Slice 4, pending.)

Context

Orrery is becoming a three-screen product — Capacitor mobile, VPS web, and a Google TV app targeting 40–50″ (4K, 3840×2160) — a ~3× spread in display size. Meanwhile the served image bulk is ~1.9 GB, and the full-size tail is broken (gallery photos at 5000–6720 px / 7.5 MB shown in a fit-to-viewport, no-zoom lightbox).

Two measurements shape the decision (RFC-030 §Survey):

  1. Weight is quality, not pixels — capping at native 4K (3840 px) + q85 saves −312 MB with only 40 files resized; the fat is bloated JPEG quality, so TV resolution is not in tension with size.
  2. A single cap can't serve three screens — cap high for TV and mobile over-fetches on cellular; cap low for mobile and TV goes soft.

Images also churn continuously (better sources found, bad ones deleted, missions/fleet growing), so any fix must be self-maintaining in the pipeline, not a one-off.

Decision

  1. Pre-generated WebP ladder (1280/2048/3072) + srcset, not an on-the-fly image server. The app is static-origin (VPS nginx + Capacitor streaming from a static CDN); srcset is browser-native, viewport-driven, and composes with the assetUrl spine + SW caching. The browser asks for the right rung per client — mobile (and the Capacitor Android-TV build) stream the size their viewport needs automatically. WebP encoding (ADR-079 D3) is folded into the ladder derivation, not a later slice. (imgproxy / Cloudflare Images reconsidered only if the ladder becomes unwieldy.)

  2. Served rungs are WebP capped at ≤ 3072 px q80 (the largest is the unsuffixed base NN.webp); the full-res original is kept permanently and never mutated in a non-served masters/ store (ADR-079 D2). The cap is on derivatives only, so every change — a sharper rung, WebP, AVIF, a re-crop — regenerates losslessly from masters. This RFC/ADR de-facto executes ADR-079's deferred Slice C, and git-LFS is the hard precondition: masters (~1–2 GB) live in LFS, out of main git, and nothing destructive lands until it exists.

  3. Client selects by viewport, never by build-flag. srcset/sizes on DOM <img>; the browser chooses by viewport × DPR. A build flag (__MOBILE__) is too coarse — it can't tell a phone-on-web from a 4K monitor, and TV-as-Capacitor-Android would collide with mobile's flag.

  4. 3D / programmatic textures select by quality-tier (the existing 4k/2k gate) — srcset can't reach TextureLoader.

  5. Exclude hotspots/ and posters/ — the zoomable 3D surface tiers keep full resolution + their tier system; downloadable art posters stay full-res JPEG (universally openable + print-friendly; the /posters page links them for download). Both are excluded from the ladder, the cap, and the provenance re-key.

  6. Auto-derive on ingest + a validate-data invariant (the churn guarantee) — the design intent; Slice 4, not yet shipped. The ladder is derived by the pipeline (build-display-ladder.mjs, cache-keyed) from masters; the target end-state adds a validate-data invariant that fails preflight if any shipped image exceeds the cap or is missing a fresh ladder rung (fixable with an images:derive script), so a swapped/manually-dropped image can't ship un-derived. Until Slice 4 lands, re-derivation is a manual node scripts/vision/build-display-ladder.mjs step.

Alternatives considered

  • On-the-fly image server (imgproxy / thumbor / Cloudflare Images, ?w=) — rejected for v1: adds a service + CPU + cache layer and doesn't fit Capacitor's stream-from-static-origin model. Not foreclosed.
  • Per-platform build flag (mobile→small, web/TV→large) — rejected: too coarse; ignores the phone-on-web and 4K-monitor gradations that viewport handles for free.
  • Single global cap (one size for all) — rejected: any single value compromises at least one of the three screens (RFC-030 §Survey measurement 2).

Consequences

  • + Each client fetches an appropriately-sized image; mobile cellular payload drops sharply; TV renders sharp.
  • + build/images ~1.9 GB → ~897 MB (shipped, WebP-only, posters excluded); deploy ~2.2 GB → ~1.15 GB.
  • + Lossless / reversible — originals live in masters/.
  • The ladder adds ~3 derived files per master (~9 k files). VPS-only (GH Pages retired) makes file-count far less critical; ladder kept short to bound it.
  • A sizes-per-component contract to maintain (RFC-030 §contract), guarded by a lint/test.
  • Dependency: gated on the GitHub-LFS masters/ store (ADR-079 D2 / Slice C) built first — the permanent-masters invariant (decision 2) can't hold otherwise. Cost: masters ~1 GB sit inside GitHub LFS's 10 GiB free tier → ~$0/mo, conditional on CI/agents skipping the master smudge (lfs.fetchexclude) so bandwidth stays within free (RFC-030 §cost).

Rollback

Every step is reversible from masters/: the cap/ladder are regenerable derivatives; reverting srcset is a component-markup change; the validate-data invariant is additive. No irreversible step once masters exist.


Orrery · ADR-080 · Responsive image ladder + srcset delivery · July 2026 — design in RFC-030, builds on ADR-079.

Orrery — architecture documentation · MIT · No tracking