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-115wraps 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|skipNavreturns 0 matches across the codebase. Keyboard users land on the 14-link nav strip on every navigation. /landingships 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-135h1 then h2 immediately, then h3 buried in mission cards),/missionsjumps 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:500use<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 carryalt=""(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-1370is 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.svelteflight 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-4078use<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-39does focus management: capture previously focused element, move focus to close button on open, restore on close. Escape closes.ScienceSearch.svelte:85-110has 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 (/flyif 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, norole="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-4007is a native<input type="range">(good — fully keyboard-accessible) but missingaria-valuetextso AT speaks "0.5" not "day 4 of 8". - Mobile nav drawer (
Nav.svelte:171-191) isrole="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. /missionsfilter pills (+page.svelte:368,377) sit insiderole="radiogroup"but the buttons are plain<button>, notrole="radio". Arrow-key cycling across radios isn't wired.
4. 3D scene a11y — ✗
This is the headline gap.
Status today:
/explore +page.svelte:1916aria-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:19222D top-down canvas:aria-label={m.explore_canvas_aria_2d()}./earth +page.svelte:1026aria-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./issand/tiangong— canvas elements carry the same single static label./moonand/mars— 2D + 3D canvases each have a label.
What's missing:
- No live region announces scene state changes. When the user clicks Mars on
/exploreand the camera zooms in, AT announces nothing. When the/flysimulator advances through Departure → Cruise → Approach, AT announces nothing (the identity HUD isrole="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
/exploreand 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
__pickModuleshims) — 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
/flyfrom en-US to es and the page silently re-renders in Spanish; noaria-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
#ffffffon#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
#4ecdc4on#04040c— 10.4:1 (passes AAA). - Mars red
#c1440eon#04040c(used on date-quality pills, mission outcome) — ~ 3.3:1 (passes AA only as large text — fails for ≤ 18 px body). - Earth blue
#4b9cd3on#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:194color: rgba(255,255,255,0.6)onrgba(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:255color: rgba(255,255,255,0.28)) — ~ 3.3:1, fails AA. - Card descriptions on the landing page (
/+page.svelte:1144color: 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
/missionsand/librarycards use colour (FLOWNgreen /ACTIVEteal /PLANNEDamber /FAILEDred) — verified via the agency-badge / outcome-pill pattern inMissionPanel.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 inAgencyBadge.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-visiblewith 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
/flyCAPCOM (+page.svelte:4037anomaly-{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 witharia-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. Hasaria-label, missingaria-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.
grepconfirmstests/e2e/has nortl|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
/flynarrates "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 — theScienceSearchinput istype="search"with nodir. 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 viaaria-describedbyfrom 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 thumbs —
MissionPanel.svelte:208,641,690alt="". 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 theImageCreditbyline ("NASA / Neil Armstrong, public domain"), not as alt text. - Planet hero images —
PlanetPanel.svelte:129,341,357alt="". Same gap; the provenance manifest has captions. - Crew portraits —
FleetEntryPanel.svelte:319alt=""— crew member's name is right there in adjacent text. Could bealt="{member.name} portrait". - Patches —
FleetEntryPanel.svelte:312alt=""— could bealt="{flight.name} mission patch". - Agency badges —
AgencyBadge.svelte:15alt=""— could bealt={agency.name}. The badge is decorative if the agency name is also rendered as text adjacent; check the parent's pattern. - Diagrams —
FleetEntryPanel.svelte:292alt="Anatomy diagram for {entry.name}",StationModulePanel.svelte:218alt="{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.svelte—role="dialog",aria-label={title}, has Escape handler (line 62), opens witharia-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", noaria-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-135 —
role="radiogroup"withrole="radio"children. Good. But the keyboard handlerhandleKey(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) isrole="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.
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-onlystyle so it only appears when keyboard users tab to it. Promote the existing<main>element toid="main-content". ~30 lines including styles.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 fromm.x_page_title(). Wrap in aclass="visually-hidden"utility on canvas-heavy screens where a visible h1 would crowd the layout.KaTeX MathML. Single-line change in
src/lib/katex.ts:22-28— setoutput: 'htmlAndMathml'. Re-build prerendered science pages. Verify with macOS VoiceOver on one formula per tab (10 spot checks). Resolves Dimension 13 entirely.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 aLightbox.sveltecomponent first.Scrubber
aria-valuetext. In/fly +page.svelte:3995-4007, add a derivedvaluetext = $derived(...)that computes "Day {Math.round(metDays * progress)} of {totalDays}, {phaseLabel} phase" and bindaria-valuetext={valuetext}on the range input. Resolves the headline screen-reader problem for the simulator scrubber.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.High-contrast palette default for
--color-text-dim. Bumptokens.css:18fromrgba(255,255,255,0.35)torgba(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.<button>→<a>for mission cards. On/missions +page.svelteand/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.Nav inactive-link contrast. Bump
Nav.svelte:255fromrgba(255,255,255,0.28)torgba(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.Locale change announcement. In
+layout.svelte $effect.pre, aftersetLanguageTag(code), push a message to a hidden live region ("Language changed to {LocaleName}"). 5 lines.Image alt: easy wins. Crew portraits (
FleetEntryPanel.svelte:319), mission patches (line 312), agency logos (AgencyBadge.svelte:15) — wirealtfrom the adjacentnametext. No localisation needed because the names are proper nouns. Resolves ~30% of the alt-text gap.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).Filter pills
role="radio". On/missions +page.svelte:368-377, setrole="radio"andaria-checkedon each pill; add a roving-tabindex pattern with arrow-key handler.Footer link contrast. Bump
+layout.svelte:194color: rgba(255,255,255,0.6)torgba(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:
## 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