Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls

# Conflicts:
#	simulator/e2e/tests/a11y.spec.ts
#	simulator/static/app.js
#	simulator/static/i18n.js
This commit is contained in:
BenStullsBets
2026-06-30 12:54:06 -07:00
37 changed files with 4904 additions and 91 deletions
@@ -0,0 +1,133 @@
# Playback Speed slider (replaces "Reduce motion") — design
**Date:** 2026-06-30
**Status:** Approved (pending written-spec review)
**Scope:** Frontend only (`simulator/static/`). No engine, pipeline, or media changes.
## Summary
Add a continuous **Playback Speed** slider (2× … 2×) that controls the resting
**altitude loop video** (`#vid-loop`) in real time as the user drags it. Below
`0×` the loop plays **in reverse**. The slider **replaces** the "Reduce motion"
toggle in the Output panel — but the reduced-motion *accessibility behavior* is
preserved, now derived silently from the OS `prefers-reduced-motion` setting
rather than a visible toggle.
## What it affects (and what it does not)
- **Affects:** the resting **altitude loop video** `#vid-loop` — the clip that
loops while parked at an altitude. The slider sets its play speed/direction.
- **Does NOT affect:** the **morph / transition video** `#vid`. That element is
scrub-driven — the altitude dial sets its `currentTime` directly — so a
playback rate has no meaning there. Speed control never touches it.
## UI
Replace the `reduce-motion` checkbox `<label>` in `index.html` with a range
slider, styled like the existing Audio level control:
```
Speed [ −2 ········●········ 2 ] 1.37×
```
- `<input type="range" id="play-speed" min="-2" max="2" step="any" value="1">`
`step="any"` so dragging yields a **continuous** value, not 0.25 detents.
- A live numeric **readout** (`<span id="play-speed-val">`) shows the current
value formatted to 2 decimals with a `×` suffix (e.g. `1.37×`, `0.75×`,
`0.00×`).
- Reference **tick labels** at the nice increments (`2 1.75 … 0 … 2`) via a
`<datalist>` bound to the input, so the round numbers are visible without
quantizing the actual applied value.
- New i18n key `speed.label` ("Speed") in en/es/fr/ja. The `rm.label` key is
retired.
## Accessibility (preserved, now invisible)
The reduce-motion *toggle* is removed, but its *behavior* is kept. `isReduced()`
now derives **live from the OS setting only**
`matchMedia("(prefers-reduced-motion: reduce)")`, re-read on its `change` event —
with no stored override and no UI. Every existing `isReduced()` call site keeps
working unchanged:
- loop-play gate (`playLoop`),
- instant (no-tween) auto altitude transitions (`autoScrub`),
- the photosensitivity-flash luminance floor.
Consequences:
- The `hef.reduceMotion` localStorage key (`RM_KEY`) is **retired**.
- When OS reduced-motion is **active**: the loop stays frozen and the speed
slider is **disabled** (with a short hint), so a vestibular/photosensitive
visitor never gets motion they did not ask for. If the OS pref toggles at
runtime, the slider enables/disables to match.
- `warn.body` copy (all 4 langs) is reworded: it currently tells users to "turn
on Reduce motion in the panel," which no longer exists. New copy points to the
system reduced-motion setting (now auto-applied).
## Speed behavior (`app.js`)
A new `applyPlaySpeed(speed)` drives `#vid-loop`, with the chosen value persisted
to `localStorage["hef.playSpeed"]` (default `1`). Applied live on the slider's
`input` event:
- **speed > 0** → `loopVid.playbackRate = speed`; native forward play. The
existing duration→`loopStartFrame()` wrap is unchanged. Any active manual
reverse loop is cancelled.
- **|speed| ≈ 0** (within a small epsilon) → pause the element (freeze on the
current frame). Reversible by moving the slider off zero. Cancels any reverse
loop.
- **speed < 0** → native negative `playbackRate` is unreliable (Chrome ignores
it), so a `requestAnimationFrame` loop owns the element: the element is
**paused** and each frame decrements `loopVid.currentTime` by `|speed|·dt`.
When it crosses the bottom boundary it **wraps to the tail** (mirror of the
forward wrap), reusing the direction-aware landing offset. The loop is
cancelled when speed goes ≥ 0, on reduced-motion freeze, when video output is
turned off, or when a new loop clip loads.
Interaction with existing motion:
- The manual reverse loop and the **auto-transition tween** (`autoRaf`, which
drives the *morph* `#vid`) are independent — different elements, different
rAF handles. The reverse loop only owns `#vid-loop`.
- On landing on a new altitude (`loadLoop` + `playLoop`), the current slider
speed is re-applied so the new clip inherits the chosen rate/direction.
## Test seam + tests
Mirrors the existing `scrub.js` / `node --test` pattern:
- A **pure** helper added to `scrub.js`:
`reverseStep(t, absSpeed, dt, loopStart, duration) → { next, wrapped }`
— computes the next `currentTime` and whether a bottom-boundary wrap occurred.
This is the reverse-loop math with no DOM; `app.js` just applies `next` to
`loopVid.currentTime`. Covered by a new `node --test` case (steps, wrap at the
boundary, clamping).
- A **Playwright** e2e added to the existing suite: the slider is present;
forward value sets `#vid-loop.playbackRate`; `0×` pauses it; a negative value
makes `currentTime` decrease over time. Also assert the slider is disabled
when `prefers-reduced-motion` is emulated.
## Known risk (noted, not blocking)
Reverse smoothness depends on the **base loop clips'** keyframe density. The
morphs are all-intra (smooth reverse), but the base altitude clips may not be;
on some browsers reverse seeking could stutter. The manual loop stays *correct*
regardless — smoothness is a visual-polish follow-up (re-encode base clips
all-intra if needed), not a blocker for this change.
## Files touched
- `simulator/static/index.html` — swap toggle → slider + datalist; reword warn.
- `simulator/static/app.js``applyPlaySpeed`, reverse rAF loop, derive
`isReduced()` from OS pref, retire `RM_KEY`, re-apply speed on landing.
- `simulator/static/scrub.js` — add pure `reverseStep` helper.
- `simulator/static/i18n.js` — add `speed.label`, reword `warn.body` (en/es/fr/ja),
retire `rm.label`.
- `simulator/static/styles.css` — slider styling (reuse audio-level pattern).
- Tests: `node --test` for `reverseStep`; Playwright e2e for the slider.
## Out of scope
- Re-encoding base clips for smoother reverse (follow-up if stutter is visible).
- Any speed control over morph/transition videos.
- Per-altitude or audio speed coupling (audio level is its own control).