Skip to content

RFC-031 — Keyboard-first navigation, focus model & WCAG 2.1 AA elevation (a11y Tier 2 · Google TV)

Status · Draft Date · 2026-07-09 Gates · #375 (TV + a11y epic) · extends ADR-025 (accessibility Tier-1 contract → executes its deferred Tier 2) · mobile epic #149 · Google-TV target (RFC-030) Closes-toward · ADR-025 Tier 2 ("canvas object keyboard navigation", "full screen-reader description of canvas content", formal WCAG target) ADR · to follow (locks the interaction contract + conformance target once slices settle)

Why this is an RFC. ADR-025 shipped an honest a11y Tier 1 and explicitly deferred canvas object keyboard navigation to "v2", calling it an architecture change (a parallel DOM mirror or an SVG migration). Two things have since changed: (1) the parallel DOM mirror already exists as a substrate (select {planetId} / select {moduleId} buttons on /explore · /iss · /tiangong, built for pickability + e2e per ADR-056), and (2) Google TV (RFC-030 target, #375) needs a D-pad remote, which is the same input model as a keyboard. So the deferred a11y work and the TV work are one build. This RFC locks the interaction contract, the focus architecture, the TV layer, and the WCAG 2.1 AA target across every route + 3D scene before the slices, so they don't re-litigate the focus model page-by-page (the exact fragmentation ADR-025 was written to stop).

Gating sentence: every interactive affordance in Orrery — including objects inside a 3D/2D canvas — is reachable and operable with keyboard alone, via one shared roving/spatial-focus engine that drives an elevated parallel DOM mirror (a focusable, correctly-ordered, :focus-visible button per canvas object whose focus highlights the object in-scene, announces it to a screen reader, and optionally eases the camera to it); the same engine is the Google-TV D-pad experience under a 10-foot / overscan-safe TV layer; a global command palette (Cmd-K, promoted app-wide) is the jump-to-anything escape hatch on all three surfaces; and the whole app is brought to a formally audited WCAG 2.1 AA conformance target with a published accessibility statement. A TV remote is a keyboard (←→↑↓ + Enter/Back) — we build the keyboard once and get the remote for free.

Context

  • ADR-025 Tier 1 (shipped): reduced-motion, nav keyboard activation, global :focus-visible, panel focus move/return + Esc, WAI-ARIA tab strips, radiogroup filter pills, honest canvas aria-labels, aria-live status/alert regions, 44 px targets. Strong foundation — 63 files already use :focus-visible.
  • ADR-025 Tier 2 (deferred, this RFC): tabbing between objects within a canvas; full screen-reader canvas description; a formal WCAG target. (High-contrast, also listed Tier 2, has since shipped as a toggle.)
  • The substrate exists: /explore renders one hidden <button> per planet/body; /iss + /tiangong render one per module. Today they are click/e2e/SR hooks, not a navigation experience — no coherent focus order, no focus→highlight, no camera coupling, no D-pad camera control, not TV-scaled.
  • Google TV (RFC-030 / #375): a Capacitor Android build on a D-pad remote with no touchscreen. Same directional-focus model as a keyboard; adds only a 10-foot presentation layer.

Goals

  1. Keyboard-complete: 100% of interactive affordances operable by keyboard, including canvas objects (WCAG 2.1.1, no traps 2.1.2).
  2. One focus engine shared by lists, panels, and canvas scenes — no per-page reinvention.
  3. Screen-reader canvas access: focusing a canvas object announces it (name + key datum).
  4. Google-TV usable on a remote under a 10-foot/overscan layer, driven by the same engine.
  5. Command palette app-wide as the jump-to-anything surface (keyboard + remote + power users).
  6. WCAG 2.1 AA formally audited + a published accessibility statement.

Non-goals

  • Apple TV / tvOS — no WKWebView on tvOS; architecturally out (per #375, would be a native rewrite / separate PRD).
  • SVG re-render of the 3D scenes — the DOM-mirror approach makes it unnecessary (ADR-025's other option, explicitly not taken).
  • AAA conformance — AA is the target; AAA items adopted only where free.
  • Voice / switch-access hardware — future, not this RFC.

The model — one input abstraction, three surfaces

        ┌──────────────── shared roving / spatial-focus engine ────────────────┐
        │  focus order · directional (2D) move · activate · escape · announce   │
        └───────────────────────────────────────────────────────────────────────┘
             ▲                         ▲                              ▲
      desktop keyboard          screen reader                   TV D-pad remote
      (Tab / arrows / Enter)    (focus + aria-live)             (←→↑↓ / OK / Back)
                                                                + 10-foot layer

A TV remote emits the same events as a keyboard. We implement the keyboard model correctly; the remote and the screen reader are the same model observed on different surfaces. The only TV-specific work is presentation (size, spacing, overscan, focus-ring weight), never interaction.

Design decisions

D1 — Shared spatial-focus primitive. A Svelte-5 store (src/lib/a11y/roving-focus.svelte.ts): register focusable items with an ordering + optional 2D screen position; expose roving tabindex, directional move (up/down/left/right → nearest item by geometry for canvas, by order for lists), activate, and escape. Every list and every canvas mirror consumes it. This replaces the ad-hoc per-page focus code ADR-025 warned was fragmenting.

D2 — Elevate the parallel DOM mirror. Keep the existing per-object <button>s; make them (a) ordered + roving-tabindex, (b) :focus-visible-ringed, (c) positioned over the object's screen projection (reuse the ADR-056 project-to-client math), (d) on focus → highlight the object in-scene + aria-live announce + optional camera ease-to. Complete the mirror where missing: surfaces /moon /mars /earth, and /fly.

D3 — Camera on keys/D-pad. Two interaction intents share the remote/keyboard: traverse objects (default — arrows move focus between mirror buttons) and fly the camera (arrows orbit/zoom). Resolve via an explicit mode toggle (e.g. OK-on-empty or a dedicated key) documented per scene; never trap the user in camera mode (Back/Tab always escapes).

D4 — Global command palette. Promote the /science Cmd-K into an app-wide palette (src/lib/components/CommandPalette.svelte): fuzzy index of routes + planets + bodies + missions + fleet + station modules. Keyboard Cmd/Ctrl-K; a visible entry point for TV (a "Search" affordance in nav). This is the jump-to-anything escape hatch that makes 100-item lists bearable on a remote and instant for keyboard users.

D5 — TV / 10-foot layer. Detect the 10-foot context ((hover: none) and (pointer: coarse) + large viewport, and/or a __TV__/leanback build signal) and apply a presentation layer: larger type scale, increased spacing, overscan-safe margins (~5%), heavier focus rings (3–4 px), and removal of hover-only affordances (tooltips become focus-driven). Presentation only — no separate interaction code path.

D6 — Screen-reader canvas narration. When a mirror button gains focus, an aria-live="polite" region announces the object (name + one key datum, e.g. "Mars — 4th planet" / "Zarya — first ISS module, 1998"). Turns an opaque canvas into a navigable list for SR users — ADR-025's deferred "full description", done incrementally and without flooding (announce on focus, not on every frame).

D7 — WCAG 2.1 AA conformance target. Formal audit (axe-core automated + manual keyboard/SR/contrast pass), a tracked fix list, and a published accessibility statement (/accessibility route or docs/), stating conformance level + known limitations. High-contrast toggle already covers a chunk of the contrast criteria.

D8 — Testing on both surfaces. Playwright keyboard-only journeys on desktop-chromium (Tab/arrow/Enter reaches every affordance incl. canvas objects) + a TV surface (emulated 1080p + hover:none, or the Orrery_TV emulator for manual). axe-core per-route checks wired into e2e as a gate. Same assertions, two surfaces.

Key mappings (contract)

InputKeyboardTV remoteAction
Move focus (list)Tab / Shift-Tab, ↑↓D-pad ↑↓roving focus, next/prev
Move focus (canvas)↑↓←→D-pad ↑↓←→nearest object by geometry
ActivateEnter / SpaceOKopen panel / select object
DismissEscBackclose panel / exit mode
Camera mode(per-scene key)(per-scene)arrows orbit/zoom
Command paletteCmd/Ctrl-K"Search" affordancejump-to-anything

Slices (each → a sub-issue under #375)

  • S0 · Audit + baseline. axe-core wired into e2e; contrast/token pass; focus-order map per route; add a skip-link; findings + fix list doc in docs/wip/.
  • S1 · Spatial-focus primitive (roving-focus.svelte.ts) + convert the big lists (nav, /missions, /fleet, station module lists, /science) to it. No canvas yet.
  • S2 · Elevate the /explore mirror — order, focus-visible, focus→highlight + SR announce + camera-ease for planets/bodies.
  • S3 · Elevate /iss + /tiangong mirrors, then complete the mirror on /moon /mars /earth + /fly.
  • S4 · Camera keyboard/D-pad control across all scenes (mode contract from D3). DEFERRED — future work. Content is fully reachable without it via the per-scene DOM object indexes (S2/S3); only camera manipulation (orbit/pan/zoom) stays pointer-driven, which is not required for WCAG 2.1 AA reachability. Tracked for a later pass — see docs/accessibility.md §Known limitations.
  • S5 · Global command palette (promote Cmd-K app-wide).
  • S6 · TV 10-foot layer — detection, type scale, overscan, focus-ring weight, hover→focus tooltips. (Leanback manifest + banner already shipped, fd1ea15adc.)
  • S7 · Accessibility statement page + WCAG 2.1 AA conformance claim + docs/ update; close ADR-025 Tier 2 in a follow-up ADR.
  • S8 · Keyboard + TV e2e journeys + axe gate in CI.

WCAG 2.1 AA success criteria touched

2.1.1 Keyboard · 2.1.2 No keyboard trap · 2.4.3 Focus order · 2.4.7 Focus visible · 2.1.4 Character-key shortcuts (palette) · 1.4.3 Contrast (audit + high-contrast) · 2.5.5 Target size (Tier 1: 44 px, re-verify) · 2.3.3 Animation from interactions (reduced-motion, shipped) · 4.1.2 Name/role/value (mirror buttons) · 4.1.3 Status messages (narration live regions).

Risks / open questions

  • Camera-vs-traverse mode ambiguity on a remote — the single hardest UX call (D3); prototype on the Orrery_TV emulator early (S2) before committing the contract.
  • Announcement volume — narrate on focus only, one datum; reuse ADR-025's "don't flood" lesson.
  • Focus-position drift — mirror buttons must track object screen-projection as the camera moves; throttle to requestAnimationFrame, and only for the focused/near items (perf, per the 2026-06-19 render-storm lesson).
  • Palette index size/cost — build the fuzzy index from existing manifests; lazy-load.

Rollback

Every slice is additive + independently revertable: the primitive is new code; the mirror elevation enhances existing buttons (revert = the current click-only substrate); the TV layer is media-query-gated; the palette is a new component; the audit/statement are docs. No slice changes the rendering architecture (the point of the DOM-mirror approach).

References

ADR-025 (a11y Tier 1 + the Tier 2 deferral this executes) · ADR-056 (window.__pickAt project-to-client math, reused by the mirror) · RFC-030 + #375 (Google-TV target) · epic #149.


Orrery · RFC-031 · Keyboard-first navigation, focus model & WCAG 2.1 AA · July 2026

Orrery — architecture documentation · MIT · No tracking