These were accidentally committed (tracked on main); they are regenerated on
every run and shouldn't be in git.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per operator: Speed only controls the resting loop, so reduce-motion is still
needed to turn off the morph/transition videos. Re-add a visible 'Reduce motion'
toggle on BOTH the welcome screen and the panel (mirrored, default from OS
prefers-reduced-motion, remembers the choice). When ON, altitude changes are
instant cuts instead of an animated morph tween (autoScrub's reduced branch);
the resting loop is unaffected — it follows the Speed slider (0x freezes it).
Decouples the loop from reduce-motion (was: main froze both videos + disabled
the slider under OS-reduced). playLoop()/landing/applyPlaySpeed no longer check
isReduced(); isReduced() now only gates the morph tween. Welcome controls are
now Language / Speed / Audio / Reduce motion. i18n: restore rm.label (4 langs),
reword warn.body to point at the on-screen control.
Tests: a11y Task 5 reworked (speed independent of reduce-motion; new instant-cut
behavior test); welcome defaults assert reduce-motion off. node 35, e2e 32,
static 2, pytest 301 (3 pre-existing ffmpeg/cv2 env fails).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Real-browser eyeball: below-0x reverse jittered because the base loop
clips aren't all-intra (the flagged risk). Narrow the slider to 0x–4x
forward (still continuous, step=any); 0x still freezes; OS reduced-motion
behavior unchanged. Removes the manual rAF reverse loop and the
HEFScrub.reverseStep helper + its unit tests.
Tests: node unit 10/10; a11y e2e 12/12 (Task 5 now 3 slider tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Continuous -2x..2x slider drives ONLY the resting altitude loop video
(#vid-loop); the morph/transition video is scrub-driven and untouched.
Forward = native playbackRate; ~0x = freeze; below 0x = a manual rAF
reverse loop (native negative playbackRate is unreliable). Reverse math
is the pure, node-tested HEFScrub.reverseStep helper.
Reduced-motion accessibility is preserved but now derives live from the
OS prefers-reduced-motion setting (no visible toggle): when active the
loop stays frozen and the slider is disabled. Retires hef.reduceMotion.
i18n: speed.label (en/es/fr/ja); warn.body reworded to point at the OS
setting.
Tests: +4 node unit cases for reverseStep (14/14 green); a11y e2e Task 5
rewritten for the slider (13/13 green); static-build e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per docs/superpowers/specs/2026-06-30-welcome-screen-design.md. Fold the bare
#loading splash, the #motion-warning modal, and the #run-sim button into one
#welcome overlay — the universal entry gate:
- State A (.welcoming): heads-up notice + four output controls (Video ON,
Reduce motion OFF, Audio 2, Language) + 'Launch Simulator' button, with the
'Loading Universe' progress pinned at the bottom (media preloads behind it).
- State B (.loading): pressing Launch while still loading hides the messaging +
button, keeps the controls, and centers the loader; entry happens automatically
once phase-1 finishes. Launch when already loaded enters directly.
- The right .panel is hidden until entry (body:has(#welcome)); welcome control
values carry into the panel inputs on entry. Language + Reduce motion drive the
shared state live (both selects/checkboxes mirror); Video + Audio apply on entry.
- Launch is the single audio-unlock gesture (beginExperience runs in-gesture even
when entry is deferred). Retire WARN_KEY (welcome shows every load); entry uses
the chosen Audio level rather than forcing 2.
- i18n: new welcome.launch key + es/fr/ja for the heads-up (warn.title/body),
reworded to point at the on-screen Reduce-motion control; drop dead run.button.
Tests: new welcome.spec.ts (defaults, live language re-render, State B
deferred-entry, straight-in when loaded, control carry-over, panel hidden);
update altitude-lock/a11y/i18n/loop-recovery/static-build e2e + the Python
audio/credits e2e to the welcome flow; add a window.__hefReady diagnostic seam.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Integrates the fully-static Cloudflare publish line (live at
benstull.art/human-experience-simulator) into main: the static build (Pages + R2,
manifest-only media tree, baked API JSON), the "Run simulation" start button, the
public CC-BY/BY-SA credits page with the "License & reuse" CC BY-SA 4.0 derivative
declaration (Option A + D1), the repeatable Cloudflare deploy script, and assorted
load/audio fixes (progressive boot, scaleAudioUrl via mediaBase).
Clean auto-merge with main's per-clip affect/feelings + i18n work — only app.js and
altitude-lock.spec.ts overlapped, both auto-resolved. Verified green on the merged
tree: node 29, pytest 315 passed / 2 skipped.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
The experience boots silent and waits for a gesture (browser autoplay blocks
an un-gestured audio play()). Until now that gesture was an unlabelled Video
toggle — not obvious to a first-time viewer. Add a "Run simulation" button,
revealed over the black stage once media has preloaded; clicking it turns
Video on, snaps the audio dial to a gentle 2/10, dismisses the prompt, and
starts the soundtrack in that one gesture. Turning Video on directly still
begins the experience too (couples audio in, dismisses the button) via a
shared beginExperience(). Localised en/es/fr/ja.
E2E coverage updated (Playwright + pytest-playwright): button shown after
load, button starts video+audio+dismisses, and direct-Video-on path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ascending the altitude dial and landing on the higher-altitude clip jumped ~3
seconds. A baked morph `transitions/{h}__{l}.mp4` spans src@0 (frac 0) ->
dst@~LOOP_TAIL_S (frac 1) and is scrubbed bidirectionally, but the steady loop
element always seeked LOOP_TAIL_S (3.0s) on landing. Descending lands on dst at
its tail (matches the seek — the s0026 loop-from-tail fix); ascending lands on
src at the morph's HEAD (src@0), so seeking the tail was a 3s jump.
Make the loop landing frame direction-aware: pure `HEFScrub.loopLandFrame(dir,
loopTailS)` returns 0 when ascending, loopTailS otherwise. `loadLoop` takes a
`landFrame`, stores it as `dataset.loopStart`, and the timeupdate/ended wrap
handlers honor it (the base clips are already seamless crossfade-loops, so a
reverse landing wraps [0,dur] cleanly). The settle path lands explicitly on the
direction's frame so a fast ascent never jumps even if the heading clip wasn't
preloaded in time. App-side only — no 154-morph LFS re-bake.
Verify: node unit (loopLandFrame) + a new reverse-landing e2e asserting the loop
anchors at the head (loopStart "0"), and the existing forward "loops from tail"
e2e still green (12/12 altitude-lock).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The post-load autoStart() flipped Video on and lifted audio to 3/10 with no
user gesture. Browser autoplay policy blocks an audio play() made outside a
gesture (muted video survives, sound does not), so auto-start showed video but
stayed silent. Boot SILENT instead and wait for the operator to turn Video on —
that click IS the gesture, and its handler couples audio to 3/10 and starts the
soundtrack in-gesture, so sound reliably comes up with video.
Updates the pytest e2e + Playwright e2e specs to assert boot-silent + the
video-on-couples-audio wiring instead of the old auto-start wiring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause: the steady-state loop (#vid-loop) restarts only via a narrow
timeupdate window (last 0.08s -> seek to LOOP_TAIL_S), with native loop off.
Headless Chromium reliably lands a final timeupdate in that window, but under
real-browser decode/GPU load a timeupdate can skip past it; the clip then fires
'ended' and freezes on its last frame ('videos aren't looping'). There was no
recovery path. Add an 'ended' listener that seeks back to the tail and resumes,
guaranteeing the loop regardless of the timeupdate race. No change to normal
playback. Proven by a Playwright regression test that forces the ended state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expanded-pools sky-scale work plus two experience tweaks:
- New `sky` altitude (airplane-window / low-flight layer) between orbit and
coast; RING_ORDER cosmos·orbit·sky·coast·reef·abyss. New clip pools across
all scales, re-baked the full adjacent-pair morph set (all-intra, LFS), and
removed the now-stale cosmos↔abyss transitions the old ring order produced.
- Auto-start: once the preload finishes the experience turns Video on and
raises the audio dial to 3/10 with no user gesture (app.js autoStart()).
- Dropped reef_hawaiian (text overlay — violates the text-free pool rule);
reef pool 7→6, manifest transitions 586→558.
Tests: rewrote tests/test_e2e_audio.py for the dial UI + auto-start; updated
the manifest morph-count assertions and the altitude-scrub target for the new
ring order. 302 pytest + 11 Playwright e2e + 9 node unit all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator: slow click/auto transitions 2x and make them the same speed between any two
altitudes (2 away should take twice as long as 1). autoScrub duration is now
PROPORTIONAL to distance (PER_ALTITUDE_MS=1200, ~2x the old fixed 600ms total): a
label-click or wheel jump of N altitudes takes N*1200ms, so per-step speed is constant.
Manual drag is unaffected (operator drives its speed). E2E asserts a 2-step jump is
~2x a 1-step.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator: audio turned off sometimes after landing on a new altitude. Root cause:
fadeVolume started a setInterval ramp that was never cancelled. When restAudio faded
the idle element to 0 and — within FADE_MS — that same element became the newly-landed
ACTIVE scale (assignElements reuses an element per soundtrack url), the stale fade-to-0
kept running and dragged the now-active element back to silence. Fix: cancelFade()
supersedes any in-flight fade when a new fade starts (fadeVolume) or a direct volume
is set (ensurePlaying). E2E proves a new fade supersedes a stale one (fails without
the fix).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator request. Replaces the Audio checkbox with a 0-10 range slider that sets the
master soundtrack gain (audioVol = level/10), scaling both the per-scale rest volume
and the two-track scrub crossfade. Level 0 = silent. Turning Video on for the first
time now raises audio to 3/10 (was: full on) so the experience opens gently; raising
the slider from 0 unlocks playback in-gesture (Safari). E2E: dial is range 0-10
default 0, and first video-on lifts it to 3/10.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator eyeball: a jolt/pause when the morph ends and the next altitude's loop
loads. Root cause: swapping vid.src to the base + seeking to the tail reloaded the
ON-SCREEN element (decode + seek stall). Now a dedicated #vid-loop element carries
the steady-state base loop; during a scrub it is PRELOADED to the clip we're heading
toward, seeked to the tail and paused on that exact frame. The paint shader reads the
active source (busy ? morph : loop), so landing is an instant source swap — no reload,
no seek. E2E asserts the loop element is armed + playing from ~3s after landing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator eyeball feedback: landing on an altitude jump-cut the loop back to frame 0.
Each morph reproduces the destination clip's first 3s (pipeline trim=0:3) and ends on
dst@~3s, so the steady-state loop now starts at LOOP_TAIL_S=3.0 and loops within
[3.0, duration] — the morph's last frame hands straight off to the loop, and the
morph-covered intro never re-shows at rest (removes the overlapping 'altitude video
that is part of the morph'). E2E asserts a landed clip loops from ~3s, not 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 5. Replaces the .rev-asserting zoom-out test with a turn-back test
(same forward morph seeked backward, audio recedes, re-locks the start clip); adds
a detent-crossing commit+lock test via a new window.__hefState diagnostic seam.
Full tier (6 tests) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 4. onWheel + jumpToScale now animate pos via autoScrub() (drives the
same setPos scrub path, ~600ms, lands exactly on the integer + locks). Removes the
now-dead advance()/playTransition()/FAST_BLEND_RATE (the server /api/ring/advance
round-trip is no longer used by the client). Post-landing morph warm-up folded into
setPos's settle branch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 3. A continuous float pos is the knob: setPos() seeks the segment's
morph currentTime by frac (throttled one seek/rAF), crossfades audio, sweeps the
needle live, and commits+locks+re-rolls on each integer crossing. Drag live-scrubs
(no more commit-on-release); release holds the blend (no auto-complete). Client-side
pool pick replaces the server round-trip for drag.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per plan Task 2. Adds <audio id=aud-b>; blendAudio(lo,frac) mixes the two
adjacent scale soundtracks by knob fraction (element reuse across crossings so
only the genuinely-new scale reloads); restAudio settles a single scale at rest.
Diagnostic readout now reports whichever element is audible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 8. New simulator/e2e Playwright tier (§9 browser tier). altitude-lock.spec
proves: zooming plays the morph matching the landed clip (forward + .rev), and
the clip is LOCKED after landing (no change while parked). advance() records
played morph paths on window.__hefMorphs (diagnostic seam; reliable under the
blob cache).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>