Compare commits

...

98 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 61bd4a7943 Merge remote-tracking branch 'origin/main' into worktree-fix+audio-and-tracking-labels 2026-06-30 09:36:17 -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 2d54023f94 chore(i18n): re-extract en.json catalog to match per-clip affect
Keeps the translator source-of-record in sync with the manifest's English.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:33:46 -07:00
BenStullsBets bf1013be74 feat(content): per-clip right-brain feelings drawn from each clip's footage
Affect was authored per SCALE — every clip in an altitude shared the same 4
emotion words. Now every clip carries its OWN feelings, read from that specific
footage (mood, motion, light), so e.g. the violent Tarantula nebula evokes "a
violent ferment" while the dying-star Southern Ring evokes "a star's slow dying /
a luminous farewell," and a drowsing seal haul-out evokes "a sun-warmed drowse."

Adds AFFECT_CLIP (per-clip override; falls back to the scale register) covering
all 41 clips — 41 distinct feeling-sets, one per clip. Keeps each scale's
emotional family as a base and swaps in 1-2 footage-specific feelings, reusing the
existing feel.* palette where it fits and adding 19 new nuance keys (sublime,
tenderness, turbulence, radiance, mortality, poignancy, eeriness, purity,
solitude, verdancy, freshness, repose, hush, poise, flow, intricacy, spectral,
alienness, strangeness) with full es/fr/ja. 42 distinct feeling keys now in use.

Drafted by viewing each clip's footage (per-scale passes); first-pass reads that
want operator by-eye review. Data-only (build_pool_manifest.py + manifest + i18n
catalogs). Verified: 34/34 catalog+i18n+build pytest, 16/16 node unit, 4-language
parity for every clip, live render shows distinct feelings per clip (incl. new
keys) with Think/Feel overlap still 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:33:21 -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 fa3bbef0d6 add sessions/0032/SESSION-0032.0-TRANSCRIPT-2026-06-30T09-20--2026-06-30T09-24.md + replace placeholder/variant SESSION-0032.0-TRANSCRIPT-2026-06-30T09-20--INPROGRESS.md 2026-06-30 09:26:01 -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 64e8f658f6 claim human-experience-filter-art session 0032 (placeholder) + sessions.json entry 2026-06-30 09:21:27 -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 760fdc7550 add sessions/0031/SESSION-0031.0-TRANSCRIPT-2026-06-30T09-06--2026-06-30T09-11.md + replace placeholder/variant SESSION-0031.0-TRANSCRIPT-2026-06-30T09-06--INPROGRESS.md 2026-06-30 09:13:37 -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 e62382c5e5 claim human-experience-filter-art session 0031 (placeholder) + sessions.json entry 2026-06-30 09:08:16 -07:00
BenStullsBets 041fcdeb7d fix(sim): keep Feel (affect) words from overlapping Think (label) chips
Denser left labels (now >=3/clip) plus the always-4 right affect words made the
two HUD layers collide. renderAffect now nudges each Feel word vertically off the
Think chip plates (and off already-placed words + the ANALYSIS status tag),
staying on-screen, keeping the least-overlapping spot if nothing is fully clear.

Feelings are scene-level (soft position) so we move THEM, never a chip off its
subject. Obstacles are computed from the manifest, not the DOM, and a tracked
label's path is sampled across the loop — so the clearance holds for the whole
loop even though the per-frame track re-render keeps moving the chips (no per-frame
re-layout, no jitter). New diagnostic seam window.__hefNoDeconflict (sibling of
__hefState) A/B-toggles it.

Verified across all 6 scales at max knobs: authored overlaps drop to 0 with none
introduced (cosmos 3->0), and a tracked abyss clip stays at 0 overlaps sampled
over loop time as its chip drifts. No affect-position test exists to break;
16/16 node unit + frontend pytest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:06:36 -07:00
BenStullsBets 31956bfdfc add sessions/0030/SESSION-0030.0-TRANSCRIPT-2026-06-30T08-10--2026-06-30T08-50.md + replace placeholder/variant SESSION-0030.0-TRANSCRIPT-2026-06-30T08-10--INPROGRESS.md 2026-06-30 08:53:05 -07:00
BenStullsBets 5a08d1dd35 claim human-experience-filter-art session 0030 (placeholder) + sessions.json entry 2026-06-30 08:50:53 -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 432fbd7703 add sessions/0029/SESSION-0029.0-TRANSCRIPT-2026-06-30T08-09--2026-06-30T08-41.md + replace placeholder/variant SESSION-0029.0-TRANSCRIPT-2026-06-30T08-09--INPROGRESS.md 2026-06-30 08:43:06 -07:00
BenStullsBets 4e4a5c256d Merge remote-tracking branch 'origin/main' into worktree-fix+audio-and-tracking-labels 2026-06-30 08:36:40 -07:00
BenStullsBets 8cfceffb76 feat(content): enrich left (Think) labels to >=3/clip + motion-track abyss creatures
Every clip now carries at least 3 factual LEFT annotations to match the 4 RIGHT
(Feel) affect words — fixes the "many clips missing Think labels" asymmetry
(right read "everywhere", left felt sparse). Adds one researched factual label to
each of the 23 thin clips (orbit_bluemarble had only 1; 22 had 2), with full
es/fr/ja parity via the catalogs, and converts the 4 still-static abyss creature
subjects (octopus, bigfin squid, dandelion siphonophore, sea pig) from fixed
boxes to 3-keyframe motion-tracked labels (tracked clips 7 -> 11).

Boxes/tracks are a first pass sampled by eye from base footage (t=0.05/0.5/0.95);
they want operator by-eye review in author mode to fine-tune positions. Remaining
static clips are landscapes (correctly static) plus 2 diffuse fish schools and 2
surface-animal scenes, available for author-mode tracking if wanted.

Data-only change (build_pool_manifest.py LABELS + manifest + i18n catalogs); no
app/engine code touched. Verified: 43/43 manifest+catalog+i18n pytest, 16/16 node
unit, live render shows 3 chips/clip in all 4 languages with no JS errors. (The 3
failing pytest are pre-existing cv2/ffmpeg-absent env limits, unrelated.)

Note: the reported "audio not playing" was a false alarm — exhaustive repro
(Chromium+WebKit, strict autoplay, both servers, RMS-verified non-silent files)
found the audio engine correct; the operator's Safari output was muted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:36:05 -07:00
BenStullsBets 5f8b8f14e6 claim human-experience-filter-art session 0029 (placeholder) + sessions.json entry 2026-06-30 08:12:39 -07:00
BenStullsBets 59c1c3a895 add sessions/0028/SESSION-0028.0-TRANSCRIPT-2026-06-30T06-53--2026-06-30T07-21.md + replace placeholder/variant SESSION-0028.0-TRANSCRIPT-2026-06-30T06-53--INPROGRESS.md 2026-06-30 07:23:18 -07:00
benstull 582183db2f Merge pull request 'fix(sim): kill the reverse-landing frame jump (D3)' (#30) from fix/d3-reverse-landing-jump into main 2026-06-30 14:17:56 +00:00
BenStullsBets c13ce36787 fix(sim): kill the reverse-landing frame jump (D3)
Ascending the altitude dial and landing on the higher-altitude clip jumped ~3
seconds. A baked morph `transitions/{h}__{l}.mp4` spans src@0 (frac 0) ->
dst@~LOOP_TAIL_S (frac 1) and is scrubbed bidirectionally, but the steady loop
element always seeked LOOP_TAIL_S (3.0s) on landing. Descending lands on dst at
its tail (matches the seek — the s0026 loop-from-tail fix); ascending lands on
src at the morph's HEAD (src@0), so seeking the tail was a 3s jump.

Make the loop landing frame direction-aware: pure `HEFScrub.loopLandFrame(dir,
loopTailS)` returns 0 when ascending, loopTailS otherwise. `loadLoop` takes a
`landFrame`, stores it as `dataset.loopStart`, and the timeupdate/ended wrap
handlers honor it (the base clips are already seamless crossfade-loops, so a
reverse landing wraps [0,dur] cleanly). The settle path lands explicitly on the
direction's frame so a fast ascent never jumps even if the heading clip wasn't
preloaded in time. App-side only — no 154-morph LFS re-bake.

Verify: node unit (loopLandFrame) + a new reverse-landing e2e asserting the loop
anchors at the head (loopStart "0"), and the existing forward "loops from tail"
e2e still green (12/12 altitude-lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:16:47 -07:00
BenStullsBets 21830069ad claim human-experience-filter-art session 0028 (placeholder) + sessions.json entry 2026-06-30 06:56:42 -07:00
BenStullsBets 50366c056e add sessions/0027/SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--2026-06-30T06-47.md + replace placeholder/variant SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--INPROGRESS.md 2026-06-30 06:49:13 -07:00
72 changed files with 11377 additions and 3254 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"
}
]
}
]
}
}
+6
View File
@@ -23,6 +23,8 @@ 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/
@@ -30,3 +32,7 @@ simulator/static/review_audio.html
# 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/
+4 -4
View File
@@ -1,7 +1,7 @@
# 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-experience-simulator`, apex redirects to
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`.
@@ -48,7 +48,7 @@ as a script):
```
Produces:
- `dist/` — Pages output: `dist/human-experience-simulator/` (frontend + baked
- `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
@@ -109,7 +109,7 @@ Then **in the dashboard** (Workers & Pages → project → Custom domains) — t
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-experience-simulator/`; the shipped
- 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
@@ -121,7 +121,7 @@ curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
```
Open `https://benstull.art/` → should redirect to
`https://benstull.art/human-experience-simulator/`. Turn the **Feel** knob to max
`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
+1 -1
View File
@@ -15,7 +15,7 @@ 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-experience-simulator}"
APP_PATH="${APP_PATH:-/human-machine-strata}"
BRANCH="${BRANCH:-main}"
JOBS="${JOBS:-10}"
+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,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,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,94 @@
# Session 0027.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-29T22-04 (PST)
> End: 2026-06-30T06-47 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED**
## Launch prompt
```
Add left and right brain annotations (including localization) for any that are missing.
```
## Plan
> Anchor: operator direct instruction (standalone leaf content-fill task, §4.3 R2b).
**Goal:** Author the missing **left-brain** factual `LABELS` (tiered general→scientific→+fact
labels) for the rotating-pool clips that lack them, plus their **es/fr/ja** translations,
then rebuild + merge the manifest. Right-brain `affect` is auto-generated per scale for
every clip via `_affect_for_clip` — nothing missing there.
## Pre-state
- `main` at `5beee55` locally but **diverged from `origin/main`**: 11 merged-but-unpushed
commits (the entire i18n localization feature + a video-loop safety-net fix) stranded
locally; `origin/main` still at `eff43bd` (PR #29). Plus 3 files with an uncommitted
"boots-silent / no auto-start" change. Two stale `--INPROGRESS` placeholders (0015, 0017).
- Annotation reality (from a read-only survey of `build_pool_manifest.py` + catalogs):
right-brain `affect` present on all 41 clips (auto-per-scale); left-brain `LABELS`
present on only 13 of 41. 28 pool clips had `"annotations": []`.
## Arc
1. **Scoped the task** — explored the annotation/i18n system; established that the gap was
left-brain only (right-brain is auto-generated), 28 clips, + their es/fr/ja strings.
2. **Claimed session 0027** (peek showed the two stale placeholders; noted + proceeded).
3. **Surfaced the unclean baseline**; operator chose *"Push stranded, also commit dirty."*
- Stashed the 3 dirty files, merged `origin/main`'s claim commit, **pushed the 11
stranded commits** (`020219f`).
- Committed the boots-silent change on `fix/boot-silent-no-autostart`, verified
(299 pytest pass), **merged + pushed** (`7bd6c9a`).
4. **Annotation work** on `feat/left-brain-annotations`:
- Added 28 `LABELS` entries (static tiered labels) to `build_pool_manifest.py`
(Round-5 block); rebuilt manifest → all 41 clips annotated; extracted en catalog.
- Dispatched **3 parallel translator subagents** (es/fr/ja) for the 67 new keys,
matching existing catalog voice; fixed 2 ja `measure.*` values to the `約`/localized
convention; merged fragments into catalogs; merged all 3 langs into the manifest.
- Verified: 41 clips × en/es/fr/ja full key + tier-length parity; 299 pytest pass
(3 pre-existing `cv2` env failures only); 37 i18n/manifest tests green; spot-checked
translations. **Merged + pushed** (`934f60c`), deleted both branches.
## Cut state
- `main` == `origin/main` @ `934f60c`, clean tree, branches removed.
- 41/41 clips carry left+right annotations with full en/es/fr/ja parity.
- §9: local tests green. No PPE/E2E machinery exists for this app yet (pipeline is policy,
not automated — §10.6); prod promotion stays operator-gated. Visual review deferred to
operator (no Chrome on box).
## Operator plate
- **Review by eye** the 28 newly-labelled clips across all 4 languages in the running sim.
- The labels are **static**; the abyss/reef *creature* clips would benefit from a later
**tracked-label** upgrade via author mode (couldn't auto-file as a tracker issue — the
capture token returned 401 in this env; full context is in the session-0027 memory note).
- A few species IDs were inferred from clip META, not the frame (`reef_redsea`→anthias,
`reef_flowergarden`→jack/scad+sergeant-major) — worth confirming at review.
- Two stale `--INPROGRESS` sessions (0015, 0017) remain unfinalized — likely the crashed
sessions that stranded the 11 commits; left untouched for the operator.
## Next-session prompt
```
/goal Review the new left-brain annotations (all 28 newly-labelled pool clips, across en/es/fr/ja) by eye in the running simulator; correct any species/box that reads wrong, and decide whether the abyss/reef creature clips should get tracked labels via author mode.
```
## Deferred decisions
- **Static labels, not tracked.** Authored all 28 clips' left-brain labels as fixed-box
`static_label`s rather than motion-tracked labels. Alternative: tracked labels (as the
reef/abyss showcase clips use) so the box follows drifting creatures. Chose static because
the footage wasn't eyeballed — fabricating track coordinates would claim precision I don't
have. The abyss/reef creature clips are the ones that would most benefit from a later
tracked-label upgrade via author mode.
- **Some species IDs inferred from clip metadata, not the footage.** `reef_redsea`→anthias,
`reef_flowergarden`→jack/scad + sergeant-major — read from the clip META title/source.
High-confidence from provenance, but worth an eyeball check.
- **`O-type` kept verbatim in es/fr/ja** (per the translation brief) rather than the more
natural `tipo O` / `type O`. Minor; flagged by the es translator.
@@ -1,28 +0,0 @@
# Session 0027.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-29T22-04 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 3e51cce1-9023-4a16-b607-99894dfe0597
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **PLACEHOLDER — claimed at session start; finalized at session end.**
>
> This file reserves session ID 0027 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0027.0-TRANSCRIPT-2026-06-29T22-04--<end>.md form at session end.
## Launch prompt
```
Add left and right brain annotations (including localization) for any that are missing.
Resolved scope: right-brain `affect` is auto-generated per scale for every clip (nothing missing). The gap is left-brain `LABELS` (factual/scientific tiered labels) on 28 rotating-pool clips that have no entry in simulator/build_pool_manifest.py, plus their es/fr/ja translations in tools/i18n/catalogs. Author them following the existing static_label pattern, rebuild the manifest, merge translations, run tests.
```
## 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,125 @@
# Session 0028.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T06-53 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: d9da7994-e036-4b7c-b2d4-d2675ac4e5a1
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED.**
> Worktree: `worktree-session-0028` (isolated; concurrent session in flight). Torn down at finalize.
> Outcome: D3 reverse-landing frame jump FIXED, app-side. PR #30 → main (`582183d`).
## Launch prompt
`/goal next` — operator then directed: "There's a concurrent session — do this one out
of a worktree in a different dir." Session 0028 runs isolated in worktree
`worktree-session-0028` while a concurrent session owns the simulator startup ("Run
simulation" button: `index.html`/`style.css`/`i18n.js`).
## Plan
> Anchor: leaf bug — D3 reverse-landing frame jump (no tracker issue; the s0027 capture
> token returned 401. Residual flagged in the altitude-morph-and-scrub memory note +
> scrub-driven-transitions design.)
**Goal:** kill the ~3-second frame jump when ASCENDING the altitude dial and landing on
the higher-altitude clip.
**Root cause (verified by reading the bake + player):** each baked morph
`transitions/{h}__{l}.mp4` spans `src(h)@0s` (frac 0) → `dst(l)@~LOOP_TAIL_S` (frac 1),
and the scrub uses this single forward file in BOTH directions. The steady loop element
always seeks to `LOOP_TAIL_S` (3.0s) on landing. So:
- DESCEND (dir>0) lands on `dst` at frac 1 → morph ends `dst@3` ≈ loop@3 → seamless ✓
(the s0026 "loop-from-tail" fix).
- ASCEND (dir<0) lands on `src` at frac 0 → morph shows `src@0`, but loop seeks `src@3`
→ ~3s jump ✗ (D3).
**Fix (app-side, no media re-bake — beats re-baking 154 LFS morphs):** make the loop
landing frame DIRECTION-AWARE. On a reverse landing seek the loop to 0 (= the morph's
frac-0 frame) and wrap `[0,dur]` (the base is already a seamless crossfade-loop); forward
keeps `[LOOP_TAIL_S,dur]`. Pure decision extracted to `scrub.js loopLandFrame(dir,
loopTailS)` + node tests; wired through `loadLoop`/settle/wrap handlers in `app.js`.
**Tasks:**
1. TDD `HEFScrub.loopLandFrame(dir, loopTailS)` in `scrub.js` (+ `scrub.test.js`).
2. Wire `app.js`: `loadLoop(clip, landFrame)`, directional settle seek, `dataset.loopStart`
in the `timeupdate`/`ended` wrap handlers.
3. Add a reverse-landing e2e (mirror of the existing forward "loops from tail" test).
4. Verify: node unit tests + pytest + e2e (best-effort) all green.
5. Branch → PR → merge (yolo). PPE/E2E machinery not built for this app (§10.6) — local
green is the bar; prod promotion stays operator-gated.
## 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._
- **Fix shape: app-side direction-aware loop landing, NOT a 154-morph re-bake.** The
s0026 memory note guessed the D3 fix would be "per-clip loop files." Reading the bake +
player showed the root cause is simpler: the morph's frac-0 frame is `src@0` but the
loop always seeked the tail (`LOOP_TAIL_S`). Fixed entirely in `app.js`/`scrub.js` by
landing the loop on the morph's actual frac-0 frame (0) when ascending. This avoids
re-baking 154 LFS morphs (no media churn, a tiny reviewable diff, the base clips are
already seamless crossfade-loops so `[0,dur]` wraps cleanly). Alternative considered:
re-bake morphs to start at `src@LOOP_TAIL_S` — heavier, no benefit.
- **Pre-existing e2e failure left as-is:** `loop-recovery.spec.ts` fails in this
environment on the CLEAN baseline too (verified by stashing my changes). It boots
without enabling video, so the loop's `loopTail` is never armed and the `ended` guard
returns early. Not introduced here; my change preserves that guard exactly. Worth a
separate look (the test likely needs to `enableVideo` first), but out of scope for D3.
- **Could not verify by eye.** The fix is logic- and test-verified (node + the new
reverse-landing e2e asserting the loop anchors at the head, not the tail); the actual
visual seamlessness of an ascending landing still wants an operator eyeball in the
running sim.
## Session arc
1. Claimed session **0028** (race-free, via git). Two stale `--INPROGRESS` placeholders
(0015, 0017) noted as orphaned, left untouched.
2. Baseline survey surfaced uncommitted `run-sim` button edits + main behind by 2
(session commits). Operator confirmed a **concurrent session** owns that startup work
and directed isolation → created worktree `worktree-session-0028` off `origin/main`,
leaving the concurrent session's edits in the canonical clone untouched.
3. Orientation: in-repo `docs/ROADMAP.md` is stale (formal frontier = deferred hardware);
live frontier is in memory. Stored `/goal next` (0027's by-eye annotation review) is
operator-eyes work I can't complete; the run-sim button is the concurrent session's.
Asked the operator to pick 0028's disjoint item → **D3 reverse-landing fix**.
4. Read the bake (`build_pool_manifest.transition_cmd`) + player (`app.js` loop/morph) to
pin the root cause (morph spans `src@0``dst@tail`; loop always seeks the tail).
5. TDD: `HEFScrub.loopLandFrame` (red → green), then wired `app.js`
(`loadLoop(clip,landFrame)`, `dataset.loopStart`, directional settle, wrap handlers).
6. Added a reverse-landing e2e. Verified: node 16/16, **altitude-lock e2e 12/12** (forward
no-regression + new reverse test), pytest 299 pass. Confirmed `loop-recovery.spec.ts`
fails on the clean baseline too (pre-existing, not mine) by stashing.
7. Committed → PR #30 → merged to `main` (`582183d`) → deleted branch. Updated memory.
## Cut state
- **On `main` (`582183d`):** D3 fix — `simulator/static/scrub.js` (+`loopLandFrame`),
`simulator/static/app.js` (direction-aware loop landing), `simulator/unit/scrub.test.js`,
`simulator/e2e/tests/altitude-lock.spec.ts` (reverse-landing test).
- **Tests:** node 16/16; altitude-lock e2e 12/12; pytest 299 pass / 3 pre-existing
env-only failures (real-ffprobe/ffmpeg + ML detect; untouched — no Python changed) / 4
skipped. `loop-recovery.spec.ts` is a pre-existing baseline failure in this env.
- **§9 / deploy:** this whole project is **exempt** from flotilla/PPE/§9 (memory:
project-exempt-from-wiggleverse-deploy; public target is static Cloudflare). Local-green
is the bar — met. No PPE stage to run.
- **No plan artifact archived:** fused leaf fix, plan inline in this transcript; app has
**no content repo** (`CONTENT_REMOTE` empty) — nothing to archive.
## Operator plate
- **Review by eye** an ASCENDING altitude landing in the running sim — confirm the
~3s reverse-landing jump is gone (logic/test-verified only; no Chrome on box here).
- Still standing from s0027: by-eye review of the 28 left-brain annotations (4 langs);
decide on tracked labels for abyss/reef creature clips.
- A concurrent session was shipping the "Run simulation" startup button — reconcile/land
that separately (its edits are in the canonical clone, not touched here).
## Next-session prompt
```
/goal Review by eye in the running simulator: (1) an ASCENDING altitude landing — confirm the D3 reverse-landing frame jump is gone; (2) the 28 left-brain annotations across en/es/fr/ja, correcting any species/box that reads wrong; then decide whether the abyss/reef creature clips should get tracked labels via author mode.
```
@@ -0,0 +1,76 @@
# Session 0029.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-09 (PST)
> End: 2026-06-30T08-41 (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
> Status: **FINALIZED**
## Launch prompt
"We need to make sure credits for Creative Commons licensed videos are compliant.
What are some options?" → after presenting options the operator chose **Option A**
(manifest-driven static credits/colophon page surfaced to the public) + **Option D1**
(declare the altered footage CC BY-SA 4.0). Non-commercial.
## Pre-state
- Branch `design/cloudflare-static-publish` (static-publish work, pushed). Working
tree had uncommitted, unrelated in-flight work: a "Run simulation" start button
(6 files) **plus** an `r2-cors.json` reformat (surfaced via stash, 7 total).
- Per-clip `license`/`source` already in `clips.json` (baked from `/api/clips`) but
only shown in the dev panel + internal review pages — public experience showed none.
- A concurrent session was live in worktree `fix+audio-and-tracking-labels` (off main);
the operator was also committing directly to `design/cloudflare-static-publish`.
## Arc
1. **Audited** the license surface: `tools/licensing.py`, `pipeline/provenance.py`,
manifest, frontend. Found credits exist in data + review tooling but not the public
experience → that's the gap. Presented options AD; operator chose A + D1.
2. **Claimed session 0029** (planning-and-executing, yolo). Two stale `--INPROGRESS`
placeholders (0015, 0017) noted as long-dead, left alone.
3. **Operator-requested pre-work:** the uncommitted in-flight work. Stashed it,
created an isolated worktree (`.claude/worktrees/session-0029` off the branch HEAD,
since the static work lives there not on main), applied the stash.
- Discovered the branch had advanced under me to `8cc472c` (operator's direct
commit). It **intentionally** keeps `r2-cors.json` in S3-array form alongside a
new `r2-cors.wrangler.json` (runbook documents both) → **dropped** my conflicting
`r2-cors.json` reformat commit; kept + rebased only the Run-simulation button.
- Committed the button (`3a45e33`), ff-merged + pushed.
4. **Built Option A + D1** (TDD): node test for the pure credits logic first (red) →
`credits.js` (UMD: `creditEntries`/`creditsListHtml`, escaped, sorted) → `credits.html`
colophon (D1 CC BY-SA declaration + "altered" note; JS-filled video attributions;
PD/CC0 audio courtesy block; back link) → header "ⓘ Credits" link (`credits.link`
i18n en/es/fr/ja) → `style.css``PUBLIC_ASSETS` in `build_static.py` + its test →
Playwright e2e (`tests/test_e2e_credits.py`).
5. **Verified:** node 28/28, pytest 315 passed / 2 skipped. Real build render: 41/41
clips credited, all 7 CC-BY/BY-SA clips show required attribution, 0 missing.
Committed (`1c7f2b7`), ff-merged + pushed, removed the worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `1c7f2b7`, clean, pushed (origin in sync).
- Public site now attribution-compliant: credits reachable from the work; altered-work
license declared. §9 is **exempt** for this project (static Cloudflare target), so
merged+pushed to the design branch is the completion state — no PPE/prod.
## Deferred decisions
- **Dropped my `r2-cors.json` reformat** rather than merging it — concluded from the
operator's `8cc472c` + runbook that the S3-array form is intentional. (Alternative:
keep both forms / ask. Chose to honor the documented two-file split.)
- **Audio credits as a static courtesy block** in `credits.html` rather than plumbing
`audio_license` through the manifest/API. Rationale: audio is all PD/CC0 (no legal
attribution duty) and not machine-readable; data-plumbing was out of scope. (Alternative:
add audio license fields — deferred, low value for non-commercial PD/CC0.)
## Next /goal
```
/goal operator by-eye review of the credits colophon + Run-simulation button on design/cloudflare-static-publish, then the actual Cloudflare deploy (provision R2 human-experience-simulator-media + CORS, run tools.build_static, Pages deploy) per deploy/cloudflare/README.md
```
@@ -0,0 +1,128 @@
# Session 0030.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-10 (PST)
> End: 2026-06-30T08-50 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: c4cec6c6-26d8-46f3-80ee-4ff4035a4f72
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Date: 2026-06-30
> Goal: Add an obvious "Run simulation" start button to the simulator's main
> screen (shown after the "Loading Universe…" preload); it turns Video on and
> sets Audio to level 2. Turning Video on directly should also start the
> experience, snap Audio to 2, and dismiss the button. Move the default
> first-Video-on audio coupling from 3/10 → 2/10.
> Outcome: Built + verified (303 pytest, 8 pytest-playwright incl. 3 new, 12
> Playwright TS incl. 1 updated + 1 new, node unit tests green). Work committed
> as 3a45e33 and pushed to design/cloudflare-static-publish — **the commit was
> landed by a CONCURRENT session (0029)** that swept up this session's
> working-tree changes while doing the CC-credits/Cloudflare-publish work.
## Plan
1. Map the splash/loading flow and the Video/Audio toggle wiring (Explore agent
+ direct reads of `simulator/static/{index.html,app.js,style.css,i18n.js}`).
2. Add a `#run-sim` button inside the stage `.screen`, hidden until media
preloads, styled to sit above the black cover (`z-index: 60`).
3. Add a `beginExperience()` helper: Video on + audio snapped to a
`START_AUDIO_LEVEL = "2"` + button dismissed + soundtrack played in-gesture
(Safari unlock). Wire it to both the button click and the first direct
Video-on (`!videoEverOn`).
4. Reveal the button after `hideLoading()`; change the old 3/10 coupling → 2/10.
5. Localize `run.button` in en/es/fr/ja (4-lang parity invariant).
6. Update E2E coverage (pytest-playwright + Playwright TS) from the old
"couples to 3" assertions to 2; add button-shown / button-starts /
direct-video-on tests.
7. Run the full suite + confirm a pre-existing failure is not mine.
## Pre-session state
- Branch `main` clean at `934f60c` (left-brain annotations merged). Simulator
boots SILENT (video off, audio 0) and waited for the operator to flip the
unlabelled **Video** toggle as the start gesture — not obvious to a first-time
viewer. First-Video-on coupled audio to **3/10**.
- No session-init gate was run at the top of this session — it was treated as a
small, fully-specified frontend change (see Deferred decisions).
## Turn-by-turn arc
1. **Explore + direct reads.** Dispatched an `Explore` subagent to map the
splash + AV-toggle code; in parallel read `index.html`, `app.js`
(`main()`/`hideLoading()`/`beginExperience` region, the audio dial, the
`videoEverOn` coupling), `style.css` (`#loading`, `.black z-index:50`,
`.hidden`), and `i18n.js` (UI_STRINGS shape, 4 languages). Confirmed the
stage `.screen` is `position: relative` and the black cover is `z-index: 50`,
so a start button needs `z-index > 50` inside the screen and the side panel
(Video toggle) stays reachable.
2. **Implement.** Added `#run-sim` to the stage; `.run-sim` CSS (centered pill,
`z-index: 60`); `run.button` i18n string (en/es/fr/ja); `beginExperience()`
unifying both entry paths; revealed the button after `hideLoading()`;
simplified the Video-toggle handler to call `beginExperience()` on first
on; changed coupling 3 → 2.
3. **Update tests.** Replaced the old `test_video_on_couples_audio_to_three…`
(pytest) and the "couples audio to 3/10" Playwright TS test with 2/10
variants; added button-shown-after-load, button-starts-video+audio, and
direct-Video-on-dismisses-button cases on both harnesses.
4. **Verify.** `303 passed, 2 skipped` (non-E2E pytest); `8 passed`
pytest-playwright (incl. 3 new); `12 passed` Playwright `altitude-lock.spec`
(incl. updated + new). Node unit tests (i18n / index-i18n / scrub) green; the
i18n completeness invariant confirms `run.button` has all 4 langs.
5. **Isolate a failure.** `loop-recovery.spec.ts` failed; stashed only the
source files and re-ran on a clean baseline — it **fails identically without
my changes**, so it's pre-existing, not introduced here. (During that stash
round-trip a `test-results/.last-run.json` artifact briefly blocked
`git stash pop`; recovered by `git checkout`-ing the artifact and popping —
no source lost.)
6. **Handed the working tree back uncommitted** for an operator by-eye review
(the project's usual cadence — no Chrome on the box). Offered to branch + PR.
## Cut state (end of session)
| Item | State |
| --- | --- |
| Run-simulation button + audio-2 change | Committed `3a45e33` on `design/cloudflare-static-publish`, **pushed** |
| Committed by | Concurrent **session 0029** (swept up this session's working-tree changes) |
| Files | `simulator/static/{index.html,app.js,style.css,i18n.js}`, `simulator/e2e/tests/altitude-lock.spec.ts`, `tests/test_e2e_audio.py` |
| This session's working tree | Clean; nothing of mine left to commit/push/merge |
| Branch `design/cloudflare-static-publish` | Not merged to `main` — intentional; awaits operator review + Cloudflare deploy (session 0029's frontier) |
| Deploy pipeline (§9) | **N/A** — project is exempt (static Cloudflare target; see memory `project-exempt-from-wiggleverse-deploy`) |
| Verification | 303 pytest + 8 pytest-playwright + 12 Playwright TS + node units green; `loop-recovery.spec.ts` red **pre-existing** |
## What lands on the operator's plate
- **By-eye / by-ear review** of the Run-simulation button in a real browser —
especially the **in-gesture audio unlock on real Safari/iOS** (headless tests
relax autoplay, so it's wired-but-unconfirmed on a real device).
- The button work lives on `design/cloudflare-static-publish` alongside the
CC-credits + Cloudflare-publish work; it merges to `main` / ships when that
branch does (session 0029's frontier), not separately.
- Note the concurrency: this session authored the change; session 0029 committed
it. Two transcripts (0029, 0030) touch the same single commit `3a45e33`
expected, not a duplicate of the commit.
## Prompt the operator can paste into the next session
```
/goal operator eyeball review of the static build (incl. the new Run-simulation button) + execute the real Cloudflare deploy to benstull.art/human-experience-simulator, per the cloudflare-static-publish design on branch design/cloudflare-static-publish
```
## Deferred decisions
_Autonomous-mode low-confidence calls; surfaced at finalize._
1. **No session-init gate at the top.** Treated the task as a small,
fully-specified frontend tweak and implemented directly without claiming a
transcript first. Consequence: the work landed on a shared checkout and was
committed by a concurrent session (0029) rather than carrying its own
end-to-end branch→PR. Alternative: claim a session + isolated worktree up
front. Flagged because shared-checkout work is what the worktree rule guards
against.
2. **`beginExperience()` snaps audio to 2 unconditionally** on first Video-on,
overriding a viewer who pre-set a higher level before pressing start /
flipping Video. Matched the operator's explicit "snap to audio level 2"
wording; alternative was to only raise from 0 (the prior 3/10 behavior). Easy
to soften to "only if currently 0" if undesired.
3. **Real-device audio autoplay unverified.** Headless Chromium ran with
`--autoplay-policy=no-user-gesture-required`, masking real Safari/iOS
behavior; the in-gesture unlock is wired but needs a by-ear check.
@@ -0,0 +1,80 @@
# Session 0031.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-06 (PST)
> End: 2026-06-30T09-11 (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
> Status: **FINALIZED**
## Launch prompt
Follow-up to session 0029's credits work. Operator's first ask was to rename the
credits URL → "about" (one page holding both about + credits) and add an
Accessibility table. Mid-clarification the operator redirected: **keep About and
Credits separate** (the About page is owned by a parallel session) and simply
**"Remove the 'About this work' from the credits page. I'll merge that into the
About page on the other session."**
## What I found before acting
- The Cloudflare site is now **LIVE** (`benstull.art/human-experience-simulator`);
the branch had advanced several commits since s0029 (live deploy, progressive
boot, an audio mediaBase fix), and moved again mid-session (`a68d0bc``d10ffca`).
- A **live concurrent session** owns `feat/accessibility-pass` with an
approved-pending design spec (`docs/superpowers/specs/2026-06-30-accessibility-and-about-page-design.md`):
a full WCAG 2.1 AA pass + a **separate `about.html`**. I surfaced the overlap with
the operator's original (one-page) ask; the operator resolved it by keeping the
pages separate and narrowing my task to the section removal.
## Arc
1. Surveyed git + the concurrent worktrees; read the a11y design spec.
2. Started to raise a coordination question; operator interrupted with the narrow
instruction (remove "About this work" from credits; they move it to About).
3. Claimed **session 0031**, isolated worktree off the live branch tip (`d10ffca`)
— only `credits.html` + its tests touched, so no overlap with the a11y session's
files (`index.html`/`style.css`/`app.js`/`about.html`).
4. Removed the `<section class="declaration">` "About this work" block from
`credits.html`; rewrote the footage note to be self-contained ("All footage is
altered in real time in the experience" — keeps the per-clip modification cue,
drops the dangling "as described above").
5. Updated tests to the new intent: `credits.test.js` and `tests/test_e2e_credits.py`
now assert the page notes footage is altered but the **CC BY-SA 4.0 declaration is
NOT** on credits (it moved to About).
6. Left the `.declaration` CSS rule in `style.css` (the About page reuses these styles).
7. Verified: node 28/28, pytest 315 passed / 2 skipped. Committed `8277417`,
ff-merged + pushed, removed the worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `8277417`, clean, pushed.
- Credits page = per-clip attributions + "footage altered" note + audio courtesy
block. The CC BY-SA work-license declaration (D1) is **no longer on credits**
it now belongs on the About page (operator + `feat/accessibility-pass` own it).
- Site is live; this change reaches `benstull.art` only after a **redeploy**.
## Deferred decisions
- **Kept a brief "footage is altered" note on the credits page** rather than
stripping all alteration mention. Rationale: CC BY/BY-SA attribution wants the
modification indicated alongside the credit; the full license declaration is what
moved to About. (Alternative: remove every alteration mention from credits —
judged worse for the per-clip attribution's "indicate changes" cue.)
- **Left `.declaration` CSS in `style.css`** though now unused on credits — the
About page (other session) will reuse it; removing it would break that page.
## Compliance note
D1 (the altered-work CC BY-SA declaration) now depends on the About page actually
carrying it. **Confirm it landed on about.html before considering D1 done** — it is
currently in neither place on the live site until the About page ships + a redeploy.
## Next /goal
```
/goal confirm the About page (feat/accessibility-pass) carries the CC BY-SA 4.0 work-license declaration (D1, moved off credits in s0031), then redeploy design/cloudflare-static-publish to benstull.art per deploy/cloudflare/README.md
```
@@ -0,0 +1,56 @@
# Session 0032.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T09-20 (PST)
> End: 2026-06-30T09-24 (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
> Status: **FINALIZED**
## Launch prompt
After confirming that the "About this work" section removed in s0031 *was* the D1
compliance (the CC BY-SA share-alike declaration, not the per-clip attribution),
the operator: "Ok let's add that content back but can we name the section something
other than 'About This Work' so it isn't confused with the about page?"
## Arc
1. Confirmed for the operator that "About this work" carried D1 specifically — the
ShareAlike license declaration for the BY-SA source clips — while the per-clip
attribution (the original goal) was untouched and stayed on credits.
2. Claimed **session 0032**, isolated worktree off the branch tip (`8277417`).
3. Re-added the CC BY-SA 4.0 declaration to `credits.html` under the heading
**"License & reuse"** (deliberately not "About this work" — that name is reserved
for the separate About page from `feat/accessibility-pass`). Restored the footage
note's "altered as described above" now that the section is back above it. Reused
the `.declaration` CSS left in place by s0031.
4. Reverted the s0031 test changes: `credits.test.js` + `tests/test_e2e_credits.py`
now assert the declaration IS present, plus pin the rename (heading ≠ "About this
work", contains "License & reuse").
5. Verified: node 28/28, pytest 315 passed / 2 skipped (e2e confirms "CC BY-SA 4.0"
visible in-browser again). Committed `ee7c65a`, ff-merged + pushed, removed the
worktree, deleted the branch.
## End state
- `design/cloudflare-static-publish` @ `ee7c65a`, clean, pushed.
- Credits page now carries BOTH halves of CC compliance: per-clip CC-BY/BY-SA
attribution **and** the CC BY-SA 4.0 derivative declaration (under "License &
reuse"). The s0031 gap (live site missing the share-alike declaration) is closed.
- Site is live; reaches `benstull.art` only after a **redeploy**.
## Deferred decisions
- **Section heading "License & reuse"** chosen (operator delegated the name). It pairs
with the "Credits & licenses" page title and is unambiguous vs. the About page.
(Alternatives weighed: "This work's license", "License & alteration".)
## Next /goal
```
/goal redeploy design/cloudflare-static-publish to benstull.art (CC-BY/BY-SA credits + "License & reuse" D1 declaration now both on the credits page) per deploy/cloudflare/README.md; the About page lands separately via the feat/accessibility-pass session
```
@@ -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.
+36
View File
@@ -79,5 +79,41 @@
},
"0027": {
"title": ""
},
"0028": {
"title": ""
},
"0029": {
"title": ""
},
"0030": {
"title": ""
},
"0031": {
"title": ""
},
"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()
+320 -8
View File
@@ -233,6 +233,268 @@ AFFECT: dict[str, list[tuple]] = {
],
}
# --- Per-CLIP affect override (feelings drawn from THAT specific footage) --------
# When a clip is here, these feelings replace the scale's shared register for it —
# tailored to what the actual video evokes (mood, motion, light), keeping the
# scale's emotional family as a base. Reuses the scale `feel.*` keys where they fit
# (their es/fr/ja already exist) and adds new keys (with catalog translations) for
# nuances a clip needs. Same tuple shape: (key, [x,y], min_level, [4 tiers]).
AFFECT_CLIP: dict[str, list[tuple]] = {
"orbit_planetearth": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.radiance", [0.22, 0.68], 2, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"orbit_bluemarble": [
("feel.unity", [0.5, 0.44], 1, ["one", "unity", "belonging", "a borderless belonging"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.distance", [0.66, 0.58], 3, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"orbit_aurora2025": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.eeriness", [0.66, 0.58], 3, ["strange", "eeriness", "an otherworldly charge", "an electric, otherworldly hush"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"orbit_citylights": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.longing", [0.22, 0.68], 2, ["want", "longing", "yearning", "an aching yearning"]),
("feel.fragility", [0.66, 0.58], 3, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.unity", [0.42, 0.82], 4, ["one", "unity", "belonging", "a borderless belonging"]),
],
"orbit_helene": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.turbulence", [0.22, 0.68], 2, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"orbit_epic": [
("feel.distance", [0.5, 0.44], 1, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.insignificance", [0.22, 0.68], 2, ["small", "smallness", "insignificance", "a humbling insignificance"]),
("feel.unity", [0.66, 0.58], 3, ["one", "unity", "belonging", "a borderless belonging"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"sky_grca_templesa": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.exhilaration", [0.42, 0.82], 4, ["whee", "exhilaration", "elation", "a soaring elation"]),
],
"sky_greenland_landice": [
("feel.exhilaration", [0.5, 0.44], 1, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.purity", [0.66, 0.58], 3, ["pure", "purity", "stillness", "a glacial hush"]),
("feel.sublime", [0.42, 0.82], 4, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
],
"sky_greenland_suture": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.solitude", [0.66, 0.58], 3, ["alone", "solitude", "isolation", "a vast frozen solitude"]),
("feel.lightness", [0.42, 0.82], 4, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
],
"sky_jungle_amazon": [
("feel.freedom", [0.5, 0.44], 1, ["free", "freedom", "release", "a boundless release"]),
("feel.verdancy", [0.22, 0.68], 2, ["lush", "verdancy", "abundance", "a teeming green abundance"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"sky_jungle_waterfall": [
("feel.wonder", [0.5, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.vertigo", [0.22, 0.68], 2, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
("feel.freshness", [0.66, 0.58], 3, ["cool", "freshness", "vitality", "a cool, cascading freshness"]),
("feel.exhilaration", [0.42, 0.82], 4, ["whee", "exhilaration", "elation", "a soaring elation"]),
],
"sky_coast_cliffspain": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.exhilaration", [0.22, 0.68], 2, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.turbulence", [0.66, 0.58], 3, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"sky_mtn_castlecrags": [
("feel.freedom", [0.5, 0.44], 1, ["free", "freedom", "release", "a boundless release"]),
("feel.serenity", [0.22, 0.68], 2, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.lightness", [0.42, 0.82], 4, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
],
"sky_mtn_rocky": [
("feel.sublime", [0.5, 0.44], 1, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vertigo", [0.22, 0.68], 2, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
"coast_birdrock": [
("feel.melancholy", [0.5, 0.44], 1, ["sad", "melancholy", "longing", "a soft seaward longing"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.nostalgia", [0.66, 0.58], 3, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
("feel.serenity", [0.42, 0.82], 4, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
],
"coast_surfgrass": [
("feel.abundance", [0.5, 0.44], 1, ["full", "abundance", "richness", "a teeming richness"]),
("feel.immersion", [0.22, 0.68], 2, ["in", "immersion", "absorption", "a held, breathless absorption"]),
("feel.curiosity", [0.66, 0.58], 3, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.ease", [0.42, 0.82], 4, ["nice", "ease", "calm", "an unhurried calm"]),
],
"coast_kelp": [
("feel.immersion", [0.5, 0.44], 1, ["in", "immersion", "absorption", "a held, breathless absorption"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.freedom", [0.42, 0.82], 4, ["free", "freedom", "release", "a boundless release"]),
],
"coast_otters": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.delight", [0.22, 0.68], 2, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.ease", [0.66, 0.58], 3, ["nice", "ease", "calm", "an unhurried calm"]),
("feel.belonging", [0.42, 0.82], 4, ["home", "belonging", "rootedness", "a tidal rootedness"]),
],
"coast_kalaloch": [
("feel.nostalgia", [0.5, 0.44], 1, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
("feel.melancholy", [0.22, 0.68], 2, ["sad", "melancholy", "longing", "a soft seaward longing"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.belonging", [0.42, 0.82], 4, ["home", "belonging", "rootedness", "a tidal rootedness"]),
],
"coast_seals": [
("feel.tenderness", [0.5, 0.44], 1, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.repose", [0.22, 0.68], 2, ["rest", "repose", "drowse", "a sun-warmed drowse"]),
("feel.belonging", [0.66, 0.58], 3, ["home", "belonging", "rootedness", "a tidal rootedness"]),
("feel.delight", [0.42, 0.82], 4, ["fun", "delight", "joy", "a darting, bright joy"]),
],
"coast_mist": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.hush", [0.66, 0.58], 3, ["quiet", "hush", "stillness", "a breath-held hush"]),
("feel.nostalgia", [0.42, 0.82], 4, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
],
"reef_lionfish": [
("feel.curiosity", [0.5, 0.44], 1, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.poise", [0.22, 0.68], 2, ["still", "poise", "grace", "a hovering, ornate poise"]),
("feel.fragility", [0.66, 0.58], 3, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_spawning": [
("feel.abundance", [0.5, 0.44], 1, ["full", "abundance", "richness", "a teeming richness"]),
("feel.flow", [0.22, 0.68], 2, ["go", "flow", "streaming", "a sweeping, schooling flow"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_hawkfish": [
("feel.radiance", [0.5, 0.44], 1, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.curiosity", [0.22, 0.68], 2, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.delight", [0.66, 0.58], 3, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"reef_coralspacific": [
("feel.intricacy", [0.5, 0.44], 1, ["fine", "intricacy", "detail", "an intricate, woven density"]),
("feel.fragility", [0.22, 0.68], 2, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.abundance", [0.66, 0.58], 3, ["full", "abundance", "richness", "a teeming richness"]),
("feel.curiosity", [0.42, 0.82], 4, ["look", "curiosity", "fascination", "an absorbed fascination"]),
],
"reef_redsea": [
("feel.serenity", [0.5, 0.44], 1, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
("feel.radiance", [0.66, 0.58], 3, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.delight", [0.42, 0.82], 4, ["fun", "delight", "joy", "a darting, bright joy"]),
],
"reef_flowergarden": [
("feel.delight", [0.5, 0.44], 1, ["fun", "delight", "joy", "a darting, bright joy"]),
("feel.abundance", [0.22, 0.68], 2, ["full", "abundance", "richness", "a teeming richness"]),
("feel.freedom", [0.66, 0.58], 3, ["free", "freedom", "release", "a boundless release"]),
("feel.immersion", [0.42, 0.82], 4, ["in", "immersion", "absorption", "a held, breathless absorption"]),
],
"abyss_wow": [
("feel.vastness", [0.5, 0.44], 1, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.isolation", [0.22, 0.68], 2, ["alone", "isolation", "solitude", "a crushing solitude"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.unease", [0.42, 0.82], 4, ["uh", "unease", "disquiet", "a creeping disquiet"]),
],
"abyss_midwaterexp": [
("feel.fragility", [0.5, 0.44], 1, ["thin", "fragility", "vulnerability", "a tender fragility"]),
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.poignancy", [0.66, 0.58], 3, ["ache", "poignancy", "bittersweetness", "a luminous farewell"]),
("feel.isolation", [0.42, 0.82], 4, ["alone", "isolation", "solitude", "a crushing solitude"]),
],
"abyss_hiding": [
("feel.spectral", [0.5, 0.44], 1, ["ghost", "the spectral", "hauntedness", "a ghost in the water"]),
("feel.unease", [0.22, 0.68], 2, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"abyss_bigfin": [
("feel.alienness", [0.5, 0.44], 1, ["odd", "alienness", "strangeness", "an alien grace"]),
("feel.fascination", [0.22, 0.68], 2, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.unease", [0.66, 0.58], 3, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.isolation", [0.42, 0.82], 4, ["alone", "isolation", "solitude", "a crushing solitude"]),
],
"abyss_dandelion": [
("feel.radiance", [0.5, 0.44], 1, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.wonder", [0.22, 0.68], 2, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.fragility", [0.42, 0.82], 4, ["thin", "fragility", "vulnerability", "a tender fragility"]),
],
"abyss_octopus": [
("feel.curiosity", [0.5, 0.44], 1, ["look", "curiosity", "fascination", "an absorbed fascination"]),
("feel.isolation", [0.22, 0.68], 2, ["alone", "isolation", "solitude", "a crushing solitude"]),
("feel.tenderness", [0.66, 0.58], 3, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.unease", [0.42, 0.82], 4, ["uh", "unease", "disquiet", "a creeping disquiet"]),
],
"abyss_seapig": [
("feel.strangeness", [0.5, 0.44], 1, ["weird", "strangeness", "the uncanny", "a fleshy strangeness"]),
("feel.unease", [0.22, 0.68], 2, ["uh", "unease", "disquiet", "a creeping disquiet"]),
("feel.fascination", [0.66, 0.58], 3, ["ooh", "fascination", "wonder", "a forbidden wonder"]),
("feel.curiosity", [0.42, 0.82], 4, ["look", "curiosity", "fascination", "an absorbed fascination"]),
],
# cosmos — Webb "Cosmic Cliffs": monumental golden ridges, cathedral-scale.
"cosmos": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.sublime", [0.22, 0.68], 2, ["grand", "the sublime", "grandeur", "a cathedral grandeur"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
# cosmos_galaxies — drifting through a dark field of galaxies: lonely, remote.
"cosmos_galaxies": [
("feel.vastness", [0.50, 0.44], 1, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.distance", [0.22, 0.68], 2, ["far", "distance", "remoteness", "an exquisite remoteness"]),
("feel.insignificance", [0.66, 0.58], 3, ["small", "smallness", "insignificance", "a humbling insignificance"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_orion — soft rose-lit star nursery: tender, dreamy, warm.
"cosmos_orion": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.tenderness", [0.22, 0.68], 2, ["soft", "tenderness", "warmth", "a cradles warmth"]),
("feel.serenity", [0.66, 0.58], 3, ["calm", "serenity", "peace", "a quiet, weightless peace"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_tarantula — chaotic, fierce star-forming nebula: turbulent, intense.
"cosmos_tarantula": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.turbulence", [0.22, 0.68], 2, ["churn", "turbulence", "ferment", "a violent ferment"]),
("feel.vastness", [0.66, 0.58], 3, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
# cosmos_westerlund — sparkling jewel-box star cluster: dazzling, radiant.
"cosmos_westerlund": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.radiance", [0.22, 0.68], 2, ["bright", "radiance", "brilliance", "a jeweled brilliance"]),
("feel.exhilaration", [0.66, 0.58], 3, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.vastness", [0.42, 0.82], 4, ["big", "vastness", "immensity", "a dizzying immensity"]),
],
# cosmos_southernring — gas shed by a dying star: elegiac, mortal, poignant.
"cosmos_southernring": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.mortality", [0.22, 0.68], 2, ["end", "mortality", "impermanence", "a stars slow dying"]),
("feel.poignancy", [0.66, 0.58], 3, ["ache", "poignancy", "bittersweetness", "a luminous farewell"]),
("feel.longing", [0.42, 0.82], 4, ["want", "longing", "yearning", "an aching yearning"]),
],
# cosmos_carina_eso — wide teeming Milky Way star field: panoramic richness.
"cosmos_carina_eso": [
("feel.wonder", [0.50, 0.44], 1, ["wow", "wonder", "awe", "transcendent awe"]),
("feel.vastness", [0.22, 0.68], 2, ["big", "vastness", "immensity", "a dizzying immensity"]),
("feel.abundance", [0.66, 0.58], 3, ["full", "abundance", "richness", "a teeming richness"]),
("feel.insignificance", [0.42, 0.82], 4, ["small", "smallness", "insignificance", "a humbling insignificance"]),
],
}
def static_label(key, salience, tiers, box):
"""A fixed-box tiered label (always on-screen, salience-gated by Left)."""
@@ -269,6 +531,7 @@ LABELS: dict[str, list[dict]] = {
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_galaxies": [
measure("measure.count", "~2T galaxies", [0.06, 0.16, 0.24, 0.08], 2),
static_label("detected.galaxy", 4, ["galaxy", "spiral galaxy", "barred spiral", "barred spiral · ~10¹¹ stars"], [0.34, 0.30, 0.30, 0.34]),
measure("measure.distance", "~Mly", [0.06, 0.06, 0.18, 0.08], 3),
],
@@ -291,13 +554,19 @@ LABELS: dict[str, list[dict]] = {
],
"orbit_bluemarble": [
static_label("detected.globe", 4, ["Earth", "the globe", "terrestrial planet", "terrestrial planet · 12,742 km across"], [0.28, 0.18, 0.44, 0.6]),
# Rotating full-disk globe: continents drift through frame, so label the
# PERSISTENT features (clouds, starfield) with static boxes, not a continent.
static_label("detected.cloud_band", 3, ["clouds", "weather systems", "cloud systems", "global cloud systems · clouds cover ~67% of Earth"], [0.34, 0.42, 0.34, 0.32]),
static_label("detected.starfield", 1, ["stars", "starfield", "background stars", "background starfield · a rendered backdrop, not to scale"], [0.74, 0.05, 0.22, 0.30]),
],
# ---------- coast ----------
"coast_birdrock": [
static_label("detected.seabirds", 2, ["birds", "seabirds", "nesting seabirds", "seabird colony · the rookery that names the rock"], [0.35, 0.18, 0.25, 0.2]),
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · wind-driven, ~10 s period"], [0.20, 0.55, 0.6, 0.3]),
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.22, 0.3]),
],
"coast_surfgrass": [
static_label("detected.tidepool", 2, ["pool", "tidepool", "intertidal pool", "tidepool · a pocket sea bared at low tide"], [0.15, 0.6, 0.4, 0.25]),
static_label("detected.surfgrass", 4, ["grass", "surfgrass", "Phyllospadix", "Phyllospadix · a marine seagrass, not algae"], [0.18, 0.40, 0.5, 0.4]),
static_label("detected.coralline", 2, ["pink", "coralline algae", "crustose coralline", "crustose coralline · calcified red algae"], [0.6, 0.55, 0.2, 0.2]),
],
@@ -336,6 +605,7 @@ LABELS: dict[str, list[dict]] = {
measure("measure.depth", "22 m", [0.06, 0.06, 0.16, 0.08], 3),
],
"reef_hawkfish": [
static_label("detected.coral", 2, ["coral", "reef coral", "hard coral", "hard coral · the reef this fish grazes into sand"], [0.1, 0.6, 0.55, 0.3]),
tracked_label(
"detected.parrotfish", 4,
["fish", "parrotfish", "Bolbometopon muricatum", "B. muricatum · humphead, grazes reef into sand"],
@@ -353,6 +623,7 @@ LABELS: dict[str, list[dict]] = {
),
],
"reef_coralspacific": [
static_label("detected.polyp", 1, ["polyps", "coral polyps", "living polyps", "polyps · each a tiny animal, the colonys builders"], [0.25, 0.4, 0.3, 0.3]),
static_label("detected.coral", 4, ["coral", "coral colony", "Pacific scleractinian", "Pacific scleractinian · a symbiosis with algae"], [0.2, 0.4, 0.4, 0.4]),
tracked_label(
"detected.spotfish", 2,
@@ -393,6 +664,7 @@ LABELS: dict[str, list[dict]] = {
measure("measure.depth", "1,600 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_hiding": [
static_label("detected.marinesnow", 1, ["specks", "marine snow", "falling detritus", "marine snow · organic debris drifting down from above"], [0.1, 0.1, 0.8, 0.6]),
tracked_label(
"detected.jelly", 4,
["jelly", "crimson jelly", "Scyphozoa", "Scyphozoa · red is invisible in the lightless deep"],
@@ -454,55 +726,68 @@ LABELS: dict[str, list[dict]] = {
],
# ---------- sky ----------
"sky_grca_templesa": [
static_label("detected.butte", 2, ["tower", "butte", "rock temple", "rock temple · an erosional butte left standing"], [0.4, 0.3, 0.2, 0.35]),
static_label("detected.canyon", 4, ["canyon", "gorge", "the Grand Canyon", "Grand Canyon · ~1.8 Gyr of rock, cut by the Colorado"], [0.10, 0.38, 0.8, 0.52]),
static_label("detected.strata", 2, ["layers", "rock strata", "sedimentary beds", "strata · stacked epochs of deposition"], [0.15, 0.55, 0.6, 0.25]),
],
"sky_greenland_landice": [
static_label("detected.crevasse", 2, ["cracks", "crevasses", "glacial crevasses", "crevasses · the ice fracturing as it flows"], [0.2, 0.55, 0.5, 0.25]),
static_label("detected.icesheet", 4, ["ice", "ice sheet", "the Greenland ice sheet", "ice sheet · up to ~3 km thick, flowing slowly seaward"], [0.08, 0.40, 0.84, 0.5]),
static_label("detected.snow", 2, ["snow", "snowfield", "firn", "firn · old snow compacting toward glacial ice"], [0.20, 0.20, 0.6, 0.25]),
],
"sky_greenland_suture": [
static_label("detected.floe", 2, ["plates", "ice floes", "pack-ice floes", "pack-ice floes · drifting plates of frozen sea"], [0.15, 0.2, 0.3, 0.3]),
static_label("detected.seaice", 4, ["ice", "sea ice", "drift ice", "drift ice · a frozen ocean skin, cracking and refreezing"], [0.08, 0.10, 0.84, 0.8]),
static_label("detected.lead", 2, ["crack", "lead", "open lead", "lead · a fracture of open water between floes"], [0.30, 0.40, 0.4, 0.2]),
],
"sky_jungle_amazon": [
static_label("detected.mist", 1, ["haze", "canopy mist", "transpiration haze", "transpiration haze · the forest exhaling water vapor"], [0.06, 0.1, 0.88, 0.18]),
static_label("detected.canopy", 4, ["trees", "forest canopy", "the Amazon canopy", "rainforest canopy · among the densest biodiversity on Earth"], [0.06, 0.30, 0.88, 0.6]),
static_label("detected.emergent", 2, ["tree", "tall tree", "emergent tree", "emergent · a giant breaking above the canopy roof"], [0.42, 0.40, 0.16, 0.2]),
],
"sky_jungle_waterfall": [
static_label("detected.spray", 2, ["mist", "spray", "plunge spray", "plunge spray · the river aerosolized on impact"], [0.36, 0.62, 0.28, 0.25]),
static_label("detected.waterfall", 4, ["falls", "waterfall", "cataract", "cataract · a river plunging off the canopys edge"], [0.34, 0.20, 0.3, 0.7]),
static_label("detected.canopy", 2, ["trees", "jungle canopy", "rainforest canopy", "canopy · dense forest crowding the gorge"], [0.05, 0.30, 0.9, 0.5]),
],
"sky_coast_cliffspain": [
static_label("detected.headland", 1, ["point", "headland", "rock promontory", "promontory · a cliffed arm of land into the sea"], [0.1, 0.2, 0.3, 0.4]),
static_label("detected.seacliff", 4, ["cliff", "sea cliff", "coastal headland", "sea cliff · Atlantic rock cut back by the surf"], [0.10, 0.25, 0.8, 0.55]),
static_label("detected.surf", 2, ["waves", "surf", "breaking swell", "breaking swell · ocean meeting stone"], [0.15, 0.70, 0.7, 0.25]),
],
"sky_mtn_castlecrags": [
static_label("detected.talus", 2, ["scree", "talus", "talus slope", "talus · frost-shattered rock piled below the crags"], [0.3, 0.55, 0.4, 0.25]),
static_label("detected.spire", 4, ["rock", "spire", "granite spire", "granite spire · a glacier-carved pluton, exhumed and weathered"], [0.30, 0.20, 0.4, 0.6]),
static_label("detected.forest", 2, ["trees", "conifer forest", "montane forest", "montane forest · cloaking the slopes below the crags"], [0.05, 0.60, 0.9, 0.35]),
],
"sky_mtn_rocky": [
static_label("detected.snowfield", 2, ["snow", "snowfield", "alpine snowfield", "alpine snowfield · lingering high-elevation snow"], [0.3, 0.22, 0.3, 0.22]),
static_label("detected.summit", 4, ["peak", "summit", "alpine summit", "alpine summit · above tree line, snow-streaked granite"], [0.25, 0.20, 0.5, 0.5]),
static_label("detected.tundra", 2, ["meadow", "alpine tundra", "alpine tundra", "alpine tundra · low cushion plants above the trees"], [0.10, 0.62, 0.8, 0.3]),
],
# ---------- coast ----------
"coast_kelp": [
static_label("detected.pneumatocyst", 1, ["floats", "gas bladders", "pneumatocysts", "pneumatocysts · gas floats lifting the blades to light"], [0.4, 0.18, 0.2, 0.25]),
static_label("detected.kelp", 4, ["kelp", "giant kelp", "Macrocystis pyrifera", "Macrocystis · can grow ~0.5 m a day toward the light"], [0.20, 0.10, 0.6, 0.8]),
static_label("detected.frond", 2, ["leaf", "blade", "kelp frond", "frond · gas-filled floats hold it upright"], [0.40, 0.30, 0.2, 0.4]),
],
"coast_otters": [
static_label("detected.fur", 1, ["fur", "dense fur", "the densest fur", "densest fur on Earth · ~1M hairs/in², no blubber"], [0.34, 0.38, 0.3, 0.25]),
static_label("detected.otter", 4, ["otter", "sea otter", "Enhydra lutris", "Enhydra lutris · eats ~25% of its weight a day to stay warm"], [0.30, 0.35, 0.4, 0.35]),
static_label("detected.water", 2, ["water", "estuary", "tidal slough", "tidal slough · sheltered nursery water"], [0.05, 0.05, 0.9, 0.25]),
],
"coast_kalaloch": [
static_label("detected.sunset", 2, ["glow", "sunset", "golden hour", "golden hour · low sun reddened through more air"], [0.05, 0.05, 0.9, 0.22]),
static_label("detected.surf", 4, ["waves", "surf", "breaking swell", "breaking swell · long-period Pacific swell"], [0.15, 0.50, 0.7, 0.35]),
static_label("detected.searock", 2, ["rock", "sea stack", "coastal sea stack", "sea stack · wave-cut residual rock"], [0.30, 0.25, 0.3, 0.3]),
],
"coast_seals": [
static_label("detected.whiskers", 1, ["whiskers", "vibrissae", "sensing whiskers", "vibrissae · whiskers that feel prey in murky water"], [0.3, 0.42, 0.2, 0.15]),
static_label("detected.seal", 4, ["seals", "harbor seals", "Phoca vitulina", "Phoca vitulina · hauls out on rock to rest and warm"], [0.20, 0.40, 0.6, 0.35]),
static_label("detected.searock", 2, ["rock", "haul-out rock", "intertidal rock", "haul-out · a tide-washed resting ledge"], [0.05, 0.6, 0.9, 0.3]),
],
"coast_mist": [
static_label("detected.swell", 2, ["waves", "swell", "ocean swell", "ocean swell · wind-built waves from distant storms"], [0.1, 0.58, 0.8, 0.25]),
static_label("detected.mist", 4, ["mist", "sea mist", "advection fog", "advection fog · warm air cooling over cold upwelling"], [0.05, 0.10, 0.9, 0.5]),
static_label("detected.searock", 2, ["rock", "shore rock", "coastal rock", "coastal rock · the standing edge of the land"], [0.20, 0.55, 0.6, 0.35]),
],
@@ -519,19 +804,43 @@ LABELS: dict[str, list[dict]] = {
],
# ---------- abyss ----------
"abyss_bigfin": [
static_label("detected.squid", 4, ["squid", "bigfin squid", "Magnapinna", "Magnapinna · elbowed arms trailing meters into the dark"], [0.30, 0.20, 0.4, 0.65]),
static_label("detected.arms", 2, ["arms", "elbowed arms", "trailing filaments", "elbowed arms · held out, then trailing meters of thread"], [0.35, 0.5, 0.3, 0.4]),
tracked_label(
"detected.squid", 4,
["squid", "bigfin squid", "Magnapinna", "Magnapinna · elbowed arms trailing meters into the dark"],
0.0, 1.0,
[(0.0, [0.3, 0.36, 0.28, 0.3]), (0.5, [0.42, 0.22, 0.26, 0.28]), (1.0, [0.55, 0.1, 0.26, 0.3])],
),
measure("measure.depth", "2,000 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_dandelion": [
static_label("detected.siphonophore", 4, ["orb", "siphonophore", "dandelion siphonophore", "Rhodaliidae · a colony of clones tethered to the seabed"], [0.32, 0.28, 0.36, 0.4]),
static_label("detected.tentacle", 2, ["threads", "tentacles", "feeding tentacles", "feeding tentacles · a drifting net for prey"], [0.3, 0.45, 0.4, 0.3]),
tracked_label(
"detected.siphonophore", 4,
["orb", "siphonophore", "dandelion siphonophore", "Rhodaliidae · a colony of clones tethered to the seabed"],
0.0, 1.0,
[(0.0, [0.38, 0.28, 0.26, 0.36]), (0.5, [0.3, 0.2, 0.34, 0.46]), (1.0, [0.18, 0.1, 0.45, 0.7])],
),
measure("measure.depth", "2,500 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_octopus": [
static_label("detected.octopus", 4, ["octopus", "deep-sea octopus", "Graneledone", "Graneledone boreopacifica · broods its eggs for ~4.5 years"], [0.25, 0.30, 0.5, 0.45]),
static_label("detected.arms", 2, ["arms", "eight arms", "sucker-lined arms", "eight arms · sucker-lined, tasting what they touch"], [0.25, 0.45, 0.5, 0.3]),
tracked_label(
"detected.octopus", 4,
["octopus", "deep-sea octopus", "Graneledone", "Graneledone boreopacifica · broods its eggs for ~4.5 years"],
0.0, 1.0,
[(0.0, [0.5, 0.1, 0.35, 0.55]), (0.5, [0.42, 0.16, 0.36, 0.52]), (1.0, [0.05, 0.02, 0.92, 0.95])],
),
measure("measure.depth", "2,500 m", [0.06, 0.06, 0.16, 0.08], 2),
],
"abyss_seapig": [
static_label("detected.seacucumber", 4, ["blob", "sea cucumber", "Enypniastes eximia", "Enypniastes · a swimming sea cucumber, the “headless chicken”"], [0.30, 0.30, 0.4, 0.4]),
static_label("detected.veil", 2, ["veil", "oral veil", "swimming veil", "oral veil · sweeps sediment, and flaps to swim"], [0.3, 0.3, 0.35, 0.25]),
tracked_label(
"detected.seacucumber", 4,
["blob", "sea cucumber", "Enypniastes eximia", "Enypniastes · a swimming sea cucumber, the “headless chicken”"],
0.0, 1.0,
[(0.0, [0.58, 0.38, 0.18, 0.3]), (0.5, [0.46, 0.32, 0.19, 0.32]), (1.0, [0.38, 0.28, 0.2, 0.36])],
),
measure("measure.depth", "2,700 m", [0.06, 0.06, 0.16, 0.08], 2),
],
}
@@ -540,10 +849,13 @@ LABELS: dict[str, list[dict]] = {
# data above; this maps measurement keys to nothing extra (their value IS the string).
def _affect_for_clip(scale: str) -> tuple[list, dict]:
"""Build the affect list + its strings for a scale's pool member."""
def _affect_for_clip(scale: str, clip_id: str) -> tuple[list, dict]:
"""Build the affect list + its strings for a clip. A per-clip override in
AFFECT_CLIP (feelings drawn from THAT footage) wins; otherwise the scale's
shared register applies."""
source = AFFECT_CLIP.get(clip_id, AFFECT[scale])
entries, strings = [], {}
for key, at, min_level, tiers in AFFECT[scale]:
for key, at, min_level, tiers in source:
entries.append({"key": key, "at": at, "min_level": min_level})
strings[key] = tiers
return entries, strings
@@ -562,7 +874,7 @@ def _labels_for_clip(clip_id: str) -> tuple[list, dict]:
def _clip_entry(scale: str, clip_id: str) -> dict:
title, license_, source = META[clip_id]
anns, lab_strings = _labels_for_clip(clip_id)
affect, aff_strings = _affect_for_clip(scale)
affect, aff_strings = _affect_for_clip(scale, clip_id)
return {
"id": clip_id,
"title": title,
@@ -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();
});
});
+85 -30
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,28 +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 2/10 and dismisses the Run prompt", 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");
// "Run simulation" is revealed once preload finishes — the obvious starting point.
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(false);
// Turning Video on directly (a click gesture) lifts the audio dial to a gentle 2/10
// and dismisses the Run prompt.
await enableVideo(page);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
});
test("the Run simulation button starts video, snaps audio to 2/10, and dismisses itself", async ({ page }) => {
await boot(page);
await page.click("#run-sim");
await expect.poll(() => page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(true);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
});
// (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);
@@ -127,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())!;
@@ -176,6 +171,38 @@ test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)",
expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0
});
test("ascending re-lands anchored at the clip head (no reverse-landing jump)", async ({ page }) => {
// D3: a morph spans src@0 (frac 0) -> dst@~3s (frac 1). Descending lands on dst at its
// tail (~3s); ASCENDING lands on src at its HEAD (~0s). The steady loop must continue
// from 0 there — seeking to the tail (~3s) was the ~3s reverse-landing jump.
await boot(page);
await enableVideo(page); // base loop only loads when video is shown
// Descend one altitude (cosmos -> orbit), then ascend back (orbit -> cosmos): the
// ascent is the reverse landing under test.
const start = (await page.locator("#scale-name").textContent())!;
await wheelOnStage(page, 60); // descend
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
const mid = (await page.locator("#scale-name").textContent())!;
expect(mid).toContain("orbit");
await wheelOnStage(page, -60); // ascend back
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, mid, { timeout: 20000 });
expect(await page.locator("#scale-name").textContent()).toContain("cosmos");
// The loop element is now anchored at the HEAD (loopStart "0"), playing — not jumped to
// the ~3s tail the descending case uses.
await page.waitForFunction(
() => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.dataset.loopStart === "0"; },
null,
{ timeout: 10000 },
);
const v = await page.evaluate(() => {
const el = document.querySelector("#vid-loop") as HTMLVideoElement;
return { loopStart: el.dataset.loopStart, loopTail: el.dataset.loopTail, paused: el.paused };
});
expect(v.loopTail).toBe("1"); // tail-loop machinery armed
expect(v.loopStart).toBe("0"); // anchored at the head, not the morph tail
expect(v.paused).toBe(false); // the loop is actually playing
});
test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => {
await boot(page);
const start = (await page.locator("#scale-name").textContent())!;
@@ -241,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 });
@@ -258,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);
});
+11 -2
View File
@@ -1,14 +1,22 @@
import { test, expect } from "@playwright/test";
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-experience-simulator/";
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([]);
});
@@ -17,6 +25,7 @@ test("dream shader runs on cross-origin media without tainting the GL texture",
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(() => {
+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.
File diff suppressed because it is too large Load Diff
+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>
+650 -158
View File
File diff suppressed because it is too large Load Diff
+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>
+8 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Credits — Human Experience Filter</title>
<title>Credits — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
@@ -19,7 +19,7 @@
<section class="declaration">
<h2>License &amp; reuse</h2>
<p>
The <em>Human Experience Filter</em> alters its source footage in real time
<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>
@@ -48,13 +48,18 @@
<section>
<h2>Sound</h2>
<p class="note">
Soundtracks are public domain or CC0 — credit is courtesy, not required.
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>
+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
});
+13 -4
View File
@@ -17,20 +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: "宇宙を読み込み中" },
"run.button": { en: "Run simulation", es: "Iniciar simulación", fr: "Lancer la simulation", 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: "空" },
+93 -25
View File
@@ -3,18 +3,56 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<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>
<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>
@@ -24,43 +62,71 @@
<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"></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>
<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>
<button type="button" id="run-sim" class="run-sim hidden" data-i18n="run.button">Run simulation</button>
</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>
@@ -114,6 +180,8 @@
<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>
+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 };
});
+38 -1
View File
@@ -29,6 +29,43 @@
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
// on dst at frac 1, whose frame is dst@loopTailS, so the loop resumes at loopTailS
// (and the morph-covered intro never re-shows). Ascending (dir<0) lands on src at
// frac 0, whose frame is src@0, so the loop must resume at 0 — seeking to loopTailS
// there is the ~3s reverse-landing jump (D3). dir 0 defaults to the forward/tail
// case (matches the loadLoop default used by non-scrub paths).
function loopLandFrame(dir, loopTailS) {
return dir < 0 ? 0 : loopTailS;
}
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
// exactly on an integer counts as crossing it (it commits that altitude).
function integerCrossings(prevPos, pos) {
@@ -41,5 +78,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, pickNextIndex, loopLandFrame, integerCrossings };
});
+65 -18
View File
@@ -76,29 +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; }
/* "Run simulation" the obvious starting point, shown over the (black) stage once
media is preloaded. Sits above the black cover (z-index 50). Dismissed when the
experience begins (the button, or turning Video on directly). */
/* "Launch Simulator" the single entry button on the welcome screen. Sits in
the welcome overlay's flow (centered column), not absolutely positioned. */
.run-sim {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 60; cursor: pointer; user-select: none;
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: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
.run-sim:active { transform: translate(-50%, -50%) scale(0.98); }
.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; }
@@ -112,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;
@@ -139,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. */
@@ -167,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;
+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);
});
+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");
});
+14
View File
@@ -58,3 +58,17 @@ test("integerCrossings: multiple crossings in travel order", () => {
assert.deepEqual(S.integerCrossings(2.4, 4.1), [{ index: 3, dir: 1 }, { index: 4, dir: 1 }]);
assert.deepEqual(S.integerCrossings(3.2, 1.8), [{ index: 3, dir: -1 }, { index: 2, dir: -1 }]);
});
test("loopLandFrame: descend lands on the morph's tail frame, ascend on its head", () => {
// A morph spans src@0 (frac 0) -> dst@loopTailS (frac 1). Descending (dir>0) lands
// on dst at frac 1 -> the base loop must continue from loopTailS. Ascending (dir<0)
// lands on src at frac 0 -> the loop must continue from 0 (NOT loopTailS — that was
// the D3 reverse-landing jump).
assert.equal(S.loopLandFrame(1, 3), 3); // descend -> tail
assert.equal(S.loopLandFrame(-1, 3), 0); // ascend -> head
// dir 0 (no travel) is treated as a forward/tail landing (the default everywhere else).
assert.equal(S.loopLandFrame(0, 3), 3);
// honors any tail value
assert.equal(S.loopLandFrame(1, 2.5), 2.5);
assert.equal(S.loopLandFrame(-1, 2.5), 0);
});
+17 -5
View File
@@ -9,9 +9,9 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
result = build_static(
out, media,
media_base="https://static.benstull.art/",
app_path="/human-experience-simulator",
app_path="/human-machine-strata",
)
app_dir = out / "human-experience-simulator"
app_dir = out / "human-machine-strata"
# frontend present under the app path; dev/author surfaces excluded
assert (app_dir / "index.html").exists()
@@ -25,10 +25,12 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
assert not (app_dir / "author.html").exists()
assert not list(app_dir.glob("review*.html"))
# apex + no-slash redirect rules
# apex + no-slash redirect rules, plus the permanent legacy-path redirect
redirects = (out / "_redirects").read_text()
assert "/ /human-experience-simulator/ 308" in redirects
assert "/human-experience-simulator /human-experience-simulator/ 308" in redirects
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())
@@ -51,3 +53,13 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
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}"
+53 -38
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, dialplay/volume, video-off
the WIRING (boot-silent, launch starts video+audio, dialplay/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. The "Run simulation" button (or turning Video ON directly) couples
the audio dial up to 2/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,19 +118,18 @@ def test_app_boots_silent_video_off(page):
) is True
def test_run_simulation_button_shows_after_load(page):
# Once media is preloaded the "Run simulation" prompt is revealed over the (still
# black) stage — the obvious starting point — while the experience stays silent.
page.wait_for_selector("#run-sim:not(.hidden)")
assert page.is_checked("#visual") is False
assert page.evaluate("document.getElementById('audio').value") == "0"
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_run_simulation_button_starts_video_and_audio(page):
# Pressing "Run simulation" (a click gesture) turns Video on, snaps the audio dial
# to 2/10, dismisses itself, and starts the soundtrack IN that gesture.
page.click("#run-sim")
page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed
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'")
@@ -123,21 +140,17 @@ def test_run_simulation_button_starts_video_and_audio(page):
)
def test_video_on_directly_couples_audio_to_two_and_dismisses_button(page):
# Turning Video on directly (skipping the Run simulation button) ALSO begins the
# experience: audio snaps to 2/10, the soundtrack plays, and the button goes away.
_toggle_visual(page) # off -> on
page.wait_for_function("document.getElementById('audio').value === '2'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'")
page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed
page.wait_for_selector("#black.hidden", state="attached") # video showing
page.wait_for_function(
"(() => { const a = document.getElementById('aud');"
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused; })()"
)
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'")
@@ -145,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');"
@@ -154,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)")
@@ -169,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(
+9 -4
View File
@@ -74,8 +74,13 @@ def test_credits_page_lists_video_attributions(browser_page):
def test_experience_links_to_credits(browser_page):
page = browser_page
page.goto(page._app_url + "/")
page.wait_for_selector("#loading", state="detached", timeout=60000)
href = page.get_attribute(".credits-link", "href")
assert href == "credits.html"
page.click(".credits-link")
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)
+68 -7
View File
@@ -25,6 +25,7 @@ sibling tools/http.py shadows the stdlib `http` that fastapi/starlette import:
"""
from __future__ import annotations
import hashlib
import json
import shutil
from pathlib import Path
@@ -35,8 +36,8 @@ 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", "style.css",
"credits.html", "credits.js"]
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:
@@ -58,15 +59,69 @@ def _write_config(app_dir: Path, media_base: str) -> None:
)
# 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). Cloudflare Pages `_redirects` syntax.
(out / "_redirects").write_text(
f"/ /{seg}/ 308\n"
f"/{seg} /{seg}/ 308\n"
# 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)
@@ -85,7 +140,9 @@ def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
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.
@@ -94,6 +151,10 @@ def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
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
@@ -115,7 +176,7 @@ if __name__ == "__main__":
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")
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))
File diff suppressed because it is too large Load Diff
+658 -15
View File
@@ -36,6 +36,12 @@
"anhelo",
"añoranza",
"una añoranza punzante"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
]
},
"cosmos_galaxies": {
@@ -69,6 +75,13 @@
"anhelo",
"añoranza",
"una añoranza punzante"
],
"measure.count": "~2 billones de galaxias",
"feel.distance": [
"lejos",
"distancia",
"lejanía",
"una lejanía exquisita"
]
},
"cosmos_orion": {
@@ -108,7 +121,19 @@
"estrella del Trapecio",
"Trapecio · cálidas estrellas O que iluminan la nebulosa"
],
"measure.distance": "≈1.344 al"
"measure.distance": "≈1.344 al",
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
]
},
"cosmos_tarantula": {
"feel.wonder": [
@@ -147,7 +172,13 @@
"R136",
"R136 · reúne algunas de las estrellas más masivas conocidas"
],
"measure.distance": "≈160.000 al"
"measure.distance": "≈160.000 al",
"feel.turbulence": [
"agitación",
"turbulencia",
"fermento",
"un fermento violento"
]
},
"cosmos_westerlund": {
"feel.wonder": [
@@ -186,7 +217,19 @@
"estrella O-type",
"O-type · blanca azulada, decenas de masas solares"
],
"measure.distance": "≈20.000 al"
"measure.distance": "≈20.000 al",
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
],
"feel.exhilaration": [
"yupi",
"euforia",
"júbilo",
"un júbilo que se eleva"
]
},
"cosmos_southernring": {
"feel.wonder": [
@@ -225,7 +268,19 @@
"enana blanca",
"enana blanca · el núcleo estelar caliente que queda"
],
"measure.distance": "≈2.500 al"
"measure.distance": "≈2.500 al",
"feel.mortality": [
"fin",
"mortalidad",
"fugacidad",
"la lenta muerte de una estrella"
],
"feel.poignancy": [
"punzada",
"desgarro",
"melancolía dulce",
"una despedida luminosa"
]
},
"cosmos_carina_eso": {
"feel.wonder": [
@@ -264,7 +319,13 @@
"estrella masiva",
"estrella masiva · la que esculpe los acantilados de Carina"
],
"measure.distance": "≈7.500 al"
"measure.distance": "≈7.500 al",
"feel.abundance": [
"lleno",
"abundancia",
"riqueza",
"una riqueza rebosante"
]
},
"orbit_planetearth": {
"detected.cloud_band": [
@@ -303,6 +364,18 @@
"distancia",
"lejanía",
"una lejanía exquisita"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
]
},
"orbit_bluemarble": {
@@ -312,6 +385,18 @@
"planeta terrestre",
"planeta terrestre · 12.742 km de diámetro"
],
"detected.cloud_band": [
"nubes",
"sistemas meteorológicos",
"sistemas nubosos",
"sistemas nubosos globales · las nubes cubren ~67% de la Tierra"
],
"detected.starfield": [
"estrellas",
"campo estelar",
"estrellas de fondo",
"campo estelar de fondo · un telón renderizado, sin escala"
],
"feel.serenity": [
"calma",
"serenidad",
@@ -335,6 +420,12 @@
"distancia",
"lejanía",
"una lejanía exquisita"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"orbit_aurora2025": {
@@ -374,7 +465,25 @@
"limbo atmosférico",
"limbo atmosférico · ~100 km de aire, brillando de canto"
],
"measure.altitude": "~408 km"
"measure.altitude": "~408 km",
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.eeriness": [
"extraño",
"inquietud",
"una carga sobrenatural",
"un susurro eléctrico y sobrenatural"
]
},
"orbit_citylights": {
"feel.serenity": [
@@ -413,7 +522,19 @@
"luminiscencia atmosférica",
"luminiscencia del aire · la débil emisión nocturna de la alta atmósfera"
],
"measure.altitude": "~408 km"
"measure.altitude": "~408 km",
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
],
"feel.longing": [
"querer",
"anhelo",
"añoranza",
"una añoranza punzante"
]
},
"orbit_helene": {
"feel.serenity": [
@@ -452,7 +573,25 @@
"limbo atmosférico",
"limbo atmosférico · la fina capa donde vive el clima"
],
"measure.altitude": "~408 km"
"measure.altitude": "~408 km",
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.turbulence": [
"agitación",
"turbulencia",
"fermento",
"un fermento violento"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"orbit_epic": {
"feel.serenity": [
@@ -491,7 +630,13 @@
"sistemas meteorológicos",
"sistemas meteorológicos · arremolinándose sobre un planeta que gira"
],
"measure.distance": "~1,5 M km"
"measure.distance": "~1,5 M km",
"feel.insignificance": [
"pequeño",
"pequeñez",
"insignificancia",
"una insignificancia que humilla"
]
},
"sky_grca_templesa": {
"feel.exhilaration": [
@@ -529,6 +674,30 @@
"estratos de roca",
"lechos sedimentarios",
"estratos · épocas de sedimentación apiladas"
],
"detected.butte": [
"torre",
"cerro testigo",
"templo de roca",
"templo de roca · un cerro testigo de la erosión"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"sky_greenland_landice": {
@@ -567,6 +736,30 @@
"nevero",
"neviza",
"neviza · nieve vieja que se compacta hacia hielo glaciar"
],
"detected.crevasse": [
"grietas",
"grietas",
"grietas glaciares",
"grietas · el hielo fracturándose al fluir"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
],
"feel.purity": [
"puro",
"pureza",
"quietud",
"un silencio glacial"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
]
},
"sky_greenland_suture": {
@@ -605,6 +798,30 @@
"canal",
"canal abierto",
"canal · una fractura de agua abierta entre témpanos"
],
"detected.floe": [
"placas",
"témpanos",
"témpanos de banquisa",
"témpanos · placas de mar congelado a la deriva"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
],
"feel.solitude": [
"solo",
"soledad",
"aislamiento",
"una vasta soledad helada"
]
},
"sky_jungle_amazon": {
@@ -643,6 +860,24 @@
"árbol alto",
"árbol emergente",
"emergente · un gigante que rompe sobre el techo del dosel"
],
"detected.mist": [
"bruma",
"bruma del dosel",
"bruma de transpiración",
"bruma de transpiración · el bosque exhalando vapor"
],
"feel.verdancy": [
"frondoso",
"verdor",
"exuberancia",
"una exuberancia verde y rebosante"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
]
},
"sky_jungle_waterfall": {
@@ -681,6 +916,24 @@
"dosel de la jungla",
"dosel selvático",
"dosel · bosque denso que abarrota la garganta"
],
"detected.spray": [
"rocío",
"aerosol",
"rocío de caída",
"rocío de caída · el río pulverizado al impactar"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.freshness": [
"fresco",
"frescura",
"vitalidad",
"una frescura que cae en cascada"
]
},
"sky_coast_cliffspain": {
@@ -719,6 +972,30 @@
"rompiente",
"oleaje rompiente",
"oleaje rompiente · el océano que encuentra la piedra"
],
"detected.headland": [
"punta",
"promontorio",
"promontorio rocoso",
"promontorio · un brazo de tierra acantilado en el mar"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.turbulence": [
"agitación",
"turbulencia",
"fermento",
"un fermento violento"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"sky_mtn_castlecrags": {
@@ -757,6 +1034,24 @@
"bosque de coníferas",
"bosque montano",
"bosque montano · cubriendo las laderas bajo los riscos"
],
"detected.talus": [
"pedrera",
"talud",
"talud de derrubios",
"talud · roca rota por el hielo amontonada bajo los riscos"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"sky_mtn_rocky": {
@@ -795,6 +1090,24 @@
"tundra alpina",
"tundra alpina",
"tundra alpina · plantas en cojín bajas sobre los árboles"
],
"detected.snowfield": [
"nieve",
"nevero",
"nevero alpino",
"nevero alpino · nieve persistente de gran altitud"
],
"feel.sublime": [
"grandioso",
"lo sublime",
"grandeza",
"una grandeza catedralicia"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"coast_birdrock": {
@@ -833,6 +1146,24 @@
"melancolía",
"anhelo",
"un suave anhelo hacia el mar"
],
"detected.seabirds": [
"aves",
"aves marinas",
"aves marinas anidando",
"colonia de aves marinas · el criadero que da nombre a la roca"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
]
},
"coast_surfgrass": {
@@ -871,6 +1202,30 @@
"melancolía",
"anhelo",
"un suave anhelo hacia el mar"
],
"detected.tidepool": [
"charca",
"poza de marea",
"poza intermareal",
"poza de marea · un mar en miniatura al bajar la marea"
],
"feel.abundance": [
"lleno",
"abundancia",
"riqueza",
"una riqueza rebosante"
],
"feel.immersion": [
"dentro",
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"feel.curiosity": [
"mira",
"curiosidad",
"fascinación",
"una fascinación absorta"
]
},
"coast_kelp": {
@@ -909,6 +1264,36 @@
"lámina",
"fronda de alga",
"fronda · flotadores llenos de gas la mantienen erguida"
],
"detected.pneumatocyst": [
"flotadores",
"vejigas de gas",
"neumatocistos",
"neumatocistos · flotadores de gas que alzan las hojas a la luz"
],
"feel.immersion": [
"dentro",
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.freedom": [
"libre",
"libertad",
"liberación",
"una liberación sin límites"
]
},
"coast_otters": {
@@ -947,6 +1332,24 @@
"estuario",
"estero de marea",
"estero de marea · aguas resguardadas de crianza"
],
"detected.fur": [
"pelaje",
"pelaje denso",
"el pelaje más denso",
"el pelaje más denso de la Tierra · ~1 M de pelos/in², sin grasa"
],
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
],
"feel.delight": [
"divertido",
"deleite",
"alegría",
"una alegría viva y centelleante"
]
},
"coast_kalaloch": {
@@ -985,6 +1388,18 @@
"farallón",
"farallón costero",
"farallón · roca residual tallada por las olas"
],
"detected.sunset": [
"resplandor",
"atardecer",
"hora dorada",
"hora dorada · el sol bajo enrojecido por más atmósfera"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
]
},
"coast_seals": {
@@ -1023,6 +1438,30 @@
"roca de descanso",
"roca intermareal",
"descansadero · una repisa de descanso bañada por la marea"
],
"detected.whiskers": [
"bigotes",
"vibrisas",
"bigotes sensores",
"vibrisas · bigotes que detectan presas en agua turbia"
],
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
],
"feel.repose": [
"descanso",
"reposo",
"sopor",
"un sopor entibiado por el sol"
],
"feel.delight": [
"divertido",
"deleite",
"alegría",
"una alegría viva y centelleante"
]
},
"coast_mist": {
@@ -1061,6 +1500,30 @@
"roca de orilla",
"roca costera",
"roca costera · el borde firme de la tierra"
],
"detected.swell": [
"olas",
"mar de fondo",
"oleaje de fondo",
"mar de fondo · olas formadas por tormentas lejanas"
],
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.hush": [
"silencio",
"quietud",
"sosiego",
"un silencio contenido"
]
},
"reef_lionfish": {
@@ -1100,6 +1563,18 @@
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"feel.poise": [
"quieto",
"aplomo",
"gracia",
"un aplomo suspendido y ornado"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
]
},
"reef_spawning": {
@@ -1139,6 +1614,18 @@
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"feel.flow": [
"fluir",
"flujo",
"corriente",
"un flujo de cardumen que barre"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"reef_hawkfish": {
@@ -1172,6 +1659,18 @@
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"detected.coral": [
"coral",
"coral de arrecife",
"coral duro",
"coral duro · el arrecife que este pez muele en arena"
],
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
]
},
"reef_coralspacific": {
@@ -1210,6 +1709,24 @@
"inmersión",
"absorción",
"una absorción contenida y sin aliento"
],
"detected.polyp": [
"pólipos",
"pólipos de coral",
"pólipos vivos",
"pólipos · cada uno un animal diminuto, constructores de la colonia"
],
"feel.intricacy": [
"fino",
"complejidad",
"detalle",
"una densidad intrincada y tejida"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
]
},
"reef_redsea": {
@@ -1249,7 +1766,19 @@
"antias",
"antias · nubes naranjas de comeplancton sobre el arrecife"
],
"measure.depth": "10 m"
"measure.depth": "10 m",
"feel.serenity": [
"calma",
"serenidad",
"paz",
"una paz serena e ingrávida"
],
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
]
},
"reef_flowergarden": {
"feel.delight": [
@@ -1288,7 +1817,13 @@
"Abudefduf saxatilis",
"Abudefduf · la damisela rayada “sargento mayor”"
],
"measure.depth": "20 m"
"measure.depth": "20 m",
"feel.freedom": [
"libre",
"libertad",
"liberación",
"una liberación sin límites"
]
},
"abyss_wow": {
"detected.jelly": [
@@ -1327,6 +1862,12 @@
"pavor",
"presagio",
"un presagio lento y frío"
],
"feel.vastness": [
"grande",
"vastedad",
"inmensidad",
"una inmensidad vertiginosa"
]
},
"abyss_midwaterexp": {
@@ -1366,6 +1907,18 @@
"pavor",
"presagio",
"un presagio lento y frío"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
],
"feel.poignancy": [
"punzada",
"desgarro",
"melancolía dulce",
"una despedida luminosa"
]
},
"abyss_hiding": {
@@ -1399,6 +1952,24 @@
"pavor",
"presagio",
"un presagio lento y frío"
],
"detected.marinesnow": [
"motas",
"nieve marina",
"detrito que cae",
"nieve marina · restos orgánicos que descienden desde arriba"
],
"feel.spectral": [
"fantasma",
"lo espectral",
"lo fantasmal",
"un fantasma en el agua"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
]
},
"abyss_bigfin": {
@@ -1432,7 +2003,19 @@
"Magnapinna",
"Magnapinna · brazos acodados que se extienden metros en la oscuridad"
],
"measure.depth": "2.000 m"
"measure.depth": "2.000 m",
"detected.arms": [
"brazos",
"brazos acodados",
"filamentos colgantes",
"brazos acodados · extendidos y luego colgando metros de hilo"
],
"feel.alienness": [
"raro",
"lo alienígena",
"extrañeza",
"una gracia alienígena"
]
},
"abyss_dandelion": {
"feel.unease": [
@@ -1465,7 +2048,31 @@
"sifonóforo diente de león",
"Rhodaliidae · una colonia de clones anclada al lecho marino"
],
"measure.depth": "2.500 m"
"measure.depth": "2.500 m",
"detected.tentacle": [
"hilos",
"tentáculos",
"tentáculos de caza",
"tentáculos de caza · una red a la deriva para atrapar presas"
],
"feel.radiance": [
"brillo",
"resplandor",
"fulgor",
"un fulgor de joyas"
],
"feel.wonder": [
"guau",
"asombro",
"sobrecogimiento",
"sobrecogimiento trascendente"
],
"feel.fragility": [
"fino",
"fragilidad",
"vulnerabilidad",
"una fragilidad tierna"
]
},
"abyss_octopus": {
"feel.unease": [
@@ -1498,7 +2105,25 @@
"Graneledone",
"Graneledone boreopacifica · incuba sus huevos durante ~4,5 años"
],
"measure.depth": "2.500 m"
"measure.depth": "2.500 m",
"detected.arms": [
"brazos",
"ocho brazos",
"brazos con ventosas",
"ocho brazos · con ventosas que saborean lo que tocan"
],
"feel.curiosity": [
"mira",
"curiosidad",
"fascinación",
"una fascinación absorta"
],
"feel.tenderness": [
"suave",
"ternura",
"calidez",
"la calidez de una cuna"
]
},
"abyss_seapig": {
"feel.unease": [
@@ -1531,6 +2156,24 @@
"Enypniastes eximia",
"Enypniastes · un pepino de mar nadador, el “pollo sin cabeza”"
],
"measure.depth": "2.700 m"
"measure.depth": "2.700 m",
"detected.veil": [
"velo",
"velo oral",
"velo natatorio",
"velo oral · barre el sedimento y aletea para nadar"
],
"feel.strangeness": [
"raro",
"rareza",
"lo inquietante",
"una rareza carnosa"
],
"feel.curiosity": [
"mira",
"curiosidad",
"fascinación",
"una fascinación absorta"
]
}
}
+658 -15
View File
@@ -36,6 +36,12 @@
"désir",
"aspiration",
"une aspiration douloureuse"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
]
},
"cosmos_galaxies": {
@@ -69,6 +75,13 @@
"désir",
"aspiration",
"une aspiration douloureuse"
],
"measure.count": "~2 000 milliards de galaxies",
"feel.distance": [
"loin",
"distance",
"éloignement",
"un éloignement exquis"
]
},
"cosmos_orion": {
@@ -108,7 +121,19 @@
"étoile du Trapèze",
"Trapèze · étoiles O chaudes illuminant la nébuleuse"
],
"measure.distance": "≈1 344 al"
"measure.distance": "≈1 344 al",
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
]
},
"cosmos_tarantula": {
"feel.wonder": [
@@ -147,7 +172,13 @@
"R136",
"R136 · concentre certaines des étoiles les plus massives connues"
],
"measure.distance": "≈160 000 al"
"measure.distance": "≈160 000 al",
"feel.turbulence": [
"remous",
"turbulence",
"ferment",
"un ferment violent"
]
},
"cosmos_westerlund": {
"feel.wonder": [
@@ -186,7 +217,19 @@
"étoile de type O",
"O-type · blanc-bleu, des dizaines de masses solaires"
],
"measure.distance": "≈20 000 al"
"measure.distance": "≈20 000 al",
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
],
"feel.exhilaration": [
"youpi",
"exaltation",
"euphorie",
"une euphorie qui s'envole"
]
},
"cosmos_southernring": {
"feel.wonder": [
@@ -225,7 +268,19 @@
"naine blanche",
"naine blanche · le cœur stellaire chaud laissé derrière"
],
"measure.distance": "≈2 500 al"
"measure.distance": "≈2 500 al",
"feel.mortality": [
"fin",
"mortalité",
"impermanence",
"la lente mort dune étoile"
],
"feel.poignancy": [
"pincement",
"émotion poignante",
"douceur amère",
"un adieu lumineux"
]
},
"cosmos_carina_eso": {
"feel.wonder": [
@@ -264,7 +319,13 @@
"étoile massive",
"étoile massive · celles qui sculptent les falaises de la Carène"
],
"measure.distance": "≈7 500 al"
"measure.distance": "≈7 500 al",
"feel.abundance": [
"plein",
"abondance",
"richesse",
"une richesse grouillante"
]
},
"orbit_planetearth": {
"detected.cloud_band": [
@@ -303,6 +364,18 @@
"distance",
"éloignement",
"un éloignement exquis"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
]
},
"orbit_bluemarble": {
@@ -312,6 +385,18 @@
"planète tellurique",
"planète tellurique · 12 742 km de diamètre"
],
"detected.cloud_band": [
"nuages",
"systèmes météo",
"systèmes nuageux",
"systèmes nuageux planétaires · les nuages couvrent ~67% de la Terre"
],
"detected.starfield": [
"étoiles",
"champ d’étoiles",
"étoiles darrière-plan",
"champ d’étoiles de fond · un décor rendu, hors échelle"
],
"feel.serenity": [
"calme",
"sérénité",
@@ -335,6 +420,12 @@
"distance",
"éloignement",
"un éloignement exquis"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"orbit_aurora2025": {
@@ -374,7 +465,25 @@
"limbe atmosphérique",
"limbe atmosphérique · ~100 km d'air, brillant sur la tranche"
],
"measure.altitude": "~408 km"
"measure.altitude": "~408 km",
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.eeriness": [
"étrange",
"étrangeté",
"une charge surnaturelle",
"un frisson électrique et surnaturel"
]
},
"orbit_citylights": {
"feel.serenity": [
@@ -413,7 +522,19 @@
"luminescence atmosphérique",
"luminescence atmosphérique · la faible émission nocturne de la haute atmosphère"
],
"measure.altitude": "~408 km"
"measure.altitude": "~408 km",
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
],
"feel.longing": [
"envie",
"désir",
"aspiration",
"une aspiration douloureuse"
]
},
"orbit_helene": {
"feel.serenity": [
@@ -452,7 +573,25 @@
"limbe atmosphérique",
"limbe atmosphérique · la fine pellicule où vit la météo"
],
"measure.altitude": "~408 km"
"measure.altitude": "~408 km",
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.turbulence": [
"remous",
"turbulence",
"ferment",
"un ferment violent"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"orbit_epic": {
"feel.serenity": [
@@ -491,7 +630,13 @@
"systèmes météorologiques",
"systèmes météorologiques · tourbillonnant sur une planète qui tourne"
],
"measure.distance": "~1,5 M km"
"measure.distance": "~1,5 M km",
"feel.insignificance": [
"petit",
"petitesse",
"insignifiance",
"une insignifiance qui rend humble"
]
},
"sky_grca_templesa": {
"feel.exhilaration": [
@@ -529,6 +674,30 @@
"strates rocheuses",
"bancs sédimentaires",
"strates · des époques de dépôt empilées"
],
"detected.butte": [
"tour",
"butte-témoin",
"temple rocheux",
"temple rocheux · une butte-témoin laissée par l’érosion"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"sky_greenland_landice": {
@@ -567,6 +736,30 @@
"champ de neige",
"névé",
"névé · vieille neige se compactant en glace glaciaire"
],
"detected.crevasse": [
"fissures",
"crevasses",
"crevasses glaciaires",
"crevasses · la glace se fracture en s’écoulant"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
],
"feel.purity": [
"pur",
"pureté",
"quiétude",
"un silence glaciaire"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
]
},
"sky_greenland_suture": {
@@ -605,6 +798,30 @@
"chenal",
"chenal libre",
"chenal · une fracture d'eau libre entre les floes"
],
"detected.floe": [
"plaques",
"floes",
"floes de banquise",
"floes · plaques de mer gelée à la dérive"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
],
"feel.solitude": [
"seul",
"solitude",
"isolement",
"une vaste solitude gelée"
]
},
"sky_jungle_amazon": {
@@ -643,6 +860,24 @@
"grand arbre",
"arbre émergent",
"émergent · un géant perçant au-dessus de la canopée"
],
"detected.mist": [
"brume",
"brume de canopée",
"brume de transpiration",
"brume de transpiration · la forêt exhalant sa vapeur"
],
"feel.verdancy": [
"luxuriant",
"verdure",
"abondance",
"une abondance verte et grouillante"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
]
},
"sky_jungle_waterfall": {
@@ -681,6 +916,24 @@
"canopée de jungle",
"canopée de forêt tropicale",
"canopée · forêt dense pressée autour de la gorge"
],
"detected.spray": [
"embruns",
"embruns",
"embruns de chute",
"embruns de chute · la rivière pulvérisée à limpact"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.freshness": [
"frais",
"fraîcheur",
"vitalité",
"une fraîcheur en cascade"
]
},
"sky_coast_cliffspain": {
@@ -719,6 +972,30 @@
"ressac",
"houle déferlante",
"houle déferlante · l'océan à la rencontre de la pierre"
],
"detected.headland": [
"pointe",
"promontoire",
"promontoire rocheux",
"promontoire · un bras de terre escarpé dans la mer"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.turbulence": [
"remous",
"turbulence",
"ferment",
"un ferment violent"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"sky_mtn_castlecrags": {
@@ -757,6 +1034,24 @@
"forêt de conifères",
"forêt montagnarde",
"forêt montagnarde · habillant les pentes sous les pics"
],
"detected.talus": [
"éboulis",
"talus",
"talus d’éboulis",
"éboulis · roches gélifractées entassées sous les aiguilles"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"sky_mtn_rocky": {
@@ -795,6 +1090,24 @@
"toundra alpine",
"toundra alpine",
"toundra alpine · plantes en coussin basses au-dessus des arbres"
],
"detected.snowfield": [
"neige",
"névé",
"névé alpin",
"névé alpin · neige persistante de haute altitude"
],
"feel.sublime": [
"grandiose",
"le sublime",
"grandeur",
"une grandeur de cathédrale"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"coast_birdrock": {
@@ -833,6 +1146,24 @@
"mélancolie",
"langueur",
"une douce langueur tournée vers le large"
],
"detected.seabirds": [
"oiseaux",
"oiseaux marins",
"oiseaux marins nicheurs",
"colonie doiseaux marins · la rookerie qui nomme le rocher"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
]
},
"coast_surfgrass": {
@@ -871,6 +1202,30 @@
"mélancolie",
"langueur",
"une douce langueur tournée vers le large"
],
"detected.tidepool": [
"mare",
"cuvette de marée",
"mare intertidale",
"cuvette de marée · une mer de poche découverte à marée basse"
],
"feel.abundance": [
"plein",
"abondance",
"richesse",
"une richesse grouillante"
],
"feel.immersion": [
"dedans",
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"feel.curiosity": [
"regarde",
"curiosité",
"fascination",
"une fascination absorbée"
]
},
"coast_kelp": {
@@ -909,6 +1264,36 @@
"lame",
"fronde de varech",
"fronde · des flotteurs remplis de gaz la maintiennent droite"
],
"detected.pneumatocyst": [
"flotteurs",
"vésicules à gaz",
"pneumatocystes",
"pneumatocystes · des flotteurs à gaz qui hissent les lames vers la lumière"
],
"feel.immersion": [
"dedans",
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.freedom": [
"libre",
"liberté",
"délivrance",
"une délivrance sans limites"
]
},
"coast_otters": {
@@ -947,6 +1332,24 @@
"estuaire",
"chenal de marée",
"chenal de marée · eaux de nourricerie abritées"
],
"detected.fur": [
"fourrure",
"fourrure dense",
"la fourrure la plus dense",
"la fourrure la plus dense au monde · ~1 M de poils/in², sans graisse"
],
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
],
"feel.delight": [
"amusant",
"ravissement",
"joie",
"une joie vive et frétillante"
]
},
"coast_kalaloch": {
@@ -985,6 +1388,18 @@
"aiguille marine",
"aiguille côtière",
"aiguille marine · roche résiduelle taillée par les vagues"
],
"detected.sunset": [
"lueur",
"coucher de soleil",
"heure dorée",
"heure dorée · le soleil bas rougi par plus datmosphère"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
]
},
"coast_seals": {
@@ -1023,6 +1438,30 @@
"rocher reposoir",
"rocher intertidal",
"reposoir · une corniche de repos balayée par la marée"
],
"detected.whiskers": [
"moustaches",
"vibrisses",
"moustaches sensorielles",
"vibrisses · des moustaches qui sentent les proies en eau trouble"
],
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
],
"feel.repose": [
"repos",
"quiétude",
"somnolence",
"une somnolence réchauffée de soleil"
],
"feel.delight": [
"amusant",
"ravissement",
"joie",
"une joie vive et frétillante"
]
},
"coast_mist": {
@@ -1061,6 +1500,30 @@
"rocher de rivage",
"rocher côtier",
"rocher côtier · le bord dressé de la terre"
],
"detected.swell": [
"vagues",
"houle",
"houle océanique",
"houle · des vagues nées de tempêtes lointaines"
],
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.hush": [
"silence",
"calme",
"quiétude",
"un silence retenu"
]
},
"reef_lionfish": {
@@ -1100,6 +1563,18 @@
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"feel.poise": [
"immobile",
"aplomb",
"grâce",
"un aplomb suspendu et ouvragé"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
]
},
"reef_spawning": {
@@ -1139,6 +1614,18 @@
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"feel.flow": [
"aller",
"flux",
"courant",
"un flux de banc qui ondule"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"reef_hawkfish": {
@@ -1172,6 +1659,18 @@
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"detected.coral": [
"corail",
"corail récifal",
"corail dur",
"corail dur · le récif que ce poisson broie en sable"
],
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
]
},
"reef_coralspacific": {
@@ -1210,6 +1709,24 @@
"immersion",
"absorption",
"une absorption retenue, à couper le souffle"
],
"detected.polyp": [
"polypes",
"polypes coralliens",
"polypes vivants",
"polypes · chacun un animal minuscule, bâtisseurs de la colonie"
],
"feel.intricacy": [
"fin",
"complexité",
"détail",
"une densité tissée et complexe"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
]
},
"reef_redsea": {
@@ -1249,7 +1766,19 @@
"anthias",
"anthias · nuages orange de mangeurs de plancton au-dessus du récif"
],
"measure.depth": "10 m"
"measure.depth": "10 m",
"feel.serenity": [
"calme",
"sérénité",
"paix",
"une paix tranquille et sans pesanteur"
],
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
]
},
"reef_flowergarden": {
"feel.delight": [
@@ -1288,7 +1817,13 @@
"Abudefduf saxatilis",
"Abudefduf · la demoiselle rayée “sergent-major”"
],
"measure.depth": "20 m"
"measure.depth": "20 m",
"feel.freedom": [
"libre",
"liberté",
"délivrance",
"une délivrance sans limites"
]
},
"abyss_wow": {
"detected.jelly": [
@@ -1327,6 +1862,12 @@
"effroi",
"pressentiment",
"un pressentiment lent et glacé"
],
"feel.vastness": [
"grand",
"vastitude",
"immensité",
"une immensité vertigineuse"
]
},
"abyss_midwaterexp": {
@@ -1366,6 +1907,18 @@
"effroi",
"pressentiment",
"un pressentiment lent et glacé"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
],
"feel.poignancy": [
"pincement",
"émotion poignante",
"douceur amère",
"un adieu lumineux"
]
},
"abyss_hiding": {
@@ -1399,6 +1952,24 @@
"effroi",
"pressentiment",
"un pressentiment lent et glacé"
],
"detected.marinesnow": [
"points",
"neige marine",
"détritus tombants",
"neige marine · débris organiques descendant des couches supérieures"
],
"feel.spectral": [
"fantôme",
"le spectral",
"hantise",
"un fantôme dans leau"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
]
},
"abyss_bigfin": {
@@ -1432,7 +2003,19 @@
"Magnapinna",
"Magnapinna · des bras coudés traînant sur des mètres dans l'obscurité"
],
"measure.depth": "2 000 m"
"measure.depth": "2 000 m",
"detected.arms": [
"bras",
"bras coudés",
"filaments traînants",
"bras coudés · tendus puis traînant des mètres de fil"
],
"feel.alienness": [
"bizarre",
"l’étrangeté",
"altérité",
"une grâce extraterrestre"
]
},
"abyss_dandelion": {
"feel.unease": [
@@ -1465,7 +2048,31 @@
"siphonophore pissenlit",
"Rhodaliidae · une colonie de clones amarrée au fond marin"
],
"measure.depth": "2 500 m"
"measure.depth": "2 500 m",
"detected.tentacle": [
"fils",
"tentacules",
"tentacules de capture",
"tentacules de capture · un filet dérivant pour les proies"
],
"feel.radiance": [
"éclat",
"rayonnement",
"scintillement",
"un scintillement de joyaux"
],
"feel.wonder": [
"ouah",
"émerveillement",
"saisissement",
"un saisissement transcendant"
],
"feel.fragility": [
"mince",
"fragilité",
"vulnérabilité",
"une tendre fragilité"
]
},
"abyss_octopus": {
"feel.unease": [
@@ -1498,7 +2105,25 @@
"Graneledone",
"Graneledone boreopacifica · couve ses œufs pendant ~4,5 ans"
],
"measure.depth": "2 500 m"
"measure.depth": "2 500 m",
"detected.arms": [
"bras",
"huit bras",
"bras à ventouses",
"huit bras · garnis de ventouses qui goûtent ce quelles touchent"
],
"feel.curiosity": [
"regarde",
"curiosité",
"fascination",
"une fascination absorbée"
],
"feel.tenderness": [
"doux",
"tendresse",
"chaleur",
"la chaleur dun berceau"
]
},
"abyss_seapig": {
"feel.unease": [
@@ -1531,6 +2156,24 @@
"Enypniastes eximia",
"Enypniastes · un concombre de mer nageur, le “poulet sans tête”"
],
"measure.depth": "2 700 m"
"measure.depth": "2 700 m",
"detected.veil": [
"voile",
"voile oral",
"voile natatoire",
"voile oral · balaie le sédiment et bat pour nager"
],
"feel.strangeness": [
"étrange",
"bizarrerie",
"linquiétant",
"une étrangeté charnue"
],
"feel.curiosity": [
"regarde",
"curiosité",
"fascination",
"une fascination absorbée"
]
}
}
+658 -15
View File
@@ -36,6 +36,12 @@
"憧れ",
"切望",
"胸を締めつける切望"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
]
},
"cosmos_galaxies": {
@@ -69,6 +75,13 @@
"憧れ",
"切望",
"胸を締めつける切望"
],
"measure.count": "約2兆個の銀河",
"feel.distance": [
"遠い",
"隔たり",
"遥かさ",
"この上なく美しい遥かさ"
]
},
"cosmos_orion": {
@@ -108,7 +121,19 @@
"Trapezium星",
"Trapezium · 星雲を照らす高温のO-type星"
],
"measure.distance": "≈1,344 光年"
"measure.distance": "≈1,344 光年",
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
]
},
"cosmos_tarantula": {
"feel.wonder": [
@@ -147,7 +172,13 @@
"R136",
"R136 · 知られている中で最も重い部類の星々を抱える"
],
"measure.distance": "≈160,000 光年"
"measure.distance": "≈160,000 光年",
"feel.turbulence": [
"渦巻き",
"乱流",
"胎動",
"激しい胎動"
]
},
"cosmos_westerlund": {
"feel.wonder": [
@@ -186,7 +217,19 @@
"O-type星",
"O-type · 青白い、太陽の数十倍の質量"
],
"measure.distance": "≈20,000 光年"
"measure.distance": "≈20,000 光年",
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
],
"feel.exhilaration": [
"わーい",
"高揚",
"歓喜",
"舞い上がる歓喜"
]
},
"cosmos_southernring": {
"feel.wonder": [
@@ -225,7 +268,19 @@
"白色矮星",
"白色矮星 · 残された高温の星の中心核"
],
"measure.distance": "≈2,500 光年"
"measure.distance": "≈2,500 光年",
"feel.mortality": [
"終わり",
"死",
"無常",
"星のゆるやかな死"
],
"feel.poignancy": [
"切なさ",
"哀切",
"ほろ苦さ",
"光に満ちた別れ"
]
},
"cosmos_carina_eso": {
"feel.wonder": [
@@ -264,7 +319,13 @@
"重い星",
"重い星 · カリーナの崖を彫り出すたぐいの星"
],
"measure.distance": "≈7,500 光年"
"measure.distance": "≈7,500 光年",
"feel.abundance": [
"いっぱい",
"豊かさ",
"充溢",
"あふれんばかりの充溢"
]
},
"orbit_planetearth": {
"detected.cloud_band": [
@@ -303,6 +364,18 @@
"隔たり",
"遥かさ",
"この上なく美しい遥かさ"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
]
},
"orbit_bluemarble": {
@@ -312,6 +385,18 @@
"岩石惑星",
"岩石惑星 · 直径12,742 km"
],
"detected.cloud_band": [
"雲",
"気象システム",
"雲システム",
"地球規模の雲システム · 雲は地表の約67%を覆う"
],
"detected.starfield": [
"星",
"星野",
"背景の星",
"背景の星野 · 描画された背景で実寸ではない"
],
"feel.serenity": [
"穏やか",
"静けさ",
@@ -335,6 +420,12 @@
"隔たり",
"遥かさ",
"この上なく美しい遥かさ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"orbit_aurora2025": {
@@ -374,7 +465,25 @@
"大気の縁",
"大気の縁 · 約100 kmの大気層が縁で光る"
],
"measure.altitude": "約408 km"
"measure.altitude": "約408 km",
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.eeriness": [
"異様",
"不気味さ",
"異世界の気配",
"電気を帯びた異世界の静けさ"
]
},
"orbit_citylights": {
"feel.serenity": [
@@ -413,7 +522,19 @@
"超高層の大気光",
"大気光 · 高層大気がかすかに放つ夜間の光"
],
"measure.altitude": "約408 km"
"measure.altitude": "約408 km",
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
],
"feel.longing": [
"欲しい",
"憧れ",
"切望",
"胸を締めつける切望"
]
},
"orbit_helene": {
"feel.serenity": [
@@ -452,7 +573,25 @@
"大気の縁",
"大気の縁 · 気象が宿る薄い層"
],
"measure.altitude": "約408 km"
"measure.altitude": "約408 km",
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.turbulence": [
"渦巻き",
"乱流",
"胎動",
"激しい胎動"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"orbit_epic": {
"feel.serenity": [
@@ -491,7 +630,13 @@
"気象系",
"気象系 · 回転する惑星の上を渦巻く"
],
"measure.distance": "約150万 km"
"measure.distance": "約150万 km",
"feel.insignificance": [
"小さい",
"小ささ",
"ちっぽけさ",
"謙虚にさせるちっぽけさ"
]
},
"sky_grca_templesa": {
"feel.exhilaration": [
@@ -529,6 +674,30 @@
"岩層",
"堆積層",
"地層 · 積み重なった堆積の時代"
],
"detected.butte": [
"塔",
"残丘",
"岩の神殿",
"岩の‘神殿’ · 浸食に取り残された残丘"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"sky_greenland_landice": {
@@ -567,6 +736,30 @@
"雪原",
"フィルン",
"フィルン · 氷河の氷へと締まっていく古い雪"
],
"detected.crevasse": [
"亀裂",
"クレバス",
"氷河の裂け目",
"クレバス · 流れる氷が割れてできた裂け目"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
],
"feel.purity": [
"清らか",
"純粋さ",
"静寂",
"氷河のような静寂"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
]
},
"sky_greenland_suture": {
@@ -605,6 +798,30 @@
"水路",
"リード",
"リード · 氷盤の間に開いた水の裂け目"
],
"detected.floe": [
"板状氷",
"氷盤",
"流氷の氷盤",
"氷盤 · 漂う凍った海の板"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
],
"feel.solitude": [
"ひとり",
"孤独",
"隔絶",
"凍てつく広大な孤独"
]
},
"sky_jungle_amazon": {
@@ -643,6 +860,24 @@
"高い木",
"突出木",
"突出木 · 樹冠の天井を突き抜ける巨木"
],
"detected.mist": [
"もや",
"林冠のもや",
"蒸散のもや",
"蒸散のもや · 森が吐き出す水蒸気"
],
"feel.verdancy": [
"緑豊か",
"青々しさ",
"繁茂",
"生い茂る緑の豊かさ"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
]
},
"sky_jungle_waterfall": {
@@ -681,6 +916,24 @@
"ジャングルの樹冠",
"熱帯雨林の樹冠",
"樹冠 · 峡谷に密集する濃い森"
],
"detected.spray": [
"水しぶき",
"しぶき",
"落下のしぶき",
"落下のしぶき · 衝突で霧化した川"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.freshness": [
"涼しい",
"清々しさ",
"生命力",
"滝のように降りそそぐ清涼"
]
},
"sky_coast_cliffspain": {
@@ -719,6 +972,30 @@
"打ち寄せる波",
"砕けるうねり",
"砕けるうねり · 海が岩に出会う"
],
"detected.headland": [
"岬",
"岬",
"岩の岬",
"岬 · 海へ突き出す断崖の地"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.turbulence": [
"渦巻き",
"乱流",
"胎動",
"激しい胎動"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"sky_mtn_castlecrags": {
@@ -757,6 +1034,24 @@
"針葉樹林",
"山地林",
"山地林 · 岩峰の下の斜面を覆う"
],
"detected.talus": [
"岩屑",
"タルス",
"岩屑の斜面",
"タルス · 凍結破砕した岩が岩峰の下に堆積"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"sky_mtn_rocky": {
@@ -795,6 +1090,24 @@
"ツンドラ",
"高山ツンドラ",
"高山ツンドラ · 樹林の上に育つ低いクッション状の植物"
],
"detected.snowfield": [
"雪",
"雪原",
"高山の雪原",
"高山の雪原 · 標高の高い場所に残る雪"
],
"feel.sublime": [
"雄大",
"崇高",
"荘厳",
"大聖堂のような荘厳さ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"coast_birdrock": {
@@ -833,6 +1146,24 @@
"憂い",
"恋しさ",
"海へと向かう柔らかな恋しさ"
],
"detected.seabirds": [
"鳥",
"海鳥",
"営巣する海鳥",
"海鳥のコロニー · 岩の名の由来となる集団繁殖地"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
]
},
"coast_surfgrass": {
@@ -871,6 +1202,30 @@
"憂い",
"恋しさ",
"海へと向かう柔らかな恋しさ"
],
"detected.tidepool": [
"潮だまり",
"タイドプール",
"潮間帯の池",
"潮だまり · 干潮で現れる小さな海"
],
"feel.abundance": [
"いっぱい",
"豊かさ",
"充溢",
"あふれんばかりの充溢"
],
"feel.immersion": [
"中へ",
"没入",
"没頭",
"息を呑む没頭"
],
"feel.curiosity": [
"見て",
"好奇心",
"魅了",
"のめり込む魅了"
]
},
"coast_kelp": {
@@ -909,6 +1264,36 @@
"葉状体",
"ケルプの葉",
"葉状部 · ガスを含んだ浮き袋が直立させる"
],
"detected.pneumatocyst": [
"浮き",
"気胞",
"気胞(ニューマトシスト)",
"気胞 · 葉を光へ持ち上げるガスの浮き"
],
"feel.immersion": [
"中へ",
"没入",
"没頭",
"息を呑む没頭"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.freedom": [
"自由",
"自由さ",
"解放",
"果てしない解放"
]
},
"coast_otters": {
@@ -947,6 +1332,24 @@
"河口",
"潮汐の入り江",
"潮の入り江 · 守られた育成の水域"
],
"detected.fur": [
"毛皮",
"密な毛皮",
"世界一密な毛皮",
"世界一密な毛皮 · 約100万本/in²、脂肪層なし"
],
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
],
"feel.delight": [
"楽しい",
"喜び",
"歓び",
"ちらちらと輝く歓び"
]
},
"coast_kalaloch": {
@@ -985,6 +1388,18 @@
"海食柱",
"海岸の海食柱",
"海食柱 · 波に削り残された岩"
],
"detected.sunset": [
"輝き",
"夕焼け",
"ゴールデンアワー",
"ゴールデンアワー · 大気を長く通り赤らむ低い太陽"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
]
},
"coast_seals": {
@@ -1023,6 +1438,30 @@
"上陸岩",
"潮間帯の岩",
"上陸地 · 潮に洗われる休息の岩棚"
],
"detected.whiskers": [
"ひげ",
"触毛",
"感覚のひげ",
"触毛 · 濁った水中で獲物を感じ取るひげ"
],
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
],
"feel.repose": [
"休息",
"安らぎ",
"まどろみ",
"陽だまりのまどろみ"
],
"feel.delight": [
"楽しい",
"喜び",
"歓び",
"ちらちらと輝く歓び"
]
},
"coast_mist": {
@@ -1061,6 +1500,30 @@
"岸の岩",
"海岸の岩",
"海岸の岩 · 陸の立つ縁"
],
"detected.swell": [
"波",
"うねり",
"海のうねり",
"うねり · 遠くの嵐が生んだ波"
],
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.hush": [
"静か",
"静けさ",
"沈黙",
"息をひそめた静けさ"
]
},
"reef_lionfish": {
@@ -1100,6 +1563,18 @@
"没入",
"没頭",
"息を呑む没頭"
],
"feel.poise": [
"静止",
"気品",
"優雅",
"宙に浮く華麗な気品"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
]
},
"reef_spawning": {
@@ -1139,6 +1614,18 @@
"没入",
"没頭",
"息を呑む没頭"
],
"feel.flow": [
"流れ",
"フロー",
"奔流",
"群れが織りなすうねり"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"reef_hawkfish": {
@@ -1172,6 +1659,18 @@
"没入",
"没頭",
"息を呑む没頭"
],
"detected.coral": [
"サンゴ",
"礁サンゴ",
"造礁サンゴ",
"造礁サンゴ · この魚が削って砂にする礁"
],
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
]
},
"reef_coralspacific": {
@@ -1210,6 +1709,24 @@
"没入",
"没頭",
"息を呑む没頭"
],
"detected.polyp": [
"ポリプ",
"サンゴのポリプ",
"生きたポリプ",
"ポリプ · 一つ一つが小さな動物、群体の建設者"
],
"feel.intricacy": [
"精緻",
"複雑さ",
"細部",
"織り込まれた緻密さ"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
]
},
"reef_redsea": {
@@ -1249,7 +1766,19 @@
"ハナダイ",
"ハナダイ · 礁の上を漂う、プランクトンを食むオレンジの群れ"
],
"measure.depth": "10 m"
"measure.depth": "10 m",
"feel.serenity": [
"穏やか",
"静けさ",
"安らぎ",
"静かで無重力の安らぎ"
],
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
]
},
"reef_flowergarden": {
"feel.delight": [
@@ -1288,7 +1817,13 @@
"Abudefduf saxatilis",
"Abudefduf · 縞模様の「sergeant major」スズメダイ"
],
"measure.depth": "20 m"
"measure.depth": "20 m",
"feel.freedom": [
"自由",
"自由さ",
"解放",
"果てしない解放"
]
},
"abyss_wow": {
"detected.jelly": [
@@ -1327,6 +1862,12 @@
"恐れ",
"胸騒ぎ",
"ゆっくりと冷たい胸騒ぎ"
],
"feel.vastness": [
"大きい",
"広大さ",
"無限の広がり",
"めまいを誘う無限の広がり"
]
},
"abyss_midwaterexp": {
@@ -1366,6 +1907,18 @@
"恐れ",
"胸騒ぎ",
"ゆっくりと冷たい胸騒ぎ"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
],
"feel.poignancy": [
"切なさ",
"哀切",
"ほろ苦さ",
"光に満ちた別れ"
]
},
"abyss_hiding": {
@@ -1399,6 +1952,24 @@
"恐れ",
"胸騒ぎ",
"ゆっくりと冷たい胸騒ぎ"
],
"detected.marinesnow": [
"粒子",
"マリンスノー",
"落下する有機物",
"マリンスノー · 上層から沈む有機物の屑"
],
"feel.spectral": [
"亡霊",
"幽玄",
"亡霊めいた気配",
"水中の亡霊"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
]
},
"abyss_bigfin": {
@@ -1432,7 +2003,19 @@
"Magnapinna",
"Magnapinna · 肘のように曲がる腕を闇へ何メートルも垂らす"
],
"measure.depth": "2,000 m"
"measure.depth": "2,000 m",
"detected.arms": [
"腕",
"肘状の腕",
"垂れ下がる糸",
"肘状の腕 · 横に張り出し、数メートルの糸を引く"
],
"feel.alienness": [
"奇妙",
"異質さ",
"異形",
"異形の優雅さ"
]
},
"abyss_dandelion": {
"feel.unease": [
@@ -1465,7 +2048,31 @@
"タンポポクダクラゲ",
"Rhodaliidae · 海底につながれたクローンの群体"
],
"measure.depth": "2,500 m"
"measure.depth": "2,500 m",
"detected.tentacle": [
"糸",
"触手",
"捕食の触手",
"捕食の触手 · 獲物を捕らえる漂う網"
],
"feel.radiance": [
"輝き",
"光輝",
"煌めき",
"宝石のような煌めき"
],
"feel.wonder": [
"わあ",
"驚き",
"畏敬",
"超越的な畏敬"
],
"feel.fragility": [
"薄い",
"儚さ",
"脆さ",
"いたわりたくなる儚さ"
]
},
"abyss_octopus": {
"feel.unease": [
@@ -1498,7 +2105,25 @@
"Graneledone",
"Graneledone boreopacifica · 卵を約4.5年抱き続ける"
],
"measure.depth": "2,500 m"
"measure.depth": "2,500 m",
"detected.arms": [
"腕",
"八本の腕",
"吸盤の並ぶ腕",
"八本の腕 · 触れたものを味わう吸盤付き"
],
"feel.curiosity": [
"見て",
"好奇心",
"魅了",
"のめり込む魅了"
],
"feel.tenderness": [
"やさしい",
"優しさ",
"ぬくもり",
"ゆりかごのようなぬくもり"
]
},
"abyss_seapig": {
"feel.unease": [
@@ -1531,6 +2156,24 @@
"Enypniastes eximia",
"Enypniastes · 泳ぐナマコ、「頭のない鶏」"
],
"measure.depth": "2,700 m"
"measure.depth": "2,700 m",
"detected.veil": [
"ヴェール",
"口前のヴェール",
"遊泳用のヴェール",
"口前のヴェール · 堆積物を掃き、羽ばたいて泳ぐ"
],
"feel.strangeness": [
"変",
"奇妙さ",
"不気味",
"肉感的な奇妙さ"
],
"feel.curiosity": [
"見て",
"好奇心",
"魅了",
"のめり込む魅了"
]
}
}