Skip to content

RFC-040 — Mobile offline: the mobile image rung + tiered offline caching

Status: Shipped (v0.8.0, 2026-08-27) · Implements: PRD-035 · builds on: the image srcset ladder (src/lib/image-srcset.ts), the mobile prune pipeline (scripts/mobile/prune-streamed-assets.mjs), the streaming SW (vite.config.ts runtimeCaching) · mobile rung + tiered offline caching live (CHANGELOG [0.8.0]). Leftover: #482 — 62 .jpg still to convert to a webp base.

Why this is an RFC. Two design decisions bind multiple subsystems and must be frozen once. Part 1 changes the image pipeline contract (which rungs exist, how wide the mobile rung is, how surface tiles are capped) — get the width wrong and we either still over-serve or regress visual quality, and a naive regen risks the LFS-stub / full-corpus-regen footguns (see the image-pipeline guards). Part 2 introduces new persistence (a large durable on-device store — deliberately not an evictable browser cache), a per-tier manifest contract that must stay in lockstep with the prune manifest, and a download engine with progress/out-of-space/network handling. Freeze the width target, the manifest shape, and the persistence layer before slicing.

1 · The problem (measured — see PRD-035 §1)

Manifest-measured (measure-mobile-corpus.mjs): 3146 images already mobile-OK; the genuine over-serve was 141 images / 33 MB (deep-sky, launch-ground, essays, missions/gemini) with no ≤1280 rung. hotspots (185 MB) are viewport-adaptive via the surface-scene tier dispatcher — not over-serve. (An earlier "859/3089" framing was a file-count artifact — see PRD-035 §1's scope correction.) No offline mode; the streamed set is unavailable without connectivity. The 1.1 GB image corpus is the whole ladder at every rung; a phone shows one rung (mobile-res corpus ~325 MB).

2 · Part 1 — the mobile image rung

Contract A — the frozen width target

A phone's widest full-bleed image is ~430pt logical × 3 DPR ≈ 1290px physical. So:

  • Standard browse imagery (galleries, missions, anatomy, etc.): the existing -1280.webp rung is the mobile rung. Part 1 = generate the missing -1280.webp (+ .1x1.jpg thumb where absent) for every base image whose base exceeds ~1280px and that lacks the rung. Buckets already ≤1280px base (e.g. many galleries at 960px, missions at 1025px) need nothing — they're already mobile-sized.
  • Surface hotspot tiles (static/images/hotspots/*.jpg, the 2048–3072px tier2*/tier3* panoramas + LROC): these are deep-zoom detail, not srcset images, so they don't get a -1280 rung. Instead cap the mobile variant at ≤1536px (a -m.jpg sibling) and have the surface-scene loader pick it under Capacitor.isNativePlatform(). The full-res tile stays for desktop + the Full offline tier.

Regeneration — served-base downscale, guarded (DONE, #482)

The over-serve buckets have no masters (essays/deep-sky/launch-ground) or only LFS stubs (missions/gemini — pulling them risks the full-corpus-regen / stub-degrade the guards prevent). So add-mobile-rung.mjs downscales the served base to the ≤1280 rung — one lanczos step from an already-web-encoded 1360–1920px base is visually lossless here. Enumerated exactly the 141 missing a rung, generated only those, diff-gated (141 new -1280.webp + manifest, zero base images touched). Idempotent (skips an existing rung); re-run when new large images land.

Verification

  • srcset on every browse surface resolves to ≤1280px on a mobile viewport (unit test extends image-srcset.test.ts; e2e asserts no >1280px image request on mobile-chromium).
  • Surface scenes request the -m tile on native.
  • A scripts/mobile/measure-mobile-corpus.mjs emits the mobile-res total per bucket → feeds Part 2's manifest + size budget.

3 · Part 2 — tiered offline caching

Contract B — persistence: the Capacitor Filesystem (durable app data)

The offline download writes the tier's files into the app's durable data container via @capacitor/filesystem (Directory.Data on both platforms — a NEW plugin dependency, needs approval). This is deliberately NOT a browser cache.

Why NOT the Service Worker Cache API (the obvious-looking choice — reuse the streaming SW): Cache Storage is OS-evictable. iOS/Android treat WebView Cache Storage / IndexedDB as reclaimable — under storage pressure the OS can silently purge it, and a user's offline download vanishes mid-flight (the exact failure the feature exists to prevent). navigator.storage.persist() requests non-eviction but its reliability inside iOS WKWebView is historically unreliable. For a "download it for the plane" feature, durability is the whole point, so evictable storage is disqualifying.

Why Filesystem Directory.Data gives the operator's desired model (verified as the intent, 2026-08):

  • Durable — app-owned data, not OS-evictable; it survives storage pressure and relaunches.
  • Visible + user-cleared as real storage — on iOS it counts under Settings → General → iPhone Storage → Orrery → "Documents & Data" (the size the app takes); on Android it's User data in the app's storage breakdown. Cleared deliberately via our in-app "Remove offline data" (a Filesystem rmdir of the offline tree) or delete-app — NOT by an accidental Android "Clear cache" tap or an OS purge. This is exactly the behaviour we want: the user sees how much space the download takes and removes it on purpose.

Cost of Filesystem over Cache API (the tradeoff we accept): the <img src> / audio URLs no longer resolve transparently, so we add a thin resolver — on native, rewrite a streamed URL to its local Filesystem.getUri(...) (a capacitor:// / file:// path) when the file is present in the offline tree, else fall through to the network. One small $lib/native/offline-assets.ts shim consulted by the image-src + audio-src helpers; a miss is a normal online fetch, so it's safe when nothing is downloaded.

Guardrails: handle write failures / out-of-space gracefully (surface it, leave the partial tree usable — never half-write silently); a tier's files live under a single offline/<tier>/… root so removal + accounting are one operation.

Pre-build verification (do before Part 2 code): confirm on a real device that Directory.Data files (a) show under iOS "Documents & Data" and Android "User data", (b) are NOT wiped by Android "Clear cache", and (c) survive a storage-pressure / relaunch cycle. This RFC asserts platform behaviour from knowledge, not an on-device test — verify, then build.

Contract C — the per-tier manifest

A build step emits static/offline-manifest.json:

{ "version": "0.8.0",
  "basic": { "bytes": <n>, "urls": [ "<mobile-rung url>", ... ] },
  "full":  { "bytes": <n>, "urls": [ ... ] } }

Generated from the SAME bucket taxonomy the prune script uses (single source of truth — Part 1's measure-mobile-corpus.mjs feeds it), so tiers can't drift from what's actually streamed. basic = core browse buckets at mobile rung; full = every streamed URL at mobile rung + audio + 4K. URLs are absolute against STREAM_ORIGIN.

The download engine

  • Fetch the manifest tier → iterate URLs → fetch each → Filesystem.writeFile into offline/<tier>/<path> with a bounded concurrency (e.g. 6), reporting {done, total, bytes} progress.
  • Network guard: @capacitor/network — Wi-Fi-only by default; on cellular, confirm with the size shown. Pause/resume on connectivity loss; the loop is idempotent (skip files already written) so a re-run resumes.
  • Idempotent + updatable: "Update" re-runs against the current manifest version, adding new/changed files.
  • Errors: per-URL failures collected and retried once; an out-of-space error stops with a clear message + the partial tree stays usable.

UI — Settings panel section (mobile only)

New "Offline" section in QualitySettingsModal.svelte, gated Capacitor.isNativePlatform():

  • Radio: Off (default) · Basic (~N MB) · Full (~N MB) — sizes from the manifest.
  • State machine: idle → downloading (progress bar + X / Y MB) → done (✓ + "Update" + "Remove offline data").
  • A one-line "Wi-Fi recommended" note + cellular override.

4 · Slice plan

Part 1 (issue #482):

  1. measure-mobile-corpus.mjs — enumerate base images lacking a ≤1280 rung + surface tiles >1536px; report sizes.
  2. Generate the missing -1280.webp + .1x1.jpg (guarded, diff-gated).
  3. Surface-tile -m variant + native loader pick.
  4. Verify: srcset unit + mobile-chromium e2e (no >1280 request); commit.

Part 2 (issue filed after Part 1): 5. On-device pre-build check: Directory.Data durability + storage-accounting behaviour (Contract B verification note). 6. offline-manifest.json build step (basic/full from the bucket taxonomy). 7. @capacitor/filesystem download engine (write to offline/<tier>/) + @capacitor/network guard + progress store + the offline-assets.ts URL resolver. 8. Settings "Offline" section + state machine + "Remove offline data" (rmdir the tier tree). 9. Verify: airplane-mode e2e per tier (mock manifest); on-device confirmation the download shows under iOS "Documents & Data" / Android "User data".

5 · Non-goals

Web-PWA offline (mobile app only); per-section/partial downloads; background/auto download; changing desktop imagery or the default streaming behaviour.

Orrery — architecture documentation · MIT · No tracking