Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54cad607fb | |||
| 053bb5c1a3 | |||
| e064a2e684 | |||
| df8b245af3 | |||
| 539618b1b8 | |||
| 22bd152f33 | |||
| 5696b94328 | |||
| 1b646c8ade |
@@ -0,0 +1,142 @@
|
||||
# Playback Speed slider (replaces "Reduce motion") — design
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Status:** Shipped, then revised
|
||||
**Scope:** Frontend only (`simulator/static/`). No engine, pipeline, or media changes.
|
||||
|
||||
> **Revision (2026-06-30, post-eyeball):** Reverse playback was **dropped**. In a
|
||||
> real browser the below-0× reverse loop jittered — the base loop clips aren't
|
||||
> all-intra, so backward seeking can't stay smooth (exactly the risk flagged
|
||||
> below). The slider is now **0×–4× forward** (still continuous, `step="any"`).
|
||||
> `0×` still freezes; OS reduced-motion behavior is unchanged. The
|
||||
> `HEFScrub.reverseStep` helper and its tests were removed. Sections below
|
||||
> describing the −2…2 range and the manual reverse loop are superseded by this
|
||||
> note.
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Session 0037.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T10-42 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: aa7ac424-d797-4918-8837-2a904441d91e
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/session-0037-altitude-snap-on-release
|
||||
> End: 2026-06-30T10-52 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
|
||||
## Summary
|
||||
|
||||
Single-task UX session. Made the altitude dial **snap to the closest altitude on
|
||||
drag release** so a grab-and-release always lands on a discrete altitude, just
|
||||
like a tap/label-click.
|
||||
|
||||
### Arc
|
||||
|
||||
1. Claimed session 0037 (two stale `--INPROGRESS` placeholders 0015/0017 noted,
|
||||
not live). Worked in an isolated worktree
|
||||
(`session-0037-altitude-snap-on-release`) branched off `origin/main` (local
|
||||
`main` was 7 behind).
|
||||
2. Located the behavior: `onDialUp` (`simulator/static/app.js`) held the live
|
||||
blend wherever a real turn stopped — the "no auto-complete" continuous-encoder
|
||||
model. Tap path (`moved < 6` → `jumpToScale`) and wheel/label auto-scrub were
|
||||
already snap-to-altitude.
|
||||
3. TDD: added an `altitude-lock.spec.ts` e2e ("releasing a partial drag snaps to
|
||||
the closest altitude") — confirmed RED (held pos never became integer).
|
||||
4. Implemented: a real turn (`moved >= 6`) now `autoScrub(Math.round(pos))` →
|
||||
short scrub to the nearest altitude, settle + lock (frac 0). Reduced motion is
|
||||
handled inside `autoScrub` (jump-to-target). GREEN.
|
||||
5. Verified: full altitude-lock (14) + a11y + i18n e2e suites green. Committed,
|
||||
pushed, opened PR #31, merged to `main` (`5696b94`).
|
||||
|
||||
### Pipeline (§9)
|
||||
|
||||
This app is **exempt** from flotilla/PPE/§9 prod gate — public target is static
|
||||
Cloudflare (Pages + R2). Change is merged to `main`; **operator redeploy** of the
|
||||
static build is the remaining ship step. No plan artifact archived: the change
|
||||
was a trivial leaf `task`, planned inline (no separate plan file; app has no
|
||||
content repo).
|
||||
|
||||
### Next session
|
||||
|
||||
Operator by-eye review of the snap feel on the live sim, then redeploy to
|
||||
Cloudflare.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> When the user is dragging the slider (rather than clicking on an altitude),
|
||||
> let's transition to the closest altitude when they let go. That way, the user
|
||||
> will always land on an altitude as if they clicked one, even if they're
|
||||
> grabbing and releasing the slider.
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: leaf `task` (UX behavior tweak — directly plan-eligible, R2b).
|
||||
|
||||
Snap-on-release for the altitude dial. Today `onDialUp` (app.js) holds the live
|
||||
blend wherever a drag stops (the "no auto-complete" continuous-encoder model).
|
||||
Change: after a real turn (`moved >= 6`), `autoScrub(Math.round(pos))` so the
|
||||
knob settles on the CLOSEST altitude (frac 0 → lock), identical to a tap/click.
|
||||
Tap path (`moved < 6` → `jumpToScale`) unchanged. Reduced-motion is handled by
|
||||
`autoScrub` (jump-to-target, no tween).
|
||||
|
||||
Verification: new e2e in `altitude-lock.spec.ts` ("releasing a partial drag
|
||||
snaps to the closest altitude") — TDD red→green; full altitude-lock (14) + a11y
|
||||
+ i18n suites green. Project is exempt from §9/PPE (static Cloudflare target);
|
||||
ships branch → PR → merge → operator redeploy.
|
||||
|
||||
## 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._
|
||||
@@ -0,0 +1,102 @@
|
||||
# Session 0038.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T12-25 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 52b811fa-0926-4619-ae31-3c71d489c686
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> End: 2026-06-30T13-20 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
|
||||
## Summary
|
||||
|
||||
Added a **Playback Speed slider** to the simulator's Output panel, replacing the
|
||||
"Reduce motion" toggle. Designed (brainstorm → spec), built TDD-first, shipped to
|
||||
`main`, then **revised the scale after operator eyeball**: the initial −2×…2×
|
||||
range with a manual reverse loop jittered in a real browser (base loop clips
|
||||
aren't all-intra), so it was narrowed to **0×–4× forward** and the reverse
|
||||
machinery removed.
|
||||
|
||||
### Arc
|
||||
|
||||
1. **Brainstorm** (no tracked init this session — claimed s0038 at finalize).
|
||||
Surfaced the real tradeoff: "Reduce motion" is the a11y safety control
|
||||
(`isReduced()`), doing three things beyond freezing the loop (OS-pref default,
|
||||
instant transitions, photosensitivity floor). Two operator decisions via
|
||||
AskUserQuestion: **keep a11y but move it** (derive from OS
|
||||
`prefers-reduced-motion`, no visible toggle) and **implement a true manual
|
||||
reverse loop** for the negative range.
|
||||
2. **Spec** written + committed to
|
||||
`docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md`.
|
||||
3. **Build (round 1, −2…2 + reverse), TDD:** worked in worktree
|
||||
`feat/playback-speed-slider` (off `origin/main`). Pure node-tested
|
||||
`HEFScrub.reverseStep` helper (RED→GREEN); slider + datalist in `index.html`;
|
||||
`applyPlaySpeed` + rAF reverse loop in `app.js`; `isReduced()` re-derived from
|
||||
OS pref; retired `hef.reduceMotion`; i18n `speed.label` + reworded `warn.body`.
|
||||
Verified 14/14 node unit, 13/13 a11y e2e (Task 5 rewritten), static-build e2e
|
||||
2/2 (built the dist bundle to confirm). Merged to `main` (`df8b245`) —
|
||||
concurrent session pushed mid-merge, so rebased onto the new tip and pushed.
|
||||
4. **Operator eyeball:** reverse was jittery; operator chose **0×–4×** as the
|
||||
scale.
|
||||
5. **Build (round 2, 0×–4× forward):** worktree `feat/speed-forward-0-4x`.
|
||||
Removed the reverse rAF loop + `reverseStep` helper + its 4 unit tests;
|
||||
slider `min=0 max=4`; datalist ticks at 0.5; spec annotated with a revision
|
||||
note. Verified 10/10 node unit, 12/12 a11y e2e (against the actually-served
|
||||
worktree code). Merged to `main` (`e064a2e`, clean FF) and pushed.
|
||||
6. Served the merged code locally at `http://localhost:8099/` for re-view both
|
||||
rounds.
|
||||
|
||||
### Pipeline (§9)
|
||||
|
||||
This app is **exempt** from flotilla/PPE/§9 prod gate — public target is static
|
||||
Cloudflare (Pages + R2). Change is merged to `main`; **operator redeploy** of the
|
||||
static build is the remaining ship step. E2E browser tests (Playwright) were run
|
||||
locally and are green. No plan artifact archived: design lived in the spec
|
||||
(already in this repo's `specs/`, merged); app has no `content` repo.
|
||||
|
||||
### Next session
|
||||
|
||||
Operator by-eye review of the 0×–4× speed feel on the live sim, then redeploy to
|
||||
Cloudflare to publish it.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Let's add a "Playback Speed" slider that goes from -2x (which would play
|
||||
> backwards) to 2x, with increments of 0.25. This only applies to the altitude
|
||||
> videos, not the morph videos. This can replace the "Reduce motion" toggle
|
||||
|
||||
_(Mid-session the operator refined: continuous drag rather than 0.25 detents;
|
||||
then, after eyeballing reverse jitter, narrowed the scale to 0×–4× forward.)_
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: leaf `feature`/`task` (UI control on an existing surface). Design →
|
||||
> spec → TDD build → ship branch → PR/merge → operator redeploy.
|
||||
|
||||
Continuous Playback Speed slider driving ONLY the resting altitude loop video
|
||||
(`#vid-loop`); morph/transition video (`#vid`) is scrub-driven and untouched.
|
||||
Reduced-motion accessibility preserved but derived silently from OS
|
||||
`prefers-reduced-motion` (slider disabled when active). Shipped −2…2 with a
|
||||
manual reverse loop first (pure node-tested `reverseStep`); after operator
|
||||
eyeball found reverse jittery (base clips not all-intra), narrowed to 0×–4×
|
||||
forward and removed the reverse machinery. Verification: node unit + Playwright
|
||||
a11y e2e + static-build e2e, all green per round. Project exempt from §9/PPE
|
||||
(static Cloudflare).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Direct FF-merge to `main` (no PR).** Operator explicitly said "Merge to
|
||||
main," so both rounds landed via fast-forward (round 1 rebased over a
|
||||
concurrent push) rather than opening a Gitea PR. Alternative: branch → PR →
|
||||
merge (§5.4 norm). Operator-directed, so low risk.
|
||||
- **Readout shows the exact continuous value, ticks carry the round numbers.**
|
||||
The slider value is shown as e.g. `1.37×`; the "nice" increments (0.5 marks)
|
||||
live as `<datalist>` ticks rather than snapping the value. My call within the
|
||||
approved design; operator approved the spec.
|
||||
- **Datalist tick spacing 0.5 over 0–4** (vs 0.25 over −2…2 in round 1). Cosmetic
|
||||
choice to avoid clutter on the wider forward range. My call.
|
||||
- **Reverse fully removed, not kept as a smaller range.** Operator said "just go
|
||||
0x - 4x as the scale," so the `reverseStep` helper + tests were deleted rather
|
||||
than parked. If reverse is wanted later, re-encode base loop clips all-intra
|
||||
first (noted in memory + spec revision).
|
||||
@@ -106,5 +106,11 @@
|
||||
},
|
||||
"0036": {
|
||||
"title": ""
|
||||
},
|
||||
"0037": {
|
||||
"title": ""
|
||||
},
|
||||
"0038": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,23 +41,58 @@ test.describe("Task 4 — Photosensitivity warning gate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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("/");
|
||||
test.describe("Task 5 — Playback speed slider (replaces Reduce motion)", () => {
|
||||
// Helper: start the experience so #vid-loop is playing.
|
||||
async function startExperience(page: import("@playwright/test").Page) {
|
||||
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();
|
||||
.toBe(false); // experience running → loop plays
|
||||
}
|
||||
|
||||
test("the Reduce motion toggle is gone, replaced by a 0–4x speed slider", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#reduce-motion")).toHaveCount(0);
|
||||
const slider = page.locator("#play-speed");
|
||||
await expect(slider).toBeVisible();
|
||||
await expect(slider).toHaveValue("1"); // default = normal speed
|
||||
await expect(slider).toHaveAttribute("min", "0");
|
||||
await expect(slider).toHaveAttribute("max", "4");
|
||||
});
|
||||
|
||||
test("forward speed sets the loop video's playbackRate (up to 4x)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await startExperience(page);
|
||||
await page.locator("#play-speed").fill("4");
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
|
||||
.toBe(4);
|
||||
});
|
||||
|
||||
test("0x freezes the loop video", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await startExperience(page);
|
||||
await page.locator("#play-speed").fill("0");
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(true); // reduced motion → frozen
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("OS reduced-motion disables the slider and freezes the loop", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
await page.locator("#run-sim").click();
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator("#play-speed")).toBeDisabled();
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(true); // OS prefers-reduced-motion → frozen regardless of slider
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+61
-24
@@ -313,7 +313,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(() => {});
|
||||
applyPlaySpeed(); // honor the chosen speed/direction (forward, freeze, or reverse)
|
||||
}
|
||||
|
||||
function ensureClipMedia() {
|
||||
@@ -1120,39 +1120,76 @@ function onDialKey(e) {
|
||||
// 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";
|
||||
const SPEED_KEY = "hef.playSpeed";
|
||||
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");
|
||||
// --- Playback speed + reduced motion -----------------------------------------
|
||||
// "Reduce motion" is no longer a visible toggle — its accessibility behavior is
|
||||
// derived LIVE from the OS `prefers-reduced-motion` setting (no persisted
|
||||
// override). The visible control is the Playback Speed slider (0x–4x forward),
|
||||
// which drives ONLY the resting altitude loop video (#vid-loop); the
|
||||
// morph/transition video (#vid) is scrub-driven and is never speed-controlled.
|
||||
// 0x freezes the loop on its current frame. (Reverse playback was dropped: base
|
||||
// loop clips aren't all-intra, so native/manual reverse seeking jitters.) When
|
||||
// the OS asks for reduced motion the loop stays frozen and the slider is
|
||||
// disabled, so a vestibular/photosensitive visitor never gets motion they did
|
||||
// not ask for.
|
||||
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
|
||||
let playSpeed = 1;
|
||||
const reduceMq = (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)")) || null;
|
||||
function isReduced() { return !!(reduceMq && reduceMq.matches); }
|
||||
|
||||
// Apply the chosen slider speed to the resting loop video. When reduced motion
|
||||
// holds or video output is off, the freeze/play decision belongs to
|
||||
// syncReducedMotion / playLoop — here we only keep the forward rate sane for
|
||||
// when it resumes.
|
||||
function applyPlaySpeed() {
|
||||
if (isReduced() || !videoEverOn || !$("visual").checked) {
|
||||
loopVid.playbackRate = Math.max(0.0625, playSpeed || 1);
|
||||
return;
|
||||
}
|
||||
if (playSpeed > SPEED_EPS) {
|
||||
loopVid.playbackRate = playSpeed;
|
||||
if (loopVid.paused) loopVid.play().catch(() => {});
|
||||
} else {
|
||||
loopVid.pause(); // 0x → freeze on the current frame
|
||||
}
|
||||
}
|
||||
function updateSpeedLabel() {
|
||||
const el = $("play-speed-val");
|
||||
if (el) el.textContent = playSpeed.toFixed(2) + "×";
|
||||
}
|
||||
function initPlaySpeed() {
|
||||
const slider = $("play-speed");
|
||||
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();
|
||||
try { stored = localStorage.getItem(SPEED_KEY); } catch (_) {}
|
||||
playSpeed = stored === null ? 1 : (parseFloat(stored) || 0);
|
||||
if (slider) {
|
||||
slider.value = String(playSpeed);
|
||||
slider.addEventListener("input", () => {
|
||||
playSpeed = parseFloat(slider.value) || 0;
|
||||
try { localStorage.setItem(SPEED_KEY, String(playSpeed)); } catch (_) {}
|
||||
updateSpeedLabel();
|
||||
applyPlaySpeed();
|
||||
});
|
||||
}
|
||||
applyReduceMotion();
|
||||
updateSpeedLabel();
|
||||
syncReducedMotion(); // initial enable/disable + freeze/play state
|
||||
if (reduceMq && reduceMq.addEventListener) reduceMq.addEventListener("change", syncReducedMotion);
|
||||
}
|
||||
function applyReduceMotion() {
|
||||
if (reduceMotion) {
|
||||
// Reconcile to the OS prefers-reduced-motion setting: when reduced, freeze both
|
||||
// videos and disable the speed slider; otherwise (re)apply the chosen speed.
|
||||
function syncReducedMotion() {
|
||||
const slider = $("play-speed");
|
||||
const reduced = isReduced();
|
||||
if (slider) slider.disabled = reduced;
|
||||
if (reduced) {
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
vid.pause();
|
||||
loopVid.pause();
|
||||
} else if (videoEverOn && $("visual").checked) {
|
||||
playLoop(); // resume the held loop on opt-out
|
||||
applyPlaySpeed(); // resume the held loop at the chosen speed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1523,7 +1560,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)
|
||||
initPlaySpeed(); // playback-speed slider + OS-derived reduced motion
|
||||
renderScaleReadout();
|
||||
// Sliders stream on "input"; the toggles fire on "change".
|
||||
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
"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: "動きを減らす" },
|
||||
"speed.label": { en: "Speed", es: "Velocidad", fr: "Vitesse", 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.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on your device’s “Reduce motion” setting before you begin — the experience honours it automatically." },
|
||||
"warn.continue": { en: "Continue" },
|
||||
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
|
||||
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
|
||||
|
||||
@@ -54,11 +54,16 @@
|
||||
<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 class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
|
||||
<input type="range" id="play-speed" min="0" max="4" step="any" value="1"
|
||||
list="play-speed-ticks" aria-describedby="play-speed-val" />
|
||||
<span id="play-speed-val">1.00×</span>
|
||||
</label>
|
||||
<datalist id="play-speed-ticks">
|
||||
<option value="0"></option><option value="0.5"></option><option value="1"></option>
|
||||
<option value="1.5"></option><option value="2"></option><option value="2.5"></option>
|
||||
<option value="3"></option><option value="3.5"></option><option value="4"></option>
|
||||
</datalist>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
</label>
|
||||
|
||||
@@ -164,6 +164,10 @@ input[type=range], select { width: 100%; }
|
||||
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.audio-level input[type=range] { flex: 1; width: auto; }
|
||||
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
|
||||
/* Playback speed (−2×…2×) reuses the audio-level row look; wider value field for the sign + decimals. */
|
||||
#play-speed-val { font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; }
|
||||
.play-speed input[type=range]:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.play-speed:has(input:disabled) { opacity: 0.6; }
|
||||
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
|
||||
win, so the panel truly collapses when Dev Mode is off. */
|
||||
|
||||
Reference in New Issue
Block a user