ADR-025 — Accessibility tier-1 contract
Status · Accepted Date · 2026-04-29 Closes · RFC-005 (accessibility approach) TA anchor · §constraints · §components
Context
RFC-005 raised the question: four of six screens render primarily to canvas or WebGL, which is inherently opaque to screen readers. What level of accessibility ships in v1, and where does v1 draw the line?
The answers needed locking before v0.1.0 because every contributor was making ad-hoc decisions (Slice 3 added some ARIA on /explore, Slice 4 added different ARIA on /fly, Slice 5 used yet another pattern on /moon). This ADR consolidates and locks the contract.
Decision
Tier 1 — what ships in v1
Reduced motion. All canvas screens detect prefers-reduced-motion: reduce via matchMedia (helper: src/lib/reduced-motion.ts) and stop unsolicited motion: /explore freezes simT, /fly defaults isPlaying=false, /moon stops auto-rotate. User-initiated motion (drag-to-orbit, scrub, scroll, click) still works. The contract is "stop motion the user didn't ask for", not "freeze the screen".
Nav bar. <nav aria-label={m.nav_aria_label()}> (Svelte's a11y rule rejects redundant role="navigation" on <nav>). All six links are real anchors so SvelteKit's router handles keyboard activation natively. The :focus-visible outline (2px brand-blue at 2px offset) is set globally on every interactive element via the design tokens.
Detail panels. Panel.svelte is <aside> with aria-label={title} (implicit role=complementary, intentionally not role=dialog because panels don't trap focus from the underlying canvas — the user can still drag/scroll the scene with a panel open). On open, focus moves to the close button via queueMicrotask; on close, focus returns to whatever was active before. Escape closes the panel.
Tab strips. Every panel tab strip (PlanetPanel, SunPanel, MissionPanel) implements the WAI-ARIA tabs pattern: role="tablist" wrapping role="tab" buttons with aria-selected, aria-controls linking to the tabpanel id, and the content area is role="tabpanel" with aria-labelledby linked back to the active tab's id.
Filter pills. /missions filter bar uses role="radiogroup" per filter group, role="radio" per pill, aria-checked reflecting active state — keyboard navigates with the native radio-button arrow-key behaviour.
Canvas screens. Every canvas (3D and 2D) carries an honest aria-label describing what it shows and how to access details: e.g., /explore's 2D canvas reads "2D top-down solar system. Drag to pan, scroll or pinch to zoom, tap planets and Sun for details." Tier 1 doesn't pretend the canvas is keyboard-navigable — it directs users to the panel.
Live regions. The /explore hover tooltip is role="status" aria-live="polite" so screen readers announce orbital data on hover. /fly's identity HUD is role="status" aria-live="polite" for phase changes. /missions and /fly load-failure banners are role="alert" for immediate announcement.
Touch targets. 44 × 44 px minimum on every interactive element. /earth uses an "invisible expanded hit pad" pattern (4–6 px visual dot, 22 px hit radius) so dense diagrams remain tappable.
Tier 2 — explicitly deferred to v2
Per RFC-005's tiered scope:
- Canvas object keyboard navigation. Tabbing between planets/missions/sites within a canvas. Requires either a parallel DOM mirror (each clickable object as a hidden
<button>overlaid on its canvas position) or porting to SVG. Both rewrite the rendering architecture; neither is justified for v1. - Full screen-reader description of canvas content. The aria-label says "click planets for details", which is honest. Reading every planet's position aloud is out of scope.
- High-contrast mode. Design system uses dark-on-dark with brand-blue accents — likely fails WCAG 2.1 AA contrast ratios in some places. v2 would require a token-level audit.
- Locales other than English. Paraglide-js + locale overlays are wired in (110 schema-validated files); adding fr, es, etc. is a content task, not a code task.
- PWA manifest + service worker.
Rationale
Tier 1 covers the user populations most blocked by canvas-heavy SPAs — keyboard-only and reduced-motion users. They can navigate the entire app, hear what every screen offers, and access the educational content via panels. Tier 2 is deferred not because it doesn't matter but because doing it right requires architectural changes (parallel DOM, SVG migration, token audit) that don't fit v1's "ship the prototype" scope.
The reduced-motion contract is the load-bearing piece. Auto-orbit on /explore + auto-play on /fly + auto-rotate on /moon would be nausea-inducing for vestibular-disorder users. Stopping these on demand — without disabling the screens — is the correct accessibility move.
Alternatives considered
- Cover everything in v1 (delay ship). Would slip v0.1.0 by ≥ 2 slices for canvas-keyboard nav alone; no return on the delay because canvas-nav requires the SVG migration anyway.
- Skip accessibility entirely. Rejected — vestibular disorders affect ~3% of users; skipping reduced-motion is hostile.
- Use ARIA-live regions to read every canvas update. Tested informally; produces overwhelming announcements. Would need an audio-cue grammar that's its own design problem.
Consequences
Positive: keyboard-only and reduced-motion users have a clean v1 experience. Every interactive element has a visible focus indicator + 44 px hit pad. Live regions announce dynamic data without flooding.
Negative: screen-reader users still can't tab between objects within a canvas — they navigate via panels. Tier 2 work is real and tracked.
Validation (closing evidence)
prefers-reduced-motionhonoured on /explore, /fly, /moon — verified manually with macOS System Settings → Accessibility → Display → Reduce motion.- Every canvas carries
aria-label(verified:grep -rn 'aria-label' src/routesreturns matches in all four canvas screens). - Every panel tab strip has matching
role=tablist/tab/tabpanel(verified: PlanetPanel, SunPanel, MissionPanel all carry the pattern). - Every
<aside class="panel">is wrapped inaria-label={title}; close-button focus management is tested intests/e2e/explore.spec.ts("toggle stays accessible when panel is open"). :focus-visibleoutline is set globally insrc/lib/styles/tokens.cssand overridden in component CSS only to brand colours, neveroutline: none.- 44 × 44 px touch targets verified across speed pills (/fly), play button (/fly), 2D/3D toggles (/explore, /moon), CAPCOM toggle (/fly), filter pills (/missions).