Skip to content

ADR-055 — Science Lens + multi-layer attribute-on-<html> state

Status · Accepted Date · 2026-05-09 (renumbered from 052 → 055 on 2026-05-10 to clear the v0.6 Fleet ADR slots planned by RFC-016 / PRD-012) Closes · v0.5.0 Phase 4 + 5 of /science integration (#39) TA anchor · §components/lens · §components/state-management Related ADRs · ADR-029 (PWA — same attribute-on-<html> pattern, applied first)

Context

The /science integration roadmap (PRD-008 Phase 4–5) called for a global Science Lens toggle and twelve sub-toggleable physics layers (gravity vectors, velocity vectors, centripetal arrows, sphere-of-influence rings, hover info cards, apsides + true anomaly, engine-off coast preview, conic-section panel, microgravity axes, atmosphere shells, tidal-lock indicator, ozone holes). The lens is global; layers are independently togglable when the lens is on.

Three constraints from CLAUDE.md complicated the obvious choices:

  1. No localStorage / sessionStorage — per-session preferences only.
  2. Mobile-first + offline — state must be accessible from CSS without import gymnastics in every component that wants to react to lens-on/off styling.
  3. SSR-safe — SvelteKit's static prerender renders the layout; no document available at that time.

Five candidate state mechanisms were considered:

OptionProsCons
Svelte store + import in every componentFamiliarEvery consumer needs an import. CSS can't react.
URL ?lens=on&layers=...ShareableFights the existing URL contract; /explore?dest=jupiter&lens=on&layers=gravity,velocity,… becomes unreadable.
CookiePersistsFights "no localStorage" spirit; needs server roundtrip semantics we don't have.
Custom event busSimpleComponents subscribe, but CSS still needs help.
Attribute on <html> (this ADR)CSS reacts via :global([data-science-lens='on']); no imports for styling; MutationObserver for script consumers; SSR-safe (script-only); session-bounded by defaultMutating document.documentElement is non-idiomatic in component frameworks

ADR-029 already established the attribute-on-<html> pattern for the high-contrast PWA toggle. This ADR generalises that pattern to the lens + the twelve sub-layers.

Decision

Master lens state

Single attribute data-science-lens on document.documentElement, value "on" or "off" (absent ≡ off). Helpers in src/lib/science-lens.ts:

ts
isScienceLensOn(): boolean             // SSR-safe getter
toggleScienceLens(): boolean           // returns new state
onScienceLensChange(cb): () => void    // MutationObserver-driven subscription

The nav button is the only UI surface that flips it. Attribute is deliberately not persisted across sessions (no localStorage rule); resets to off on every page load. This is intentional — lens-on is an opt-in mode, not a permanent preference.

Per-layer state

Twelve layers, one attribute per layer: data-science-layer-<key> where <key> is one of:

gravity · velocity · soi · hover · centripetal · apsides · coast · conics ·
microgravity · atmosphere · tidal-lock · ozone

Helpers in src/lib/science-layers.ts:

ts
type LayerKey = 'gravity' | 'velocity' || 'ozone'  // 12-member union
LAYER_ORDER: readonly LayerKey[]
LAYER_DEFAULTS: Record<LayerKey, boolean>             // sensible per-layer default
isLayerOn(key): boolean        // gated on master lens AND layer attribute
setLayer(key, on): void
ensureLayerDefaults(): void    // applies defaults to missing attributes
onLayerChange(key, cb): () => void   // re-emits on lens flip OR layer flip

isLayerOn returns false whenever the master lens is off, even if a layer's attribute says "on". This means a user can configure their preferred layer set with the lens off and have it materialise the moment they flip the lens on. The per-layer attribute persists across lens flips so preferences are remembered within the session.

CSS interop

Components style for lens-on without any import:

css
:global([data-science-lens='on']) .my-trajectory {
  stroke: var(--lens-accent);
}
:global([data-science-layer-gravity='on']) .gravity-arrow {
  display: block;
}

Subscription contract

Both onScienceLensChange and onLayerChange follow the same contract:

  1. Fire once on subscribe with the current state.
  2. Re-emit on every state change (via MutationObserver on the attribute).
  3. Return an unsubscribe function (call to disconnect the observer).
  4. SSR-safe (return undefined if document is unavailable).

onLayerChange additionally subscribes to lens changes internally so subscribers see the effective state (lens AND layer) rather than the raw attribute.

Rationale

  • CSS-first — the most common consumer of lens-state is styling. Attribute-on-<html> lets every .svelte component style for the lens via a global selector with zero imports.
  • Script consumers via MutationObserver — works in every browser we support; no extra runtime; tied to the same source of truth.
  • No new state library — keeps the dep tree clean (CLAUDE.md rule: "Do not add npm dependencies without ADR").
  • SSR-safe by default — every helper checks typeof document first; prerender doesn't crash.
  • Pattern reuse — mirrors ADR-029 exactly so contributors only need to learn the pattern once.

Alternatives considered

  • Svelte 5 $state rune in a shared module — would work but couples to Svelte 5; CSS still needs help; no advantage over the attribute approach.
  • Single data-science-lens="<comma-separated-layers>" attribute — compact but harder to query from CSS (no attribute-contains selector that's broadly supported with the right semantics).
  • Persistence to URL — explicitly rejected: lens state is ephemeral, not shareable, and URL params already carry mission/destination state.

Consequences

Positive:

  • Single source of truth (document.documentElement attributes).
  • CSS components style for lens-on without any import; works for every existing and future component.
  • Test-friendly: deterministic — set the attribute, read the state.
  • Pattern reused from ADR-029 (PWA high-contrast toggle), so contributors recognise it.

Negative:

  • Mutating document.documentElement is unusual in framework-heavy codebases; readers familiar with stores need to learn the pattern.
  • 12 attributes on <html> is a lot of attributes; harmless but visible in devtools.
  • Pattern requires MutationObserver for script subscribers (negligible cost; jsdom supports it for tests).

Implementation notes

  • src/lib/science-lens.ts — master-lens state.
  • src/lib/science-layers.ts — multi-flag layer state, depends on science-lens.ts.
  • src/lib/components/ScienceLensBanner.svelte — banner UI; subscribes to lens state.
  • src/lib/components/ScienceLayersPanel.svelte — sub-toggle UI; appears top-center under the banner when lens is on.
  • Per-layer 3D helpers (src/lib/orbit-overlays.ts, src/lib/microgravity-axes.ts) are vanilla THREE.Group builders that route mounting code subscribes via onLayerChange and toggles group.visible.
  • Tests: src/lib/science-lens.test.ts, src/lib/science-layers.test.ts (jsdom env).

When a new layer is added, the LayerKey union, LAYER_ORDER array, LAYER_DEFAULTS record, and the science-layers.test.ts:LAYER_ORDER.length assertion must all be updated together — the test breaks loudly if you forget the array, the type breaks if you forget the union, and ensureLayerDefaults skips the layer if you forget the defaults record.

Orrery — architecture documentation · MIT · No tracking