Skip to content

A11Y Gap Analysis — 2026-05-15

Working doc for re-scoping GH issue #43. Author: Claude (architecture audit, no code touched). Authoritative scope today: ADR-025 (a11y tier-1 contract, 2026-04-29).


Re-frame issue #43

Issue #43 as it stands today reads: "Raise Lighthouse accessibility to >= 95 on all primary routes, with focus on /missions (92) and /mars (90) first." It treats accessibility as a single number to nudge upward. Lighthouse's automated audit catches roughly 30% of WCAG failures — it confirms that buttons have names, that <html lang> is set, that colour contrast on visible text passes a ratio check. It cannot tell you that the porkchop ∆v heatmap encodes its primary signal in colour with no alternative, that the /fly timeline scrubber emits "0.5" to a screen reader where a human would say "day 4 of 8, cruise phase, 184 million km from Earth", that the entire /explore scene is a black box to VoiceOver beyond a single canvas label, or that the Arabic locale (which we ship) has never been tested for focus order. A score of 95 across the board would tell us nothing about any of these.

The mismatch is that Orrery has a flagship i18n story — 14 locales, native scripts (Cyrillic, Devanagari, CJK, Arabic), runtime <html lang>/dir switching, bidi-aware logical-property CSS, locale-canonicalising URLs, KaTeX rendered at build time, per-image provenance manifests. We treat localisation as a first-class product capability, not a checkbox. Accessibility today lives at the opposite pole: ADR-025 declared tier-1 ("don't trap anyone, honour reduced-motion, label every canvas honestly, ship 44 px touch targets") and explicitly deferred the interesting work to "v2" — canvas object navigation, full screen-reader description of canvases, high-contrast colour audit, anything beyond the English locale. That tier-1 contract was right for v0.1 in April. We are now at v0.6 with 37 missions, 18 ISS modules, 85 science sections, KaTeX math, hand-drawn SVG diagrams, mission narration, and a public demo. Tier-1 is no longer the right bar.

What "demo-grade a11y" means here is concrete: a VoiceOver / NVDA user landing on /fly should hear "Apollo 11, day 4 of 8, cruise phase, 184 million km from Earth, signal delay 10.2 minutes" without sighted help; an Arabic-locale user should be able to tab through /missions with focus order matching reading direction; a keyboard-only user should be able to pick any of 8 planets on /explore without a mouse; a colour-blind user should be able to read the porkchop ∆v map by shape or numeric labels, not hue alone; every KaTeX equation on /science should have a spoken form. The work below sequences how we get from where we are (ADR-025 tier-1, Lighthouse 90–95, English-only) to that bar, in three tiers that map to v0.7 / v0.8 / v1.0.


Audit — 18 dimensions

Score key: shipped, partial, gap.

1. Document structure —

src/app.html:2 ships <html lang="en-US" dir="ltr"> and src/lib/locale.ts:260-265 (syncDocumentLocaleAttributes) rewrites both at runtime when the locale changes. src/routes/+layout.svelte:27-32 calls it inside $effect.pre so the swap happens before children paint. <svelte:head><title>...</title></svelte:head> is set on every route — verified across src/routes/{credits,earth,explore,fleet,fly,iss,library,mars,missions,moon,plan,science,tiangong}/+page.svelte and the science [tab] + [section] nested layouts.

What's missing:

  • No <main> landmark. src/routes/+layout.svelte:113-115 wraps children in a bare <main> element but the page-level routes don't carry their own landmark structure. /missions (src/routes/missions/+page.svelte) opens with <div class="library"> instead of <main id="main-content">.
  • No skip-to-content link. grep skip-link|skipNav returns 0 matches across the codebase. Keyboard users land on the 14-link nav strip on every navigation.
  • /landing ships h1 + h2 cleanly (src/routes/+page.svelte:367 <h1>ORRERY</h1>, headings 388/397/405/415/971), but several routes have h-level jumps: /library (src/routes/library/+page.svelte:134-135 h1 then h2 immediately, then h3 buried in mission cards), /missions jumps from no h1 to <h2 class="card-name"> per card.
  • Several routes ship no <h1> at all/missions/+page.svelte, /fleet/+page.svelte (h2 cards only), /plan/+page.svelte, /explore/+page.svelte, /fly/+page.svelte. The first heading a screen reader hits is whatever h2 appears inside the panel — useful, but architecturally wrong.

2. Semantic HTML —

Strong patterns: <nav aria-label=...> (src/lib/components/Nav.svelte:89), <aside> for detail panels (Panel.svelte:62), <button type="button"> everywhere we click (verified across 100+ onclick callsites — no <div onclick> anti-patterns), <a href> for routing (Nav, landing cards, mission cards, science chips).

Gaps:

  • Mission cards on /missions/+page.svelte:500 use <button> wrapping <h2 class="card-name">. The semantic intent is "card link", but mission cards aren't navigation targets — they open a side panel. The <h2> inside an interactive <button> is read awkwardly ("button, Apollo 11 heading level 2…").
  • Per-image provenance markup: <img> tags carry alt="" (decorative) almost everywhere — gallery thumbs, hero images, lightbox images. Examples: MissionPanel.svelte:208,641,690, PlanetPanel.svelte:129,341,357, +page.svelte:1176,1311,1371. The hero image of Apollo 11 is read as nothing. This is intentional under ADR-025 ("the image is decorative; the panel's label carries the name") but is a real accessibility issue for screen reader users who currently get zero information about a 800 × 600 hero photo.
  • Tabular mission data on /library/+page.svelte:1326-1370 is rendered as <div class="card-meta"> rows with key/value spans. A <dl> or <table> would let AT users navigate by column / row.
  • Mission flight params on MissionPanel.svelte flight tab ("∆v 9.4 km/s · TOF 254 d · launch C3 12.1 km²/s²") is <dl>-shaped data emitted as styled divs.
  • CAPCOM events on /fly/+page.svelte:4069-4078 use <ul> correctly, but the per-event metadata (time / label / note) is loose spans where a <dl> would help.

3. Keyboard navigation —

:focus-visible outlines are systematically present — src/lib/styles/tokens.css doesn't declare a global outline rule, but every interactive component sets one locally (Nav.svelte:236,323,359,391,428; Panel.svelte; ScienceChip.svelte:96; FleetEntryPanel.svelte; etc — 80+ callsites across 21 files). outline: none only appears alongside an immediate replacement style.

Strong:

  • Tab strips implement the WAI-ARIA tabs pattern (PlanetPanel, MissionPanel, SunPanel, SmallBodyPanel, StationModulePanel, FleetEntryPanel — verified all six).
  • Panel.svelte:17-39 does focus management: capture previously focused element, move focus to close button on open, restore on close. Escape closes.
  • ScienceSearch.svelte:85-110 has full keyboard support (Cmd-K open, arrow up/down, Enter, Escape).
  • 44 × 44 px touch targets on the lens-toggle / contrast-toggle / menu-toggle (Nav.svelte:280, 343, 376), the play button (/fly +page.svelte:3987), the scrub speed pills, the close button (Panel.svelte:140).

Gaps:

  • Canvas objects are not tabbable. Every clickable planet (/explore), moon site (/moon), Mars site (/mars), ISS module (/iss), Tiangong module (/tiangong), Earth-orbit body (/earth), and mission card (/fly if it had one) is a mouse-only / touch-only target. ADR-025 explicitly defers this to "tier 2"; the gap is real and is the single biggest blocker to demo-grade a11y.
  • The lightbox in every panel (MissionPanel.svelte:687-691, PlanetPanel.svelte:354-358, StationModulePanel.svelte:278-282, etc.) is a <button> wrapping the image, no role="dialog", no focus trap. Click outside (the button itself) closes it. Escape doesn't close it (verified — no Escape handler in any of the lightbox callsites; only the panel-level Escape closes the parent panel).
  • The timeline scrubber on /fly +page.svelte:3995-4007 is a native <input type="range"> (good — fully keyboard-accessible) but missing aria-valuetext so AT speaks "0.5" not "day 4 of 8".
  • Mobile nav drawer (Nav.svelte:171-191) is role="dialog" aria-modal="false" — the "false" is honest (it doesn't trap focus) but the user can tab out of it to the underlying page, then tab back in awkwardly. No focus trap; no first-link auto-focus.
  • /missions filter pills (+page.svelte:368,377) sit inside role="radiogroup" but the buttons are plain <button>, not role="radio". Arrow-key cycling across radios isn't wired.

4. 3D scene a11y —

This is the headline gap.

Status today:

  • /explore +page.svelte:1916 aria-label={m.explore_canvas_aria_3d()} ("3D solar system. Drag to orbit, scroll or pinch to zoom, tap planets and Sun for details").
  • /explore +page.svelte:1922 2D top-down canvas: aria-label={m.explore_canvas_aria_2d()}.
  • /earth +page.svelte:1026 aria-label={m.earth_canvas_label()}.
  • /fly +page.svelte:??? — verified via grep, the 3D canvas has an aria-label, but the cislunar / heliocentric scene state (current phase, day, distance) is not exposed.
  • /iss and /tiangong — canvas elements carry the same single static label.
  • /moon and /mars — 2D + 3D canvases each have a label.

What's missing:

  • No live region announces scene state changes. When the user clicks Mars on /explore and the camera zooms in, AT announces nothing. When the /fly simulator advances through Departure → Cruise → Approach, AT announces nothing (the identity HUD is role="status" aria-live="polite" per ADR-025 §Live regions but verifying the actual content: it currently carries the phase label only, not "Cruise day 4 of 8, 184 million km from Earth").
  • No parallel non-visual mode. A screen reader user lands on /explore and hears "3D solar system. Drag to orbit…" and is stuck — no list of planets, no way to know the dwarf planets exist, no way to access the sphere-of-influence rings or hover info cards.
  • Test-only window hooks exist (verified via the iss.spec.ts pattern of __pickModule shims) — these are exactly the integration points a screen reader fallback would need.

5. Screen reader announcements —

aria-live appears in exactly 4 places: /explore +page.svelte:2018 (sizes-card status), /fly +page.svelte:3796 (sim status), /missions:420 (loading), /plan:748 (loading). Load failures are role="alert" in 7 places (good).

Decorative SVG aria-hidden="true" is consistently applied — verified across 200+ matches (footer separators, agency badges, icon glyphs inside buttons, panel chevrons, banners — all decorative SVG correctly hidden).

Gaps:

  • Locale change announces nothing — the user swaps /fly from en-US to es and the page silently re-renders in Spanish; no aria-live "Language changed to Español".
  • Layer toggles on /earth (+page.svelte:1068-1123 — Stations / Observatories / Constellations / Comsats / MoonOrbiters / Orbits) flip state but emit no announcement; the user has to infer that the canvas changed.
  • Mission load on /fly (replacing the trajectory + repositioning the camera + updating the Flight Director banner) announces nothing.
  • Science Lens toggle (Nav.svelte:139) flips the entire physics-overlay layer; no announcement.
  • Filter changes on /missions (category / agency / outcome) silently refilter the card grid; no "47 missions visible" live region.

6. Colour contrast —

Brand palette (src/lib/styles/tokens.css):

  • Body text #ffffff on #04040c — 19.8:1 (passes AAA).
  • --color-text-dim: rgba(255,255,255,0.35) on #04040c — ~ 4.0:1 (fails WCAG AA for body text — 4.5 required).
  • --color-text-faint: rgba(255,255,255,0.15) on #04040c — ~ 1.6:1 (fails everything; only used for hairline borders/dividers).
  • Teal accent #4ecdc4 on #04040c — 10.4:1 (passes AAA).
  • Mars red #c1440e on #04040c (used on date-quality pills, mission outcome) — ~ 3.3:1 (passes AA only as large text — fails for ≤ 18 px body).
  • Earth blue #4b9cd3 on #04040c — 6.8:1 (passes AA).

The high-contrast mode (tokens.css:65-95, data-high-contrast='true') bumps text-dim to 0.7 alpha (passes AA) and is wired to a toggle in the Nav (Nav.svelte:151-165) plus prefers-contrast: more. Good infrastructure, but the default — what 99% of users see — has the contrast issue with --color-text-dim body text in dozens of places (mission card metadata, science chip tooltips, panel rail headings, the footer link strip).

Specific failure spots already known:

  • Footer links (+layout.svelte:194 color: rgba(255,255,255,0.6) on rgba(4,4,12,0.55) background): foreground ~ 4.3:1, fails AA body.
  • Dim text on hover cards in the Science Lens overlay (used across explore/earth/moon/mars/fly).
  • Nav link inactive state (Nav.svelte:255 color: rgba(255,255,255,0.28)) — ~ 3.3:1, fails AA.
  • Card descriptions on the landing page (/+page.svelte:1144 color: rgba(255,255,255,0.55)).

7. Colour-as-only-signal —

Multiple confirmed gaps:

  • Porkchop heatmap on /plan +page.svelte — the ∆v cost grid encodes its single most important value in hue (red = expensive, teal = cheap). No isolines, no shape-encoded ranks, no per-cell numeric label until you hover. Colour-blind users (~8% of male, ~0.5% of female) can't read the cheap lobe at all.
  • Mission status pills on /missions and /library cards use colour (FLOWN green / ACTIVE teal / PLANNED amber / FAILED red) — verified via the agency-badge / outcome-pill pattern in MissionPanel.svelte. The pill text is the label, but the colour change is what catches the eye in a grid scan.
  • Agency colour pills (tokens.css:55-62, NASA blue / ESA blue / CNSA red / ISRO orange / Roscosmos dark red / JAXA blue / SpaceX blue) — used in AgencyBadge.svelte. The text label is always present so this passes the strict rule, but two pairs are nearly indistinguishable (NASA / ESA, JAXA / SpaceX) for low-vision users.
  • Layer-active gold border on layer toggles (/earth, /explore, /moon, /mars) — +page.svelte:2204 .chip.active:focus-visible with gold border. State is conveyed by border colour change only; no icon swap, no ✓ glyph.
  • Conic-section detection on /fly (ConicSectionPanel.svelte) uses colour to indicate which arc shape (circle / ellipse / parabola / hyperbola) is currently active; the small inline SVGs help, but the "active" state is hue-only.
  • Anomaly indicators on /fly CAPCOM (+page.svelte:4037 anomaly-{anomalyLevel}) use colour (green/yellow/red). The text label is present so this is passable, but the dot is decorative-only.

8. Motion —

Best-in-class. src/lib/reduced-motion.ts wraps matchMedia('(prefers-reduced-motion: reduce)') cleanly; reduced-motion.test.ts covers SSR + browser paths. Honoured at JS level on /explore (+page.svelte:838-881), /fly (+page.svelte:1672-1713, sim defaults isPlaying=false at 304), /moon (+page.svelte:871-876), /earth (+page.svelte:897-939). CSS catch-all in app.css:79-87 cuts animation/transition durations to 0.01ms globally when the media query matches. ADR-025 explicitly tested manually on macOS. The TimelineNavigator uses it on /library. The hover transition on landing cards has its own @media (prefers-reduced-motion: reduce) block at +page.svelte:1243.

One small gap: the SvelteKit fly: transitions on Panel.svelte:68-69 (in:fly / out:fly) bypass the CSS catch-all because they're Svelte's JS-driven motion API. They should consult prefersReducedMotion() and emit null transitions when reduced.

9. Forms + inputs —

Only 10 form controls across the entire app (grep <input|<select|<textarea returns 10 results in 4 files). Each:

  • ScienceLayersPanel.svelte:168<input> for science layer toggle (checkbox). Needs verification it has a <label> association.
  • ScienceSearch.svelte:131<input type="search" aria-label="Search query"> — has a label. ✓
  • TimelineNavigator.svelte:190, 201 — two <input> year-range fields with aria-label={m.lib_timeline_from()} / _to(). ✓
  • +page.svelte:452, 686, 915 — three <select> dropdowns (mission filters, plan-config destinations, library year filters). All need verification.
  • /fly +page.svelte:3995-4007<input type="range"> for scrubber. Has aria-label, missing aria-valuetext (see Dimension 3 above). The scrubber speaks "0.5" — should speak "day 4 of 8, cruise phase".

LocalePicker is a custom widget (combobox-ish), not a <select> — it carries aria-haspopup="listbox", aria-expanded, role="listbox" / role="option" (LocalePicker.svelte:64-90). Good.

10. Touch / mobile —

ADR-018 § 44 px target. Verified across the toggle buttons (Nav.svelte:280,343,376 all min-height: 44px), play button (/fly:3987 44 px via padding), close buttons (Panel.svelte:140 width:44px; height:44px), CTAs (landing +page.svelte:1039 min-height:44px), Science Chips (24 × 24 wrap — relaxed from 44 per ADR-018, "small inline label adornments"), drawer-link items (12 px padding × font-size).

The new compact-mobile science nav chip strip (commit c346dc0d) sits inside the layout and uses the chip-class targets which inherit the 24×24 wrap. The mobile drawer items (Nav.svelte:412-417) get 12 + 12 + line-height ≈ 38 px — slightly under 44 px. Marginal.

11. RTL + bidi —

Infrastructure exists: RTL_LOCALES = Set<LocaleCode>(['ar']) in src/lib/locale.ts:78, isRtlLocale() exported, syncDocumentLocaleAttributes() sets dir="rtl" on the <html> element when Arabic is active. The footer (+layout.svelte:163 inset-inline-end: 10px) uses logical inset for mirroring. The bottom-sheet panel (Panel.svelte:115-122) uses left: 0; right: 0 (still works in both directions).

Gaps:

  • No CI / E2E test runs against the Arabic locale. grep confirms tests/e2e/ has no rtl|ar.json|dir="rtl" smoke test.
  • The nav uses display: flex + justify-content: space-between (Nav.svelte:201) — the brand goes to start, the toggles to end, which mirrors correctly in RTL. But the link strip in the middle (Nav.svelte:225) iterates in document order — in RTL this reads right-to-left visually but the tab order still goes left-to-right (DOM order). For an Arabic user this means the first thing they hit after the brand is /science, not /explore.
  • The Flight Director banner on /fly narrates "Departure → TLI → Cruise → Approach → Arrival" in linear English order with a left-to-right chevron progress indicator. In Arabic the chevrons should flip; verifying requires a manual RTL test which has never been run.
  • Trajectory arrows in the 3D scene (the engine-off coast preview, the spacecraft heading vector) are spatial, not directional in a linguistic sense — they don't need to flip. Good.
  • No dir="auto" on user-input fields — the ScienceSearch input is type="search" with no dir. When an Arabic user pastes Arabic text it will render LTR. Edge case but legitimate.
  • The compact-mobile science nav (the new chip strip from commit c346dc0d) hasn't been visually verified in RTL.

12. Internationalised announcements —

When the user switches locale, the entire app re-renders but <html lang> is the only lang attribute that updates. Subtrees with mixed-language content carry no <span lang="..."> markers. Examples:

  • Mission IDs ("Apollo 11", "Curiosity", "Chang'e 5") are proper nouns and currently stay in English across all locales. A Russian-locale user lands on the Russian narrative containing "Apollo 11"; VoiceOver speaks "Apollo eleven" with a Russian pronunciation engine, which is wrong. Should be <span lang="en">Apollo 11</span>.
  • Agency names — "NASA", "JAXA", "ROSCOSMOS", "CNSA" — same problem. Acronyms have correct English pronunciation only when announced under lang="en".
  • KaTeX equations (/science) — math is universal but the inline variable names ("v" for velocity, "a" for semi-major axis) get pronounced in the page's lang; in Arabic VoiceOver this is wrong.
  • The agency badges on mission cards print m.mission_agency() which is already translated, but the alongside-mission-ID text isn't wrapped.

13. KaTeX formula a11y —

src/lib/katex.ts:22-28 calls katex.renderToString(latex, { displayMode: true, throwOnError: true, output: 'html', strict: 'warn' }). The crucial argument is missing: output: 'html' produces visual-HTML only, no MathML. KaTeX supports output: 'htmlAndMathml' which emits both a visual .katex-html and an SR-only .katex-mathml <math> element. The MathML is what screen readers read aloud (NVDA via MathPlayer, VoiceOver natively on Apple platforms).

Right now every formula on every /science section is rendered with output: 'html', so AT speaks the raw glyphs in DOM order — "v two equals v two zero plus two a x" instead of a structured math reading. This is a one-line fix in katex.ts and a re-build of the prerendered science pages.

14. Error reporting —

Load-failure banners are role="alert" on every canvas-screen and panel route (/earth +page.svelte:1149, /fly +page.svelte:3786, /iss:853, /mars:1290, /library:418, /moon:1042, /plan:756, /tiangong:809). Good.

Gaps:

  • WebGL-disabled (no fallback path detected). If the user's browser blocks WebGL, the canvas screens render an empty <canvas> with the aria-label — no in-app message.
  • Locale fetch 404 (the Apollo 13 problem fixed in 9176e9de) — currently falls through to en-US silently; no AT announcement.
  • The mission-data caveat banners (RECONSTRUCTED / SPARSE / UNKNOWN) use class="flight-caveat" role="note" (MissionPanel.svelte:336). role="note" is announced by some AT only on explicit navigation, not when the panel opens. Should be inside the live region or announced via aria-describedby from the panel's heading.

15. Page titles + meta —

Every route has a <svelte:head><title>{m.x_page_title()}</title></svelte:head> block — 37 matches across 27 files, all localised through Paraglide. The science [tab] and [section] nested layouts compose titles ("Orbits · Encyclopedia · Orrery", "Hohmann transfer · Encyclopedia · Orrery").

Minor gap: /fleet +page.svelte:321 is hardcoded <title>Fleet — Spaceflight hardware</title> instead of a Paraglide message.

16. Image alt text —

This is the second-biggest gap, after canvas a11y.

Pattern: alt="" on almost every <img> — verified across 60+ matches. The justification is that the image is decorative because the panel header already names the entity ("Apollo 11" appears as <h1 class="name"> in the panel). For sighted users this is fine; for AT users this is hostile — the panel header says "Apollo 11" and then 6-10 unnamed images take up the screen.

Specific gaps:

  • Mission hero / gallery thumbsMissionPanel.svelte:208,641,690 alt="". Should be the photo's caption ("Buzz Aldrin saluting the flag, Sea of Tranquility, 1969-07-20"), which we have in the provenance manifest for every photo. The provenance is currently consumed only as the ImageCredit byline ("NASA / Neil Armstrong, public domain"), not as alt text.
  • Planet hero imagesPlanetPanel.svelte:129,341,357 alt="". Same gap; the provenance manifest has captions.
  • Crew portraitsFleetEntryPanel.svelte:319 alt="" — crew member's name is right there in adjacent text. Could be alt="{member.name} portrait".
  • PatchesFleetEntryPanel.svelte:312 alt="" — could be alt="{flight.name} mission patch".
  • Agency badgesAgencyBadge.svelte:15 alt="" — could be alt={agency.name}. The badge is decorative if the agency name is also rendered as text adjacent; check the parent's pattern.
  • DiagramsFleetEntryPanel.svelte:292 alt="Anatomy diagram for {entry.name}", StationModulePanel.svelte:218 alt="{mod.name} anatomy diagram" — good, both carry meaningful alt. The exception, not the rule.
  • Provenance captions are NOT localised. Even if we wire alt-text from the provenance manifest, every caption is English-only in static/data/image-credits.* files. The localisation pipeline (Paraglide + locale overlays) doesn't currently extend to image captions.

17. Custom widgets (chips / popovers / lightbox) —

Strong:

  • WhyPopover.svelterole="dialog", aria-label={title}, has Escape handler (line 62), opens with aria-haspopup="dialog" on trigger (line 85). Close button focusable. Doesn't trap focus (deliberate, says ADR-025).
  • ScienceSearch.svelte — full dialog semantics, arrow-key roving, Escape, listbox/option pattern, focus moves to input on open.
  • Panel.svelte — focus capture/restore, Escape closes, focus to close button on open.
  • ScienceChip.svelte — plain <a href>, no custom keyboard handling needed, focus ring set.

Gaps:

  • Lightbox in every panel — no focus trap, no Escape (only the parent panel's Escape closes both at once which is wrong UX — user lightbox-opened the image deliberately and wants Escape to close just the lightbox), no role="dialog", no aria-label="Photo of {name}". The lightbox <img alt=""> (MissionPanel.svelte:690, PlanetPanel.svelte:357, etc) carries no description so AT users hear silence.
  • TimelineNavigator scrubber dots+page.svelte:266 "mission dot" buttons inside the timeline track. aria-label="{mission.name}, {mission.year}" is good. But there are dozens of them inside a tight strip; no roving tabindex pattern, so each one steals a tab stop.
  • EpochTimelineStrip.svelte:116-135role="radiogroup" with role="radio" children. Good. But the keyboard handler handleKey (line 93) isn't shown above; needs audit to confirm arrow-key cycling is implemented per the WAI-ARIA radio pattern.
  • Mobile nav drawer (Nav.svelte:171) is role="dialog" aria-modal="false" with no focus trap and no auto-focus to first link. The user has to tab through page chrome to reach the menu they just opened.

18. Auto-tests —

grep axe returns zero matches across the codebase, including in tests/e2e/, package.json, playwright.config.ts. Lighthouse runs in CI (referenced in 12 PRDs and ADRs, all with the "≥ 95" target). There is no automated a11y regression gate beyond Lighthouse, which means:

  • A regression that adds an unlabelled button passes CI as long as Lighthouse still scores 95+ (Lighthouse audits a sample of buttons, not all of them).
  • A regression that breaks RTL passes silently — no RTL test in the playwright suite.
  • A regression that breaks keyboard focus order passes silently.
  • A regression that introduces <div onclick> instead of <button> passes silently.

@axe-core/playwright would catch most of dimensions 1, 2, 6, 9, 16, plus regressions on the focus-management work done in Panel.svelte and the dialog widgets.


Quick-fix shortlist (Tier 1 — 1-2 days each)

Each item is a self-contained PR. Listed in suggested merge order — earlier ones unblock later ones.

  1. Skip-to-content link. Add an <a class="skip-link" href="#main-content">Skip to content</a> as the first child of +layout.svelte, with a :not(:focus) sr-only style so it only appears when keyboard users tab to it. Promote the existing <main> element to id="main-content". ~30 lines including styles.

  2. Per-route h1. Add a visually-hidden or visible h1 to every route currently missing one: /missions, /fleet, /plan, /explore, /fly. Use the localised page title from m.x_page_title(). Wrap in a class="visually-hidden" utility on canvas-heavy screens where a visible h1 would crowd the layout.

  3. KaTeX MathML. Single-line change in src/lib/katex.ts:22-28 — set output: 'htmlAndMathml'. Re-build prerendered science pages. Verify with macOS VoiceOver on one formula per tab (10 spot checks). Resolves Dimension 13 entirely.

  4. Lightbox Escape + dialog semantics. Wrap every lightbox in role="dialog" aria-modal="true" aria-label={photo.caption}. Add a window-level Escape handler that closes only the lightbox (event.stopPropagation so the parent panel doesn't also close). Move focus to the close button on open; restore on close. ~40 lines per panel, but the pattern is identical across MissionPanel / PlanetPanel / SunPanel / SmallBodyPanel / StationModulePanel / FleetEntryPanel — refactor into a Lightbox.svelte component first.

  5. Scrubber aria-valuetext. In /fly +page.svelte:3995-4007, add a derived valuetext = $derived(...) that computes "Day {Math.round(metDays * progress)} of {totalDays}, {phaseLabel} phase" and bind aria-valuetext={valuetext} on the range input. Resolves the headline screen-reader problem for the simulator scrubber.

  6. Layer-toggle live region. Add a single <div role="status" aria-live="polite" class="visually-hidden"> to /earth, /explore, /moon, /mars. Push a message ("Stations layer enabled" / "Stations layer disabled") on each layer-toggle click. Resolves Dimension 5 sub-gap.

  7. High-contrast palette default for --color-text-dim. Bump tokens.css:18 from rgba(255,255,255,0.35) to rgba(255,255,255,0.55) (~ 6.0:1, passes AA). Verify visually across panel rails, footer, science chip tooltips, mission card metadata — likely no perceptible difference. Resolves the largest contrast gap without touching the design.

  8. <button><a> for mission cards. On /missions +page.svelte and /fleet +page.svelte, change the card from <button> to <a href="/missions?id={mission.id}"> so the <h2 class="card-name"> reads naturally and the URL becomes shareable. Wire the panel open via existing URL-param handler.

  9. Nav inactive-link contrast. Bump Nav.svelte:255 from rgba(255,255,255,0.28) to rgba(255,255,255,0.55). Trade-off: the inactive links become more visually present (closer to a "normal" menu), losing some of the "current page is highlighted" effect. Test with the design lead.

  10. Locale change announcement. In +layout.svelte $effect.pre, after setLanguageTag(code), push a message to a hidden live region ("Language changed to {LocaleName}"). 5 lines.

  11. Image alt: easy wins. Crew portraits (FleetEntryPanel.svelte:319), mission patches (line 312), agency logos (AgencyBadge.svelte:15) — wire alt from the adjacent name text. No localisation needed because the names are proper nouns. Resolves ~30% of the alt-text gap.

  12. Lang attribute on proper-noun spans (manual hotfix). Wrap mission IDs and agency acronyms in <span lang="en"> across the Russian and Arabic locale overlay files (smallest set, biggest impact for Cyrillic / Arabic speakers).

  13. Filter pills role="radio". On /missions +page.svelte:368-377, set role="radio" and aria-checked on each pill; add a roving-tabindex pattern with arrow-key handler.

  14. Footer link contrast. Bump +layout.svelte:194 color: rgba(255,255,255,0.6) to rgba(255,255,255,0.75) on the dark backdrop.


Mid-term work (Tier 2 — 3-5 days each)

Each is a focused work-package, larger than a single PR but smaller than a feature.

A. @axe-core/playwright in CI. Add @axe-core/playwright to devDependencies. New file tests/e2e/a11y.spec.ts that visits every route, runs @axe-core/playwright's analyze(), and fails the build on any violation. Include serious + critical severity gating; warnings allowed. Pin a baseline today so we don't break the build immediately; ratchet down. Roughly 4 days including writing the baseline and triaging existing failures (most are likely in dimensions 6 + 16 — contrast + alt text).

B. Image alt-text via provenance manifest. Extend static/data/image-credits.* schema to carry a caption field per image. Update the build-time scripts/build-image-provenance.ts to surface captions. Wire MissionPanel.svelte:208,641,690, PlanetPanel.svelte:129,341,357, and the other 4 panel callsites to read alt={panelGalleryCredit(src).caption ?? ''}. The harder sub-task: caption localisation — Paraglide messages don't currently extend into JSON data files. Solve by either (i) flat-translating captions in a new static/data/i18n/<locale>/image-captions.json overlay, or (ii) translating only the agency / mission name and keeping the descriptive caption in English. Estimate 5 days including caption authoring for ~ 200 images.

C. 3D scene aria-labels — dynamic, not static. Today's labels are constants ("3D solar system. Drag to orbit…"). Make them reactive: on /explore the label reads "3D solar system, 13 bodies, Mars selected"; on /fly it reads "Mission Arc, Apollo 11 day 4 of 8, cruise phase, 184 million km from Earth"; on /iss it reads "ISS, 18 modules, Cupola selected". Use Svelte 5 $derived to bind the label to scene state. About 3 days touching every canvas screen.

D. Lightbox component + dialog refactor. Pull every panel's lightbox into a single Lightbox.svelte with full focus trap, Escape, role="dialog" aria-modal="true", alt text from provenance, prev/next keyboard nav, close-on-backdrop-click. Replace 6 inline copies. Side benefit: smaller bundle.

E. Custom-widget audit + WAI-ARIA conformance. Walk every custom widget — LocalePicker, ScienceLayersPanel, ScienceSearch, EpochTimelineStrip, TimelineNavigator, ScienceLensBanner, FlightDirectorBanner, MicrogravityAxesLegend, StationOrbitBanner — and verify each against the WAI-ARIA Authoring Practices pattern it implements. Fix gaps. Add tests.

F. High-contrast colour-token audit. Run a colour-contrast checker against every token combination used in the app. Generate a report (one row per pair, ratio, pass/fail). Bump or replace each failing pair. Especially: porkchop heatmap palette (Dimension 7) — replace pure-colour encoding with a colour + isoline + numeric label scheme.

G. Site-wide page-title pattern + breadcrumbs. Verify <title> localisation across every route (current state: 95% there); add aria-current="page" on the active nav link (Nav.svelte:113); add a <nav aria-label="Breadcrumb"> on nested routes (/science/[tab]/[section]). Helps AT users navigate deep linking.


Long-term flagship pieces (Tier 3 — 1-2 weeks each)

These are the demo-grade features that put a11y on par with i18n.

X. Non-visual parallel mode for every canvas screen. Behind a "List view" toggle (or auto-enabled when a screen reader is detected via window.matchMedia('(prefers-reduced-motion: reduce)') heuristic, or always-on for AT), render a parallel DOM tree alongside each canvas:

  • /explore<ul aria-label="Solar system bodies"> with 13 <li> children, each <button> opening the same detail panel: "Mercury, 0.39 AU from Sun, 47.4 km/s, period 88 days". Live-update via the same data the canvas reads.
  • /fly<section aria-label="Mission state"> with the current phase, day, distance, signal delay, all derived from the simulator's tick. Plus a "Trajectory milestones" list of past + upcoming events.
  • /missions — already mostly there; the card grid is keyboard-tabbable. Add <dl> semantic markup to each card's metadata block.
  • /moon, /mars<ul aria-label="Landing sites"> with each site as a <button> opening the panel.
  • /iss, /tiangong<ul aria-label="Modules"> with each of the 18 / 4 modules as a <button>.
  • /earth<ul aria-label="Earth-orbit bodies"> grouped by category (Stations / Observatories / Constellations).

This is the architectural move. Two weeks for /explore + /fly + /moon + /mars (the four screens where it matters most); a third week for /iss + /tiangong + /earth.

Y. Narrated mission playback on /fly. Wire a role="log" aria-live="polite" region that emits structured phase-tick narration as the simulator advances: "CRUISE day 4 of 8, 184 million km from Earth, 10.2 minutes one-way signal delay, ∆v margin 1.4 km/s". One emission per phase change; throttled in CRUISE to avoid flooding. Localised through Paraglide so a Spanish-locale user hears "CRUCERO día 4 de 8…". About a week.

Z. Full RTL keyboard + visual pass. Hire (or arrange a contributor) to run through the entire app in Arabic, with a screen reader, on macOS + Windows + iOS + Android. Document every focus-order error, every untranslated tooltip, every flipped icon that shouldn't be flipped (and every un-flipped one that should be). Triage into PRs. About two weeks with a real RTL-native tester; one week of follow-up fixes.

W. Reduced-motion respect on Svelte transitions. Walk every transition:fly, transition:fade, transition:slide in the codebase and wrap them in a prefersReducedMotion() ? null : { duration: 200 } helper. Eliminates the last remaining motion that bypasses the CSS catch-all. ~1 week including writing the helper, refactoring callsites, and testing.

V. Caption / alt-text i18n pipeline. Make image captions, provenance fields, and any other JSON-data strings first-class citizens in the Paraglide pipeline alongside messages/*.json. This unblocks Tier 2 item B and goes further — every alt text, every caveat-banner copy, every panel-section heading currently in JSON data files becomes localisable. About 2 weeks including authoring + reviewer workflow.


Proposed issue #43 retitle + body

Title: a11y(flagship): demo-grade accessibility — re-scope #43 from "Lighthouse 95" to a v0.7 release theme

Body:

markdown
## Re-frame

Orrery ships best-in-class i18n: 14 locales, native scripts (Cyrillic, Devanagari,
CJK, Arabic), runtime locale switching, RTL `dir` attribute, KaTeX rendered at
build time, locale-canonicalising URLs. Accessibility today sits at ADR-025 tier-1
("don't trap anyone, honour reduced-motion, label canvases honestly") with the
interesting work deferred. That was right for v0.1; for v0.7 we want a11y on par
with the i18n bar.

This issue is now the parent epic for a flagship a11y push. Lighthouse ≥ 95
remains a sanity gate, not the goal.

## Audit

Full file-level gap analysis: `docs/wip/a11y-gap-2026-05-15.md` (18 dimensions,
70+ specific citations). Headlines:

- **3D scenes are opaque to screen readers**`/explore`, `/fly`, `/iss`,
  `/tiangong`, `/moon`, `/mars`, `/earth` carry a single static canvas label
  and no parallel DOM mode. (Dimension 4.)
- **Image alt is empty almost everywhere** — 60+ `<img alt="">` callsites across
  every panel. Per-image provenance manifest has captions; we just don't wire
  them. (Dimension 16.)
- **KaTeX renders HTML-only** — every formula on `/science` is read glyph-by-glyph
  by VoiceOver because `output: 'html'` skips the MathML branch. One-line fix.
  (Dimension 13.)
- **Colour-as-only-signal on porkchop, status pills, layer-active borders.**
  Colour-blind users can't read the ∆v map. (Dimension 7.)
- **`<input type="range">` scrubber speaks "0.5" instead of "day 4 of 8"** — no
  `aria-valuetext`. (Dimension 9 / 3.)
- **No axe-core in CI** — only Lighthouse, which catches ~30% of WCAG failures.
  (Dimension 18.)
- **RTL has never been keyboard-tested.** Infrastructure exists; verification
  doesn't. (Dimension 11.)
- **Default body-dim text fails WCAG AA contrast.** (Dimension 6.)

## Sequencing

**Phase 1 — Tier 1 quick fixes (v0.7.0, ~2 weeks).** 14 PR-sized items. Skip
links, per-route h1, KaTeX MathML, lightbox Escape + dialog, scrubber valuetext,
live regions for layer toggles + locale change, contrast bumps on text-dim / nav /
footer, image alt for crew portraits + patches + logos, lang spans on proper
nouns. Closes Dimensions 1 (partial), 5, 6 (partial), 13, 17 (partial).

**Phase 2 — Tier 2 mid-term (v0.8.0, ~6 weeks).** axe-core in CI, image alt via
provenance manifest with caption localisation, dynamic 3D scene aria-labels,
shared Lightbox component, custom-widget WAI-ARIA conformance audit, full colour
contrast token audit. Closes Dimensions 2, 6, 7 (partial), 16, 17, 18.

**Phase 3 — Tier 3 flagship (v0.9 → v1.0, ~6 weeks).** Non-visual parallel mode
for every canvas screen, narrated mission playback on `/fly`, full RTL keyboard +
visual pass (with a native Arabic-speaking tester), Svelte-transition reduced-
motion respect, caption i18n pipeline. Closes Dimensions 4, 7, 11, 12, 16.

## Success criteria (v0.7 — the a11y release)

- `@axe-core/playwright` runs in CI on all primary routes, zero serious/critical
  violations.
- Lighthouse a11y ≥ 95 on every route (current floor).
- Manual macOS VoiceOver pass on `/explore`, `/fly`, `/science/<one section per
  tab>`, `/missions`, `/iss` — every interactive element reachable + labelled.
- Manual Arabic-locale focus-order pass on `/missions` + landing + `/iss`.
- No `<div onclick>` regressions (ESLint rule, currently passing).
- KaTeX MathML emitted for 100% of `/science` formulas.

## Out of scope (v0.7)

- Full screen-reader description of 3D-scene contents (deferred to phase 3).
- Caption translation into all 14 locales (deferred to phase 3).
- Sign-language video (out of scope entirely).

## Related ADRs

- ADR-025 — Accessibility tier-1 contract (this issue raises the bar)
- ADR-018 — 44 px touch targets (keep as is)
- ADR-029 — PWA service worker + high-contrast toggle (keep as is)
- RFC-005 — Accessibility approach (this issue closes the "tier 2" follow-up)

Sequencing recommendation

Land in this order during v0.7 cycle:

Week 1 — foundation PRs. PR 1 (skip link) → PR 2 (per-route h1s) → PR 4 (Lightbox component refactor + dialog semantics) → PR 3 (KaTeX MathML). These are independent and small; do them first to build momentum and clear the "Lighthouse score" easy wins. After this, every route has a proper landmark structure and an h1.

Week 2 — live regions + contrast + scrubber. PR 5 (scrubber valuetext) → PR 6 (layer-toggle live region) → PR 7 (text-dim contrast) → PR 9 (nav contrast) → PR 14 (footer contrast) → PR 10 (locale-change announcement). After this, the biggest screen-reader gap (scrubber) is closed and the contrast issue is resolved without design churn.

Week 3 — semantic + form work. PR 8 (<button><a> for cards) → PR 11 (image alt easy wins) → PR 13 (filter pills role="radio") → PR 12 (lang spans on proper nouns). Semantic cleanup; modest scope but raises the AT experience meaningfully.

Week 4 — axe-core in CI + baseline. Tier 2 item A. Adds the regression gate that protects everything above. Pin the baseline today so the build doesn't go red; ratchet down across v0.7.x patches.

Declare v0.7 — the a11y release — feature-complete at the end of week 4 once axe-core is green at the new baseline + manual VoiceOver pass clears on the 5 priority routes. Tag v0.7.0.

v0.8 (~6 weeks later) picks up Tier 2 items B (alt via provenance with i18n) + C (dynamic canvas labels) + E (widget audit) + F (full token contrast audit). Tier 3 items X (parallel DOM mode) and Y (narrated playback) start in v0.8 and finish in v0.9. Tier 3 item Z (RTL pass) lands in v0.9 alongside item W (Svelte-transition motion respect).

v1.0 ships with everything in this report closed. That's the "demo-grade a11y on par with i18n" bar: 14 locales, full keyboard navigation including canvases, MathML-rendered formulas, axe-clean CI, RTL-verified, narrated 3D scenes for non-sighted users, captioned imagery in every supported language.


Orrery · a11y gap analysis · 2026-05-15 · companion to ADR-025

Orrery — architecture documentation · MIT · No tracking