From 0334fc8bc4e868a2ea4ce992e57c30b682a74a71 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 10:36:01 -0700 Subject: [PATCH 1/7] claim human-experience-filter-art session 0036 (placeholder) + sessions.json entry --- ...TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md | 23 +++++++++++++++++++ sessions/sessions.json | 3 +++ 2 files changed, 26 insertions(+) create mode 100644 sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md diff --git a/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md b/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md new file mode 100644 index 0000000..77b9a9a --- /dev/null +++ b/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md @@ -0,0 +1,23 @@ +# Session 0036.0 — Transcript + +> App: human-experience-filter-art +> Start: 2026-06-30T06-30 (PST) +> Type: planning-and-executing +> Posture: yolo +> Claude-Session: a7814ad4-473f-4946-b158-a5fad35b4bf1 +> 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 0036 for human-experience-filter-art. The driver replaces this +> body with the full transcript and renames the file to its final +> SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--.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._ diff --git a/sessions/sessions.json b/sessions/sessions.json index 93fe965..d044dc2 100644 --- a/sessions/sessions.json +++ b/sessions/sessions.json @@ -103,5 +103,8 @@ }, "0035": { "title": "" + }, + "0036": { + "title": "" } } From 00fd9aab17a1401160b102327ebc4c63f2082ee4 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 10:39:44 -0700 Subject: [PATCH 2/7] add sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--2026-06-30T12-00.md + replace placeholder/variant SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md --- ...RIPT-2026-06-30T06-30--2026-06-30T12-00.md | 108 ++++++++++++++++++ ...TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md | 23 ---- 2 files changed, 108 insertions(+), 23 deletions(-) create mode 100644 sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--2026-06-30T12-00.md delete mode 100644 sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md diff --git a/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--2026-06-30T12-00.md b/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--2026-06-30T12-00.md new file mode 100644 index 0000000..9f7b35a --- /dev/null +++ b/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--2026-06-30T12-00.md @@ -0,0 +1,108 @@ +# Session 0036.0 — Transcript + +> App: human-experience-filter-art +> Start: 2026-06-30T06-30 (PST) +> Type: planning-and-executing +> Posture: yolo +> Claude-Session: a7814ad4-473f-4946-b158-a5fad35b4bf1 +> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art +> Status: finalized + +## Launch prompt + +"Let's publish this to benstull.art. Ideally this would be hosted on Cloudflare +Pages. Cloudflare Pages does have limits though, so we should check that against +this project." (Started conversationally — no tracked init; finalized as +planning-and-executing/yolo.) + +## Pre-state + +The simulator was a FastAPI server (`simulator/app.py`) serving a ~92 KB static +frontend + 3.9 GB of media (1080p clip bases + 558 already-720p transition morphs + +audio) via dynamic endpoints. Not yet public anywhere. Operator wanted it live at +benstull.art on Cloudflare Pages. Concurrent sessions (0029/0031/0033/0034/0035) +were active on the SAME checkout. + +## Arc + +**1. Brainstorm → design → plan (Cloudflare publish).** Established the blocker: +Pages has a 25 MiB/file hard limit; the project has 26 files >25 MiB. Designed a +fully-static target: Pages frontend + R2 media (no per-file limit, free egress) + +read-APIs baked to JSON + client-side pick + `/api/alteration` ported to JS. +Operator chose: both tiers irrelevant→dropped; path `/human-experience-simulator` +with apex redirect; no cross-session cache (initially). Spec + plan written under +`docs/superpowers/`. Operator declared the WHOLE project exempt from flotilla/PPE/§9. + +**2. Executed the plan (5 tasks).** Client-side alteration engine (`alteration.js` ++ node/py parity tests); config-driven `mediaBase` + baked-JSON boot + client pick + +`crossOrigin` for the WebGL dream shader; `tools/build_static.py` (dist/ + manifest- +only ~2.3 GB media tree + `_redirects`); Cloudflare deploy artifacts; static-build +E2E. Hit + cleanly extracted a parallel session's in-flight "Run simulation" work +that got swept into a commit (operator chose extraction). + +**3. Live deploy.** Installed wrangler; operator did `wrangler login`. Created R2 +bucket `human-experience-simulator-media`, CORS (wrangler's `{rules:[...]}` schema, +not S3-style), bound `static.benstull.art` via CLI (zone-id from API), parallel +604-file upload (no `wrangler r2 sync` exists). Created + deployed Pages project; +operator bound `benstull.art`. **Live.** + +**4. Debugging (systematic).** "Morphs not showing" = upload still in progress +(transitions sort last) → fixed by parallel upload. "Video + audio not playing" → +drove the LIVE site with Playwright: video fine, **audio 404'd** because +`scaleAudioUrl` hardcoded `/media/audio/` instead of `mediaBase()` — fixed. +"Still no video after hard refresh" → **stale cached JS** (Pages caches .js +max-age=14400, ignores `_headers`) → fixed with content-versioned asset URLs +(`app.js?v=`) + no-cache `_headers`. Private-window confirmed fresh code worked. + +**5. Preload algorithm.** "Next altitude's video doesn't start" = a buggy +hold-last-frame settle gate I'd added (gated playLoop behind a canplay that doesn't +re-fire) → reverted. Operator specified the algorithm: gate on 1 clip/altitude + +connecting morphs (~126 MB, ~10-20s — measured), then grow the pool in the +background; the random pick is restricted to fully-loaded (eligible) clips so +navigation never stalls and new clips are reachable only once fully downloaded. +Built `preload.js` (pure planner + node tests), wired phase-1 gate + eligible picks. + +**6. Worktree isolation enforcement.** The shared checkout caused branch-switch +chaos (my commits landed on a switched-to `main`). Operator: "make sure every +session works off a fresh worktree not in the main directory." Built +`.claude/settings.json` (worktree.bgIsolation + baseRef:fresh) + a PreToolUse hook +(`worktree-guard.sh`) that DENIES edits to repo files while in the main checkout + +SessionStart notice → EnterWorktree. Proven live (blocked my own edit); fixed an +over-broad version that blocked external files too. Dogfooded the merge of guard + +preload onto origin/main IN an isolated worktree. + +## Cut state + +- **Live:** https://benstull.art/human-experience-simulator/ — Pages + R2, preload + algorithm, audio fixed, cache-versioned. Operator confirmed video+audio work. +- **origin/main (`a4d3d83`+):** has the Cloudflare publish, preload algorithm, and + worktree-isolation guard, merged cleanly with parallel a11y work. +- Plan archived in-repo (`docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`), + on origin/main. **No `content` repo** → plan stays in-repo (app.json gap). +- Local `main` checkout is behind origin/main (parallel session actively deploying). +- Backup branches on origin: `chore/worktree-isolation`, `fix/preload-gate`, + `design/cloudflare-static-publish` (content all merged to main; safe to delete). + +## Pipeline (§9) status + +Operator-set Cloudflare exception (no flotilla/PPE). localhost + **E2E green** (33 +node tests; 13 altitude-lock + 2 static-build Playwright); no PPE (exempt); **prod = +live** via `make deploy`. Repeatable deploy: `make deploy` / `make deploy-media` +(`deploy/cloudflare/`). + +## Operator plate (next session) + +- By-eye/ear review of the live site: load time, video, audio, rapid-altitude + navigation smoothness (eligible-pick growth), the preload behavior under real + network. The hold/grow path only triggers when media isn't cached — needs eyes. +- Restart any still-running sessions so they pick up the worktree guard + move to + worktrees. Clean up backup branches if desired. + +## Deferred decisions + +- Estimated session start time (no tracked init) — used 2026-06-30T06-30. +- Did NOT reconcile local `main` (behind origin/main) — a parallel session is + actively deploying origin/main; left the shared checkout untouched to avoid + disrupting it. +- Cross-session Service-Worker disk cache and a 720p base tier were proposed and + DEFERRED (morphs already 720p; bases dominate load but per-altitude growth covers it). diff --git a/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md b/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md deleted file mode 100644 index 77b9a9a..0000000 --- a/sessions/0036/SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--INPROGRESS.md +++ /dev/null @@ -1,23 +0,0 @@ -# Session 0036.0 — Transcript - -> App: human-experience-filter-art -> Start: 2026-06-30T06-30 (PST) -> Type: planning-and-executing -> Posture: yolo -> Claude-Session: a7814ad4-473f-4946-b158-a5fad35b4bf1 -> 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 0036 for human-experience-filter-art. The driver replaces this -> body with the full transcript and renames the file to its final -> SESSION-0036.0-TRANSCRIPT-2026-06-30T06-30--.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._ From 1b646c8ade6e65599c318de8aa822e9d624379fe Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 10:45:10 -0700 Subject: [PATCH 3/7] claim human-experience-filter-art session 0037 (placeholder) + sessions.json entry --- ...TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md | 23 +++++++++++++++++++ sessions/sessions.json | 3 +++ 2 files changed, 26 insertions(+) create mode 100644 sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md diff --git a/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md b/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md new file mode 100644 index 0000000..997ece8 --- /dev/null +++ b/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md @@ -0,0 +1,23 @@ +# 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 +> Status: **PLACEHOLDER — claimed at session start; finalized at session end.** +> +> This file reserves session ID 0037 for human-experience-filter-art. The driver replaces this +> body with the full transcript and renames the file to its final +> SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--.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._ diff --git a/sessions/sessions.json b/sessions/sessions.json index d044dc2..50d5654 100644 --- a/sessions/sessions.json +++ b/sessions/sessions.json @@ -106,5 +106,8 @@ }, "0036": { "title": "" + }, + "0037": { + "title": "" } } From 8e790b4a957a7430ebd573a6554ee457bb39c437 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 10:50:51 -0700 Subject: [PATCH 4/7] feat(dial): snap to the closest altitude on drag release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A drag is a way of grabbing the altitude dial, not a free-standing encoder detent. Until now onDialUp held the live blend wherever the knob stopped (the "no auto-complete" model), so a grab-and-release could leave the experience parked mid-morph between two altitudes. Now a real turn (moved >= 6) auto-scrubs the short way to Math.round(pos) and settles there (frac 0 locks) — a grab-and-release always lands on a discrete altitude, exactly like a tap or label click. The tap path (moved < 6 -> jumpToScale) is unchanged, and reduced motion is handled by autoScrub (jump to target, no tween). Adds an altitude-lock e2e asserting a partial drag settles on an integer altitude after release. Full altitude-lock (14) + a11y + i18n suites green. Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/altitude-lock.spec.ts | 30 ++++++++++++++++++++++- simulator/static/app.js | 6 ++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index b86c830..f6aeb3a 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -276,7 +276,8 @@ test("crossing a detent commits and locks the destination clip", async ({ page } const box = (await page.locator("#dial").boundingBox())!; const cx = box.x + box.width / 2, cy = box.y + box.height / 2; // Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents), - // crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete). + // crossing integer 1 (orbit) so it commits, then hold past it WHILE STILL DRAGGING + // (the live blend holds wherever the knob is held; the snap happens only on release). await page.mouse.move(cx, cy - 30); await page.mouse.down(); await page.mouse.move(cx + 30, cy + 6, { steps: 14 }); @@ -293,3 +294,30 @@ test("crossing a detent commits and locks the destination clip", async ({ page } expect(st2.activeClipId).toBe(st.activeClipId); await page.mouse.up(); }); + +test("releasing a partial drag snaps to the closest altitude (no held mid-blend)", async ({ page }) => { + // The operator can grab the dial, turn it PART of a detent, and let go between two + // altitudes. On release the knob must auto-scrub to the CLOSEST altitude and settle + // there (integer position, frac 0) — landing on a discrete altitude exactly as if + // they had clicked it, rather than holding the half-finished blend. + await boot(page); + const box = (await page.locator("#dial").boundingBox())!; + const cx = box.x + box.width / 2, cy = box.y + box.height / 2; + await page.mouse.move(cx, cy - 30); + await page.mouse.down(); + await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // ~0.5 detent — parks BETWEEN altitudes + // While held we are mid-blend: a fractional knob position. + await page.waitForFunction( + () => { const p = (window as any).__hefState().pos; return Math.abs(p - Math.round(p)) > 0.05; }, + null, { timeout: 5000 }, + ); + await page.mouse.up(); + // After release the position settles on an integer altitude (the snap). + await page.waitForFunction( + () => Number.isInteger((window as any).__hefState().pos), + null, { timeout: 10000 }, + ); + const st = await page.evaluate(() => (window as any).__hefState()); + expect(Number.isInteger(st.pos)).toBe(true); // landed on an altitude, not held mid-blend + expect(st.ringIndex).toBe(st.pos); // committed altitude == the position it landed on +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 3023f57..3fbb19a 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -1076,7 +1076,11 @@ function onDialUp(e) { } return; } - // No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model). + // Snap on release: a drag is a way of GRABBING the dial, not a free-standing + // encoder detent — so when the operator lets go between altitudes, auto-scrub the + // short way to the CLOSEST one and settle there (frac 0 locks). The result is the + // same as a tap/label-click: a grab-and-release always lands on a discrete altitude. + autoScrub(Math.round(pos)); } // Click a label → auto-scrub the SHORTEST signed way around the ring to that scale. From 22bd152f33b027b00f0e069f371bbaa3fb039e60 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 10:54:38 -0700 Subject: [PATCH 5/7] add sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--2026-06-30T10-52.md + replace placeholder/variant SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md --- ...RIPT-2026-06-30T10-42--2026-06-30T10-52.md | 76 +++++++++++++++++++ ...TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md | 23 ------ 2 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--2026-06-30T10-52.md delete mode 100644 sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md diff --git a/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--2026-06-30T10-52.md b/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--2026-06-30T10-52.md new file mode 100644 index 0000000..ce76dca --- /dev/null +++ b/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--2026-06-30T10-52.md @@ -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._ diff --git a/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md b/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md deleted file mode 100644 index 997ece8..0000000 --- a/sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md +++ /dev/null @@ -1,23 +0,0 @@ -# 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 -> Status: **PLACEHOLDER — claimed at session start; finalized at session end.** -> -> This file reserves session ID 0037 for human-experience-filter-art. The driver replaces this -> body with the full transcript and renames the file to its final -> SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--.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._ From 539618b1b82980aa99b12f48b4c132cbd35cbc89 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 11:03:33 -0700 Subject: [PATCH 6/7] docs(spec): Playback Speed slider replaces Reduce motion toggle Continuous -2x..2x speed control for the resting altitude loop video (#vid-loop); manual rAF reverse loop below 0x; reduced-motion a11y behavior preserved invisibly via OS prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...2026-06-30-playback-speed-slider-design.md | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md diff --git a/docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md b/docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md new file mode 100644 index 0000000..5b87d50 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md @@ -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 ` -