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
/scienceintegration (#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:
- No
localStorage/sessionStorage— per-session preferences only. - Mobile-first + offline — state must be accessible from CSS without import gymnastics in every component that wants to react to lens-on/off styling.
- SSR-safe — SvelteKit's static prerender renders the layout; no
documentavailable at that time.
Five candidate state mechanisms were considered:
| Option | Pros | Cons |
|---|---|---|
| Svelte store + import in every component | Familiar | Every consumer needs an import. CSS can't react. |
URL ?lens=on&layers=... | Shareable | Fights the existing URL contract; /explore?dest=jupiter&lens=on&layers=gravity,velocity,… becomes unreadable. |
| Cookie | Persists | Fights "no localStorage" spirit; needs server roundtrip semantics we don't have. |
| Custom event bus | Simple | Components 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 default | Mutating 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:
isScienceLensOn(): boolean // SSR-safe getter
toggleScienceLens(): boolean // returns new state
onScienceLensChange(cb): () => void // MutationObserver-driven subscriptionThe 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 · ozoneHelpers in src/lib/science-layers.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 flipisLayerOn 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:
: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:
- Fire once on subscribe with the current state.
- Re-emit on every state change (via
MutationObserveron the attribute). - Return an unsubscribe function (call to disconnect the observer).
- SSR-safe (return
undefinedifdocumentis 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.sveltecomponent 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 documentfirst; prerender doesn't crash. - Pattern reuse — mirrors ADR-029 exactly so contributors only need to learn the pattern once.
Alternatives considered
- Svelte 5
$staterune 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.documentElementattributes). - 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.documentElementis 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
MutationObserverfor 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 onscience-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 viaonLayerChangeand togglesgroup.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.