← back to Dead Agentabrams

docs/TK-11101-perf-a11y-proposal.md

75 lines

# TK-11101 — `room/index.html` Performance + Accessibility Proposal

**Author:** dead-t3 · **For:** dead-orb (owner of `room/index.html`) · **Date:** 2026-09-02
**Status:** PROPOSAL — a reviewed unified diff is attached; **nothing has been applied to `room/index.html`.**
**Cost:** $0 (all local — python static server + local headless Chrome via Playwright; no paid API except one codex sanity-pass ~$0.02).

---

## How this was measured

- Served the repo locally (`python3 -m http.server`, `127.0.0.1:8790`) — **never deployed**.
- Drove the page in headless Chrome (Playwright) against `http://127.0.0.1:8790/room/index.html`.
- Ran the **`a11y-check`** skill checklist over the dock/controls + canvas layer.
- Built the fix on a **copy**, re-served it, and smoke-tested the patched build (0 JS errors).
- Ran the findings past **codex (gpt-5.3-codex)** — confirmed valid, no false positives.

The three animated layers are three stacked full-screen `<canvas>`: `#sky` + `#scene` (2D) and `#orb` (Three.js/WebGL). There are **three independent `requestAnimationFrame` loops** running: the 2D `frame()`, the WebGL orb `loop()`, and the audio `sampleLevel()`.

### Empirical results
| Probe | Result |
|---|---|
| Idle FPS (headless, this machine) | ~72 fps, worst frame **14.9 ms** — headroom *on a fast Mac*; the concern is low-end/battery + the correctness bugs below |
| Canvas count / sizing | 3 × full-screen canvases, `devicePixelRatio` capped at **2** → on a Retina display that is ~3× (2400×2216) buffers |
| 2D `frame()` while **paused AND reduced-motion ON** | **Still redrawing** — `scene` pixels changed across 450 ms (full pipeline runs every frame) |
| WebGL orb vs reduced-motion | orb `loop()` contains **zero** reference to `S.reduced`/`S.playing` → renders WebGL every frame regardless |
| `sampleLevel()` audio loop | `_looping` is set `true` once and **never cleared** → keeps sampling every frame after "React to Music" is turned off / audio paused |

---

## Findings & fixes (all in the attached patch unless noted)

### P1 — WebGL orb ignores Reduced Motion  *(WCAG 2.3.3 · perf/battery)* — **HIGH**
The orb's own rAF loop spins + re-renders WebGL every frame even when the visitor has Reduced Motion on. The orb IIFE is a separate closure and can't see the visual `S` state.
**Fix (EDIT B + C):** the 2D loop publishes `window.SRM.reduced` / `window.SRM.playing` (SRM is already the cross-script bridge); the orb loop renders **one calm static frame then early-returns** while reduced. Stops the most prominent 3D motion *and* the WebGL draw cost for reduced-motion users.

### P2 — Audio analysis loop never stops  *(perf)* — **MEDIUM**
`sampleLevel()` recurses via rAF forever once React-to-Music is first enabled — it keeps calling `getByteFrequencyData()` every frame after the feature is toggled **off** or audio is paused.
**Fix (EDIT D1 + D2):** early-return + `_looping=false` when `!SRM.react || audio.paused`; the react button and the audio `play` event restart it. Net: the analyser loop only runs while it is actually analyzing.

### P3 — 2D `frame()` never idles  *(perf)* — **MEDIUM — recommendation only, NOT patched**
Confirmed empirically: the full draw pipeline (3 canyon layers + river + boat + venue + motifs) redraws every frame even while paused + reduced, purely to animate the one intended idle "backflip bear."
**Deferred on purpose** (codex agreed): a safe fix (dirty-flag or a reduced frame-rate path under reduced-motion) interacts with the idle-bear intent and wants dead-orb's judgment. Recommended follow-up: when `S.reduced`, throttle the 2D loop to ~30 fps, or gate the heavy background layers behind a `dirty` flag so a static scene isn't repainted at 60 fps.

### A1 — Broken `listbox` / `tablist` ARIA patterns  *(WCAG 4.1.2 / 2.1.1)* — **HIGH**
`#mrMap` is `role="listbox"` with child `<button role="option">`, and `#mrSrc` is `role="tablist"` with `role="tab"` buttons — but there is **no roving tabindex, no arrow-key navigation, no `aria-activedescendant`, and no tabpanels**. Screen readers announce a widget contract the page doesn't honor.
**Fix (EDIT E1–E5):** downgrade both to `role="group"` and make the source buttons proper `aria-pressed` toggle buttons; drop the phantom `role="option"` on the year tiles. They are already real focusable `<button>`s, so **Tab access and Enter/Space activation are unchanged** — only the misleading roles are removed. (Full listbox/tablist keyboard could be implemented instead, but "no-pattern listbox" is worse than plain grouped buttons — codex concurred.)

### A2 — `prefers-reduced-motion` CSS leaks  *(WCAG 2.3.3)* — **MEDIUM**
The `@media (prefers-reduced-motion: reduce)` block only killed `transition` on 4 selectors. The infinite `@keyframes pulse` (playing live-dot) and `mrPulse` (loading year tiles) keep animating.
**Fix (EDIT A):** add the standard reduced-motion animation reset + explicit `animation:none` on the two known infinite loops.
*Codex caveat:* a global `animation-duration:.001ms` can surprise external animation libraries — this file has none (only Three.js via rAF), so it's safe here, but dead-orb may prefer to keep only the two targeted `animation:none` rules if that feels safer.

### A3 — Reduced-motion not re-checked at runtime  *(nicety)* — **LOW**
OS Reduced-Motion is read once at load. **Fix (EDIT F):** a `matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', …)` keeps `S.reduced` + the button's `aria-pressed` in sync if the visitor flips the OS setting mid-session.

### Notes (no patch — dead-orb's call)
- **Duplicate `<h1>`:** one `.sr` `<h1>` (line 259) + one `aria-hidden` visual `<h1>` (line 269). The a11y tree sees one, so it's benign, but it's a validation smell.
- **Contrast:** `--ink-dim` (#c9a98c) on the near-black panels estimates ~7–8:1 (passes AA); worth a quick axe confirm on the 10 px uppercase labels.
- **`#mrResults` / `#mrTracks` `role="list"` with `<button>` children:** lesser issue (buttons aren't `listitem`); left alone to keep the patch tight.
- The console errors seen in testing are all blocked `archive.org` fetches (the sandbox has no external network) — **not code defects.**

---

## Apply / verify (for dead-orb)

```bash
cd ~/Projects/dead-agentabrams
git apply --check docs/TK-11101-perf-a11y.patch   # dry-run — verified clean by dead-t3
git apply docs/TK-11101-perf-a11y.patch           # apply when ready
```

**Patch:** `docs/TK-11101-perf-a11y.patch` — 11 hunks, applies cleanly, `room/index.html` currently untouched.
**Smoke-tested patched build:** 0 JS errors; all controls wired; SRM reduced/playing bridge live; roles corrected; `m` keyboard shortcut still toggles reduced motion.
Local-only. Any deploy / DNS / publish stays Steve-gated.