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.webprung is the mobile rung. Part 1 = generate the missing-1280.webp(+.1x1.jpgthumb 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–3072pxtier2*/tier3*panoramas + LROC): these are deep-zoom detail, notsrcsetimages, so they don't get a-1280rung. Instead cap the mobile variant at ≤1536px (a-m.jpgsibling) and have the surface-scene loader pick it underCapacitor.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
srcseton every browse surface resolves to ≤1280px on a mobile viewport (unit test extendsimage-srcset.test.ts; e2e asserts no >1280px image request onmobile-chromium).- Surface scenes request the
-mtile on native. - A
scripts/mobile/measure-mobile-corpus.mjsemits 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
rmdirof 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.Datafiles (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 →
fetcheach →Filesystem.writeFileintooffline/<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):
measure-mobile-corpus.mjs— enumerate base images lacking a ≤1280 rung + surface tiles >1536px; report sizes.- Generate the missing
-1280.webp+.1x1.jpg(guarded, diff-gated). - Surface-tile
-mvariant + native loader pick. - Verify: srcset unit +
mobile-chromiume2e (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.