Compare commits

..

94 Commits

Author SHA1 Message Date
benstull b6bf970684 Merge pull request 'feat(altitude): auto-advance when the altitude video completes (not a 60s timer)' (#36) from feat/auto-advance-on-video-complete into main 2026-07-03 20:00:23 +00:00
BenStullsBets 34fb48cdec feat(altitude): auto-advance when the altitude's video completes (not a timer)
Replace the fixed 60s idle timer with video-completion pacing: when the base loop
video finishes one playthrough (the timeupdate wrap point, or the ended safety
net), drift one altitude deeper (descend, wraps) — gated by the default-on toggle
and deferred during a manual drag or a playing morph. Removes the whole
AUTO_ADVANCE_MS/scheduleAutoAdvance/bump timer machinery and its interaction
resets. loop-recovery.spec disables the toggle to test the loop-back path in
isolation. auto-advance.spec reworked to the completion-driven model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:00:14 -07:00
benstull c8416ef2e9 Merge pull request 'feat(audio): Music shuffles to a new random track when each finishes (gapless)' (#35) from feat/music-shuffle-on-end into main 2026-07-03 13:00:47 +00:00
BenStullsBets e666465d62 feat(audio): Music plays each track to its end, then a random next (gapless)
Replace the fixed 5-min rotation with end-driven shuffle: each track plays through,
and a short crossfade a few seconds before it ends rolls a random OTHER track
(pickNextIndex, no immediate repeat) — so Music is always playing while selected.
An 'ended' handler is the safety net (short track / missed timeupdate) and a
watchdog resumes an unexpectedly-paused active track. Crossfade injectable via
window.__hefMusicXfadeMs. music-playlist.spec.ts reworked to the end-driven model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 06:00:27 -07:00
benstull 8c6778230b Merge pull request 'fix(build): include the Music playlist in the static media sync' (#34) from feat/experience-tuning into main 2026-07-03 01:02:40 +00:00
BenStullsBets 89cd6aa1dd fix(build): include the Music playlist in the static media sync
The Music-option tracks are referenced by app.js (MUSIC_PLAYLIST), not the
manifest/ring, so build_static skipped them — Music would 404 on the static
site. Add audio/music/<name>.mp3 explicitly. +1 pytest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 18:02:22 -07:00
benstull 04c8855ddc Merge pull request 'Experience tuning: mood-through-morphs fix, auto-advance, label wrap, binaural candidates' (#33) from feat/experience-tuning into main 2026-07-03 01:00:21 +00:00
BenStullsBets da3581afea feat(audio): add the Audio source dropdown to the welcome screen
The opening screen only had Volume + Video speed — no way to pick the sound mode
before launching. Add a welcome Audio dropdown (None/Soundtrack/Music) mirroring
the panel; applyWelcomeToPanel carries the choice into the session on launch, None
greys the welcome Volume, and ?audio= presets it on the welcome screen too. +4 e2e
(welcome-audio.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:56:17 -07:00
BenStullsBets 4f4f21a767 feat(audio): Music becomes a crossfading playlist of 5 mellow synth tracks
Replace the single ethereal loop with the operator's chosen set — Music for
Manatees, Dewdrop Fantasy, Eastminster, Overheat, Concentration (Kevin MacLeod,
CC BY 4.0, drumless). The Music source now plays one track and every 5 min
crossfades to a random OTHER track (pure HEFScrub.pickNextIndex, never an
immediate repeat), altitude-independent, reusing the two audio elements; both
primed in-gesture for Safari. Intervals injectable via window.__hefMusicRotateMs
/ __hefMusicXfadeMs.

Media loudness-normalized + transcoded under the 25MB LFS limit (build_audio_media
MUSIC), committed via LFS; ethereal loop retired. CC-BY attribution added to
credits.html. Docs updated (music-candidate-pool = final set; audio-candidate-pool
ethereal section superseded).

+2 node unit (pickNextIndex), +2 e2e (music-playlist), audio-source spec updated.
40 unit + 49 e2e green; live probe confirms playback + rotation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:22:28 -07:00
BenStullsBets 969236b3f7 feat(audio): shareable ?audio= deep-link presets the sound mode
?audio=none|soundtrack|music (and ?vol=0..10) presets the Audio source/volume on
load, so a link opens straight into a chosen sound mode — e.g. share …/?audio=music
with someone who'd prefer music, …/?audio=soundtrack for the ambience. Invalid
values ignored (default Soundtrack stands). +5 e2e (url-params.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:00:59 -07:00
BenStullsBets b0bd784305 docs(audio): drumless Round-4 KM candidates (like operator's 2 keepers)
Operator kept Dream Culture + Silver Blue Light, rejected the rest (boring or
too much snare). Round-4 = 12 Kevin MacLeod CC-BY tracks, drumless confirmed
from his pieces.json instrument metadata, ranked by chord movement (best:
Impact Lento, Dreams Become Real, Aretes, When The Wind Blows, Reawakening,
Almost New). Lo-fi dropped. review_music.html rebuilt as the drumless cut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:32:23 -07:00
BenStullsBets 04c3a133c3 docs(audio): pivot Music direction to mellow chordal synth
Operator dropped binaural. New direction: mellow, soothing synth with real chord
movement. docs/music-candidate-pool.md = 12 ranked + spares, all CC0 (HoliznaCC0
lo-fi) or CC BY (Kevin MacLeod calming synth), verified live. Local review gallery
simulator/static/review_music.html (gitignored). Binaural doc parked with a pointer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 14:20:31 -07:00
BenStullsBets 028d02a033 docs(audio): add Round-2 musical/evolving binaural candidates
11 more candidates for the 'not boring' end: MANTICE Sacred Drone series
(bassimat, CC0 — evolving composed ambient, binaural-capable via the generator
but not certified per render), HoliznaCC0 long-form ambient music (FMA, CC0, no
binaural), and shagrugge (ccMixter, CC BY, binaural-filtered vocals). Notes the
CC BY-SA 'Hang Binaural' opt-in (best musical+confirmed-binaural fit, outside the
CC0/CC-BY rule) and the NC drops. review_binaural.html now groups all 22 into
Musical / True-binaural / Ambient sections with honest binaural-status badges.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 06:43:01 -07:00
BenStullsBets 08cc7c6355 docs(audio): binaural stress-relief candidate pool + local review page
11 CC0 stereo binaural/entrainment candidates (verified on freesound) for
evolving the Music option into a crossfading stress-relief set. Durable record
in docs/binaural-candidate-pool.md; interactive audition gallery at
simulator/static/review_binaural.html (gitignored local-only, like the other
review galleries — streams the CC0 previews, tick-to-keep + copy picks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:51:18 -07:00
BenStullsBets 9d42ed7fc4 feat(altitude): auto-advance one altitude per minute when idle
After the dial sits idle for 60s the altitude drifts one step deeper (descend;
wraps past the deepest back to the top), like a slow kiosk tour. Any manual
interaction (drag/wheel/arrow-key/knob) restarts the idle countdown; a mid-morph
or mid-drag beat is skipped and retried. A default-on 'Auto-advance' toggle in
the Altitude panel disables it. Interval is injectable for tests via
window.__hefAutoAdvanceMs. i18n for 4 langs. +3 e2e (auto-advance.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:44:41 -07:00
BenStullsBets 88ebb07578 fix(ui): stop the 'Video speed' label wrapping in the Output panel
Widen the panel (280->300px), keep control labels on one line (white-space:
nowrap), and let the range shrink (min-width:0) instead of pushing the label
to a second row. +1 e2e (panel-layout.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:40:05 -07:00
BenStullsBets 2f8641cb28 fix(mood): keep the Dark/Light grade applied through altitude morphs
The mood grade was dropped while a morph played (busy): the WebGL canvas set
filter 'none' and the fallback set #vid's filter 'none' on morph load, so the
transition showed ungraded and the grade snapped back only on landing. Now the
grade rides through the morph on both paths (the painterly DREAM stays off mid-
morph, as before, to keep it crisp). +1 e2e (mood-grade.spec.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 05:38:19 -07:00
benstull f6b45b6cd7 Merge pull request 'feat(audio): Audio source dropdown (None/Soundtrack/Music) + Volume rename' (#32) from worktree-audio-music-option into main 2026-07-02 05:28:11 +00:00
BenStullsBets d161db8110 feat(audio): add CC0 ethereal Music loop (freesound #808032, via LFS)
The Music-option asset produced by build_audio_media.py: deadrobotmusic
'Ambient F Sharp Minor Ethereal Choir Pad' (CC0), sourced from the freesound
hq preview and looped/loudness-normalized to ethereal.loop.mp3 (3:34). Both
the raw source and the served loop are committed via git-LFS, matching the
per-scale soundtracks; served at /media/audio/music/ethereal.loop.mp3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 22:26:14 -07:00
BenStullsBets ee7190bb4a feat(audio): Audio source dropdown (None/Soundtrack/Music) + Volume rename
Split the single 0–10 'Audio' slider into two controls:
- Audio  — a dropdown selecting the source: None / Soundtrack / Music
- Volume — the renamed 0–10 slider (master gain for the active source)

Music is one ethereal, relaxing ambient loop played the same at every
altitude (no per-altitude crossfade), per the innerflo.me brief. Sourced
CC0: deadrobotmusic 'Ambient F Sharp Minor Ethereal Choir Pad' (freesound
#808032); added to build_audio_media.py, the candidate-pool doc, and the
public credits colophon. Media stays gitignored; run build_audio_media.py.

Routing lives in a pure, node-tested HEFScrub.audioPlan(source, level, frac);
app.js's restAudio/blendAudio consult it. None greys out Volume. The welcome
screen's mirror level slider is relabeled Volume too. i18n for all 4 langs.

Spec: docs/superpowers/specs/2026-07-01-audio-music-option-design.md
Tests: +5 node unit (audioPlan), +4 e2e (audio-source.spec.ts); 32 related
e2e green, 38 node unit green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:55:23 -07:00
BenStullsBets 6816e4ce75 docs(spec): Audio source dropdown (None/Soundtrack/Music) + Volume rename
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:41:40 -07:00
BenStullsBets 5226a1ebd5 add sessions/0039/SESSION-0039.0-TRANSCRIPT-2026-06-30T10-40--2026-06-30T23-52.md + replace placeholder/variant SESSION-0039.0-TRANSCRIPT-2026-06-30T10-40--INPROGRESS.md 2026-07-01 00:02:22 -07:00
BenStullsBets 6695f32b74 claim human-experience-filter-art session 0039 (placeholder) + sessions.json entry 2026-06-30 23:55:54 -07:00
BenStullsBets 7ec8b607d2 Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls 2026-06-30 15:06:59 -07:00
BenStullsBets b135cb5f5e fix(deploy): redirect the bare legacy root too (/* splat misses the empty case)
Add an explicit /human-experience-simulator/ → /human-machine-strata/ 301 (the
subpath splat doesn't match the trailing-slash root).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:02:29 -07:00
BenStullsBets 950c1e6c8c feat(deploy): move app URL to /human-machine-strata + 301 from the legacy path
Build default app-path → /human-machine-strata; deploy.sh APP_PATH default too.
_write_redirects now emits a permanent redirect /human-experience-simulator/* →
/human-machine-strata/:splat so old links still land. Media (R2 bucket) + Pages
project names are unchanged (not in the benstull.art URL). Updated the build
test (asserts the legacy redirect) + static-build e2e path + README paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:00:40 -07:00
BenStullsBets 958c3060d2 feat(rename): retitle the project to "Human/Machine Strata"
Page <title>, on-page <h1> (app.title — kept as the proper title, same across
all 4 languages), the About + Credits headings/prose, and the author dev tool.
"Strata" = the layered cosmos→abyss descent; the slash = the human↔machine
membrane the work is about.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:00:40 -07:00
BenStullsBets c36f995463 feat(panel): move Altitude to the top of the panel (mobile: reachable with video on screen)
On mobile the panel wraps below the (sticky) stage; putting the Altitude knob
first means it sits right under the video instead of behind Output + knobs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:29:02 -07:00
BenStullsBets b63518bfbb chore(title): h1 header → "Human Experience Filter Simulation" (4-lang, matches page title)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:23:43 -07:00
BenStullsBets 4555bb29a8 chore(title): page title → "Human Experience Filter Simulation"
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:22:51 -07:00
BenStullsBets 8c63b14967 chore(deploy): no-store the whole app path (per-HTML override didn't stick)
Cloudflare Pages let the /seg/* no-cache rule win over the per-HTML no-store, so
the index was still served no-cache. no-store the whole app path instead — the
shell is tiny and media is on R2 (separate, immutable). Guarantees a fresh index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:20:53 -07:00
BenStullsBets ecd444b53b fix(reduce-motion): re-roll the clip on an instant-cut altitude change
With reduce motion on, selecting a new altitude jumps (autoScrub's reduced
branch → setPos) and skips the segment that would re-roll the destination clip,
so activeClipId stayed on the previous altitude's clip and the video didn't
change. Self-heal at the landing point: if the landed altitude's pool doesn't
contain activeClipId, pickPoolClip a fresh one. TDD: new altitude-lock e2e.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:18:16 -07:00
BenStullsBets fafb498d6f feat(panel): move Experience knobs above Altitude; drop dial hint + '(0–4)'
Per operator: Think/Feel/Mood group sits above the Altitude knob; remove the
'Turn the knob…' altitude hint (and its dead i18n key); 'Experience knobs (0–4)'
→ 'Experience knobs' (all 4 langs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:12:58 -07:00
BenStullsBets e06191db32 chore(deploy): no-store the app-shell HTML (stop Safari serving a stale index)
no-cache (revalidate) wasn't enough — Safari served a stale cached index from
disk/bfcache, which loaded old versioned JS and broke the page (black, silent).
HTML entry points now no-store (always fresh index → always fresh hashed assets).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:12:57 -07:00
BenStullsBets ccd40103a2 fix(speed): cap Video speed at 2x (>2x stutters — base loops not all-intra)
Same root cause that retired reverse playback: the resting loop clips aren't
all-intra, so playbackRate >2x makes Safari stutter on inter-frame seeks. Narrow
both sliders (welcome + panel) + the tick datalist to 0–2x; default still 1x.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:08:11 -07:00
BenStullsBets 79f64e3f7c feat(welcome): rename "Speed" control to "Video speed" (4-lang)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:57:13 -07:00
BenStullsBets 7cdf594794 chore(e2e): untrack + gitignore Playwright test-results artifacts
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>
2026-06-30 13:27:25 -07:00
BenStullsBets 5f9ae6b212 feat(welcome): reduce-motion toggle for morph transitions; decouple loop from it
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>
2026-06-30 13:27:18 -07:00
BenStullsBets 54cad607fb add sessions/0038/SESSION-0038.0-TRANSCRIPT-2026-06-30T12-25--2026-06-30T13-20.md + replace placeholder/variant SESSION-0038.0-TRANSCRIPT-2026-06-30T12-25--INPROGRESS.md 2026-06-30 13:19:40 -07:00
BenStullsBets 053bb5c1a3 claim human-experience-filter-art session 0038 (placeholder) + sessions.json entry 2026-06-30 13:17:25 -07:00
BenStullsBets c176ae7231 Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls
# Conflicts:
#	simulator/e2e/tests/a11y.spec.ts
#	simulator/static/app.js
2026-06-30 12:55:42 -07:00
BenStullsBets 6647390cb1 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
2026-06-30 12:54:06 -07:00
BenStullsBets e064a2e684 feat(speed): switch Playback Speed to 0x–4x forward (drop reverse)
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>
2026-06-30 12:48:14 -07:00
BenStullsBets df8b245af3 feat(speed): Playback Speed slider replaces Reduce motion toggle
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>
2026-06-30 12:39:06 -07:00
BenStullsBets 539618b1b8 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) <noreply@anthropic.com>
2026-06-30 12:39:06 -07:00
BenStullsBets f077193df9 feat(welcome): pre-load Welcome screen with controls + Launch gate
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>
2026-06-30 11:14:35 -07:00
BenStullsBets 22bd152f33 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 2026-06-30 10:54:38 -07:00
benstull 5696b94328 Merge pull request 'feat(dial): snap to the closest altitude on drag release' (#31) from worktree-session-0037-altitude-snap-on-release into main 2026-06-30 17:51:39 +00:00
BenStullsBets 8e790b4a95 feat(dial): snap to the closest altitude on drag release
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>
2026-06-30 10:50:51 -07:00
BenStullsBets b9de6695a0 docs(welcome): entry button is 'Launch Simulator' (new welcome.launch key)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:48:43 -07:00
BenStullsBets 05328b1b5e docs(welcome): brainstorming spec for the Welcome screen + controls
Unify #loading / #motion-warning / #run-sim into one #welcome overlay:
heads-up notice + four output controls (Video on, Reduce motion off,
Audio 2, English) + Loading Universe progress. Two states (welcome →
centered loading); right panel hidden until entry; Continue is the single
entry gesture. Forward note: extra welcome copy lands later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:47:14 -07:00
BenStullsBets 1b646c8ade claim human-experience-filter-art session 0037 (placeholder) + sessions.json entry 2026-06-30 10:45:10 -07:00
BenStullsBets 00fd9aab17 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 2026-06-30 10:39:44 -07:00
BenStullsBets 0334fc8bc4 claim human-experience-filter-art session 0036 (placeholder) + sessions.json entry 2026-06-30 10:36:01 -07:00
BenStullsBets a4d3d831b0 Merge remote-tracking branch 'origin/main' into chore/land-isolation 2026-06-30 10:20:33 -07:00
BenStullsBets d711db6a2c fix(guard): scope the edit-block to files INSIDE the repo
The PreToolUse guard blocked ALL edits while in the main checkout — including files
outside the repo (auto-memory, /tmp, scratchpad). Now it only denies edits to files
under the main checkout's working tree; external files are allowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:18:39 -07:00
BenStullsBets 17856cac32 chore(dev): enforce isolated worktrees outside main for every session
Multiple sessions sharing this one checkout clobbered each other (branch switches,
edits landing on the wrong branch). Enforce per-session isolation:
- worktree.bgIsolation=worktree + baseRef=fresh (native bg-session isolation)
- PreToolUse(Edit|Write|NotebookEdit) hook DENIES edits while in the main checkout
- SessionStart hook directs each session to EnterWorktree (fresh worktree, off main)
.claude/hooks/worktree-guard.sh detects main-vs-worktree via git-dir != git-common-dir.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:15:57 -07:00
BenStullsBets 34478bb3a3 add sessions/0035/SESSION-0035.0-TRANSCRIPT-2026-06-30T08-45--2026-06-30T10-08.md + replace placeholder/variant SESSION-0035.0-TRANSCRIPT-2026-06-30T08-45--INPROGRESS.md 2026-06-30 10:15:43 -07:00
BenStullsBets f7abb5c29b claim human-experience-filter-art session 0035 (placeholder) + sessions.json entry 2026-06-30 10:10:03 -07:00
BenStullsBets 86ce291056 add sessions/0034/SESSION-0034.0-TRANSCRIPT-2026-06-30T07-24--2026-06-30T10-01.md + replace placeholder/variant SESSION-0034.0-TRANSCRIPT-2026-06-30T07-24--INPROGRESS.md 2026-06-30 10:06:07 -07:00
BenStullsBets e0964eae6c claim human-experience-filter-art session 0034 (placeholder) + sessions.json entry 2026-06-30 10:03:35 -07:00
BenStullsBets 1a6ffc74a1 feat(load): phase-1 preload gate + eligible-pick pool growth
Gate the universe on one clip per altitude + connecting morphs (~126 MB, ~10-20s),
then grow the pool in the background. The random pick (pickPoolClip/pickRandomMember)
is restricted to fully-loaded clips — base + connecting morphs both ways cached
(HEFPreload.eligibleDestinations) — so a transition never stalls on un-downloaded
media and new clips become reachable only once fully loaded. Pure planner in
preload.js (+ node tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:55:46 -07:00
BenStullsBets 380c2d13e7 Merge origin/main into feat/accessibility-pass (reconcile static-publish + a11y with main)
# Conflicts:
#	simulator/static/app.js
2026-06-30 09:47:40 -07:00
BenStullsBets ff06783173 add sessions/0033/SESSION-0033.0-TRANSCRIPT-2026-06-30T09-38--2026-06-30T09-43.md + replace placeholder/variant SESSION-0033.0-TRANSCRIPT-2026-06-30T09-38--INPROGRESS.md 2026-06-30 09:44:55 -07:00
BenStullsBets 87d605db39 Merge design/cloudflare-static-publish into main: Cloudflare static publish + CC credits
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>
2026-06-30 09:42:44 -07:00
BenStullsBets 75961ea18e claim human-experience-filter-art session 0033 (placeholder) + sessions.json entry 2026-06-30 09:39:18 -07:00
BenStullsBets b83758fbca feat(content): about.html — intent, provenance, honest limits; static-build copy
English-first page mirroring credits.html (for-everyone / tech arc
underwater→drones→space / built-with-LLMs / honest-it's-imperfect). Header
link + about.link i18n key; about.html + flash.js added to PUBLIC_ASSETS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:37:00 -07:00
BenStullsBets 79105a3ef9 feat(a11y): wire flash-clamp into autoScrub; document 3-flash/sec audit
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>
2026-06-30 09:34:10 -07:00
BenStullsBets 7c8c4b45cd fix(load): preload all bases + morphs before interaction; remove buggy hold-gate
The hold-last-frame settle gated playLoop() behind a canplay event that doesn't
re-fire on an already-loaded element → the next altitude's video never started.
Reverted to the simple swap, and gate the universe on a full preload (every base +
every morph) so navigation is smooth with no on-demand stall. Adds a ?debug on-screen
media-state overlay for diagnosing real-browser playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:31:10 -07:00
BenStullsBets b9a52b1c41 fix(deploy): bust the app-shell cache — no-cache _headers + content-versioned asset urls
Pages caches .js/.css by type (max-age=14400) and ignores _headers for them, so
returning visitors ran stale JS (no-video/no-audio until a private window). index.html
is no-cache and now references app.js?v=<hash> etc., so a normal reload always gets
fresh code; media on R2 stays immutable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:31:10 -07:00
BenStullsBets f1ce23c4fe feat(a11y): aria-live narration of scale/clip; hide decorative SVGs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:28:19 -07:00
BenStullsBets 0db602ebd0 feat(a11y): keyboard + ARIA slider semantics for the Altitude dial
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>
2026-06-30 09:26:45 -07:00
BenStullsBets 717bf5b08b feat(a11y): reduced-motion freeze-to-stills with OS-default toggle
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>
2026-06-30 09:24:00 -07:00
BenStullsBets ee7c65a0f2 feat(credits): restore the CC BY-SA declaration as "License & reuse" (Option D1)
s0031 removed "About this work" to avoid duplicating it on the separate About
page, but that section WAS the D1 compliance — it declares the altered work is a
derivative offered under CC BY-SA 4.0, which is what satisfies the ShareAlike
obligation for the BY-SA source clips. Without it the live site credited the
BY-SA authors but never declared the derivative share-alike.

Restore the declaration on the credits page, under the heading "License & reuse"
instead of "About this work" so it isn't confused with the separate About page
(which covers the project's intent). Tests assert the declaration is present and
that the heading is NOT "About this work".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:23:57 -07:00
BenStullsBets 6cf8fffc51 feat(a11y): one-time photosensitivity warning gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:18:04 -07:00
BenStullsBets 1f98b7bd8c feat(a11y): flash.js pure WCAG 2.3.1 flash-clamp helper + tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:16:02 -07:00
BenStullsBets 6e2e65202e feat(a11y): AA contrast bumps, global focus-visible, visually-hidden util
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:15:41 -07:00
BenStullsBets fe77677d4c feat(a11y): move language picker below Audio, globe inline-left
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>
2026-06-30 09:14:47 -07:00
BenStullsBets a895e7139b plan: accessibility pass + About page implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:11:43 -07:00
BenStullsBets 8277417583 chore(credits): drop the "About this work" section — it moves to the About page
The CC BY-SA work-license declaration ("About this work") was on the credits
page, but the About page (built on the parallel feat/accessibility-pass) will
own that content; keeping it in both places would duplicate it. Remove the
section from credits.html and make the footage note self-contained (it still
states the footage is altered — the per-clip "indicate modifications" cue stays
with the attributions). Tests updated to assert the declaration is intentionally
no longer on the credits page. The .declaration CSS rule is left in place for the
About page to reuse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:10:59 -07:00
BenStullsBets d10ffca4e8 fix(audio): route scaleAudioUrl through mediaBase — was hardcoded /media (404 on static)
The audio layer built the soundtrack url as a literal /media/audio/<f>, which 404s
on the static R2 build (audio err 4, silent). Route it through mediaBase() like the
video path: dev → /media/ (unchanged), static → https://static.benstull.art/. Verified
live: aud loads 200 from R2 and plays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:06:41 -07:00
BenStullsBets a68d0bc4df feat(deploy): repeatable Cloudflare deploy script + make targets
deploy/cloudflare/deploy.sh (build → optional parallel R2 media sync → Pages
deploy); 'make deploy' (frontend-only) / 'make deploy-media'. One-time setup
stays documented in README. Guards against unauth + mis-keyed R2 uploads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:56:30 -07:00
BenStullsBets 7da944af0c feat(load): progressive boot + hold-last-frame until media loaded
Boot becomes ready on the first clip and streams the rest in the background
(was: blocked on the full ~2.3 GB before 'Run simulation' appeared). During nav,
the visible morph and landing base only swap to cached blobs — otherwise the
current frame holds (settleGen guards a stale settle) — so rapid altitude-clicking
never blanks the stage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:53:40 -07:00
BenStullsBets 768fee5ccd design: accessibility pass + About page (non-kiosk public web)
Reduced-motion freeze-to-stills, photosensitivity warning gate + flash
audit, keyboard/SR-operable Altitude dial, low-vision contrast, and a new
about.html (English-first). Targets WCAG 2.1 AA plus a motion/seizure layer
for the unattended public URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:49:02 -07:00
BenStullsBets 1c7f2b7785 feat(credits): public CC-BY/BY-SA attribution colophon + CC BY-SA work license
CC BY / CC BY-SA footage needs its author + license shown to the viewer, and the
installation alters every frame (a derivative). Per-clip license/source already
shipped in clips.json but only surfaced in the dev panel and internal review
pages — the public experience showed nothing, so the static benstull.art site
was not attribution-compliant.

Option A — add a manifest-driven credits/colophon (credits.html + credits.js):
the page fetches the same clips the experience uses (baked clips.json in static,
the live API otherwise) and renders one attribution block per clip (title,
license, source), HTML-escaped, sorted, never silently dropping a clip. An
"ⓘ Credits & licenses" link in the header (localised en/es/fr/ja) makes it
reachable from the work — what CC's "reasonable to the medium" calls for. Audio
is all PD/CC0, credited as a courtesy block.

Option D1 — the colophon declares the altered work itself is offered under
CC BY-SA 4.0 and states the footage is modified, satisfying the ShareAlike +
"indicate changes" obligations for the BY-SA sources. Non-commercial.

Ships in the static build (added to PUBLIC_ASSETS). Verified: 41/41 clips
credited incl. all 7 CC-BY/BY-SA clips, 0 missing a license. Tests: node units
for creditEntries/creditsListHtml + escaping + page structure/declaration;
pytest build-output assertion; Playwright e2e (page lists attributions, header
links through).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:39:26 -07:00
BenStullsBets 3a45e338f3 feat(static): "Run simulation" start button over the black stage
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>
2026-06-30 08:28:49 -07:00
BenStullsBets 8cc472c93c docs(static): correct Cloudflare runbook to match actual deploy
Real bucket name, wrangler 'cors set --file' (+ wrangler-format CORS file),
per-file r2 object put upload (no bulk sync exists), CLI custom-domain bind via
zone-id, Pages domain is dashboard-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:25:08 -07:00
BenStullsBets bcae35b63e test(static): E2E — static boot with no API + dream shader survives cross-origin CORS
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:48:22 -07:00
BenStullsBets a93c0d57c8 chore(static): Cloudflare deploy artifacts — R2 CORS policy + deploy runbook
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:45:20 -07:00
BenStullsBets 77cb04bb8c feat(static): build script — dist/<app>/ + manifest-only media tree, baked API JSON, _redirects
Run via 'python -m tools.build_static' (avoids tools/http.py shadowing stdlib http).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:43:49 -07:00
BenStullsBets 271586625c feat(static): config-driven media base, baked-JSON boot, client pick/alteration, crossOrigin
Relative asset paths (work at dev-root and under the /human-experience-simulator
deploy prefix), config.js gate, devLiveReload no-ops in static mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:43:49 -07:00
BenStullsBets be13248bdd feat(static): client-side alteration engine (port of player.alteration/audio)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:25:24 -07:00
BenStullsBets 7ef3e0ccc6 plan: Cloudflare static publish — implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:23:57 -07:00
BenStullsBets 7a51d8f6d4 design: publish to benstull.art via Cloudflare Pages + R2 (fully static)
Solution design for a no-origin public target at
benstull.art/human-experience-simulator (apex redirects): static frontend on
Pages, ~2GB served media on R2 behind static.benstull.art, dynamic logic baked
to JSON / moved client-side. Records the deliberate flotilla/PPE process
exception for a static art target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:18:00 -07:00
74 changed files with 6276 additions and 201 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Worktree isolation guard. This repo is worked by MULTIPLE concurrent sessions; if
# they share the main checkout they clobber each other (branch switches, lost edits,
# commits on the wrong branch). Every session must work in its OWN git worktree
# OUTSIDE the main directory.
#
# worktree-guard.sh block PreToolUse(Edit|Write|NotebookEdit) — DENY edits to
# files INSIDE the main checkout while in it. Files
# outside the repo (memory, /tmp, scratchpad) are allowed.
# worktree-guard.sh notice SessionStart — tell the agent to enter a worktree.
#
# Isolated = the session sits in a git worktree, where `--git-dir` (…/.git/worktrees/
# <name>) differs from `--git-common-dir` (…/.git). Equal = the shared main checkout.
set -u
mode="${1:-notice}"
gd=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 # not a repo → nothing to guard
gc=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) || exit 0
[ "$gd" = "$gc" ] || exit 0 # already in a worktree → allow
root=$(git rev-parse --show-toplevel 2>/dev/null)
repo=$(basename "${root:-repo}")
guidance="You are in the SHARED main checkout ($root). Multiple sessions use it \
concurrently and WILL clobber each other. Work in an isolated worktree OUTSIDE the \
main directory: use the EnterWorktree tool (preferred — it relocates this session to \
a fresh worktree branched from origin/main), or run \
\`git worktree add -b <branch> ../${repo}-worktrees/<name> origin/main\` and cd there. \
Do NOT edit files in the main checkout."
esc() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
if [ "$mode" = "block" ]; then
# Only guard files INSIDE the main checkout's working tree; allow edits to files
# outside the repo (auto-memory, /tmp, scratchpad, other repos).
fp=$(python3 -c 'import json,sys
try:
d=json.load(sys.stdin); print(d.get("tool_input",{}).get("file_path","") or "")
except Exception: print("")' 2>/dev/null)
case "$fp" in
/*) abs="$fp" ;;
"") abs="$root" ;;
*) abs="$root/$fp" ;;
esac
case "$abs" in
"$root"/*|"$root") : ;; # inside the repo → fall through to deny
*) exit 0 ;; # outside the repo → allow
esac
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(esc "$guidance")"
else
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$(esc "⚠ WORKTREE ISOLATION REQUIRED. $guidance")"
fi
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"worktree": {
"bgIsolation": "worktree",
"baseRef": "fresh"
},
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" notice"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" block"
}
]
}
]
}
}
+10
View File
@@ -23,6 +23,16 @@ simulator/sample_media/**/*.m4a
# Local-only candidate review galleries (re-buildable; not shipped content).
simulator/static/review.html
simulator/static/review_audio.html
simulator/static/review_binaural.html
simulator/static/review_music.html
# Editor annotation/comment-thread metadata (not project content)
.threads/
# Static build outputs (tools/build_static.py)
/dist/
/dist-media/
# Playwright e2e artifacts (should never be committed)
simulator/e2e/test-results/
simulator/e2e/playwright-report/
+10 -1
View File
@@ -1,4 +1,4 @@
.PHONY: sim sim-local
.PHONY: sim sim-local deploy deploy-media
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
# fall back to python3, then python.
@@ -9,3 +9,12 @@ sim:
sim-local:
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
# Repeatable deploy to benstull.art (Cloudflare Pages + R2). One-time setup
# (wrangler login, bucket/CORS/domain) is in deploy/cloudflare/README.md.
# `deploy` is frontend-only (fast); `deploy-media` also re-syncs media to R2.
deploy:
./deploy/cloudflare/deploy.sh
deploy-media:
./deploy/cloudflare/deploy.sh --media
+128
View File
@@ -0,0 +1,128 @@
# Deploying to benstull.art — Cloudflare Pages + R2
Publishes the simulator as a fully-static site: the frontend on **Cloudflare
Pages** (`benstull.art`, app at `/human-machine-strata`, apex redirects to
it) and the video/audio on **R2** behind `static.benstull.art`. No origin server.
Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`.
Plan: `docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`.
This project is **exempt from the flotilla/PPE/§9 pipeline** (operator-set) — it
deploys directly to Cloudflare. Safety/hygiene rules still hold.
## Repeatable deploy (after the one-time setup below)
Once the bucket/CORS/custom-domains + `wrangler login` exist (one-time, §13), every
later deploy is one command (`deploy/cloudflare/deploy.sh`, or the Make targets):
```bash
make deploy # frontend only: build + Pages deploy (fast — most deploys)
make deploy-media # also re-sync all media to R2 (~2 min, parallel) — when media changed
./deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, skip Pages
```
The script rebuilds `dist/`, (optionally) parallel-uploads `dist-media/` to R2, and
deploys Pages — printing the live URL. It checks `wrangler` auth first and fails
loudly. Override defaults via env: `BUCKET`, `PROJECT`, `MEDIA_BASE`, `APP_PATH`,
`BRANCH`, `JOBS`. The sections below document the one-time setup the script assumes.
## Prerequisites (operator gestures — need Cloudflare auth)
`wrangler` is not installed in the dev box; the deploy runs from a machine with
Cloudflare credentials.
```bash
npm i -g wrangler
wrangler login
```
`benstull.art` is already a Cloudflare zone (no nameserver migration).
## 1. Build the artifacts
From the repo root (run as a MODULE — `tools/http.py` shadows stdlib `http` if run
as a script):
```bash
.venv/bin/python -m tools.build_static
```
Produces:
- `dist/` — Pages output: `dist/human-machine-strata/` (frontend + baked
`clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a
root `dist/_redirects` (apex → app path, and no-slash → slashed).
- `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only
(clip bases + 154 transition morphs + 5 per-scale audio). No master/mezzanine
sources.
## 2. R2 — bucket, CORS, custom domain, media
Bucket name: **`human-experience-simulator-media`**. The default `wrangler login`
OAuth token already has R2 + Pages + zone scopes (no separate API token needed).
```bash
wrangler r2 bucket create human-experience-simulator-media
# CORS — load-bearing (the WebGL dream shader taints without it). The wrangler CLI
# wants its own schema, NOT the S3-style array; use the *.wrangler.json file:
wrangler r2 bucket cors set human-experience-simulator-media --file deploy/cloudflare/r2-cors.wrangler.json
# (deploy/cloudflare/r2-cors.json is the equivalent S3-style policy, for the
# dashboard / S3 API; r2-cors.wrangler.json is the same rule in wrangler's
# `{ "rules": [ { "allowed": {...} } ] }` form.)
# Custom domain — wrangler CAN do this (no dashboard needed); needs the zone id.
ZONE=$(curl -s -H "Authorization: Bearer $(grep '^oauth_token' ~/.wrangler/config/default.toml | cut -d'"' -f2)" \
"https://api.cloudflare.com/client/v4/zones?name=benstull.art" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
wrangler r2 bucket domain add human-experience-simulator-media \
--domain static.benstull.art --zone-id "$ZONE" --min-tls 1.2
```
**Media upload — there is NO `wrangler r2 bucket sync`.** Upload per-file with
`wrangler r2 object put ... --remote`, setting content-type + immutable cache. The
URLs are content-hash-versioned (`?v=<hash>`), so `immutable` is safe — a re-baked
clip gets a new hash → new URL → cache busts. ~604 files / ~2.3 GB, sequential:
```bash
WR=$(command -v wrangler); BUCKET=human-experience-simulator-media
find dist-media -type f | while read -r f; do
key="${f#dist-media/}"
case "${f##*.}" in mp4) ct=video/mp4;; mp3) ct=audio/mpeg;; json) ct=application/json;; *) ct=application/octet-stream;; esac
"$WR" r2 object put "$BUCKET/$key" --file "$f" --remote \
--content-type "$ct" --cache-control "public, max-age=31536000, immutable"
done
```
(If you have `rclone` + an R2 S3 API token, `rclone copy dist-media <remote>:bucket`
is much faster than the loop.)
If you change the media base, rebuild with `--media-base https://<host>/` so
`config.js` matches.
## 3. Pages — deploy + domain + redirect
```bash
wrangler pages project create human-experience-simulator --production-branch main
wrangler pages deploy dist --project-name human-experience-simulator --branch main
```
Then **in the dashboard** (Workers & Pages → project → Custom domains) — there is
no wrangler CLI for Pages custom domains:
- Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind
serves `522` until the cert/routing provisions.)
- The app serves at `benstull.art/human-machine-strata/`; the shipped
`dist/_redirects` sends `/` and the no-slash form to it.
## 4. Verify before/after going live
```bash
# CORS + Range on the media origin (after step 2)
curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
https://static.benstull.art/<some>/base.mp4 | grep -iE "access-control-allow-origin|content-range|accept-ranges"
```
Open `https://benstull.art/` → should redirect to
`https://benstull.art/human-machine-strata/`. Turn the **Feel** knob to max
and confirm the dream effect renders (no `SecurityError` in the console — that
would mean CORS is misconfigured and the GL texture is tainted). The local
static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same
cross-origin path before you ship.
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Repeatable deploy of the HEF simulator to benstull.art (Cloudflare Pages + R2).
#
# deploy/cloudflare/deploy.sh # frontend only: build + Pages deploy (fast)
# deploy/cloudflare/deploy.sh --media # also re-sync all media to R2 (~2 min, parallel)
# deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, no Pages deploy
#
# Assumes the ONE-TIME setup is already done (see README.md): wrangler logged in,
# the R2 bucket + CORS + static.benstull.art custom domain, and the Pages project +
# benstull.art custom domain. This script only does the repeatable part.
#
# Override any of these via env: BUCKET, PROJECT, MEDIA_BASE, APP_PATH, BRANCH, JOBS.
set -euo pipefail
BUCKET="${BUCKET:-human-experience-simulator-media}"
PROJECT="${PROJECT:-human-experience-simulator}"
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
APP_PATH="${APP_PATH:-/human-machine-strata}"
BRANCH="${BRANCH:-main}"
JOBS="${JOBS:-10}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT="$REPO_ROOT/dist"
export MEDIA_OUT="$REPO_ROOT/dist-media"
PY="${PY:-$REPO_ROOT/.venv/bin/python}"
export WRANGLER="${WRANGLER:-$(command -v wrangler || echo "$HOME/.npm-global/bin/wrangler")}"
do_media=0
do_pages=1
case "${1:-}" in
--media) do_media=1 ;;
--media-only) do_media=1; do_pages=0 ;;
"") ;;
*) echo "unknown flag: $1 (use --media or --media-only)"; exit 2 ;;
esac
[ -x "$WRANGLER" ] || { command -v "$WRANGLER" >/dev/null || { echo "wrangler not found ($WRANGLER) — npm i -g wrangler"; exit 1; }; }
"$WRANGLER" whoami >/dev/null 2>&1 || { echo "not authenticated — run: $WRANGLER login"; exit 1; }
echo "▶ build ($MEDIA_BASE)"
cd "$REPO_ROOT"
"$PY" -m tools.build_static --out "$OUT" --media-out "$MEDIA_OUT" \
--media-base "$MEDIA_BASE" --app-path "$APP_PATH" | tail -1
if [ "$do_media" = 1 ]; then
echo "▶ R2 media sync → $BUCKET (parallel x$JOBS, no bulk-sync exists)"
export BUCKET
fails=$(find "$MEDIA_OUT" -type f -print0 \
| xargs -0 -P "$JOBS" -I {} bash "$REPO_ROOT/deploy/cloudflare/r2-put-one.sh" {} \
| tee /dev/stderr | grep -c '^FAIL ' || true)
[ "$fails" = 0 ] || { echo "$fails media uploads failed"; exit 1; }
echo "✔ media synced"
fi
if [ "$do_pages" = 1 ]; then
echo "▶ Pages deploy → $PROJECT"
"$WRANGLER" pages deploy "$OUT" --project-name "$PROJECT" --branch "$BRANCH" --commit-dirty=true | tail -3
fi
echo "✔ done — https://benstull.art$APP_PATH/"
+9
View File
@@ -0,0 +1,9 @@
[
{
"AllowedOrigins": ["https://benstull.art"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "Content-Type"],
"ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 86400
}
]
+13
View File
@@ -0,0 +1,13 @@
{
"rules": [
{
"allowed": {
"origins": ["https://benstull.art"],
"methods": ["GET", "HEAD"],
"headers": ["Range", "Content-Type"]
},
"exposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"maxAgeSeconds": 86400
}
]
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Upload ONE file to R2 with the right content-type + immutable cache. Used by
# deploy.sh's parallel xargs (wrangler has no bulk sync). Reads BUCKET / MEDIA_OUT /
# WRANGLER from the environment; the single argument is the local file path.
set -u
f="$1"
key="${f#"$MEDIA_OUT"/}"
# Guard: if the prefix strip didn't fire (e.g. relative path vs absolute MEDIA_OUT),
# the key would wrongly keep a leading dir → wrong R2 path. Fail loudly, don't ship it.
if [ "$key" = "$f" ]; then
echo "FAIL $f (key strip failed — MEDIA_OUT='$MEDIA_OUT' is not a prefix; pass absolute paths)"
exit 1
fi
case "${f##*.}" in
mp4) ct=video/mp4 ;;
mp3) ct=audio/mpeg ;;
json) ct=application/json ;;
*) ct=application/octet-stream ;;
esac
if "$WRANGLER" r2 object put "$BUCKET/$key" --file "$f" --remote \
--content-type "$ct" --cache-control "public, max-age=31536000, immutable" >/dev/null 2>&1; then
echo "ok $key"
else
echo "FAIL $key"
fi
+30 -3
View File
@@ -9,8 +9,10 @@ model — random pick per landing, or pairable per-clip).
The "audio + video" setting layers **two** kinds of audio per altitude:
1. **Ambience / audio track** — the soundscape (waves, reef crackle, chorus, etc.).
**This is what we're sourcing now.**
2. **Music track** — a separate composed musical layer. **Deferred** until the
ambience tracks are settled across all altitudes.
2. **Music track** — a separate composed musical layer. **Shipped** (2026-07-01)
as the Audio-dropdown **"Music"** option: unlike the earlier per-altitude idea,
it is now **one single ethereal loop played the same at every altitude** (see
§ Music below).
Pools below are the **ambience** layer unless marked `music`. **Distinction (operator):**
the ambience/soundtrack layer is an *environmental, real-world soundscape* (waves, reef,
@@ -48,6 +50,28 @@ recordings (NASA/NOAA) keep the "machines reshaping perception" thesis literal.
| 🏖 coast | `coast/waves` | Sea waves + tern calls | BigSoundBank #0267 — https://bigsoundbank.com/sea-waves-and-seagulls-s0267.html (`/UPLOAD/mp3/0267.mp3`) | 57s | CC0 |
| 🕳 abyss | `abyss/whale` | NE Pacific blue-whale "AB call" | NOAA PMEL — https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav | 13s | PD (NOAA) |
## Music (the Audio-dropdown "Music" option)
> **SUPERSEDED 2026-07-02:** the single ethereal loop below was replaced by a
> **rotating crossfading playlist of mellow drumless synth** (Kevin MacLeod, CC BY) —
> see `docs/music-candidate-pool.md`. The ethereal-loop record is kept for history.
### (history) ethereal loop — shipped 2026-07-01, then replaced
**One** ethereal ambient loop, played the same at every altitude (altitude-independent —
NOT the per-altitude idea the "cosmos/music" / "Frozen Star" rows above reserved; those
stay as historical candidates). The operator's brief: *ethereal, relaxing — like
innerflo.me.* CC0 (operator's sourcing choice), so attribution is courtesy, not required.
| id | clip | source | dur | license |
|---|---|---|---|---|
| `music/ethereal` | **✓ KEEP** — "Ambient F Sharp Minor Ethereal Choir Pad 1": blurry, paulstretched layered choir — "peaceful, magical, soothing" | deadrobotmusic — https://freesound.org/people/deadrobotmusic/sounds/808032/ (hq preview: `cdn.freesound.org/previews/808/808032_*-hq.mp3`) | 3:38 | CC0 |
Production: `simulator/build_audio_media.py` `SOURCES["music"]` → seamless loudness-normalized
loop at `audio/music/ethereal.loop.mp3`, served `/media/audio/music/ethereal.loop.mp3`
(client fallback: `app.js` `MUSIC_FALLBACK`). Download the sound page's WAV/MP3 to
`simulator/sample_media/audio/music/ethereal.mp3`, then `python simulator/build_audio_media.py`.
## Sourcing rule (learned 2026-06-26)
**Source only from a browsable page the operator can open and whose license is stated.**
@@ -67,7 +91,10 @@ hubs: NASA SVS / Chandra, NOAA PMEL/SanctSound (PD), BigSoundBank (CC0), Pixabay
- **reef** — ✅ round-4 6-layer bake (`reef/soundscape`): NOAA PD biological layers + a CC0
submerged underwater "you're under" bed (DCSFX freesound #366159, hq preview). License clean.
SanctSound clips: `files/SanctSound_*.mp4` at `https://sanctsound.ioos.us/files/` (extract audio).
- **music layer** — separate composed music track per altitude, deferred (cosmos slot reserved).
- **music layer** — ✅ SHIPPED 2026-07-01 as the Audio-dropdown "Music" option, but as
ONE altitude-independent ethereal loop (`music/ethereal`, CC0), not the per-altitude
track once envisioned. See § Music above. (The `cosmos/music` / "Frozen Star" rows are
now historical per-altitude candidates, not planned work.)
- **Production (after picks):** loop seams, length normalization, cross-altitude crossfade
(mirror video); cosmos/abyss clips are short → loop.
+110
View File
@@ -0,0 +1,110 @@
# Binaural / entrainment candidate pool — stress-relief "Music" tracks (2026-07-02)
> **PARKED (2026-07-02):** the operator dropped the binaural direction in favor of
> **mellow chordal synth music** — see `docs/music-candidate-pool.md`. This file is kept
> as the record of the binaural exploration (and its NC-licensing dead-ends).
Candidates for evolving the **Music** audio option into a stress-relief set of
**binaural-beat / brainwave-entrainment** tracks that **crossfade between each
other** (not a single loop). Operator brief: "binaural beats for stress relief."
**All 11 below are CC0** (public domain — no attribution legally required; we credit
anyway as courtesy), **all stereo**, all sourced from freesound.org and license-
verified by fetching each page. Review gallery (gitignored, local-only, like
`review_audio.html`): `simulator/static/review_binaural.html` → open
`/review_binaural.html` with the sim server running. It streams each track's public
HQ preview so you can audition + tick keepers.
## Caveats (operator, read before picking)
- **True binaural (#17) needs headphones.** The beat *is* the L/R frequency
difference, so on speakers it collapses to a steady carrier tone (no entrainment).
**#811** are ambient sub-bass / pulsing drones that keep the calm feel on speakers.
- **Band coverage:** heavy on **delta (0.51.5 Hz)** + one clean **theta (4 Hz, #2)**.
**No CC0 alpha (812 Hz)** track surfaced — widen to CC-BY (FMA/ccMixter) if wanted.
- **No clean CC0/CC-BY isochronic or monaural** (the speaker-correct entrainment type)
was found — every hit was NC/SA or unlicensed (see rejects). For speaker playback,
lean on #811 for the "beat" feel.
- **Previews vs originals:** the `cdn.freesound.org/…-hq.mp3` URLs are public streamable
previews (verified live, HTTP 206). The **originals** (WAV/AIF/full-MP3) need a free
freesound login; CC0 permits self-hosting, so for keepers download the originals and
host on R2 — do not hot-link the CDN in production.
## Candidates
| # | id | title | artist | source page | license | dur | type | beat / carrier |
|---|---|---|---|---|---|---|---|---|
| 1 | `fs75309` | 60-64-delta | iantm | https://freesound.org/people/iantm/sounds/75309/ | CC0 | 0:30 | true-binaural | 4 Hz · 60/64 Hz |
| 2 | `fs457654` | Binaural Beats (Theta Waves 4 Hz) | Fabrizio84 | https://freesound.org/people/Fabrizio84/sounds/457654/ | CC0 | 30:00 | true-binaural | 4 Hz theta |
| 3 | `fs803742` | 1.23456789 Hz binaural beat (69L/70.23R) | itchytooth | https://freesound.org/people/itchytooth/sounds/803742/ | CC0 | 60:00 | true-binaural | 1.23 Hz delta · 69/70.23 Hz |
| 4 | `fs414271` | Binaural Delta 440/440.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414271/ | CC0 | 10:00 | true-binaural | 0.5 Hz · 440/440.5 Hz |
| 5 | `fs414272` | Binaural Delta 500/501.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414272/ | CC0 | 10:00 | true-binaural | 1.5 Hz · 500/501.5 Hz |
| 6 | `fs414273` | Binaural Delta 52/52.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414273/ | CC0 | 10:00 | true-binaural | 0.5 Hz · 52/52.5 Hz |
| 7 | `fs414274` | Binaural Delta 60/61.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414274/ | CC0 | 10:00 | true-binaural | 1.5 Hz · 60/61.5 Hz |
| 8 | `fs856225` | Delta Pulse Subharmonic Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856225/ | CC0 | 5:00 | ambient+beats (speaker-ok) | delta sub-bass (procedural) |
| 9 | `fs854874` | Void Monolith Subharmonic Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/854874/ | CC0 | 5:00 | ambient (speaker-ok) | dark sub-drone |
| 10 | `fs860122` | Expanding & Vibrating Stereo Pulsating Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/860122/ | CC0 | 2:00 | ambient+beats (speaker-ok) | slow stereo pulsation |
| 11 | `fs493524` | Pulse Breath *(lower pref)* | phylobates | https://freesound.org/people/phylobates/sounds/493524/ | CC0 | 1:40 | ambient binaural | octaves of G / G♭ |
Preview MP3s (for the review page) follow `https://cdn.freesound.org/previews/<dir>/<id>_<uploader>-hq.mp3`
— exact URLs are embedded in `review_binaural.html`.
## Round 2 — musical / evolving (the "not boring" set, 2026-07-02)
Operator found the pure-tone set (#17) too plain. **Reality:** truly *musical* tracks
with a **confirmed** binaural layer are almost all NC (see rejects). Best legitimate
options — pick for how they *sound*; treat binaural as a bonus:
| id | title | artist | source | license | dur | binaural? |
|---|---|---|---|---|---|---|
| `fs856223` | Theta Gateway Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856223/ | CC0 | 5:00 | capable (theta band; not certified on render) |
| `fs856221` | Raga of Stillness Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856221/ | CC0 | 5:00 | capable (Δ/θ/α/β optional) |
| `fs856220` | Ice Cathedral Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856220/ | CC0 | 5:00 | capable |
| `fs856224` | Vowel of the Deep Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856224/ | CC0 | 5:00 | capable |
| `fs856222` | Stellar Vowel Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856222/ | CC0 | 5:00 | capable |
| `fs856329` | Harmonic Chamber High-Frequency Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856329/ | CC0 | 5:00 | capable |
| `fs854438` | Immersive Ethereal Generative Ambient (Floating Harmonics) | bassimat | https://freesound.org/people/bassimat/sounds/854438/ | CC0 | 11:33 | no (richest single piece) |
| `fma_cosmic` | Cosmic Waves | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/cosmic-waves/ | CC0 | 33:04 | no (long-form melodic pads) |
| `fma_dream` | DreamScape | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/dreamscape/ | CC0 | 21:59 | no |
| `fma_rain` | Rain / Sleep / Meditation | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/rain-sleep-meditation/ | CC0 | 14:35 | no (rain + pads) |
| `ccm_shagrugge` | How to Get Rid of a Dream (real imagination mix) | shagrugge | http://dig.ccmixter.org/files/shagrugge/12629 | CC BY 3.0 | ~ | yes-ish (binaural-filtered vocals; experimental) |
- The MANTICE **Sacred Drone** series is the composed extension of Round-1 #810 (same
generator; the generator *does* binaural entrainment across Δ/θ/α/β, but the individual
freesound renders don't certify the beat is on — bassimat could confirm/render on request).
- **HoliznaCC0** (FMA, CC0) = the strongest pure-*music* options; no binaural layer.
- FMA `files.freemusicarchive.org/...` URLs verified live (206) — safe to self-host.
The **ccMixter** shagrugge URL is **hotlink-protected** (403) — download in a browser and self-host.
### Opt-in (outside the CC0/CC-BY rule — needs operator OK)
- **"Hang Binaural" — Mindseye** — https://freemusicarchive.org/music/MindsEye/single/hang-binaural
**CC BY-SA 4.0**. The single best musical+*confirmed*-binaural fit (hang-drum music +
real binaural beats, 3:36, stereo), but ShareAlike copyleft. Add only if SA is acceptable.
## Rejected / could not verify
- **archive.org "Binaural Beats Alpha/Beta/Theta/Delta"** & **"Isochronic Tones &
Binaural Beats Combined"** (loopool / Jean-Paul Garnier) — **CC BY-NC-SA 3.0** (NC).
The most promising isochronic set — rejected for the NC clause.
- **archive.org "Om Alpha Brainwave Entrainment Loop"** (Entraino / KS Chauhan;
freesound chauhans/100370) — **CC BY-NC 3.0**. Rejected (NC).
- **archive.org "1hr 30 Min Theta Binaural Beat (7 Hz)"** (Chari G) — no license shown.
Rejected (unverifiable).
- **freesound "deadthetawaves.wav"** (NoiseCollector/43295) — license fine (**CC BY 3.0**)
but "evil sounding loop of the living dead" — rejected for *fit*, not license. Back
pocket only if an unsettling deep-scale track is ever wanted.
- **TunePocket / dialedggsound / miraclefrequencies / audio.com "Theta 7Hz"** —
"royalty-free"/commercial pages, murky redistribution, no per-track CC license.
- **Round 2 NC drops** (great content, non-commercial licence): "Fear Nothing (Relaxation
Mix)" — Jeris (CC BY-NC), "On the Earth" — Quarkstar (CC BY-NC), "Timeless (Binaural
Beatmix)" — duckett (CC BY-NC), "Ambient Meditation" — Scott Holmes (CC BY-NC). ccMixter's
binaural tag is overwhelmingly NC; only shagrugge (CC BY) survived.
## Next
Operator auditions `review_binaural.html`, ticks keepers (Copy picks). Then we wire the
playback: the **Music** source becomes a multi-track set that crossfades between the kept
tracks (architecture decision — replace vs. join the ethereal loop — deferred to that
step; see [[audio-music-option]]).
+129
View File
@@ -0,0 +1,129 @@
# Music candidate pool — mellow chordal synth (2026-07-02)
The chosen direction for the **Music** audio option: **mellow, soothing synth music with
real chord movement** ("many chords" — progressions that evolve, not static drones or
binaural tones). Supersedes the binaural exploration ([[binaural-candidate-pool]] — parked).
We'll use **several** tracks and **crossfade between them** (not loop one).
All **CC0 or CC-BY** (verified by fetching each page + a live HTTP 206 check on every audio
URL). Local review gallery (gitignored, like the other review pages):
`simulator/static/review_music.html` → open `/review_music.html` with the sim server running.
**Honesty flag:** licenses + URLs are verified; the "harmony" notes are inferred from
genre/reputation, not a by-ear check — audition before final selection.
## FINAL selection — WIRED 2026-07-02
After a Round-4 drumless pass, the operator's **final Music set = 5 Kevin MacLeod tracks**
(CC BY 4.0), now **shipped as a rotating crossfading playlist**:
**Music for Manatees · Dewdrop Fantasy · Eastminster · Overheat · Concentration**
Wiring: `app.js` `MUSIC_PLAYLIST` — the Music source plays one track and every
`MUSIC_ROTATE_MS` (5 min) crossfades to a random OTHER track (`pickNextIndex`, never an
immediate repeat), altitude-independent, on the two `#aud`/`#aud-b` elements. Files
loudness-normalized + transcoded under the 25MB LFS limit by `build_audio_media.py`
(`MUSIC`), committed via LFS; the single ethereal loop was **retired**. Credited on
`credits.html` (CC BY attribution). Earlier candidates below kept for record.
*(History: the operator first kept Dream Culture + Silver Blue Light, then — after the
drumless Round-4 pass — chose the 5 long-form/floaty tracks above instead.)*
## Two license families
- **HoliznaCC0 = CC0** — zero attribution burden (simplest for the credits page).
- **Kevin MacLeod = CC BY 4.0** — must credit, e.g. *"Chill Wave by Kevin MacLeod —
incompetech.com — Licensed under Creative Commons: By Attribution 4.0."* The credits page
already exists, so this is fine.
- **Self-host everything** on R2 for the install — the `files.freemusicarchive.org` and
`incompetech.com` URLs are live/hot-linkable now but shouldn't be depended on in prod.
## Round 4 — drumless, chord-moving (the refined set, 2026-07-02)
All **Kevin MacLeod, CC BY 4.0**, **drumless confirmed from his per-track `pieces.json`
instrument metadata** (tonal instruments only — no drum kit/percussion), verified live (206).
This is the authoritative candidate list; the lo-fi below (beat-driven) is dropped.
*(mp3 host `https://incompetech.com/music/royalty-free/mp3-royaltyfree/<Title>.mp3`; source page
`https://incompetech.com/music/royalty-free/index.html?isrc=<code>`.)*
**Best matches — metered, chords clearly move** (safest bets, like the two keepers):
| title | isrc | dur | instruments | harmony |
|---|---|---|---|---|
| Impact Lento | USUAN1100619 | 3:31 | Synths, Choir | "several striking chord progressions" |
| Dreams Become Real | USUAN1500027 | 6:50 | Piano, Synths | dreamy, layered, evolving (closest to Dream Culture) |
| Aretes | USUAN1100325 | 4:36 | Piano, Synths, Choir | warm major, clear motion |
| When The Wind Blows | USUAN1100717 | 4:26 | Guitar, Synths | lilting, calm (Silver-Blue-Light recipe) |
| Reawakening | USUAN1400017 | 3:34 | Piano, Cello | warm resolving progression |
| Almost New | USUAN1800008 | 3:23 | Piano, Basses | gentle moving harmony (documented) |
**Long-form / floaty — drumless but slower-moving** (audition for "boring" risk):
| title | isrc | dur | instruments | note |
|---|---|---|---|---|
| Music for Manatees | USUAN1400009 | 17:36 | Piano, EP, Strings, Synth | slow consonant shifts |
| Clean Soul | USUAN1300033 | 5:07 | Electric Piano, Bass | spacious, sparser |
| Soaring | USUAN1600040 | 6:20 | Synths | evolving pads, mystical-calm |
| Dewdrop Fantasy | USUAN1700001 | 34:58 | Synths, Rain | slowly changing, lower density |
| Eastminster | USUAN1100719 | 6:45 | Plucked Strings, Synths | drone + koto, some tension — least chordal |
| Overheat | USUAN1100630 | 7:03 | Flute, Synth, Violin | trance-y minimalism |
| Concentration | — | 29:53 | Synths, EP | floaty, lower density |
Reference: the kept **Dream Culture** is tagged "Piano, **Percussion**" (subtle beat); **Silver Blue
Light** is "Guitar, Synths" (drumless). The Round-4 picks are strictly tonal-only. A ~40-track
filtered pool of more drumless-calm-chordal KM tracks exists if a bigger rotation is wanted.
**Rejected this round (percussion / not soothing):** Angel Share (Drums), Dreamer (Percussion,
bpm 100), Lightless Dawn (Percussion + unnerving), Anamalie (Percussion + dark), Chill Wave &
all HoliznaCC0 lo-fi (beat-driven).
## Candidates — earlier mellow-synth round (lo-fi DROPPED, kept for record)
Ranked by chord movement.
### Lo-fi / dreamy — HoliznaCC0 (CC0)
| id | title | source page | mp3 (verified 206) | dur | harmony |
|---|---|---|---|---|---|
| `tokyo` | Tokyo Sunset | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/tokyo-sunset-lofi-peaceful-soft/ | .../Xnd9Hr5AVzB68IlWcImKtXPlwCePD2G2m8ZFSVj4.mp3 | 2:18 | jazzy extended-chord progression |
| `reverie` | Dreamy Reverie | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/dreamy-reverie-lofi-nostalgic-chill/ | .../Bo2XWazV4kHpnPksjJGRRLr16EmOE44NZnMITZaB.mp3 | 2:23 | moving 7th/9th chords |
| `complic` | Complicated Feelings | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/complicated-feelings-lofi-dreamy-relaxed/ | .../jEFkzWNxxtz76vjPoUOjZALHvdK3HkVMQAnGpgTn.mp3 | 2:39 | bittersweet changes |
| `drift` | Peaceful Drift | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/peaceful-drift-lofi-nostalgic-calm/ | .../SQvtLguk6S1VSthv0oXWycoB6ipUS0pt8jzAxxPq.mp3 | 2:08 | calm nostalgic progression |
| `currents`| Calm Currents | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/calm-currents-lofi-relax-calm/ | .../4rKapZUMNnNSPAOvpjlfSH6B5Ib8rgEWdvjnM7C6.mp3 | 2:27 | flowing chords |
| `moon` | Moon Unit | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/moon-unit-lofi-reflection-dreamy/ | .../CbNZO1QUuJq1f50RHzZ5kykNj1hdqT04UaWOYSNf.mp3 | 2:52 | reflective dreamy chords |
| `mist` | Into The Mist | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/into-the-mist-lofi-calm-relaxed/ | .../cI5iv9Xga0OOvGsS7mGZn9BlTshZdzAqLW7bmArA.mp3 | 2:09 | calm progression |
| `wave` | Wave Maker | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/wave-maker-lofi-dreamy-retro/ | .../SbPJTWMnBmT5yBsXP4jLfp6RsvWLhfOnbw2PxXol.mp3 | 3:05 | warm analog retro chords |
*(mp3 host: `https://files.freemusicarchive.org/storage-freemusicarchive-org/tracks/`)*
### Calming synth — Kevin MacLeod (CC BY 4.0 — credit required)
| id | title | source page | mp3 (verified 206) | dur | harmony |
|---|---|---|---|---|---|
| `chillwave` | Chill Wave | https://archive.org/details/ChillWave_781 | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Chill%20Wave.mp3 | 3:57 | clear repeating 4-chord cycle |
| `enchanted` | Enchanted Journey | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Enchanted%20Journey.mp3 | 4:29 | many changes, warm major |
| `dreamcult` | Dream Culture | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Dream%20Culture.mp3 | 3:34 | evolving chord sequence |
| `silverblue` | Silver Blue Light | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Silver%20Blue%20Light.mp3 | 5:50 | slow, deliberate changes |
### Spares
- **Clean Soul** — Kevin MacLeod — CC BY — 5:06 — `https://incompetech.com/music/royalty-free/mp3-royaltyfree/Clean%20Soul.mp3`
- **Old Age** — HoliznaCC0 — CC0 — 3:33 — https://freemusicarchive.org/music/holiznacc0/only-in-the-milky-way-part-1/old-age/
- **Deja Vu** — HoliznaCC0 — CC0 — 2:04 — https://freemusicarchive.org/music/holiznacc0/only-in-the-milky-way-part-1/deja-vu/
- More Kevin MacLeod (all CC BY 4.0, incompetech): Dreamer, Reawakening, Lightless Dawn,
Almost New, Angel Share. The whole **HoliznaCC0 "Public Domain Lofi"** album is 42 CC0
tracks in this vibe — a ready-made rotation if wanted.
## Rejected / not used
- **Kevin MacLeod baroque/classical "public-domain" pieces** — the *composition* is PD but
incompetech states the *recording* is CC BY 4.0; harpsichord/orchestral, off-brief anyway.
- **NC finds** (great but non-commercial): Scott Holmes "Ambient Meditation" (CC BY-NC),
ccMixter chordal ambient (Jeris/Quarkstar/duckett — CC BY-NC).
- **"Hang Binaural" — Mindseye** — CC BY-SA (ShareAlike), excluded per the CC0/CC-BY rule.
## Next
Operator auditions `review_music.html`, ticks keepers (Copy picks). Then wire the **Music**
source to a multi-track set that crossfades between the kept tracks (replaces the single
ethereal loop; see [[audio-music-option]]). Kept CC-BY tracks get a credit line on
`credits.html`; all kept files self-hosted on R2.
@@ -0,0 +1,885 @@
# Accessibility Pass + About Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the simulator usable away from the attended kiosk — reduced-motion, seizure safety, keyboard/screen-reader access, AA contrast — and add an English-first `about.html`.
**Architecture:** All client-side in `simulator/static/`. One new pure helper (`flash.js`, UMD + node tests). One new page (`about.html`) mirroring `credits.html`. Behavior changes live in `app.js`; chrome/contrast in `style.css` and `index.html`. The Python static build (`tools/build_static.py`) copies the new static files.
**Tech Stack:** Vanilla JS (no framework, UMD modules like `i18n.js`/`credits.js`), CSS, Playwright e2e (`simulator/e2e/`), `node --test` for pure helpers, FastAPI dev server for e2e fixtures.
## Global Constraints
- No engine/Python behavior changes — only `tools/build_static.py`'s copy list is touched.
- New JS modules follow the existing **UMD** pattern (browser global + `module.exports`), like `i18n.js` and `credits.js`.
- About page is **English-first**; i18n keys may be EN-only and fall back via `pickUiString` (returns `v.en`).
- Target **WCAG 2.1 AA** (4.5:1 text / 3:1 large) plus WCAG 2.3.1 (≤3 flashes/sec) and 2.3.3 (no autonomous animation under reduced-motion).
- Persist user toggles in `localStorage`, mirroring `DEV_KEY = "hef.devMode"` (`app.js:918`).
- Reduced-motion toggle key: `RM_KEY = "hef.reduceMotion"`. Warning-gate dismissal key: `WARN_KEY = "hef.motionWarnDismissed"`.
- `<html lang>` switching already exists (`applyUiStrings`, `app.js:1325`) — do NOT re-implement.
- E2E in this env: start uvicorn by hand with the venv `python` (only `python3` exists on PATH) and run Playwright with `reuseExistingServer`. `loop-recovery.spec.ts` is known-red on a clean baseline (boots without video).
- Direction convention: descend = `+1` (cosmos→abyss), matching wheel-down (`app.js:703`).
---
### Task 1: Output-panel layout — language picker below Audio, globe inline-left
**Files:**
- Modify: `simulator/static/index.html:37-51` (Output fieldset)
- Modify: `simulator/static/style.css` (add `.lang-pick` rule)
- Test: `simulator/e2e/tests/a11y.spec.ts` (new)
**Interfaces:**
- Produces: the `#lang-select` element ends up after `#audio` in DOM order; `.lang-pick` is a flex row.
- [ ] **Step 1: Write the failing e2e test**
Create `simulator/e2e/tests/a11y.spec.ts`:
```ts
import { test, expect } from "@playwright/test";
test("language picker sits below the Audio control", async ({ page }) => {
await page.goto("/");
const audio = page.locator("#audio");
const lang = page.locator("#lang-select");
await expect(audio).toBeVisible();
await expect(lang).toBeVisible();
const aBox = await audio.boundingBox();
const lBox = await lang.boundingBox();
expect(lBox!.y).toBeGreaterThan(aBox!.y); // lang is rendered lower than audio
});
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
await page.goto("/");
const pick = page.locator(".lang-pick");
const select = page.locator("#lang-select");
const pBox = await pick.boundingBox();
const sBox = await select.boundingBox();
// select starts to the right of the label's left edge, and shares its row (height ~ one line)
expect(sBox!.x).toBeGreaterThan(pBox!.x);
expect(pBox!.height).toBeLessThan(40);
});
```
- [ ] **Step 2: Run it to verify the first test fails**
Start the dev server (separate shell, from `simulator/`):
`../.venv/bin/python -m uvicorn server.app:app --port 8000` (adjust to the project's venv/module path).
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "below the Audio"`
Expected: FAIL (lang currently renders above audio).
- [ ] **Step 3: Move the language picker in `index.html`**
In the Output `<fieldset>`, delete the `<label class="lang-pick">…</label>` block from its current spot (above the Video toggle) and re-insert it as the LAST child of the fieldset, after the Audio `<label class="audio-level">`. Result order: legend → Video toggle → Audio level → language picker.
- [ ] **Step 4: Add the `.lang-pick` flex rule in `style.css`**
Add near the other panel rules:
```css
/* Globe + language select on one row (the select no longer goes full-width here). */
.lang-pick { display: flex; align-items: center; gap: 0.4rem; margin: 0.4rem 0; }
.lang-pick select { flex: 1; width: auto; }
```
- [ ] **Step 5: Run both layout tests to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "language picker|globe"`
Expected: PASS (2 tests).
- [ ] **Step 6: Commit**
```bash
git add simulator/static/index.html simulator/static/style.css simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): move language picker below Audio, globe inline-left"
```
---
### Task 2: Low-vision contrast + global focus rings + visually-hidden utility
**Files:**
- Modify: `simulator/static/style.css` (color bumps, `:focus-visible`, `.visually-hidden`)
**Interfaces:**
- Produces: a `.visually-hidden` utility class used by Task 4 (gate) and Task 7 (aria-live).
- [ ] **Step 1: Bump failing text colors to AA**
In `style.css`, change these declarations (values chosen to clear 4.5:1 on the dark backgrounds; verify with a contrast check in Step 3):
```css
/* was #789 — too low on #111 */
.hint { color: #9fb3c8; }
/* dial labels/captions were #789ac0 / #4d6184 on #0d1320 */
.dial-label { fill: #b8cfe6; }
.dial-caption { fill: #8fa6c4; }
```
(Keep every other property on those selectors unchanged — edit only the color/fill.)
- [ ] **Step 2: Add focus-visible + visually-hidden utilities**
```css
/* Visible keyboard focus for every interactive element (was only on .dev-switch). */
:focus-visible { outline: 2px solid #9af; outline-offset: 2px; }
#dial:focus-visible { outline-offset: 4px; }
/* Screen-reader-only text (announcements, labels) — present in a11y tree, off-screen. */
.visually-hidden {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
```
- [ ] **Step 3: Verify contrast**
Manually confirm with any WCAG contrast tool that `#9fb3c8` on `#111`, `#b8cfe6` on `#0d1320`, and `#8fa6c4` on `#0d1320` each meet ≥4.5:1 (≥3:1 acceptable for the dial caption if it reads as large). Adjust lighter if any fails.
- [ ] **Step 4: Commit**
```bash
git add simulator/static/style.css
git commit -m "feat(a11y): AA contrast bumps, global focus-visible, visually-hidden util"
```
---
### Task 3: `flash.js` — pure flash-clamp helper + node tests
**Files:**
- Create: `simulator/static/flash.js`
- Test: `simulator/static/flash.test.js` (new, `node --test`)
**Interfaces:**
- Produces: `HEFFlash.minSafeDurationMs(count)` → minimum total ms for `count` luminance transitions to stay ≤3/sec; `HEFFlash.clampDurationMs(requestedMs, count)``max(requestedMs, minSafeDurationMs(count))`. Consumed by Task 8 (audit).
- [ ] **Step 1: Write the failing test**
Create `simulator/static/flash.test.js`:
```js
const test = require("node:test");
const assert = require("node:assert");
const F = require("./flash.js");
test("minSafeDurationMs: N transitions need N/3 seconds", () => {
assert.strictEqual(F.minSafeDurationMs(3), 1000); // 3 flashes in >=1s
assert.strictEqual(F.minSafeDurationMs(6), 2000);
assert.strictEqual(F.minSafeDurationMs(0), 0);
assert.strictEqual(F.minSafeDurationMs(1), 1000 / 3);
});
test("clampDurationMs: stretches only when too fast", () => {
assert.strictEqual(F.clampDurationMs(2000, 3), 2000); // already safe
assert.strictEqual(F.clampDurationMs(200, 3), 1000); // too fast → clamped up
assert.strictEqual(F.clampDurationMs(500, 1), 500); // single transition, slow enough
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd simulator/static && node --test flash.test.js`
Expected: FAIL ("Cannot find module './flash.js'").
- [ ] **Step 3: Implement `flash.js`**
```js
// Pure photosensitivity helper. WCAG 2.3.1: no more than 3 general flashes
// (luminance transitions) per second. Given a transition COUNT, returns the
// minimum total duration that keeps the rate at or below 3/sec, and a clamp
// that only ever slows a transition down. UMD: browser `HEFFlash` + require().
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFFlash = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const MAX_PER_SEC = 3;
function minSafeDurationMs(count) {
const n = Math.max(0, Number(count) || 0);
return (n / MAX_PER_SEC) * 1000;
}
function clampDurationMs(requestedMs, count) {
return Math.max(Number(requestedMs) || 0, minSafeDurationMs(count));
}
return { MAX_PER_SEC, minSafeDurationMs, clampDurationMs };
});
```
- [ ] **Step 4: Run tests to verify pass**
Run: `cd simulator/static && node --test flash.test.js`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add simulator/static/flash.js simulator/static/flash.test.js
git commit -m "feat(a11y): flash.js pure WCAG 2.3.1 flash-clamp helper + tests"
```
---
### Task 4: Photosensitivity warning gate
**Files:**
- Modify: `simulator/static/index.html` (gate markup inside `.screen`)
- Modify: `simulator/static/style.css` (gate styles)
- Modify: `simulator/static/app.js` (gate logic around `run-sim` reveal, `app.js:1297`)
- Modify: `simulator/static/i18n.js` (gate strings)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: nothing.
- Produces: `WARN_KEY` localStorage flag; `maybeShowMotionWarning()` called before `run-sim` is revealed.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("motion warning gate shows once, then is remembered", async ({ page }) => {
await page.goto("/");
const gate = page.locator("#motion-warning");
await expect(gate).toBeVisible();
await page.locator("#motion-warning-continue").click();
await expect(gate).toBeHidden();
// Reload: dismissal persisted, gate stays hidden, run-sim is the entry point.
await page.reload();
await expect(page.locator("#motion-warning")).toBeHidden();
await expect(page.locator("#run-sim")).toBeVisible();
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
Expected: FAIL (no `#motion-warning`).
- [ ] **Step 3: Add gate markup in `index.html`**
Inside `<div class="screen">`, after the `#run-sim` button:
```html
<div id="motion-warning" class="motion-warning hidden" role="dialog" aria-modal="true" aria-labelledby="mw-title">
<div class="mw-card">
<h2 id="mw-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
<p data-i18n="warn.body">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.</p>
<button type="button" id="motion-warning-continue" class="run-sim" data-i18n="warn.continue">Continue</button>
</div>
</div>
```
- [ ] **Step 4: Add gate styles in `style.css`**
```css
.motion-warning { position: absolute; inset: 0; z-index: 70; display: flex;
align-items: center; justify-content: center; padding: 1rem;
background: rgba(2, 4, 10, 0.92); }
.motion-warning.hidden { display: none; }
.mw-card { max-width: 30rem; text-align: center; color: #dfeaff; }
.mw-card h2 { font-size: 1.1rem; margin: 0 0 0.6rem; }
.mw-card p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 1.2rem; color: #b9c8e0; }
.mw-card .run-sim { position: static; transform: none; }
.mw-card .run-sim:hover { transform: scale(1.04); }
```
- [ ] **Step 5: Add i18n strings in `i18n.js`**
Add to `UI_STRINGS` (EN required; other langs may be added later, fallback is EN):
```js
"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.continue": { en: "Continue" },
```
- [ ] **Step 6: Wire gate logic in `app.js`**
Add near `DEV_KEY` (`app.js:918`):
```js
const WARN_KEY = "hef.motionWarnDismissed";
function motionWarnDismissed() {
try { return localStorage.getItem(WARN_KEY) === "1"; } catch (_) { return false; }
}
function maybeShowMotionWarning() {
const gate = $("motion-warning");
if (!gate) return;
if (motionWarnDismissed()) { gate.classList.add("hidden"); return; }
gate.classList.remove("hidden");
$("motion-warning-continue").addEventListener("click", () => {
try { localStorage.setItem(WARN_KEY, "1"); } catch (_) {}
gate.classList.add("hidden");
$("motion-warning-continue").focus({ preventScroll: true });
$("run-sim").focus({ preventScroll: true });
}, { once: true });
}
```
In `main()`, immediately after `$("run-sim").classList.remove("hidden");` (`app.js:1297`), add:
```js
maybeShowMotionWarning(); // photosensitivity notice on first visit (over the stage)
```
- [ ] **Step 7: Run the gate test to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
Expected: PASS. (If `localStorage` carries across tests, the test clears it via `page.goto` fresh context — Playwright uses a fresh context per test by default.)
- [ ] **Step 8: Commit**
```bash
git add simulator/static/index.html simulator/static/style.css simulator/static/app.js simulator/static/i18n.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): one-time photosensitivity warning gate"
```
---
### Task 5: Reduced-motion freeze-to-stills + toggle
**Files:**
- Modify: `simulator/static/index.html` (toggle in Output fieldset)
- Modify: `simulator/static/i18n.js` (toggle label)
- Modify: `simulator/static/app.js` (`reduceMotion` state, freeze logic, `autoScrub` instant path)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: nothing.
- Produces: `reduceMotion` boolean; `isReduced()` accessor used by `autoScrub` (Task 5) and dial keyboard (Task 6); `applyReduceMotion()` pauses/resumes playback.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("reduce-motion toggle pauses video playback", async ({ page }) => {
await page.goto("/");
// Dismiss the gate and start the experience so video is playing.
await page.locator("#motion-warning-continue").click().catch(() => {});
await page.locator("#run-sim").click();
await page.waitForTimeout(400);
await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
await page.locator("#reduce-motion").check();
await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
Expected: FAIL (no `#reduce-motion`).
- [ ] **Step 3: Add the toggle markup in `index.html`**
In the Output fieldset, after the Audio level label (and before the language picker added in Task 1), add a switch mirroring the existing `.dev-switch`:
```html
<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>
```
- [ ] **Step 4: Add the i18n label in `i18n.js`**
```js
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
```
- [ ] **Step 5: Add `reduceMotion` state + freeze logic in `app.js`**
Near `DEV_KEY`/`WARN_KEY`:
```js
const RM_KEY = "hef.reduceMotion";
let reduceMotion = false;
function isReduced() { return reduceMotion; }
function initReduceMotion() {
const box = $("reduce-motion");
let stored = null;
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
// Default to the OS setting when the user hasn't chosen yet.
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();
});
}
applyReduceMotion();
}
function applyReduceMotion() {
if (reduceMotion) {
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
vid.pause();
loopVid.pause();
} else if (videoEverOn && $("visual").checked) {
playLoop();
}
}
```
- [ ] **Step 6: Make `autoScrub` instant under reduced motion**
At the top of `autoScrub` (`app.js:678`), after the `if (!ring …) return;` guard, add:
```js
if (isReduced()) { // no autonomous tween — jump straight to the target
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
setPos(targetPos);
return;
}
```
- [ ] **Step 7: Keep playback paused when reduced-motion is on after landing**
In `beginExperience()` (`app.js:1250`) and wherever `playLoop()` is called on a settle, guard the play with `if (!isReduced()) playLoop();` — specifically wrap the settle-time `playLoop()` at `app.js:850` and the `beginExperience` start so turning the experience on while reduced does not animate. (Inspect each `playLoop()` call site; guard the autonomous ones, leave the explicit toggle-off resume in `applyReduceMotion`.)
- [ ] **Step 8: Call `initReduceMotion()` in `main()`**
After `initLanguage();` (`app.js:1266`) add:
```js
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
```
- [ ] **Step 9: Run the reduced-motion test to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
Expected: PASS.
- [ ] **Step 10: Commit**
```bash
git add simulator/static/index.html simulator/static/i18n.js simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): reduced-motion freeze-to-stills with OS-default toggle"
```
---
### Task 6: Keyboard + ARIA for the Altitude dial
**Files:**
- Modify: `simulator/static/index.html:56` (dial svg attrs)
- Modify: `simulator/static/app.js` (keydown handler, aria-value sync, label buttons)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: `autoScrub`, `jumpToScale`, `ring`, `ringIndex`, `dialStep` (all in `app.js`).
- Produces: `setDialAria()` updates `aria-valuenow/valuetext`; called wherever the needle settles.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("altitude dial is keyboard-operable", async ({ page }) => {
await page.goto("/");
await page.locator("#motion-warning-continue").click().catch(() => {});
const dial = page.locator("#dial");
await expect(dial).toHaveAttribute("role", "slider");
await dial.focus();
const before = await page.locator("#scale-name").textContent();
await dial.press("ArrowDown"); // descend one altitude
await expect.poll(async () => page.locator("#scale-name").textContent()).not.toBe(before);
await dial.press("Home"); // jump to cosmos (top)
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
Expected: FAIL (dial has no `role`/key handling).
- [ ] **Step 3: Add ARIA attrs to the dial in `index.html`**
Change line 56 to:
```html
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn to change scale"
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
```
- [ ] **Step 4: Add `setDialAria()` and call it on settle**
In `app.js`, add:
```js
// Reflect the committed altitude into the dial's slider semantics for AT.
function setDialAria() {
if (!dial || !ring) return;
dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
dial.setAttribute("aria-valuenow", String(ringIndex));
const s = ring.scales[ringIndex];
if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
}
```
Call `setDialAria()` at the settle point in `setPos` where `setNeedle(ringIndex * dialStep())` runs on frac 0 (`app.js:852`), and once in `buildDial()` after the dial is drawn.
- [ ] **Step 5: Add the keydown handler**
```js
function onDialKey(e) {
if (!ring || ring.scales.length < 2) return;
let handled = true;
switch (e.key) {
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break;
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break;
case "Home": jumpToScale(0); break;
case "End": jumpToScale(ring.scales.length - 1); break;
default: handled = false;
}
if (handled) e.preventDefault();
}
```
Wire it in `main()` next to the other dial listeners (`app.js:1289`):
```js
dial.addEventListener("keydown", onDialKey);
```
- [ ] **Step 6: Make the dial labels keyboard-activatable**
In `buildDial()` (where each `.dial-label` is created, ~`app.js:749`), add to the label's attributes: `role: "button"`, `tabindex: "0"`, and an `aria-label` of the scale name. Then in `main()` add a delegated keydown on the dial that activates a focused label:
```js
dial.addEventListener("keydown", (e) => {
const t = e.target;
if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
jumpToScale(+t.getAttribute("data-index"));
}
});
```
- [ ] **Step 7: Run the keyboard test to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): keyboard + ARIA slider semantics for the Altitude dial"
```
---
### Task 7: Screen-reader narration (aria-live) + hide decorative SVG
**Files:**
- Modify: `simulator/static/index.html` (aria-live region; `aria-hidden` on decorative layers)
- Modify: `simulator/static/app.js` (announce on settle, throttled)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: `activeClipId`, the clip's resolved strings, `ring`, `ringIndex`.
- Produces: `announce(text)`; `decorative SVGs are aria-hidden`.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("an aria-live region narrates the current scale", async ({ page }) => {
await page.goto("/");
await page.locator("#motion-warning-continue").click().catch(() => {});
const live = page.locator("#sr-status");
await expect(live).toHaveAttribute("aria-live", "polite");
await page.locator("#dial").focus();
await page.locator("#dial").press("Home");
await expect.poll(async () => (await live.textContent())?.toLowerCase()).toContain("cosmos");
});
test("decorative overlay SVGs are hidden from AT", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
Expected: FAIL.
- [ ] **Step 3: Add the live region + aria-hidden in `index.html`**
Add `aria-hidden="true"` to `#overlay`, `#affect`, `#tint`, `#paint`. Add inside `.panel` (top, after the opening tag) a live region:
```html
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
```
- [ ] **Step 4: Add `announce()` + call on settle in `app.js`**
```js
let lastAnnounced = "";
function announce(text) {
const el = $("sr-status");
if (!el || !text || text === lastAnnounced) return;
lastAnnounced = text;
el.textContent = text;
}
// Build the spoken summary: scale + top factual (left) label for the active clip.
function announceState() {
if (!ring) return;
const s = ring.scales[ringIndex];
const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
const clip = activeClip && activeClip(); // use existing accessor for the locked clip
let label = "";
if (clip) {
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
label = firstFactualLabel(strings) || "";
}
announce(label ? `${scaleName}. ${label}` : scaleName);
}
```
If no `activeClip()` accessor exists, read the locked clip via the existing `activeClipId` lookup used in `renderScaleReadout` (mirror that code). `firstFactualLabel` returns the first non-empty left/`LABELS`-style string from `strings`; if the shape makes this awkward, announce just `scaleName` (degrade gracefully — the scale name is the load-bearing part).
Call `announceState()` at the same settle point as `setDialAria()` (frac 0 in `setPos`) and at the end of `setLanguage()` so a language switch re-announces.
- [ ] **Step 5: Run the narration tests to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): aria-live narration of scale/label; hide decorative SVGs"
```
---
### Task 8: Apply the flash audit to real transitions
**Files:**
- Modify: `simulator/static/app.js` and/or `simulator/static/style.css` (clamp the audited transitions)
- Modify: `simulator/static/index.html` (load `flash.js` before `app.js`)
**Interfaces:**
- Consumes: `HEFFlash.clampDurationMs` (Task 3).
- [ ] **Step 1: Load `flash.js` in `index.html`**
Add before `app.js` (after `i18n.js`, `index.html:117`):
```html
<script src="flash.js"></script>
```
- [ ] **Step 2: Audit + enumerate the rapid-luminance transitions**
Inspect the three sources and record findings as a comment in `app.js` above the change:
1. Fast-spin blended dial pass — `autoScrub` total `ms = perAltMs * |dist|`; the per-altitude floor is 1200ms (well under 3/sec for one step), so a multi-step spin is already ≥3 transitions over ≥N×1.2s = safe. Confirm and note.
2. `#black` cover fade — `style.css` `transition: opacity 200ms`; a single fade is one transition, not a repeated flash — safe.
3. Audio-coupled crossfade — visual? If it drives an opacity swap, check its duration.
For any source that CAN repeat faster than 3/sec (e.g. a rapid wheel/keyboard repeat firing `autoScrub` back-to-back), clamp using `HEFFlash`:
```js
// Guard against a fast key/wheel repeat producing >3 luminance swings/sec.
const safeMs = HEFFlash.clampDurationMs(PER_ALTITUDE_MS, 1);
```
If the audit finds NO breach (the likely outcome given the 1200ms floor), record that conclusion in the comment and make no timing change beyond loading `flash.js` for the helper's availability — do not invent a clamp that isn't needed (YAGNI). The deliverable of this task is the documented audit + `flash.js` wired in.
- [ ] **Step 3: Run existing e2e to confirm no regression**
Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts`
Expected: PASS (12 tests, per project memory).
- [ ] **Step 4: Commit**
```bash
git add simulator/static/index.html simulator/static/app.js
git commit -m "feat(a11y): wire flash-clamp helper; document 3-flash/sec audit"
```
---
### Task 9: about.html + header link + static-build copy
**Files:**
- Create: `simulator/static/about.html`
- Modify: `simulator/static/index.html:18` (header link)
- Modify: `simulator/static/i18n.js` (about link label)
- Modify: `tools/build_static.py:38` (add `about.html`, `flash.js` to `PUBLIC_ASSETS`)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: existing `.credits-page` / `.credits-wrap` / `.back-link` styles.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("about page loads and links back to the experience", async ({ page }) => {
await page.goto("/about.html");
await expect(page.locator("h1")).toContainText(/about/i);
await expect(page.getByText(/imperfect/i)).toBeVisible();
await page.locator(".back-link").click();
await expect(page).toHaveURL(/index\.html|\/$/);
});
test("header links to the about page", async ({ page }) => {
await page.goto("/");
await expect(page.locator('a[href="about.html"]')).toBeVisible();
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
Expected: FAIL (no about.html / link).
- [ ] **Step 3: Create `about.html`**
Mirror `credits.html` structure exactly (body `class="credits-page"`, `main.credits-wrap`, back-link, `config.js`):
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About — Human Experience Filter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
<main class="credits-wrap">
<header>
<h1>About this work</h1>
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
</header>
<section>
<h2>For everyone</h2>
<p>The <em>Human Experience Filter</em> is built to be accessible to — and
representative of — the full range of human experience. That is why it works
with a keyboard, with a screen reader, with reduced motion, and away from any
single screen or kiosk: an experience about being human should be open to as
many humans as possible.</p>
</section>
<section>
<h2>Places technology let us see</h2>
<p>The scales you move through — the deep sea, the coast, the sky, the orbit
of the Earth, the wider cosmos — are vantage points no unaided human could
ever witness. We can share them only because our tools evolved to reach them:
from <strong>underwater exploration</strong>, to <strong>drones and aerial
imaging</strong>, to <strong>space exploration</strong>. Each scale is a place
a machine went first so that a person could feel what it is like to be there.</p>
</section>
<section>
<h2>Built with LLMs</h2>
<p>This piece was itself built using large language models — an extension of
that same long arc of tools that widen what a single human can reach. The
software, the words, and much of the craft were shaped in collaboration with
a machine.</p>
</section>
<section>
<h2>Honest about its limits</h2>
<p>This implementation is imperfect. It is also more than I could have made
alone: better with these tools assisting than by my hand as a single human.
That tension is part of the point — technology does not replace the person
behind the work; it extends their reach, flaws and all.</p>
</section>
</main>
<script src="config.js"></script>
</body>
</html>
```
- [ ] **Step 4: Add the header link in `index.html`**
After the existing credits link (`index.html:18`), add:
```html
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
```
- [ ] **Step 5: Add the i18n label in `i18n.js`**
```js
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
```
- [ ] **Step 6: Add the new files to the static build**
In `tools/build_static.py:38`, extend `PUBLIC_ASSETS`:
```python
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
"credits.html", "credits.js", "about.html", "flash.js"]
```
- [ ] **Step 7: Run the about-page tests to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
Expected: PASS.
- [ ] **Step 8: Verify the static build includes the new files**
Run: `cd simulator/e2e && npx playwright test static-build.spec.ts` (or run `python3 tools/build_static.py` and confirm `dist/.../about.html` + `flash.js` exist).
Expected: PASS / files present.
- [ ] **Step 9: Commit**
```bash
git add simulator/static/about.html simulator/static/index.html simulator/static/i18n.js tools/build_static.py simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(content): about.html — intent, provenance, honest limits; static-build copy"
```
---
### Task 10: Full-suite green + finish
**Files:** none (verification).
- [ ] **Step 1: Run the full e2e a11y spec**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts`
Expected: all PASS.
- [ ] **Step 2: Run the node helper tests**
Run: `cd simulator/static && node --test flash.test.js`
Expected: PASS.
- [ ] **Step 3: Run the existing suites that should stay green**
Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts i18n.spec.ts static-build.spec.ts`
Expected: PASS. (`loop-recovery.spec.ts` is known-red on a clean baseline here — do not block on it, but confirm it is no MORE broken.)
Run: `python3 -m pytest` from repo root for the Python suite.
Expected: PASS.
- [ ] **Step 4: Final review + branch finish**
Invoke `superpowers:finishing-a-development-branch` to choose merge/PR. Do NOT auto-merge to main without the operator's go (per session posture).
## Self-Review
**Spec coverage:** A1 reduced-motion → Task 5; A2 gate → Task 4, audit → Tasks 3+8; B keyboard/focus → Tasks 2+6; C contrast → Task 2; D SR narration → Task 7; E about.html → Task 9; F layout → Task 1. `<html lang>` correctly omitted (already done). All covered.
**Placeholder scan:** Code shown for every code step. Task 7's `firstFactualLabel`/`activeClip` note explicitly degrades to scale-name-only if the clip-strings shape is awkward — that is a defined fallback, not a placeholder. Task 8 explicitly allows a "no breach found → document and stop" outcome (YAGNI), which is a real deliverable.
**Type consistency:** `isReduced()`, `autoRaf`, `setPos`, `autoScrub`, `jumpToScale`, `setDialAria`, `announce`, `HEFFlash.clampDurationMs`, `WARN_KEY`/`RM_KEY` used consistently across tasks. `reduce-motion` element id matches between HTML (Task 5 Step 3) and JS (Task 5 Step 5) and test.
@@ -0,0 +1,706 @@
# Cloudflare Static Publish — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Publish the simulator as a fully-static site (no origin server) to `benstull.art/human-experience-simulator` on Cloudflare Pages, with media on R2.
**Architecture:** Port the only live server logic (`/api/alteration`, the delta=0 random pick) to client JS; serve the read-APIs as baked JSON; point media at a configurable R2 base with CORS; keep the existing graduated prefetch. A Python build script emits `dist/` (Pages) + a media sync tree (R2). The dev FastAPI server stays for localhost/authoring.
**Tech Stack:** Vanilla JS (UMD modules, `node --test`), Python 3 build script (imports existing `simulator`/`player` builders), Playwright E2E, `wrangler` (Pages + R2), Cloudflare redirect rule.
## Global Constraints
- **Path:** app served at `/human-experience-simulator`; apex `benstull.art` **redirects** there.
- **Media base:** `https://static.benstull.art/` (R2 custom domain), public bucket.
- **CORS is load-bearing:** R2 must send `Access-Control-Allow-Origin: https://benstull.art`; `<video>`/`<audio>` carry `crossOrigin="anonymous"` — else the WebGL dream shader taints.
- **Served media = manifest-referenced only:** `base.mp4` clip bases + 154 transition morphs + 10 `.mp3`. NEVER sync `master.mp4`/`mezzanine.mp4`.
- **Versioning preserved:** keep the `?v=<hash>` query; set those objects `Cache-Control: public, max-age=31536000, immutable` on the CDN.
- **No new runtime:** no Pages Functions / Worker; no Service Worker.
- **Project is exempt from flotilla/PPE/§9** (operator-set) — deploy directly to Cloudflare. Safety/hygiene rules (git-ssh, branch→PR, secrets) still hold.
- **Module idiom:** UMD — `(function(root,factory){ if (module?.exports) module.exports=factory(); else root.HEFx=factory(); })(...)` — matching `simulator/static/scrub.js`.
- **JS tests run:** `node --test simulator/unit`. **Python tests run:** `.venv/bin/python -m pytest`.
---
### Task 1: Client-side alteration engine (`HEFAlteration`)
Port `player/alteration.py::plan_alteration` (+ `render_plan_to_dict`) and `player/audio.py::resolve_visual`/`resolve_audio` to a pure UMD JS module, producing byte-for-byte the same shape `/api/alteration` returns. Calibration is always unity (`DEFAULT_CALIBRATION`; the client never sends one — `app.js:619`).
**Files:**
- Create: `simulator/static/alteration.js`
- Test: `simulator/unit/alteration.test.js`
**Interfaces:**
- Produces (browser global `HEFAlteration`, also `module.exports`):
- `plan(controls)``{ grade:{tone}, overlay:{level,intensity}, affect:{strength,intensity}, dream:{strength,intensity}, is_identity }`
- `renderAudio(source, scaleAudio, mediaBase)``{ source, url, altitude_coupled }`
- `alteration(controls, scaleAudio, mediaBase)``{ plan, render:{ video:{shown}, audio:{source,url,altitude_coupled} } }` (the `/api/alteration` response)
- `controls` shape (from `app.js:595` `controls()`): `{ visual:"on"|"off", audio:"soundtrack"|"off", left:int, right:int, dark:int, light:int }`
- [ ] **Step 1: Write the failing test**
```js
// simulator/unit/alteration.test.js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const A = require("../static/alteration.js");
const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o });
test("identity at zero knobs", () => {
const p = A.plan(c({}));
assert.equal(p.grade.tone, 0);
assert.equal(p.overlay.level, 0);
assert.equal(p.dream.strength, 0);
assert.equal(p.is_identity, true);
});
test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => {
assert.equal(A.plan(c({ light: 4 })).grade.tone, 1); // full light
assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1); // full dark
assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5);
});
test("left drives overlay level + intensity; right drives dream + affect", () => {
const p = A.plan(c({ left: 2, right: 4 }));
assert.equal(p.overlay.level, 2);
assert.equal(p.overlay.intensity, 0.5);
assert.equal(p.dream.strength, 4);
assert.equal(p.dream.intensity, 1);
assert.equal(p.affect.strength, 4);
assert.equal(p.affect.intensity, 1);
assert.equal(p.is_identity, false);
});
test("renderAudio: off → null, no coupling", () => {
assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"),
{ source: "off", url: null, altitude_coupled: false });
});
test("renderAudio: soundtrack → mediaBase audio url, coupled", () => {
assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"),
{ source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true });
// no scale audio → null url but still coupled
assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"),
{ source: "soundtrack", url: null, altitude_coupled: true });
});
test("alteration() composes plan + render with video.shown from visual", () => {
const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/");
assert.equal(r.render.video.shown, false);
assert.equal(r.render.audio.url, "https://x/audio/reef.mp3");
assert.equal(r.plan.overlay.level, 1);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test simulator/unit/alteration.test.js`
Expected: FAIL — `Cannot find module '../static/alteration.js'`.
- [ ] **Step 3: Write the implementation**
```js
// simulator/static/alteration.js
// Client-side port of player/alteration.py + player/audio.py (the alteration
// engine). Replaces the live POST /api/alteration in the static build — pure math,
// unity calibration. UMD so the browser gets `HEFAlteration` and node --test can
// require() it. Keep IN SYNC with the Python (guarded by parity tests there).
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFAlteration = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const KNOB_MAX = 4; // player/alteration.py:25
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
function plan(c) {
const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1);
const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1);
const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1);
const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1); // affect uses overlay_gain too
const is_identity = tone === 0 && c.left === 0 && c.right === 0;
return {
grade: { tone },
overlay: { level: c.left, intensity: overlayIntensity },
affect: { strength: c.right, intensity: affectIntensity },
dream: { strength: c.right, intensity: dreamIntensity },
is_identity,
};
}
function renderAudio(source, scaleAudio, mediaBase) {
if (source === "off") return { source: "off", url: null, altitude_coupled: false };
// soundtrack — couple to the current altitude's asset
const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null;
return { source: "soundtrack", url, altitude_coupled: true };
}
function alteration(controls, scaleAudio, mediaBase) {
return {
plan: plan(controls),
render: {
video: { shown: controls.visual === "on" },
audio: renderAudio(controls.audio, scaleAudio, mediaBase),
},
};
}
return { plan, renderAudio, alteration, KNOB_MAX };
});
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `node --test simulator/unit/alteration.test.js`
Expected: PASS (6 tests).
- [ ] **Step 5: Add a Python parity test (guards JS↔Python drift)**
Create `tests/test_alteration_js_parity.py`:
```python
"""The static build reimplements plan_alteration/resolve_audio in JS
(simulator/static/alteration.js). This pins the numeric contract so the Python
and JS cannot silently diverge — if you change the engine, change both."""
from hef.selection import Coordinate
from player.alteration import plan_alteration, render_plan_to_dict
from player.audio import resolve_audio, resolve_visual
def test_engine_contract_matches_js_expectations():
# mirrors the cases asserted in simulator/unit/alteration.test.js
p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0)))
assert p["overlay"] == {"level": 2, "intensity": 0.5}
assert p["dream"] == {"strength": 4, "intensity": 1.0}
assert p["affect"] == {"strength": 4, "intensity": 1.0}
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0
assert resolve_visual("off") is False
a = resolve_audio("soundtrack", scale_audio="cosmos.mp3")
assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True
```
- [ ] **Step 6: Run + commit**
Run: `node --test simulator/unit/alteration.test.js && .venv/bin/python -m pytest tests/test_alteration_js_parity.py -q`
Expected: all PASS.
```bash
git add simulator/static/alteration.js simulator/unit/alteration.test.js tests/test_alteration_js_parity.py
git commit -m "feat(static): client-side alteration engine (port of player.alteration/audio)"
```
---
### Task 2: Static config + boot rewiring in `app.js`
Introduce a runtime config the build injects, then make `app.js` (a) read media from a configurable base, (b) boot from baked JSON in static mode, (c) pick pool members client-side, (d) compute alteration locally, (e) tag media elements with `crossOrigin`, (f) skip the dev-version poll in static mode. All changes degrade to today's behavior when config is absent (dev server unaffected).
**Files:**
- Create: `simulator/static/config.js` (dev default; the build overwrites it in `dist/`)
- Modify: `simulator/static/app.js` (boot `loadData` ~59, `pickRandomMember` ~87, `mediaNetUrl` ~123, alteration fetch ~617, dev poll ~1041, video/audio element creation)
- Modify: `simulator/static/index.html:111` (load `config.js` before `app.js`)
**Interfaces:**
- Consumes: `HEFAlteration` (Task 1).
- Produces: global `HEF_CONFIG = { mediaBase: string, static: boolean }`. Dev default `{ mediaBase: "/media/", static: false }`.
- [ ] **Step 1: Create the dev-default config and load it**
`simulator/static/config.js`:
```js
// Runtime config. This dev default serves media from the local FastAPI /media
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
```
In `simulator/static/index.html`, add before `app.js` (line 111 area):
```html
<script src="/config.js"></script>
```
(Place it before `<script src="/app.js"></script>`.)
- [ ] **Step 2: Media base — modify `mediaNetUrl` (`app.js:123`)**
Replace:
```js
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
```
with:
```js
function mediaNetUrl(file) {
const base = (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/";
const v = mediaVersions[file];
return base + file + (v ? "?v=" + v : "");
}
```
Note: audio-layer absolute urls already begin with the configured base — keep the existing `file.startsWith("/media/")` guard in `mediaUrl`, and ALSO short-circuit absolute `http` urls:
```js
function mediaUrl(file) {
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
return mediaBlobs[file] || mediaNetUrl(file);
}
```
And in `HEFAlteration.alteration(...)` we pass `mediaBase`, so the audio `url` it returns is already absolute (R2) in static mode and `/media/...` in dev.
- [ ] **Step 3: Boot from baked JSON — modify `loadData` (`app.js:59`)**
Replace the three `fetch("/api/...")` calls with a base that depends on mode:
```js
async function loadData() {
const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
? { clips: "./clips.json", versions: "./media-versions.json", ring: "./ring.json" }
: { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
const clips = (await (await fetch(api.clips)).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch(api.ring);
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
// ...rest unchanged (fallback ring synth, morphByPair build, ringIndex = 0)
}
```
(`clips.json` mirrors `{clips:[...]}`, `ring.json` mirrors `ring_to_dict(...)`, `media-versions.json` mirrors `{versions:{...}}` — Task 3 bakes them.)
- [ ] **Step 4: Client-side pick — modify `pickRandomMember` (`app.js:87`)**
The delta=0 server pick becomes a uniform client-side pool pick (matches `hef`'s `pick_clip_id`):
```js
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
}
```
(Drag/scroll navigation already resolves picks + morphs client-side via `scrub.js`; this removes the last `/api/ring/advance` dependency. `serverRing` stays as the "real ring vs synthesized" flag.)
- [ ] **Step 5: Local alteration — modify the fetch at `app.js:617`**
Replace:
```js
const resp = await fetch("/api/alteration", { /* ... */ body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }) });
const data = await resp.json();
```
with a mode-aware path that reuses the engine:
```js
let data;
if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
const scale = ring && ring.scales[ringIndex];
const scaleAudio = (scale && scale.audio) || "";
const base = window.HEF_CONFIG.mediaBase;
data = HEFAlteration.alteration(controls(), scaleAudio, base);
} else {
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
data = await resp.json();
}
```
**Prerequisite:** `ring.json` must expose each scale's `audio` field. Verify `ring_to_dict` includes it; if not, that is a Task 3 bake concern (add it). The rest of the function (`data.render`, `data.plan.*`) is unchanged.
- [ ] **Step 6: crossOrigin on media elements (CORS for WebGL)**
Find where `<video>` and `<audio>` elements are created/used in `app.js` (the double-buffered `#vid`/loop videos and the audio layer). Set `crossOrigin` BEFORE assigning `src`:
```js
videoEl.crossOrigin = "anonymous";
```
Apply to: the visible video, the preload/double-buffer video, and the `<audio>` element. (In dev, same-origin `/media/` ignores it; in static it prevents GL-texture taint and lets ranged `fetch()` succeed under CORS.) Also set `crossorigin="anonymous"` on any static `<video>`/`<audio>` in `index.html`.
- [ ] **Step 7: Skip the dev-version poll in static mode (`app.js:1041`)**
Wrap the `/dev/version` poller so it no-ops when `HEF_CONFIG.static` (there is no dev server):
```js
if (!(window.HEF_CONFIG && window.HEF_CONFIG.static)) {
// existing setInterval(... fetch("/dev/version") ...) poll
}
```
- [ ] **Step 8: Manual dev smoke (no regression)**
Run (this env has only `python3`):
```bash
.venv/bin/python -m uvicorn simulator.app:app --port 8000
```
Open `http://localhost:8000/`, confirm boot, altitude changes, dream/overlay still work (dev mode: `HEF_CONFIG.static=false`, behavior identical to before).
- [ ] **Step 9: Commit**
```bash
git add simulator/static/config.js simulator/static/app.js simulator/static/index.html
git commit -m "feat(static): config-driven media base, baked-JSON boot, client pick/alteration, crossOrigin"
```
---
### Task 3: Static build script → `dist/` + media sync tree
A Python script that produces the deployable `dist/` (frontend + baked JSON + static config) and a separate media sync tree containing ONLY manifest-referenced files. Reuses the live app's loaders so the baked JSON is identical to the API output.
**Files:**
- Create: `tools/build_static.py`
- Create: `tests/test_build_static.py`
**Interfaces:**
- Consumes: `simulator.app.create_app` (to render the same JSON the API serves) OR the underlying loaders (`simulator.clips`/`build_pool_manifest` + `player`/`hef`). Prefer driving `create_app(manifest)` via `fastapi.testclient.TestClient` so the bake is byte-identical to the API.
- Produces: `build_static(out_dir, media_out, *, media_base, app_path) -> dict` (counts), and a CLI.
- [ ] **Step 1: Write the failing test**
```python
# tests/test_build_static.py
import json
from pathlib import Path
from tools.build_static import build_static
def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
out = tmp_path / "dist"
media = tmp_path / "media"
result = build_static(out, media, media_base="https://static.benstull.art/",
app_path="/human-experience-simulator")
# frontend present, dev/author surfaces excluded
assert (out / "index.html").exists()
assert (out / "app.js").exists()
assert (out / "scrub.js").exists()
assert (out / "alteration.js").exists()
assert (out / "config.js").exists()
assert not (out / "author.html").exists()
assert not list(out.glob("review*.html"))
# baked JSON matches the API shape
clips = json.loads((out / "clips.json").read_text())
assert "clips" in clips and clips["clips"]
ring = json.loads((out / "ring.json").read_text())
assert "scales" in ring and "transitions" in ring
assert all("audio" in s for s in ring["scales"]) # needed by client alteration
versions = json.loads((out / "media-versions.json").read_text())
assert "versions" in versions
# config.js points at R2 + static mode
cfg = (out / "config.js").read_text()
assert "https://static.benstull.art/" in cfg and "static: true" in cfg
# media tree holds ONLY referenced files — no masters/mezzanines
synced = {p.name for p in media.rglob("*.mp4")}
assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced)
# every versions key exists in the media tree
for f in versions["versions"]:
assert (media / f).exists(), f"missing synced media: {f}"
assert result["media_files"] == len(list(media.rglob("*")))
```
- [ ] **Step 2: Run to verify it fails**
Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
Expected: FAIL — `ModuleNotFoundError: tools.build_static`.
- [ ] **Step 3: Implement the build script**
```python
# tools/build_static.py
"""Build the fully-static deployable for Cloudflare (Pages + R2).
Emits `out_dir/` (frontend + baked API JSON + static config.js) and a `media_out/`
sync tree containing ONLY the files the manifest references (clip bases + transition
morphs + audio) — never the master/mezzanine pipeline sources. The baked JSON is
produced through the real app (TestClient) so it is byte-identical to the API.
Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in
deploy/cloudflare/ — this script only produces artifacts.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from fastapi.testclient import TestClient
from simulator.app import create_app, MEDIA_DIR
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css"]
def _bake_api(out: Path) -> dict:
app = create_app()
client = TestClient(app)
clips = client.get("/api/clips").json()
ring = client.get("/api/ring").json()
versions = client.get("/api/media-versions").json()
(out / "clips.json").write_text(json.dumps(clips, ensure_ascii=False))
(out / "ring.json").write_text(json.dumps(ring, ensure_ascii=False))
(out / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False))
return versions["versions"]
def _write_config(out: Path, media_base: str) -> None:
(out / "config.js").write_text(
"// GENERATED by tools/build_static.py — do not edit in dist/.\n"
f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n'
)
def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
out = Path(out_dir)
media = Path(media_out)
for d in (out, media):
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True)
for name in PUBLIC_ASSETS:
src = STATIC / name
if src.exists():
shutil.copy2(src, out / name)
versions = _bake_api(out)
_write_config(out, media_base)
# Sync ONLY referenced media (versions covers bases + morphs); add audio explicitly.
referenced = set(versions.keys())
ring = json.loads((out / "ring.json").read_text())
for s in ring.get("scales", []):
if s.get("audio"):
referenced.add(f"audio/{s['audio']}")
n = 0
for rel in sorted(referenced):
src = MEDIA_DIR / rel
if not src.exists():
raise FileNotFoundError(f"referenced media missing on disk: {rel}")
dst = media / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
n += 1
return {"out": str(out), "media": str(media), "media_files": n, "app_path": app_path}
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="dist")
ap.add_argument("--media-out", default="dist-media")
ap.add_argument("--media-base", default="https://static.benstull.art/")
ap.add_argument("--app-path", default="/human-experience-simulator")
a = ap.parse_args()
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
print(json.dumps(r, indent=2))
```
**Verify before running the test:** confirm `simulator/app.py` exports `MEDIA_DIR` and that `ring_to_dict` includes `audio` per scale. If `audio` is absent from `ring.json`, add it to `ring_to_dict` (small change in `simulator/app.py` / the ring serializer) and add a Python test asserting `"audio" in scale dict`.
- [ ] **Step 4: Run the test to verify it passes**
Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
Expected: PASS. (Test asserts media tree excludes masters and includes every versioned file + audio.)
- [ ] **Step 5: Real build smoke**
Run: `.venv/bin/python tools/build_static.py`
Expected: prints counts; `dist/` has the frontend + 3 JSON + `config.js`; `dist-media/` ≈ ~2 GB, no `master.mp4`/`mezzanine.mp4`.
- [ ] **Step 6: Commit** (do NOT commit `dist/`/`dist-media/` — add to `.gitignore`)
```bash
printf '\n/dist/\n/dist-media/\n' >> .gitignore
git add tools/build_static.py tests/test_build_static.py .gitignore simulator/app.py
git commit -m "feat(static): build script — dist/ + manifest-only media sync tree, baked API JSON"
```
---
### Task 4: Cloudflare config artifacts + deploy runbook
Produce the version-controlled Cloudflare config (CORS policy, redirect, deploy commands). The actual deploy needs operator Cloudflare auth (`wrangler login` / API token) — `wrangler` is NOT installed here, so this task delivers artifacts + exact commands and the operator runs the authenticated steps.
**Files:**
- Create: `deploy/cloudflare/README.md` (runbook)
- Create: `deploy/cloudflare/r2-cors.json` (bucket CORS policy)
- Create: `deploy/cloudflare/_redirects` (copied into `dist/` for the apex/path redirect, or documented as a dashboard Redirect Rule)
**Interfaces:** none (ops artifacts).
- [ ] **Step 1: CORS policy**
`deploy/cloudflare/r2-cors.json`:
```json
[
{
"AllowedOrigins": ["https://benstull.art"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "Content-Type"],
"ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 86400
}
]
```
- [ ] **Step 2: Redirect artifact**
`deploy/cloudflare/_redirects` (Pages `_redirects` form — apex/root to the app path):
```
/ /human-experience-simulator 308
```
(If the app is deployed as a Pages project bound to `benstull.art`, also document a dashboard **Redirect Rule**: `benstull.art/``/human-experience-simulator` as the authoritative alternative.)
- [ ] **Step 3: Runbook**
`deploy/cloudflare/README.md` — exact operator commands (run after `tools/build_static.py`):
```bash
npm i -g wrangler
wrangler login
wrangler r2 bucket create hef-media
wrangler r2 bucket cors put hef-media --rules deploy/cloudflare/r2-cors.json
# bind custom domain static.benstull.art to the bucket (dashboard: R2 > bucket > Settings > Custom Domains)
# upload media (preserves keys):
wrangler r2 bucket sync ./dist-media r2://hef-media
cp deploy/cloudflare/_redirects dist/_redirects
wrangler pages project create human-experience-simulator
wrangler pages deploy dist --project-name human-experience-simulator
# bind benstull.art to the Pages project; serve at /human-experience-simulator
# add the apex Redirect Rule if not using _redirects
```
Document: set the media objects' `Cache-Control: public, max-age=31536000, immutable` (sync flag or bucket lifecycle), and verify `wrangler r2 bucket sync` excludes nothing unexpected (the tree is already filtered by the build).
- [ ] **Step 4: Commit**
```bash
git add deploy/cloudflare/
git commit -m "chore(static): Cloudflare deploy artifacts — R2 CORS, apex redirect, runbook"
```
---
### Task 5: E2E against the static build
Verify the built `dist/` boots with **no `/api/*` server**, the dream shader survives cross-origin media (CORS, no taint), and scrub/morph/audio work. Serve `dist/` and the media from **two different origins** to exercise CORS the way R2 will.
**Files:**
- Create: `simulator/e2e/tests/static-build.spec.ts`
- Create: `simulator/e2e/serve-static.mjs` (tiny dual static server: app on one port, media w/ CORS on another)
**Interfaces:** consumes the `dist/` + `dist-media/` produced by Task 3.
- [ ] **Step 1: Dual static server with CORS**
`simulator/e2e/serve-static.mjs`:
```js
// Serves dist/ (app) and dist-media/ (media w/ CORS + Range) on two ports — a
// local stand-in for Pages + R2, so the static-build E2E exercises cross-origin
// media exactly as production will.
import http from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname } from "node:path";
const TYPES = { ".html":"text/html", ".js":"text/javascript", ".json":"application/json",
".css":"text/css", ".mp4":"video/mp4", ".mp3":"audio/mpeg" };
function serve(rootEnv, port, cors) {
const root = process.env[rootEnv];
http.createServer((req, res) => {
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
let p = decodeURIComponent(req.url.split("?")[0]);
if (p === "/") p = "/index.html";
const file = join(root, p);
try {
const st = statSync(file);
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
const range = req.headers.range; // honor Range for video scrub
if (range && /^bytes=/.test(range)) {
const [s, e] = range.replace("bytes=", "").split("-");
const start = +s, end = e ? +e : st.size - 1;
res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${st.size}`,
"Content-Length": end - start + 1 });
createReadStream(file, { start, end }).pipe(res);
} else { res.writeHead(200, { "Content-Length": st.size }); createReadStream(file).pipe(res); }
} catch { res.writeHead(404); res.end("nf"); }
}).listen(port);
}
serve("DIST_DIR", 8077, false);
serve("MEDIA_DIR_E2E", 8078, true);
console.log("static app :8077 media(cors) :8078");
```
- [ ] **Step 2: The spec**
`simulator/e2e/tests/static-build.spec.ts`:
```ts
import { test, expect } from "@playwright/test";
// Built dist/ is served on :8077; media (CORS) on :8078. The build's config.js
// must be overridden to point media at :8078 for this run (see Step 3).
test.use({ baseURL: "http://localhost:8077" });
test("boots fully static — no /api calls, ring + clips load from JSON", async ({ page }) => {
const apiCalls: string[] = [];
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
await page.goto("/");
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
expect(apiCalls, "no /api/* in static mode").toEqual([]);
});
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto("/");
// crank Right to engage the dream, let a few frames render
await page.evaluate(() => { const r = document.getElementById("right") as HTMLInputElement; r.value = "4"; r.dispatchEvent(new Event("input", { bubbles: true })); });
await page.waitForTimeout(2000);
// a tainted texture throws SecurityError on the GL draw; assert none surfaced
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
});
```
- [ ] **Step 3: Wire the run (build → point media → serve → test)**
Document in `simulator/e2e/README.md` and use a throwaway config for the run:
```bash
.venv/bin/python tools/build_static.py --media-base http://localhost:8078/
DIST_DIR=$PWD/dist MEDIA_DIR_E2E=$PWD/dist-media node simulator/e2e/serve-static.mjs &
cd simulator/e2e && npx playwright test static-build.spec.ts
```
(Building with `--media-base http://localhost:8078/` writes that base into `dist/config.js`, so the app fetches media cross-origin from the CORS server — the production CORS path, locally.)
- [ ] **Step 4: Run the E2E**
Expected: both tests PASS — zero `/api/*` requests; no `SecurityError` from the dream shader.
- [ ] **Step 5: Commit**
```bash
git add simulator/e2e/serve-static.mjs simulator/e2e/tests/static-build.spec.ts simulator/e2e/README.md
git commit -m "test(static): E2E — static boot with no API + dream shader survives cross-origin CORS"
```
---
## Self-Review
**Spec coverage:**
- No-origin static frontend → Tasks 2, 3. ✅
- Media on R2 + CORS + crossOrigin → Tasks 2 (crossOrigin), 4 (CORS policy). ✅
- Bake read-APIs to JSON → Task 3. ✅
- Random pick client-side → Task 2 Step 4. ✅
- `/api/alteration` client-side → Tasks 1, 2 Step 5. ✅
- Served = manifest-only (no masters) → Task 3 (test asserts). ✅
- `?v=` versioning + immutable caching → Task 2 (preserved), Task 4 (cache header). ✅
- Path `/human-experience-simulator` + apex redirect → Task 4. ✅
- E2E against static build (no API, dream survives CORS) → Task 5. ✅
- Prefetch unchanged → no task needed (only `mediaNetUrl` base changes, Task 2 Step 2). ✅
**Placeholder scan:** none — every code step has full content.
**Type consistency:** `HEF_CONFIG.{mediaBase,static}`, `HEFAlteration.{plan,renderAudio,alteration}`, `controls()` shape, and `build_static(out, media_out, *, media_base, app_path)` are used identically across tasks. ✅
**Open verify-at-execution items (flagged, not placeholders):** (a) confirm `ring_to_dict` emits per-scale `audio` — add it if missing (Task 3 Step 3); (b) confirm `simulator/app.py` exports `MEDIA_DIR` (used by the build); (c) locate the exact video/audio element creation sites for `crossOrigin` (Task 2 Step 6).
@@ -0,0 +1,187 @@
# Accessibility pass + About page — Solution Design
**Date:** 2026-06-30
**Branch:** `feat/accessibility-pass` (off `design/cloudflare-static-publish`)
**Status:** Draft — pending operator review
## Problem
The simulator was built for an attended **kiosk**: a known display, a person
nearby, no assistive technology in the loop. The Cloudflare static publish
(`design/cloudflare-static-publish`) puts the same experience on a public URL
(`benstull.art`) where none of those assumptions hold. That raises two bars at
once:
- **Legal/usability** — a public site is expected to meet WCAG 2.1 AA; today the
primary navigation control (the Altitude dial) is pointer-only, several text
colors fail contrast, and there is no screen-reader path into a piece whose
*meaning* is already textual.
- **Physical safety** — the piece is continuous motion (video morphs, auto-scrub,
crossfades, a "trippy at max" WebGL shader, black-cover fades) with no
`prefers-reduced-motion` support and no photosensitivity safeguard. Unattended,
that is a vestibular and seizure risk.
This work makes the *experience* (not just the chrome) usable away from the
kiosk, and adds an **About** page explaining what the piece is for.
## Goals
1. Honor reduced-motion and add seizure safety (the public-web-specific layer).
2. Make every control keyboard- and screen-reader-operable.
3. Fix low-vision contrast.
4. Add `about.html` — the project's intent and provenance.
5. A small Output-panel layout fix (language picker placement).
**Non-goals:** no engine/Python changes; no new languages (about page is
English-first, i18n-keyed for later); no RTL; no redesign of the visual art.
The `<html lang>` switch is **already implemented** (`applyUiStrings`,
`app.js:1325`) and is out of scope.
## Target
WCAG 2.1 **AA** as the baseline, plus a motion/seizure layer (WCAG 2.3.1 flash
threshold + 2.3.3 animation-from-interactions) on top, since those are the risks
that genuinely change when the piece leaves the kiosk.
## Design
All changes are client-side, in `simulator/static/`. Seven units.
### A1. Reduced-motion: freeze-to-stills
A single `reduceMotion` state, default-on when
`window.matchMedia('(prefers-reduced-motion: reduce)').matches`, plus a visible
toggle in the Output fieldset (so a visitor whose OS setting disagrees can
override either way). Persisted in `localStorage` (mirrors the existing `devMode`
pattern, `app.js:934`).
When `reduceMotion` is **on**:
- **Video holds a frame.** Pause `#vid` / `#vid-loop` (do not call `playLoop()` /
the `.play()` paths at `app.js:260,288`). The Kuwahara paint loop keeps running
but composites a *static* frame, so knob changes (mood grade, dream, labels)
still re-render — the image responds, it just doesn't animate on its own.
- **Transitions are instant, not animated.** `autoScrub()` (`app.js:678`) jumps
`pos` straight to the target (one assignment + a single settle render) instead
of driving the rAF tween. Dial drag still scrubs live under the finger (that is
a direct-manipulation gesture, not autonomous motion — allowed under 2.3.3),
but on release it settles without a spin.
- **Auto-scrub speed coupling is disabled** (the constant per-altitude auto-spin).
The toggle flips state live (no reload): turning it off resumes `playLoop()`;
turning it on pauses and holds.
### A2. Photosensitivity: warning gate + flash audit
- **Warning gate.** A one-time interstitial over the stage, shown with the
existing `#run-sim` flow before the experience begins: a short "contains motion
and flashing effects" notice with a **Continue** action. Dismissal is
remembered in `localStorage` so it shows once per visitor, not every load. It
reuses the run-sim z-layer (above the black cover) and does not block the rest
of the page (controls remain reachable).
- **Flash audit.** Review the three motion sources that can produce rapid
luminance swings — the fast-spin blended dial pass, the `#black` cover fades
(`app.js`/`style.css`), and audio-coupled crossfades — and clamp any that can
exceed **3 transitions/second** (WCAG 2.3.1). The clamp math (min transition
duration given a luminance delta) is a **pure function** in a small module so it
is unit-testable; the audit findings and any clamps are recorded in the
implementation plan.
### B. Keyboard + focus
- **Dial as a real slider.** The `#dial` SVG gets `role="slider"`, `tabindex="0"`,
and live `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext`
(the human scale name, e.g. "reef"). A `keydown` handler maps:
- `ArrowDown` / `ArrowRight` → descend one altitude (`+1`, matching wheel-down).
- `ArrowUp` / `ArrowLeft` → ascend one altitude (`-1`).
- `Home` → cosmos (index 0); `End` → the deepest scale.
- Each step calls the existing `autoScrub` / `jumpToScale` path (so reduced-motion
instant-jump is inherited for free).
- **Dial labels become buttons.** The tap-to-jump `.dial-label` nodes get
`role="button"` + `tabindex="0"` + Enter/Space activation, reusing `jumpToScale`.
- **Visible focus everywhere.** A global `:focus-visible` outline rule (today only
`.dev-switch` has one).
### C. Low-vision contrast
Bump the failing dark-on-dark text to meet AA (4.5:1 for body, 3:1 for large):
the panel `.hint` (`#789`), `.dial-caption` (`#4d6184`), `.dial-label` (`#789ac0`),
and any others a contrast check flags on the `#111` / `#0d1320` backgrounds.
Verify the 280px panel reflows and text scales to 200% without clipping.
### D. Screen-reader basics
- Ensure every control has an accessible name (sliders via their `<label>`; the
dial via `aria-valuetext`; the new toggle labeled).
- **Narrate the alteration.** A visually-hidden `aria-live="polite"` region
announces the current scale and the active clip's top factual (left-brain)
label as the altitude/knobs change — turning the visual alteration into words.
Updates are throttled/debounced so a drag doesn't flood the queue.
- The decorative HUD/affect SVG layers get `aria-hidden="true"`.
### E. about.html
A standalone page mirroring `credits.html` exactly (same `credits-page` /
`credits-wrap` styles, a `← Back to the experience` link, `config.js` loaded).
English prose, structured so it can be i18n-keyed later. Linked from the header
beside the existing Credits link (`index.html:18`). Narrative beats:
- **For everyone.** The piece aims to be accessible to, and representative of,
the full range of human experience — which is *why* this accessibility work
exists.
- **Vantage points technology gave us.** The scales — deep-sea, coast, sky,
drone/aerial, orbit, cosmos — are places no unaided human could witness. We can
share them only because technology evolved to reach them: **underwater
exploration → drones → space exploration.**
- **Built with LLMs.** The work itself was built using large language models — an
extension of that same arc of tools extending human reach.
- **Honest about its limits.** This implementation is **imperfect** — but it is
more than the author could make alone; better *with* LLMs assisting than by a
single human hand. The imperfection is part of the point: tools extend us, they
don't replace the human behind them.
### F. Layout fix (Output fieldset)
Move the language picker **below** the Audio control (currently it sits above
Video/Audio). Put the globe `🌐` inline to the **left** of the `<select>` via a
flex row — today `.lang-pick` has no CSS, so the full-width `select` rule
(`style.css` `select { width: 100% }`) pushes the globe onto its own line above.
Add a `.lang-pick { display:flex; align-items:center; gap }` rule and let the
select flex to fill the rest.
## Components / files
| File | Change |
| --- | --- |
| `index.html` | Reduced-motion toggle; warning-gate markup; aria-live region; dial a11y attrs; move lang picker below Audio; about-page header link |
| `style.css` | `.lang-pick` flex; contrast bumps; `:focus-visible`; visually-hidden util; warning-gate styles |
| `app.js` | `reduceMotion` state + freeze logic; dial keyboard handler + aria-value sync; label buttons; aria-live narration; gate dismissal |
| `flash.js` *(new, pure)* | Flash-clamp helper (min duration given luminance delta), UMD like `i18n.js`/`credits.js` |
| `i18n.js` | New UI keys for the toggle, warning gate, about link (EN populated; other langs may fall back) |
| `about.html` *(new)* | The page above |
| `tools/build_static.py` | Ensure `about.html` + `flash.js` are copied into `dist/` (verify the static build includes new static files) |
## Testing
- **Node unit tests** for the pure flash-clamp helper (`flash.js`) — boundary
cases around the 3/sec threshold.
- **Playwright e2e** (extends the existing suite): reduced-motion toggle pauses
video and makes a dial step instant; dial is keyboard-focusable and
Arrow/Home/End change the scale; the warning gate appears and dismisses once;
`about.html` loads and its back-link returns to `index.html`; the language
picker renders below Audio with the globe inline.
- **Manual/automated contrast check** on the bumped colors.
- Existing suites stay green: the current Playwright specs, node `--test`, and
`pytest`.
## Risks / open questions
- **Freeze-to-stills + WebGL.** Need to confirm the paint loop still composites a
paused-video frame (some browsers stop delivering frames to WebGL from a paused
`<video>`); fallback is to draw the last frame once to the canvas and stop the
loop. Resolved during implementation.
- **Flash audit is the fuzziest unit** — the clamp is mechanical, but deciding
which existing transitions actually breach 3/sec needs measurement; the plan
will enumerate them explicitly rather than hand-wave.
- E2E in this environment starts uvicorn via `python` (only `python3` exists) —
start the server by hand and use `reuseExistingServer`, per project memory.
@@ -0,0 +1,149 @@
# Publish to benstull.art — Cloudflare Pages + R2 (fully static) — Design
**Status:** Approved (brainstormed 2026-06-30). Ready for writing-plans.
## Goal
Publish the Human Experience **Simulator** publicly at
`benstull.art/human-experience-simulator`, hosted on Cloudflare, with the
`benstull.art` apex **redirecting** to that path for now.
The simulator today is a FastAPI server (`simulator/app.py`) serving ~92 KB of
static frontend plus video/audio from a local `/media/` mount. The public target
has **no origin server**: the frontend is **fully static** on **Cloudflare Pages**,
and the video/audio lives in a public **R2** bucket behind a custom domain. Nothing
runs, nothing to patch, nothing to keep alive. The existing GCE/flotilla deployment
stays as the dev/authoring surface — this is a *new, additional* public target, not
a migration of the dev box.
## Why this shape (the constraint that forces it)
Cloudflare Pages has a **hard 25 MiB per-file limit** (and 20,000 files free /
100,000 paid; no total-size cap). The repo carries **3.9 GB across 663 mp4 + 10
mp3**, with individual files up to ~117 MB. Pages cannot host the video. R2 has no
relevant per-file limit, **free egress**, and ~2 GB of served media sits inside its
10 GB-month free tier (≈ free). So: **Pages for the ~92 KB frontend, R2 for the
media.** (Verified against Cloudflare docs 2026-06-30.)
## Decisions (locked)
- **Path:** experience at `/human-experience-simulator`; **apex `benstull.art`
redirects** there (302/308 redirect, not a rewrite).
- **Fully static**, no Pages Functions / Worker — the dynamic logic is trivial
client-side (see Component 2).
- **Media on R2** at a custom domain `static.benstull.art` (public bucket binding).
- **No cross-session cache** (no Service Worker) for now — the existing in-memory
graduated prefetch is sufficient. Deferred, not rejected.
- **benstull.art is already a Cloudflare zone** — no nameserver migration needed.
- **Operates outside Wiggleverse deployment patterns (standing, operator-set):**
this **entire project** is exempt from the typical Wiggleverse deployment
conventions — `flotilla-only-provisioning`, the `ppe-before-prod` GCE pipeline,
and the §9 prod-promotion gate do not bind it. It is a static public art target
hosted directly on Cloudflare. (Safety/hygiene rules — `git-ssh`, the secrets
rule, branch→PR — still apply; only the *deployment* machinery is out of scope.)
## Served media — what actually ships
The on-disk 3.9 GB is mostly **pipeline source**, not served bytes. The manifest
(`simulator/sample_media/manifest.json`) references only **`base.mp4`** for clips —
the `master.mp4` (20 files, incl. the 91117 MB `coast_birdrock` set) and
`mezzanine.mp4` proxies are build inputs that **never reach the browser**. The
served set is:
- the `base.mp4` clip bases (one per pool member),
- the **154 transition morphs** (`*__*.mp4` / `*.rev.mp4`, ~1.6 GB, each already
kept < 25 MB),
- the **10 audio `.mp3`** layers (~12 MB).
**~2 GB** to R2. The build computes the exact set by walking the manifest — it
**must not** blindly sync `sample_media/` (that would push the masters too).
## Components
### 1. Static build (`tools/` — new build script → `dist/`)
Replaces the server with baked artifacts:
- **Bake the read endpoints to static JSON** the page loads at boot, replacing the
fetches to `/api/clips`, `/api/ring`, `/api/media-versions`:
- `clips.json``GET /api/clips`
- `ring.json``GET /api/ring`
- `media-versions.json``GET /api/media-versions` (file → content-hash)
Generate these by importing the existing builders (`simulator/clips.py`,
`build_pool_manifest.py`) directly — **single source of truth, no hand-copy**.
- **Copy only manifest-referenced media** into the R2 sync tree (see Served media).
- **Emit the public frontend** (`index.html`, `app.js`, `scrub.js`, `style.css`,
`i18n.js`) into `dist/`, **excluding** author/review/dev surfaces
(`author.*`, `review*.html`, `/api/author/*`).
- Output is deterministic and re-runnable; checked by the integration test below.
### 2. Client-side dynamic logic (`simulator/static/app.js`)
The only server-computed behavior is the **random clip pick on landing**
(`POST /api/ring/advance`). The scrub work (PR #28, spec 2026-06-27) **already
moved the pick client-side** for drag/scroll gestures via the pool + `morphByPair`
lookup — so the remaining task is to ensure **every** entry path (initial load, tap-
label jumps) uses the client-side pick and that **no code path depends on a live
`/api/*` call**. Boot loads the baked JSON instead of fetching the API.
`/api/alteration` is verified client-resolvable or dropped from the public build.
### 3. Media URL base + CORS (`simulator/static/app.js`)
- `mediaNetUrl(file)` changes its base from `"/media/" + file` to a configurable
**`MEDIA_BASE`** (`https://static.benstull.art/`), preserving the existing
`?v=<hash>` content-hash versioning. A single const, fed from the baked config so
localhost dev still works against the FastAPI `/media/` mount.
- **CORS is load-bearing** because of the WebGL dream shader: `app.js` preloads each
clip via `fetch()` (cross-origin → needs `Access-Control-Allow-Origin`) into a
blob, and any clip that falls back to the *direct network* `<video>` path feeding
WebGL **taints the GL texture** without CORS. So:
- R2 bucket gets a **CORS policy** allowing `https://benstull.art`.
- `<video>` elements carry **`crossOrigin="anonymous"`**.
Without this, plain playback works but the *dream effect* throws a security error.
### 4. Prefetch (unchanged)
Keep the existing graduated strategy verbatim — `preloadList()` (all bases),
`preloadOrder()` (current pool → neighbors outward → reachable morphs → sweep),
`refreshReachablePreload()` (per-landing), `preloadAllMedia(concurrency=4)` behind
the "Loading Universe…" bar. It improves on R2: Cloudflare edge-caches each object
after first fetch; egress is free, so the background fill is free. No code change
beyond the `MEDIA_BASE` swap (Component 3).
### 5. Caching headers
Server today sets `/media/` to `no-cache` (revalidate-always). On the CDN, set the
versioned media objects to long-lived **`immutable`** — the `?v=<hash>` query already
guarantees a new URL when bytes change, so a re-baked clip busts cache without a
manual purge. Pages assets keep normal Cloudflare defaults.
### 6. Deploy & redirect
- **Pages:** deploy `dist/` (Pages Git integration or `wrangler pages deploy`).
Serve the app under the `/human-experience-simulator` path.
- **R2:** sync the media tree to the bucket (`wrangler r2` or rclone), bind
`static.benstull.art` as the public custom domain, apply the CORS policy.
- **Apex redirect:** `benstull.art/``/human-experience-simulator` via a
Cloudflare redirect rule (or Pages `_redirects`).
## Testing
- **Build integration test:** run the build against the real manifest; assert
`dist/` contains the expected static files, the three baked JSONs match the live
API output, and the media sync tree contains **only** manifest-referenced files
(no masters/mezzanines, nothing > some sanity bound).
- **E2E (Playwright) against the static build:** point the existing suite at the
built `dist/` + an R2-equivalent media origin and verify: boot with **no `/api/*`
server**, the **dream/Kuwahara shader survives CORS** (texture not tainted),
scrub-driven morphs stay smooth, audio crossfade works, language dropdown works.
The E2E browser tier is required per the §9 pipeline (handbook).
- **Pre-deploy check:** a smoke run confirming CORS headers + range requests on the
R2 custom domain before flipping the apex redirect.
## Out of scope / deferred
- Service Worker / cross-session caching (deferred, not rejected).
- Migrating the dev/authoring GCE deployment (stays as-is on flotilla).
- Per-clip loop files for the reverse-landing frame jump (pre-existing residual,
tracked elsewhere).
- Cloudflare Stream (R2 is sufficient and cheaper for this payload).
@@ -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,171 @@
# Welcome screen — design
**Date:** 2026-06-30
**Status:** Approved (brainstorming)
**Surface:** `simulator/static/` (frontend only — `index.html`, `style.css`, `app.js`, `i18n.js`)
## Summary
Replace the current bare boot flow with a single **Welcome screen**: the
universal entry gate the visitor sees on every load. It carries the
photosensitivity "Heads up" notice, the four output controls (Video, Reduce
motion, Audio, Language) with welcome-appropriate defaults, a **Launch
Simulator** button, and the "Loading Universe" progress. The right control panel is hidden
until the visitor enters the simulation.
## Why
Today the universe loads behind a bare "Loading Universe" splash; the splash
fades to reveal a "Run simulation" button over the black stage; a first-visit
"Heads up" modal appears; clicking through begins the experience. The four
output controls live in the right panel and are visible the whole time —
including during loading, which the panel should not be.
We want a deliberate, configurable front door: the visitor reads the heads-up,
sets their preferences, and presses one button to begin — while the universe
loads in the background so entry is instant when they are ready.
## Current flow (being replaced)
- `#loading` — fullscreen splash (`Loading Universe` title + `#loading-fill`
progress bar). `simulator/static/index.html:10-15`.
- `#run-sim` — "Run simulation" button revealed over the black stage after
phase-1 preload. `index.html:33`; wired at `app.js:1533`.
- `#motion-warning` — first-visit-only "Heads up" modal, persisted dismissed via
`WARN_KEY`. `index.html:34-40`; shown by `maybeShowMotionWarning()`
(`app.js:1161`).
- Four controls in `.panel` (`#visual`, `#audio`, `#reduce-motion`,
`#lang-select`). `index.html:46-65`. The panel is visible throughout.
- `beginExperience()` (`app.js:1505`) snaps Video on and Audio to
`START_AUDIO_LEVEL = "2"` in the click gesture (Safari audio-unlock).
## Target design
### 1. One unified `#welcome` overlay
Fold `#loading`, `#motion-warning`, and `#run-sim` into a single fullscreen
`#welcome` overlay layered above everything. It absorbs all three roles. The
right `.panel` is **hidden** for the entire welcome + loading period and revealed
only on entry to the simulation.
The overlay contains three blocks:
1. **Welcome messaging** — the "Heads up — motion & flashing" title + body
(today's `warn.*` strings, reworded to point at the on-screen Reduce-motion
control rather than "the panel"). Structured so additional welcome copy can be
added later (see Forward notes).
2. **Controls** — the four output controls (below).
3. **Launch Simulator** — the single entry button.
4. **Loading** — the "Loading Universe" title + progress bar.
### 2. Two visual states
**State A — Welcome (on load):** welcome messaging + controls + "Launch
Simulator" button shown; the Loading block sits at the **bottom**, its bar
filling as phase-1 preload runs in the background.
**State B — Loading (after Launch, only if not yet loaded):** the welcome
messaging and Launch button **disappear**; the **controls remain**; the Loading
block moves to **center**. When phase-1 preload completes, auto-enter the
simulation.
**Launch when already loaded** → skip State B; enter the simulation directly.
State is driven by a class on `#welcome` (e.g. `.welcoming``.loading`), with
the "loaded yet?" decision made from a flag set when phase-1 preload resolves.
### 3. Control defaults (welcome screen)
| Control | Welcome default | Notes |
|---------------|-----------------|--------------------------------------------------|
| Video | **ON** | Was OFF in the panel; on by default here. |
| Reduce motion | **OFF** | Unless OS `prefers-reduced-motion` / stored pref.|
| Audio | **2** (010) | Was 0 in the panel. |
| Language | **English** | Global; changing it re-renders all welcome text. |
Reduce motion keeps deferring to a stored value / the OS media query when those
exist (same precedence as `initReduceMotion()` today); "OFF" is the no-signal
default.
### 4. Entry into the simulation
"Launch Simulator" is the user gesture that unlocks audio (the role `#run-sim`
had). On Launch → enter (either immediately when loaded, or at end of State B):
1. Copy the four welcome values into the existing panel inputs (`#visual`,
`#reduce-motion`, `#audio`, `#lang-select`).
2. Fire the existing handlers / `beginExperience()` so the experience starts in
the same code paths as today.
3. Remove the `#welcome` overlay and reveal `.panel`.
Per decisions: the four controls **stay in the panel** for mid-experience
adjustment; **Video-off at entry** starts with video off (audio still plays per
the Audio level — `beginExperience` no longer forces Video on regardless).
### 5. Controls drive shared state
The welcome controls write the same underlying state as the panel controls — they
are not a parallel system:
- **Language** routes through the global `setLanguage()`, which sets `activeLang`
and runs `applyUiStrings()` over **all** `[data-i18n]` elements — so the
heads-up copy, control labels, and Launch button re-render live, exactly like
the panel selector does today. The panel `#lang-select` value is synced to
`activeLang` on entry.
- **Reduce motion** sets the same `reduceMotion` variable + `RM_KEY` localStorage
and calls `applyReduceMotion()`; panel checkbox synced on entry.
- **Audio / Video** values are applied on entry via the panel inputs +
`beginExperience()`.
### 6. Behavior changes (intentional)
- The welcome screen shows on **every** load (it is the entry gate holding the
controls), so the first-visit-only persistence of the heads-up (`WARN_KEY`) is
**retired**.
- Entry uses the **chosen** Audio level rather than always snapping to 2 (2 is the
default, not a forced value).
- `#run-sim` is removed; **"Launch Simulator"** is the single entry button.
## i18n
New / changed `UI_STRINGS` keys in `simulator/static/i18n.js` (all four langs —
en/es/fr/ja, matching existing parity):
- A new `welcome.launch` key — **"Launch Simulator"** (the single entry button),
translated in all four languages.
- Reworded `warn.body` (point at the on-screen Reduce-motion control).
- Welcome-screen control labels reuse the existing `output.video`,
`output.audio`, `rm.label` keys (already translated). The language selector
needs no string (native names from the registry).
All welcome text must live under `data-i18n` so `applyUiStrings()` re-renders it
on language change.
## Out of scope / Forward notes
- **Additional welcome copy** beyond the heads-up (intro / framing text) comes in
a later pass, once this flow works. The welcome-messaging block is structured so
adding more `data-i18n` paragraphs is trivial.
- No backend / API changes. No content-pipeline changes.
## Testing
Frontend lives behind Playwright e2e (`simulator/static/` tests) + node unit
tests. Plan to:
- **Update** existing tests that reference `#loading`, `#run-sim`, and
`#motion-warning` to the new `#welcome` flow.
- **Add** coverage for:
- Welcome defaults (Video on, Reduce motion off, Audio 2, English).
- Language switch re-renders welcome text (heads-up + labels + Launch button)
live.
- Launch **while loading** → State B (messaging gone, controls remain,
progress centered), then auto-enter when loaded.
- Launch **when already loaded** → straight into the simulation.
- `.panel` hidden during welcome + loading; revealed on entry.
- Welcome control values carry into the panel inputs on entry.
Environment caveats (from prior sessions): this box has only `python3` (start
uvicorn by hand; `reuseExistingServer`); a stale uvicorn is the recurring
"no transitions" root cause; `loop-recovery.spec.ts` fails on a clean baseline
here (boots without video).
@@ -0,0 +1,113 @@
# Audio "Music" option + "Volume" rename — design
*Date: 2026-07-01 · Status: approved design, awaiting spec review*
## Goal
Give the listener a choice of what to hear. Today the Output panel has a single
010 slider labeled **Audio** that acts as the master gain for the per-altitude
ambience crossfade. We split that into two controls:
- **Audio** — a dropdown selecting the audio *source*: **None · Soundtrack · Music**
- **Volume** — the existing 010 slider (relabeled), master gain for whatever
source is active
**Music** is one ethereal, relaxing ambient loop (the innerflo.me vibe) that
plays continuously and does **not** change with altitude.
## Non-goals
- No per-altitude music set (Music is a single track, not a musical Soundtrack).
- No change to the Soundtrack behavior itself (per-altitude crossfade stays exactly as-is).
- No new audio elements — the existing `#aud` / `#aud-b` pair is reused.
## Control surface
The Output `<fieldset>` (`simulator/static/index.html`) gains a dropdown above the
renamed slider:
```
Output
[ Video ] (toggle)
Audio [ Soundtrack ▾ ] ← NEW: <select id="audio-source">
Volume ●──────── 2/10 ← existing #audio slider, label → "Volume"
Speed ●──────── 1.00×
🌐 [ Language ▾ ]
```
- The dropdown is a `<select id="audio-source">` with options `none` / `soundtrack`
/ `music`, default **`soundtrack`** (preserves today's launch experience).
- The slider keeps `id="audio"` (avoids touching every reference) but its visible
label becomes **Volume** (new i18n key `output.volume`). The `output.audio` key
("Audio") now labels the dropdown.
## Behavior by mode
| Mode | Behavior |
|---|---|
| **None** | Both `#aud`/`#aud-b` fade to silence — the existing `audioLevel()===0` path. The Volume slider is disabled (greyed). |
| **Soundtrack** | Unchanged: per-altitude ambience crossfading across the two elements as the dial moves (`blendAudio` / `restAudio`). |
| **Music** | The single music loop plays on `#aud` at master volume; `#aud-b` fades out. Altitude changes do **not** crossfade or reload audio. |
Volume = 0 remains a hard mute in every mode.
## Code shape
All in `simulator/static/app.js`, following the existing audio-layer seams
(`restAudio`, `blendAudio`, `ensurePlaying`, `scaleAudioUrl`):
- **`audioSource()`** — new helper reading `#audio-source``"none" | "soundtrack" | "music"`.
- **`musicUrl()`** — returns the resolved music loop url via `mediaBase()`
(mirrors `scaleAudioUrl`'s R2/local routing), e.g. `mediaBase()+"audio/music/<name>.loop.mp3"`.
- **`restAudio(index)`** branches:
- `none` (or `audioLevel()===0`) → existing fade-to-silence.
- `music``ensurePlaying(aud, musicUrl(), audioVol())`; `fadeVolume(audB, 0, …)`.
- `soundtrack` → unchanged per-altitude logic.
- **`blendAudio(lo, frac)`** returns early (no crossfade) when source is `music` or
`none`; only Soundtrack blends across the altitude boundary.
- **Dropdown wiring**: `#audio-source` `change` → reconcile synchronously in the
gesture (`applyAudio()`), same Safari-unlock discipline the slider already uses;
toggles the Volume slider's `disabled` when None.
- **`beginExperience()`**: unchanged except it snaps **Volume** (`#audio`) to the
start level 2; default source stays Soundtrack.
- **Diagnostic readout** (`audioStatusText`): prefix with the current source
(`none`/`soundtrack`/`music`) so a "no sound" report stays legible.
No server/engine change — audio source is a pure client concern.
## Music asset & licensing
- One **CC0 / public-domain** ethereal ambient loop (innerflo.me-style: soft,
evolving, no percussion, seamless loop). Sourced by the implementer; reviewed by
ear before ship.
- Stored at `simulator/.../media/audio/music/<name>.loop.mp3` alongside the
per-scale soundtracks, and registered in the credits manifest exactly like every
other audio asset (this project credits all CC/PD audio — see `credits.html`).
- Fallback path constant mirrors the `SCALE_AUDIO_FALLBACK` pattern so Music works
even against an older server process.
## i18n + a11y
- New key `output.volume` (en: "Volume", es: "Volumen", fr: "Volume", ja: "音量").
- Dropdown option labels localized in en/es/fr/ja:
- None (Ninguno / Aucun / なし)
- Soundtrack (Banda sonora / Bande-son / サウンドトラック)
- Music (Música / Musique / 音楽)
- `<select id="audio-source">` gets an `aria-label` tied to the Audio label.
- Reduced-motion behavior is unaffected (audio is orthogonal to motion).
## Tests
- **Node unit** (`simulator/unit/…`): `audioSource()` routing and the
`blendAudio`/`restAudio` branch selection — none→silent, music→single-element +
no crossfade on altitude change, soundtrack→unchanged.
- **E2E** (`simulator/e2e/…`): dropdown switches modes; Music produces a single
playing element that persists across an altitude change; None disables the Volume
slider and silences audio; the slider label reads "Volume".
## Decisions (confirmed with operator)
1. **Music = one continuous, altitude-independent loop** (not per-altitude, not ducked).
2. Default Audio source = **Soundtrack**; Music is opt-in.
3. **None** disables the Volume slider.
4. Music track is **operator-approved CC0** sourced by the implementer.
@@ -0,0 +1,56 @@
# Session 0033.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-38 (PST)
> End: 2026-06-30T09-43 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Branch: design/cloudflare-static-publish (canonical); merge performed on main via worktree
> Status: **FINALIZED**
## Launch prompt
Operator asked "Have you merged to main?" → no (all work was on
`design/cloudflare-static-publish`) → operator: "Yes" (merge it to main).
## Arc
1. Confirmed none of the credits work (nor the 17-commit static-publish line) was on
`main`; surfaced the considerations (live branch, in-flight a11y session, brings all
commits). Operator approved the merge.
2. Claimed **session 0033**. Surveyed divergence: `main` and the branch had diverged —
`main` carried real app work from the parallel labels/affect session (per-clip
right-brain feelings `bf1013b`, i18n re-extract `2d54023`, affect/label fix
`041fcde`), not just transcript publishes.
3. Found only **two** overlapping files (`app.js`, `altitude-lock.spec.ts`).
**Test-merged in a throwaway worktree** off `origin/main`: clean auto-merge, no
conflicts. Ran the FULL suite on the merged tree — **node 29, pytest 315/2-skip,
green** — before trusting the textual merge.
4. Performed the real `--no-ff` merge on `main` in a worktree (keeping the canonical
checkout on the live deploy branch), pushed `main` (`75961ea``87d605d`).
5. Verified credits commits + `credits.html` are on `origin/main`, branch now 0 ahead.
Removed the worktree; canonical checkout clean on `design/cloudflare-static-publish`.
## End state
- `origin/main` @ `87d605d` — now carries the entire Cloudflare static-publish line,
including the CC-BY/BY-SA credits page + "License & reuse" CC BY-SA declaration.
- `design/cloudflare-static-publish` is fully integrated (0 commits ahead of main).
- The live site still serves the pre-merge build; **a redeploy** ships the merged main.
## Deferred decisions
- **Merged via local `--no-ff` + push rather than a Gitea PR.** `gh` isn't wired (Gitea
host) and the operator approved directly in autonomous mode; the work was developed on
a branch and the merge produces the same merge-commit a PR would. (Alternative: open a
Gitea-API PR for a review record — skipped for speed; can add retroactively if wanted.)
- **Did not merge `main` back into `design/cloudflare-static-publish`.** The branch is 0
ahead now; future static work can re-branch from main. Left as-is.
## Next /goal
```
/goal redeploy benstull.art from main (now carries the CC-BY/BY-SA credits + "License & reuse" D1 declaration) per deploy/cloudflare/README.md; the About page / WCAG pass lands separately via the feat/accessibility-pass session
```
@@ -0,0 +1,107 @@
# Session 0034.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T07-24 (PST)
> End: 2026-06-30T10-01 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 08b95eb0-3ec5-400a-9236-da78f01139a0
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/fix+audio-and-tracking-labels
> Status: FINALIZED
## Launch prompt
> The audio isn't playing at all. Let's fix that and then let's make sure there are
> left and right brain tracking labels for everything. Make sure you're working on a
> worktree. There are other concurrent sessions that may have real merge conflicts to
> handle.
## Pre-state
- The simulator (`simulator/`) is a FastAPI + static-JS experience: an altitude dial
over 6 scales (cosmos/orbit/sky/coast/reef/abyss), 41 rotating-pool clips, a
real-time Kuwahara "dream" shader, two HUD label layers — **Think** (left-brain
factual chips, `#overlay`) and **Feel** (right-brain affect words, `#affect`) —
plus a per-altitude soundtrack with a 010 audio dial and a built-in
`#audio-status` diagnostic.
- s0027 had brought all 41 clips to left+right annotation with 4-lang parity, but the
left labels were thin (many clips 12) and STATIC; affect was authored PER-SCALE
(every clip in an altitude shared 4 feelings).
- Operator was running a stale `:8000` server on the `design/cloudflare-static-publish`
branch (a concurrent session's WIP — "Run simulation" begin-experience flow, +
later a credits page). Project is EXEMPT from the Wiggleverse deploy pipeline
(flotilla/PPE/§9); public target is static Cloudflare. cv2/imageio NOT installed in
this env.
## Session arc
1. **Worktree.** Created native worktree `fix+audio-and-tracking-labels` off
`origin/main` (582183d). All work done here.
2. **Audio (systematic-debugging) — FALSE ALARM.** Could not reproduce "no audio" in
ANY automated condition: Chromium + WebKit, strict autoplay
(`--autoplay-policy=user-gesture-required`), clean-main server AND the operator's
`:8000` WIP server, via Video-toggle / Audio-dial / "Run simulation" button; all 5
soundtracks decode to real non-silent audio (RMS 0.080.24). The raw
native-controls `#aud-test` player was ALSO silent on the operator's Safari while
the element reported playing → isolated it BELOW the app. Operator confirmed:
**their Safari sound was muted.** No code change.
3. **"Labels not showing" on cosmos_tarantula.** Root cause: operator was on `:8000`
(design branch), whose manifest had that clip's `annotations: []`. My branch
renders all labels correctly; labels are knob-gated (none at Think/Feel 0).
4. **Left (Think) enrichment.** Every clip → ≥3 factual labels (added one to each of
23 thin clips; orbit_bluemarble 1→3), full es/fr/ja parity. Pipeline:
`LABELS` in `build_pool_manifest.py` → build → catalogs → `translate_manifest.py
merge`. (commit `8cfceff`)
5. **Motion-tracking.** Converted the 4 still-static abyss creatures (octopus, bigfin
squid, dandelion siphonophore, sea pig) static→tracked, 3-keyframe paths sampled
by eye from frames (cv2 unavailable). Tracked clips 7→11. (`8cfceff`)
6. **Think/Feel overlap avoidance.** `renderAffect` now nudges Feel words off Think
chip plates (obstacles computed from manifest, tracked paths sampled → holds whole
loop, no per-frame jitter; new `window.__hefNoDeconflict` seam). Verified 0
overlaps across all 6 scales incl. a tracked clip over time. (commit `041fcde`)
7. **Per-clip right-brain feelings.** Operator: feelings should match each ACTUAL
video. Added `AFFECT_CLIP` (per-clip override, scale fallback) for all 41 clips →
41 distinct feeling-sets. cosmos done by me as a reviewed sample; the other 5
scales drafted by **5 parallel subagents** (one per scale) that each VIEWED footage
frames and followed the cosmos recipe; I normalized + authored all es/fr/ja for the
19 new nuance keys. 42 distinct feeling keys now. (commits `bf1013b` + `2d54023`)
8. **Merge.** Merged branch → `origin/main` (clean; main had only advanced by
`sessions/` transcript commits). Confirmed all 4 work commits are ancestors of
`origin/main`; another session (`feat/accessibility-pass`) has since merged main +
a11y on top (`380c2d1`).
## Cut state (final)
- All work committed, merged to `origin/main`, pushed. Working tree clean.
- Verification: 34/34 catalog+i18n+build pytest, 16/16 node unit, live render shows
≥3 Think chips + distinct Feel words per clip (incl. new keys) with overlap 0 and no
JS errors. (3 unrelated pytest failures = cv2/ffmpeg absent in this env.)
- Worktree RETAINED (not torn down) because it is serving the operator's by-eye review
at `http://127.0.0.1:8011/author.html`; tear down + stop `:8011` when review done.
- §9 deploy pipeline: N/A — project is exempt (static Cloudflare target).
## Deferred decisions (low-confidence calls)
- **Audio = no code change.** Chose to treat it as environmental after exhaustive
repro rather than guess-patch a working engine; operator confirmed muted Safari.
- **Left-label box positions authored WITHOUT viewing each frame** (23 enriched
clips, except bluemarble) — factual text is grounded, positions are estimates that
want by-eye review.
- **Per-clip feelings drafted from a single MID-FRAME** (motion not seen) via parallel
subagents — first-pass reads; emotions may want tuning once a clip is in motion.
- **Left reef/coast moving-subject clips left STATIC** (reef_redsea/flowergarden
schools, coast_otters/seals) — tracking deferred (diffuse subjects + cv2 absent).
- **Merged directly to `main` via `git push HEAD:main`** (no Gitea PR) at operator's
explicit request; gh is GitHub-only here and no `tea` CLI.
## Operator plate
- Review the first-pass label/track/feeling positions in `/author.html` on `:8011`.
- For Think: check each chip sits on its subject + the 4 abyss tracks follow the
creature. For Feel: check emotions ring true once clips are in motion.
## Next /goal
```
/goal refine the first-pass left-label box positions and the 4 abyss creature tracks per operator by-eye review (grab a frame per clip, nudge each chip onto its subject), and motion-track the remaining moving-subject clips (reef schools, coast animals) — data-only via build_pool_manifest + i18n pipeline
```
@@ -0,0 +1,102 @@
# Session 0035.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-45 (PST)
> End: 2026-06-30T10-08 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: ee3eaaac-d0a4-41a9-aa53-1919052fd8d3
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED**
## Launch prompt
Not a `/goal` launch. Opened conversationally: "We need to make the entire
simulator accessible, assuming it's not being used at a kiosk. What are the
different types of accessibility and your suggestions (and difficulty)?" Then:
build the first pass on a worktree, and add an `about.html` about the project's
intent (accessible to all humans; vantage points from underwater→drones→space
exploration; built with LLMs; honest that it's imperfect but more than the author
alone could make).
Drove through the **superpowers** pipeline (brainstorming → writing-plans →
executing-plans → finishing-a-development-branch), not a wgl init — so the wgl
transcript ID was claimed at finalize (this file).
## Pre-state
- Simulator built for an attended **kiosk**; the Cloudflare static publish
(`design/cloudflare-static-publish`, already merged to `main` mid-session as
`87d605d`) puts it on a public URL where no AT/attendant assumptions hold.
- No `prefers-reduced-motion`, no keyboard on the SVG Altitude dial, several
failing-contrast colors, no screen-reader path, no seizure safeguard — on a
piece that is continuous motion + a "trippy at max" WebGL shader.
## Arc (turn-by-turn)
1. **Analysis** — read `index.html` / `style.css`, grepped `app.js` (zero aria /
keyboard / reduced-motion). Enumerated 7 accessibility types grounded in the
actual UI, with difficulty + a priority table. Recommended WCAG 2.1 AA + a
motion/seizure layer.
2. **Brainstorming** — operator chose: reduced-motion = **freeze-to-stills**;
photosensitivity = **warning gate + flash audit**; about page = **English-first**.
Added the about-page "imperfect but more than alone" beat. Wrote + committed the
design spec.
3. **Worktree** — first tried `EnterWorktree` (branches off `origin/main`); on
inspecting the diff, the work belonged on `design/cloudflare-static-publish`
(correct `app.js`, existing `credits.html` pattern). Operator: "new worktree off
that, keep separate until ready for main." Created
`feat/accessibility-pass` off the design branch under `.claude/worktrees/`.
4. **Plan** — 10 bite-sized TDD tasks, committed.
5. **Execution (TDD, test-first each)** — (1) layout: lang picker below Audio,
globe inline-left; (2) AA contrast + focus-visible + visually-hidden; (3)
`flash.js` pure WCAG-2.3.1 helper + node tests; (4) one-time photosensitivity
gate; (5) reduced-motion freeze (single `playLoop()` guard + instant `autoScrub`);
(6) keyboard/ARIA dial (`role=slider`, Arrow/Home/End, label buttons); (7)
aria-live narration + `aria-hidden` decoratives; (8) wire flash-clamp + document
audit (no breach found); (9) `about.html` + header link + `PUBLIC_ASSETS`.
9 a11y e2e + 2 node tests, all green; fixed a gate-vs-run-sim regression in the
shared `boot()` helper.
6. **Finish** — operator: merge everything to main + push origin. `origin/main`
had advanced 29 commits (static-publish already landed there) → divergent merge;
one `app.js` conflict (main's D3 reverse-landing fix vs my reduced-motion guard)
resolved keeping **both**. Re-verified on merged code (a11y 9/9, altitude-lock
13/13, i18n 1/1, static-build 2/2, flash 2/2). Pushed `ff06783..380c2d1 main`.
Removed the worktree; stopped bg servers.
## Cut state
- **a11y pass + About page MERGED + PUSHED to `origin/main` (`380c2d1`).**
- On `main` but **NOT deployed** — live benstull.art needs a Cloudflare redeploy.
- App has **no content repo** → plan stays in-repo at
`docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md`. No
`submit-plan.sh`. Project is **exempt** from §9/flotilla/PPE (Cloudflare static).
- A **concurrent** commit `1a6ffc7` ("phase-1 preload gate") sits on local `main`
ahead of origin by 1 — **not this session's; left untouched/unpushed** per
operator ("leave anything that isn't yours alone"). Other worktree
`fix+audio-and-tracking-labels` also left alone.
- Checkout now on `main` (was `design/cloudflare-static-publish` at start).
## Deferred decisions
_Autonomous low-confidence calls (no live placeholder existed, so logged here):_
- **SR narration = scale + clip title**, not the tiered factual (Think) HUD label —
simpler/robust; the plan explicitly permitted the degrade. Could enrich later.
- **Flash audit concluded no >3/sec breach** (1200ms per-altitude floor; fades are
single, ≥1.2s apart); `flash.js` wired only as a guard floor (no-op today).
- **Branched off the design branch, not main** — corrected after operator input.
- **Merged the full divergent history to main** (static-publish was already on
origin/main, so net-new was just the a11y work) — on explicit operator instruction.
## Next /goal
```
/goal redeploy benstull.art/human-experience-simulator to Cloudflare so the accessibility pass + About page go live, per cloudflare-deploy-live (build with `python3 -m tools.build_static`, then the R2/Pages deploy gestures)
```
Operator by-eye review of the a11y changes precedes the redeploy. Env gotchas for
the next session: run `build_static` as a **module** (`python3 -m tools.build_static`)
or `tools/http.py` shadows stdlib `http`; uvicorn dies mid-session and the e2e
`webServer` fallback calls `python` (only `python3` here) → start it by hand on :8099;
Playwright `test.use()` inside `test.describe()` breaks collection in PW 1.45.
@@ -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=<hash>`) + 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).
@@ -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 04** (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).
@@ -0,0 +1,68 @@
# Session 0039.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T10-40 (PST)
> End: 2026-06-30T23-52 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 9c986be3-c1f9-4123-bf7a-d62dd34810dd
> Checkout: worktree `welcome-screen-controls` (branch `worktree-welcome-screen-controls`, off origin/main)
**Date:** 2026-06-30
**Goal:** Build a pre-load "Welcome" screen for the simulator, then iterate it live per operator feedback.
**Outcome:** Shipped a large run of frontend work to production and **merged to `main` (`7ec8b60`, fast-forward)**. The project was renamed **Human/Machine Strata** and moved to **benstull.art/human-machine-strata/** (old URL 301-redirects). Live == main at close.
## Plan
Emergent, operator-driven (not a single up-front plan). The through-line:
1. Welcome screen: hide the right pane during load; put Video / Reduce-motion / Audio / Language controls on the pre-load screen; heads-up before "Loading Universe"; two states (welcome → centered loader); "Launch Simulator" entry.
2. Reconcile two `main` updates that landed mid-session (Playback Speed slider, 2..2 then 0×–4×).
3. Rework welcome controls → Language / Speed / Audio; drop the video toggle (video assumed on); re-add a Reduce-motion toggle that turns OFF morph transitions only (loop stays on Speed).
4. Publish to Cloudflare; then a stream of live tweaks: cap speed at 2×, cache hardening, panel reorder, rename, URL move.
5. Merge to `main` + final deploy.
## Pre-session state
- Simulator is a fully-static Cloudflare deployment (Pages + R2) — exempt from the Wiggleverse §9/flotilla pipeline; deploys straight to benstull.art. Was live at `/human-experience-simulator/`.
- Entry flow was: bare "Loading Universe" splash → fade → "Run simulation" button over a black stage → first-visit "Heads up" modal → begin (video on, audio→2). Controls (Video/Audio/Reduce-motion/Language) lived in the always-visible right panel.
- Parallel sessions were active on the shared checkout; this session ran in an isolated worktree per the isolation rule.
## Turn-by-turn arc
1. **Brainstorm + spec.** Explored the loading screen, right pane, controls, and i18n via an Explore agent. Wrote/committed the design spec (`05328b1`, `docs/superpowers/specs/2026-06-30-welcome-screen-design.md`). Operator refined it live (State B centered loader; "Launch Simulator" as the single entry button).
2. **Build the welcome screen** (`f077193`): folded `#loading` + `#motion-warning` + `#run-sim` into one `#welcome` overlay; State A (heads-up + controls + Launch, loader at bottom) → State B (Launch-while-loading: messaging gone, controls stay, loader centered) → auto-enter; panel hidden until entry via `body:has(#welcome)`; added `window.__hefReady` e2e seam; i18n `welcome.launch` + localized the previously en-only `warn.*`. Discovered + fixed a pre-existing i18n parity failure in passing. Node 35, e2e 27, static 2, pytest 301. Untracked stray `test-results/` artifacts (`7cdf594`).
3. **Reconcile main (twice).** Mid-session `main` gained a Playbook Speed slider (`df8b245`, 2..2) then narrowed it to 0×–4× forward, dropping reverse (`e064a2e`). Merged both into the branch; resolved conflicts in app.js/i18n/a11y; generalized the speed machinery to drive both the welcome and panel sliders.
4. **Welcome controls reworked** → Language / Speed / Audio (video assumed on at entry).
5. **Reduce-motion returns** (`5f9ae6b`). Operator: "we actually do need Reduced motion to turn off the morph videos." Speed only drives the resting loop, so re-added a visible Reduce-motion toggle (welcome + panel, default from OS pref) that makes altitude changes **instant cuts** (no morph tween) while the loop keeps following Speed. Decoupled the loop from reduce-motion (main had them coupled).
6. **Publish to benstull.art.** Verified wrangler auth, ran the frontend-only deploy; verified live + a headless smoke.
7. **Live tweaks (a stream):** "Video speed" rename (`79f64e3`); cap speed at 02× — >2× stutters, same all-intra reason reverse was dropped (`ccd4010`); panel: Experience knobs above Altitude, drop the dial hint + "(04)" (`fafb498`); page title + h1 → then renamed to **Human/Machine Strata** (`958c306`); move Altitude to the TOP of the panel for mobile (`c36f995`).
8. **Debugged "video + audio not playing on launch"** (systematic-debugging). Could not reproduce in Chromium headless, Chromium strict-autoplay, OR WebKit — everything played. Turned out to be a **stale Safari app-shell** (worked in a private window). Also found a stale uvicorn from another checkout squatting on :8099 serving old HTML, and `| tail` masking playwright's exit code — both wasted time.
9. **Cache hardening.** app-shell was `no-cache`; a per-HTML `no-store` override did NOT stick (Cloudflare Pages let `/seg/*` win), so switched the whole app path to `no-store` (`8c63b14`).
10. **Reduce-motion regression fixed** (`ecd444b`). Operator: "video doesn't change when a new altitude is selected with Reduce motion on." TDD: reproduced with a failing e2e, root-caused (the reduced-motion jump skips the segment that re-rolls the destination clip → `activeClipId` stayed on the old scale's clip), fixed with a self-heal at the `setPos` frac-0 landing (re-roll if the landed pool doesn't contain `activeClipId`).
11. **Rename + URL move.** Named the project **Human/Machine Strata**; moved the URL to `/human-machine-strata/` (`950c1e6`) with a permanent 301 from the legacy path (`LEGACY_APP_PATH`); the bare-root case needed its own rule (`b135cb5`). R2 bucket + Pages project names kept (not in the URL → no media re-upload). Updated the build test, static-build e2e path, and README paths.
12. **Merge + final deploy.** `main` had diverged by 2 commits (session-transcript files only) — merged origin/main in cleanly, then fast-forwarded `main` to the branch (`7ec8b60`). Deployed; verified new URL + old-URL 301.
## Cut state (end of session)
- **`main` = `origin/main` = branch `worktree-welcome-screen-controls` = `7ec8b60`.** Working tree clean; everything pushed and merged.
- **Live: https://benstull.art/human-machine-strata/** serves "Human/Machine Strata"; old `/human-experience-simulator/` (root + subpaths) 301-redirects; app-shell `Cache-Control: no-store`.
- Verification at close: node 35/35, Playwright dynamic e2e 33/33 (incl. new reduce-motion instant-cut clip test), static-build 2/2, pytest 301 passed (3 pre-existing ffmpeg/cv2 env-only failures, unrelated).
- Key commits (all on `main` via `7ec8b60`): `05328b1` spec · `f077193` welcome screen · reconcile merges of main's speed feature · `5f9ae6b` reduce-motion toggle · `ccd4010` 2× cap · `8c63b14` no-store · `fafb498` knobs-above-altitude · `ecd444b` reduce-motion clip re-roll · `c36f995` altitude-on-top · `958c306` rename · `950c1e6` URL move · `b135cb5` bare-root redirect · `7ec8b60` merge.
## What lands on the operator's plate
- **By-eye review on real Safari** of the shipped experience (headless can't exercise real autoplay/GPU; the reduce-motion instant-cut and 2× cap are logic/test-verified only).
- **Optional cleanup:** the dev-panel `#aud-test` element has a hardcoded `/media/...` src that 404s on the R2 site (dev-only, no user impact) — route it through `mediaBase()` if desired.
- **Possible follow-up:** "more welcome copy beyond the heads-up" (operator flagged early; the welcome-message block is structured to take more `data-i18n` paragraphs).
## Prompt the operator can paste into the next session
```
/goal Human/Machine Strata follow-ups. The welcome screen + rename + URL move are live and merged to main (benstull.art/human-machine-strata/). Next: (1) add the intro/framing welcome copy beyond the "Heads up" notice (welcome-message block is data-i18n-ready, 4 langs); (2) optionally clean up the dev-panel #aud-test hardcoded /media 404 by routing it through mediaBase(). Reminder env gotchas: only python3 on this box (start uvicorn by hand, __hefReady e2e seam replaces the old #loading wait); a stale uvicorn from another checkout can squat on :8099 (curl :8099 | grep welcome to confirm); don't trust `… | tail` exit codes.
```
## Deferred decisions
- Kept "Human/Machine Strata" as an **untranslated proper title** across all 4 languages (app.title), rather than localizing it — treated as the work's title. Low-confidence; easy to localize if the operator prefers.
- Renamed the app **URL path** but **not** the Pages project / R2 bucket names (they aren't in the benstull.art URL; renaming them would force a 2.3 GB media re-upload). Judged not worth it.
- Session started `careful`? No — no init ran; posture defaulted to `yolo` (planning-and-executing). All merges/deploys were operator-directed step by step.
+21
View File
@@ -94,5 +94,26 @@
},
"0032": {
"title": ""
},
"0033": {
"title": ""
},
"0034": {
"title": ""
},
"0035": {
"title": ""
},
"0036": {
"title": ""
},
"0037": {
"title": ""
},
"0038": {
"title": ""
},
"0039": {
"title": ""
}
}
+46 -10
View File
@@ -1,22 +1,26 @@
"""Production pass for the 5 per-altitude soundtracks (audio spec §5.3). The audio
analogue of build_pool_manifest.py --media: read the sourced ambience clips
(re-downloadable per docs/audio-candidate-pool.md), and make each a seamless
loudness-normalized loop.
"""Production pass for the 5 per-altitude soundtracks + the Music playlist
(audio spec §5.3; Music per docs/music-candidate-pool.md). The audio analogue of
build_pool_manifest.py --media: read the sourced clips (re-downloadable per the
candidate-pool docs) and make each web-ready.
Media is gitignored; this script is the reproducible record. Outputs land under
simulator/sample_media/audio/<scale>/<name>.loop.mp3, served at /media/audio/...
Run: python simulator/build_audio_media.py
- Soundtracks: seamless loudness-normalized loops -> audio/<scale>/<name>.loop.mp3.
- Music: the mellow drumless synth playlist (Kevin MacLeod, CC BY 4.0) — NOT looped
(the client crossfades between tracks). Each is loudness-normalized and transcoded
to a bitrate that keeps it under the ~25MB git-LFS/nginx limit (ambient tolerates
it) -> audio/music/<name>.mp3. Put the downloaded original at audio/music/<name>.src.mp3
(URLs below) first; this transcodes it.
(White-noise is deferred — the live Audio control is a simple on/off soundtrack
toggle. `tools.pipeline.audio_run.generate_white_noise` remains for when it's
wanted, but no noise bed is built here.)
Media is committed via git-LFS; this script is the reproducible record.
Run: python -m simulator.build_audio_media
"""
from __future__ import annotations
import subprocess
from pathlib import Path
from tools.pipeline.audio_run import process_soundtrack
from tools.pipeline.run import resolve_ffmpeg
AUDIO = Path(__file__).parent / "sample_media" / "audio"
@@ -30,6 +34,29 @@ SOURCES: dict[str, tuple[str, str]] = {
"abyss": ("whale.wav", "whale.loop.mp3"),
}
# Music playlist (docs/music-candidate-pool.md § Round 4): name -> (incompetech source
# URL, target bitrate). Kevin MacLeod, CC BY 4.0 — credited on credits.html. Drumless,
# chord-moving ambient; the operator's picks. Longer tracks get a lower bitrate to stay
# under the LFS size limit. Client playlist: app.js MUSIC_PLAYLIST.
_KM = "https://incompetech.com/music/royalty-free/mp3-royaltyfree/"
MUSIC: dict[str, tuple[str, str]] = {
"manatees": (_KM + "Music%20for%20Manatees.mp3", "96k"),
"dewdrop": (_KM + "Dewdrop%20Fantasy.mp3", "80k"),
"eastminster": (_KM + "Eastminster.mp3", "128k"),
"overheat": (_KM + "Overheat.mp3", "128k"),
"concentration": (_KM + "Concentration.mp3", "80k"),
}
def _transcode(src: Path, dst: Path, bitrate: str, ff: str) -> Path:
"""Loudness-normalize + transcode to mp3 at `bitrate` (no looping)."""
subprocess.run(
[ff, "-y", "-hide_banner", "-loglevel", "error", "-i", str(src),
"-af", "loudnorm", "-c:a", "libmp3lame", "-b:a", bitrate, "-ar", "44100", str(dst)],
check=True,
)
return dst
def main() -> None:
for scale, (raw, out) in SOURCES.items():
@@ -40,6 +67,15 @@ def main() -> None:
dst = process_soundtrack(src, AUDIO / scale / out)
print(f" produced {dst.relative_to(AUDIO.parent)}")
ff = resolve_ffmpeg()
for name, (url, bitrate) in MUSIC.items():
src = AUDIO / "music" / f"{name}.src.mp3"
if not src.exists():
print(f" SKIP music/{name}: source missing ({src}) — download {url}")
continue
dst = _transcode(src, AUDIO / "music" / f"{name}.mp3", bitrate, ff)
print(f" produced {dst.relative_to(AUDIO.parent)} @ {bitrate}")
if __name__ == "__main__":
main()
+21
View File
@@ -32,3 +32,24 @@ npm test
`playwright.config.ts` starts uvicorn on port 8099 automatically (`reuseExistingServer`
locally) and points the tests at it.
## Static-build E2E (Cloudflare publish)
`tests/static-build.spec.ts` verifies the **built static site** (Cloudflare publish
— see `deploy/cloudflare/README.md`): it boots with NO `/api/*` server (baked JSON),
and the WebGL dream shader runs on **cross-origin** media without tainting the GL
texture (the CORS path R2 will use in production).
It uses `playwright.static.config.ts` (no uvicorn `webServer`) and a dual static
server, `serve-static.mjs`, that serves the built `dist/` (app, :8077) and
`dist-media/` (media with CORS + Range, :8078):
```bash
# from the repo root: build with the media base pointed at the local CORS server
.venv/bin/python -m tools.build_static --media-base http://localhost:8078/
# from this directory (simulator/e2e): start the dual server, then run the spec
DIST_DIR="$PWD/../../dist" MEDIA_DIR_E2E="$PWD/../../dist-media" node serve-static.mjs &
npx playwright test --config playwright.static.config.ts
```
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
// Static-build E2E: NO webServer — the built dist/ + dist-media/ are served by
// serve-static.mjs (started manually, see README.md), so there is no uvicorn/API.
// Run: npx playwright test --config playwright.static.config.ts
export default defineConfig({
testDir: "./tests",
testMatch: /static-build\.spec\.ts/,
timeout: 60_000,
use: { actionTimeout: 10_000 },
});
+48
View File
@@ -0,0 +1,48 @@
// Serves the built dist/ (app) and dist-media/ (media, with CORS + Range) on two
// ports — a local stand-in for Pages + R2, so the static-build E2E exercises the
// cross-origin media path exactly as production will (CORS for the WebGL dream
// shader, Range for video scrubbing).
//
// Env: DIST_DIR (app root), MEDIA_DIR_E2E (media root). Ports: app 8077, media 8078.
import http from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname, normalize } from "node:path";
const TYPES = {
".html": "text/html", ".js": "text/javascript", ".json": "application/json",
".css": "text/css", ".mp4": "video/mp4", ".mp3": "audio/mpeg",
};
function serve(root, port, cors, indexFallback) {
http.createServer((req, res) => {
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
let p = decodeURIComponent(req.url.split("?")[0]);
if (p.endsWith("/")) p += "index.html";
// contain to root (no path traversal)
const file = normalize(join(root, p));
if (!file.startsWith(normalize(root))) { res.writeHead(403); return res.end("forbidden"); }
try {
const st = statSync(file);
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
const range = req.headers.range; // honor Range for video scrub
if (range && /^bytes=/.test(range)) {
const [s, e] = range.replace("bytes=", "").split("-");
const start = +s, end = e ? +e : st.size - 1;
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${st.size}`,
"Content-Length": end - start + 1,
});
createReadStream(file, { start, end }).pipe(res);
} else {
res.writeHead(200, { "Content-Length": st.size });
createReadStream(file).pipe(res);
}
} catch {
res.writeHead(404); res.end("not found");
}
}).listen(port);
}
serve(process.env.DIST_DIR, 8077, false);
serve(process.env.MEDIA_DIR_E2E, 8078, true);
console.log("static app :8077 media(cors) :8078");
@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}
+162
View File
@@ -0,0 +1,162 @@
import { test, expect, Page } from "@playwright/test";
// Accessibility pass (feat/accessibility-pass) — non-kiosk public-web a11y.
// Each block maps to a task in
// docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md.
// Enter the simulation via the welcome screen (the panel is hidden until then).
async function enter(page: Page) {
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
test.describe("Task 1 — Output panel layout", () => {
test("language picker sits below the Audio control", async ({ page }) => {
await page.goto("/");
await enter(page);
const audio = page.locator("#audio");
const lang = page.locator("#lang-select");
await expect(audio).toBeVisible();
await expect(lang).toBeVisible();
const aBox = await audio.boundingBox();
const lBox = await lang.boundingBox();
expect(lBox!.y).toBeGreaterThan(aBox!.y); // lang renders lower than audio
});
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
await page.goto("/");
await enter(page);
const pick = page.locator(".panel .lang-pick");
const select = page.locator("#lang-select");
const pBox = await pick.boundingBox();
const sBox = await select.boundingBox();
expect(sBox!.x).toBeGreaterThan(pBox!.x); // select right of the label's left edge
expect(pBox!.height).toBeLessThan(40); // one row, not stacked
});
});
test.describe("Task 4 — Photosensitivity notice", () => {
test("the welcome screen carries the heads-up notice and the Speed control", async ({ page }) => {
await page.goto("/");
// The heads-up is shown on the welcome screen itself (it is the entry gate).
await expect(page.locator("#welcome-title")).toBeVisible();
await expect(page.locator("#welcome-title")).toHaveText(/motion|flashing/i);
// The Speed control + Launch button sit right there, before the visitor launches.
await expect(page.locator("#welcome-play-speed")).toBeVisible();
await expect(page.locator("#welcome-launch")).toBeVisible();
});
});
test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
test("Speed is a 02x forward slider; the loop follows it (default 1x)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
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", "2"); // capped at 2x — base clips aren't all-intra, so >2x stutters
});
test("forward speed sets the loop video's playbackRate (up to 2x)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
await page.locator("#play-speed").fill("2");
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
.toBe(2);
});
test("0x freezes the loop video", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
await page.locator("#play-speed").fill("0");
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(true);
});
test("Reduce motion defaults from the OS setting and does NOT disable Speed", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/");
// The welcome toggle reflects the OS preference before launch.
await expect(page.locator("#welcome-reduce-motion")).toBeChecked();
await enter(page);
await expect(page.locator("#reduce-motion")).toBeChecked(); // panel mirrors it
await expect(page.locator("#play-speed")).toBeEnabled(); // Speed is independent of reduce-motion
});
test("Reduce motion turns altitude changes into instant cuts (no morph tween)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await enter(page);
await page.locator('label[for="reduce-motion"]').click(); // hidden checkbox → toggle via label
await expect(page.locator("#reduce-motion")).toBeChecked();
const start = (await page.locator("#scale-name").textContent())!;
const t0 = await page.evaluate(() => performance.now());
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 60 }); // descend one altitude
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 5000 });
const dt = (await page.evaluate(() => performance.now())) - t0;
expect(dt).toBeLessThan(600); // instant cut, not the ~1200ms animated morph tween
});
});
test.describe("Task 6 — Keyboard + ARIA dial", () => {
test("altitude dial is a keyboard-operable slider", async ({ page }) => {
await page.goto("/");
await enter(page);
const dial = page.locator("#dial");
await expect(dial).toHaveAttribute("role", "slider");
await expect(dial).toHaveAttribute("tabindex", "0");
await dial.focus();
const before = await page.locator("#scale-name").textContent();
await dial.press("ArrowDown"); // descend one altitude
await expect
.poll(async () => page.locator("#scale-name").textContent())
.not.toBe(before);
await dial.press("Home"); // jump to the top scale (cosmos)
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
await expect(dial).toHaveAttribute("aria-valuenow", "0");
});
});
test.describe("Task 7 — Screen-reader narration", () => {
test("an aria-live region narrates the current scale", async ({ page }) => {
await page.goto("/");
await enter(page);
const live = page.locator("#sr-status");
await expect(live).toHaveAttribute("aria-live", "polite");
await page.locator("#dial").focus();
await page.locator("#dial").press("Home");
await expect
.poll(async () => (await live.textContent())?.toLowerCase())
.toContain("cosmos");
});
test("decorative overlay SVGs are hidden from assistive tech", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
});
});
test.describe("Task 9 — About page", () => {
test("about page loads, names its themes, and links back", async ({ page }) => {
await page.goto("/about.html");
await expect(page.locator("h1")).toContainText(/about/i);
await expect(page.getByText(/imperfect/i)).toBeVisible(); // honest-about-limits beat
await expect(page.getByText(/space exploration/i)).toBeVisible(); // technology arc beat
await expect(page.getByText(/large language models/i)).toBeVisible();
await page.locator(".back-link").click();
await expect(page).toHaveURL(/index\.html|\/$/);
});
test("the header links to the about page", async ({ page }) => {
await page.goto("/");
await expect(page.locator('a[href="about.html"]')).toBeVisible();
});
});
+53 -18
View File
@@ -10,14 +10,15 @@ import { test, expect, Page } from "@playwright/test";
// ("<scale> · <clip> (i/n)").
async function boot(page: Page) {
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
await page.addInitScript(() => {
localStorage.setItem("hef.devMode", "1");
});
await page.goto("/");
// Wait for the "Loading Universe" preload to finish (splash gets class "done").
await page.waitForFunction(
() => document.querySelector("#loading")?.classList.contains("done"),
null,
{ timeout: 45_000 },
);
// Wait for phase-1 preload, then enter via the welcome screen's Launch button
// (Video defaults on, Audio 2 → the experience is running once we're in).
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
await expect(page.locator("#scale-name")).toContainText("cosmos");
}
@@ -83,16 +84,8 @@ test("audio is a 0-10 level dial", async ({ page }) => {
expect(ctl.max).toBe("10");
});
test("boots silent (video off, audio 0); turning video on couples audio to 3/10", async ({ page }) => {
await boot(page);
// Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites.
expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false);
expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
// Turning Video on (a click gesture) lifts the audio dial to a gentle 3/10.
await enableVideo(page);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
});
// (Entry via the welcome screen — defaults, Launch, control carry-over, State B —
// is covered in welcome.spec.ts.)
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
await boot(page);
@@ -115,6 +108,20 @@ test("dragging the dial scrubs morph currentTime and audio gains", async ({ page
await page.mouse.up();
});
test("reduce motion: selecting a new altitude still swaps to a fresh clip (instant cut)", async ({ page }) => {
await boot(page);
// #scale-name reads "<scale> · <clip> (i/n)"; extract the clip id.
const clipId = (s: string) => (s.split("·")[1] || "").split("(")[0].trim();
await page.locator('label[for="reduce-motion"]').click(); // engage reduce motion
await expect(page.locator("#reduce-motion")).toBeChecked();
const before = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // descend one altitude (instant cut)
await page.waitForFunction(() => /orbit/.test(document.querySelector("#scale-name")?.textContent || ""), null, { timeout: 20000 });
const after = (await page.locator("#scale-name").textContent())!;
expect(after).toContain("orbit");
expect(clipId(after)).not.toBe(clipId(before)); // the clip actually changed (not stuck on the old scale's clip)
});
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
@@ -261,7 +268,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 });
@@ -278,3 +286,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
});
+76
View File
@@ -0,0 +1,76 @@
import { test, expect, Page } from "@playwright/test";
// The Audio-source feature (docs/superpowers/specs/2026-07-01-audio-music-option-design.md):
// the old 010 "Audio" slider becomes "Volume", and a new Audio <select> chooses the
// source — None / Soundtrack / Music. Music is a rotating playlist played the same at
// every altitude (no per-altitude crossfade).
async function enter(page: Page) {
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
async function selectSource(page: Page, v: string) {
await page.selectOption("#audio-source", v);
}
// The url the active <audio> element is loaded with (dataset.url is the raw path
// app.js assigns; set regardless of whether play() is allowed to start).
async function audUrls(page: Page): Promise<{ a: string; b: string }> {
return await page.evaluate(() => ({
a: (document.querySelector("#aud") as HTMLAudioElement).dataset.url || "",
b: (document.querySelector("#aud-b") as HTMLAudioElement).dataset.url || "",
}));
}
async function wheelOnStage(page: Page, deltaY: number) {
await page.locator("#stage").dispatchEvent("wheel", { deltaY });
}
test.describe("Audio source dropdown + Volume rename", () => {
test("the level slider is now labelled Volume and the Audio control is a 3-option dropdown", async ({ page }) => {
await enter(page);
await expect(page.locator('.panel label.audio-level span[data-i18n="output.volume"]')).toHaveText("Volume");
await expect(page.locator('.panel label.audio-source span[data-i18n="output.audio"]')).toHaveText("Audio");
const opts = page.locator("#audio-source option");
await expect(opts).toHaveCount(3);
await expect(opts).toHaveText(["None", "Soundtrack", "Music"]);
});
test("selecting None disables the Volume slider; a real source re-enables it", async ({ page }) => {
await enter(page);
await selectSource(page, "none");
await expect(page.locator("#audio")).toBeDisabled();
await selectSource(page, "soundtrack");
await expect(page.locator("#audio")).toBeEnabled();
});
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
test("Music plays a playlist track that persists across an altitude change", async ({ page }) => {
await enter(page);
await selectSource(page, "music");
let urls = await audUrls(page);
expect(urls.a).toMatch(MUSIC_RE);
// Move the dial: Music is altitude-independent, so #aud stays on a music track
// (it does NOT switch to a per-altitude soundtrack). Default rotate interval is
// 5 min, so it's the SAME track here.
await wheelOnStage(page, 120);
await wheelOnStage(page, 120);
await page.waitForTimeout(300);
urls = await audUrls(page);
expect(urls.a).toMatch(MUSIC_RE);
});
test("Soundtrack loads a per-altitude ambience, not a music track", async ({ page }) => {
await enter(page);
await selectSource(page, "soundtrack");
await page.waitForTimeout(200);
const urls = await audUrls(page);
const loaded = urls.a + "|" + urls.b;
expect(loaded).toContain("/audio/");
expect(loaded).not.toContain("/audio/music/");
});
});
+56
View File
@@ -0,0 +1,56 @@
import { test, expect, Page } from "@playwright/test";
// Auto-advance moves to the next altitude when the altitude's loop VIDEO completes a
// playthrough (not on a fixed timer). Descends one step + wraps; a default-on toggle
// disables it; manual drag / a playing morph defer it. The e2e simulates a completed
// loop by dispatching the loop element's "ended" (the wrap-point and ended share the
// same advance path).
async function boot(page: Page) {
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
await expect(page.locator("#scale-name")).toContainText("cosmos");
}
const state = (page: Page) => page.evaluate(() => (window as any).__hefState());
const idx = async (page: Page) => (await state(page)).ringIndex as number;
// Simulate the loop video finishing one playthrough.
async function completeLoopVideo(page: Page) {
await page.evaluate(() => {
const v = document.querySelector("#vid-loop") as HTMLVideoElement;
v.dataset.loopTail = "1"; // armed, as after a real landing
v.dispatchEvent(new Event("ended"));
});
}
test("a completed loop video advances one altitude (descending, wraps)", async ({ page }) => {
await boot(page);
const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length);
const start = await idx(page);
await completeLoopVideo(page);
await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 });
expect(await idx(page)).toBe((start + 1) % n);
});
test("Auto-advance off: a completed video stays on the altitude", async ({ page }) => {
await boot(page);
await page.evaluate(() => { (document.querySelector("#auto-advance") as HTMLInputElement).checked = false; });
const start = await idx(page);
await completeLoopVideo(page);
await page.waitForTimeout(1500);
expect(await idx(page)).toBe(start);
});
test("keeps touring: a second completed video advances again", async ({ page }) => {
await boot(page);
const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length);
const start = await idx(page);
await completeLoopVideo(page);
// wait until it has advanced AND settled (pos back on an integer detent)
await page.waitForFunction((s) => { const st = (window as any).__hefState(); return st.ringIndex !== s && Number.isInteger(st.pos); }, start, { timeout: 8000 });
await completeLoopVideo(page);
await page.waitForFunction((a) => (window as any).__hefState().ringIndex === (a.start + 2) % a.n, { start, n }, { timeout: 8000 });
expect(await idx(page)).toBe((start + 2) % n);
});
+9 -6
View File
@@ -7,22 +7,25 @@ import { test, expect, Page } from "@playwright/test";
async function boot(page: Page) {
await page.goto("/");
// initLanguage() populates the select during startup (before media preload).
await expect(page.locator("#lang-select option")).toHaveCount(4);
// initLanguage() populates BOTH selects during startup (before media preload).
// The welcome select is the visible one (the panel is hidden until entry).
await expect(page.locator("#welcome-lang option")).toHaveCount(4);
}
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
await boot(page);
const sel = page.locator("#lang-select");
const sel = page.locator("#welcome-lang"); // the welcome screen's selector
// English selected by default
// English selected by default (both selects mirror activeLang)
await expect(sel).toHaveValue("en");
await expect(page.locator("#lang-select")).toHaveValue("en");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
await expect(page.locator("html")).toHaveAttribute("lang", "en");
// switch to Spanish -> chrome changes, <html lang> updates
// switch to Spanish -> chrome changes (incl. the hidden panel), <html lang> updates
await sel.selectOption("es");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
await expect(page.locator("#lang-select")).toHaveValue("es"); // panel select mirrors
await expect(page.locator("html")).toHaveAttribute("lang", "es");
// Japanese
@@ -31,6 +34,6 @@ test("language dropdown localizes chrome and resets to English on reload", async
// reload returns to English (session-only, no persistence)
await page.reload();
await expect(page.locator("#lang-select")).toHaveValue("en");
await expect(page.locator("#welcome-lang")).toHaveValue("en");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
});
+11 -2
View File
@@ -4,12 +4,21 @@ import { test, expect } from "@playwright/test";
// offset via a narrow timeupdate window. If that window is ever missed the clip
// fires `ended` and — without a safety net — freezes on its last frame ("videos
// aren't looping"). This asserts the `ended` safety net recovers playback.
// (With Auto-advance ON — the default — a completed video instead moves to the next
// altitude; that path is covered by auto-advance.spec.ts. Here we disable it to test
// the loop-back recovery in isolation.)
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
await page.goto("/");
// Enter via the welcome screen (Video defaults on → the loop video loads).
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
await page.evaluate(() => { const c = document.querySelector("#auto-advance") as HTMLInputElement; if (c) c.checked = false; });
// Wait for the loop video to actually have media before forcing the stuck state.
await page.waitForFunction(
() => document.querySelector("#loading")?.classList.contains("done"),
() => { const v = document.getElementById("vid-loop") as HTMLVideoElement; return v && v.duration > 0; },
null,
{ timeout: 45_000 },
{ timeout: 20_000 },
);
const r = await page.evaluate(async () => {
+46
View File
@@ -0,0 +1,46 @@
import { test, expect, Page } from "@playwright/test";
// Bug: the Dark/Light Mood grade was dropped DURING an altitude morph — the morph
// showed ungraded ("normal") and the grade snapped back only on landing. Both render
// paths were at fault: the WebGL canvas set filter "none" while `busy`, and the
// fallback set #vid's filter "none" when a morph loaded. The grade must ride through.
async function boot(page: Page) {
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
await expect(page.locator("#scale-name")).toBeVisible();
}
test("Dark Mood grade stays applied to the morph while scrubbing", async ({ page }) => {
await boot(page);
// Drive Mood hard dark; wait for the debounced render to build the grade.
await page.evaluate(() => {
const m = document.querySelector("#mood") as HTMLInputElement;
m.value = "-4";
m.dispatchEvent(new Event("input", { bubbles: true }));
});
await page.waitForTimeout(300);
// Begin a drag → a morph plays (busy = true). Sample the VISIBLE morph surface:
// the paint canvas (WebGL path) or #vid (fallback). #vid-loop is hidden mid-morph,
// so we deliberately exclude it.
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 });
await page.waitForFunction(() => (document.querySelector("#vid") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
const filters = await page.evaluate(() => [
(document.querySelector("#paint") as HTMLElement).style.filter,
(document.querySelector("#vid") as HTMLElement).style.filter,
]);
await page.mouse.up();
// Dark grade is brightness(<1)… — at least one visible surface must carry it.
// Before the fix both were "none" here.
const graded = filters.some((f) => /brightness\(/.test(f) && !/brightness\(1(\.0+)?\)/.test(f));
expect(graded, `visible morph surface must carry the dark grade; saw ${JSON.stringify(filters)}`).toBe(true);
});
@@ -0,0 +1,63 @@
import { test, expect, Page } from "@playwright/test";
// Music is a shuffle: each track plays to its END, then a random OTHER track plays
// (never an immediate repeat), gaplessly, for as long as Music is selected. The e2e
// injects a short crossfade via window.__hefMusicXfadeMs and simulates a track finishing
// by dispatching its "ended" event (real tracks are minutes long).
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
async function boot(page: Page, xfadeMs = 300) {
await page.addInitScript((x) => { (window as any).__hefMusicXfadeMs = x; }, xfadeMs);
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
// id ("aud"/"aud-b") of the louder (active) music element, and the track it holds.
async function active(page: Page): Promise<{ id: string; url: string }> {
return await page.evaluate(() => {
const a = document.querySelector("#aud") as HTMLAudioElement;
const b = document.querySelector("#aud-b") as HTMLAudioElement;
const el = b.volume > a.volume ? b : a;
return { id: el.id, url: el.dataset.url || "" };
});
}
// Simulate the active track finishing.
async function endActive(page: Page, id: string) {
await page.evaluate((i) => (document.querySelector("#" + i) as HTMLAudioElement).dispatchEvent(new Event("ended")), id);
}
test("when the current track ends, a new RANDOM track plays", async ({ page }) => {
await boot(page);
await page.selectOption("#audio-source", "music");
await page.waitForTimeout(200);
const first = await active(page);
expect(first.url).toMatch(MUSIC_RE);
await endActive(page, first.id);
await page.waitForFunction((f) => {
const a = document.querySelector("#aud") as HTMLAudioElement;
const b = document.querySelector("#aud-b") as HTMLAudioElement;
const el = b.volume > a.volume ? b : a;
return (el.dataset.url || "").includes("/audio/music/") && (el.dataset.url || "") !== f;
}, first.url, { timeout: 6000 });
const second = await active(page);
expect(second.url).toMatch(MUSIC_RE);
expect(second.url).not.toBe(first.url); // never an immediate repeat
});
test("Music keeps playing across several track-ends", async ({ page }) => {
await boot(page);
await page.selectOption("#audio-source", "music");
const seen = new Set<string>();
for (let i = 0; i < 3; i++) {
await page.waitForTimeout(400);
const cur = await active(page);
seen.add(cur.url);
await endActive(page, cur.id);
await page.waitForTimeout(400); // let the crossfade settle
}
expect([...seen].every((u) => MUSIC_RE.test(u))).toBe(true);
expect(seen.size).toBeGreaterThan(1); // it moved through multiple tracks
});
+25
View File
@@ -0,0 +1,25 @@
import { test, expect, Page } from "@playwright/test";
// Guard: the "Video speed" control label must stay on ONE line in the Output panel
// (it was wrapping to two rows at the old panel width).
async function enter(page: Page) {
await page.goto("/");
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
test('the "Video speed" label does not wrap to a second line', async ({ page }) => {
await enter(page);
const info = await page.evaluate(() => {
const span = document.querySelector('.panel .play-speed span[data-i18n="speed.label"]') as HTMLElement;
const cs = getComputedStyle(span);
let lh = parseFloat(cs.lineHeight);
if (Number.isNaN(lh)) lh = parseFloat(cs.fontSize) * 1.3; // "normal"
return { text: span.textContent, height: span.getBoundingClientRect().height, lineHeight: lh };
});
expect(info.text).toBe("Video speed");
// A wrapped label is ~2× a single line; allow slack but stay well under two lines.
expect(info.height).toBeLessThan(info.lineHeight * 1.6);
});
+39
View File
@@ -0,0 +1,39 @@
import { test, expect, Page } from "@playwright/test";
// Runs against the BUILT static site: dist/ served on :8077, media (CORS) on :8078
// by serve-static.mjs. Build with `--media-base http://localhost:8078/` so config.js
// points media cross-origin — the production CORS path, locally. See README.md.
const APP = "http://localhost:8077/human-machine-strata/";
// Enter the simulation via the welcome screen (Video defaults on → media plays).
async function enter(page: Page) {
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 30_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
test("boots fully static — no /api calls, ring + clips load from baked JSON", async ({ page }) => {
const apiCalls: string[] = [];
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
await page.goto(APP);
await enter(page);
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
expect(apiCalls, "static build must not call /api/*").toEqual([]);
});
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(APP);
await enter(page);
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
// crank the Feel (Right) knob to engage the dream, let a few frames render
await page.evaluate(() => {
const r = document.getElementById("right") as HTMLInputElement;
r.value = "4";
r.dispatchEvent(new Event("input", { bubbles: true }));
});
await page.waitForTimeout(2000);
// a tainted GL texture throws SecurityError on draw; assert none surfaced
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
});
+47
View File
@@ -0,0 +1,47 @@
import { test, expect, Page } from "@playwright/test";
// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load
// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode.
async function ready(page: Page) {
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
}
async function launch(page: Page) {
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
const srcVal = (page: Page) => page.locator("#audio-source").inputValue();
test("?audio=music preselects the Music source and survives launch", async ({ page }) => {
await page.goto("/?audio=music");
await ready(page);
expect(await srcVal(page)).toBe("music"); // set at init, before launch
await launch(page);
expect(await srcVal(page)).toBe("music");
});
test("?audio=soundtrack preselects Soundtrack", async ({ page }) => {
await page.goto("/?audio=soundtrack");
await ready(page);
expect(await srcVal(page)).toBe("soundtrack");
});
test("?audio=none preselects None and disables Volume", async ({ page }) => {
await page.goto("/?audio=none");
await ready(page);
expect(await srcVal(page)).toBe("none");
await expect(page.locator("#audio")).toBeDisabled();
});
test("an invalid ?audio value is ignored (stays default Soundtrack)", async ({ page }) => {
await page.goto("/?audio=banana");
await ready(page);
expect(await srcVal(page)).toBe("soundtrack");
});
test("?vol sets the master volume for the shared link", async ({ page }) => {
await page.goto("/?audio=music&vol=7");
await ready(page);
await launch(page);
expect(await page.locator("#audio").inputValue()).toBe("7");
});
+43
View File
@@ -0,0 +1,43 @@
import { test, expect, Page } from "@playwright/test";
// The welcome (opening) screen mirrors the panel controls. It must offer the Audio
// SOURCE choice (None/Soundtrack/Music) too — so a visitor can pick their sound mode
// before launching — carried into the session on entry.
async function ready(page: Page) {
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
}
test("the welcome screen has an Audio source dropdown with 3 options", async ({ page }) => {
await page.goto("/");
await ready(page);
await expect(page.locator("#welcome")).toBeVisible();
const opts = page.locator("#welcome-audio-source option");
await expect(opts).toHaveCount(3);
await expect(opts).toHaveText(["None", "Soundtrack", "Music"]);
});
test("choosing Music on the welcome screen carries into the session", async ({ page }) => {
await page.goto("/");
await ready(page);
await page.selectOption("#welcome-audio-source", "music");
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
expect(await page.locator("#audio-source").inputValue()).toBe("music");
await page.waitForTimeout(300);
const url = await page.evaluate(() => (document.querySelector("#aud") as HTMLAudioElement).dataset.url || "");
expect(url).toMatch(/audio\/music\/\w+\.mp3/);
});
test("welcome None greys out the welcome Volume slider", async ({ page }) => {
await page.goto("/");
await ready(page);
await page.selectOption("#welcome-audio-source", "none");
await expect(page.locator("#welcome-audio")).toBeDisabled();
});
test("?audio=music preselects Music on the welcome screen too", async ({ page }) => {
await page.goto("/?audio=music");
await ready(page);
expect(await page.locator("#welcome-audio-source").inputValue()).toBe("music");
});
+114
View File
@@ -0,0 +1,114 @@
import { test, expect, Page } from "@playwright/test";
// The Welcome screen is the universal entry gate (see
// docs/superpowers/specs/2026-06-30-welcome-screen-design.md). It carries the
// photosensitivity heads-up, the four output controls (Video on / Reduce motion
// off / Audio 2 / English), a "Launch Simulator" button, and the "Loading
// Universe" progress. The right panel is hidden until the visitor enters.
const ready = (page: Page) =>
page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
// Set a range input's value + fire `input` (Playwright's fill() doesn't drive ranges).
async function setRange(page: Page, id: string, value: string) {
await page.evaluate(({ id, value }) => {
const el = document.getElementById(id) as HTMLInputElement;
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
}, { id, value });
}
test("welcome screen shows on load with the expected defaults; the panel is hidden", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await expect(page.locator("#welcome")).toBeVisible();
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
// Defaults: Language English, Speed 1, Audio 2, Reduce motion off (no OS pref).
await expect(page.locator("#welcome-lang")).toHaveValue("en");
await expect(page.locator("#welcome-play-speed")).toHaveValue("1");
await expect(page.locator("#welcome-play-speed-val")).toHaveText("1.00×");
await expect(page.locator("#welcome-audio")).toHaveValue("2");
await expect(page.locator("#welcome-audio-val")).toHaveText("2");
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked();
// Heads-up + Launch button present; "Loading Universe" progress present.
await expect(page.locator("#welcome-title")).toBeVisible();
await expect(page.locator("#welcome-launch")).toHaveText("Launch Simulator");
await expect(page.locator(".welcome-loading")).toBeVisible();
// The right panel is hidden behind the welcome screen.
await expect(page.locator(".panel")).toBeHidden();
});
test("the language picker re-renders the welcome text live", async ({ page }) => {
await page.goto("/");
const sel = page.locator("#welcome-lang");
await expect(sel).toHaveValue("en");
await expect(page.locator("#welcome-launch")).toHaveText("Launch Simulator");
await sel.selectOption("es");
await expect(page.locator("#welcome-launch")).toHaveText("Iniciar simulador");
await expect(page.locator("#welcome-title")).toHaveText(/Atención/);
await expect(page.locator("html")).toHaveAttribute("lang", "es");
// the hidden panel selector mirrors the choice
await expect(page.locator("#lang-select")).toHaveValue("es");
await sel.selectOption("ja");
await expect(page.locator("#welcome-launch")).toHaveText("シミュレーターを起動");
});
test("Launch when already loaded enters the simulation directly and reveals the panel", async ({ page }) => {
await page.goto("/");
await ready(page);
await page.locator("#welcome-launch").click();
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
await expect(page.locator(".panel")).toBeVisible();
// Welcome assumes Video on; Audio default 2 carried into the panel.
await expect(page.locator("#visual")).toBeChecked();
await expect(page.locator("#audio")).toHaveValue("2");
});
test("welcome control values carry into the panel on entry", async ({ page }) => {
await page.goto("/");
await ready(page);
await setRange(page, "welcome-audio", "5");
await setRange(page, "welcome-play-speed", "2"); // mirrors into the panel slider live
await page.locator("#welcome-launch").click();
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
await expect(page.locator("#audio")).toHaveValue("5");
await expect(page.locator("#play-speed")).toHaveValue("2");
await expect(page.locator("#visual")).toBeChecked(); // welcome assumes video on
});
test("Launch while still loading shows the centered loader with controls, then auto-enters", async ({ page }) => {
// Stall media so phase-1 preload hasn't finished when Launch is pressed.
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
await page.route("**/media/**", async (route) => {
await gate;
await route.continue();
});
await page.goto("/");
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
await page.locator("#welcome-launch").click();
// State B: messaging + button hidden, controls remain, loader shown (centered).
await expect(page.locator("#welcome")).toHaveClass(/loading/);
await expect(page.locator("#welcome-title")).toBeHidden();
await expect(page.locator("#welcome-launch")).toBeHidden();
await expect(page.locator(".welcome-controls")).toBeVisible();
await expect(page.locator(".welcome-loading")).toBeVisible();
// Let media through → universe becomes ready → auto-enter.
release();
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 30_000 });
await expect(page.locator(".panel")).toBeVisible();
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
<main class="credits-wrap">
<header>
<h1>About this work</h1>
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
</header>
<section>
<h2>For everyone</h2>
<p><em>Human/Machine Strata</em> is built to be accessible to — and
representative of — the full range of human experience. That is why it works
with a keyboard, with a screen reader, with reduced motion, and away from any
single screen or kiosk: an experience about being human should be open to as
many humans as possible.</p>
</section>
<section>
<h2>Places technology let us see</h2>
<p>The scales you move through — the deep sea, the coast, the sky, the orbit
of the Earth, the wider cosmos — are vantage points no unaided human could
ever witness. We can share them only because our tools evolved to reach them:
from <strong>underwater exploration</strong>, to <strong>drones and aerial
imaging</strong>, to <strong>space exploration</strong>. Each scale is a place
a machine went first, so that a person could feel what it is like to be there.</p>
</section>
<section>
<h2>Built with LLMs</h2>
<p>This piece was itself built using large language models — an extension of
that same long arc of tools that widen what a single human can reach. The
software, the words, and much of the craft were shaped in collaboration with
a machine.</p>
</section>
<section>
<h2>Honest about its limits</h2>
<p>This implementation is imperfect. It is also more than I could have made
alone: better with these tools assisting than by my hand as a single human.
That tension is part of the point — technology does not replace the person
behind the work; it extends their reach, flaws and all.</p>
</section>
</main>
<script src="config.js"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
// Client-side port of player/alteration.py + player/audio.py (the alteration
// engine). Replaces the live POST /api/alteration in the static build — pure math,
// unity calibration (the client never sends a calibration, so DEFAULT_CALIBRATION
// = all gains 1.0 always applies). UMD so the browser gets `HEFAlteration` and
// `node --test` can require() it. Keep IN SYNC with the Python — the numeric
// contract is guarded by tests/test_alteration_js_parity.py.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFAlteration = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const KNOB_MAX = 4; // player/alteration.py:25
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
// Mirror of player/alteration.py::plan_alteration with DEFAULT_CALIBRATION (unity).
function plan(c) {
const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1);
const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1);
const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1);
const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1); // affect uses overlay_gain too
const is_identity = tone === 0 && c.left === 0 && c.right === 0;
return {
grade: { tone },
overlay: { level: c.left, intensity: overlayIntensity },
affect: { strength: c.right, intensity: affectIntensity },
dream: { strength: c.right, intensity: dreamIntensity },
is_identity,
};
}
// Mirror of player/audio.py::resolve_audio. `scaleAudio` is the current scale's
// `audio` filename (relative to <mediaBase>audio/); off → silence.
function renderAudio(source, scaleAudio, mediaBase) {
if (source === "off") return { source: "off", url: null, altitude_coupled: false };
const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null;
return { source: "soundtrack", url, altitude_coupled: true };
}
// The full /api/alteration response shape, computed locally.
function alteration(controls, scaleAudio, mediaBase) {
return {
plan: plan(controls),
render: {
video: { shown: controls.visual === "on" }, // player/audio.py::resolve_visual
audio: renderAudio(controls.audio, scaleAudio, mediaBase),
},
};
}
return { plan, renderAudio, alteration, KNOB_MAX };
});
+564 -85
View File
@@ -57,14 +57,19 @@ let activeLang = "en"; // session-only; no persistence (resets to en each lo
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
async function loadData() {
const clips = (await (await fetch("/api/clips")).json()).clips || [];
// Static build: boot from baked JSON (no server). Dev: the live API. The baked
// files sit alongside index.html, so RELATIVE urls resolve under the deploy path.
const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
? { clips: "clips.json", versions: "media-versions.json", ring: "ring.json" }
: { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
const clips = (await (await fetch(api.clips)).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
// Per-file content-hash tokens → appended to /media URLs as ?v=<hash> so a
// re-baked clip (new bytes, same path) gets a fresh URL the browser can't serve
// stale. Best-effort: an empty map just yields un-versioned URLs.
try { mediaVersions = (await (await fetch("/api/media-versions")).json()).versions || {}; }
try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch("/api/ring");
const r = await fetch(api.ring);
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
if (!ring && clips.length) {
@@ -84,19 +89,30 @@ async function loadData() {
// advance (content-pipeline §11.1, Python owns the randomness). The synthesized
// fallback ring has a pool of one, so it just returns that member. Returns a
// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button.
// Lookups for the pure preload planner (HEFPreload): map clip/morph → media file and
// test the in-memory blob cache. A clip is "loaded" only when its file is a cached blob.
function _preloadDeps() {
return {
baseOf: (id) => (clipsById[id] || {}).base_file,
morphFile: (a, b) => morphByPair[`${a}${b}`] || null,
isCached: (f) => !!f && !!mediaBlobs[f],
};
}
function poolIds(scale) {
return ((scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }]).map((m) => m.clip_id);
}
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
if (serverRing) {
try {
const resp = await fetch("/api/ring/advance", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ from_index: ringIndex, delta: 0 }),
});
if (resp.ok) return (await resp.json()).target_clip_id;
} catch (_) { /* fall through to the primary member */ }
}
return scale.clip_id;
// Show only a clip whose base is already loaded (eligible). Initially that's the
// phase-1 first member; the pool grows as the background preload finishes. Fallback
// to the first member (guaranteed loaded by the phase-1 gate).
const ids = poolIds(scale);
let elig = HEFPreload.eligibleMembers(ids, _preloadDeps());
if (!elig.length) elig = [ids[0]];
return HEFPreload.pick(elig, Math.random);
}
// Land on the current scale: pick a random pool member and force its media to load.
@@ -118,9 +134,11 @@ let mediaVersions = {}; // file -> content-hash token (from /api/media-version
// Network path for a media file, content-hash-versioned so a re-baked clip's URL
// changes with its bytes (permanent cache-bust). The blob cache, when present,
// wins — those bytes are already the current ones for this session.
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
function mediaBase() { return (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/"; }
function mediaNetUrl(file) { const v = mediaVersions[file]; return mediaBase() + file + (v ? "?v=" + v : ""); }
function mediaUrl(file) {
if (file.startsWith("/media/")) return file; // already a resolved absolute url (audio layer)
// Already a resolved absolute url (audio layer, or R2 in static mode): pass through.
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
return mediaBlobs[file] || mediaNetUrl(file);
}
@@ -168,10 +186,27 @@ function preloadOrder() {
}
}
for (const f of reachableMorphFiles()) add(f);
for (const f of preloadList()) add(f); // sweep up anything not on the ring
for (const f of preloadList()) add(f); // sweep up every base not already on the ring
// ALL morphs last: preloading every transition before interaction guarantees a
// smooth knob turn anywhere (any random pool re-roll lands on an already-cached
// morph). They're ordered last so the first scenes + nearest transitions cache
// first and the loading bar fills meaningfully.
for (const f of Object.values(morphByPair)) add(f);
return ordered;
}
// Cache a fixed list of files (bounded concurrency), driving the "Loading Universe…"
// bar. Used for the phase-1 gate (one clip/altitude + connecting morphs) before unlock.
async function cacheMany(files, concurrency = 6) {
const total = files.length || 1;
let done = 0, i = 0;
setLoadingProgress(0, total);
async function worker() {
while (i < files.length) { await cacheClip(files[i++]); done++; setLoadingProgress(done, total); }
}
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
}
// Fetch one media file into the blob cache (idempotent — cached files are skipped).
async function cacheClip(file) {
if (!file || mediaBlobs[file]) return;
@@ -277,7 +312,7 @@ function loadLoop(clip, landFrame) {
}
function playLoop() {
if (loopVid.paused) loopVid.play().catch(() => {});
applyPlaySpeed(); // honor the chosen speed (forward, or 0x = freeze) — independent of reduce-motion
}
function ensureClipMedia() {
@@ -293,6 +328,7 @@ function ensureClipMedia() {
// tail. The morph element (#vid) is driven directly by the scrub.
loopVid.addEventListener("timeupdate", () => {
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
}
});
@@ -304,6 +340,7 @@ loopVid.addEventListener("timeupdate", () => {
// rarely hits this; loaded real machines do.
loopVid.addEventListener("ended", () => {
if (loopVid.dataset.loopTail !== "1") return;
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
loopVid.play().catch(() => {});
});
@@ -416,11 +453,13 @@ function paintLoop() {
if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; }
gl.viewport(0, 0, w, h);
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {}
// No painterly restyle during a ring transition (busy) — show it raw.
// No painterly DREAM during a ring transition (busy) — show the morph crisp.
// The Dark/Light mood GRADE, however, must ride through the transition too
// (else the morph shows ungraded and the grade snaps back only on landing).
gl.uniform1f(uAmt, busy ? 0 : dreamIntensity);
gl.uniform2f(uTexel, 1 / w, 1 / h);
gl.drawArrays(gl.TRIANGLES, 0, 3);
paint.style.filter = busy ? "none" : gradeFilter;
paint.style.filter = gradeFilter;
}
// Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed
// labels (a label enters/leaves frame as the loop plays) against playback time.
@@ -677,9 +716,28 @@ function renderScaleReadout() {
const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
$("scale-name").textContent = `${scaleName} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
renderDial();
announceState(); // narrate the new altitude (scale + clip) for screen readers
refreshDevClip(); // keep the Dev Mode pool picker + clip data in sync with the landing
}
// Turn the visual alteration into words for assistive tech: the current scale plus
// the clip you've landed on. Deduped so a settle on the same state stays quiet.
let lastAnnounced = "";
function announce(text) {
const el = $("sr-status");
if (!el || !text || text === lastAnnounced) return;
lastAnnounced = text;
el.textContent = text;
}
function announceState() {
if (!ring) return;
const s = ring.scales[ringIndex];
const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
const clip = activeClip();
const title = clip && clip.title ? clip.title : "";
announce(title ? `${scaleName}. ${title}` : scaleName);
}
function controls() {
// Video (on/off) and Audio (on/off) are orthogonal toggles (audio spec §2).
// Audio on = the per-altitude soundtrack (white-noise is deferred). One bipolar
@@ -702,12 +760,21 @@ function controls() {
let timer = null;
async function update() {
if (busy) return;
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
const data = await resp.json();
let data;
if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
// Static build: compute the alteration locally (HEFAlteration is the JS port of
// player.alteration/audio). Audio couples to the current altitude's asset.
const scale = ring && ring.scales[ringIndex];
const scaleAudio = (scale && scale.audio) || "";
data = HEFAlteration.alteration(controls(), scaleAudio, mediaBase());
} else {
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
data = await resp.json();
}
readout.textContent = JSON.stringify(data, null, 2);
// Tolerate a stale server that still returns {content:{video}} instead of the
// {render:{video:{shown}}} (visual/audio-split) shape, so video works either way.
@@ -753,10 +820,22 @@ const PER_ALTITUDE_MS = 1200;
let autoRaf = 0;
function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) {
if (!ring || ring.scales.length < 2) return;
if (isReduced()) { // reduced motion: no autonomous tween — jump to target + lock
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
setPos(targetPos);
return;
}
if (autoRaf) cancelAnimationFrame(autoRaf);
const fromPos = pos, dist = targetPos - fromPos;
if (!dist) { setPos(targetPos); return; }
const ms = perAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long
// Photosensitivity floor (WCAG 2.3.1): each altitude step is one luminance
// transition (a blended crossfade), so a single step must take >= 1/3 s to stay
// <= 3 flashes/sec. The clamp is a no-op at today's 1200ms but guards the floor
// if PER_ALTITUDE_MS is ever lowered. (Audit: the only other luminance sources —
// the #black cover fade (200ms, single, >=1.2s apart) and the audio-coupled
// crossfade — cannot repeat faster than 3/sec, so they need no clamp.)
const safeAltMs = HEFFlash.clampDurationMs(perAltMs, 1);
const ms = safeAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long
let startTs = null;
const tick = (ts) => {
if (startTs === null) startTs = ts;
@@ -780,6 +859,20 @@ function onWheel(e) {
}, 90);
}
// --- Auto-advance: when the altitude's base loop VIDEO completes one playthrough, drift
// one altitude deeper (descend; past the deepest it wraps back to the top) — a slow kiosk
// tour paced by the footage itself. A default-on toggle disables it; a manual drag or a
// playing morph defers the move (it happens on the NEXT completion instead). Hooked into
// the loop element's completion points (the timeupdate wrap + the ended safety net).
function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; }
function autoAdvanceOnLoopComplete() {
if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) {
autoScrub(Math.round(pos) + 1); // completed a playthrough → next altitude
return true; // advanced — caller skips the normal loop-back
}
return false;
}
// --- Altitude knob: the endless rotary encoder, drawn as a turnable dial ---
// cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales
// (cosmos → orbit → coast → reef → abyss) and past the deepest WRAPS back to the
@@ -823,6 +916,8 @@ function buildDial() {
const t = svg("text", {
x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central",
class: "dial-label", "data-index": String(i),
role: "button", tabindex: "0",
"aria-label": HEFi18n.pickUiString("scale." + ring.scales[i].id, activeLang),
}, dial);
t.textContent = ring.scales[i].id;
}
@@ -848,6 +943,16 @@ function renderDial() {
for (const el of dial.querySelectorAll(".dial-label")) {
el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex);
}
setDialAria();
}
// Reflect the committed altitude into the dial's slider semantics for assistive tech.
function setDialAria() {
if (!dial || !ring) return;
dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
dial.setAttribute("aria-valuenow", String(ringIndex));
const s = ring.scales[ringIndex];
if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
}
function dialAngle(e) {
@@ -867,11 +972,18 @@ function angDelta(a, b) {
// destination pick (was the server's job in /api/ring/advance; moved here so the
// scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the
// canonical pure helper for the Pi player).
function pickPoolClip(index) {
function pickPoolClip(index, fromId) {
const n = ring.scales.length;
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
const ids = poolIds(s);
// Pick a DESTINATION only among clips fully loaded relative to fromId (base +
// morphs both ways) — so the transition never stalls on un-downloaded media. The
// eligible set grows as the background preload completes. Fallback to the first
// pool member (the phase-1 gate guarantees it + its connecting morphs are loaded).
let elig = fromId ? HEFPreload.eligibleDestinations(ids, fromId, _preloadDeps())
: HEFPreload.eligibleMembers(ids, _preloadDeps());
if (!elig.length) elig = [ids[0]];
return HEFPreload.pick(elig, Math.random);
}
// Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude
@@ -881,15 +993,18 @@ function pickPoolClip(index) {
function rebuildSegment(lo, enteredFrom) {
const n = ring.scales.length;
const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n);
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo);
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1);
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo, activeClipId);
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1, activeClipId);
const file = morphByPair[`${loId}${hiId}`] || null;
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
if (file) {
overlay.style.opacity = "0";
affectLayer.style.opacity = "0";
tint.style.opacity = "0";
vid.style.filter = "none";
// WebGL path: the graded canvas sits on top, so #vid stays raw (no double-grade).
// Fallback path: #vid IS the visible surface, so it must carry the mood grade
// through the morph (setting "none" here was the "morph ignores Mood" bug).
vid.style.filter = paintOK ? "none" : gradeFilter;
vid.loop = false;
if (vid.dataset.morph !== file) {
vid.dataset.morph = file;
@@ -921,6 +1036,12 @@ function setPos(next) {
const { lo, frac } = HEFScrub.segmentOf(pos);
if (frac === 0) { // settled exactly on an altitude → lock + base loop + single soundtrack
busy = false;
// If we JUMPED straight here (a reduced-motion instant cut skips the segment that
// would re-roll the destination), activeClipId still points at the previous
// altitude's clip. Re-roll from this altitude's pool so the video actually changes.
if (!poolIds(ring.scales[ringIndex]).includes(activeClipId)) {
activeClipId = pickPoolClip(ringIndex, activeClipId);
}
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
// Land the loop on the frame the morph last showed for THIS travel direction:
@@ -928,7 +1049,7 @@ function setPos(next) {
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
playLoop(); // the loop element was preloaded to this clip during the scrub → instant, no reload
playLoop(); // run the resting loop at the chosen Speed (0x freezes it) — independent of reduce-motion
showActiveSource();
setNeedle(ringIndex * dialStep());
renderScaleReadout();
@@ -980,7 +1101,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.
@@ -993,14 +1118,119 @@ function jumpToScale(idx) {
if (d) autoScrub(Math.round(pos) + d);
}
// Keyboard operation of the dial (role="slider"): arrows step one altitude, Home/End
// jump to the ends. Enter/Space on a focused label jumps to that scale.
function onDialKey(e) {
if (!ring || ring.scales.length < 2) return;
const t = e.target;
if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
jumpToScale(+t.getAttribute("data-index"));
return;
}
let handled = true;
switch (e.key) {
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break; // descend
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break; // ascend
case "Home": jumpToScale(0); break;
case "End": jumpToScale(ring.scales.length - 1); break;
default: handled = false;
}
if (handled) e.preventDefault();
}
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
// Off by default, state persisted in localStorage. Everything here is read from
// data the renderer already has (clips, ring, the alteration response, the preload
// cache) — no server endpoints. Editing labels stays in /author.html.
const DEV_KEY = "hef.devMode";
const SPEED_KEY = "hef.playSpeed";
const RM_KEY = "hef.reduceMotion";
let devMode = false;
let devStatsTimer = null;
// --- Playback speed -----------------------------------------------------------
// The Playback Speed slider (0x4x forward) 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 was
// dropped: base loop clips aren't all-intra, so reverse seeking jitters.)
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
let playSpeed = 1;
// Apply the chosen slider speed to the resting loop video. When video output is
// off we just keep the forward rate sane for when it resumes.
function applyPlaySpeed() {
if (!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
}
}
// Both the welcome screen's slider and the panel's drive the same playSpeed; each
// mirrors the other and the shared "1.00×" readout.
const SPEED_SLIDERS = ["welcome-play-speed", "play-speed"];
const SPEED_VALS = ["welcome-play-speed-val", "play-speed-val"];
function syncSpeedSliders() {
for (const id of SPEED_SLIDERS) { const s = $(id); if (s && s.value !== String(playSpeed)) s.value = String(playSpeed); }
}
function updateSpeedLabel() {
const txt = playSpeed.toFixed(2) + "×";
for (const id of SPEED_VALS) { const el = $(id); if (el) el.textContent = txt; }
}
function initPlaySpeed() {
let stored = null;
try { stored = localStorage.getItem(SPEED_KEY); } catch (_) {}
playSpeed = stored === null ? 1 : (parseFloat(stored) || 0);
syncSpeedSliders();
for (const id of SPEED_SLIDERS) {
const slider = $(id);
if (!slider) continue;
slider.addEventListener("input", () => {
playSpeed = parseFloat(slider.value) || 0;
try { localStorage.setItem(SPEED_KEY, String(playSpeed)); } catch (_) {}
syncSpeedSliders();
updateSpeedLabel();
applyPlaySpeed();
});
}
updateSpeedLabel();
}
// --- Reduce motion (morph transitions) ----------------------------------------
// A visible toggle (welcome + panel, mirrored) that turns OFF the morph/transition
// videos: when ON, altitude changes are instant cuts instead of an animated morph
// tween (see autoScrub's reduced branch). It does NOT touch the resting loop —
// that is the Speed slider's job (Speed 0 freezes the loop independently).
// Defaults from the OS `prefers-reduced-motion` setting, then remembers the
// visitor's choice.
let reduceMotion = false;
function isReduced() { return reduceMotion; }
const RM_BOXES = ["welcome-reduce-motion", "reduce-motion"];
function syncReduceMotionBoxes() {
for (const id of RM_BOXES) { const b = $(id); if (b && b.checked !== reduceMotion) b.checked = reduceMotion; }
}
function initReduceMotion() {
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";
syncReduceMotionBoxes();
for (const id of RM_BOXES) {
const box = $(id);
if (!box) continue;
box.addEventListener("change", () => {
reduceMotion = box.checked; // governs FUTURE transitions (cut vs morph)
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
syncReduceMotionBoxes();
});
}
}
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
@@ -1129,6 +1359,7 @@ async function reroll() {
// from the live API and masks it otherwise). Reloads on my edits AND on a server
// restart. Dev-only convenience; harmless if the endpoint is absent.
function devLiveReload() {
if (window.HEF_CONFIG && window.HEF_CONFIG.static) return; // no dev server in the static build
let seen = null;
setInterval(async () => {
try {
@@ -1159,6 +1390,40 @@ function updateAudioLevelLabel() {
if (el) el.textContent = String(audioLevel());
}
// The Audio dropdown source: "none" | "soundtrack" | "music". `restAudio`/
// `blendAudio` consult HEFScrub.audioPlan(source, level, frac) for the mode branch.
function audioSource() {
const el = $("audio-source");
return el ? el.value : "soundtrack";
}
// Volume is meaningless when there is no source — grey it out on None.
function syncVolumeDisabled() {
const slider = $("audio");
if (slider) slider.disabled = audioSource() === "none";
}
// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load
// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode
// — e.g. share …/?audio=music with someone who'd prefer music over the soundtrack. The
// source is set on the (welcome-hidden) panel dropdown; the launch gesture then applies
// it. Invalid values are ignored (the default Soundtrack stands).
function applyUrlAudioParams() {
const p = new URLSearchParams(location.search);
const src = (p.get("audio") || "").toLowerCase();
if (src === "none" || src === "soundtrack" || src === "music") {
const el = $("audio-source"); if (el) el.value = src;
const w = $("welcome-audio-source"); if (w) w.value = src; // reflect on the opening screen too
}
const vol = p.get("vol");
if (vol !== null && vol !== "" && Number.isFinite(+vol)) {
const v = String(Math.max(0, Math.min(10, Math.round(+vol))));
// Seed BOTH the welcome mirror (carried into the panel on launch) and the panel
// slider, so the shared level survives entry.
const w = $("welcome-audio"); if (w) w.value = v;
const a = $("audio"); if (a) a.value = v;
}
}
// The element currently carrying the most signal — for the diagnostic readout,
// which used to assume a single `aud`. With two crossfading elements either may be
// the audible one, so report on whichever is louder (ties → aud).
@@ -1167,12 +1432,14 @@ function activeAudEl() { return audB.volume > aud.volume ? audB : aud; }
// --- Live audio status readout (diagnostic) — so a "no sound" report is legible ---
const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"];
function audioStatusText() {
if (audioLevel() === 0) return "audio: off (level 0)";
const src = audioSource();
if (src === "none") return "audio: off (source None)";
if (audioLevel() === 0) return "audio: off (volume 0)";
const s = ring && ring.scales[ringIndex];
const url = soundtrackUrl();
const url = currentAudioUrl();
const el = activeAudEl();
const head = `L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
if (!url) return "audio: " + head + " ← no soundtrack URL for this scale";
const head = `${src} · L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
if (!url) return "audio: " + head + " ← no URL for this source/scale";
if (el.error) return `audio: ${head} · MEDIA ERROR ${el.error.code} (${AUD_ERR[el.error.code] || "?"})`;
if (audLastErr) return `audio: ${head} · ${audLastErr}`;
if (el.paused) return `audio: ${head} · PAUSED rs=${el.readyState}`;
@@ -1183,14 +1450,15 @@ function updateAudioStatus() {
if (!el) return;
el.textContent = audioStatusText();
const a = activeAudEl();
const on = audioLevel() > 0;
const playing = on && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr;
const on = audioSource() !== "none" && audioLevel() > 0;
const url = currentAudioUrl();
const playing = on && !!url && !a.paused && !a.error && !audLastErr;
el.classList.toggle("playing", playing);
el.classList.toggle("blocked", on && (!soundtrackUrl() || !!a.error || !!audLastErr));
el.classList.toggle("blocked", on && (!url || !!a.error || !!audLastErr));
}
aud.addEventListener("error", updateAudioStatus);
audB.addEventListener("error", updateAudioStatus);
setInterval(updateAudioStatus, 400);
setInterval(() => { updateAudioStatus(); musicWatchdog(); }, 400);
// Cancel any in-flight fade on an element. A fade is a setInterval ramping volume;
// if a stale one keeps running after a new fade or a direct volume set, it drags the
@@ -1237,11 +1505,39 @@ function scaleAudioUrl(index) {
if (!ring || !ring.scales.length) return null;
const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
return a ? "/media/audio/" + a : null;
// Route through mediaBase() so audio loads from R2 in the static build (and the
// local /media mount in dev) — NOT a hardcoded /media path, which 404s on the
// static site. mediaUrl() then passes this absolute url straight through.
return a ? mediaBase() + "audio/" + a : null;
}
// The current resting altitude's soundtrack url (kept for the status readout).
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
// --- Music: a rotating playlist of mellow synth tracks that CROSSFADE between each
// other (not one looping track), altitude-independent — the same relaxing rotation at
// every altitude. Each track plays to its END, then a random OTHER one plays (never an
// immediate repeat); a short crossfade a few seconds before the end keeps it GAPLESS, so
// Music is always playing while selected. Reuses the two audio elements. The crossfade
// duration is injectable for tests.
const MUSIC_PLAYLIST = [
"music/manatees.mp3", "music/dewdrop.mp3", "music/eastminster.mp3",
"music/overheat.mp3", "music/concentration.mp3",
];
const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000;
const MUSIC_XFADE_S = MUSIC_XFADE_MS / 1000;
function musicTrackUrl(i) {
return mediaBase() + "audio/" + MUSIC_PLAYLIST[HEFScrub.wrapIndex(i, MUSIC_PLAYLIST.length)];
}
let musicIdx = -1; // index of the track on the active music element (-1 = not started)
let musicEl = null; // the element currently carrying the active music track
let musicXfading = false; // true during a track→track crossfade (guards re-entry)
// The url the active source would play right now (for the diagnostic readout).
function currentAudioUrl() {
if (audioSource() === "music") return musicTrackUrl(musicIdx < 0 ? 0 : musicIdx);
return soundtrackUrl();
}
// Start (once) and set the volume of one element to a url. Safari needs the first
// play() inside a user gesture; later programmatic plays reuse the unlocked element.
// Only (re)loads `src` when the url actually changes (so a same-url call is a no-op
@@ -1273,31 +1569,97 @@ function assignElements(urlLo, urlHi) {
return { elLo: aud, elHi: audB };
}
// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac),
// both scaled by the master audio level. No-op when the level is 0.
// Both elements fade to silence (None, or Volume 0). Also ends any music rotation.
function silenceAll() {
stopMusic();
fadeVolume(aud, 0, FADE_MS, () => aud.pause());
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
}
// Crossfade the finishing track out and a random OTHER track in on the other element.
function advanceMusic() {
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) return;
musicXfading = true;
const next = HEFScrub.pickNextIndex(musicIdx, MUSIC_PLAYLIST.length, Math.random());
const nextEl = musicEl === aud ? audB : aud;
const prevEl = musicEl;
ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent...
fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS, () => { musicXfading = false; }); // ...fade it in,
fadeVolume(prevEl, 0, MUSIC_XFADE_MS, () => prevEl.pause()); // ...fade the finishing one out.
musicEl = nextEl; musicIdx = next;
updateAudioStatus();
}
// timeupdate: a few seconds before the active track ends, crossfade to a random next
// (gapless). Only the active element, in Music mode, once per track.
function onMusicProgress(e) {
if (musicXfading || audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
const d = musicEl.duration;
if (d && isFinite(d) && d > MUSIC_XFADE_S * 2 && musicEl.currentTime >= d - MUSIC_XFADE_S) advanceMusic();
}
// ended: safety net — a track that finishes without a crossfade (short track / missed
// timeupdate) immediately rolls a new random one, so Music never stops while selected.
function onMusicEnded(e) {
if (audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
musicXfading = false;
advanceMusic();
}
// Start playback (first call) or just re-apply the master volume to the active track. The
// FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock them
// on Safari (later crossfades play the 2nd element outside a gesture).
function startMusic(vol) {
if (musicIdx < 0) {
musicXfading = false;
musicIdx = Math.floor(Math.random() * MUSIC_PLAYLIST.length);
musicEl = aud;
ensurePlaying(aud, musicTrackUrl(musicIdx), vol);
ensurePlaying(audB, musicTrackUrl(musicIdx), 0); // prime + unlock the 2nd element (silent)
} else {
cancelFade(musicEl);
musicEl.volume = HEFScrub.clamp01(vol);
}
updateAudioStatus();
}
// Reset (on leaving Music, or going silent).
function stopMusic() { musicXfading = false; musicIdx = -1; musicEl = null; }
// Keep Music playing while selected: resume the active track if it stalls/pauses unexpectedly.
function musicWatchdog() {
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0 || !musicEl) return;
if (musicEl.paused && !musicXfading) { const pr = musicEl.play(); if (pr) pr.catch(() => {}); }
}
for (const el of [aud, audB]) {
el.addEventListener("timeupdate", onMusicProgress);
el.addEventListener("ended", onMusicEnded);
}
// Mid-segment scrub. Soundtrack: crossfade scale `loIndex` (gain 1-frac) against
// loIndex+1 (gain frac), scaled by the master volume. Music: hold the single loop.
// Silent (None / Volume 0): leave the elements be — restAudio fades them on landing.
function blendAudio(loIndex, frac) {
const lvl = audioVol();
if (lvl === 0) return;
const plan = HEFScrub.audioPlan(audioSource(), audioVol(), frac);
if (plan.mode === "silent") return;
if (plan.mode === "music") { startMusic(plan.vol); return; }
stopMusic(); // leaving Music for the soundtrack crossfade
const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1);
const g = HEFScrub.crossfadeGains(frac);
const { elLo, elHi } = assignElements(urlLo, urlHi);
ensurePlaying(elLo, urlLo, g.from * lvl);
ensurePlaying(elHi, urlHi, g.to * lvl);
ensurePlaying(elLo, urlLo, plan.lo);
ensurePlaying(elHi, urlHi, plan.hi);
updateAudioStatus();
}
// At rest on a single altitude: the element holding it plays at the master level, the
// other fades out. Level 0 → both fade to silence.
// At rest on a single altitude. Silent → both fade out. Music → the single loop at
// the master volume. Soundtrack → the element holding this altitude plays at the
// master volume (frac 0 → lo = level), the other fades out.
function restAudio(index) {
if (audioLevel() === 0) {
fadeVolume(aud, 0, FADE_MS, () => aud.pause());
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
return;
}
const plan = HEFScrub.audioPlan(audioSource(), audioVol(), 0);
if (plan.mode === "silent") { silenceAll(); return; }
if (plan.mode === "music") { startMusic(plan.vol); return; }
stopMusic(); // soundtrack → ensure the music rotation is stopped
const url = scaleAudioUrl(index);
const active = (audB.dataset.url === url) ? audB : aud;
const idle = active === audB ? aud : audB;
ensurePlaying(active, url, audioVol());
ensurePlaying(active, url, plan.lo);
fadeVolume(idle, 0, FADE_MS);
updateAudioStatus();
}
@@ -1306,19 +1668,85 @@ function restAudio(index) {
// synchronously from the toggle gesture the first time it plays (Safari unlock).
function applyAudio() { restAudio(ringIndex); }
// "Loading Universe…" splash — hidden once media is preloaded; reflects progress.
// "Loading Universe…" progress (lives in the welcome screen) — reflects preload.
function setLoadingProgress(done, total) {
const fill = document.getElementById("loading-fill");
if (fill && total) fill.style.width = Math.round((done / total) * 100) + "%";
}
function hideLoading() {
const el = document.getElementById("loading");
let videoEverOn = false; // has Video ever been switched on this session?
let universeReady = false; // has phase-1 preload finished (safe to enter)?
let launchPending = false; // did the visitor press Launch while still loading?
// Begin the experience: mark video-on, play the soundtrack at the chosen level,
// and start the resting loop at the chosen speed. Called from the Launch gesture
// so the audio play() unlocks on Safari (which blocks play() outside a user
// gesture). Idempotent via videoEverOn.
function beginExperience() {
videoEverOn = true;
updateAudioLevelLabel();
applyAudio();
applyPlaySpeed();
}
// Welcome screen ----------------------------------------------------------------
// The welcome controls mirror the panel controls. Speed and Language drive shared
// state live (see initPlaySpeed / setLanguage). Video is assumed ON at entry; the
// Audio level is copied into the panel input.
function applyWelcomeToPanel() {
$("visual").checked = true; // the welcome screen assumes Video on (no toggle)
const wa = $("welcome-audio");
if (wa) { $("audio").value = wa.value; updateAudioLevelLabel(); }
const ws = $("welcome-audio-source"); // carry the chosen source into the panel dropdown
if (ws) { $("audio-source").value = ws.value; syncVolumeDisabled(); }
}
function updateWelcomeAudioLabel() {
const el = $("welcome-audio-val");
if (el) el.textContent = String(+($("welcome-audio") || {}).value || 0);
}
// Volume is meaningless on None — grey the welcome slider too, mirroring the panel.
function syncWelcomeVolumeDisabled() {
const src = $("welcome-audio-source"), slider = $("welcome-audio");
if (src && slider) slider.disabled = src.value === "none";
}
// Remove the welcome overlay (fade out) — the panel reveals via `body:has(#welcome)`.
function enterSimulation() {
applyWelcomeToPanel(); // pick up any last-moment Video/Audio change made in State B
beginExperience();
update(); // render with the chosen Video/Audio state
const el = $("welcome");
if (!el) return;
el.classList.add("done");
setTimeout(() => el.remove(), 700);
}
let videoEverOn = false; // has Video ever been switched on this session?
// Launch button: the single entry gesture. If the universe is ready, go straight
// in; otherwise switch the welcome screen to its centered-loading state and enter
// automatically once phase-1 finishes. beginExperience() runs in THIS gesture so
// audio unlocks on Safari even when entry is deferred.
function onLaunch() {
applyWelcomeToPanel();
beginExperience();
if (universeReady) {
enterSimulation();
} else {
launchPending = true;
const w = $("welcome");
if (w) w.classList.replace("welcoming", "loading");
}
}
function initWelcome() {
updateWelcomeAudioLabel();
const wa = $("welcome-audio");
if (wa) wa.addEventListener("input", updateWelcomeAudioLabel);
const ws = $("welcome-audio-source");
if (ws) ws.addEventListener("change", syncWelcomeVolumeDisabled);
syncWelcomeVolumeDisabled();
const launch = $("welcome-launch");
if (launch) launch.addEventListener("click", onLaunch);
}
async function main() {
devLiveReload();
@@ -1327,7 +1755,10 @@ async function main() {
await landScale(); // pick the initial scale's pool member before first render
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
initLanguage(); // populate both language dropdowns + wire live switching
initPlaySpeed(); // playback-speed sliders (welcome + panel)
initReduceMotion(); // reduce-motion toggles (welcome + panel) — morph transitions off
initWelcome(); // wire the welcome-screen controls + Launch button
renderScaleReadout();
// Sliders stream on "input"; the toggles fire on "change".
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
@@ -1336,14 +1767,18 @@ async function main() {
// play() outside a user gesture).
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
updateAudioLevelLabel();
// Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one
// flip = the full experience at a gentle level). If audio is already raised, leave it.
// Set + played in THIS gesture so it unlocks on Safari.
// Audio source dropdown: reconcile SYNCHRONOUSLY in this change gesture so
// switching to Soundtrack/Music unlocks + plays on Safari; None greys Volume.
$("audio-source").addEventListener("change", () => { syncVolumeDisabled(); applyAudio(); debounced(); });
applyUrlAudioParams(); // shareable ?audio=/?vol= deep-link presets
syncVolumeDisabled();
syncWelcomeVolumeDisabled(); // reflect a URL-set source on the welcome slider too
updateWelcomeAudioLabel();
// Video toggle (panel): the FIRST time video turns on without having entered via
// Launch, begin the experience too — couple audio in. (Normally Launch entered
// first, so videoEverOn is already true and this is a no-op beyond re-render.)
$("visual").addEventListener("change", () => {
if ($("visual").checked && !videoEverOn) {
videoEverOn = true;
if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); }
}
if ($("visual").checked && !videoEverOn) beginExperience();
debounced();
});
// Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
@@ -1351,32 +1786,47 @@ async function main() {
window.addEventListener("pointermove", onDialMove);
window.addEventListener("pointerup", onDialUp);
dial.addEventListener("wheel", onWheel, { passive: false });
dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels
$("stage").addEventListener("wheel", onWheel, { passive: false });
update(); // render the initial state (both toggles off → black, silent)
await preloadAllMedia(); // download all media, updating the loading bar
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
// Phase 1 — gate the universe on a minimal, fully-loaded set: one clip per altitude
// + the morphs connecting them (~126 MB). Fast to start; the knob is smooth from the
// first turn because the random pick only ever lands on loaded clips (eligibility).
await cacheMany(HEFPreload.phase1Files(ring.scales, _preloadDeps()));
universeReady = true; // safe to enter — the welcome "Loading Universe" bar is full
window.__hefReady = true; // diagnostic seam: e2e waits on this before pressing Launch
// If the visitor already pressed Launch (and is watching the centered loader),
// enter now that the universe is ready.
if (launchPending) enterSimulation();
// Phase 2 — grow the pool in the background: more clips per altitude + their morphs.
// Each becomes eligible for the random pick only once fully loaded, so navigation
// keeps using the already-loaded clips until the new ones are ready (no stall).
preloadAllMedia(); // NO await — streams the rest behind the unlocked universe
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
// outside a user gesture (muted video survives, sound does not), so an auto-start
// would show video but stay silent. Instead the experience waits for the operator to
// turn Video on — that click IS the gesture, and its handler (above) lifts audio to
// 3/10 and starts the soundtrack in-gesture, so sound reliably comes up with video.
// outside a user gesture (muted video survives, sound does not). The Launch click IS
// the gesture — beginExperience() plays the soundtrack in-gesture, so sound reliably
// comes up with video whether entry is immediate or deferred until media is ready.
}
// Populate the language dropdown from the registry and wire live switching.
// English default, session-only (no persistence). Switching swaps UI chrome
// and re-renders the current annotations/affect in place — no reload, no fetch.
// Two selects share the same language state: the welcome screen's and the panel's.
const LANG_SELECTS = ["welcome-lang", "lang-select"];
function initLanguage() {
const sel = $("lang-select");
if (!sel) return;
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
const o = document.createElement("option");
o.value = code;
o.textContent = nativeName;
sel.appendChild(o);
for (const id of LANG_SELECTS) {
const sel = $(id);
if (!sel) continue;
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
const o = document.createElement("option");
o.value = code;
o.textContent = nativeName;
sel.appendChild(o);
}
sel.value = activeLang;
sel.addEventListener("change", () => setLanguage(sel.value));
}
sel.value = activeLang;
applyUiStrings(activeLang);
sel.addEventListener("change", () => setLanguage(sel.value));
}
// Fill every [data-i18n] node with its catalog string for `lang`.
@@ -1389,6 +1839,7 @@ function applyUiStrings(lang) {
function setLanguage(lang) {
activeLang = lang;
for (const id of LANG_SELECTS) { const s = $(id); if (s && s.value !== lang) s.value = lang; }
applyUiStrings(lang);
renderScaleReadout();
if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
@@ -1396,3 +1847,31 @@ function setLanguage(lang) {
}
main();
// Temporary ?debug overlay: an on-screen readout of media/render state, for
// diagnosing real-browser playback that headless can't reproduce. Add ?debug to the
// URL to show it. Harmless/no-op without the flag; safe to remove once resolved.
(function debugOverlay() {
if (!/[?&]debug\b/.test(location.search)) return;
const box = document.createElement("div");
box.style.cssText = "position:fixed;left:6px;bottom:6px;z-index:99999;max-width:96vw;" +
"background:rgba(0,0,0,.82);color:#0f0;font:11px/1.35 monospace;padding:8px 10px;" +
"white-space:pre-wrap;border:1px solid #0a0;border-radius:4px;";
document.body.appendChild(box);
let webgl = "?";
try { const c = document.createElement("canvas"); webgl = c.getContext("webgl2") ? "webgl2" : (c.getContext("webgl") ? "webgl1" : "NONE"); }
catch (e) { webgl = "throw:" + e.message; }
const errs = [];
window.addEventListener("error", (e) => { errs.push((e.message || e.error || "err").toString().slice(0, 80)); });
const m = (el) => el ? `rs=${el.readyState} ${el.paused ? "PAUSED" : "play"} t=${(el.currentTime || 0).toFixed(1)} err=${el.error ? el.error.code : "-"} ${(el.currentSrc || el.src || "(no src)").slice(0, 46)}` : "absent";
setInterval(() => {
const cfg = window.HEF_CONFIG || {};
box.textContent =
`cfg: static=${cfg.static} base=${cfg.mediaBase}\n` +
`webgl=${webgl} visual=${(document.getElementById("visual") || {}).checked}\n` +
`loop : ${m(document.getElementById("vid-loop"))}\n` +
`morph: ${m(document.getElementById("vid"))}\n` +
`aud : ${m(document.getElementById("aud"))}\n` +
`errs : ${errs.slice(-3).join(" | ") || "none"}`;
}, 400);
})();
+2 -2
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Label Author Mode</title>
<title>Human/Machine Strata — Label Author Mode</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/author.css" />
</head>
<body class="author">
<header><h1>HEF — Label Author Mode</h1>
<header><h1>Human/Machine Strata — Label Author Mode</h1>
<p class="hint">Scrub a pool clip · drag a box on a subject · run the tracker · author the tiers · save to the manifest. Geometry is propagated; <strong>semantics are hand-authored</strong> (content-pipeline §11.5). <a href="/">← back to preview</a></p>
</header>
<main>
+4
View File
@@ -0,0 +1,4 @@
// Runtime config. This dev default serves media from the local FastAPI /media
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Credits — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
<main class="credits-wrap">
<header>
<h1>Credits &amp; licenses</h1>
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
</header>
<!-- Option D1: the altered work's own license + the "modified" statement that
CC BY / CC BY-SA attribution requires. Heading deliberately NOT "About" — the
separate About page covers the project's intent. -->
<section class="declaration">
<h2>License &amp; reuse</h2>
<p>
<em>Human/Machine Strata</em> alters its source footage in real time
(overlays, an emotion/affect channel, and a WebGL “dream” shader), so every
frame you see is a <strong>derivative</strong> of the original clip.
</p>
<p>
This altered work is offered under
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="license">Creative
Commons Attribution-ShareAlike 4.0 (CC BY-SA 4.0)</a>. The original sources it
builds on are credited below. This is a non-commercial art installation.
</p>
</section>
<section>
<h2>Footage</h2>
<p class="note">
Original video sources and their licenses. Public-domain (US-government) and
CC0 clips need no attribution; the names below for CC BY / CC BY-SA clips are
required credit. All footage is altered as described above.
</p>
<div id="video-credits" class="credits-list">
<p class="loading">Loading credits…</p>
</div>
</section>
<!-- Audio is all public-domain / CC0, so attribution is courtesy, not required;
it isn't tracked per-clip in the API, so it's acknowledged here as a block. -->
<section>
<h2>Sound</h2>
<p class="note">
Per-altitude soundtracks are public domain or CC0 — credit is courtesy. The
<em>Music</em> option is Creative Commons <strong>BY</strong> — attribution required, given below.
</p>
<ul class="audio-credits">
<li><strong>NASA / Chandra X-ray Observatory (CXC/SAO)</strong> — cosmos sonification (public domain).</li>
<li><strong>NOAA</strong> SanctSound &amp; PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li>
<li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li>
<li><strong>BigSoundBank</strong> — sea waves &amp; gull calls (CC0).</li>
<li><strong>Kevin MacLeod</strong> (<a href="https://incompetech.com" rel="noopener">incompetech.com</a>) — the
<em>Music</em> option: “Music for Manatees”, “Dewdrop Fantasy”, “Eastminster”, “Overheat”, and
“Concentration”. Licensed under
<a href="https://creativecommons.org/licenses/by/4.0/" rel="license">Creative Commons: By Attribution 4.0</a>.</li>
</ul>
</section>
</main>
<script src="config.js"></script>
<script src="credits.js"></script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
// Credits/colophon data + render — no framework. UMD so the browser gets a
// `HEFCredits` global and `node --test` can require() the pure functions.
//
// The legally load-bearing attribution is per VIDEO clip (CC BY / CC BY-SA need
// the author + license shown to the viewer, and the footage is altered → a
// derivative). That data already ships in clips.json (baked from /api/clips):
// each clip carries free-text `license` + `source`. This module turns that into
// the credits list the public colophon (credits.html) renders. Audio is all
// PD/CC0 and credited as a static courtesy block in the page itself.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFCredits = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// One attribution entry per clip, sorted by id (deterministic). A clip is
// NEVER silently dropped — even one with an empty license appears, so a
// missing credit is visible rather than hidden. License/source are the
// free-text strings authored in the manifest.
function creditEntries(clips) {
return (clips || [])
.map((c) => ({
id: c.id,
title: c.title || c.id,
license: c.license || "",
source: c.source || "",
}))
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
}
function creditHtml(e) {
const lic = e.license
? `<span class="lic">${escapeHtml(e.license)}</span>`
: `<span class="lic missing">license not recorded</span>`;
const src = e.source ? `<div class="src">${escapeHtml(e.source)}</div>` : "";
return (
`<div class="credit">` +
`<div class="title">${escapeHtml(e.title)}</div>` +
lic + src +
`</div>`
);
}
function creditsListHtml(clips) {
return creditEntries(clips).map(creditHtml).join("");
}
// Browser bootstrap: fetch the same clips the experience uses (baked
// clips.json in the static build, the live API otherwise — the config.js
// flag, loaded before this script, decides) and fill the container.
function init(doc, cfg) {
const fetchUrl = cfg && cfg.static ? "clips.json" : "/api/clips";
const box = doc.getElementById("video-credits");
if (!box) return;
return fetch(fetchUrl)
.then((r) => r.json())
.then((d) => { box.innerHTML = creditsListHtml(d.clips || []); })
.catch((err) => { box.innerHTML = `<p class="err">Could not load credits: ${escapeHtml(err.message)}</p>`; });
}
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", () => {
init(document, (typeof window !== "undefined" && window.HEF_CONFIG) || {});
});
}
return { escapeHtml, creditEntries, creditHtml, creditsListHtml, init };
});
+20
View File
@@ -0,0 +1,20 @@
// Pure photosensitivity helper. WCAG 2.3.1: no more than 3 general flashes
// (luminance transitions) per second. Given a transition COUNT, returns the
// minimum total duration that keeps the rate at or below 3/sec, and a clamp
// that only ever slows a transition down. UMD: browser `HEFFlash` + require().
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFFlash = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const MAX_PER_SEC = 3;
function minSafeDurationMs(count) {
const n = Math.max(0, Number(count) || 0);
return (n / MAX_PER_SEC) * 1000;
}
function clampDurationMs(requestedMs, count) {
return Math.max(Number(requestedMs) || 0, minSafeDurationMs(count));
}
return { MAX_PER_SEC, minSafeDurationMs, clampDurationMs };
});
+16
View File
@@ -0,0 +1,16 @@
const test = require("node:test");
const assert = require("node:assert");
const F = require("./flash.js");
test("minSafeDurationMs: N transitions need N/3 seconds", () => {
assert.strictEqual(F.minSafeDurationMs(3), 1000); // 3 flashes in >=1s
assert.strictEqual(F.minSafeDurationMs(6), 2000);
assert.strictEqual(F.minSafeDurationMs(0), 0);
assert.strictEqual(F.minSafeDurationMs(1), 1000 / 3);
});
test("clampDurationMs: stretches only when too fast", () => {
assert.strictEqual(F.clampDurationMs(2000, 3), 2000); // already safe
assert.strictEqual(F.clampDurationMs(200, 3), 1000); // too fast -> clamped up
assert.strictEqual(F.clampDurationMs(500, 1), 500); // single transition, slow enough
});
+14 -3
View File
@@ -17,18 +17,29 @@
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
const UI_STRINGS = {
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"app.title": { en: "Human/Machine Strata", es: "Human/Machine Strata", fr: "Human/Machine Strata", ja: "Human/Machine Strata" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", ja: "宇宙を読み込み中" },
"welcome.launch": { en: "Launch Simulator", es: "Iniciar simulador", fr: "Lancer le simulateur", ja: "シミュレーターを起動" },
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
"output.audio.none": { en: "None", es: "Ninguno", fr: "Aucun", ja: "なし" },
"output.audio.soundtrack": { en: "Soundtrack", es: "Banda sonora", fr: "Bande-son", ja: "サウンドトラック" },
"output.audio.music": { en: "Music", es: "Música", fr: "Musique", ja: "音楽" },
"output.volume": { en: "Volume", es: "Volumen", fr: "Volume", ja: "音量" },
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer daltitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動" },
"knobs.legend": { en: "Experience knobs (04)", es: "Mandos de experiencia (04)", fr: "Boutons dexpérience (04)", ja: "体験つまみ0〜4" },
"altitude.autoadvance": { en: "Auto-advance", es: "Avance automático", fr: "Avance automatique", ja: "自動で移動" },
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons dexpérience", ja: "体験つまみ" },
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
"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: "開発モード" },
"speed.label": { en: "Video speed", es: "Velocidad de vídeo", fr: "Vitesse vidéo", ja: "映像速度" },
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
"warn.title": { en: "Heads up — motion & flashing", es: "Atención — movimiento y destellos", fr: "Attention — mouvement et flashs", ja: "ご注意 — 動きと点滅" },
"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” below before you launch — altitude changes will cut instantly instead of animating.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa «Reducir movimiento» abajo antes de iniciar — los cambios de altitud serán cortes instantáneos en vez de animarse.", fr: "Cette expérience contient des mouvements continus, des flashs et des images changeantes. Si vous êtes sensible au mouvement ou aux lumières clignotantes, activez « Réduire les animations » ci-dessous avant de lancer — les changements daltitude seront des coupes instantanées au lieu de sanimer.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください — 高度の変化はアニメーションせず瞬時に切り替わります。" },
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
"scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
+106 -32
View File
@@ -3,60 +3,130 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<link rel="stylesheet" href="/style.css" />
<title>Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="loading">
<div class="loading-inner">
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
<div class="loading-bar"><div id="loading-fill"></div></div>
<!-- Welcome screen: the universal entry gate. State A (.welcoming) shows the
heads-up + controls + Launch button with the loader at the bottom; State B
(.loading, after Launch while media is still loading) hides the messaging +
button, keeps the controls, and centers the loader. Removed on entry. -->
<div id="welcome" class="welcoming" role="dialog" aria-modal="true" aria-labelledby="welcome-title">
<div class="welcome-inner">
<div class="welcome-message">
<h2 id="welcome-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch — altitude changes will cut instantly instead of animating.</p>
</div>
<div class="welcome-controls">
<label class="lang-pick">🌐
<select id="welcome-lang" aria-label="Language"></select>
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
<input type="range" id="welcome-play-speed" min="0" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
<span id="welcome-play-speed-val">1.00×</span>
</label>
<label class="audio-source"><span data-i18n="output.audio">Audio</span>
<select id="welcome-audio-source" aria-label="Audio source">
<option value="none" data-i18n="output.audio.none">None</option>
<option value="soundtrack" data-i18n="output.audio.soundtrack" selected>Soundtrack</option>
<option value="music" data-i18n="output.audio.music">Music</option>
</select>
</label>
<label class="audio-level"><span data-i18n="output.volume">Volume</span>
<input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" />
<span id="welcome-audio-val">2</span>/10
</label>
<label class="dev-switch" for="welcome-reduce-motion">
<input type="checkbox" id="welcome-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>
</div>
<button type="button" id="welcome-launch" class="run-sim" data-i18n="welcome.launch">Launch Simulator</button>
<div class="welcome-loading">
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
<div class="loading-bar"><div id="loading-fill"></div></div>
</div>
</div>
</div>
<header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
<header>
<h1 data-i18n="app.title">Human/Machine Strata</h1>
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits &amp; licenses</a>
</header>
<main>
<section class="stage" id="stage">
<div class="screen">
<video id="vid" loop muted playsinline></video>
<video id="vid-loop" loop muted playsinline></video>
<audio id="aud" loop preload="auto"></audio>
<audio id="aud-b" loop preload="auto"></audio>
<canvas id="paint"></canvas>
<div id="tint"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
<video id="vid" loop muted playsinline crossorigin="anonymous"></video>
<video id="vid-loop" loop muted playsinline crossorigin="anonymous"></video>
<audio id="aud" loop preload="auto" crossorigin="anonymous"></audio>
<audio id="aud-b" loop preload="auto" crossorigin="anonymous"></audio>
<canvas id="paint" aria-hidden="true"></canvas>
<div id="tint" aria-hidden="true"></div>
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
<div id="black" class="black hidden"></div>
</div>
</section>
<section class="panel">
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
<fieldset>
<legend data-i18n="altitude.legend">Altitude</legend>
<div class="dial-wrap">
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn or use arrow keys to change scale"
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
</div>
<span id="scale-name" class="scale-name"></span>
<label class="dev-switch" for="auto-advance">
<input type="checkbox" id="auto-advance" checked />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="altitude.autoadvance">Auto-advance</span>
</label>
</fieldset>
<fieldset>
<legend data-i18n="output.legend">Output</legend>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
</label>
<label class="dev-switch" for="visual">
<input type="checkbox" id="visual" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="output.video">Video</span>
</label>
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
<label class="audio-source"><span data-i18n="output.audio">Audio</span>
<select id="audio-source" aria-label="Audio source">
<option value="none" data-i18n="output.audio.none">None</option>
<option value="soundtrack" data-i18n="output.audio.soundtrack" selected>Soundtrack</option>
<option value="music" data-i18n="output.audio.music">Music</option>
</select>
</label>
<label class="audio-level"><span data-i18n="output.volume">Volume</span>
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
<input type="range" id="play-speed" min="0" max="2" 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.25"></option><option value="0.5"></option>
<option value="0.75"></option><option value="1"></option><option value="1.25"></option>
<option value="1.5"></option><option value="1.75"></option><option value="2"></option>
</datalist>
<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>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
</label>
</fieldset>
<fieldset>
<legend data-i18n="altitude.legend">Altitude</legend>
<div class="dial-wrap">
<svg id="dial" viewBox="0 0 100 100" aria-label="Altitude knob (turn to change scale)"></svg>
</div>
<span id="scale-name" class="scale-name"></span>
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
</fieldset>
<fieldset>
<legend data-i18n="knobs.legend">Experience knobs (04)</legend>
<legend data-i18n="knobs.legend">Experience knobs</legend>
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
@@ -107,8 +177,12 @@
</div>
</section>
</main>
<script src="/scrub.js"></script>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
<script src="config.js"></script>
<script src="scrub.js"></script>
<script src="alteration.js"></script>
<script src="preload.js"></script>
<script src="flash.js"></script>
<script src="i18n.js"></script>
<script src="app.js"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
// Pure preload planning — no DOM. The boot caches phase1Files() before unlocking the
// universe; the random clip pick is then restricted to eligible clips (fully loaded
// base + connecting morphs), so a transition never stalls on un-downloaded media and
// the pool grows safely as the background preload finishes. UMD so the browser gets
// `HEFPreload` and `node --test` can require() it.
//
// deps shape: { baseOf(clipId)->file, morphFile(fromId,toId)->file|null, isCached(file)->bool }
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFPreload = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
// The minimal set to load before the simulator can run: the FIRST pool member of
// every altitude + the morphs connecting adjacent first-members (both directions,
// cyclic ring). One clip per altitude + corresponding morphs.
function phase1Files(scales, deps) {
const n = scales.length;
const chosen = scales.map((s) => (s.pool && s.pool.length ? s.pool[0].clip_id : s.clip_id));
const files = new Set();
for (const c of chosen) {
const bf = deps.baseOf(c);
if (bf) files.add(bf);
}
for (let i = 0; i < n; i++) {
const a = chosen[i], b = chosen[(i + 1) % n];
for (const f of [deps.morphFile(a, b), deps.morphFile(b, a)]) if (f) files.add(f);
}
return [...files];
}
// Pool members eligible as a DESTINATION from `fromId`: base cached AND the morph
// cached BOTH ways (so the forward transition and a turn-back are ready).
function eligibleDestinations(poolIds, fromId, deps) {
return poolIds.filter((c) =>
deps.isCached(deps.baseOf(c)) &&
deps.isCached(deps.morphFile(fromId, c)) &&
deps.isCached(deps.morphFile(c, fromId)));
}
// Pool members eligible to SHOW at their own altitude (no transition): base cached.
function eligibleMembers(poolIds, deps) {
return poolIds.filter((c) => deps.isCached(deps.baseOf(c)));
}
function pick(list, rnd) {
return list.length ? list[Math.floor(rnd() * list.length)] : null;
}
return { phase1Files, eligibleDestinations, eligibleMembers, pick };
});
+26 -1
View File
@@ -29,6 +29,31 @@
return { from: 1 - f, to: f };
}
// Pure audio-source routing: given the Audio dropdown source ("none" |
// "soundtrack" | "music"), the master volume `level` (0..1), and the altitude
// crossfade fraction `frac`, decide what the audio layer should do. app.js's
// restAudio/blendAudio consult this for the mode branch; the physical <audio>
// element assignment (which element carries which url) stays in app.js.
// - silent: play nothing (None, or level 0 — a hard mute in every mode)
// - music : ONE continuous track at the master level, altitude-independent
// - blend : the per-altitude soundtrack crossfade (lo = frac 0 scale, hi = next)
function audioPlan(source, level, frac) {
if (!(level > 0) || source === "none") return { mode: "silent" };
if (source === "music") return { mode: "music", vol: level };
const g = crossfadeGains(frac);
return { mode: "blend", lo: g.from * level, hi: g.to * level };
}
// Pick the next track in a rotating playlist: a random index in [0,count) that is
// NEVER `current` (so a rotation always changes track), given r in [0,1). Maps r
// onto the (count-1) other slots. count<=1 stays on 0. Pure so the shuffle is
// node-testable; the caller passes Math.random().
function pickNextIndex(current, count, r) {
if (count <= 1) return 0;
const k = Math.min(count - 2, Math.floor(clamp01(r) * (count - 1)));
return k >= current ? k + 1 : k;
}
// The base-loop frame a landing must continue from so the steady loop picks up
// the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0)
// -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands
@@ -53,5 +78,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, pickNextIndex, loopLandFrame, integerCrossings };
});
+98 -12
View File
@@ -1,7 +1,32 @@
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
header { padding: 0.6rem 1rem; background: #000; }
header { padding: 0.6rem 1rem; background: #000; display: flex; align-items: baseline;
justify-content: space-between; gap: 1rem; }
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
/* Always-visible link to the credits/colophon — reachable from the work, which is
what CC BY / BY-SA attribution "reasonable to the medium" calls for. */
.credits-link { color: #9af; text-decoration: none; font-size: 12px; white-space: nowrap; flex: none; }
.credits-link:hover { text-decoration: underline; }
/* --- Credits / colophon page (credits.html) --- */
.credits-page { background: #0c0f14; }
.credits-wrap { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
.credits-wrap h1 { font-size: 1.6rem; }
.credits-wrap h2 { font-size: 1.05rem; color: #9af; margin: 1.8rem 0 0.5rem; }
.credits-wrap a { color: #9af; }
.credits-wrap .back-link { font-size: 13px; }
.credits-wrap .note { color: #9ab; font-size: 13px; }
.declaration { background: #131a24; border: 1px solid #233; border-radius: 6px;
padding: 0.8rem 1rem; margin-top: 1rem; }
.credits-list { display: grid; gap: 0.7rem; margin-top: 0.8rem; }
.credit { background: #131820; border: 1px solid #1f2a3a; border-radius: 5px; padding: 0.6rem 0.8rem; }
.credit .title { font-weight: 600; color: #dfe7f2; }
.credit .lic { display: inline-block; margin-top: 0.2rem; font-size: 12px; color: #cbd; }
.credit .lic.missing { color: #f97; }
.credit .src { margin-top: 0.3rem; font-size: 12px; color: #8aa; line-height: 1.5; }
.audio-credits { color: #b9c4d2; font-size: 13px; line-height: 1.6; padding-left: 1.2rem; }
.credits-list .loading, .credits-list .err { color: #9ab; }
.credits-list .err { color: #f97; }
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: flex-start; }
/* Stage stays pinned in view while the right pane scrolls on its own. */
.stage { flex: 1 1 640px; position: sticky; top: 1rem; }
@@ -51,15 +76,33 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
.black { position: absolute; inset: 0; background: #000; opacity: 1;
z-index: 50; transform: translateZ(0); transition: opacity 200ms ease; }
.hidden { display: none; }
/* "Launch Simulator" — the single entry button on the welcome screen. Sits in
the welcome overlay's flow (centered column), not absolutely positioned. */
.run-sim {
cursor: pointer; user-select: none;
padding: 0.85rem 2rem; border: 0; border-radius: 999px;
font: 600 18px/1 system-ui, sans-serif; letter-spacing: 0.03em;
color: #04101f; background: linear-gradient(90deg, #4e9cff, #9af);
box-shadow: 0 4px 24px rgba(78, 156, 255, 0.45);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.run-sim:hover { transform: scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
.run-sim:active { transform: scale(0.98); }
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
.dev-switch + .dev-switch { margin-top: 0.45rem; }
/* Globe + language select on one row (the select no longer goes full-width here). */
.lang-pick { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0 0.2rem; }
.lang-pick select { flex: 1; width: auto; }
/* Live audio status readout (diagnostic) — turns green when actually playing. */
.audio-status { margin-top: 0.5rem; font: 11px/1.4 monospace; color: #9ab; }
.audio-status.playing { color: #4e9; }
.audio-status.blocked { color: #f97; }
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem;
.panel { flex: 0 0 300px; display: flex; flex-direction: column; gap: 0.8rem;
max-height: calc(100vh - 2rem); overflow-y: auto;
position: sticky; top: 1rem; }
/* The control panel stays hidden behind the welcome screen and appears only once
the welcome overlay is removed on entry. */
body:has(#welcome) .panel { display: none; }
fieldset { border: 1px solid #333; border-radius: 6px; }
legend { color: #9af; padding: 0 0.4rem; }
label { display: block; margin: 0.4rem 0; }
@@ -73,17 +116,25 @@ input[type=range], select { width: 100%; }
.dial-rim { fill: #0d1320; stroke: #243352; stroke-width: 1.2; }
.dial-body { fill: #16203200; stroke: #2c3c5c; stroke-width: 1; }
.dial-tick { stroke: #3a4d70; stroke-width: 0.8; }
.dial-label { fill: #789ac0; font-size: 6px; font-family: ui-monospace, monospace;
.dial-label { fill: #b8cfe6; font-size: 6px; font-family: ui-monospace, monospace;
letter-spacing: 0.2px; cursor: pointer; }
.dial-label:hover { fill: #cde; }
.dial-label.active { fill: #9cf; font-weight: 700; }
.dial-caption { fill: #4d6184; font-size: 4.4px; letter-spacing: 1.2px;
.dial-caption { fill: #8fa6c4; font-size: 4.4px; letter-spacing: 1.2px;
font-family: ui-monospace, monospace; }
.dial-needle { fill: #9cf; }
.dial-needle polygon { filter: drop-shadow(0 0 1px #9cf); }
.dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; }
.scale-name { display: block; text-align: center; font-size: 12px; color: #cde; }
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #9fb3c8; }
/* Visible keyboard focus for every interactive element (was only on .dev-switch). */
:focus-visible { outline: 2px solid #9af; outline-offset: 2px; }
#dial:focus-visible { outline-offset: 4px; border-radius: 50%; }
/* Screen-reader-only text (announcements, labels): present in the a11y tree, off-screen. */
.visually-hidden {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* --- Dev Mode --- a single switch; all dev controls/data live in #dev-panel below it. */
.dev-switch { display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
@@ -100,8 +151,15 @@ input[type=range], select { width: 100%; }
/* Audio level dial (010): label · slider · value on one row, matching the switch look. */
.audio-level { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.45rem;
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
.audio-level input[type=range] { flex: 1; width: auto; }
/* Keep the control label on one line ("Video speed" was wrapping); let the range
shrink (min-width:0) instead of pushing the label to a second row. */
.audio-level > span:first-child { white-space: nowrap; }
.audio-level input[type=range] { flex: 1; width: auto; min-width: 0; }
#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, #welcome-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. */
@@ -128,17 +186,45 @@ input[type=range], select { width: 100%; }
.dev-anno .anno-meta { color: #678; }
.dev-anno .anno-empty { color: #567; font-style: italic; }
/* "Loading Universe…" splash — shown until all media is preloaded and the
experience is ready to run smoothly, then faded out. */
#loading {
/* Welcome screen — the universal entry gate (fixed, above everything). Holds the
heads-up notice, the four output controls, the Launch button, and the
"Loading Universe" progress, which fills as media preloads in the background. */
#welcome {
position: fixed; inset: 0; z-index: 10000;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse at center, #0a1230 0%, #02030a 70%);
color: #cfe3ff; user-select: none;
overflow-y: auto;
transition: opacity 0.6s ease;
}
#loading.done { opacity: 0; pointer-events: none; }
.loading-inner { display: flex; flex-direction: column; align-items: center; gap: 1.1rem; }
#welcome.done { opacity: 0; pointer-events: none; }
.welcome-inner {
min-height: 100%; box-sizing: border-box;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1.4rem; padding: 2.5rem 1.25rem 5.5rem;
}
.welcome-message { max-width: 32rem; text-align: center; }
.welcome-message h2 { font-size: 1.25rem; margin: 0 0 0.6rem; color: #dfeaff; }
.welcome-message p { font-size: 0.98rem; line-height: 1.55; margin: 0; color: #c3d2e8; }
/* The four output controls, boxed for legibility over the dark overlay. Spacing
comes from the column gap, so reset the controls' own in-panel margins. */
.welcome-controls {
display: flex; flex-direction: column; gap: 0.6rem;
width: 100%; max-width: 18rem;
padding: 1rem 1.1rem; border: 1px solid #2a3550; border-radius: 8px;
background: rgba(10, 18, 48, 0.5);
}
.welcome-controls .dev-switch,
.welcome-controls .audio-level,
.welcome-controls .lang-pick { margin: 0; }
/* "Loading Universe" — pinned to the bottom in State A (.welcoming); recentered in
the column flow in State B (.loading), once messaging + button are hidden. */
.welcome-loading {
position: absolute; left: 0; right: 0; bottom: 2rem;
display: flex; flex-direction: column; align-items: center; gap: 1rem;
}
#welcome.loading .welcome-message,
#welcome.loading #welcome-launch { display: none; }
#welcome.loading .welcome-loading { position: static; }
.loading-title { font: 600 30px/1.2 system-ui, sans-serif; letter-spacing: 0.04em; }
.loading-dots::after {
content: ""; animation: loading-dots 1.4s steps(4, end) infinite;
+51
View File
@@ -0,0 +1,51 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const A = require("../static/alteration.js");
const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o });
test("identity at zero knobs", () => {
const p = A.plan(c({}));
assert.equal(p.grade.tone, 0);
assert.equal(p.overlay.level, 0);
assert.equal(p.dream.strength, 0);
assert.equal(p.is_identity, true);
});
test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => {
assert.equal(A.plan(c({ light: 4 })).grade.tone, 1); // full light
assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1); // full dark
assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5);
});
test("left drives overlay level + intensity; right drives dream + affect", () => {
const p = A.plan(c({ left: 2, right: 4 }));
assert.equal(p.overlay.level, 2);
assert.equal(p.overlay.intensity, 0.5);
assert.equal(p.dream.strength, 4);
assert.equal(p.dream.intensity, 1);
assert.equal(p.affect.strength, 4);
assert.equal(p.affect.intensity, 1);
assert.equal(p.is_identity, false);
});
test("renderAudio: off → null, no coupling", () => {
assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"),
{ source: "off", url: null, altitude_coupled: false });
});
test("renderAudio: soundtrack → mediaBase audio url, coupled", () => {
assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"),
{ source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true });
// no scale audio → null url but still coupled
assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"),
{ source: "soundtrack", url: null, altitude_coupled: true });
});
test("alteration() composes plan + render with video.shown from visual", () => {
const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/");
assert.equal(r.render.video.shown, false);
assert.equal(r.render.audio.url, "https://x/audio/reef.mp3");
assert.equal(r.plan.overlay.level, 1);
});
+56
View File
@@ -0,0 +1,56 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const S = require("../static/scrub.js");
// audioPlan decides, purely, what the audio layer should do for a given source
// mode + master level + crossfade fraction. app.js's restAudio/blendAudio consult
// it for the mode branch; the physical <audio> element assignment stays in app.js.
test("audioPlan: None source is silent regardless of level", () => {
assert.deepEqual(S.audioPlan("none", 1, 0.5), { mode: "silent" });
});
test("audioPlan: level 0 is silent even for soundtrack/music", () => {
assert.deepEqual(S.audioPlan("soundtrack", 0, 0.5), { mode: "silent" });
assert.deepEqual(S.audioPlan("music", 0, 0), { mode: "silent" });
});
test("audioPlan: Music plays a single track at the master level, no blend", () => {
assert.deepEqual(S.audioPlan("music", 0.8, 0.5), { mode: "music", vol: 0.8 });
});
test("audioPlan: Music ignores altitude fraction (constant across the dial)", () => {
const a = S.audioPlan("music", 0.6, 0);
const b = S.audioPlan("music", 0.6, 0.9);
assert.deepEqual(a, b);
});
test("pickNextIndex: never returns the current track; covers the rest", () => {
// r in [0,1) maps onto the (count-1) other indices, skipping `current`.
assert.equal(S.pickNextIndex(0, 5, 0), 1);
assert.equal(S.pickNextIndex(0, 5, 0.999), 4);
assert.equal(S.pickNextIndex(2, 5, 0), 0);
assert.equal(S.pickNextIndex(2, 5, 0.5), 3);
assert.equal(S.pickNextIndex(4, 5, 0.999), 3);
// sweep: for each current, every r lands somewhere valid and never on current
for (let cur = 0; cur < 5; cur++) {
for (let i = 0; i < 20; i++) {
const n = S.pickNextIndex(cur, 5, i / 20);
assert.ok(n >= 0 && n < 5 && n !== cur, `cur=${cur} r=${i / 20} -> ${n}`);
}
}
});
test("pickNextIndex: single-track playlist stays on 0", () => {
assert.equal(S.pickNextIndex(0, 1, 0.7), 0);
});
test("audioPlan: Soundtrack blends lo/hi by fraction, scaled by level", () => {
assert.deepEqual(S.audioPlan("soundtrack", 1, 0), { mode: "blend", lo: 1, hi: 0 });
assert.deepEqual(S.audioPlan("soundtrack", 1, 1), { mode: "blend", lo: 0, hi: 1 });
const mid = S.audioPlan("soundtrack", 0.5, 0.5);
assert.equal(mid.mode, "blend");
assert.ok(Math.abs(mid.lo - 0.25) < 1e-9);
assert.ok(Math.abs(mid.hi - 0.25) < 1e-9);
});
+66
View File
@@ -0,0 +1,66 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const C = require("../static/credits.js");
const CLIPS = [
{ id: "reef_x", title: "Reef X", license: "CC-BY-SA 4.0 — credit Joe Mabel", source: "Wikimedia <reef>" },
{ id: "cosmos", title: "Cosmic Cliffs", license: "CC-BY-class — credit NASA, ESA, CSA, STScI", source: "NASA SVS 31348" },
{ id: "pd_only", title: "PD clip", license: "public-domain (US Gov, 17 U.S.C. §105)", source: "NASA" },
];
test("creditEntries returns one entry per clip, preserving license/source", () => {
const out = C.creditEntries(CLIPS);
assert.equal(out.length, CLIPS.length, "no clip is silently dropped from the credits");
const reef = out.find((e) => e.id === "reef_x");
assert.equal(reef.license, "CC-BY-SA 4.0 — credit Joe Mabel");
assert.equal(reef.source, "Wikimedia <reef>");
assert.equal(reef.title, "Reef X");
});
test("creditEntries is sorted by id for deterministic output", () => {
const ids = C.creditEntries(CLIPS).map((e) => e.id);
assert.deepEqual(ids, ["cosmos", "pd_only", "reef_x"]);
});
test("creditEntries falls back title->id and tolerates missing fields", () => {
const out = C.creditEntries([{ id: "bare" }]);
assert.deepEqual(out, [{ id: "bare", title: "bare", license: "", source: "" }]);
});
test("creditEntries handles null/empty input", () => {
assert.deepEqual(C.creditEntries(null), []);
assert.deepEqual(C.creditEntries([]), []);
});
test("creditsListHtml renders every clip's attribution and escapes HTML", () => {
const html = C.creditsListHtml(CLIPS);
assert.match(html, /Cosmic Cliffs/);
assert.match(html, /Joe Mabel/);
// free-text source "Wikimedia <reef>" must be escaped, not injected as a tag
assert.match(html, /Wikimedia &lt;reef&gt;/);
assert.doesNotMatch(html, /<reef>/);
// one block per clip
const blocks = html.match(/class="credit"/g) || [];
assert.equal(blocks.length, CLIPS.length);
});
test("credits.html wires config.js + credits.js, the attributions container, and a back link", () => {
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
assert.match(html, /<script src="config\.js">/); // static/live media-base + flag
assert.match(html, /<script src="credits\.js">/);
assert.match(html, /id="video-credits"/); // JS fills this
assert.match(html, /href="index\.html"/); // back into the experience
});
test("credits.html carries the CC BY-SA derivative declaration (Option D1)", () => {
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
assert.match(html, /CC BY-SA 4\.0/); // the altered work's own license
assert.match(html, /altered/i); // states the footage is modified
// The section is NOT titled "About this work" — that name belongs to the separate
// About page; here the declaration lives under "License & reuse".
assert.doesNotMatch(html, /About this work/i);
assert.match(html, /License &amp; reuse/);
});
+1 -1
View File
@@ -8,7 +8,7 @@ const I = require("../static/i18n.js");
const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");
test("index.html includes the i18n script and a language select", () => {
assert.match(html, /<script src="\/i18n\.js">/);
assert.match(html, /<script src="i18n\.js">/); // relative: works at dev-root and under the deploy path prefix
assert.match(html, /id="lang-select"/);
});
+53
View File
@@ -0,0 +1,53 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const P = require("../static/preload.js");
// Tiny fixture: 3-altitude cyclic ring, 2 pool members each. base_file = "<id>.b";
// morph file = "<from>>-<to>" when it exists.
const scales = [
{ id: "a", pool: [{ clip_id: "a1" }, { clip_id: "a2" }] },
{ id: "b", pool: [{ clip_id: "b1" }, { clip_id: "b2" }] },
{ id: "c", pool: [{ clip_id: "c1" }, { clip_id: "c2" }] },
];
const baseOf = (id) => id + ".b";
const morphFile = (a, b) => `${a}>-${b}`; // pretend every pair has a morph
const cached = new Set();
const deps = () => ({ baseOf, morphFile, isCached: (f) => cached.has(f) });
test("phase1Files = first member of each altitude + connecting morphs (both dirs, cyclic)", () => {
const files = P.phase1Files(scales, deps());
// bases: a1.b b1.b c1.b
for (const b of ["a1.b", "b1.b", "c1.b"]) assert.ok(files.includes(b), `missing base ${b}`);
assert.ok(!files.includes("a2.b"), "second members must NOT be in phase 1");
// connecting morphs between adjacent first-members, both directions, cyclic (a-b, b-c, c-a)
for (const m of ["a1>-b1", "b1>-a1", "b1>-c1", "c1>-b1", "c1>-a1", "a1>-c1"])
assert.ok(files.includes(m), `missing morph ${m}`);
assert.equal(files.length, 3 + 6);
});
test("eligibleDestinations requires base + BOTH morphs cached", () => {
cached.clear();
// from a1, candidate b1: nothing cached → not eligible
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), []);
cached.add("b1.b"); cached.add("a1>-b1"); // base + forward only
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), [], "reverse morph still missing");
cached.add("b1>-a1"); // now reverse too
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), ["b1"]);
// b2 fully cached too → both eligible
cached.add("b2.b"); cached.add("a1>-b2"); cached.add("b2>-a1");
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()).sort(), ["b1", "b2"]);
});
test("eligibleMembers requires only the base cached", () => {
cached.clear();
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), []);
cached.add("a1.b");
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), ["a1"]);
});
test("pick returns null on empty, a member otherwise", () => {
assert.equal(P.pick([], Math.random), null);
assert.equal(P.pick(["x"], () => 0), "x");
assert.equal(P.pick(["x", "y", "z"], () => 0.99), "z");
});
+21
View File
@@ -0,0 +1,21 @@
"""The static build reimplements plan_alteration/resolve_audio in JS
(simulator/static/alteration.js). This pins the numeric contract so the Python
and JS cannot silently diverge — if you change the engine, change both (the
mirrored JS cases live in simulator/unit/alteration.test.js)."""
from hef.selection import Coordinate
from player.alteration import plan_alteration, render_plan_to_dict
from player.audio import resolve_audio, resolve_visual
def test_engine_contract_matches_js_expectations():
# mirrors the cases asserted in simulator/unit/alteration.test.js
p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0)))
assert p["overlay"] == {"level": 2, "intensity": 0.5}
assert p["dream"] == {"strength": 4, "intensity": 1.0}
assert p["affect"] == {"strength": 4, "intensity": 1.0}
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 0)))["is_identity"] is True
assert resolve_visual("off") is False
a = resolve_audio("soundtrack", scale_audio="cosmos.mp3")
assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True
+65
View File
@@ -0,0 +1,65 @@
import json
from tools.build_static import build_static
def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
out = tmp_path / "dist"
media = tmp_path / "media"
result = build_static(
out, media,
media_base="https://static.benstull.art/",
app_path="/human-machine-strata",
)
app_dir = out / "human-machine-strata"
# frontend present under the app path; dev/author surfaces excluded
assert (app_dir / "index.html").exists()
assert (app_dir / "app.js").exists()
assert (app_dir / "scrub.js").exists()
assert (app_dir / "alteration.js").exists()
assert (app_dir / "config.js").exists()
# Credits/colophon ships so CC-BY / CC-BY-SA attribution is reachable from the public site.
assert (app_dir / "credits.html").exists()
assert (app_dir / "credits.js").exists()
assert not (app_dir / "author.html").exists()
assert not list(app_dir.glob("review*.html"))
# apex + no-slash redirect rules, plus the permanent legacy-path redirect
redirects = (out / "_redirects").read_text()
assert "/ /human-machine-strata/ 308" in redirects
assert "/human-machine-strata /human-machine-strata/ 308" in redirects
assert "/human-experience-simulator/ /human-machine-strata/ 301" in redirects
assert "/human-experience-simulator/* /human-machine-strata/:splat 301" in redirects
# baked JSON matches the API shape
clips = json.loads((app_dir / "clips.json").read_text())
assert "clips" in clips and clips["clips"]
ring = json.loads((app_dir / "ring.json").read_text())
assert "scales" in ring and "transitions" in ring
assert all("audio" in s for s in ring["scales"]) # needed by client alteration
versions = json.loads((app_dir / "media-versions.json").read_text())
assert "versions" in versions
# config.js points at R2 + static mode
cfg = (app_dir / "config.js").read_text()
assert "https://static.benstull.art/" in cfg and "static: true" in cfg
# media tree holds ONLY referenced files — no masters/mezzanines
synced = {p.name for p in media.rglob("*.mp4")}
assert synced, "expected synced media"
assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced)
# every versioned file exists in the media tree
for f in versions["versions"]:
assert (media / f).exists(), f"missing synced media: {f}"
assert result["media_files"] == sum(1 for _ in media.rglob("*") if _.is_file())
def test_build_includes_the_music_playlist(tmp_path):
# The Music-option tracks are referenced by app.js (MUSIC_PLAYLIST), not the
# manifest — the build must copy them explicitly or Music 404s on the static site.
out = tmp_path / "dist"
media = tmp_path / "media"
build_static(out, media, media_base="https://static.benstull.art/", app_path="/human-machine-strata")
for name in ("manatees", "dewdrop", "eastminster", "overheat", "concentration"):
assert (media / "audio" / "music" / f"{name}.mp3").exists(), f"music track not synced: {name}"
+60 -21
View File
@@ -2,15 +2,16 @@
cleanly when Playwright or its browser binary is absent.
NOTE: headless engines relax BOTH autoplay and GPU compositing, so these assert
the WIRING (boot-silent, video-on couples audio, dial→play/volume, video-off
the WIRING (boot-silent, launch starts video+audio, dial→play/volume, video-off
blanking) — real Safari/iOS autoplay and real-GPU compositing still need a device
by-ear/eye check.
The experience BOOTS SILENT — video off, audio dial at 0 — so nothing plays until a
real user gesture. Turning Video ON (a click) couples the audio dial up to 3/10 and
starts the soundtrack IN that gesture, sidestepping the browser autoplay block that
swallowed an un-gestured auto-start. Audio is a 010 range dial (not a checkbox);
set it by writing `value` + firing an `input` event.
The WELCOME screen is the entry gate: it carries the controls (Video on, Audio 2)
and a "Launch Simulator" button. Until Launch, the panel stays black/silent (panel
Video off, dial 0). Pressing Launch (a click gesture) copies the welcome values
into the panel and starts the soundtrack IN that gesture, sidestepping the browser
autoplay block. Audio is a 010 range dial (not a checkbox); set it by writing
`value` + firing an `input` event.
Install: pip install -e '.[e2e]' && python -m playwright install chromium
"""
@@ -65,15 +66,23 @@ def page(app_url):
pytest.skip(f"chromium not available: {exc}")
pg = browser.new_page()
pg.goto(app_url)
# wait out the "Loading Universe…" splash (it overlays + blocks clicks); the
# experience boots silent (video off, audio 0) as it dismisses.
pg.wait_for_selector("#loading", state="detached", timeout=60000)
# Wait for phase-1 preload to finish; the welcome screen (State A) is showing.
# The panel stays black/silent until the visitor presses Launch.
pg.wait_for_function("window.__hefReady === true", timeout=60000)
yield pg
browser.close()
def _enter(page):
"""Press the welcome screen's Launch button (the entry gesture) and wait for the
overlay to be removed, revealing the panel + running the experience."""
page.click("#welcome-launch")
page.wait_for_selector("#welcome", state="detached", timeout=10000)
def _toggle_visual(page):
"""Click the visible Video toggle track (the checkbox input is hidden)."""
"""Click the visible Video toggle track (the checkbox input is hidden). Only
valid after _enter() — the panel is hidden behind the welcome screen."""
page.click("label[for='visual'] .dev-switch-track")
@@ -87,9 +96,18 @@ def _set_audio(page, level):
)
def test_app_boots_silent_video_off(page):
# The experience boots SILENT: Video off, audio dial at 0, screen black, both
# crossfade elements paused — nothing plays until a real user gesture.
def _set_welcome_audio(page, level):
"""Drive the welcome screen's 010 audio dial (before Launch)."""
page.evaluate(
"(lvl) => { const a = document.getElementById('welcome-audio');"
" a.value = String(lvl); a.dispatchEvent(new Event('input', {bubbles:true})); }",
level,
)
def test_panel_silent_until_launch(page):
# Before Launch the panel stays SILENT: panel Video off, audio dial at 0, screen
# black, both crossfade elements paused — nothing plays until the Launch gesture.
assert page.is_checked("#visual") is False
assert page.evaluate("document.getElementById('audio').value") == "0"
assert page.evaluate("document.getElementById('audio-level-val').textContent") == "0"
@@ -100,12 +118,21 @@ def test_app_boots_silent_video_off(page):
) is True
def test_video_on_couples_audio_to_three_and_plays(page):
# Turning Video on (a click gesture) lifts the audio dial to 3/10 and starts the
# soundtrack IN that gesture: video shows (black hidden), a scale soundtrack plays.
_toggle_visual(page) # off -> on
page.wait_for_function("document.getElementById('audio').value === '3'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '3'")
def test_welcome_launch_shown_after_load_with_defaults(page):
# Once media is preloaded the welcome screen shows its Launch button with the
# default controls (Speed 1, Audio 2) while the panel stays silent.
assert page.is_visible("#welcome-launch")
assert page.evaluate("document.getElementById('welcome-play-speed').value") == "1"
assert page.evaluate("document.getElementById('welcome-audio').value") == "2"
def test_launch_enters_and_starts_video_and_audio(page):
# Pressing "Launch Simulator" (a click gesture) enters: Video on, the audio dial
# carries the welcome default 2/10, the overlay is removed, the soundtrack plays.
_enter(page)
assert page.is_checked("#visual") is True
page.wait_for_function("document.getElementById('audio').value === '2'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'")
page.wait_for_selector("#black.hidden", state="attached") # video showing
page.wait_for_function(
"(() => { const a = document.getElementById('aud');"
@@ -113,7 +140,17 @@ def test_video_on_couples_audio_to_three_and_plays(page):
)
def test_chosen_audio_level_carries_through_launch(page):
# Lowering the welcome Audio dial before launching carries the chosen level into
# the panel (entry uses the chosen level, not a forced 2).
_set_welcome_audio(page, 4)
_enter(page)
page.wait_for_function("document.getElementById('audio').value === '4'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '4'")
def test_audio_dial_sets_level_and_label(page):
_enter(page)
_set_audio(page, 7)
page.wait_for_function("document.getElementById('audio').value === '7'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '7'")
@@ -121,6 +158,7 @@ def test_audio_dial_sets_level_and_label(page):
def test_audio_dial_zero_silences(page):
# Level 0 fades both crossfade elements to silence, then pauses them.
_enter(page)
_set_audio(page, 0)
page.wait_for_function(
"(() => { const a = document.getElementById('aud'), b = document.getElementById('aud-b');"
@@ -130,9 +168,9 @@ def test_audio_dial_zero_silences(page):
def test_video_off_blanks(page):
# Turn Video on (boot is off), then off again: that blanks the screen and hides
# Enter (Video defaults on), then turn Video off: that blanks the screen and hides
# the GPU layers.
_toggle_visual(page) # off -> on
_enter(page)
page.wait_for_selector("#black.hidden", state="attached")
_toggle_visual(page) # on -> off
page.wait_for_selector("#black:not(.hidden)")
@@ -145,6 +183,7 @@ def test_soundtrack_fallback_when_ring_lacks_audio(page):
# Client resilience: even if /api/ring omits the per-scale `audio` field, the
# scale-id fallback map keeps a soundtrack playing. Drop the field, re-apply the
# dial, and confirm the cosmos soundtrack still resolves + plays.
_enter(page)
page.evaluate("ring.scales.forEach(s => { delete s.audio; })")
_set_audio(page, 5)
page.wait_for_function(
+86
View File
@@ -0,0 +1,86 @@
"""E2E (Playwright) — the public credits/colophon page actually surfaces the
CC-BY / CC-BY-SA video attribution to a visitor.
Unit tests pin the credits *logic* (creditEntries/creditsListHtml) and the build
ships the files; this confirms the live integration: credits.js fetches the clips
and fills the page in a real browser, and the experience links to it. The suite
skips cleanly when Playwright/Chromium is absent (headless box)."""
import socket
import threading
import time
from contextlib import closing
import pytest
pytest.importorskip("playwright.sync_api")
def _free_port() -> int:
with closing(socket.socket()) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
@pytest.fixture(scope="module")
def app_url():
import uvicorn
from simulator.app import app
port = _free_port()
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
for _ in range(50):
if server.started:
break
time.sleep(0.1)
if not server.started:
pytest.skip("uvicorn did not start")
yield f"http://127.0.0.1:{port}"
server.should_exit = True
thread.join(timeout=5)
@pytest.fixture
def browser_page(app_url):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
try:
browser = p.chromium.launch()
except Exception as exc: # no browser binary installed
pytest.skip(f"chromium not available: {exc}")
page = browser.new_page()
page._app_url = app_url # stash for the tests
yield page
browser.close()
def test_credits_page_lists_video_attributions(browser_page):
page = browser_page
page.goto(page._app_url + "/credits.html")
# credits.js fetches /api/clips and fills the container with one block per clip.
page.wait_for_selector("#video-credits .credit", timeout=30000)
count = page.eval_on_selector_all("#video-credits .credit", "els => els.length")
assert count >= 40, f"expected the full clip pool credited, got {count}"
# the required CC-BY / CC-BY-SA credits are visible to the viewer
body = page.inner_text("body")
assert "NASA, ESA, CSA, STScI" in body
assert "Joe Mabel" in body # a CC BY-SA clip's required author credit
assert "CC BY-SA 4.0" in body # the Option-D1 derivative declaration ("License & reuse")
def test_experience_links_to_credits(browser_page):
page = browser_page
page.goto(page._app_url + "/")
page.wait_for_function("window.__hefReady === true", timeout=60000)
# The Credits link is in the header. Use the href-specific selector (the About
# link shares the .credits-link class), and enter past the welcome screen so the
# header is no longer covered by the overlay before clicking.
link = "a[href='credits.html']"
assert page.get_attribute(link, "href") == "credits.html"
page.click("#welcome-launch")
page.wait_for_selector("#welcome", state="detached", timeout=10000)
page.click(link)
page.wait_for_selector("#video-credits .credit", timeout=30000)
+182
View File
@@ -0,0 +1,182 @@
"""Build the fully-static deployable for Cloudflare (Pages + R2).
Emits the Pages output tree and a separate R2 media sync tree:
out_dir/
_redirects apex + no-slash → /<app>/ (308)
<app>/ the app (served at benstull.art/<app>/)
index.html app.js scrub.js i18n.js alteration.js style.css
config.js GENERATED: { mediaBase, static: true }
clips.json ring.json media-versions.json baked API responses
media_out/ R2 upload tree — ONLY manifest-referenced files
<clip base.mp4 / transition morphs / audio>
The app lives under a path segment (so apex can redirect to it) and uses RELATIVE
asset urls, which resolve correctly under the `/<app>/` prefix. The baked JSON is
produced through the real app (TestClient) so it is byte-identical to the API. The
media tree contains ONLY referenced files — never the master/mezzanine pipeline
sources. Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in
deploy/cloudflare/ — this script only produces artifacts.
Run as a MODULE so the repo root (not tools/) is on sys.path — otherwise the
sibling tools/http.py shadows the stdlib `http` that fastapi/starlette import:
.venv/bin/python -m tools.build_static
"""
from __future__ import annotations
import hashlib
import json
import shutil
from pathlib import Path
from fastapi.testclient import TestClient
from simulator.app import MEDIA_DIR, create_app
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js",
"style.css", "credits.html", "credits.js", "about.html", "flash.js"]
def _bake_api(app_dir: Path) -> dict:
app = create_app()
client = TestClient(app)
clips = client.get("/api/clips").json()
ring = client.get("/api/ring").json()
versions = client.get("/api/media-versions").json()
(app_dir / "clips.json").write_text(json.dumps(clips, ensure_ascii=False))
(app_dir / "ring.json").write_text(json.dumps(ring, ensure_ascii=False))
(app_dir / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False))
return versions["versions"]
def _write_config(app_dir: Path, media_base: str) -> None:
(app_dir / "config.js").write_text(
"// GENERATED by tools/build_static.py — do not edit in dist/.\n"
f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n'
)
# The app's original deploy path, kept as a permanent redirect so old links to
# /human-experience-simulator/… still land on the renamed /human-machine-strata/….
LEGACY_APP_PATH = "human-experience-simulator"
def _write_redirects(out: Path, seg: str) -> None:
# Apex → app path, and the no-trailing-slash form → slashed (so RELATIVE asset
# urls resolve under the prefix). Plus a permanent (301) redirect from the legacy
# path to the current one. Cloudflare Pages `_redirects` syntax.
lines = [
f"/ /{seg}/ 308\n",
f"/{seg} /{seg}/ 308\n",
]
if LEGACY_APP_PATH and LEGACY_APP_PATH != seg:
# Exact root (trailing slash) first — the `/*` splat does NOT match the empty
# case, so the bare /legacy/ needs its own rule. Then the splat for subpaths,
# then the no-trailing-slash form.
lines.append(f"/{LEGACY_APP_PATH}/ /{seg}/ 301\n")
lines.append(f"/{LEGACY_APP_PATH}/* /{seg}/:splat 301\n")
lines.append(f"/{LEGACY_APP_PATH} /{seg}/ 301\n")
(out / "_redirects").write_text("".join(lines))
def _write_headers(out: Path, seg: str) -> None:
# The app shell must NOT be browser-cached — otherwise a deploy is invisible to
# returning visitors for hours (Pages' default is max-age=14400). `no-store`
# (never stored → a guaranteed-fresh fetch every visit) for the whole app path:
# `no-cache` (revalidate) was NOT enough — Safari served a STALE index from its
# disk/bfcache, loaded the OLD versioned JS, and broke the page (black, silent).
# A per-HTML no-store override didn't stick (Pages let the `/seg/*` rule win), so
# we no-store the whole path. The app shell is tiny (~150 KB); the media is on R2
# (immutable + content-hash, a separate domain) and is unaffected. The content-
# hashed asset URLs (see _version_assets) remain as belt-and-suspenders for the
# case where Pages caches .js/.css at the edge by type and ignores _headers.
# Cloudflare Pages `_headers` syntax.
(out / "_headers").write_text(
f"/{seg}/*\n"
" Cache-Control: no-store\n"
)
def _version_assets(app_dir: Path) -> None:
# Content-version the app-shell asset URLs in the HTML so a new build = a new URL
# = guaranteed-fresh fetch on a normal reload. The HTML itself is served no-cache
# (_headers), but Cloudflare Pages caches .js/.css by type (max-age=14400) and
# ignores _headers for them — so without this, returning visitors run stale JS.
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js", "flash.js",
"style.css", "credits.js", "config.js"]
tok = {}
for a in assets:
p = app_dir / a
if p.exists():
tok[a] = hashlib.sha1(p.read_bytes()).hexdigest()[:12]
for html in ("index.html", "credits.html"):
hp = app_dir / html
if not hp.exists():
continue
s = hp.read_text()
for a, h in tok.items():
s = s.replace(f'src="{a}"', f'src="{a}?v={h}"').replace(f'href="{a}"', f'href="{a}?v={h}"')
hp.write_text(s)
def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
out = Path(out_dir)
media = Path(media_out)
seg = app_path.strip("/")
app_dir = out / seg
for d in (out, media):
if d.exists():
shutil.rmtree(d)
app_dir.mkdir(parents=True)
media.mkdir(parents=True)
for name in PUBLIC_ASSETS:
src = STATIC / name
if src.exists():
shutil.copy2(src, app_dir / name)
versions = _bake_api(app_dir)
_write_config(app_dir, media_base)
_version_assets(app_dir) # after config.js exists, so it gets hashed too
_write_redirects(out, seg)
_write_headers(out, seg)
# Sync ONLY referenced media: `versions` covers clip bases + transition morphs;
# add each scale's audio explicitly. Never the master/mezzanine pipeline sources.
referenced = set(versions.keys())
ring = json.loads((app_dir / "ring.json").read_text())
for s in ring.get("scales", []):
if s.get("audio"):
referenced.add(f"audio/{s['audio']}")
# The Music-option playlist is referenced by the client (app.js MUSIC_PLAYLIST),
# not the manifest/ring — add it explicitly or Music 404s on the static site.
for name in ("manatees", "dewdrop", "eastminster", "overheat", "concentration"):
referenced.add(f"audio/music/{name}.mp3")
n = 0
for rel in sorted(referenced):
src = MEDIA_DIR / rel
if not src.exists():
raise FileNotFoundError(f"referenced media missing on disk: {rel}")
dst = media / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
n += 1
return {"out": str(out), "app_dir": str(app_dir), "media": str(media),
"media_files": n, "app_path": app_path}
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="dist")
ap.add_argument("--media-out", default="dist-media")
ap.add_argument("--media-base", default="https://static.benstull.art/")
ap.add_argument("--app-path", default="/human-machine-strata")
a = ap.parse_args()
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
print(json.dumps(r, indent=2))