Compare commits

..

16 Commits

Author SHA1 Message Date
BenStullsBets d711db6a2c fix(guard): scope the edit-block to files INSIDE the repo
The PreToolUse guard blocked ALL edits while in the main checkout — including files
outside the repo (auto-memory, /tmp, scratchpad). Now it only denies edits to files
under the main checkout's working tree; external files are allowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:18:39 -07:00
BenStullsBets 17856cac32 chore(dev): enforce isolated worktrees outside main for every session
Multiple sessions sharing this one checkout clobbered each other (branch switches,
edits landing on the wrong branch). Enforce per-session isolation:
- worktree.bgIsolation=worktree + baseRef=fresh (native bg-session isolation)
- PreToolUse(Edit|Write|NotebookEdit) hook DENIES edits while in the main checkout
- SessionStart hook directs each session to EnterWorktree (fresh worktree, off main)
.claude/hooks/worktree-guard.sh detects main-vs-worktree via git-dir != git-common-dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:15:57 -07:00
BenStullsBets 1a6ffc74a1 feat(load): phase-1 preload gate + eligible-pick pool growth
Gate the universe on one clip per altitude + connecting morphs (~126 MB, ~10-20s),
then grow the pool in the background. The random pick (pickPoolClip/pickRandomMember)
is restricted to fully-loaded clips — base + connecting morphs both ways cached
(HEFPreload.eligibleDestinations) — so a transition never stalls on un-downloaded
media and new clips become reachable only once fully loaded. Pure planner in
preload.js (+ node tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:55:46 -07:00
BenStullsBets 380c2d13e7 Merge origin/main into feat/accessibility-pass (reconcile static-publish + a11y with main)
# Conflicts:
#	simulator/static/app.js
2026-06-30 09:47:40 -07:00
BenStullsBets ff06783173 add sessions/0033/SESSION-0033.0-TRANSCRIPT-2026-06-30T09-38--2026-06-30T09-43.md + replace placeholder/variant SESSION-0033.0-TRANSCRIPT-2026-06-30T09-38--INPROGRESS.md 2026-06-30 09:44:55 -07:00
BenStullsBets b83758fbca feat(content): about.html — intent, provenance, honest limits; static-build copy
English-first page mirroring credits.html (for-everyone / tech arc
underwater→drones→space / built-with-LLMs / honest-it's-imperfect). Header
link + about.link i18n key; about.html + flash.js added to PUBLIC_ASSETS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:37:00 -07:00
BenStullsBets 79105a3ef9 feat(a11y): wire flash-clamp into autoScrub; document 3-flash/sec audit
Clamp each per-altitude step to the WCAG 2.3.1 floor (no-op at 1200ms, guards
a future lowering). Audit: #black fade + audio crossfade can't exceed 3/sec, no
clamp needed. boot() now seeds the gate-dismissed flag (returning visitor) so
the run-sim test isn't blocked by the new interstitial.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:34:10 -07:00
BenStullsBets f1ce23c4fe feat(a11y): aria-live narration of scale/clip; hide decorative SVGs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:28:19 -07:00
BenStullsBets 0db602ebd0 feat(a11y): keyboard + ARIA slider semantics for the Altitude dial
role=slider + tabindex on the dial; Arrow/Home/End stepping; labels become
Enter/Space buttons; aria-valuenow/valuetext track the committed altitude via
renderDial. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:26:45 -07:00
BenStullsBets 717bf5b08b feat(a11y): reduced-motion freeze-to-stills with OS-default toggle
Single guard in playLoop() holds a still frame; autoScrub jumps instantly;
applyReduceMotion pauses/resumes. Default from prefers-reduced-motion, then
the choice persists. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:24:00 -07:00
BenStullsBets 6cf8fffc51 feat(a11y): one-time photosensitivity warning gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:18:04 -07:00
BenStullsBets 1f98b7bd8c feat(a11y): flash.js pure WCAG 2.3.1 flash-clamp helper + tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:16:02 -07:00
BenStullsBets 6e2e65202e feat(a11y): AA contrast bumps, global focus-visible, visually-hidden util
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:15:41 -07:00
BenStullsBets fe77677d4c feat(a11y): move language picker below Audio, globe inline-left
Adds the Reduce-motion toggle markup + i18n label in the same Output
fieldset (wired in a later task). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:14:47 -07:00
BenStullsBets a895e7139b plan: accessibility pass + About page implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:11:43 -07:00
BenStullsBets 768fee5ccd design: accessibility pass + About page (non-kiosk public web)
Reduced-motion freeze-to-stills, photosensitivity warning gate + flash
audit, keyboard/SR-operable Altitude dial, low-vision contrast, and a new
about.html (English-first). Targets WCAG 2.1 AA plus a motion/seizure layer
for the unattended public URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:49:02 -07:00
18 changed files with 1761 additions and 55 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Worktree isolation guard. This repo is worked by MULTIPLE concurrent sessions; if
# they share the main checkout they clobber each other (branch switches, lost edits,
# commits on the wrong branch). Every session must work in its OWN git worktree
# OUTSIDE the main directory.
#
# worktree-guard.sh block PreToolUse(Edit|Write|NotebookEdit) — DENY edits to
# files INSIDE the main checkout while in it. Files
# outside the repo (memory, /tmp, scratchpad) are allowed.
# worktree-guard.sh notice SessionStart — tell the agent to enter a worktree.
#
# Isolated = the session sits in a git worktree, where `--git-dir` (…/.git/worktrees/
# <name>) differs from `--git-common-dir` (…/.git). Equal = the shared main checkout.
set -u
mode="${1:-notice}"
gd=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 # not a repo → nothing to guard
gc=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) || exit 0
[ "$gd" = "$gc" ] || exit 0 # already in a worktree → allow
root=$(git rev-parse --show-toplevel 2>/dev/null)
repo=$(basename "${root:-repo}")
guidance="You are in the SHARED main checkout ($root). Multiple sessions use it \
concurrently and WILL clobber each other. Work in an isolated worktree OUTSIDE the \
main directory: use the EnterWorktree tool (preferred — it relocates this session to \
a fresh worktree branched from origin/main), or run \
\`git worktree add -b <branch> ../${repo}-worktrees/<name> origin/main\` and cd there. \
Do NOT edit files in the main checkout."
esc() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
if [ "$mode" = "block" ]; then
# Only guard files INSIDE the main checkout's working tree; allow edits to files
# outside the repo (auto-memory, /tmp, scratchpad, other repos).
fp=$(python3 -c 'import json,sys
try:
d=json.load(sys.stdin); print(d.get("tool_input",{}).get("file_path","") or "")
except Exception: print("")' 2>/dev/null)
case "$fp" in
/*) abs="$fp" ;;
"") abs="$root" ;;
*) abs="$root/$fp" ;;
esac
case "$abs" in
"$root"/*|"$root") : ;; # inside the repo → fall through to deny
*) exit 0 ;; # outside the repo → allow
esac
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(esc "$guidance")"
else
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$(esc "⚠ WORKTREE ISOLATION REQUIRED. $guidance")"
fi
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"worktree": {
"bgIsolation": "worktree",
"baseRef": "fresh"
},
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" notice"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" block"
}
]
}
]
}
}
@@ -0,0 +1,885 @@
# 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.
@@ -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 `<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.
@@ -0,0 +1,56 @@
# Session 0033.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-38 (PST)
> End: 2026-06-30T09-43 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Branch: design/cloudflare-static-publish (canonical); merge performed on main via worktree
> Status: **FINALIZED**
## Launch prompt
Operator asked "Have you merged to main?" → no (all work was on
`design/cloudflare-static-publish`) → operator: "Yes" (merge it to main).
## Arc
1. Confirmed none of the credits work (nor the 17-commit static-publish line) was on
`main`; surfaced the considerations (live branch, in-flight a11y session, brings all
commits). Operator approved the merge.
2. Claimed **session 0033**. Surveyed divergence: `main` and the branch had diverged —
`main` carried real app work from the parallel labels/affect session (per-clip
right-brain feelings `bf1013b`, i18n re-extract `2d54023`, affect/label fix
`041fcde`), not just transcript publishes.
3. Found only **two** overlapping files (`app.js`, `altitude-lock.spec.ts`).
**Test-merged in a throwaway worktree** off `origin/main`: clean auto-merge, no
conflicts. Ran the FULL suite on the merged tree — **node 29, pytest 315/2-skip,
green** — before trusting the textual merge.
4. Performed the real `--no-ff` merge on `main` in a worktree (keeping the canonical
checkout on the live deploy branch), pushed `main` (`75961ea``87d605d`).
5. Verified credits commits + `credits.html` are on `origin/main`, branch now 0 ahead.
Removed the worktree; canonical checkout clean on `design/cloudflare-static-publish`.
## End state
- `origin/main` @ `87d605d` — now carries the entire Cloudflare static-publish line,
including the CC-BY/BY-SA credits page + "License & reuse" CC BY-SA declaration.
- `design/cloudflare-static-publish` is fully integrated (0 commits ahead of main).
- The live site still serves the pre-merge build; **a redeploy** ships the merged main.
## Deferred decisions
- **Merged via local `--no-ff` + push rather than a Gitea PR.** `gh` isn't wired (Gitea
host) and the operator approved directly in autonomous mode; the work was developed on
a branch and the merge produces the same merge-commit a PR would. (Alternative: open a
Gitea-API PR for a review record — skipped for speed; can add retroactively if wanted.)
- **Did not merge `main` back into `design/cloudflare-static-publish`.** The branch is 0
ahead now; future static work can re-branch from main. Left as-is.
## Next /goal
```
/goal redeploy benstull.art from main (now carries the CC-BY/BY-SA credits + "License & reuse" D1 declaration) per deploy/cloudflare/README.md; the About page / WCAG pass lands separately via the feat/accessibility-pass session
```
@@ -1,23 +0,0 @@
# Session 0033.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-38 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0033 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0033.0-TRANSCRIPT-2026-06-30T09-38--<end>.md form at session end.
## Launch prompt
_(launch prompt not captured at claim time)_
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
+116
View File
@@ -0,0 +1,116 @@
import { test, expect } from "@playwright/test";
// Accessibility pass (feat/accessibility-pass) — non-kiosk public-web a11y.
// Each block maps to a task in
// docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md.
test.describe("Task 1 — Output panel layout", () => {
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 renders 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();
expect(sBox!.x).toBeGreaterThan(pBox!.x); // select right of the label's left edge
expect(pBox!.height).toBeLessThan(40); // one row, not stacked
});
});
test.describe("Task 4 — Photosensitivity warning gate", () => {
test("motion warning gate shows once, then is remembered", async ({ page }) => {
await page.goto("/");
const gate = page.locator("#motion-warning");
await expect(gate).toBeVisible({ timeout: 30000 }); // shown after media preload + splash fade
await page.locator("#motion-warning-continue").click();
await expect(gate).toBeHidden();
// Reload in the SAME context: dismissal persisted, gate stays hidden.
await page.reload();
await expect(page.locator("#motion-warning")).toBeHidden();
await expect(page.locator("#run-sim")).toBeVisible();
});
});
test.describe("Task 5 — Reduced motion", () => {
test("reduce-motion toggle pauses video playback", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off
await page.goto("/");
await page.locator("#motion-warning-continue").click().catch(() => {});
const rm = page.locator("#reduce-motion");
await expect(rm).not.toBeChecked(); // default-off under no-preference
await page.locator("#run-sim").click();
await page.waitForTimeout(500);
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(false); // experience running → video plays
await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label
await expect(rm).toBeChecked();
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(true); // reduced motion → frozen
});
});
test.describe("Task 6 — Keyboard + ARIA dial", () => {
test("altitude dial is a keyboard-operable slider", async ({ page }) => {
await page.goto("/");
const dial = page.locator("#dial");
await expect(dial).toHaveAttribute("role", "slider");
await expect(dial).toHaveAttribute("tabindex", "0");
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 the top scale (cosmos)
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
await expect(dial).toHaveAttribute("aria-valuenow", "0");
});
});
test.describe("Task 7 — Screen-reader narration", () => {
test("an aria-live region narrates the current scale", async ({ page }) => {
await page.goto("/");
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 assistive tech", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
});
});
test.describe("Task 9 — About page", () => {
test("about page loads, names its themes, and links back", async ({ page }) => {
await page.goto("/about.html");
await expect(page.locator("h1")).toContainText(/about/i);
await expect(page.getByText(/imperfect/i)).toBeVisible(); // honest-about-limits beat
await expect(page.getByText(/space exploration/i)).toBeVisible(); // technology arc beat
await expect(page.getByText(/large language models/i)).toBeVisible();
await page.locator(".back-link").click();
await expect(page).toHaveURL(/index\.html|\/$/);
});
test("the header links to the about page", async ({ page }) => {
await page.goto("/");
await expect(page.locator('a[href="about.html"]')).toBeVisible();
});
});
+4 -1
View File
@@ -10,7 +10,10 @@ import { test, expect, Page } from "@playwright/test";
// ("<scale> · <clip> (i/n)").
async function boot(page: Page) {
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
await page.addInitScript(() => {
localStorage.setItem("hef.devMode", "1");
localStorage.setItem("hef.motionWarnDismissed", "1"); // returning visitor: skip the photosensitivity gate
});
await page.goto("/");
// Wait for the "Loading Universe" preload to finish (splash gets class "done").
await page.waitForFunction(
+53
View File
@@ -0,0 +1,53 @@
<!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>
+177 -17
View File
@@ -89,15 +89,30 @@ async function loadData() {
// advance (content-pipeline §11.1, Python owns the randomness). The synthesized
// fallback ring has a pool of one, so it just returns that member. Returns a
// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button.
// Lookups for the pure preload planner (HEFPreload): map clip/morph → media file and
// test the in-memory blob cache. A clip is "loaded" only when its file is a cached blob.
function _preloadDeps() {
return {
baseOf: (id) => (clipsById[id] || {}).base_file,
morphFile: (a, b) => morphByPair[`${a}${b}`] || null,
isCached: (f) => !!f && !!mediaBlobs[f],
};
}
function poolIds(scale) {
return ((scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }]).map((m) => m.clip_id);
}
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
// Uniform random pool member, client-side (mirrors hef.selection.pick_clip_id).
// The drag/scroll navigation already resolves picks + morphs client-side via
// scrub.js; this removes the last /api/ring/advance dependency so the build is
// fully static. The synthesized fallback ring has a pool of one → returns it.
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
// Show only a clip whose base is already loaded (eligible). Initially that's the
// phase-1 first member; the pool grows as the background preload finishes. Fallback
// to the first member (guaranteed loaded by the phase-1 gate).
const ids = poolIds(scale);
let elig = HEFPreload.eligibleMembers(ids, _preloadDeps());
if (!elig.length) elig = [ids[0]];
return HEFPreload.pick(elig, Math.random);
}
// Land on the current scale: pick a random pool member and force its media to load.
@@ -180,6 +195,18 @@ function preloadOrder() {
return ordered;
}
// Cache a fixed list of files (bounded concurrency), driving the "Loading Universe…"
// bar. Used for the phase-1 gate (one clip/altitude + connecting morphs) before unlock.
async function cacheMany(files, concurrency = 6) {
const total = files.length || 1;
let done = 0, i = 0;
setLoadingProgress(0, total);
async function worker() {
while (i < files.length) { await cacheClip(files[i++]); done++; setLoadingProgress(done, total); }
}
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
}
// Fetch one media file into the blob cache (idempotent — cached files are skipped).
async function cacheClip(file) {
if (!file || mediaBlobs[file]) return;
@@ -285,6 +312,7 @@ function loadLoop(clip, landFrame) {
}
function playLoop() {
if (isReduced()) return; // reduced motion holds a still frame — never auto-play the loop
if (loopVid.paused) loopVid.play().catch(() => {});
}
@@ -685,9 +713,28 @@ function renderScaleReadout() {
const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
$("scale-name").textContent = `${scaleName} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
renderDial();
announceState(); // narrate the new altitude (scale + clip) for screen readers
refreshDevClip(); // keep the Dev Mode pool picker + clip data in sync with the landing
}
// Turn the visual alteration into words for assistive tech: the current scale plus
// the clip you've landed on. Deduped so a settle on the same state stays quiet.
let lastAnnounced = "";
function announce(text) {
const el = $("sr-status");
if (!el || !text || text === lastAnnounced) return;
lastAnnounced = text;
el.textContent = text;
}
function announceState() {
if (!ring) return;
const s = ring.scales[ringIndex];
const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
const clip = activeClip();
const title = clip && clip.title ? clip.title : "";
announce(title ? `${scaleName}. ${title}` : scaleName);
}
function controls() {
// Video (on/off) and Audio (on/off) are orthogonal toggles (audio spec §2).
// Audio on = the per-altitude soundtrack (white-noise is deferred). One bipolar
@@ -770,10 +817,22 @@ const PER_ALTITUDE_MS = 1200;
let autoRaf = 0;
function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) {
if (!ring || ring.scales.length < 2) return;
if (isReduced()) { // reduced motion: no autonomous tween — jump to target + lock
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
setPos(targetPos);
return;
}
if (autoRaf) cancelAnimationFrame(autoRaf);
const fromPos = pos, dist = targetPos - fromPos;
if (!dist) { setPos(targetPos); return; }
const ms = perAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long
// Photosensitivity floor (WCAG 2.3.1): each altitude step is one luminance
// transition (a blended crossfade), so a single step must take >= 1/3 s to stay
// <= 3 flashes/sec. The clamp is a no-op at today's 1200ms but guards the floor
// if PER_ALTITUDE_MS is ever lowered. (Audit: the only other luminance sources —
// the #black cover fade (200ms, single, >=1.2s apart) and the audio-coupled
// crossfade — cannot repeat faster than 3/sec, so they need no clamp.)
const safeAltMs = HEFFlash.clampDurationMs(perAltMs, 1);
const ms = safeAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long
let startTs = null;
const tick = (ts) => {
if (startTs === null) startTs = ts;
@@ -840,6 +899,8 @@ function buildDial() {
const t = svg("text", {
x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central",
class: "dial-label", "data-index": String(i),
role: "button", tabindex: "0",
"aria-label": HEFi18n.pickUiString("scale." + ring.scales[i].id, activeLang),
}, dial);
t.textContent = ring.scales[i].id;
}
@@ -865,6 +926,16 @@ function renderDial() {
for (const el of dial.querySelectorAll(".dial-label")) {
el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex);
}
setDialAria();
}
// Reflect the committed altitude into the dial's slider semantics for assistive tech.
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));
}
function dialAngle(e) {
@@ -884,11 +955,18 @@ function angDelta(a, b) {
// destination pick (was the server's job in /api/ring/advance; moved here so the
// scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the
// canonical pure helper for the Pi player).
function pickPoolClip(index) {
function pickPoolClip(index, fromId) {
const n = ring.scales.length;
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
const ids = poolIds(s);
// Pick a DESTINATION only among clips fully loaded relative to fromId (base +
// morphs both ways) — so the transition never stalls on un-downloaded media. The
// eligible set grows as the background preload completes. Fallback to the first
// pool member (the phase-1 gate guarantees it + its connecting morphs are loaded).
let elig = fromId ? HEFPreload.eligibleDestinations(ids, fromId, _preloadDeps())
: HEFPreload.eligibleMembers(ids, _preloadDeps());
if (!elig.length) elig = [ids[0]];
return HEFPreload.pick(elig, Math.random);
}
// Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude
@@ -898,8 +976,8 @@ function pickPoolClip(index) {
function rebuildSegment(lo, enteredFrom) {
const n = ring.scales.length;
const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n);
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo);
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1);
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo, activeClipId);
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1, activeClipId);
const file = morphByPair[`${loId}${hiId}`] || null;
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
if (file) {
@@ -945,7 +1023,8 @@ function setPos(next) {
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
playLoop(); // the loop element was preloaded to this clip during the scrub → instant, no reload
if (!isReduced()) playLoop(); // reduced motion holds the landing frame; else run the base loop
else { vid.pause(); loopVid.pause(); }
showActiveSource();
setNeedle(ringIndex * dialStep());
renderScaleReadout();
@@ -1010,14 +1089,88 @@ function jumpToScale(idx) {
if (d) autoScrub(Math.round(pos) + d);
}
// Keyboard operation of the dial (role="slider"): arrows step one altitude, Home/End
// jump to the ends. Enter/Space on a focused label jumps to that scale.
function onDialKey(e) {
if (!ring || ring.scales.length < 2) return;
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"));
return;
}
let handled = true;
switch (e.key) {
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break; // descend
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break; // ascend
case "Home": jumpToScale(0); break;
case "End": jumpToScale(ring.scales.length - 1); break;
default: handled = false;
}
if (handled) e.preventDefault();
}
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
// Off by default, state persisted in localStorage. Everything here is read from
// data the renderer already has (clips, ring, the alteration response, the preload
// cache) — no server endpoints. Editing labels stays in /author.html.
const DEV_KEY = "hef.devMode";
const WARN_KEY = "hef.motionWarnDismissed";
const RM_KEY = "hef.reduceMotion";
let devMode = false;
let devStatsTimer = null;
// --- Reduced motion (freeze-to-stills) ---------------------------------------
// Default follows the OS `prefers-reduced-motion` until the visitor chooses, then
// their choice persists. When ON: video holds a frame (paused), auto transitions
// jump instantly instead of tweening — knob changes still re-grade the still.
let reduceMotion = false;
function isReduced() { return reduceMotion; }
function initReduceMotion() {
const box = $("reduce-motion");
let stored = null;
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
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(); // resume the held loop on opt-out
}
}
// One-time photosensitivity notice shown over the stage before the experience
// begins. Independent of media load; gated visually by the loading splash, then
// dismissed-once (persisted) so returning visitors go straight to "Run simulation".
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");
const btn = $("run-sim");
if (btn) btn.focus({ preventScroll: true }); // move focus to the entry point
}, { once: true });
}
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
@@ -1366,6 +1519,7 @@ async function main() {
buildDial(); // draw the altitude knob from the ring's scales
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
initLanguage(); // populate the language dropdown + wire live switching
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
renderScaleReadout();
// Sliders stream on "input"; the toggles fire on "change".
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
@@ -1392,14 +1546,20 @@ async function main() {
window.addEventListener("pointermove", onDialMove);
window.addEventListener("pointerup", onDialUp);
dial.addEventListener("wheel", onWheel, { passive: false });
dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels
$("stage").addEventListener("wheel", onWheel, { passive: false });
update(); // render the initial state (both toggles off → black, silent)
// Preload BEFORE interaction: a base for every altitude + all related morphs, so
// turning the knob is always smooth (no on-demand stall, no blank next scene). The
// "Loading Universe…" bar tracks this; the universe stays gated until it's ready.
await preloadAllMedia();
// Phase 1 — gate the universe on a minimal, fully-loaded set: one clip per altitude
// + the morphs connecting them (~126 MB). Fast to start; the knob is smooth from the
// first turn because the random pick only ever lands on loaded clips (eligibility).
await cacheMany(HEFPreload.phase1Files(ring.scales, _preloadDeps()));
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
$("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage
// Phase 2 — grow the pool in the background: more clips per altitude + their morphs.
// Each becomes eligible for the random pick only once fully loaded, so navigation
// keeps using the already-loaded clips until the new ones are ready (no stall).
preloadAllMedia(); // NO await — streams the rest behind the unlocked universe
maybeShowMotionWarning(); // first-visit photosensitivity notice, above the button
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
// outside a user gesture (muted video survives, sound does not), so an auto-start
// would show video but stay silent. Instead the experience waits for the operator to
+20
View File
@@ -0,0 +1,20 @@
// 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 };
});
+16
View File
@@ -0,0 +1,16 @@
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
});
+5
View File
@@ -31,6 +31,11 @@
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
"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" },
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
"scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
+26 -8
View File
@@ -15,6 +15,7 @@
</div>
<header>
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits &amp; licenses</a>
</header>
<main>
@@ -24,21 +25,26 @@
<video id="vid-loop" loop muted playsinline crossorigin="anonymous"></video>
<audio id="aud" loop preload="auto" crossorigin="anonymous"></audio>
<audio id="aud-b" loop preload="auto" crossorigin="anonymous"></audio>
<canvas id="paint"></canvas>
<div id="tint"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<canvas id="paint" aria-hidden="true"></canvas>
<div id="tint" aria-hidden="true"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
<div id="black" class="black hidden"></div>
<button type="button" id="run-sim" class="run-sim hidden" data-i18n="run.button">Run simulation</button>
<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>
</div>
</section>
<section class="panel">
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
<fieldset>
<legend data-i18n="output.legend">Output</legend>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
</label>
<label class="dev-switch" for="visual">
<input type="checkbox" id="visual" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
@@ -48,12 +54,22 @@
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
<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>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
</label>
</fieldset>
<fieldset>
<legend data-i18n="altitude.legend">Altitude</legend>
<div class="dial-wrap">
<svg id="dial" viewBox="0 0 100 100" aria-label="Altitude knob (turn to change scale)"></svg>
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn or use arrow keys to change scale"
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
</div>
<span id="scale-name" class="scale-name"></span>
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
@@ -114,6 +130,8 @@
<script src="config.js"></script>
<script src="scrub.js"></script>
<script src="alteration.js"></script>
<script src="preload.js"></script>
<script src="flash.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
</body>
+52
View File
@@ -0,0 +1,52 @@
// Pure preload planning — no DOM. The boot caches phase1Files() before unlocking the
// universe; the random clip pick is then restricted to eligible clips (fully loaded
// base + connecting morphs), so a transition never stalls on un-downloaded media and
// the pool grows safely as the background preload finishes. UMD so the browser gets
// `HEFPreload` and `node --test` can require() it.
//
// deps shape: { baseOf(clipId)->file, morphFile(fromId,toId)->file|null, isCached(file)->bool }
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFPreload = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
// The minimal set to load before the simulator can run: the FIRST pool member of
// every altitude + the morphs connecting adjacent first-members (both directions,
// cyclic ring). One clip per altitude + corresponding morphs.
function phase1Files(scales, deps) {
const n = scales.length;
const chosen = scales.map((s) => (s.pool && s.pool.length ? s.pool[0].clip_id : s.clip_id));
const files = new Set();
for (const c of chosen) {
const bf = deps.baseOf(c);
if (bf) files.add(bf);
}
for (let i = 0; i < n; i++) {
const a = chosen[i], b = chosen[(i + 1) % n];
for (const f of [deps.morphFile(a, b), deps.morphFile(b, a)]) if (f) files.add(f);
}
return [...files];
}
// Pool members eligible as a DESTINATION from `fromId`: base cached AND the morph
// cached BOTH ways (so the forward transition and a turn-back are ready).
function eligibleDestinations(poolIds, fromId, deps) {
return poolIds.filter((c) =>
deps.isCached(deps.baseOf(c)) &&
deps.isCached(deps.morphFile(fromId, c)) &&
deps.isCached(deps.morphFile(c, fromId)));
}
// Pool members eligible to SHOW at their own altitude (no transition): base cached.
function eligibleMembers(poolIds, deps) {
return poolIds.filter((c) => deps.isCached(deps.baseOf(c)));
}
function pick(list, rnd) {
return list.length ? list[Math.floor(rnd() * list.length)] : null;
}
return { phase1Files, eligibleDestinations, eligibleMembers, pick };
});
+26 -3
View File
@@ -90,8 +90,23 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
}
.run-sim:hover { transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
.run-sim:active { transform: translate(-50%, -50%) scale(0.98); }
/* One-time photosensitivity warning, over the stage (above run-sim's z-60). */
.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: #c3d2e8; }
/* The Continue button is centered in flow here, not absolutely positioned. */
.mw-card .run-sim { position: static; transform: none; }
.mw-card .run-sim:hover { transform: scale(1.04); }
.mw-card .run-sim:active { transform: scale(0.98); }
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
.dev-switch + .dev-switch { margin-top: 0.45rem; }
/* 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.6rem 0 0.2rem; }
.lang-pick select { flex: 1; width: auto; }
/* Live audio status readout (diagnostic) — turns green when actually playing. */
.audio-status { margin-top: 0.5rem; font: 11px/1.4 monospace; color: #9ab; }
.audio-status.playing { color: #4e9; }
@@ -112,17 +127,25 @@ input[type=range], select { width: 100%; }
.dial-rim { fill: #0d1320; stroke: #243352; stroke-width: 1.2; }
.dial-body { fill: #16203200; stroke: #2c3c5c; stroke-width: 1; }
.dial-tick { stroke: #3a4d70; stroke-width: 0.8; }
.dial-label { fill: #789ac0; font-size: 6px; font-family: ui-monospace, monospace;
.dial-label { fill: #b8cfe6; font-size: 6px; font-family: ui-monospace, monospace;
letter-spacing: 0.2px; cursor: pointer; }
.dial-label:hover { fill: #cde; }
.dial-label.active { fill: #9cf; font-weight: 700; }
.dial-caption { fill: #4d6184; font-size: 4.4px; letter-spacing: 1.2px;
.dial-caption { fill: #8fa6c4; font-size: 4.4px; letter-spacing: 1.2px;
font-family: ui-monospace, monospace; }
.dial-needle { fill: #9cf; }
.dial-needle polygon { filter: drop-shadow(0 0 1px #9cf); }
.dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; }
.scale-name { display: block; text-align: center; font-size: 12px; color: #cde; }
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #9fb3c8; }
/* 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; border-radius: 50%; }
/* Screen-reader-only text (announcements, labels): present in the 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;
}
/* --- Dev Mode --- a single switch; all dev controls/data live in #dev-panel below it. */
.dev-switch { display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
+53
View File
@@ -0,0 +1,53 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const P = require("../static/preload.js");
// Tiny fixture: 3-altitude cyclic ring, 2 pool members each. base_file = "<id>.b";
// morph file = "<from>>-<to>" when it exists.
const scales = [
{ id: "a", pool: [{ clip_id: "a1" }, { clip_id: "a2" }] },
{ id: "b", pool: [{ clip_id: "b1" }, { clip_id: "b2" }] },
{ id: "c", pool: [{ clip_id: "c1" }, { clip_id: "c2" }] },
];
const baseOf = (id) => id + ".b";
const morphFile = (a, b) => `${a}>-${b}`; // pretend every pair has a morph
const cached = new Set();
const deps = () => ({ baseOf, morphFile, isCached: (f) => cached.has(f) });
test("phase1Files = first member of each altitude + connecting morphs (both dirs, cyclic)", () => {
const files = P.phase1Files(scales, deps());
// bases: a1.b b1.b c1.b
for (const b of ["a1.b", "b1.b", "c1.b"]) assert.ok(files.includes(b), `missing base ${b}`);
assert.ok(!files.includes("a2.b"), "second members must NOT be in phase 1");
// connecting morphs between adjacent first-members, both directions, cyclic (a-b, b-c, c-a)
for (const m of ["a1>-b1", "b1>-a1", "b1>-c1", "c1>-b1", "c1>-a1", "a1>-c1"])
assert.ok(files.includes(m), `missing morph ${m}`);
assert.equal(files.length, 3 + 6);
});
test("eligibleDestinations requires base + BOTH morphs cached", () => {
cached.clear();
// from a1, candidate b1: nothing cached → not eligible
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), []);
cached.add("b1.b"); cached.add("a1>-b1"); // base + forward only
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), [], "reverse morph still missing");
cached.add("b1>-a1"); // now reverse too
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), ["b1"]);
// b2 fully cached too → both eligible
cached.add("b2.b"); cached.add("a1>-b2"); cached.add("b2>-a1");
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()).sort(), ["b1", "b2"]);
});
test("eligibleMembers requires only the base cached", () => {
cached.clear();
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), []);
cached.add("a1.b");
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), ["a1"]);
});
test("pick returns null on empty, a member otherwise", () => {
assert.equal(P.pick([], Math.random), null);
assert.equal(P.pick(["x"], () => 0), "x");
assert.equal(P.pick(["x", "y", "z"], () => 0.99), "z");
});
+4 -3
View File
@@ -36,8 +36,8 @@ from simulator.app import MEDIA_DIR, create_app
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
"credits.html", "credits.js"]
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js",
"style.css", "credits.html", "credits.js", "about.html", "flash.js"]
def _bake_api(app_dir: Path) -> dict:
@@ -84,7 +84,8 @@ def _version_assets(app_dir: Path) -> None:
# = guaranteed-fresh fetch on a normal reload. The HTML itself is served no-cache
# (_headers), but Cloudflare Pages caches .js/.css by type (max-age=14400) and
# ignores _headers for them — so without this, returning visitors run stale JS.
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "style.css", "credits.js", "config.js"]
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js", "flash.js",
"style.css", "credits.js", "config.js"]
tok = {}
for a in assets:
p = app_dir / a