# Accessibility Pass + About Page Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make the simulator usable away from the attended kiosk — reduced-motion, seizure safety, keyboard/screen-reader access, AA contrast — and add an English-first `about.html`.

**Architecture:** All client-side in `simulator/static/`. One new pure helper (`flash.js`, UMD + node tests). One new page (`about.html`) mirroring `credits.html`. Behavior changes live in `app.js`; chrome/contrast in `style.css` and `index.html`. The Python static build (`tools/build_static.py`) copies the new static files.

**Tech Stack:** Vanilla JS (no framework, UMD modules like `i18n.js`/`credits.js`), CSS, Playwright e2e (`simulator/e2e/`), `node --test` for pure helpers, FastAPI dev server for e2e fixtures.

## Global Constraints

- No engine/Python behavior changes — only `tools/build_static.py`'s copy list is touched.
- New JS modules follow the existing **UMD** pattern (browser global + `module.exports`), like `i18n.js` and `credits.js`.
- About page is **English-first**; i18n keys may be EN-only and fall back via `pickUiString` (returns `v.en`).
- Target **WCAG 2.1 AA** (4.5:1 text / 3:1 large) plus WCAG 2.3.1 (≤3 flashes/sec) and 2.3.3 (no autonomous animation under reduced-motion).
- Persist user toggles in `localStorage`, mirroring `DEV_KEY = "hef.devMode"` (`app.js:918`).
- Reduced-motion toggle key: `RM_KEY = "hef.reduceMotion"`. Warning-gate dismissal key: `WARN_KEY = "hef.motionWarnDismissed"`.
- `<html lang>` switching already exists (`applyUiStrings`, `app.js:1325`) — do NOT re-implement.
- E2E in this env: start uvicorn by hand with the venv `python` (only `python3` exists on PATH) and run Playwright with `reuseExistingServer`. `loop-recovery.spec.ts` is known-red on a clean baseline (boots without video).
- Direction convention: descend = `+1` (cosmos→abyss), matching wheel-down (`app.js:703`).

---

### Task 1: Output-panel layout — language picker below Audio, globe inline-left

**Files:**
- Modify: `simulator/static/index.html:37-51` (Output fieldset)
- Modify: `simulator/static/style.css` (add `.lang-pick` rule)
- Test: `simulator/e2e/tests/a11y.spec.ts` (new)

**Interfaces:**
- Produces: the `#lang-select` element ends up after `#audio` in DOM order; `.lang-pick` is a flex row.

- [ ] **Step 1: Write the failing e2e test**

Create `simulator/e2e/tests/a11y.spec.ts`:

```ts
import { test, expect } from "@playwright/test";

test("language picker sits below the Audio control", async ({ page }) => {
  await page.goto("/");
  const audio = page.locator("#audio");
  const lang = page.locator("#lang-select");
  await expect(audio).toBeVisible();
  await expect(lang).toBeVisible();
  const aBox = await audio.boundingBox();
  const lBox = await lang.boundingBox();
  expect(lBox!.y).toBeGreaterThan(aBox!.y); // lang is rendered lower than audio
});

test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
  await page.goto("/");
  const pick = page.locator(".lang-pick");
  const select = page.locator("#lang-select");
  const pBox = await pick.boundingBox();
  const sBox = await select.boundingBox();
  // select starts to the right of the label's left edge, and shares its row (height ~ one line)
  expect(sBox!.x).toBeGreaterThan(pBox!.x);
  expect(pBox!.height).toBeLessThan(40);
});
```

- [ ] **Step 2: Run it to verify the first test fails**

Start the dev server (separate shell, from `simulator/`):
`../.venv/bin/python -m uvicorn server.app:app --port 8000` (adjust to the project's venv/module path).
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "below the Audio"`
Expected: FAIL (lang currently renders above audio).

- [ ] **Step 3: Move the language picker in `index.html`**

In the Output `<fieldset>`, delete the `<label class="lang-pick">…</label>` block from its current spot (above the Video toggle) and re-insert it as the LAST child of the fieldset, after the Audio `<label class="audio-level">`. Result order: legend → Video toggle → Audio level → language picker.

- [ ] **Step 4: Add the `.lang-pick` flex rule in `style.css`**

Add near the other panel rules:

```css
/* Globe + language select on one row (the select no longer goes full-width here). */
.lang-pick { display: flex; align-items: center; gap: 0.4rem; margin: 0.4rem 0; }
.lang-pick select { flex: 1; width: auto; }
```

- [ ] **Step 5: Run both layout tests to verify pass**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "language picker|globe"`
Expected: PASS (2 tests).

- [ ] **Step 6: Commit**

```bash
git add simulator/static/index.html simulator/static/style.css simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): move language picker below Audio, globe inline-left"
```

---

### Task 2: Low-vision contrast + global focus rings + visually-hidden utility

**Files:**
- Modify: `simulator/static/style.css` (color bumps, `:focus-visible`, `.visually-hidden`)

**Interfaces:**
- Produces: a `.visually-hidden` utility class used by Task 4 (gate) and Task 7 (aria-live).

- [ ] **Step 1: Bump failing text colors to AA**

In `style.css`, change these declarations (values chosen to clear 4.5:1 on the dark backgrounds; verify with a contrast check in Step 3):

```css
/* was #789 — too low on #111 */
.hint { color: #9fb3c8; }
/* dial labels/captions were #789ac0 / #4d6184 on #0d1320 */
.dial-label { fill: #b8cfe6; }
.dial-caption { fill: #8fa6c4; }
```

(Keep every other property on those selectors unchanged — edit only the color/fill.)

- [ ] **Step 2: Add focus-visible + visually-hidden utilities**

```css
/* Visible keyboard focus for every interactive element (was only on .dev-switch). */
:focus-visible { outline: 2px solid #9af; outline-offset: 2px; }
#dial:focus-visible { outline-offset: 4px; }
/* Screen-reader-only text (announcements, labels) — present in a11y tree, off-screen. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
```

- [ ] **Step 3: Verify contrast**

Manually confirm with any WCAG contrast tool that `#9fb3c8` on `#111`, `#b8cfe6` on `#0d1320`, and `#8fa6c4` on `#0d1320` each meet ≥4.5:1 (≥3:1 acceptable for the dial caption if it reads as large). Adjust lighter if any fails.

- [ ] **Step 4: Commit**

```bash
git add simulator/static/style.css
git commit -m "feat(a11y): AA contrast bumps, global focus-visible, visually-hidden util"
```

---

### Task 3: `flash.js` — pure flash-clamp helper + node tests

**Files:**
- Create: `simulator/static/flash.js`
- Test: `simulator/static/flash.test.js` (new, `node --test`)

**Interfaces:**
- Produces: `HEFFlash.minSafeDurationMs(count)` → minimum total ms for `count` luminance transitions to stay ≤3/sec; `HEFFlash.clampDurationMs(requestedMs, count)` → `max(requestedMs, minSafeDurationMs(count))`. Consumed by Task 8 (audit).

- [ ] **Step 1: Write the failing test**

Create `simulator/static/flash.test.js`:

```js
const test = require("node:test");
const assert = require("node:assert");
const F = require("./flash.js");

test("minSafeDurationMs: N transitions need N/3 seconds", () => {
  assert.strictEqual(F.minSafeDurationMs(3), 1000); // 3 flashes in >=1s
  assert.strictEqual(F.minSafeDurationMs(6), 2000);
  assert.strictEqual(F.minSafeDurationMs(0), 0);
  assert.strictEqual(F.minSafeDurationMs(1), 1000 / 3);
});

test("clampDurationMs: stretches only when too fast", () => {
  assert.strictEqual(F.clampDurationMs(2000, 3), 2000); // already safe
  assert.strictEqual(F.clampDurationMs(200, 3), 1000);  // too fast → clamped up
  assert.strictEqual(F.clampDurationMs(500, 1), 500);   // single transition, slow enough
});
```

- [ ] **Step 2: Run it to verify it fails**

Run: `cd simulator/static && node --test flash.test.js`
Expected: FAIL ("Cannot find module './flash.js'").

- [ ] **Step 3: Implement `flash.js`**

```js
// Pure photosensitivity helper. WCAG 2.3.1: no more than 3 general flashes
// (luminance transitions) per second. Given a transition COUNT, returns the
// minimum total duration that keeps the rate at or below 3/sec, and a clamp
// that only ever slows a transition down. UMD: browser `HEFFlash` + require().
(function (root, factory) {
  const api = factory();
  if (typeof module !== "undefined" && module.exports) module.exports = api;
  else root.HEFFlash = api;
})(typeof self !== "undefined" ? self : this, function () {
  "use strict";
  const MAX_PER_SEC = 3;
  function minSafeDurationMs(count) {
    const n = Math.max(0, Number(count) || 0);
    return (n / MAX_PER_SEC) * 1000;
  }
  function clampDurationMs(requestedMs, count) {
    return Math.max(Number(requestedMs) || 0, minSafeDurationMs(count));
  }
  return { MAX_PER_SEC, minSafeDurationMs, clampDurationMs };
});
```

- [ ] **Step 4: Run tests to verify pass**

Run: `cd simulator/static && node --test flash.test.js`
Expected: PASS (2 tests).

- [ ] **Step 5: Commit**

```bash
git add simulator/static/flash.js simulator/static/flash.test.js
git commit -m "feat(a11y): flash.js pure WCAG 2.3.1 flash-clamp helper + tests"
```

---

### Task 4: Photosensitivity warning gate

**Files:**
- Modify: `simulator/static/index.html` (gate markup inside `.screen`)
- Modify: `simulator/static/style.css` (gate styles)
- Modify: `simulator/static/app.js` (gate logic around `run-sim` reveal, `app.js:1297`)
- Modify: `simulator/static/i18n.js` (gate strings)
- Test: `simulator/e2e/tests/a11y.spec.ts`

**Interfaces:**
- Consumes: nothing.
- Produces: `WARN_KEY` localStorage flag; `maybeShowMotionWarning()` called before `run-sim` is revealed.

- [ ] **Step 1: Write the failing e2e test**

Append to `a11y.spec.ts`:

```ts
test("motion warning gate shows once, then is remembered", async ({ page }) => {
  await page.goto("/");
  const gate = page.locator("#motion-warning");
  await expect(gate).toBeVisible();
  await page.locator("#motion-warning-continue").click();
  await expect(gate).toBeHidden();
  // Reload: dismissal persisted, gate stays hidden, run-sim is the entry point.
  await page.reload();
  await expect(page.locator("#motion-warning")).toBeHidden();
  await expect(page.locator("#run-sim")).toBeVisible();
});
```

- [ ] **Step 2: Run to verify it fails**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
Expected: FAIL (no `#motion-warning`).

- [ ] **Step 3: Add gate markup in `index.html`**

Inside `<div class="screen">`, after the `#run-sim` button:

```html
<div id="motion-warning" class="motion-warning hidden" role="dialog" aria-modal="true" aria-labelledby="mw-title">
  <div class="mw-card">
    <h2 id="mw-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
    <p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin.</p>
    <button type="button" id="motion-warning-continue" class="run-sim" data-i18n="warn.continue">Continue</button>
  </div>
</div>
```

- [ ] **Step 4: Add gate styles in `style.css`**

```css
.motion-warning { position: absolute; inset: 0; z-index: 70; display: flex;
  align-items: center; justify-content: center; padding: 1rem;
  background: rgba(2, 4, 10, 0.92); }
.motion-warning.hidden { display: none; }
.mw-card { max-width: 30rem; text-align: center; color: #dfeaff; }
.mw-card h2 { font-size: 1.1rem; margin: 0 0 0.6rem; }
.mw-card p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 1.2rem; color: #b9c8e0; }
.mw-card .run-sim { position: static; transform: none; }
.mw-card .run-sim:hover { transform: scale(1.04); }
```

- [ ] **Step 5: Add i18n strings in `i18n.js`**

Add to `UI_STRINGS` (EN required; other langs may be added later, fallback is EN):

```js
"warn.title":    { en: "Heads up — motion & flashing" },
"warn.body":     { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin." },
"warn.continue": { en: "Continue" },
```

- [ ] **Step 6: Wire gate logic in `app.js`**

Add near `DEV_KEY` (`app.js:918`):

```js
const WARN_KEY = "hef.motionWarnDismissed";
function motionWarnDismissed() {
  try { return localStorage.getItem(WARN_KEY) === "1"; } catch (_) { return false; }
}
function maybeShowMotionWarning() {
  const gate = $("motion-warning");
  if (!gate) return;
  if (motionWarnDismissed()) { gate.classList.add("hidden"); return; }
  gate.classList.remove("hidden");
  $("motion-warning-continue").addEventListener("click", () => {
    try { localStorage.setItem(WARN_KEY, "1"); } catch (_) {}
    gate.classList.add("hidden");
    $("motion-warning-continue").focus({ preventScroll: true });
    $("run-sim").focus({ preventScroll: true });
  }, { once: true });
}
```

In `main()`, immediately after `$("run-sim").classList.remove("hidden");` (`app.js:1297`), add:

```js
  maybeShowMotionWarning();    // photosensitivity notice on first visit (over the stage)
```

- [ ] **Step 7: Run the gate test to verify pass**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
Expected: PASS. (If `localStorage` carries across tests, the test clears it via `page.goto` fresh context — Playwright uses a fresh context per test by default.)

- [ ] **Step 8: Commit**

```bash
git add simulator/static/index.html simulator/static/style.css simulator/static/app.js simulator/static/i18n.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): one-time photosensitivity warning gate"
```

---

### Task 5: Reduced-motion freeze-to-stills + toggle

**Files:**
- Modify: `simulator/static/index.html` (toggle in Output fieldset)
- Modify: `simulator/static/i18n.js` (toggle label)
- Modify: `simulator/static/app.js` (`reduceMotion` state, freeze logic, `autoScrub` instant path)
- Test: `simulator/e2e/tests/a11y.spec.ts`

**Interfaces:**
- Consumes: nothing.
- Produces: `reduceMotion` boolean; `isReduced()` accessor used by `autoScrub` (Task 5) and dial keyboard (Task 6); `applyReduceMotion()` pauses/resumes playback.

- [ ] **Step 1: Write the failing e2e test**

Append to `a11y.spec.ts`:

```ts
test("reduce-motion toggle pauses video playback", async ({ page }) => {
  await page.goto("/");
  // Dismiss the gate and start the experience so video is playing.
  await page.locator("#motion-warning-continue").click().catch(() => {});
  await page.locator("#run-sim").click();
  await page.waitForTimeout(400);
  await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
  await page.locator("#reduce-motion").check();
  await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
});
```

- [ ] **Step 2: Run to verify it fails**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
Expected: FAIL (no `#reduce-motion`).

- [ ] **Step 3: Add the toggle markup in `index.html`**

In the Output fieldset, after the Audio level label (and before the language picker added in Task 1), add a switch mirroring the existing `.dev-switch`:

```html
<label class="dev-switch" for="reduce-motion">
  <input type="checkbox" id="reduce-motion" />
  <span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
  <span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
</label>
```

- [ ] **Step 4: Add the i18n label in `i18n.js`**

```js
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
```

- [ ] **Step 5: Add `reduceMotion` state + freeze logic in `app.js`**

Near `DEV_KEY`/`WARN_KEY`:

```js
const RM_KEY = "hef.reduceMotion";
let reduceMotion = false;
function isReduced() { return reduceMotion; }
function initReduceMotion() {
  const box = $("reduce-motion");
  let stored = null;
  try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
  // Default to the OS setting when the user hasn't chosen yet.
  const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  reduceMotion = stored === null ? !!prefers : stored === "1";
  if (box) {
    box.checked = reduceMotion;
    box.addEventListener("change", () => {
      reduceMotion = box.checked;
      try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
      applyReduceMotion();
    });
  }
  applyReduceMotion();
}
function applyReduceMotion() {
  if (reduceMotion) {
    if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
    vid.pause();
    loopVid.pause();
  } else if (videoEverOn && $("visual").checked) {
    playLoop();
  }
}
```

- [ ] **Step 6: Make `autoScrub` instant under reduced motion**

At the top of `autoScrub` (`app.js:678`), after the `if (!ring …) return;` guard, add:

```js
  if (isReduced()) {            // no autonomous tween — jump straight to the target
    if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
    setPos(targetPos);
    return;
  }
```

- [ ] **Step 7: Keep playback paused when reduced-motion is on after landing**

In `beginExperience()` (`app.js:1250`) and wherever `playLoop()` is called on a settle, guard the play with `if (!isReduced()) playLoop();` — specifically wrap the settle-time `playLoop()` at `app.js:850` and the `beginExperience` start so turning the experience on while reduced does not animate. (Inspect each `playLoop()` call site; guard the autonomous ones, leave the explicit toggle-off resume in `applyReduceMotion`.)

- [ ] **Step 8: Call `initReduceMotion()` in `main()`**

After `initLanguage();` (`app.js:1266`) add:

```js
  initReduceMotion();          // reduced-motion state + toggle (default from OS pref)
```

- [ ] **Step 9: Run the reduced-motion test to verify pass**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
Expected: PASS.

- [ ] **Step 10: Commit**

```bash
git add simulator/static/index.html simulator/static/i18n.js simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): reduced-motion freeze-to-stills with OS-default toggle"
```

---

### Task 6: Keyboard + ARIA for the Altitude dial

**Files:**
- Modify: `simulator/static/index.html:56` (dial svg attrs)
- Modify: `simulator/static/app.js` (keydown handler, aria-value sync, label buttons)
- Test: `simulator/e2e/tests/a11y.spec.ts`

**Interfaces:**
- Consumes: `autoScrub`, `jumpToScale`, `ring`, `ringIndex`, `dialStep` (all in `app.js`).
- Produces: `setDialAria()` updates `aria-valuenow/valuetext`; called wherever the needle settles.

- [ ] **Step 1: Write the failing e2e test**

Append to `a11y.spec.ts`:

```ts
test("altitude dial is keyboard-operable", async ({ page }) => {
  await page.goto("/");
  await page.locator("#motion-warning-continue").click().catch(() => {});
  const dial = page.locator("#dial");
  await expect(dial).toHaveAttribute("role", "slider");
  await dial.focus();
  const before = await page.locator("#scale-name").textContent();
  await dial.press("ArrowDown");          // descend one altitude
  await expect.poll(async () => page.locator("#scale-name").textContent()).not.toBe(before);
  await dial.press("Home");               // jump to cosmos (top)
  await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
});
```

- [ ] **Step 2: Run to verify it fails**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
Expected: FAIL (dial has no `role`/key handling).

- [ ] **Step 3: Add ARIA attrs to the dial in `index.html`**

Change line 56 to:

```html
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
     aria-label="Altitude — turn to change scale"
     aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
```

- [ ] **Step 4: Add `setDialAria()` and call it on settle**

In `app.js`, add:

```js
// Reflect the committed altitude into the dial's slider semantics for AT.
function setDialAria() {
  if (!dial || !ring) return;
  dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
  dial.setAttribute("aria-valuenow", String(ringIndex));
  const s = ring.scales[ringIndex];
  if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
}
```

Call `setDialAria()` at the settle point in `setPos` where `setNeedle(ringIndex * dialStep())` runs on frac 0 (`app.js:852`), and once in `buildDial()` after the dial is drawn.

- [ ] **Step 5: Add the keydown handler**

```js
function onDialKey(e) {
  if (!ring || ring.scales.length < 2) return;
  let handled = true;
  switch (e.key) {
    case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break;
    case "ArrowUp":   case "ArrowLeft":  autoScrub(Math.round(pos) - 1); break;
    case "Home": jumpToScale(0); break;
    case "End":  jumpToScale(ring.scales.length - 1); break;
    default: handled = false;
  }
  if (handled) e.preventDefault();
}
```

Wire it in `main()` next to the other dial listeners (`app.js:1289`):

```js
  dial.addEventListener("keydown", onDialKey);
```

- [ ] **Step 6: Make the dial labels keyboard-activatable**

In `buildDial()` (where each `.dial-label` is created, ~`app.js:749`), add to the label's attributes: `role: "button"`, `tabindex: "0"`, and an `aria-label` of the scale name. Then in `main()` add a delegated keydown on the dial that activates a focused label:

```js
  dial.addEventListener("keydown", (e) => {
    const t = e.target;
    if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
      e.preventDefault();
      jumpToScale(+t.getAttribute("data-index"));
    }
  });
```

- [ ] **Step 7: Run the keyboard test to verify pass**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
Expected: PASS.

- [ ] **Step 8: Commit**

```bash
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): keyboard + ARIA slider semantics for the Altitude dial"
```

---

### Task 7: Screen-reader narration (aria-live) + hide decorative SVG

**Files:**
- Modify: `simulator/static/index.html` (aria-live region; `aria-hidden` on decorative layers)
- Modify: `simulator/static/app.js` (announce on settle, throttled)
- Test: `simulator/e2e/tests/a11y.spec.ts`

**Interfaces:**
- Consumes: `activeClipId`, the clip's resolved strings, `ring`, `ringIndex`.
- Produces: `announce(text)`; `decorative SVGs are aria-hidden`.

- [ ] **Step 1: Write the failing e2e test**

Append to `a11y.spec.ts`:

```ts
test("an aria-live region narrates the current scale", async ({ page }) => {
  await page.goto("/");
  await page.locator("#motion-warning-continue").click().catch(() => {});
  const live = page.locator("#sr-status");
  await expect(live).toHaveAttribute("aria-live", "polite");
  await page.locator("#dial").focus();
  await page.locator("#dial").press("Home");
  await expect.poll(async () => (await live.textContent())?.toLowerCase()).toContain("cosmos");
});

test("decorative overlay SVGs are hidden from AT", async ({ page }) => {
  await page.goto("/");
  await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
  await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
});
```

- [ ] **Step 2: Run to verify it fails**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
Expected: FAIL.

- [ ] **Step 3: Add the live region + aria-hidden in `index.html`**

Add `aria-hidden="true"` to `#overlay`, `#affect`, `#tint`, `#paint`. Add inside `.panel` (top, after the opening tag) a live region:

```html
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
```

- [ ] **Step 4: Add `announce()` + call on settle in `app.js`**

```js
let lastAnnounced = "";
function announce(text) {
  const el = $("sr-status");
  if (!el || !text || text === lastAnnounced) return;
  lastAnnounced = text;
  el.textContent = text;
}
// Build the spoken summary: scale + top factual (left) label for the active clip.
function announceState() {
  if (!ring) return;
  const s = ring.scales[ringIndex];
  const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
  const clip = activeClip && activeClip();   // use existing accessor for the locked clip
  let label = "";
  if (clip) {
    const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
    label = firstFactualLabel(strings) || "";
  }
  announce(label ? `${scaleName}. ${label}` : scaleName);
}
```

If no `activeClip()` accessor exists, read the locked clip via the existing `activeClipId` lookup used in `renderScaleReadout` (mirror that code). `firstFactualLabel` returns the first non-empty left/`LABELS`-style string from `strings`; if the shape makes this awkward, announce just `scaleName` (degrade gracefully — the scale name is the load-bearing part).

Call `announceState()` at the same settle point as `setDialAria()` (frac 0 in `setPos`) and at the end of `setLanguage()` so a language switch re-announces.

- [ ] **Step 5: Run the narration tests to verify pass**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): aria-live narration of scale/label; hide decorative SVGs"
```

---

### Task 8: Apply the flash audit to real transitions

**Files:**
- Modify: `simulator/static/app.js` and/or `simulator/static/style.css` (clamp the audited transitions)
- Modify: `simulator/static/index.html` (load `flash.js` before `app.js`)

**Interfaces:**
- Consumes: `HEFFlash.clampDurationMs` (Task 3).

- [ ] **Step 1: Load `flash.js` in `index.html`**

Add before `app.js` (after `i18n.js`, `index.html:117`):

```html
  <script src="flash.js"></script>
```

- [ ] **Step 2: Audit + enumerate the rapid-luminance transitions**

Inspect the three sources and record findings as a comment in `app.js` above the change:
1. Fast-spin blended dial pass — `autoScrub` total `ms = perAltMs * |dist|`; the per-altitude floor is 1200ms (well under 3/sec for one step), so a multi-step spin is already ≥3 transitions over ≥N×1.2s = safe. Confirm and note.
2. `#black` cover fade — `style.css` `transition: opacity 200ms`; a single fade is one transition, not a repeated flash — safe.
3. Audio-coupled crossfade — visual? If it drives an opacity swap, check its duration.

For any source that CAN repeat faster than 3/sec (e.g. a rapid wheel/keyboard repeat firing `autoScrub` back-to-back), clamp using `HEFFlash`:

```js
  // Guard against a fast key/wheel repeat producing >3 luminance swings/sec.
  const safeMs = HEFFlash.clampDurationMs(PER_ALTITUDE_MS, 1);
```

If the audit finds NO breach (the likely outcome given the 1200ms floor), record that conclusion in the comment and make no timing change beyond loading `flash.js` for the helper's availability — do not invent a clamp that isn't needed (YAGNI). The deliverable of this task is the documented audit + `flash.js` wired in.

- [ ] **Step 3: Run existing e2e to confirm no regression**

Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts`
Expected: PASS (12 tests, per project memory).

- [ ] **Step 4: Commit**

```bash
git add simulator/static/index.html simulator/static/app.js
git commit -m "feat(a11y): wire flash-clamp helper; document 3-flash/sec audit"
```

---

### Task 9: about.html + header link + static-build copy

**Files:**
- Create: `simulator/static/about.html`
- Modify: `simulator/static/index.html:18` (header link)
- Modify: `simulator/static/i18n.js` (about link label)
- Modify: `tools/build_static.py:38` (add `about.html`, `flash.js` to `PUBLIC_ASSETS`)
- Test: `simulator/e2e/tests/a11y.spec.ts`

**Interfaces:**
- Consumes: existing `.credits-page` / `.credits-wrap` / `.back-link` styles.

- [ ] **Step 1: Write the failing e2e test**

Append to `a11y.spec.ts`:

```ts
test("about page loads and links back to the experience", async ({ page }) => {
  await page.goto("/about.html");
  await expect(page.locator("h1")).toContainText(/about/i);
  await expect(page.getByText(/imperfect/i)).toBeVisible();
  await page.locator(".back-link").click();
  await expect(page).toHaveURL(/index\.html|\/$/);
});

test("header links to the about page", async ({ page }) => {
  await page.goto("/");
  await expect(page.locator('a[href="about.html"]')).toBeVisible();
});
```

- [ ] **Step 2: Run to verify it fails**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
Expected: FAIL (no about.html / link).

- [ ] **Step 3: Create `about.html`**

Mirror `credits.html` structure exactly (body `class="credits-page"`, `main.credits-wrap`, back-link, `config.js`):

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>About — Human Experience Filter</title>
  <link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
  <main class="credits-wrap">
    <header>
      <h1>About this work</h1>
      <p><a href="index.html" class="back-link">← Back to the experience</a></p>
    </header>

    <section>
      <h2>For everyone</h2>
      <p>The <em>Human Experience Filter</em> is built to be accessible to — and
      representative of — the full range of human experience. That is why it works
      with a keyboard, with a screen reader, with reduced motion, and away from any
      single screen or kiosk: an experience about being human should be open to as
      many humans as possible.</p>
    </section>

    <section>
      <h2>Places technology let us see</h2>
      <p>The scales you move through — the deep sea, the coast, the sky, the orbit
      of the Earth, the wider cosmos — are vantage points no unaided human could
      ever witness. We can share them only because our tools evolved to reach them:
      from <strong>underwater exploration</strong>, to <strong>drones and aerial
      imaging</strong>, to <strong>space exploration</strong>. Each scale is a place
      a machine went first so that a person could feel what it is like to be there.</p>
    </section>

    <section>
      <h2>Built with LLMs</h2>
      <p>This piece was itself built using large language models — an extension of
      that same long arc of tools that widen what a single human can reach. The
      software, the words, and much of the craft were shaped in collaboration with
      a machine.</p>
    </section>

    <section>
      <h2>Honest about its limits</h2>
      <p>This implementation is imperfect. It is also more than I could have made
      alone: better with these tools assisting than by my hand as a single human.
      That tension is part of the point — technology does not replace the person
      behind the work; it extends their reach, flaws and all.</p>
    </section>
  </main>
  <script src="config.js"></script>
</body>
</html>
```

- [ ] **Step 4: Add the header link in `index.html`**

After the existing credits link (`index.html:18`), add:

```html
    <a href="about.html" class="credits-link" data-i18n="about.link">About</a>
```

- [ ] **Step 5: Add the i18n label in `i18n.js`**

```js
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
```

- [ ] **Step 6: Add the new files to the static build**

In `tools/build_static.py:38`, extend `PUBLIC_ASSETS`:

```python
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
                 "credits.html", "credits.js", "about.html", "flash.js"]
```

- [ ] **Step 7: Run the about-page tests to verify pass**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
Expected: PASS.

- [ ] **Step 8: Verify the static build includes the new files**

Run: `cd simulator/e2e && npx playwright test static-build.spec.ts` (or run `python3 tools/build_static.py` and confirm `dist/.../about.html` + `flash.js` exist).
Expected: PASS / files present.

- [ ] **Step 9: Commit**

```bash
git add simulator/static/about.html simulator/static/index.html simulator/static/i18n.js tools/build_static.py simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(content): about.html — intent, provenance, honest limits; static-build copy"
```

---

### Task 10: Full-suite green + finish

**Files:** none (verification).

- [ ] **Step 1: Run the full e2e a11y spec**

Run: `cd simulator/e2e && npx playwright test a11y.spec.ts`
Expected: all PASS.

- [ ] **Step 2: Run the node helper tests**

Run: `cd simulator/static && node --test flash.test.js`
Expected: PASS.

- [ ] **Step 3: Run the existing suites that should stay green**

Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts i18n.spec.ts static-build.spec.ts`
Expected: PASS. (`loop-recovery.spec.ts` is known-red on a clean baseline here — do not block on it, but confirm it is no MORE broken.)
Run: `python3 -m pytest` from repo root for the Python suite.
Expected: PASS.

- [ ] **Step 4: Final review + branch finish**

Invoke `superpowers:finishing-a-development-branch` to choose merge/PR. Do NOT auto-merge to main without the operator's go (per session posture).

## Self-Review

**Spec coverage:** A1 reduced-motion → Task 5; A2 gate → Task 4, audit → Tasks 3+8; B keyboard/focus → Tasks 2+6; C contrast → Task 2; D SR narration → Task 7; E about.html → Task 9; F layout → Task 1. `<html lang>` correctly omitted (already done). All covered.

**Placeholder scan:** Code shown for every code step. Task 7's `firstFactualLabel`/`activeClip` note explicitly degrades to scale-name-only if the clip-strings shape is awkward — that is a defined fallback, not a placeholder. Task 8 explicitly allows a "no breach found → document and stop" outcome (YAGNI), which is a real deliverable.

**Type consistency:** `isReduced()`, `autoRaf`, `setPos`, `autoScrub`, `jumpToScale`, `setDialAria`, `announce`, `HEFFlash.clampDurationMs`, `WARN_KEY`/`RM_KEY` used consistently across tasks. `reduce-motion` element id matches between HTML (Task 5 Step 3) and JS (Task 5 Step 5) and test.
