# Accessibility pass + About page — Solution Design

**Date:** 2026-06-30
**Branch:** `feat/accessibility-pass` (off `design/cloudflare-static-publish`)
**Status:** Draft — pending operator review

## Problem

The simulator was built for an attended **kiosk**: a known display, a person
nearby, no assistive technology in the loop. The Cloudflare static publish
(`design/cloudflare-static-publish`) puts the same experience on a public URL
(`benstull.art`) where none of those assumptions hold. That raises two bars at
once:

- **Legal/usability** — a public site is expected to meet WCAG 2.1 AA; today the
  primary navigation control (the Altitude dial) is pointer-only, several text
  colors fail contrast, and there is no screen-reader path into a piece whose
  *meaning* is already textual.
- **Physical safety** — the piece is continuous motion (video morphs, auto-scrub,
  crossfades, a "trippy at max" WebGL shader, black-cover fades) with no
  `prefers-reduced-motion` support and no photosensitivity safeguard. Unattended,
  that is a vestibular and seizure risk.

This work makes the *experience* (not just the chrome) usable away from the
kiosk, and adds an **About** page explaining what the piece is for.

## Goals

1. Honor reduced-motion and add seizure safety (the public-web-specific layer).
2. Make every control keyboard- and screen-reader-operable.
3. Fix low-vision contrast.
4. Add `about.html` — the project's intent and provenance.
5. A small Output-panel layout fix (language picker placement).

**Non-goals:** no engine/Python changes; no new languages (about page is
English-first, i18n-keyed for later); no RTL; no redesign of the visual art.
The `<html lang>` switch is **already implemented** (`applyUiStrings`,
`app.js:1325`) and is out of scope.

## Target

WCAG 2.1 **AA** as the baseline, plus a motion/seizure layer (WCAG 2.3.1 flash
threshold + 2.3.3 animation-from-interactions) on top, since those are the risks
that genuinely change when the piece leaves the kiosk.

## Design

All changes are client-side, in `simulator/static/`. Seven units.

### A1. Reduced-motion: freeze-to-stills

A single `reduceMotion` state, default-on when
`window.matchMedia('(prefers-reduced-motion: reduce)').matches`, plus a visible
toggle in the Output fieldset (so a visitor whose OS setting disagrees can
override either way). Persisted in `localStorage` (mirrors the existing `devMode`
pattern, `app.js:934`).

When `reduceMotion` is **on**:

- **Video holds a frame.** Pause `#vid` / `#vid-loop` (do not call `playLoop()` /
  the `.play()` paths at `app.js:260,288`). The Kuwahara paint loop keeps running
  but composites a *static* frame, so knob changes (mood grade, dream, labels)
  still re-render — the image responds, it just doesn't animate on its own.
- **Transitions are instant, not animated.** `autoScrub()` (`app.js:678`) jumps
  `pos` straight to the target (one assignment + a single settle render) instead
  of driving the rAF tween. Dial drag still scrubs live under the finger (that is
  a direct-manipulation gesture, not autonomous motion — allowed under 2.3.3),
  but on release it settles without a spin.
- **Auto-scrub speed coupling is disabled** (the constant per-altitude auto-spin).

The toggle flips state live (no reload): turning it off resumes `playLoop()`;
turning it on pauses and holds.

### A2. Photosensitivity: warning gate + flash audit

- **Warning gate.** A one-time interstitial over the stage, shown with the
  existing `#run-sim` flow before the experience begins: a short "contains motion
  and flashing effects" notice with a **Continue** action. Dismissal is
  remembered in `localStorage` so it shows once per visitor, not every load. It
  reuses the run-sim z-layer (above the black cover) and does not block the rest
  of the page (controls remain reachable).
- **Flash audit.** Review the three motion sources that can produce rapid
  luminance swings — the fast-spin blended dial pass, the `#black` cover fades
  (`app.js`/`style.css`), and audio-coupled crossfades — and clamp any that can
  exceed **3 transitions/second** (WCAG 2.3.1). The clamp math (min transition
  duration given a luminance delta) is a **pure function** in a small module so it
  is unit-testable; the audit findings and any clamps are recorded in the
  implementation plan.

### B. Keyboard + focus

- **Dial as a real slider.** The `#dial` SVG gets `role="slider"`, `tabindex="0"`,
  and live `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext`
  (the human scale name, e.g. "reef"). A `keydown` handler maps:
  - `ArrowDown` / `ArrowRight` → descend one altitude (`+1`, matching wheel-down).
  - `ArrowUp` / `ArrowLeft` → ascend one altitude (`-1`).
  - `Home` → cosmos (index 0); `End` → the deepest scale.
  - Each step calls the existing `autoScrub` / `jumpToScale` path (so reduced-motion
    instant-jump is inherited for free).
- **Dial labels become buttons.** The tap-to-jump `.dial-label` nodes get
  `role="button"` + `tabindex="0"` + Enter/Space activation, reusing `jumpToScale`.
- **Visible focus everywhere.** A global `:focus-visible` outline rule (today only
  `.dev-switch` has one).

### C. Low-vision contrast

Bump the failing dark-on-dark text to meet AA (4.5:1 for body, 3:1 for large):
the panel `.hint` (`#789`), `.dial-caption` (`#4d6184`), `.dial-label` (`#789ac0`),
and any others a contrast check flags on the `#111` / `#0d1320` backgrounds.
Verify the 280px panel reflows and text scales to 200% without clipping.

### D. Screen-reader basics

- Ensure every control has an accessible name (sliders via their `<label>`; the
  dial via `aria-valuetext`; the new toggle labeled).
- **Narrate the alteration.** A visually-hidden `aria-live="polite"` region
  announces the current scale and the active clip's top factual (left-brain)
  label as the altitude/knobs change — turning the visual alteration into words.
  Updates are throttled/debounced so a drag doesn't flood the queue.
- The decorative HUD/affect SVG layers get `aria-hidden="true"`.

### E. about.html

A standalone page mirroring `credits.html` exactly (same `credits-page` /
`credits-wrap` styles, a `← Back to the experience` link, `config.js` loaded).
English prose, structured so it can be i18n-keyed later. Linked from the header
beside the existing Credits link (`index.html:18`). Narrative beats:

- **For everyone.** The piece aims to be accessible to, and representative of,
  the full range of human experience — which is *why* this accessibility work
  exists.
- **Vantage points technology gave us.** The scales — deep-sea, coast, sky,
  drone/aerial, orbit, cosmos — are places no unaided human could witness. We can
  share them only because technology evolved to reach them: **underwater
  exploration → drones → space exploration.**
- **Built with LLMs.** The work itself was built using large language models — an
  extension of that same arc of tools extending human reach.
- **Honest about its limits.** This implementation is **imperfect** — but it is
  more than the author could make alone; better *with* LLMs assisting than by a
  single human hand. The imperfection is part of the point: tools extend us, they
  don't replace the human behind them.

### F. Layout fix (Output fieldset)

Move the language picker **below** the Audio control (currently it sits above
Video/Audio). Put the globe `🌐` inline to the **left** of the `<select>` via a
flex row — today `.lang-pick` has no CSS, so the full-width `select` rule
(`style.css` `select { width: 100% }`) pushes the globe onto its own line above.
Add a `.lang-pick { display:flex; align-items:center; gap }` rule and let the
select flex to fill the rest.

## Components / files

| File | Change |
| --- | --- |
| `index.html` | Reduced-motion toggle; warning-gate markup; aria-live region; dial a11y attrs; move lang picker below Audio; about-page header link |
| `style.css` | `.lang-pick` flex; contrast bumps; `:focus-visible`; visually-hidden util; warning-gate styles |
| `app.js` | `reduceMotion` state + freeze logic; dial keyboard handler + aria-value sync; label buttons; aria-live narration; gate dismissal |
| `flash.js` *(new, pure)* | Flash-clamp helper (min duration given luminance delta), UMD like `i18n.js`/`credits.js` |
| `i18n.js` | New UI keys for the toggle, warning gate, about link (EN populated; other langs may fall back) |
| `about.html` *(new)* | The page above |
| `tools/build_static.py` | Ensure `about.html` + `flash.js` are copied into `dist/` (verify the static build includes new static files) |

## Testing

- **Node unit tests** for the pure flash-clamp helper (`flash.js`) — boundary
  cases around the 3/sec threshold.
- **Playwright e2e** (extends the existing suite): reduced-motion toggle pauses
  video and makes a dial step instant; dial is keyboard-focusable and
  Arrow/Home/End change the scale; the warning gate appears and dismisses once;
  `about.html` loads and its back-link returns to `index.html`; the language
  picker renders below Audio with the globe inline.
- **Manual/automated contrast check** on the bumped colors.
- Existing suites stay green: the current Playwright specs, node `--test`, and
  `pytest`.

## Risks / open questions

- **Freeze-to-stills + WebGL.** Need to confirm the paint loop still composites a
  paused-video frame (some browsers stop delivering frames to WebGL from a paused
  `<video>`); fallback is to draw the last frame once to the canvas and stop the
  loop. Resolved during implementation.
- **Flash audit is the fuzziest unit** — the clamp is mechanical, but deciding
  which existing transitions actually breach 3/sec needs measurement; the plan
  will enumerate them explicitly rather than hand-wave.
- E2E in this environment starts uvicorn via `python` (only `python3` exists) —
  start the server by hand and use `reuseExistingServer`, per project memory.
