# 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).
