Skip to content

ADR-072 — Surface routes (/moon and /mars) share SurfaceScene.svelte; differences are justified per body or rejected as drift

Status · Accepted Date · 2026-05-30 Closes into · src/lib/surface-scene/SurfaceScene.svelte (new shared component) + src/lib/surface-scene/README.md (implementation contract) Related · ADR-037 (Shared surface-site type, deferred shared <SurfaceMap> component — this ADR is the closing of that deferral), ADR-061 (Surface hotspots are regions), Issue #42 (closed — partial extraction of pure helpers), Issue #283 (Slice 2 epic) Triggered by · Drift audit comparing /moon/+page.svelte (2143 LOC) and /mars/+page.svelte (2763 LOC), 2026-05-30. Twenty-two divergences identified that were not justified by the bodies being different — they were drift from Mars being implemented after Moon and the implementer making improvements only on Mars.

Context

The original plan (ADR-037, closed RFC-012 OQ-1) was for /moon and /mars to share a <SurfaceMap> component. ADR-037 explicitly deferred the extraction: Mars V1 shipped as a focused clone of Moon's pattern, with the intention to extract once both routes were known-good.

Issue #42 opened to track that extraction. It closed with partial work: ~35 pure helpers were extracted into src/lib/surface-map/* (12 modules) and src/lib/three/* (23 modules). The orchestration — onMount Three.js scene assembly, per-frame animation loop, reactive Svelte state, template HUD pieces — stayed inline in both routes. Closed as "good enough for v0.6 ship" with a list of drift risks left for later.

By 2026-05-30, those drift risks had matured. A focused recon of both routes (Issue #283 Slice 2 design phase) found eight true body differences and twenty-two accidental drifts — places where Mars had been improved without back-porting to Moon (or vice versa, in three cases). The drifts included:

  • UX behaviour (Mars's drag inertia, smooth zoom lerp, fly-in animation, altitude indicator)
  • Visual constants (different label sizes, halo radii, label offsets)
  • Code architecture (status-badge as CSS classes vs as a function, hover outline target, orbital ring parenting)
  • Latent bugs (Moon's moon-sites.json has 8 orbiters that the route silently filters out)

Without a deliberate consolidation, every future surface-route change would have to be made twice — and the drift would keep widening, because Slice 3+ of Issue #283 is going to touch this code heavily (rectangular hotspots, sphere → flat-patch transition, scale-aware markers, traverse polyline, HUD).

Decision

Build SurfaceScene.svelte and migrate both routes onto it. The component owns the shared 90 %; routes become thin shells that pass a planet config and a panel snippet. Drift is resolved on the way in — Mars's improvements back-port to Moon, Moon's improvements forward-port to Mars, compromise picks where neither was clearly right.

True body differences (stay as SurfaceSceneConfig knobs)

KnobJustification
textureUrlDifferent bodies → different surfaces
atmosphere?Mars has one (CO₂, dust); Moon doesn't (vacuum)
tidalLockOverlay?Moon is tidally locked → "near side" is a real concept. Mars isn't
axialTiltDegMars 25.19°; Moon ~0° (1.5° in reality, effectively zero at this rendering scale). Real obliquity is physical, not arbitrary
landerModelBuilderMoon has Apollo/Luna/Chang'e/Beresheet/SLIM/Chandrayaan-3 catalogue; Mars has Vikings/Pathfinder/MER/Curiosity/Phoenix/Mars 3/Zhurong/Schiaparelli/Beagle 2. Domain content differs
twoDModeMars rotates → equirectangular is honest. Moon is tidally locked → two-disc near/far is the only honest projection (a single equirectangular Moon map would imply rotation relative to Earth)
traverses?Mars has 4 vendored rover paths (Curiosity, Perseverance, Spirit, Opportunity). Moon has historical EVA paths (Apollo 14/15/16/17) and Lunokhod tracks that should be added in a future slice but aren't vendored today
registerHotspotBuildersAlready consolidated as per-planet bundles in Slice 2A (registerMoonHotspotBuilders / registerMarsHotspotBuilders)

Drifts that get consolidated

DriftOld MoonOld MarsConsolidationWhy
Camera near plane0.50.050.05Mars's allows closer zoom; required for Tier-2 detail
Initial camR809085Compromise — neither value was special
Initial camP (pitch)π/245°45°Mars's angled view is more inviting than Moon's straight-equator view
Camera zoom range[30.2, 200][30.2, 180][30.2, 200]Wider zoom-out preserved from Moon
Surface light intensity0.70.80.8Mars's value is brighter; preserve tint difference (Moon 0x666688, Mars 0x886655) since tint hints at body palette
Star fielddefault (~150 stars)STAR_FIELD.planetSTAR_FIELD.planetDeliberate value beats accidental default
Tier-2 opacity ramp rangecamR 50 → 33camR 33 → 30.5camR 33 → 30.5Mars's range was the more recent tuning that matches actual Tier-2 patch dimensions
Marker label offset(0, 3.2, 0) straight up(2.6, 1.4, 0) side+up(2.6, 1.4, 0)Mars's offset (deliberate 2026-05-22 improvement) avoids labels sitting on top of the marker
Marker label size1.61.41.4Mars's tighter sizing reads better without competing with the lander glyph
Marker halo radius1.81.41.4Mars's smaller halo is less intrusive
Auto-spin pauseURL deep-link onlyAny selectionAny selectionClicking a marker without the body coming to rest is jarring
Drag inertianone0.92/frame decay0.92/frame on bothInertia makes drag feel natural
Smooth zoom lerpsnaplerp toward targetLerp on bothMars's lerp feels much better than instant snap
Fly-in animationlongitude flip only (instant)full 3D tween (800 ms ease-out cubic)Full fly-in on bothMars's deep-link fly-in is a great UX moment
Hover outline targetentire marker grouptier0Group onlytier0Group onlyOutlining the label sprite + halo looks messy
Altitude indicatorabsentshown in km above surfaceShown on both"How zoomed am I" is body-agnostic
Status badgepredefined CSS classes (.status-completed/ongoing/planned)dynamic statusTone() fnstatusTone() on bothFunction approach is more flexible and centralised
2D pick tolerance22 px18 px20 px on bothCompromise; neither value had stated rationale
Orbital ring opacitydefault (0.35)explicit 0.35 / 0.18 dimmedExplicit on bothDimmed-orbiter visual cue is useful
Site kind filteringonMount filters out orbitersboth surface + orbiterBoth kinds on bothMoon's moon-sites.json has 8 orbiters (LRO, Chandrayaan-1, Chang'e 1/2, SMART-1, Luna 10, Clementine, Lunar Prospector) that the route was silently filtering out — latent bug
Orbital ring log divisor50100Derive from max(altitude_km) at loadHardcoded values are drift; data-derived scaling adapts to whichever bodies the route renders
Orbital parentscenemarsAxis (inherits planet tilt)scene on bothOrbital mechanics are inertial — orbits shouldn't inherit a planet's tilt. Mars was wrong
Panel structure (amendment, added Slice 2 Wave E).grid / .cell cell layout with cells for year / landing / coords / status / surface time / EVA / samplesdt/dd .meta-grid with badges-row (agency + status + kind) + mission-type chip + dt/dd pairsMoon's .grid / .cell layout on bothMars's dt/dd structure surfaced the same information with a different HTML scaffold; not a body-justified divergence. Consolidated to Moon's layout since it's the version already living in SurfaceScene. Amendment to this ADR — wasn't in the original 22 drifts because the panel divergence didn't surface until we tried to migrate /mars

Behavioural changes shipped by this consolidation

The consolidation lands UX changes that users will notice — flagged here so the migration PR can call them out:

  • Moon gains: drag inertia, smooth zoom lerp, deep-link fly-in animation, altitude indicator HUD, orbiter rendering (8 missions previously hidden), auto-spin pause on any selection, smaller markers/halos, side-offset labels, statusTone() badges.
  • Mars loses (in the sense of "reverts to common"): the marsAxis-parented orbiters (orbits will no longer share the planet tilt); the dt/dd .meta-grid panel layout (replaced by Moon's .grid / .cell cell layout per the drift #23 amendment); the scaleNote line in TierContextCard (Mars-only field that the canonical TierContextCard usage in SurfaceScene doesn't pass — see retrospective fix #13).
  • Both gain: data-derived orbital ring scaling, more sensible 2D pick tolerance (20 px compromise).

Alternatives considered

  • "Preserve all current behaviour" — would have meant 25+ config knobs on SurfaceScene to preserve drift. Rejected because (a) the resulting god-component would itself be a maintenance burden, and (b) most "differences" weren't deliberate — they were drift the implementer would have removed if they'd seen the comparison side-by-side.
  • "Stay with two route files; extract just render-path helpers" — the targeted middle path. Rejected by Marko in favour of the full extraction since drift was deemed the root issue, not duplication itself.
  • "Skip extraction; live with the duplication for v0.7" — rejected. Slice 3+ of #283 (rectangular hotspots, sphere → flat-patch, marker primitive, scale-bar HUD, traverse polyline) would multiply the drift surface.

Consequences

  • SurfaceScene.svelte becomes the canonical home for surface-route rendering. New routes in this family (hypothetical: Mercury, Vesta) would consume the same component with a third config.
  • Extended to /earth in #285 Phase 2 (commit cb22bd107, 2026-05-30). SurfacePlanet union grew from 'moon' | 'mars' to 'moon' | 'mars' | 'earth'. /earth is a hybrid: orbital mode (default) stays bespoke in EarthOrbitalScene; surface mode (?mode=surface) consumes SurfaceScene with EARTH_LAUNCH_CONFIG to render the 14 launch-site markers. Earth's physical differences (Rayleigh-blue atmosphere shell, 23.4° axial tilt, equirectangular 2D, no tidal-lock) all rode on existing optional knobs — no new config props were added, ADR-072 discipline preserved.
  • New body-vs-body differences require either (a) a config knob justified by physical/domain reality (atmosphere, tidal-lock, axial tilt, projection mode, lander catalogue, traverse data availability) OR (b) a fresh ADR explaining why the difference isn't drift. Future drift is gated.
  • The implementation contract — what's common, what's a config knob, how to add either — lives in src/lib/surface-scene/README.md for code-level reference. PRs touching surface routes should reference it.
  • Issue #42 (closed) is functionally closed-for-real by this ADR. The "drift risks queued" section of #42 is the work this ADR ships.
  • ADR-037's "deferred shared component" line gets a closure footnote — that deferral is now resolved.

Orrery — architecture documentation · MIT · No tracking