RFC-018 · Capacitor Mobile Wrapper — iOS + Android
Status: Draft v0.5 · 2026-05-15 (decisions baked 2026-05-16; amended 2026-07-06 per ADR-078; §2/§5/§8/§11.2/§11.4 corrected 2026-07-07 against shipped code) · Closes: PRD-015
v0.5 (2026-07-07). Post-first-on-device-run corrections, inline per section: Capacitor 8 (§2), the real
capacitor.config.tsdeltas (§5), app-levelassetUrl()streaming vs SW-rewrite (§8), reload-based context-loss recovery (§11.2), and the safe-area native shim — §11.4's original "nothing to change" was wrong and is corrected. Code is the source of truth; these notes stop the RFC from misleading.
v0.4 amendment (ADR-078, 2026-07-06). A re-assessment against current
maininvalidated two 2026-05-16 locks. (1) Ship order flips to iOS-first (§9–§11 re-sequenced). (2) The bundle strategy flips to stream-heavy (§8.1 selected over §8.2; §4 rewritten with measured numbers): the naive build is ~2 GB, not the 355 MB modelled here in May —static/images/alone is 1.6 GB andstatic/audio/(97 MB) was never modelled. Two of §4's three original slim tactics are moot or already shipped. ADR-078 is the governing record; original May text is retained with inline~~strikethrough~~/ amendment notes for the audit trail.
Why this is an RFC. The choice of Capacitor 7 (vs Tauri Mobile, React Native, or PWA-only) and the bundle-slimming + PWA-autoUpdate strategy that make a 355 MB naive build shippable are architectural decisions that bind every future asset, route, and runtime-state choice. They need to be debated and recorded before any
ios/orandroid/directory lands in the repo.
1 · Motivation
Orrery (v0.6.0, May 2026) is a SvelteKit static site deployed to GitHub Pages. The build output (build/) is HTML, JS, CSS, JSON manifests, fonts, planet textures, mission galleries, agency logos, and 12 Paraglide message bundles. Capacitor takes that directory and wraps it in a Chromium WebView (Android) or WKWebView (iOS) to produce native app binaries for Google Play and the App Store.
The web app does not change. The build pipeline gains a sync step. Three things, however, are no longer free:
- Bundle size. The
build/output is ~355 MB today. Google Play has no hard cap but flags downloads above ~150 MB; iOS rejects OTA installs above 200 MB and bundles above 4 GB. A naivecap syncships an app that fails Google Play's expedited review and exceeds iOS's OTA install threshold. - WebView feature parity. Three.js r128, WebGL2, History API routing (ADR-013), Web Workers (Lambert solver), service workers,
IntersectionObserver,prefers-reduced-motion, viewport units — all already shipped, all need verification on Android Chromium WebView and iOS WKWebView 16+. - Update story. The PWA service worker we shipped in
v0.5.x(ADR-029,registerType: 'autoUpdate') silently swaps in new bundles on every navigation in browser. Inside a Capacitor wrapper that same SW is still installed against the local file:// origin and continues to update content over the air — which doubles as the answer to "how do I push a content fix without a Play/App Store submission?" but raises a tension with App Store review rules about remote code loading. Section 8 details the policy line.
This RFC defines what changes (and what does not) to ship Orrery on Google Play first and the App Store second, without breaking the existing GitHub-Pages browser deploy.
2 · Capacitor 8 (was 7 at writing)
Shipped: Capacitor 8.4.1 (ADR-078 addendum, 2026-07-07). "Capacitor 7" below was version-at-writing (May 2026); the scaffold was upgraded 7→8 via
cap migratewhile still fresh. Cap 8 raised the iOS deployment target to 15 and Android requires JDK 21. The May text is retained for the audit trail.
Capacitor 7.x — current stable as of May 2026. v7 raised the iOS minimum to 16+ (matches PRD M3) and the Android minSdkVersion to 23 / targetSdkVersion 34 (Play Store policy minimum). The plugin API is stable across 6 → 7 with one breaking change: the Capacitor.getPlatform() value is now strictly 'android' | 'ios' | 'web' (no more 'electron'); irrelevant to us.
npm install -D @capacitor/cli
npm install @capacitor/core @capacitor/android @capacitor/ios
npx cap init "Orrery" "io.github.chipi.orrery" --web-dir=buildappId uses the reverse-DNS form of the GitHub Pages host (chipi.github.io) so we don't need to register a new domain just to publish.
--web-dir=build is the only coupling between SvelteKit and Capacitor — the static adapter already writes there.
3 · Repository changes
orrery/
├── capacitor.config.ts ← new, committed
├── android/ ← new, committed (Android Studio project)
│ └── app/
│ ├── src/main/AndroidManifest.xml
│ └── build.gradle
├── ios/ ← new, committed (Xcode project)
│ └── App/App/Info.plist
├── build/ ← existing, gitignored (SvelteKit output)
└── src/ ← existing (no structural change)The android/ and ios/ directories are committed (Capacitor convention). They grow as plugins are added. CI will not touch them — Xcode and Android SDK are not in the GitHub Actions runner; mobile builds are local-only for this phase (§9).
4 · Bundle strategy — the 355 MB 2 GB problem
REWRITTEN v0.4 (ADR-078, 2026-07-06). The original §4 below the ruler is retained for the audit trail but superseded. Its 355 MB baseline, its texture tactic, and its ~85 MB target no longer hold.
Measured baseline (2026-07-06). static/ copies wholesale into build/ via adapter-static (svelte.config.js), so static/ size ≈ on-device asset weight:
| Bucket | May estimate | Measured 2026-07 | Notes |
|---|---|---|---|
static/images/ | ~120 MB | 1.6 GB | Image pipeline v2 (post-#5) blew this up 13× |
static/audio/ | not modelled | 97 MB | /fly narration + Curator Tour — new since May |
static/data/ | ~150 MB @ 12 loc | 67 MB @ 14 loc | Actually smaller + one more slice of locales |
static/textures/ | ~30 MB "8K" | 64 MB | Max 4K; 2k-default/4k-LOD already shipped |
static/ total | ~355 MB | ~1.9 GB | A naive cap sync ships a ~2 GB app — 10× the iOS 200 MB OTA cap |
Strategy (ADR-078 D2) — stream-heavy. Partition assets at build time:
- Bundled on-device (~150–250 MB target): app shell + Three.js + Svelte chunks; planet textures (2k default, 4k LOD); core mission/site/fleet JSON; the default locale bundle.
- Streamed from
chipi.github.io, SW-cached on first view: allstatic/images/galleries + non-critical heroes (the 1.6 GB) and allstatic/audio/(the 97 MB). Governed by the §8.1 network-aware SWruntimeCachingrule. §2.5.2-clean — streamed content is inert (image/audio/JSON), never executable.
Retained from the original plan:
- 4.2 Lazy-loaded locale bundles — still valid; 14 locales now, ship the default in the initial chunk, others fetch + cache on locale switch.
Dropped from the original plan (ADR-078):
4.1 Texture downsample (8K→4K)— moot. No 8K textures exist on disk; max is 4K and a 2k-default/4k-on-approach LOD already shipped (src/lib/three/fly-cislunar-scene.tsupdateTextureLod()).4.3 Bundle 256-px thumbnails— replaced by full streaming. With the image bucket at 1.6 GB, streaming all gallery imagery (not just heroes) is the only path under the OTA cap; a bundled thumbnail tier is optional polish, not the mechanism.
Net result (v0.4): ~2 GB → ~150–250 MB on-device. Clears the iOS 200 MB OTA cap (the binding gate under iOS-first) and Google Play's ~150 MB soft flag.
Original v0.3 §4 (superseded — audit trail only):
Naive npm run build produces ≈ 355 MB across roughly:
| Bucket | Size | Notes |
|---|---|---|
static/images/fleet-galleries/ | ~120 MB | 30 missions × ~4 MB hero images |
static/data/locale/* + paraglide bundles | ~150 MB | 12 locales × ~12 MB of mission/site/fleet JSON + overlays |
static/textures/planets/ | ~30 MB | 8K Earth + Mars + Moon + Jupiter |
static/data/science-overlays/ + diagrams | ~10 MB | SVG + JSON science 101 chapters |
| Fonts (Space Grotesk, Space Mono, JetBrains Mono) | ~5 MB | 3 families × 2 weights × WOFF2 |
Agency logos (static/logos/) | ~3 MB | NASA, ESA, JAXA, ISRO, ROSCOSMOS, CNSA SVG/PNG |
| App shell + Three.js + Svelte chunk | ~37 MB raw / ~5 MB gzipped | Code |
To meet the PRD M11 budget (≤ 150 MB installed), three actions land in this RFC:
4.1 Texture downsample for mobile builds. Add a MOBILE=1 Vite env that swaps static/textures/planets/8k.jpg → 4k.jpg references. The 4K textures already exist in the repo (used by the ?lowres=1 URL flag from ADR-022 reduced-motion fallback). Saves ~22 MB.
4.2 Lazy-loaded locale bundles. Today all 12 Paraglide locales ship in every bundle. Move 11 non-default locales to async imports loaded on locale switch. Saves ~140 MB (the en-US bundle stays in the initial chunk; others fetch on demand). The PWA service worker caches them after first load. Detail in §8.
4.3 Fleet-gallery thumbnail tier. Bundle 256 px thumbnails (~150 KB each) for all 30 missions. Hero-quality images stay on GitHub Pages and stream on-demand via the existing PWA cache. Saves ~110 MB. Acceptable because the gallery is browse-then-tap; the hero swap on tap is invisible at WebView paint speed and the SW caches subsequent visits.
Net result: ~355 MB → ~85 MB installed bundle. Within Google Play expedited review (≤ 150 MB) and well under iOS OTA cap (200 MB).
What this does not require: a separate codebase, a build matrix, or per-platform repos. The MOBILE=1 env flips three SvelteKit process.env-style branches, all in vite.config.ts or app.html. The browser build at chipi.github.io is unchanged.
5 · capacitor.config.ts
Shipped config differs from the block below (discovered on the first on-device run, 2026-07-07). The live
capacitor.config.tsis the source of truth; key deltas:
ios.scrollEnabled: true(NOTfalse).falsedisabled scrolling on every content page, not just the 3D routes. The 3D canvases block native scroll themselves via CSStouch-action: none, so global scroll stays on. (The iOS scroll view also neededisScrollEnabled = trueforced natively inSafeAreaViewController— the config flag wasn't reliably applied.)plugins.SplashScreen.launchShowDuration: 1800(was 600) +iosSpinnerStyle: 'small'— hold the splash long enough to be seen (S10).- Safe-area is NOT handled by this config — see §11.4.
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'io.github.chipi.orrery',
appName: 'Orrery',
webDir: 'build',
server: {
// No live server in production — app serves from local file:// bundle.
// For local development against Vite dev server, set:
// url: 'http://192.168.1.x:5173',
// cleartext: true,
// and call `npx cap run android --live-reload` (LAN access required).
androidScheme: 'https', // keeps the WebView origin stable for SW + History API
},
android: {
backgroundColor: '#04040c', // matches --bg-base; no white flash
captureInput: true, // keyboard focus stays in WebView
webContentsDebuggingEnabled: false, // true only in debug builds
},
ios: {
contentInset: 'always',
backgroundColor: '#04040c',
scrollEnabled: false, // 3D scenes consume touch directly
limitsNavigationsToAppBoundDomains: true,
allowsLinkPreview: false,
},
plugins: {
SplashScreen: {
launchShowDuration: 600,
launchAutoHide: true,
backgroundColor: '#04040c',
androidSplashResourceName: 'splash',
showSpinner: false,
},
},
};
export default config;Decisions worth flagging:
androidScheme: 'https'— Capacitor 7 default. Critical for us because the PWA service worker requires a secure origin and the History API requires a non-file://scheme. iOS usescapacitor://for the same reasons.scrollEnabled: false(iOS) — Three.js pointer events handle orbit/pan/zoom. Native scroll would intercept single-finger drag and break every 3D scene. Android WebView does not need this flag (Chromium passes touch through by default).limitsNavigationsToAppBoundDomains: true(iOS) — App Store hard requirement. External educational links route through@capacitor/browser(§7) so the wrapper WebView never navigates off-origin.backgroundColor: '#04040c'— same on both platforms; matches--bg-base. Eliminates the white flash at WebView mount.
6 · Plugins
| Plugin | Why |
|---|---|
@capacitor/splash-screen | Dark splash matching --bg-base; auto-hides at 600 ms |
@capacitor/status-bar | Translucent dark on Android; matches the app shell |
@capacitor/browser | External educational links open in Chrome Custom Tab / SFSafariViewController, not the app WebView |
@capacitor/share | Native share sheet for mission-arc share-links |
@capacitor/app | App-state lifecycle (foreground / background), appUrlOpen for deep links, Android back-button override |
@capacitor/haptics | Mission-event tactile cues (S2 in PRD-015) |
Explicitly not used:
@capacitor/preferences/@capacitor/storage— runtime-only state per CLAUDE.md (no localStorage / sessionStorage); theorrery_localecookie is the lone persistent value and the WebView handles cookies natively.@capacitor/filesystem— all data bundled or PWA-cached.@capacitor/push-notifications, camera, geolocation — out of scope.
7 · Routing, links, deep-links
Routing. Orrery uses History API routing (ADR-013, SvelteKit default) — no hash routes. The androidScheme: 'https' / iOS capacitor:// config (§5) ensures the WebView treats the local bundle as a real origin, so pushState / popState work without modification.
External links. Every external <a> already goes through the global click delegation in src/routes/+layout.svelte (analytics external-link-click event). For Capacitor we add a small adapter that intercepts those clicks before navigation:
// src/lib/external-link.ts
import { Capacitor } from '@capacitor/core';
import { Browser } from '@capacitor/browser';
export async function openExternal(url: string) {
if (Capacitor.isNativePlatform()) {
await Browser.open({ url, presentationStyle: 'popover' });
return;
}
window.open(url, '_blank', 'noopener,noreferrer');
}The existing layout-level click handler routes through openExternal() when running under Capacitor. One file changes; every route inherits it.
Deep links. PRD-015 S6: orrery://mission?id=curiosity should open the mission directly.
<!-- android/app/src/main/AndroidManifest.xml -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="orrery" />
</intent-filter><!-- ios/App/App/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>orrery</string></array>
</dict>
</array>// src/lib/deep-links.ts
import { App } from '@capacitor/app';
import { goto } from '$app/navigation';
App.addListener('appUrlOpen', ({ url }) => {
const u = new URL(url); // orrery://mission?id=curiosity
void goto(`/${u.host}${u.search}`); // → /mission?id=curiosity
});Universal Links (https://chipi.github.io/orrery/...) are deferred to a follow-up RFC; they require an apple-app-site-association file and a Digital Asset Links record on the GH Pages domain.
8 · PWA service worker × Capacitor — content updates without store review
Shipped mechanism (ADR-079 D1): app-level
assetUrl()spine, not an SW origin-rewrite. This section frames streaming as the service worker rewriting local requests tochipi.github.io. The actual implementation is simpler and lives in the app:src/lib/asset-url.tsresolves streamed-bucket URLs (assetOrigin/streamedUrl/localeBundleOrigin) to the CDN origin under__MOBILE__, so the app requests the CDN URL directly. The SW's existing origin-agnosticruntimeCachingrules (match byrequest.destination/url.pathname) then cache those CDN responses — no custom SW origin-rewrite needed. The build-time prune (scripts/mobile/prune-streamed-assets.mjs) removes the streamed buckets frombuild/socap syncnever bundles them. See ADR-079 for the spine + the ~9 threaded chokepoints.
This is the most consequential decision in this RFC.
We shipped @vite-pwa/sveltekit in registerType: 'autoUpdate' mode (ADR-029, see vite.config.ts and src/routes/+layout.svelte lines 70–87). The SW caches the app shell + assets and silently rotates them on the next navigation when a new version is published.
Inside Capacitor, that SW is still installed against the wrapper origin (https://localhost on Android, capacitor://localhost on iOS). It will:
- Serve the bundled
build/content from cache on first run. - On subsequent runs with network, attempt to update its own caches against… nothing. The bundled
build/has noService-Worker-Allowedheader, no remote/sw.jsto refresh against. Updates from the web won't propagate.
Two strategies:
8.1 Network-aware SW (recommended). Configure the SW with a runtimeCaching rule that, when running under Capacitor and the device is online, fetches a manifest from https://chipi.github.io/orrery/build-info.json and selectively pulls content-only updates (mission JSON, locale bundles, gallery thumbs). The app shell and Three.js code stay frozen at the version the user installed from the store. Code updates remain a Play / App Store release.
This satisfies App Store §2.5.2 (no remote-loading executable code) because nothing under static/data/ or messages/ is executable — they are JSON / text. Three.js and Svelte chunks are not refreshed at runtime.
8.2 Disable SW under Capacitor. Skip useRegisterSW() when Capacitor.isNativePlatform(). Simpler; means content fixes require a store release. Reasonable for the first ship.
Recommendation: ship 8.2 (disabled SW) for v1.0 SUPERSEDED — ship §8.1 (network-aware SW), ADR-078 D2. The re-assessment made §8.1 the only path under the iOS 200 MB OTA cap: the 1.6 GB image bucket + 97 MB audio must stream from chipi.github.io and cache at runtime, which is exactly what §8.1 provides. §8.1 is §2.5.2-clean because runtimeCaching touches only inert content (images, audio, JSON) — never JavaScript. The frozen-at-install code path (Three.js, Svelte chunks) never refreshes at runtime.
Under Capacitor the SW therefore stays registered (not skipped), with a runtimeCaching rule that fetches + caches media on demand. The original v0.3 §8.2 skip-registration snippet below is retained for the audit trail but is not the shipped path:
// v0.3 §8.2 (SUPERSEDED by ADR-078) — DO NOT ship this skip:
// import { Capacitor } from '@capacitor/core';
// if (Capacitor.isNativePlatform()) return; // ← removed; SW stays on under §8.1The §8.1 runtimeCaching config (media-only CacheFirst against chipi.github.io) and the build-info.json content-manifest shape are specced under S2/S3 of the epic when implementation starts (RFC-018 §15 O.Q. 5 reopened as in-scope).
9 · Build pipeline
Ordering amended (ADR-078): iOS first. The commands are unchanged; the ship sequence is iOS → Android.
# Web build (mobile profile)
MOBILE=1 npm run build # → build/, with §4 stream-heavy asset partition
# Sync into native projects
npx cap sync # → android/app/src/main/assets/public/
# → ios/App/App/public/
# iOS — App Store (FIRST to ship, per ADR-078)
npx cap open ios # opens Xcode
# Product → Archive → Distribute → App Store Connect
# First track: TestFlight (review-lite)
# Bundle MUST clear the 200 MB OTA cap before this step
# Android — Google Play (after iOS is live)
npx cap open android # opens Android Studio
# Build → Generate Signed App Bundle → upload .aab to Play Console
# First track: Internal Testing (no review)npm scripts:
{
"scripts": {
"build:mobile": "MOBILE=1 npm run build",
"build:android": "npm run build:mobile && npx cap sync android",
"build:ios": "npm run build:mobile && npx cap sync ios",
"open:android": "npx cap open android",
"open:ios": "npx cap open ios",
"dev:android": "npx cap run android --live-reload --external"
}
}CI. GitHub Actions does not build the native binaries — Xcode and Android SDK are not in the standard runners and we don't pay for cloud Mac runners. Mobile builds remain local. The validate-data + preflight chain runs on every push as today and catches any data drift before the operator runs cap sync. If CI mobile builds become needed later, Codemagic and Bitrise both have first-class Capacitor support.
10 · Android — platform specifics (now second to ship, per ADR-078)
10.1 SDK targets
minSdkVersion: 23 (Android 6.0). 99%+ of active devices.targetSdkVersion: 34 (Android 14). Required by Google Play as of August 2024.compileSdkVersion: 34.
10.2 WebView
Android System WebView is shipped as an updatable Play Store component (or via Chrome on older devices). Chromium-based, current within months of upstream. WebGL2, Web Workers, History API, IntersectionObserver, prefers-reduced-motion, service workers — all supported on every device meeting minSdkVersion.
Hardware acceleration is on by default in Capacitor's generated AndroidManifest.xml. Verify it stays on — software rendering for Three.js drops to <5 fps.
10.3 Back gesture
Android's back gesture should pop the WebView's history stack, not exit the app immediately. With History API routing this is the system default; we add an App.addListener('backButton', …) only to call App.exitApp() when there is no history left.
import { App } from '@capacitor/app';
App.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) window.history.back();
else App.exitApp();
});10.4 Network security
Default policy (deny cleartext) is correct. Orrery makes only HTTPS requests, all to chipi.github.io (under §8.1) or no requests at all (under §8.2). No network_security_config.xml exceptions.
10.5 build.gradle
android {
compileSdkVersion 34
defaultConfig {
applicationId "io.github.chipi.orrery"
minSdkVersion 23
targetSdkVersion 34
versionCode 1 // increment on every Play submission
versionName "0.6.0" // matches package.json
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}11 · iOS — platform specifics (now FIRST to ship, per ADR-078)
11.1 SDK targets
iOS Deployment Target: 16.0. Covers ≈ 96 % of active iPhones as of May 2026.
11.2 WKWebView and WebGL — context loss
WKWebView on iOS 16+ supports WebGL2 + Three.js r128 reliably. The known issue is WebGL context loss when the app backgrounds — more aggressive on iOS than Android. Three.js does not auto-restore.
Shipped (S8, 2026-07-07): reload-on-loss, not per-scene reinit.
src/lib/native/webgl-recovery.ts(wired in+layout) detects a lost context (webglcontextlost/restored+@capacitor/appappStateChange) and reloads the route so the scene rebuilds cleanly — reliable, no permanently-blank scene, at the cost of a reload flash + lost camera state. The per-scenereinit()factor-out below is deferred (tracked in #195) because it can't be verified without triggering context loss on real hardware. Do it once TestFlight is on a physical device.
Each 3D scene gets a context-loss listener. Concretely there are 7 scenes today: /explore, /fly (heliocentric), /fly (cislunar per ADR-058), /earth, /moon, /mars, /iss, /tiangong. Each has a single renderer setup module; a small mixin handles restore:
// src/lib/webgl-restore.ts
import { App } from '@capacitor/app';
export function attachContextRestore(canvas: HTMLCanvasElement, reinit: () => void) {
canvas.addEventListener('webglcontextlost', (e) => {
e.preventDefault();
});
canvas.addEventListener('webglcontextrestored', reinit);
App.addListener('appStateChange', ({ isActive }) => {
if (!isActive) return;
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
if (gl?.isContextLost()) reinit();
});
}Each scene already has a renderer-init function; we factor it so the same function can be called as reinit. PRD-015 M5 owns the per-scene work.
11.3 Pixel ratio
Existing Math.min(devicePixelRatio, 2) cap stays. iPhone Pro models are 3× — uncapped at 60 fps drops frames. Already correct in the renderer setup; just don't let anyone "fix" it on iOS.
11.4 Safe areas — CORRECTED (2026-07-07)
This section's original claim ("already wired via
env(), nothing to change") was WRONG and cost a full debugging session. Capacitor's iOS WKWebView returnsenv(safe-area-inset-*)= 0 (measured on-device) — so the nav/footer render under the status bar / Dynamic Island / home indicator.env()does not work here.What actually ships: a native shim —
SafeAreaViewController(subclass ofCAPBridgeViewControllerinios/App/App/AppDelegate.swift, wired viaMain.storyboard) reads the nativeview.safeAreaInsetsand injects them as CSS custom properties (--safe-area-inset-top/right/bottom/left) viaevaluateJavaScript. App CSS readsvar(--safe-area-inset-top, env(safe-area-inset-top))— the injected value under Capacitor,env()as the browser fallback.--nav-height(tokens.css) folds the top inset in so the sticky nav + everycalc(var(--nav-height) + Npx)canvas control move together. The@capacitor-community/safe-areaplugin was added (Android edge-to-edge) but does not fix iOSenv().One gotcha: a
@media (max-width: 500px) { nav { padding: 0 6px } }shorthand was zeroing the nav'spadding-topon mobile — it must preserve the inset. Diagnose safe-area issues by injecting the computed values on-screen, not by guessing atenv().
Original (superseded): Already wired via env(safe-area-inset-*) in src/lib/styles/app.css and the footer. Nothing to change.
11.5 Info.plist
No NSAppTransportSecurity exceptions (all traffic HTTPS, all to chipi.github.io or absent). No NSCameraUsageDescription / location strings. The only key worth adding is for the share plugin if photo-library save is ever enabled (currently not in PRD-015 scope).
11.6 App Store §2.5.2 — remote code
Critical: under §8.2 (no SW under Capacitor) we are clean. Under §8.1 (network-aware SW) we are clean only if runtime cache rules touch JSON/text only and never JavaScript. The SW config must be reviewed against the App Store policy before any iOS submission with §8.1 enabled.
12 · Web Worker (Lambert solver) compatibility
The Lambert solver in /fly runs in a Web Worker. Both Chromium WebView and WKWebView 16+ support Web Workers without restriction. Vite emits the worker as assets/lambert.worker-*.js; verify after cap sync:
ls android/app/src/main/assets/public/_app/immutable/workers/
ls ios/App/App/public/_app/immutable/workers/If the worker file is missing post-sync, vite.config.ts needs worker: { format: 'es' } (default in current Vite, but worth checking after a major Vite bump).
13 · App icons + splash screens
npm install -D @capacitor/assets
npx capacitor-assets generate \
--iconBackgroundColor '#04040c' \
--iconBackgroundColorDark '#04040c' \
--splashBackgroundColor '#04040c' \
--splashBackgroundColorDark '#04040c'Source: a single assets/icon.png (1024 × 1024) and assets/splash.png (2732 × 2732) — generates all required Android mipmap-* densities and iOS AppIcon.appiconset sizes. Icon design is a separate task (PRD-015 open Q4).
14 · Alternatives considered
Tauri Mobile 2.x. Rust-backed, smaller binary. We have no native processing to gain from a Rust backend; smaller binary doesn't help when our payload is data assets, not code. Capacitor is more battle-tested for WebView + WebGL workloads. Rejected.
React Native. Would mean rewriting Three.js orchestration and all UI in React + JSI bridges. The web app is the product. Rejected.
PWA only (Add to Home Screen). Works on Android (Chromium engine, full SW + manifest), works limitedly on iOS Safari (no push, no background sync, smaller storage cap). Reaches motivated users; misses store-driven discovery. Capacitor does not preclude PWA — the chipi.github.io PWA stays online. Rejected as the only distribution.
NativeScript. XML/Vue/Angular template rewrite. Web app is the product. Rejected.
15 · Resolved decisions + open questions
Resolved 2026-05-16 (handed back from PRD-015); items 1, 2, 5 amended 2026-07-06 by ADR-078:
- §8 SW policy —
RESOLVED: 8.2SUPERSEDED (ADR-078): ship §8.1 (network-aware SW). §8.1 is now the only path under the iOS 200 MB OTA cap and is §2.5.2-clean (streamed content is inert). The frozen code path never refreshes at runtime. - Bundle slimming target —
RESOLVED: ~85 MBSUPERSEDED (ADR-078): ~150–250 MB via streaming. The 355 MB baseline was ~2 GB in July; §4.1 is moot (no 8K), the image bucket grew 13×. Stream galleries + audio, bundle code + textures + core JSON + default locale. - iOS code-signing ownership — RESOLVED: Marko owns it (personal Apple Developer account, $99/yr). Apple Developer Programme membership + provisioning profiles managed under his account. Now the first submission (ADR-078 iOS-first).
build-info.jsonshape —mootREOPENED (ADR-078): in-scope. §8.1 is now selected, so the content-manifest shape is specced when S2/S3 streaming lands.
Amended 2026-07-06 (ADR-078):
- Ship order — iOS-first. App Store before Google Play; reverses the §9–§10 "Android first to ship" framing. iOS front-loads the OTA-cap + WKWebView context-loss gates while they are cheapest to fix.
Still open:
- Per-scene WebGL restore — owners. §11.2 lists 7 scenes that each need a
reinit()factor-out. Track as 7 sub-tasks under PRD-015 M5 when implementation starts. Not an architectural decision; just task-list scope.
RFC-018 · Orrery · 2026-05-15 (decisions baked 2026-05-16; amended 2026-07-06 per ADR-078) · Closes-into-PRD-015