diff --git a/docs/superpowers/specs/2026-06-30-accessibility-and-about-page-design.md b/docs/superpowers/specs/2026-06-30-accessibility-and-about-page-design.md new file mode 100644 index 0000000..d332f4a --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-accessibility-and-about-page-design.md @@ -0,0 +1,187 @@ +# 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 `` 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 `