Compare commits

..

82 Commits

Author SHA1 Message Date
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 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 ee7c65a0f2 feat(credits): restore the CC BY-SA declaration as "License & reuse" (Option D1)
s0031 removed "About this work" to avoid duplicating it on the separate About
page, but that section WAS the D1 compliance — it declares the altered work is a
derivative offered under CC BY-SA 4.0, which is what satisfies the ShareAlike
obligation for the BY-SA source clips. Without it the live site credited the
BY-SA authors but never declared the derivative share-alike.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:23:57 -07:00
BenStullsBets 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 8277417583 chore(credits): drop the "About this work" section — it moves to the About page
The CC BY-SA work-license declaration ("About this work") was on the credits
page, but the About page (built on the parallel feat/accessibility-pass) will
own that content; keeping it in both places would duplicate it. Remove the
section from credits.html and make the footage note self-contained (it still
states the footage is altered — the per-clip "indicate modifications" cue stays
with the attributions). Tests updated to assert the declaration is intentionally
no longer on the credits page. The .declaration CSS rule is left in place for the
About page to reuse.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:39:26 -07:00
BenStullsBets 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 3a45e338f3 feat(static): "Run simulation" start button over the black stage
The experience boots silent and waits for a gesture (browser autoplay blocks
an un-gestured audio play()). Until now that gesture was an unlabelled Video
toggle — not obvious to a first-time viewer. Add a "Run simulation" button,
revealed over the black stage once media has preloaded; clicking it turns
Video on, snaps the audio dial to a gentle 2/10, dismisses the prompt, and
starts the soundtrack in that one gesture. Turning Video on directly still
begins the experience too (couples audio in, dismisses the button) via a
shared beginExperience(). Localised en/es/fr/ja.

E2E coverage updated (Playwright + pytest-playwright): button shown after
load, button starts video+audio+dismisses, and direct-Video-on path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 08:28:49 -07:00
BenStullsBets 8cc472c93c docs(static): correct Cloudflare runbook to match actual deploy
Real bucket name, wrangler 'cors set --file' (+ wrangler-format CORS file),
per-file r2 object put upload (no bulk sync exists), CLI custom-domain bind via
zone-id, Pages domain is dashboard-only.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 07:18:00 -07:00
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
BenStullsBets 934f60cbd2 Merge feat/left-brain-annotations: factual labels + localization for 28 pool clips 2026-06-30 06:44:37 -07:00
BenStullsBets 7995ba4069 feat(content): left-brain factual labels for the 28 pool clips that lacked them
Every rotating-pool clip shipped with right-brain `affect` (auto-generated per
scale by _affect_for_clip) but only 13 carried LEFT-brain factual `LABELS`. The
other 28 rendered with no detected.*/measure.* labels at any Left knob level.

Authors static tiered labels (general → specific → scientific → +fact) for all
28 — 5 cosmos, 4 orbit, 8 sky, 5 coast, 2 reef, 4 abyss — in build_pool_manifest.py
following the existing static_label pattern, plus their es/fr/ja translations in
tools/i18n/catalogs (binomials/measurements kept verbatim; units localized per
each catalog's convention). Rebuilds the manifest and merges all four languages.

Result: 41/41 clips now have left+right annotations with full en/es/fr/ja parity.

Labels are static (footage not eyeballed); the abyss/reef creature clips could
later be upgraded to tracked labels via author mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 06:44:36 -07:00
BenStullsBets 7bd6c9a57b Merge fix/boot-silent-no-autostart: boot silent, no un-gestured auto-start 2026-06-30 06:32:12 -07:00
BenStullsBets e5fe00e948 fix(sim): boot silent (video off, audio 0) — no un-gestured auto-start
The post-load autoStart() flipped Video on and lifted audio to 3/10 with no
user gesture. Browser autoplay policy blocks an audio play() made outside a
gesture (muted video survives, sound does not), so auto-start showed video but
stayed silent. Boot SILENT instead and wait for the operator to turn Video on —
that click IS the gesture, and its handler couples audio to 3/10 and starts the
soundtrack in-gesture, so sound reliably comes up with video.

Updates the pytest e2e + Playwright e2e specs to assert boot-silent + the
video-on-couples-audio wiring instead of the old auto-start wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 06:31:38 -07:00
BenStullsBets 020219f9a6 Merge remote-tracking branch 'origin/main' 2026-06-30 06:30:55 -07:00
BenStullsBets 534c54fe12 claim human-experience-filter-art session 0027 (placeholder) + sessions.json entry 2026-06-29 22:14:22 -07:00
BenStullsBets 5beee55119 Merge fix/video-loop-ended-safety-net: base loop never freezes
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:52:55 -07:00
BenStullsBets ec660c4880 fix(sim): add ended safety net so the base loop never freezes
Root cause: the steady-state loop (#vid-loop) restarts only via a narrow
timeupdate window (last 0.08s -> seek to LOOP_TAIL_S), with native loop off.
Headless Chromium reliably lands a final timeupdate in that window, but under
real-browser decode/GPU load a timeupdate can skip past it; the clip then fires
'ended' and freezes on its last frame ('videos aren't looping'). There was no
recovery path. Add an 'ended' listener that seeks back to the tail and resumes,
guaranteeing the loop regardless of the timeupdate race. No change to normal
playback. Proven by a Playwright regression test that forces the ended state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:52:50 -07:00
BenStullsBets a2d8179507 Merge feat/localization-language-dropdown: language dropdown (en/es/fr/ja)
Localizes simulator UI controls + Left/Right annotations with a live
language dropdown. English default, session-only. 41 clips × 4 langs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 21:26:26 -07:00
BenStullsBets 7090d3a836 test(i18n): e2e for language dropdown chrome + reset on reload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:55:36 -07:00
BenStullsBets 8df7859c5a feat(i18n): author es/fr/ja annotation translations + merge into manifest
41 clips, 753 string-variants per language, structure-validated (key-sets
and tier-counts identical to en). Escalation semantics preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:54:37 -07:00
BenStullsBets dcdc3d62f7 feat(i18n): manifest translation extract/merge tool + en catalog
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:49:52 -07:00
BenStullsBets 1977e679ee feat(i18n): live language switching for chrome + annotations
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:48:48 -07:00
BenStullsBets 49b77e0700 feat(i18n): tag controls with data-i18n + add language dropdown
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:47:03 -07:00
BenStullsBets 00534e116c feat(i18n): language registry + UI-string catalog module
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:45:33 -07:00
BenStullsBets 696f8f0901 docs(i18n): implementation plan for language dropdown
7 tasks: i18n module + catalog, index.html tagging + dropdown, live
switching in app.js, manifest translate tool, author es/fr/ja content,
e2e, full verification. TDD steps throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:44:11 -07:00
BenStullsBets 43370292e3 docs(i18n): localization + language-dropdown design spec
Design for a language dropdown localizing the simulator UI controls and
the Left/Right brain annotations. This pass: en/es/fr/ja (Hebrew + Chinese
deferred — no RTL work in scope). English default, session-only (no
persistence), English fallback on missing keys. Annotations extend the
existing per-clip strings dict (no schema change); ~2,260 strings authored
via a one-shot idempotent translate_manifest tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 18:38:05 -07:00
benstull eff43bd148 Merge pull request 'feat(sim): sky scale + auto-start experience + drop reef_hawaiian' (#29) from feat/sky-scale-expanded-pools into main 2026-06-29 14:01:46 +00:00
BenStullsBets 73d92b90d0 feat(sim): sky scale + auto-start experience + drop reef_hawaiian
Expanded-pools sky-scale work plus two experience tweaks:

- New `sky` altitude (airplane-window / low-flight layer) between orbit and
  coast; RING_ORDER cosmos·orbit·sky·coast·reef·abyss. New clip pools across
  all scales, re-baked the full adjacent-pair morph set (all-intra, LFS), and
  removed the now-stale cosmos↔abyss transitions the old ring order produced.
- Auto-start: once the preload finishes the experience turns Video on and
  raises the audio dial to 3/10 with no user gesture (app.js autoStart()).
- Dropped reef_hawaiian (text overlay — violates the text-free pool rule);
  reef pool 7→6, manifest transitions 586→558.

Tests: rewrote tests/test_e2e_audio.py for the dial UI + auto-start; updated
the manifest morph-count assertions and the altitude-scrub target for the new
ring order. 302 pytest + 11 Playwright e2e + 9 node unit all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 06:56:25 -07:00
BenStullsBets ed5ce09a3c add sessions/0026/SESSION-0026.0-TRANSCRIPT-2026-06-27T15-26--2026-06-27T18-48.md + replace placeholder/variant SESSION-0026.0-TRANSCRIPT-2026-06-27T15-26--INPROGRESS.md 2026-06-27 18:50:09 -07:00
693 changed files with 30882 additions and 3786 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"
}
]
}
]
}
}
+4
View File
@@ -26,3 +26,7 @@ simulator/static/review_audio.html
# Editor annotation/comment-thread metadata (not project content)
.threads/
# Static build outputs (tools/build_static.py)
/dist/
/dist-media/
+10 -1
View File
@@ -1,4 +1,4 @@
.PHONY: sim sim-local
.PHONY: sim sim-local deploy deploy-media
# Prefer the project venv's interpreter (this box has no bare `python` on PATH),
# fall back to python3, then python.
@@ -9,3 +9,12 @@ sim:
sim-local:
$(PYTHON) -m uvicorn simulator.app:app --reload --port 8000
# Repeatable deploy to benstull.art (Cloudflare Pages + R2). One-time setup
# (wrangler login, bucket/CORS/domain) is in deploy/cloudflare/README.md.
# `deploy` is frontend-only (fast); `deploy-media` also re-syncs media to R2.
deploy:
./deploy/cloudflare/deploy.sh
deploy-media:
./deploy/cloudflare/deploy.sh --media
+128
View File
@@ -0,0 +1,128 @@
# Deploying to benstull.art — Cloudflare Pages + R2
Publishes the simulator as a fully-static site: the frontend on **Cloudflare
Pages** (`benstull.art`, app at `/human-experience-simulator`, apex redirects to
it) and the video/audio on **R2** behind `static.benstull.art`. No origin server.
Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`.
Plan: `docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`.
This project is **exempt from the flotilla/PPE/§9 pipeline** (operator-set) — it
deploys directly to Cloudflare. Safety/hygiene rules still hold.
## Repeatable deploy (after the one-time setup below)
Once the bucket/CORS/custom-domains + `wrangler login` exist (one-time, §13), every
later deploy is one command (`deploy/cloudflare/deploy.sh`, or the Make targets):
```bash
make deploy # frontend only: build + Pages deploy (fast — most deploys)
make deploy-media # also re-sync all media to R2 (~2 min, parallel) — when media changed
./deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, skip Pages
```
The script rebuilds `dist/`, (optionally) parallel-uploads `dist-media/` to R2, and
deploys Pages — printing the live URL. It checks `wrangler` auth first and fails
loudly. Override defaults via env: `BUCKET`, `PROJECT`, `MEDIA_BASE`, `APP_PATH`,
`BRANCH`, `JOBS`. The sections below document the one-time setup the script assumes.
## Prerequisites (operator gestures — need Cloudflare auth)
`wrangler` is not installed in the dev box; the deploy runs from a machine with
Cloudflare credentials.
```bash
npm i -g wrangler
wrangler login
```
`benstull.art` is already a Cloudflare zone (no nameserver migration).
## 1. Build the artifacts
From the repo root (run as a MODULE — `tools/http.py` shadows stdlib `http` if run
as a script):
```bash
.venv/bin/python -m tools.build_static
```
Produces:
- `dist/` — Pages output: `dist/human-experience-simulator/` (frontend + baked
`clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a
root `dist/_redirects` (apex → app path, and no-slash → slashed).
- `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only
(clip bases + 154 transition morphs + 5 per-scale audio). No master/mezzanine
sources.
## 2. R2 — bucket, CORS, custom domain, media
Bucket name: **`human-experience-simulator-media`**. The default `wrangler login`
OAuth token already has R2 + Pages + zone scopes (no separate API token needed).
```bash
wrangler r2 bucket create human-experience-simulator-media
# CORS — load-bearing (the WebGL dream shader taints without it). The wrangler CLI
# wants its own schema, NOT the S3-style array; use the *.wrangler.json file:
wrangler r2 bucket cors set human-experience-simulator-media --file deploy/cloudflare/r2-cors.wrangler.json
# (deploy/cloudflare/r2-cors.json is the equivalent S3-style policy, for the
# dashboard / S3 API; r2-cors.wrangler.json is the same rule in wrangler's
# `{ "rules": [ { "allowed": {...} } ] }` form.)
# Custom domain — wrangler CAN do this (no dashboard needed); needs the zone id.
ZONE=$(curl -s -H "Authorization: Bearer $(grep '^oauth_token' ~/.wrangler/config/default.toml | cut -d'"' -f2)" \
"https://api.cloudflare.com/client/v4/zones?name=benstull.art" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
wrangler r2 bucket domain add human-experience-simulator-media \
--domain static.benstull.art --zone-id "$ZONE" --min-tls 1.2
```
**Media upload — there is NO `wrangler r2 bucket sync`.** Upload per-file with
`wrangler r2 object put ... --remote`, setting content-type + immutable cache. The
URLs are content-hash-versioned (`?v=<hash>`), so `immutable` is safe — a re-baked
clip gets a new hash → new URL → cache busts. ~604 files / ~2.3 GB, sequential:
```bash
WR=$(command -v wrangler); BUCKET=human-experience-simulator-media
find dist-media -type f | while read -r f; do
key="${f#dist-media/}"
case "${f##*.}" in mp4) ct=video/mp4;; mp3) ct=audio/mpeg;; json) ct=application/json;; *) ct=application/octet-stream;; esac
"$WR" r2 object put "$BUCKET/$key" --file "$f" --remote \
--content-type "$ct" --cache-control "public, max-age=31536000, immutable"
done
```
(If you have `rclone` + an R2 S3 API token, `rclone copy dist-media <remote>:bucket`
is much faster than the loop.)
If you change the media base, rebuild with `--media-base https://<host>/` so
`config.js` matches.
## 3. Pages — deploy + domain + redirect
```bash
wrangler pages project create human-experience-simulator --production-branch main
wrangler pages deploy dist --project-name human-experience-simulator --branch main
```
Then **in the dashboard** (Workers & Pages → project → Custom domains) — there is
no wrangler CLI for Pages custom domains:
- Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind
serves `522` until the cert/routing provisions.)
- The app serves at `benstull.art/human-experience-simulator/`; the shipped
`dist/_redirects` sends `/` and the no-slash form to it.
## 4. Verify before/after going live
```bash
# CORS + Range on the media origin (after step 2)
curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
https://static.benstull.art/<some>/base.mp4 | grep -iE "access-control-allow-origin|content-range|accept-ranges"
```
Open `https://benstull.art/` → should redirect to
`https://benstull.art/human-experience-simulator/`. Turn the **Feel** knob to max
and confirm the dream effect renders (no `SecurityError` in the console — that
would mean CORS is misconfigured and the GL texture is tainted). The local
static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same
cross-origin path before you ship.
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Repeatable deploy of the HEF simulator to benstull.art (Cloudflare Pages + R2).
#
# deploy/cloudflare/deploy.sh # frontend only: build + Pages deploy (fast)
# deploy/cloudflare/deploy.sh --media # also re-sync all media to R2 (~2 min, parallel)
# deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, no Pages deploy
#
# Assumes the ONE-TIME setup is already done (see README.md): wrangler logged in,
# the R2 bucket + CORS + static.benstull.art custom domain, and the Pages project +
# benstull.art custom domain. This script only does the repeatable part.
#
# Override any of these via env: BUCKET, PROJECT, MEDIA_BASE, APP_PATH, BRANCH, JOBS.
set -euo pipefail
BUCKET="${BUCKET:-human-experience-simulator-media}"
PROJECT="${PROJECT:-human-experience-simulator}"
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
APP_PATH="${APP_PATH:-/human-experience-simulator}"
BRANCH="${BRANCH:-main}"
JOBS="${JOBS:-10}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
OUT="$REPO_ROOT/dist"
export MEDIA_OUT="$REPO_ROOT/dist-media"
PY="${PY:-$REPO_ROOT/.venv/bin/python}"
export WRANGLER="${WRANGLER:-$(command -v wrangler || echo "$HOME/.npm-global/bin/wrangler")}"
do_media=0
do_pages=1
case "${1:-}" in
--media) do_media=1 ;;
--media-only) do_media=1; do_pages=0 ;;
"") ;;
*) echo "unknown flag: $1 (use --media or --media-only)"; exit 2 ;;
esac
[ -x "$WRANGLER" ] || { command -v "$WRANGLER" >/dev/null || { echo "wrangler not found ($WRANGLER) — npm i -g wrangler"; exit 1; }; }
"$WRANGLER" whoami >/dev/null 2>&1 || { echo "not authenticated — run: $WRANGLER login"; exit 1; }
echo "▶ build ($MEDIA_BASE)"
cd "$REPO_ROOT"
"$PY" -m tools.build_static --out "$OUT" --media-out "$MEDIA_OUT" \
--media-base "$MEDIA_BASE" --app-path "$APP_PATH" | tail -1
if [ "$do_media" = 1 ]; then
echo "▶ R2 media sync → $BUCKET (parallel x$JOBS, no bulk-sync exists)"
export BUCKET
fails=$(find "$MEDIA_OUT" -type f -print0 \
| xargs -0 -P "$JOBS" -I {} bash "$REPO_ROOT/deploy/cloudflare/r2-put-one.sh" {} \
| tee /dev/stderr | grep -c '^FAIL ' || true)
[ "$fails" = 0 ] || { echo "$fails media uploads failed"; exit 1; }
echo "✔ media synced"
fi
if [ "$do_pages" = 1 ]; then
echo "▶ Pages deploy → $PROJECT"
"$WRANGLER" pages deploy "$OUT" --project-name "$PROJECT" --branch "$BRANCH" --commit-dirty=true | tail -3
fi
echo "✔ done — https://benstull.art$APP_PATH/"
+9
View File
@@ -0,0 +1,9 @@
[
{
"AllowedOrigins": ["https://benstull.art"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "Content-Type"],
"ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 86400
}
]
+13
View File
@@ -0,0 +1,13 @@
{
"rules": [
{
"allowed": {
"origins": ["https://benstull.art"],
"methods": ["GET", "HEAD"],
"headers": ["Range", "Content-Type"]
},
"exposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"maxAgeSeconds": 86400
}
]
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Upload ONE file to R2 with the right content-type + immutable cache. Used by
# deploy.sh's parallel xargs (wrangler has no bulk sync). Reads BUCKET / MEDIA_OUT /
# WRANGLER from the environment; the single argument is the local file path.
set -u
f="$1"
key="${f#"$MEDIA_OUT"/}"
# Guard: if the prefix strip didn't fire (e.g. relative path vs absolute MEDIA_OUT),
# the key would wrongly keep a leading dir → wrong R2 path. Fail loudly, don't ship it.
if [ "$key" = "$f" ]; then
echo "FAIL $f (key strip failed — MEDIA_OUT='$MEDIA_OUT' is not a prefix; pass absolute paths)"
exit 1
fi
case "${f##*.}" in
mp4) ct=video/mp4 ;;
mp3) ct=audio/mpeg ;;
json) ct=application/json ;;
*) ct=application/octet-stream ;;
esac
if "$WRANGLER" r2 object put "$BUCKET/$key" --file "$f" --remote \
--content-type "$ct" --cache-control "public, max-age=31536000, immutable" >/dev/null 2>&1; then
echo "ok $key"
else
echo "FAIL $key"
fi
+252
View File
@@ -63,6 +63,258 @@ stays noncommercial**.
> confirm with operator). The current ring scale id is `cosmos`; when the pool is
> wired, `cosmos` becomes one pool member.
## 🏗️ BUILD (2026-06-28, branch `feat/sky-scale-expanded-pools`) — curated pools + sky scale
> **Decision:** add the `sky` scale + **curate every pool to ~78 clips** (operator chose
> "curate, expand from the sim" over a full ~2,000-morph bake). Morphs are **cheap and
> length-independent** — `transition_cmd` does `trim=0:3`, so each morph uses only the
> first 3s of each clip (26MB all-intra), so the curated bake is ~616 morphs ≈ ~1.8GB /
> ~30 min. **Long clips (4560s) validated:** a 53s loop bitrate-capped (libx264 crf 23,
> `-maxrate 2.8M -bufsize 5.6M`, 1080p) = ~20MB, under the ~25MB front-proxy/LFS ceiling.
> The pool processor downloads each source, trims its long window, [vidstab-stabilizes /
> xfade-splices where flagged], crossfade-loops, and writes the capped proxy to
> `simulator/sample_media/<id>/base.mp4`.
>
> **Curated lineup (~43 clips; 30 new + 13 existing reused; prune/expand from the sim):**
> - **cosmos (7):** cosmos, cosmos_galaxies + cosmos_orion, _tarantula, _westerlund, _southernring, _carina_eso
> - **orbit (7):** orbit_planetearth, _bluemarble + orbit_aurora2025, _citylights, _helene, _epic, _illuminated
> - **sky (8) NEW SCALE** (between orbit & coast): sky_grca_templesa, sky_greenland_landice (stab), sky_greenland_suture (stab), sky_jungle_amazon (amazon+canopy splice), sky_jungle_waterfall, sky_coast_cliffspain, sky_mtn_castlecrags, sky_mtn_rocky
> - **coast (7):** coast_birdrock, _surfgrass + coast_kelp, _otters, _kalaloch, _seals, _mist (dropped short drakesbeach/elkbeach)
> - **reef (6):** reef_lionfish, _spawning, _hawkfish, _coralspacific + reef_redsea, _flowergarden (dropped murky reef_snapper; dropped reef_hawaiian — text overlay)
> - **abyss (7):** abyss_wow, _midwaterexp, _hiding + abyss_bigfin, _dandelion, _octopus, _seapig
>
> **Remaining build steps:** (1) processor finishes the 30 new bases → (2) wire
> `build_pool_manifest.py` (RING_ORDER + `sky`, POOLS, SCALE_AUDIO, META/AFFECT/LABELS for
> new clips) → (3) regenerate manifest + `--media` morph bake (~616) → (4) update
> `test_build_pool_manifest.py` morph-count assertion + run pytest/playwright → (5) ship via
> §9 (localhost+E2E → PPE). All dropped/unpicked Round-4 candidates remain in
> `/review_pool.html` for easy add-back.
## 📜 License policy FINALIZED (2026-06-28) + Round-4 expanded candidates
> **License policy (supersedes the "strict PD" framing above).** The operator confirmed
> the installation **alters** footage (needs derivative rights), **will keep a credits
> list**, and **may be shown commercially**. **Usable:** `CC0`, US-gov **Public Domain**,
> **CC BY**, **CC BY-SA** (share-alike accepted), **Pexels License**. **Never usable:**
> **CC BY-ND** (no derivatives) and **all NonCommercial (`CC BY-NC*`)** variants. For CC
> BY / BY-SA, credit the author + note "footage altered." Pexels needs no attribution.
> Recorded in agent memory `media-license-policy`.
>
> ⚠️ **License correction (abyss sourcing):** **Schmidt Ocean Institute, Nautilus Live /
> Ocean Exploration Trust, and MBARI are NonCommercial / all-rights-reserved → NOT usable.**
> Deep-sea picks are NOAA Ocean Exploration (PD) / CC0 only.
>
> **Round-4 (2026-06-28): ~10 fresh candidates PER altitude**, sourced under the widened
> licenses and chosen for **long clean windows (4560s)** per the operator's "make the
> videos longer" ask (we cap bitrate to keep LFS objects <25MB). All 56 are HEAD-verified
> and live in the streaming gallery **`/review_pool.html`** (the URLs/windows/licenses are
> recorded in that committed file). Awaiting operator picks per altitude. Highlights:
> cosmos = Orion/Tarantula/Westerlund/Bubble/Carina-ESO/Stephan's-Quintet/Southern-Ring
> flythroughs (NASA SVS PD + ESA/ESO CC BY); orbit = ISS auroras + city-lights + storm-limb
> + EPIC full-disk + ESA Sentinel (PD/CC0/CC BY-SA); sky = volcano/savanna/autumn/sea-of-clouds/
> dunes/salt-flats/vineyards/badlands/boreal terrains; coast = kelp/otters/tidepool-crab/
> surf-on-rock/gulls/seals; reef = Red Sea (CC BY-SA) + 6 NOAA reels (clear-water windows);
> abyss = bigfin squid/dandelion siphonophore/psychedelic medusa/ghost shark/octopus/
> CC0 sea-pig/bamboo-coral forest/vents/dragonfish/isopod.
## ☁️ sky — PROPOSED NEW scale (candidates, awaiting operator review)
> **New altitude: "up in the clouds, flying over the earth" (sourced 2026-06-27).**
> A new ring scale that sits **between `orbit` (ISS / space) and `coast` (ground)** —
> the airplane-window / low-flight layer. Proposed slug **`sky`**, emoji ☁️; ring
> order would become cosmos → orbit → **sky** → coast → reef → abyss (operator to
> confirm slug + placement). The 20 candidates below are **NOT yet downloaded or
> processed** — they are a review list. Watch them streaming from the source at
> **`/review_sky.html`** (served by the sim; nothing downloads until you press
> play), then pick the keepers and we'll trim → crossfade-loop → 1080p them into a
> pool like every other scale. All are strict US-gov **public domain** unless noted;
> all direct URLs HEAD-verified 200 + `video/mp4` on 2026-06-27.
> ✅ **OPERATOR SELECTED (2026-06-28) — 15 clips, SOURCING CLOSED** (Selected band on `/review_sky.html`):
> - *Aircraft aerials (4):* `sky_grca_templesa`, `sky_grca_templesb`,
> `sky_greenland_landice` (**stabilize**, vidstab — wobbly), `sky_greenland_suture` (**stabilize**).
> *(`sky_greenland_gopro` removed.)*
> - *Rainforest (4):* `sky_jungle_amazon` (**spliced with `sky_jungle_canopy`** into one clip,
> short crossfade at the seam), `sky_jungle_river`, `sky_jungle_waterfall`,
> `sky_jungle_warmlight`. *(All Pexels License.)*
> - *Coast (3):* `sky_coast_dunes` (CC-BY — credit; downscale the 4K),
> `sky_coast_turquoise`, `sky_coast_cliffspain` (Pexels).
> *(`sky_coast_norway` and `sky_coast_cliffsurf2` removed.)*
> - *Mountains (4):* `sky_mtn_rocky`, `sky_mtn_castlecrags`, `sky_mtn_granite`,
> `sky_mtn_sierra` (swapped in for `sky_mtn_snowfield`).
>
> Balance: aircraft 4 · rainforest 4 · coast 3 · mountains 4. The Round-3b/3c
> rainforest candidates not listed above (`misty`/`clouds`/`riverhd`/`textured` +
> `_alt`s) were **not chosen**.
>
> **Clip-length audit (2026-06-28, measured via cv2 on the on-disk pool):** shortest
> existing loops are `coast_drakesbeach` **14s** and `coast_elkbeach` **17s** (both the
> ground-level coast scale), then `reef_snapper` 20s, `cosmos_miri` 21s; everything else
> ~23s (`reef_spawning` 27s longest). The new `sky` clips all have ≥24s windows → bake to
> ~2223s. Open option: re-trim the two short coast outliers to longer windows during the
> sky processing pass.
>
> **Dropped / superseded:** the other 15 round-1 candidates; round-2 coast
> `sky_coast_icefjord` + `sky_coast_moher`; round-2 mountains `sky_mtn_shasta` +
> `sky_mtn_snowfield`; the round-2 rainforest batch (lidar/DVIDS jungle — all rejected);
> round-3 coast `sky_coast_reef` (unpicked) + `sky_coast_oregon` (humans + nauseating
> FPV). `sky_coast_reeflagoon`/`sky_coast_cliffsurf` (too short) → replaced; the short
> round-3 rainforest clips (`amazon`/`canopy`/`river` kept; `misty`/`clouds`/`riverhd`
> too short) → replaced in Round-3b. Live review page: `/review_sky.html`.
**Group A — real aerial, from aircraft** (the heart of the scale):
| id | clip | source | direct media | window (s) | notes |
|----|------|--------|--------------|-----------|-------|
| `sky_grca_pivot` | Grand Canyon — high-altitude, straight down + slow 360° pivot | NPS GRCA b-roll hd09 — https://www.nps.gov/grca/learn/photosmultimedia/b-roll_hd09.htm | https://www.nps.gov/nps-audiovideo/audiovideo/302c9dbf-f9ad-4964-bbf3-a21b536f36ed1080p.mp4 | 024 | PD · 1080p · text/human-free; ~25s clip (use whole) |
| `sky_grca_upstream` | Grand Canyon — high-elevation glide tracking upstream | NPS GRCA hd09 (same page) | https://www.nps.gov/nps-audiovideo/audiovideo/237ed336-b101-4a44-81f1-36d692c08a7a1080p.mp4 | 1236 | PD · 1080p · trim before the mid-clip zoom |
| `sky_grca_templesa` | Grand Canyon — banking aerial over cliffs & "temples" (A) | NPS GRCA hd10 — https://www.nps.gov/grca/learn/photosmultimedia/b-roll_hd10.htm | https://www.nps.gov/nps-audiovideo/audiovideo/0121cbe5-b185-4f9f-94af-e55f3ade40161080p.mp4 | 024 | PD · 1080p |
| `sky_grca_templesb` | Grand Canyon — cliffs & "temples" aerial (B, alt angle) | NPS GRCA hd10 (same page) | https://www.nps.gov/nps-audiovideo/audiovideo/0b014199-8287-441c-b535-2416d7b049521080p.mp4 | 024 | PD · 1080p |
| `sky_grca_river` | Grand Canyon — low-elevation aerial skimming the Colorado River | NPS GRCA hd08 — https://www.nps.gov/grca/learn/photosmultimedia/b-roll_hd08.htm | https://www.nps.gov/nps-audiovideo/audiovideo/008a5147-7b14-49a1-9871-13aeb03325aa1080p.mp4 | 024 | PD · 1080p · lower/faster altitude variety |
| `sky_greenland_gopro` | IceBridge — Western Greenland, forward flight (GoPro) | NASA SVS 13474 — https://svs.gsfc.nasa.gov/13474 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013474/YOUTUBE_1080_13474_2019_GoPro_Front_4K_youtube_1080.mp4 | 3054 | PD · 1080p (4K master on page) |
| `sky_greenland_landice` | IceBridge — N. Greenland, low flight over snowy land ice | NASA SVS 13472 — https://svs.gsfc.nasa.gov/13472 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013472/YOUTUBE_1080_13472_Panasonic_2016_Airborne_LandIce_4K_youtube_1080.mp4 | 2044 | PD · 1080p |
| `sky_svalbard_land` | IceBridge — Svalbard, over glaciated mountains | NASA SVS 13473 — https://svs.gsfc.nasa.gov/13473 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013473/YOUTUBE_1080_13473_LandIce_2017_iPhone_youtube_1080.mp4 | 2549 | PD · 1080p |
| `sky_svalbard_seaice` | IceBridge — Svalbard, banking over fractured pack ice | NASA SVS 13477 — https://svs.gsfc.nasa.gov/13477 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013477/YOUTUBE_1080_13477_iPhone_2017_SeaIce_Broken_youtube_1080.mp4 | 2044 | PD · 1080p · abstract ice leads |
| `sky_petermann_icebergs` | IceBridge — over icebergs toward Petermann calving front | NASA SVS 13471 — https://svs.gsfc.nasa.gov/13471 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013471/YOUTUBE_1080_3471_4K_SeaIce_2017_Icebergs_CalvingFront_youtube_1080.mp4 | 3054 | PD · 1080p (4K master) |
| `sky_greenland_suture` | IceBridge — N. Greenland, overhead sea-ice patterning ("Suture") | NASA SVS 13472 — https://svs.gsfc.nasa.gov/13472 | https://svs.gsfc.nasa.gov/vis/a010000/a013400/a013472/YOUTUBE_1080_13472_4K_SeaIce_2017_Suture_youtube_1080.mp4 | 1539 | PD · 1080p (4K master) · nadir ice texture |
| `sky_helheim` | "The Helheim Experience" — scenic C-130 flight near Helheim Glacier | NASA SVS 11862 — https://svs.gsfc.nasa.gov/11862/ | https://svs.gsfc.nasa.gov/vis/a010000/a011800/a011862/Helheim_scenic_youtube_hq.mov | 4064 | PD · ⚠ **720p only** · source has a music bed (mute it) |
| `sky_clouds_mountains` | "Cloudy with a Chance of Mountains" — clouds + mountains aerial | US DoD / DVIDS 464215 — https://www.dvidshub.net/video/464215/cloudy-with-chance-mountains-no-lower-thirds | https://d34w7g4gy10iej.cloudfront.net/video/1604/DOD_103314280/DOD_103314280-1024x576-1769k.mp4 | scrub | PD (DoD) · ⚠ **576p** · "no lower thirds" cut; scrub to an aerial-only segment |
**Group B — cloudscapes & atmosphere (CG renders, "in/above the clouds"):**
| id | clip | source | direct media | window (s) | notes |
|----|------|--------|--------------|-----------|-------|
| `sky_clouds_na` | Clouds over North America — cinematic cloud-layer sweep | NASA SVS 3860 — https://svs.gsfc.nasa.gov/3860/ | https://svs.gsfc.nasa.gov/vis/a000000/a003800/a003860/hsymph_na.mp4 | 327 | PD · 1080p · closest "sea of clouds" beauty shot (~115 MB) |
| `sky_clouds_gulf` | Simulated clouds over the Gulf — artistic "beauty" render | NASA SVS 3921 — https://svs.gsfc.nasa.gov/3921/ | https://svs.gsfc.nasa.gov/vis/a000000/a003900/a003921/michael_beauty_v08_1080p.mp4 | 226 | PD · 1080p · art-lit towering clouds (~118 MB) |
| `sky_geos_7km` | GEOS-5 modeled clouds — 7 km global resolution | NASA SVS 3657 — https://svs.gsfc.nasa.gov/3657/ | https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003657/comp_geos5_7km.mp4 | 327 | PD · 1080p · small file (~23 MB, LFS-friendly) |
| `sky_geos_35km` | GEOS-5 modeled clouds — 3.5 km global resolution | NASA SVS 3659 — https://svs.gsfc.nasa.gov/3659/ | https://svs.gsfc.nasa.gov/vis/a000000/a003600/a003659/comp_geos5_3.5km_new.mp4 | 428 | PD · 1080p · ⚠ white flashes at loop-restart — land window between them (~141 MB) |
| `sky_geos_visible` | GEOS-5 "visible" — photoreal clouds + aerosols deck | NASA SVS 30591 — https://svs.gsfc.nasa.gov/30591/ | https://svs.gsfc.nasa.gov/vis/a030000/a030500/a030591/geos_visible_1080p.mp4 | 1034 | PD · 1080p (4K master) · ⚠ **large ~573 MB**; scrub for any colorbar |
**Group C — atmospheric limb / full-disk (orbit-adjacent, optional — may overlap `orbit`):**
| id | clip | source | direct media | window (s) | notes |
|----|------|--------|--------------|-----------|-------|
| `sky_earth_atmos` | Spinning Earth with atmosphere, clouds & night lights | NASA SVS 5570 — https://svs.gsfc.nasa.gov/5570/ | https://svs.gsfc.nasa.gov/vis/a000000/a005500/a005570/Earth_wAtmos_spin_02_1080p60.mp4 | 024 | PD · 1080p60 (4K master) · visible atmospheric limb + cloud layer (~33 MB) |
| `sky_goes_fulldisk` | GOES-16 — first full-disk Earth (true color) | NOAA GOES-R — https://www.goes-r.gov/multimedia/dataAndImageryVideosGoes-16.html | https://www.goes-r.gov/multimedia/originalVideoCopies/dataAndImagery/GOES16/ABI_Jan15_2017_fulldisk_loop.mp4 | 07 (loop) | PD (NOAA/NASA) · 1440×1080 · ⚠ tiny NOAA+NASA logo bottom-right (croppable) |
### Round-2 candidates (2026-06-28) — aerial over rainforest / coast / mountains
All direct URLs HEAD-verified 200 on 2026-06-28. Strict US-gov **public domain**
unless a license is given; CC-BY/CC-BY-SA usable with the credit shown; ⚠ Pexels
clips use a custom non-PD license (flagged). "Human-free" on the CC-BY/Pexels clips
is from descriptions — eyeball the window before baking.
**🌴 rainforest (aerial over canopy)** — *clean strict-PD photographic rainforest aerial is thin; NASA's are lidar renders, the DVIDS clips need windowing:*
| id | clip | source | direct media | window (s) | notes |
|----|------|--------|--------------|-----------|-------|
| `sky_canopy_transect` | 50 km of Brazilian forest canopy — continuous flyover | NASA SVS 4530 — https://svs.gsfc.nasa.gov/4530 | https://svs.gsfc.nasa.gov/vis/a000000/a004500/a004530/transect2014_1080p30.mp4 | 1842 | PD · 1080p (4K avail) · ⚠ lidar render, not photographic |
| `sky_canopy_flythrough` | Flying through LIDAR canopy — into the leaves | NASA SVS 4532 — https://svs.gsfc.nasa.gov/4532/ | https://svs.gsfc.nasa.gov/vis/a000000/a004500/a004532/flythrough_1080p30.mp4 | 1034 | PD · 1080p · ⚠ lidar render; abstract |
| `sky_jungle_pr` | Puerto Rico — helicopter aerial over jungle mountains | DVIDS 584062 — https://www.dvidshub.net/video/584062/ | https://d34w7g4gy10iej.cloudfront.net/video/1802/DOD_105308416/DOD_105308416.mp4 | 3458 | PD (USACE) · 1080p, no audio · ⚠ recovery b-roll — only mountain-overflight is clean |
| `sky_jungle_thai` | Thailand — aerial over dense jungle canopy (Cobra Gold) | DVIDS 585804 — https://www.dvidshub.net/video/585804/ | https://d34w7g4gy10iej.cloudfront.net/video/1802/DOD_105335187/DOD_105335187.mp4 | 024 | PD (USMC) · ≤1080p · ⚠ live-fire exercise — pick early canopy beat |
**🌊 coast — no humans (aerial):**
| id | clip | source | direct media | window (s) | license / notes |
|----|------|--------|--------------|-----------|-----------------|
| `sky_coast_icefjord` | Greenland — Ilulissat ice fjord, glaciers meeting the sea | NASA SVS 11982 — https://svs.gsfc.nasa.gov/11982/ | https://svs.gsfc.nasa.gov/vis/a010000/a011900/a011982/Aerials.webm | 3054 | PD · 4K webm ~56 MB · real helicopter aerials (1080p mp4 `Aerials_2.mp4` also live, ~282 MB) |
| `sky_coast_moher` | Cliffs of Moher — drone over Atlantic sea cliffs | Wikimedia — https://commons.wikimedia.org/wiki/File:Cliffs_of_Moher_drone-video.webm | https://upload.wikimedia.org/wikipedia/commons/6/62/Cliffs_of_Moher_drone-video.webm | 1539 | **CC BY-SA 4.0 — credit Wiebe de Jager** · 1080p · use over-water window |
| `sky_coast_norway` | Arctic Norway — fjords &amp; coastal waterways (FPV drone) | Wikimedia — https://commons.wikimedia.org/wiki/File:Aerial_imagery_of_fjords_and_rivers_in_Arctic_Norway.webm | https://upload.wikimedia.org/wikipedia/commons/8/80/Aerial_imagery_of_fjords_and_rivers_in_Arctic_Norway.webm | 6084 | **CC BY 3.0 — credit anotherview** · 4K ⚠ ~485 MB, trim |
| `sky_coast_dunes` | The Slufter, Texel — coastal dunes &amp; tidal channels | Wikimedia — https://commons.wikimedia.org/wiki/File:The_Slufter_from_above.webm | https://upload.wikimedia.org/wikipedia/commons/1/12/The_Slufter_from_above.webm | 3054 | **CC BY 3.0 — credit Sander van Schie De Verfilmer** · 4K ⚠ ~436 MB, trim |
| `sky_coast_reeflagoon` | Coral reef / turquoise lagoon from above | Pexels 12952333 — https://www.pexels.com/video/drone-video-of-coral-reefs-under-turquoise-sea-water-12952333/ | https://videos.pexels.com/video-files/12952333/12952333-hd_1920_1080_25fps.mp4 | 024 | ⚠ **Pexels License (not PD)** · 1080p (1440p avail) · "No People" tag — confirm |
| `sky_coast_cliffsurf` | Rugged cliff coastline above the surf | Pexels 5619876 — https://www.pexels.com/video/drone-footage-of-a-cliff-coastline-above-the-sea-water-5619876/ | https://videos.pexels.com/video-files/5619876/5619876-hd_1920_1080_25fps.mp4 | 024 | ⚠ **Pexels License (not PD)** · 1080p ~8 MB · confirm no surfers/boats |
**⛰️ mountains (aerial over peaks &amp; ridgelines)** — *the richest strict-PD vein (NPS + US Forest Service):*
| id | clip | source | direct media | window (s) | notes |
|----|------|--------|--------------|-----------|-------|
| `sky_mtn_rocky` | Rocky Mountain NP — aerial over alpine summits &amp; tundra | NPS — https://www.nps.gov/media/video/view.htm?id=8E66DB80-64C2-4258-B878-5F9244FB6C60 | https://www.nps.gov/nps-audiovideo/audiovideo/188046ff-c9d6-4859-8c7a-1e0d3d306ed61080p.mp4 | 6084 | PD · 1080p · pick peaks segment (Trail Ridge Rd in some shots) |
| `sky_mtn_castlecrags` | Castle Crags — past jagged granite spires &amp; ridgelines | DVIDS 998726 — https://www.dvidshub.net/video/998726/ | https://d34w7g4gy10iej.cloudfront.net/video/2603/DOD_111563303/DOD_111563303.mp4 | 120144 | PD (USFS) · 1080p · confirmed no text/people — strongest pick |
| `sky_mtn_shasta` | Mount Shasta — drone aerial of the snow-capped volcano | DVIDS 998733 — https://www.dvidshub.net/video/998733/ | https://d34w7g4gy10iej.cloudfront.net/video/2603/DOD_111563312/DOD_111563312.mp4 | 3054 | PD (USFS) · 1080p · distant town in part of clip |
| `sky_mtn_granite` | Shasta-Trinity NF — high alpine terrain near Granite Peak | DVIDS 998735 — https://www.dvidshub.net/video/998735/ | https://d34w7g4gy10iej.cloudfront.net/video/2603/DOD_111563314/DOD_111563314.mp4 | 90114 | PD (USFS) · 1080p · ridgelines, no text/people noted |
| `sky_mtn_snowfield` | Mount Shasta slopes — over white alpine snowfields | DVIDS 998725 — https://www.dvidshub.net/video/998725/ | https://d34w7g4gy10iej.cloudfront.net/video/2603/DOD_111563300/DOD_111563300.mp4 | 3054 | PD (USFS) · 1080p · favor higher-altitude snow (trailhead may appear low) |
| `sky_mtn_sierra` | Sierra near Peddler Hill — snow-blanketed high country | DVIDS 996457 — https://www.dvidshub.net/video/996457/ | https://d34w7g4gy10iej.cloudfront.net/video/2602/DOD_111530221/DOD_111530221.mp4 | 4064 | PD (USFS) · 1080p · softer snowy ridges |
### Round-3 candidates (2026-06-28) — fresh rainforest + longer reef/cliff
Replaces the rejected round-2 rainforest batch, and gives the two "liked but too
short" coast subjects proper length. All HEAD-verified 200 on 2026-06-28; all
durations support a 24s+ window. **Mostly Pexels License** (free to use, **not**
PD/CC — flagged) since clean strict-PD photographic aerial in these subjects is
effectively absent; one CC-BY option included. Human-free is per description —
eyeball the window before baking.
**🌴 rainforest — fresh batch (photographic drone, wild/uninhabited):**
| id | clip | direct media | window (s) | license / notes |
|----|------|--------------|-----------|-----------------|
| `sky_jungle_amazon` | Lush Amazon rainforest canopy — slow aerial push | https://videos.pexels.com/video-files/31291128/13360655_3840_2160_60fps.mp4 | 327 | ⚠ Pexels · 4K60 · confirmed no people/buildings/roads/watermark — **top pick** · [page](https://www.pexels.com/video/aerial-view-of-lush-amazon-rainforest-canopy-31291128/) |
| `sky_jungle_canopy` | Lush green jungle canopy — gliding aerial | https://videos.pexels.com/video-files/29780524/12796533_1920_1080_25fps.mp4 | 226 | ⚠ Pexels · native 1080p ~61 MB · [page](https://www.pexels.com/video/aerial-view-of-lush-green-jungle-canopy-29780524/) |
| `sky_jungle_river` | Amazon rainforest river through unbroken jungle | https://videos.pexels.com/video-files/35969886/15251199_3840_2160_30fps.mp4 | 327 | ⚠ Pexels · 4K · the "jungle river" angle · [page](https://www.pexels.com/video/stunning-aerial-view-of-amazon-rainforest-river-35969886/) |
| `sky_jungle_misty` | Misty mountain forest — low cloud over jungle hills | https://videos.pexels.com/video-files/34697024/14706326_3840_2160_30fps.mp4 | 327 | ⚠ Pexels · 4K · cloud-forest mood · [page](https://www.pexels.com/video/misty-mountain-forest-aerial-view-in-dense-jungle-34697024/) |
| `sky_jungle_clouds` | Lush greenery shrouded in clouds — aerial | https://videos.pexels.com/video-files/36998596/15673868_3840_2160_60fps.mp4 | 327 | ⚠ Pexels · 4K60 ~19 MB (small) · [page](https://www.pexels.com/video/aerial-view-of-lush-greenery-shrouded-in-clouds-36998596/) |
| `sky_jungle_riverhd` | Serene jungle river landscape — aerial | https://videos.pexels.com/video-files/35897960/15226438_1920_1080_30fps.mp4 | 024 | ⚠ Pexels · native 1080p ~25 MB (fits LFS as-is) · [page](https://www.pexels.com/video/serene-aerial-view-of-jungle-river-landscape-35897960/) |
**🌊 coast — longer replacements (≥26s of footage):**
| id | clip | direct media | window (s) | dur | license / notes |
|----|------|--------------|-----------|-----|-----------------|
| `sky_coast_reef` | Coral reef + blue lagoon, Balicasag (replaces `reeflagoon`) | https://videos.pexels.com/video-files/36379860/15428423_3840_2160_60fps.mp4 | 428 | 32s | ⚠ Pexels · 4K60 · truest coral match · ⚠ dive site — check window for boats · [page](https://www.pexels.com/video/aerial-drone-view-of-balicasag-island-coral-reefs-36379860/) |
| `sky_coast_turquoise` | Rocks in shallow turquoise water, straight down (reeflagoon alt) | https://videos.pexels.com/video-files/12738229/12738229-uhd_2560_1440_30fps.mp4 | 428 | 32s | ⚠ Pexels · 1440p · cleanest turquoise (rocky seabed > coral) · [page](https://www.pexels.com/video/drone-view-of-rocks-in-shallow-turquoise-water-12738229/) |
| `sky_coast_cliffsurf2` | Mountain cliffs above the sea, crashing surf (replaces `cliffsurf`) | https://videos.pexels.com/video-files/4385207/4385207-hd_1920_1080_30fps.mp4 | 832 | 50s | ⚠ Pexels · native 1080p · rugged cliffs + surf, no man-made · [page](https://www.pexels.com/video/drone-footage-of-mountain-cliffs-above-the-sea-water-4385207/) |
| `sky_coast_cliffspain` | Dramatic Atlantic sea cliffs, N. Spain (cliffsurf alt) | https://videos.pexels.com/video-files/36668415/15545045_3840_2160_60fps.mp4 | 125 | 26s | ⚠ Pexels · 4K60 · page-confirmed no people/boats/buildings/text · [page](https://www.pexels.com/video/dramatic-cliffs-in-northern-spain-with-ocean-views-36668415/) |
| `sky_coast_oregon` | Oregon Coast — rugged Pacific sea cliffs &amp; surf (FPV drone) | https://upload.wikimedia.org/wikipedia/commons/4/40/Oregon_Coast_Odyssey-_FPV_Drone_Captures_Stunning_Ocean_Views.webm | 2044 | 96s | **CC BY 3.0 — credit EagleView** · 4K · the one clean CC cliff option · ⚠ FPV = faster/tilted motion · [page](https://commons.wikimedia.org/wiki/File:Oregon_Coast_Odyssey-_FPV_Drone_Captures_Stunning_Ocean_Views.webm) |
**🌴 Round-3b rainforest — longer versions of the three liked-but-too-short moods (still open):**
Durations measured via ffprobe; all comfortably support a 24s window. All ⚠ Pexels License.
| id | clip | direct media | window (s) | dur | notes |
|----|------|--------------|-----------|-----|-------|
| `sky_jungle_misty` | Misty rainforest in dense fog — fog rolling over canopy | https://videos.pexels.com/video-files/30770305/13162099_3840_2160_30fps.mp4 | 1034 | 120s | 4K · top misty pick, no flags · [page](https://www.pexels.com/video/misty-rainforest-aerial-view-in-dense-fog-30770305/) |
| `sky_jungle_misty_alt` | Misty mountainous jungle, Vietnam — mist on ridgelines | https://videos.pexels.com/video-files/30575002/13092740_3840_2160_60fps.mp4 | 327 | 31s | 4K60 · cloud-forest ridgelines · [page](https://www.pexels.com/video/misty-mountainous-jungle-in-vietnam-30575002/) |
| `sky_jungle_clouds` | Serene misty green hills — same shoot as original, longer | https://videos.pexels.com/video-files/36998553/15673828_3840_2160_60fps.mp4 | 529 | 38s | 4K60 · adjacent ID to the liked clip · [page](https://www.pexels.com/video/serene-aerial-view-of-misty-green-hills-36998553/) |
| `sky_jungle_clouds_alt` | Flying through clouds over green hills | https://videos.pexels.com/video-files/28499216/12400645_3840_2160_25fps.mp4 | 1236 | 49s | 4K · strongest "shrouded" feel · [page](https://www.pexels.com/video/aerial-view-through-clouds-over-green-hills-28499216/) |
| `sky_jungle_riverhd` | Lush green forest &amp; river — river through canopy | https://videos.pexels.com/video-files/35612503/15091993_3840_2160_30fps.mp4 | 1034 | 54s | 4K · 2nd river (redundant if one river is enough) · [page](https://www.pexels.com/video/aerial-view-of-lush-green-forest-and-river-35612503/) |
**✨ Round-3c rainforest — more distinct looks (balance rainforest to ~4, still open):**
Distinct subjects beyond canopy/river/misty. All ⚠ Pexels License, all ≥33s.
Gaps the search could NOT fill at the 28s + clean bar: golden-hour raking light,
volcanic-rainforest ridges, dramatic oxbow S-curves, mist-in-columns (closest
options were <28s, vertical, contaminated with villages/canoes, or temperate biome).
| id | clip | direct media | window (s) | dur | look |
|----|------|--------------|-----------|-----|------|
| `sky_jungle_waterfall` | Waterfall plunging out of dense jungle canopy | https://videos.pexels.com/video-files/8334944/8334944-uhd_4096_2160_24fps.mp4 | 1034 | 46s | WATERFALL — fully distinct, strongest new look · [page](https://www.pexels.com/video/drone-footage-of-a-forest-with-waterfalls-8334944/) |
| `sky_jungle_warmlight` | Tropical jungle canopy in warm hazy morning light | https://videos.pexels.com/video-files/26829790/12015586_3840_2160_30fps.mp4 | 428 | ~33s | warm low-light (closest to golden-hour found) · [page](https://www.pexels.com/video/aerial-view-of-a-tropical-jungle-with-fog-and-trees-26829790/) |
| `sky_jungle_textured` | Varied jungle canopy — mixed greens &amp; emergent texture | https://videos.pexels.com/video-files/2317719/2317719-uhd_3840_2160_24fps.mp4 | 630 | 37s | textured/emergent canopy, richer than the plain glide · [page](https://www.pexels.com/video/aerial-footage-of-a-jungle-2317719/) |
> **Round-3 sourcing note:** reef/lagoon clips ≥25s are scarce (most are 1220s); the
> two above clear the bar. Cliff-coast supply is good. The genuine remaining gap is
> *strict-PD / CC* reef + rainforest — Commons has no clean aerial reef video and only
> short/credit-bound rainforest, so Pexels is the pragmatic floor here.
> **Round-2 sourcing notes:** NPS bans in-park drone ops (2014) → almost no NPS
> *aerial* coast/forest; DVIDS coast aerials nearly all had people/boats/vehicles;
> photographic strict-PD rainforest aerial is the genuine gap (NASA's are lidar
> renders) — Pixabay/Pexels drone canopy is the fallback if none of the above land.
> DVIDS direct files serve as `binary/octet-stream` but are valid mp4 (the page uses
> `<source type="video/mp4">` so they preview inline).
> **Sources considered & rejected** (so a future session doesn't re-chase them):
> NOAA's high-res GOES satellite loops (FOUREASTER full-disk, Irma/Maria geocolor,
> "Clouds with Precipitation", "Satellite Models") all carry burned-in telemetry
> bars / political borders / date stamps / colorbars → **unusable**. NOAA's clean
> globes (SOS "Clouds real-time", "Blue Marble") are text-free but only **400×400**
> *and* overlap the existing `orbit` blue-marble. NESDIS "Earth from Orbit" and the
> Ocean-Today "Hurricane Hunters" piece are produced/narrated packages with
> text+people. NASA "Fly Above Alaskan Glaciers" is 360°/equirectangular (not a flat
> clip). The IceBridge cockpit "banking over Disko Bay" cut has windshield/panel in
> frame. The full **NASA SVS IceBridge 1347113477** series is a rich vein if more
> aerial-ice clips are wanted later (each page also offers multi-GB 4K `.mov`
> masters).
## 🛰️ orbit (pool of 3)
| id | clip | source | window (s) | license |
@@ -0,0 +1,733 @@
# Localization & Language Dropdown 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:** Add a language dropdown to the simulator that localizes all visitor-facing UI controls and the Left/Right brain annotations into English, Spanish, French, and Japanese, switchable live without reload.
**Architecture:** A new UMD module `simulator/static/i18n.js` (dual-export like `scrub.js`) holds the language registry, the UI-string catalog, and pure helpers (`resolveStrings`, `pickUiString`). `index.html` static text is tagged with `data-i18n` attributes; `app.js` populates a `<select>` from the registry and on change swaps UI strings + re-renders annotations. Annotation content extends the existing per-clip `strings` dict (`{lang: {key: text|list}}`) with `es/fr/ja` keys — no schema change. A Python tool (`tools/i18n/translate_manifest.py`) extracts the English catalog and merges authored translation catalogs back into the manifest.
**Tech Stack:** Vanilla JS (UMD modules, `node:test`), FastAPI (existing, untouched), Python (stdlib `json` + pytest), Playwright (E2E).
## Global Constraints
- **Languages (exact codes & order):** `en` (English, default + fallback), `es` (Español), `fr` (Français), `ja` (日本語). No Hebrew, no Chinese, **no RTL work**.
- **Default & persistence:** `activeLang` defaults to `"en"` every load; **no localStorage** — selection is session-only.
- **Fallback:** every annotation lookup falls back to the `en` value when a key is missing in the active language; a missing language never blanks the overlay.
- **No schema change:** annotation translations are sibling keys inside each clip's existing `strings` object.
- **Manifest file:** `simulator/sample_media/manifest.json` is the live manifest (`DEFAULT_MANIFEST` in `simulator/app.py:47`). 41 clips, 753 English string-variants.
- **Tier preservation:** a string authored as a list (tiers) stays a list of identical length in every language; a plain string stays a plain string.
- **Scope out:** dev-panel diagnostics internals (`#dev-panel`, audio diagnostics, pool picker), `/author.html`, clip titles. Only visitor-facing chrome + annotations are localized.
- **Repo conventions:** SSH git; commit messages carry `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`. Run commands from repo root unless noted.
---
### Task 1: i18n module — registry, catalog, pure helpers
**Files:**
- Create: `simulator/static/i18n.js`
- Test: `simulator/unit/i18n.test.js`
**Interfaces:**
- Consumes: nothing.
- Produces (the `HEFi18n` global / `module.exports`):
- `LANGUAGES``[{code:"en",nativeName:"English"}, {code:"es",nativeName:"Español"}, {code:"fr",nativeName:"Français"}, {code:"ja",nativeName:"日本語"}]`
- `UI_STRINGS``{ key: {en, es, fr, ja} }` map (control chrome, all four languages)
- `pickUiString(key, lang)` → string; the `lang` value, else the `en` value, else the `key` itself.
- `resolveStrings(clipStrings, lang)` → object; `clipStrings[lang]` merged over `clipStrings.en` (so a missing key in `lang` falls back to `en`), else `clipStrings.en`, else `{}`.
- [ ] **Step 1: Write the failing test**
```js
// simulator/unit/i18n.test.js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const I = require("../static/i18n.js");
test("LANGUAGES lists the four codes in order, English first", () => {
assert.deepEqual(I.LANGUAGES.map((l) => l.code), ["en", "es", "fr", "ja"]);
assert.equal(I.LANGUAGES[0].nativeName, "English");
assert.equal(I.LANGUAGES.find((l) => l.code === "ja").nativeName, "日本語");
});
test("pickUiString returns the language value, falls back to en, then to key", () => {
assert.equal(I.pickUiString("output.legend", "es"), I.UI_STRINGS["output.legend"].es);
// a key present in en but (hypothetically) missing in fr falls back to en:
const saved = I.UI_STRINGS["output.legend"].fr;
delete I.UI_STRINGS["output.legend"].fr;
assert.equal(I.pickUiString("output.legend", "fr"), I.UI_STRINGS["output.legend"].en);
I.UI_STRINGS["output.legend"].fr = saved;
assert.equal(I.pickUiString("totally.unknown.key", "en"), "totally.unknown.key");
});
test("UI_STRINGS has every language for every key", () => {
for (const [key, vals] of Object.entries(I.UI_STRINGS)) {
for (const code of ["en", "es", "fr", "ja"]) {
assert.ok(typeof vals[code] === "string" && vals[code].length, `${key} missing ${code}`);
}
}
});
test("resolveStrings merges lang over en (per-key fallback)", () => {
const cs = { en: { a: "A", b: "B" }, es: { a: "Aes" } };
assert.deepEqual(I.resolveStrings(cs, "es"), { a: "Aes", b: "B" });
assert.deepEqual(I.resolveStrings(cs, "fr"), { a: "A", b: "B" }); // no fr -> all en
assert.deepEqual(I.resolveStrings({}, "es"), {});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test simulator/unit/i18n.test.js`
Expected: FAIL — `Cannot find module '../static/i18n.js'`.
- [ ] **Step 3: Write the module**
```js
// simulator/static/i18n.js
// UI/annotation localization — registry, control-string catalog, and pure
// lookup helpers. UMD so the browser gets a `HEFi18n` global and
// `node --test` can require() it. English is the source of truth & fallback.
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFi18n = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const LANGUAGES = [
{ code: "en", nativeName: "English" },
{ code: "es", nativeName: "Español" },
{ code: "fr", nativeName: "Français" },
{ code: "ja", nativeName: "日本語" },
];
// 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: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", 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: "音声" },
"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" },
"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: "開発モード" },
"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: "空" },
"scale.coast": { en: "coast", es: "costa", fr: "côte", ja: "海岸" },
"scale.reef": { en: "reef", es: "arrecife", fr: "récif", ja: "サンゴ礁" },
"scale.abyss": { en: "abyss", es: "abismo", fr: "abîme", ja: "深海" },
};
function pickUiString(key, lang) {
const v = UI_STRINGS[key];
if (!v) return key;
return (v[lang] != null ? v[lang] : v.en) || key;
}
function resolveStrings(clipStrings, lang) {
if (!clipStrings || !clipStrings.en) return (clipStrings && clipStrings[lang]) || {};
if (lang === "en" || !clipStrings[lang]) return clipStrings.en;
return Object.assign({}, clipStrings.en, clipStrings[lang]);
}
return { LANGUAGES, UI_STRINGS, pickUiString, resolveStrings };
});
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test simulator/unit/i18n.test.js`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add simulator/static/i18n.js simulator/unit/i18n.test.js
git commit -m "feat(i18n): language registry + UI-string catalog module"
```
---
### Task 2: Tag index.html + add the language dropdown
**Files:**
- Modify: `simulator/static/index.html`
- Test: `simulator/unit/index-i18n.test.js`
**Interfaces:**
- Consumes: `data-i18n` key names from Task 1's `UI_STRINGS`.
- Produces: a `<select id="lang-select">` and `data-i18n`-tagged nodes for Task 3 to drive.
- [ ] **Step 1: Write the failing test** (parses the HTML as text — no DOM lib needed)
```js
// simulator/unit/index-i18n.test.js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const I = require("../static/i18n.js");
const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");
test("index.html includes the i18n script and a language select", () => {
assert.match(html, /<script src="\/i18n\.js">/);
assert.match(html, /id="lang-select"/);
});
test("every data-i18n key in index.html exists in UI_STRINGS", () => {
const keys = [...html.matchAll(/data-i18n="([^"]+)"/g)].map((m) => m[1]);
assert.ok(keys.length >= 12, `expected many tagged nodes, found ${keys.length}`);
for (const k of keys) assert.ok(I.UI_STRINGS[k], `data-i18n key not in catalog: ${k}`);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test simulator/unit/index-i18n.test.js`
Expected: FAIL — no `i18n.js` script / no `lang-select` / no `data-i18n` attributes yet.
- [ ] **Step 3: Edit index.html**
Apply these exact edits:
1. Load the module before `app.js` (after the `scrub.js` script, line ~107):
```html
<script src="/scrub.js"></script>
<script src="/i18n.js"></script>
<script src="/app.js"></script>
```
2. Tag the loading title (line 12):
```html
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
```
3. Tag the header (line 16):
```html
<header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
```
4. In the **Output** fieldset (lines 3344), tag the legend + video label, and **add the language select** as the first control:
```html
<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>
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
</fieldset>
```
5. Tag the **Altitude** legend (line 47) and hint (line 52):
```html
<legend data-i18n="altitude.legend">Altitude</legend>
```
```html
<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>
```
6. Tag the **Experience knobs** fieldset (lines 5659):
```html
<legend data-i18n="knobs.legend">Experience knobs (04)</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>
```
7. Tag the **Dev Mode** label (line 66):
```html
<span class="dev-switch-label" data-i18n="devmode.label">Dev Mode</span>
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node --test simulator/unit/index-i18n.test.js`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add simulator/static/index.html simulator/unit/index-i18n.test.js
git commit -m "feat(i18n): tag controls with data-i18n + add language dropdown"
```
---
### Task 3: Wire live language switching in app.js
**Files:**
- Modify: `simulator/static/app.js` (render sites at `:505` and `:556`; add init + handler)
- Test: covered by Task 1's `resolveStrings` unit test (pure logic) + Task 6 E2E (DOM behavior). No new unit file.
**Interfaces:**
- Consumes: `HEFi18n.LANGUAGES`, `HEFi18n.pickUiString`, `HEFi18n.resolveStrings`; existing `renderOverlay`, `renderAffect`, `renderScaleReadout`, `lastOverlay`.
- Produces: module-global `activeLang`; `lastAffect` cache for re-render.
- [ ] **Step 1: Add `activeLang` + `lastAffect` globals**
Near the other module globals (around `simulator/static/app.js:4755`, after `let lastOverlay = null;`), add:
```js
let activeLang = "en"; // session-only; no persistence (resets to en each load)
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
```
- [ ] **Step 2: Use `activeLang` at the two render sites**
In `renderOverlay` (line ~505) replace:
```js
const strings = (clip.strings && clip.strings.en) || {};
```
with:
```js
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
```
In `renderAffect` (line ~556) replace the same line with the same call, **and** cache the args at the top of `renderAffect` so a language switch can re-render without a server round-trip. Right after `function renderAffect(strength, intensity, right) {` add:
```js
lastAffect = { strength, intensity, right };
```
and replace its `const strings = (clip.strings && clip.strings.en) || {};` with:
```js
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
```
- [ ] **Step 3: Localize the scale-name readout**
In `renderScaleReadout` (line ~575) replace:
```js
$("scale-name").textContent = `${s.id} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
```
with (localize only the scale id; `member` is a clip title and stays as-is):
```js
const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
$("scale-name").textContent = `${scaleName} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
```
- [ ] **Step 4: Add the init + change handler**
Add this function and call it once during startup. Place the function near the other `apply*` helpers, and invoke `initLanguage()` from the existing startup path (the same place that first calls `renderScaleReadout()` / builds the panel — search for the boot sequence after the manifest `fetch` in the init function around `simulator/static/app.js:58104`; add `initLanguage();` there):
```js
// Populate the language dropdown from the registry and wire live switching.
// English default, session-only (no persistence). Switching swaps UI chrome
// and re-renders the current annotations/affect in place — no reload, no fetch.
function initLanguage() {
const sel = $("lang-select");
if (!sel) return;
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
const o = document.createElement("option");
o.value = code;
o.textContent = nativeName;
sel.appendChild(o);
}
sel.value = activeLang;
applyUiStrings(activeLang);
sel.addEventListener("change", () => setLanguage(sel.value));
}
// Fill every [data-i18n] node with its catalog string for `lang`.
function applyUiStrings(lang) {
document.documentElement.lang = lang;
for (const el of document.querySelectorAll("[data-i18n]")) {
el.textContent = HEFi18n.pickUiString(el.getAttribute("data-i18n"), lang);
}
}
function setLanguage(lang) {
activeLang = lang;
applyUiStrings(lang);
renderScaleReadout();
if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
if (lastAffect) renderAffect(lastAffect.strength, lastAffect.intensity, lastAffect.right);
}
```
- [ ] **Step 5: Run the full node unit suite**
Run: `node --test simulator/unit/`
Expected: PASS — existing `scrub.test.js`, plus `i18n.test.js` and `index-i18n.test.js`.
- [ ] **Step 6: Manual smoke (optional but recommended)**
Run the app and confirm the dropdown lists English/Español/Français/日本語, switching changes the control labels and (with Think/Feel up) the annotations, and reload returns to English. (Server: the existing uvicorn launch — restart it with the venv python so it serves the new static files; a stale uvicorn is the recurring "no change" root cause.)
- [ ] **Step 7: Commit**
```bash
git add simulator/static/app.js
git commit -m "feat(i18n): live language switching for chrome + annotations"
```
---
### Task 4: Manifest translation tool (extract + merge)
**Files:**
- Create: `tools/i18n/translate_manifest.py`
- Create: `tools/i18n/__init__.py` (empty — `tools/` is a package; siblings `tools/pipeline/`, `tools/ingest/` each have one)
- Test: `tests/test_i18n_translate.py` (repo `tests/` is flat — match it)
**Interfaces:**
- Consumes: the manifest JSON shape — `{"clips": [ {"id", "strings": {"en": {key: str|list}}}, ... ]}`.
- Produces:
- `extract_en_catalog(manifest) -> {clip_id: {key: str|list}}` — the English strings of every clip.
- `merge_catalog(manifest, lang, catalog) -> manifest` — writes `catalog[clip_id]` into each clip's `strings[lang]`; **structure-validated** (raises `ValueError` if a key's type/list-length differs from `en`); **idempotent** (re-running with the same catalog is a no-op); leaves `en` untouched.
- [ ] **Step 1: Write the failing test**
```python
# tests/test_i18n_translate.py
import copy
import pytest
from tools.i18n.translate_manifest import extract_en_catalog, merge_catalog
MANIFEST = {
"clips": [
{"id": "cosmos", "strings": {"en": {
"detected.star": ["star", "young star"],
"measure.distance": "≈7,500 ly",
}}},
]
}
def test_extract_pulls_every_en_string():
cat = extract_en_catalog(MANIFEST)
assert cat == {"cosmos": {"detected.star": ["star", "young star"], "measure.distance": "≈7,500 ly"}}
def test_merge_writes_lang_and_leaves_en():
m = copy.deepcopy(MANIFEST)
cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
merge_catalog(m, "es", cat)
s = m["clips"][0]["strings"]
assert s["en"]["measure.distance"] == "≈7,500 ly" # en untouched
assert s["es"]["detected.star"] == ["estrella", "estrella joven"]
def test_merge_is_idempotent():
m = copy.deepcopy(MANIFEST)
cat = {"cosmos": {"detected.star": ["estrella", "estrella joven"], "measure.distance": "≈7.500 al"}}
merge_catalog(m, "es", cat)
once = copy.deepcopy(m)
merge_catalog(m, "es", cat)
assert m == once
def test_merge_rejects_tier_length_mismatch():
m = copy.deepcopy(MANIFEST)
bad = {"cosmos": {"detected.star": ["estrella"], "measure.distance": "≈7.500 al"}} # 1 tier vs en's 2
with pytest.raises(ValueError):
merge_catalog(m, "es", bad)
def test_merge_rejects_type_mismatch():
m = copy.deepcopy(MANIFEST)
bad = {"cosmos": {"detected.star": "estrella", "measure.distance": "≈7.500 al"}} # str vs en's list
with pytest.raises(ValueError):
merge_catalog(m, "es", bad)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_i18n_translate.py -q`
Expected: FAIL — `ModuleNotFoundError: tools.i18n.translate_manifest`.
- [ ] **Step 3: Write the tool**
```python
# tools/i18n/translate_manifest.py
"""Extract a manifest's English annotation strings and merge authored
translations back into each clip's `strings` dict (no schema change).
Workflow:
1. `extract` writes tools/i18n/catalogs/en.json — the English strings.
2. A translator (human or LLM) authors es.json / fr.json / ja.json with the
SAME structure (lists stay lists of the same length).
3. `merge` validates structure and writes strings[lang] into the manifest.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def extract_en_catalog(manifest: dict) -> dict:
out = {}
for clip in manifest.get("clips", []):
en = (clip.get("strings") or {}).get("en")
if en:
out[clip["id"]] = en
return out
def _check_shape(key: str, en_val, tr_val):
if isinstance(en_val, list):
if not isinstance(tr_val, list) or len(tr_val) != len(en_val):
raise ValueError(f"{key}: expected list of {len(en_val)} tiers, got {tr_val!r}")
elif not isinstance(tr_val, str):
raise ValueError(f"{key}: expected string, got {tr_val!r}")
def merge_catalog(manifest: dict, lang: str, catalog: dict) -> dict:
if lang == "en":
raise ValueError("refusing to overwrite the en source")
for clip in manifest.get("clips", []):
en = (clip.get("strings") or {}).get("en")
tr = catalog.get(clip["id"])
if not en or not tr:
continue
for key, en_val in en.items():
if key not in tr:
raise ValueError(f"{clip['id']}/{key}: missing in {lang} catalog")
_check_shape(f"{clip['id']}/{key}", en_val, tr[key])
clip["strings"][lang] = {k: tr[k] for k in en} # en key order, lang values
return manifest
def _main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("command", choices=["extract", "merge"])
ap.add_argument("--manifest", default="simulator/sample_media/manifest.json")
ap.add_argument("--catalog-dir", default="tools/i18n/catalogs")
ap.add_argument("--lang", help="merge: target language code")
args = ap.parse_args(argv)
mpath = Path(args.manifest)
manifest = json.loads(mpath.read_text(encoding="utf-8"))
cdir = Path(args.catalog_dir)
if args.command == "extract":
cdir.mkdir(parents=True, exist_ok=True)
(cdir / "en.json").write_text(
json.dumps(extract_en_catalog(manifest), ensure_ascii=False, indent=1) + "\n",
encoding="utf-8",
)
print(f"wrote {cdir / 'en.json'}")
else:
if not args.lang:
ap.error("merge requires --lang")
catalog = json.loads((cdir / f"{args.lang}.json").read_text(encoding="utf-8"))
merge_catalog(manifest, args.lang, catalog)
mpath.write_text(json.dumps(manifest, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
print(f"merged {args.lang} into {mpath}")
if __name__ == "__main__":
_main()
```
Create empty `tools/i18n/__init__.py` (and confirm whether `tools/` and `tools/pipeline/` have `__init__.py`; match the existing pattern — `simulator/app.py:296` imports `tools.pipeline.manifest`, so `tools` is importable as a package).
- [ ] **Step 4: Run test to verify it passes**
Run: `python -m pytest tests/test_i18n_translate.py -q`
Expected: PASS (5 tests).
- [ ] **Step 5: Generate the English catalog**
Run: `python -m tools.i18n.translate_manifest extract`
Expected: writes `tools/i18n/catalogs/en.json` (41 clips).
- [ ] **Step 6: Commit**
```bash
git add tools/i18n/translate_manifest.py tools/i18n/__init__.py tests/test_i18n_translate.py tools/i18n/catalogs/en.json
git commit -m "feat(i18n): manifest translation extract/merge tool + en catalog"
```
---
### Task 5: Author es/fr/ja translations + merge into the manifest
**Files:**
- Create: `tools/i18n/catalogs/es.json`, `tools/i18n/catalogs/fr.json`, `tools/i18n/catalogs/ja.json`
- Modify: `simulator/sample_media/manifest.json` (via the merge tool)
- Test: `tests/test_i18n_manifest.py`
**Interfaces:**
- Consumes: `tools/i18n/catalogs/en.json` (Task 4), `merge_catalog` (Task 4).
- Produces: a manifest where every clip's `strings` has `en/es/fr/ja`.
- [ ] **Step 1: Write the failing invariant test**
```python
# tests/test_i18n_manifest.py
import json
from pathlib import Path
LANGS = ["en", "es", "fr", "ja"]
MANIFEST = Path("simulator/sample_media/manifest.json")
def _tier_len(v):
return len(v) if isinstance(v, list) else 0 # 0 = plain string
def test_every_clip_has_all_languages_with_matching_shape():
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
for clip in manifest["clips"]:
s = clip.get("strings", {})
en = s.get("en")
if not en:
continue
for lang in LANGS:
assert lang in s, f"{clip['id']} missing language {lang}"
assert set(s[lang]) == set(en), f"{clip['id']}/{lang} key-set differs from en"
for key, en_val in en.items():
assert _tier_len(s[lang][key]) == _tier_len(en_val), (
f"{clip['id']}/{lang}/{key} tier-count differs from en"
)
```
- [ ] **Step 2: Run test to verify it fails**
Run: `python -m pytest tests/test_i18n_manifest.py -q`
Expected: FAIL — clips have only `en`.
- [ ] **Step 3: Author the three translation catalogs**
Copy `tools/i18n/catalogs/en.json` to `es.json`, `fr.json`, `ja.json` and translate **every value**, preserving each key and every list's length. Guidance:
- Tier lists escalate (general → scientific+fact for `detected.*`; basic → compound emotion for `feel.*`) — preserve that escalation in the target language.
- `measure.*` are plain strings (units/quantities) — localize number formatting where natural (e.g. `7,500``7.500` in es/fr) but keep the value.
- These are LLM-authored; the Japanese pass gets a human spot-check before merge.
This is the bulk content step (~2,260 strings across the three files). It is data authoring, not code — produce the JSON, then validate with the merge tool's structure checks in the next step.
- [ ] **Step 4: Merge each language into the manifest**
```bash
python -m tools.i18n.translate_manifest merge --lang es
python -m tools.i18n.translate_manifest merge --lang fr
python -m tools.i18n.translate_manifest merge --lang ja
```
Expected: each prints `merged <lang> into simulator/sample_media/manifest.json`. Any structure error (missing key, wrong tier length) aborts that merge — fix the catalog and re-run (merge is idempotent).
- [ ] **Step 5: Run test to verify it passes**
Run: `python -m pytest tests/test_i18n_manifest.py -q`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add tools/i18n/catalogs/es.json tools/i18n/catalogs/fr.json tools/i18n/catalogs/ja.json simulator/sample_media/manifest.json tests/test_i18n_manifest.py
git commit -m "feat(i18n): author es/fr/ja annotation translations + merge into manifest"
```
---
### Task 6: E2E — dropdown switches UI and annotations
**Files:**
- Create: `simulator/e2e/tests/i18n.spec.ts` (match the existing `e2e/tests/*.spec.ts` location/pattern — confirm the exact tests dir, cf. `e2e/tests/altitude-lock.spec.ts`)
- Test: this IS the test.
**Interfaces:**
- Consumes: the running app with the language dropdown + localized manifest.
- [ ] **Step 1: Write the E2E test**
```ts
// simulator/e2e/tests/i18n.spec.ts
import { test, expect } from "@playwright/test";
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
await page.goto("/");
const sel = page.locator("#lang-select");
await expect(sel).toBeVisible();
// four languages, English selected by default
await expect(sel.locator("option")).toHaveCount(4);
await expect(sel).toHaveValue("en");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
// switch to Spanish -> chrome changes, <html lang> updates
await sel.selectOption("es");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
await expect(page.locator("html")).toHaveAttribute("lang", "es");
// Japanese
await sel.selectOption("ja");
await expect(page.locator('[data-i18n="knobs.think"]')).toHaveText("考える");
// reload returns to English (session-only, no persistence)
await page.reload();
await expect(page.locator("#lang-select")).toHaveValue("en");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
});
```
- [ ] **Step 2: Run the E2E test**
Run: the existing Playwright command for this repo (check `simulator/e2e/` for the runner — e.g. `npx playwright test i18n` from `simulator/e2e/`, with the app server running as the other specs expect).
Expected: PASS.
- [ ] **Step 3: Commit**
```bash
git add simulator/e2e/tests/i18n.spec.ts
git commit -m "test(i18n): e2e for language dropdown chrome + reset on reload"
```
---
### Task 7: Full verification + finish the branch
- [ ] **Step 1: Run every suite**
```bash
node --test simulator/unit/
python -m pytest tests/ -q
```
Expected: all green (node unit incl. i18n; pytest incl. translate-tool + manifest-language invariants; existing suites unaffected).
- [ ] **Step 2: Run the E2E suite**
Run the repo's full Playwright command (as other specs run it) and confirm the i18n spec plus the existing specs pass.
- [ ] **Step 3: Open the PR**
```bash
git push -u origin feat/localization-language-dropdown
```
Then open a PR to `main` via the Gitea flow, citing this plan and the design spec.
---
## Self-Review
**Spec coverage:**
- §2 languages (en/es/fr/ja, no RTL) → Task 1 registry + Global Constraints. ✓
- §3.1 UI chrome → Task 1 catalog + Task 2 tagging. ✓
- §3.2 / §4.3 annotations (extend strings dict, fallback) → Task 1 `resolveStrings`, Task 3 render sites, Tasks 45 content. ✓
- §4.1 registry → Task 1. ✓ §4.2 UI catalog + `applyUiStrings` + `data-i18n` → Tasks 13. ✓
- §5 dropdown, English default, session-only, live switch no reload → Task 2 (select) + Task 3 (init/handler). ✓
- §6 one-shot idempotent tool, tier-structure preservation → Task 4. ✓
- §7 tests: node unit (registry/swap/fallback/tier) → Tasks 1, 3; pytest manifest invariants → Task 5; Playwright per-language → Task 6. ✓
- §8 out-of-scope honored (no dev-panel internals, no author.html, no clip titles). ✓
**Placeholder scan:** Task 5 Step 3 is intentionally a content-authoring step (the 2,260 translations can't be inlined); its structure is fully constrained by the tool's validation and Task 5's invariant test. No code step is left as a placeholder.
**Type consistency:** `resolveStrings(clipStrings, lang)`, `pickUiString(key, lang)`, `LANGUAGES[].{code,nativeName}`, `extract_en_catalog`, `merge_catalog(manifest, lang, catalog)`, globals `activeLang`/`lastAffect`/`lastOverlay` — names used identically across Tasks 1, 3, 4, 5. ✓
@@ -0,0 +1,885 @@
# Accessibility Pass + About Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the simulator usable away from the attended kiosk — reduced-motion, seizure safety, keyboard/screen-reader access, AA contrast — and add an English-first `about.html`.
**Architecture:** All client-side in `simulator/static/`. One new pure helper (`flash.js`, UMD + node tests). One new page (`about.html`) mirroring `credits.html`. Behavior changes live in `app.js`; chrome/contrast in `style.css` and `index.html`. The Python static build (`tools/build_static.py`) copies the new static files.
**Tech Stack:** Vanilla JS (no framework, UMD modules like `i18n.js`/`credits.js`), CSS, Playwright e2e (`simulator/e2e/`), `node --test` for pure helpers, FastAPI dev server for e2e fixtures.
## Global Constraints
- No engine/Python behavior changes — only `tools/build_static.py`'s copy list is touched.
- New JS modules follow the existing **UMD** pattern (browser global + `module.exports`), like `i18n.js` and `credits.js`.
- About page is **English-first**; i18n keys may be EN-only and fall back via `pickUiString` (returns `v.en`).
- Target **WCAG 2.1 AA** (4.5:1 text / 3:1 large) plus WCAG 2.3.1 (≤3 flashes/sec) and 2.3.3 (no autonomous animation under reduced-motion).
- Persist user toggles in `localStorage`, mirroring `DEV_KEY = "hef.devMode"` (`app.js:918`).
- Reduced-motion toggle key: `RM_KEY = "hef.reduceMotion"`. Warning-gate dismissal key: `WARN_KEY = "hef.motionWarnDismissed"`.
- `<html lang>` switching already exists (`applyUiStrings`, `app.js:1325`) — do NOT re-implement.
- E2E in this env: start uvicorn by hand with the venv `python` (only `python3` exists on PATH) and run Playwright with `reuseExistingServer`. `loop-recovery.spec.ts` is known-red on a clean baseline (boots without video).
- Direction convention: descend = `+1` (cosmos→abyss), matching wheel-down (`app.js:703`).
---
### Task 1: Output-panel layout — language picker below Audio, globe inline-left
**Files:**
- Modify: `simulator/static/index.html:37-51` (Output fieldset)
- Modify: `simulator/static/style.css` (add `.lang-pick` rule)
- Test: `simulator/e2e/tests/a11y.spec.ts` (new)
**Interfaces:**
- Produces: the `#lang-select` element ends up after `#audio` in DOM order; `.lang-pick` is a flex row.
- [ ] **Step 1: Write the failing e2e test**
Create `simulator/e2e/tests/a11y.spec.ts`:
```ts
import { test, expect } from "@playwright/test";
test("language picker sits below the Audio control", async ({ page }) => {
await page.goto("/");
const audio = page.locator("#audio");
const lang = page.locator("#lang-select");
await expect(audio).toBeVisible();
await expect(lang).toBeVisible();
const aBox = await audio.boundingBox();
const lBox = await lang.boundingBox();
expect(lBox!.y).toBeGreaterThan(aBox!.y); // lang is rendered lower than audio
});
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
await page.goto("/");
const pick = page.locator(".lang-pick");
const select = page.locator("#lang-select");
const pBox = await pick.boundingBox();
const sBox = await select.boundingBox();
// select starts to the right of the label's left edge, and shares its row (height ~ one line)
expect(sBox!.x).toBeGreaterThan(pBox!.x);
expect(pBox!.height).toBeLessThan(40);
});
```
- [ ] **Step 2: Run it to verify the first test fails**
Start the dev server (separate shell, from `simulator/`):
`../.venv/bin/python -m uvicorn server.app:app --port 8000` (adjust to the project's venv/module path).
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "below the Audio"`
Expected: FAIL (lang currently renders above audio).
- [ ] **Step 3: Move the language picker in `index.html`**
In the Output `<fieldset>`, delete the `<label class="lang-pick">…</label>` block from its current spot (above the Video toggle) and re-insert it as the LAST child of the fieldset, after the Audio `<label class="audio-level">`. Result order: legend → Video toggle → Audio level → language picker.
- [ ] **Step 4: Add the `.lang-pick` flex rule in `style.css`**
Add near the other panel rules:
```css
/* Globe + language select on one row (the select no longer goes full-width here). */
.lang-pick { display: flex; align-items: center; gap: 0.4rem; margin: 0.4rem 0; }
.lang-pick select { flex: 1; width: auto; }
```
- [ ] **Step 5: Run both layout tests to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "language picker|globe"`
Expected: PASS (2 tests).
- [ ] **Step 6: Commit**
```bash
git add simulator/static/index.html simulator/static/style.css simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): move language picker below Audio, globe inline-left"
```
---
### Task 2: Low-vision contrast + global focus rings + visually-hidden utility
**Files:**
- Modify: `simulator/static/style.css` (color bumps, `:focus-visible`, `.visually-hidden`)
**Interfaces:**
- Produces: a `.visually-hidden` utility class used by Task 4 (gate) and Task 7 (aria-live).
- [ ] **Step 1: Bump failing text colors to AA**
In `style.css`, change these declarations (values chosen to clear 4.5:1 on the dark backgrounds; verify with a contrast check in Step 3):
```css
/* was #789 — too low on #111 */
.hint { color: #9fb3c8; }
/* dial labels/captions were #789ac0 / #4d6184 on #0d1320 */
.dial-label { fill: #b8cfe6; }
.dial-caption { fill: #8fa6c4; }
```
(Keep every other property on those selectors unchanged — edit only the color/fill.)
- [ ] **Step 2: Add focus-visible + visually-hidden utilities**
```css
/* Visible keyboard focus for every interactive element (was only on .dev-switch). */
:focus-visible { outline: 2px solid #9af; outline-offset: 2px; }
#dial:focus-visible { outline-offset: 4px; }
/* Screen-reader-only text (announcements, labels) — present in a11y tree, off-screen. */
.visually-hidden {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
```
- [ ] **Step 3: Verify contrast**
Manually confirm with any WCAG contrast tool that `#9fb3c8` on `#111`, `#b8cfe6` on `#0d1320`, and `#8fa6c4` on `#0d1320` each meet ≥4.5:1 (≥3:1 acceptable for the dial caption if it reads as large). Adjust lighter if any fails.
- [ ] **Step 4: Commit**
```bash
git add simulator/static/style.css
git commit -m "feat(a11y): AA contrast bumps, global focus-visible, visually-hidden util"
```
---
### Task 3: `flash.js` — pure flash-clamp helper + node tests
**Files:**
- Create: `simulator/static/flash.js`
- Test: `simulator/static/flash.test.js` (new, `node --test`)
**Interfaces:**
- Produces: `HEFFlash.minSafeDurationMs(count)` → minimum total ms for `count` luminance transitions to stay ≤3/sec; `HEFFlash.clampDurationMs(requestedMs, count)``max(requestedMs, minSafeDurationMs(count))`. Consumed by Task 8 (audit).
- [ ] **Step 1: Write the failing test**
Create `simulator/static/flash.test.js`:
```js
const test = require("node:test");
const assert = require("node:assert");
const F = require("./flash.js");
test("minSafeDurationMs: N transitions need N/3 seconds", () => {
assert.strictEqual(F.minSafeDurationMs(3), 1000); // 3 flashes in >=1s
assert.strictEqual(F.minSafeDurationMs(6), 2000);
assert.strictEqual(F.minSafeDurationMs(0), 0);
assert.strictEqual(F.minSafeDurationMs(1), 1000 / 3);
});
test("clampDurationMs: stretches only when too fast", () => {
assert.strictEqual(F.clampDurationMs(2000, 3), 2000); // already safe
assert.strictEqual(F.clampDurationMs(200, 3), 1000); // too fast → clamped up
assert.strictEqual(F.clampDurationMs(500, 1), 500); // single transition, slow enough
});
```
- [ ] **Step 2: Run it to verify it fails**
Run: `cd simulator/static && node --test flash.test.js`
Expected: FAIL ("Cannot find module './flash.js'").
- [ ] **Step 3: Implement `flash.js`**
```js
// Pure photosensitivity helper. WCAG 2.3.1: no more than 3 general flashes
// (luminance transitions) per second. Given a transition COUNT, returns the
// minimum total duration that keeps the rate at or below 3/sec, and a clamp
// that only ever slows a transition down. UMD: browser `HEFFlash` + require().
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFFlash = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const MAX_PER_SEC = 3;
function minSafeDurationMs(count) {
const n = Math.max(0, Number(count) || 0);
return (n / MAX_PER_SEC) * 1000;
}
function clampDurationMs(requestedMs, count) {
return Math.max(Number(requestedMs) || 0, minSafeDurationMs(count));
}
return { MAX_PER_SEC, minSafeDurationMs, clampDurationMs };
});
```
- [ ] **Step 4: Run tests to verify pass**
Run: `cd simulator/static && node --test flash.test.js`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add simulator/static/flash.js simulator/static/flash.test.js
git commit -m "feat(a11y): flash.js pure WCAG 2.3.1 flash-clamp helper + tests"
```
---
### Task 4: Photosensitivity warning gate
**Files:**
- Modify: `simulator/static/index.html` (gate markup inside `.screen`)
- Modify: `simulator/static/style.css` (gate styles)
- Modify: `simulator/static/app.js` (gate logic around `run-sim` reveal, `app.js:1297`)
- Modify: `simulator/static/i18n.js` (gate strings)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: nothing.
- Produces: `WARN_KEY` localStorage flag; `maybeShowMotionWarning()` called before `run-sim` is revealed.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("motion warning gate shows once, then is remembered", async ({ page }) => {
await page.goto("/");
const gate = page.locator("#motion-warning");
await expect(gate).toBeVisible();
await page.locator("#motion-warning-continue").click();
await expect(gate).toBeHidden();
// Reload: dismissal persisted, gate stays hidden, run-sim is the entry point.
await page.reload();
await expect(page.locator("#motion-warning")).toBeHidden();
await expect(page.locator("#run-sim")).toBeVisible();
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
Expected: FAIL (no `#motion-warning`).
- [ ] **Step 3: Add gate markup in `index.html`**
Inside `<div class="screen">`, after the `#run-sim` button:
```html
<div id="motion-warning" class="motion-warning hidden" role="dialog" aria-modal="true" aria-labelledby="mw-title">
<div class="mw-card">
<h2 id="mw-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin.</p>
<button type="button" id="motion-warning-continue" class="run-sim" data-i18n="warn.continue">Continue</button>
</div>
</div>
```
- [ ] **Step 4: Add gate styles in `style.css`**
```css
.motion-warning { position: absolute; inset: 0; z-index: 70; display: flex;
align-items: center; justify-content: center; padding: 1rem;
background: rgba(2, 4, 10, 0.92); }
.motion-warning.hidden { display: none; }
.mw-card { max-width: 30rem; text-align: center; color: #dfeaff; }
.mw-card h2 { font-size: 1.1rem; margin: 0 0 0.6rem; }
.mw-card p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 1.2rem; color: #b9c8e0; }
.mw-card .run-sim { position: static; transform: none; }
.mw-card .run-sim:hover { transform: scale(1.04); }
```
- [ ] **Step 5: Add i18n strings in `i18n.js`**
Add to `UI_STRINGS` (EN required; other langs may be added later, fallback is EN):
```js
"warn.title": { en: "Heads up — motion & flashing" },
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin." },
"warn.continue": { en: "Continue" },
```
- [ ] **Step 6: Wire gate logic in `app.js`**
Add near `DEV_KEY` (`app.js:918`):
```js
const WARN_KEY = "hef.motionWarnDismissed";
function motionWarnDismissed() {
try { return localStorage.getItem(WARN_KEY) === "1"; } catch (_) { return false; }
}
function maybeShowMotionWarning() {
const gate = $("motion-warning");
if (!gate) return;
if (motionWarnDismissed()) { gate.classList.add("hidden"); return; }
gate.classList.remove("hidden");
$("motion-warning-continue").addEventListener("click", () => {
try { localStorage.setItem(WARN_KEY, "1"); } catch (_) {}
gate.classList.add("hidden");
$("motion-warning-continue").focus({ preventScroll: true });
$("run-sim").focus({ preventScroll: true });
}, { once: true });
}
```
In `main()`, immediately after `$("run-sim").classList.remove("hidden");` (`app.js:1297`), add:
```js
maybeShowMotionWarning(); // photosensitivity notice on first visit (over the stage)
```
- [ ] **Step 7: Run the gate test to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
Expected: PASS. (If `localStorage` carries across tests, the test clears it via `page.goto` fresh context — Playwright uses a fresh context per test by default.)
- [ ] **Step 8: Commit**
```bash
git add simulator/static/index.html simulator/static/style.css simulator/static/app.js simulator/static/i18n.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): one-time photosensitivity warning gate"
```
---
### Task 5: Reduced-motion freeze-to-stills + toggle
**Files:**
- Modify: `simulator/static/index.html` (toggle in Output fieldset)
- Modify: `simulator/static/i18n.js` (toggle label)
- Modify: `simulator/static/app.js` (`reduceMotion` state, freeze logic, `autoScrub` instant path)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: nothing.
- Produces: `reduceMotion` boolean; `isReduced()` accessor used by `autoScrub` (Task 5) and dial keyboard (Task 6); `applyReduceMotion()` pauses/resumes playback.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("reduce-motion toggle pauses video playback", async ({ page }) => {
await page.goto("/");
// Dismiss the gate and start the experience so video is playing.
await page.locator("#motion-warning-continue").click().catch(() => {});
await page.locator("#run-sim").click();
await page.waitForTimeout(400);
await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
await page.locator("#reduce-motion").check();
await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
Expected: FAIL (no `#reduce-motion`).
- [ ] **Step 3: Add the toggle markup in `index.html`**
In the Output fieldset, after the Audio level label (and before the language picker added in Task 1), add a switch mirroring the existing `.dev-switch`:
```html
<label class="dev-switch" for="reduce-motion">
<input type="checkbox" id="reduce-motion" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
</label>
```
- [ ] **Step 4: Add the i18n label in `i18n.js`**
```js
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
```
- [ ] **Step 5: Add `reduceMotion` state + freeze logic in `app.js`**
Near `DEV_KEY`/`WARN_KEY`:
```js
const RM_KEY = "hef.reduceMotion";
let reduceMotion = false;
function isReduced() { return reduceMotion; }
function initReduceMotion() {
const box = $("reduce-motion");
let stored = null;
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
// Default to the OS setting when the user hasn't chosen yet.
const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
reduceMotion = stored === null ? !!prefers : stored === "1";
if (box) {
box.checked = reduceMotion;
box.addEventListener("change", () => {
reduceMotion = box.checked;
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
applyReduceMotion();
});
}
applyReduceMotion();
}
function applyReduceMotion() {
if (reduceMotion) {
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
vid.pause();
loopVid.pause();
} else if (videoEverOn && $("visual").checked) {
playLoop();
}
}
```
- [ ] **Step 6: Make `autoScrub` instant under reduced motion**
At the top of `autoScrub` (`app.js:678`), after the `if (!ring …) return;` guard, add:
```js
if (isReduced()) { // no autonomous tween — jump straight to the target
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
setPos(targetPos);
return;
}
```
- [ ] **Step 7: Keep playback paused when reduced-motion is on after landing**
In `beginExperience()` (`app.js:1250`) and wherever `playLoop()` is called on a settle, guard the play with `if (!isReduced()) playLoop();` — specifically wrap the settle-time `playLoop()` at `app.js:850` and the `beginExperience` start so turning the experience on while reduced does not animate. (Inspect each `playLoop()` call site; guard the autonomous ones, leave the explicit toggle-off resume in `applyReduceMotion`.)
- [ ] **Step 8: Call `initReduceMotion()` in `main()`**
After `initLanguage();` (`app.js:1266`) add:
```js
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
```
- [ ] **Step 9: Run the reduced-motion test to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
Expected: PASS.
- [ ] **Step 10: Commit**
```bash
git add simulator/static/index.html simulator/static/i18n.js simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): reduced-motion freeze-to-stills with OS-default toggle"
```
---
### Task 6: Keyboard + ARIA for the Altitude dial
**Files:**
- Modify: `simulator/static/index.html:56` (dial svg attrs)
- Modify: `simulator/static/app.js` (keydown handler, aria-value sync, label buttons)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: `autoScrub`, `jumpToScale`, `ring`, `ringIndex`, `dialStep` (all in `app.js`).
- Produces: `setDialAria()` updates `aria-valuenow/valuetext`; called wherever the needle settles.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("altitude dial is keyboard-operable", async ({ page }) => {
await page.goto("/");
await page.locator("#motion-warning-continue").click().catch(() => {});
const dial = page.locator("#dial");
await expect(dial).toHaveAttribute("role", "slider");
await dial.focus();
const before = await page.locator("#scale-name").textContent();
await dial.press("ArrowDown"); // descend one altitude
await expect.poll(async () => page.locator("#scale-name").textContent()).not.toBe(before);
await dial.press("Home"); // jump to cosmos (top)
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
Expected: FAIL (dial has no `role`/key handling).
- [ ] **Step 3: Add ARIA attrs to the dial in `index.html`**
Change line 56 to:
```html
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
aria-label="Altitude — turn to change scale"
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
```
- [ ] **Step 4: Add `setDialAria()` and call it on settle**
In `app.js`, add:
```js
// Reflect the committed altitude into the dial's slider semantics for AT.
function setDialAria() {
if (!dial || !ring) return;
dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
dial.setAttribute("aria-valuenow", String(ringIndex));
const s = ring.scales[ringIndex];
if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
}
```
Call `setDialAria()` at the settle point in `setPos` where `setNeedle(ringIndex * dialStep())` runs on frac 0 (`app.js:852`), and once in `buildDial()` after the dial is drawn.
- [ ] **Step 5: Add the keydown handler**
```js
function onDialKey(e) {
if (!ring || ring.scales.length < 2) return;
let handled = true;
switch (e.key) {
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break;
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break;
case "Home": jumpToScale(0); break;
case "End": jumpToScale(ring.scales.length - 1); break;
default: handled = false;
}
if (handled) e.preventDefault();
}
```
Wire it in `main()` next to the other dial listeners (`app.js:1289`):
```js
dial.addEventListener("keydown", onDialKey);
```
- [ ] **Step 6: Make the dial labels keyboard-activatable**
In `buildDial()` (where each `.dial-label` is created, ~`app.js:749`), add to the label's attributes: `role: "button"`, `tabindex: "0"`, and an `aria-label` of the scale name. Then in `main()` add a delegated keydown on the dial that activates a focused label:
```js
dial.addEventListener("keydown", (e) => {
const t = e.target;
if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
jumpToScale(+t.getAttribute("data-index"));
}
});
```
- [ ] **Step 7: Run the keyboard test to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): keyboard + ARIA slider semantics for the Altitude dial"
```
---
### Task 7: Screen-reader narration (aria-live) + hide decorative SVG
**Files:**
- Modify: `simulator/static/index.html` (aria-live region; `aria-hidden` on decorative layers)
- Modify: `simulator/static/app.js` (announce on settle, throttled)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: `activeClipId`, the clip's resolved strings, `ring`, `ringIndex`.
- Produces: `announce(text)`; `decorative SVGs are aria-hidden`.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("an aria-live region narrates the current scale", async ({ page }) => {
await page.goto("/");
await page.locator("#motion-warning-continue").click().catch(() => {});
const live = page.locator("#sr-status");
await expect(live).toHaveAttribute("aria-live", "polite");
await page.locator("#dial").focus();
await page.locator("#dial").press("Home");
await expect.poll(async () => (await live.textContent())?.toLowerCase()).toContain("cosmos");
});
test("decorative overlay SVGs are hidden from AT", async ({ page }) => {
await page.goto("/");
await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
Expected: FAIL.
- [ ] **Step 3: Add the live region + aria-hidden in `index.html`**
Add `aria-hidden="true"` to `#overlay`, `#affect`, `#tint`, `#paint`. Add inside `.panel` (top, after the opening tag) a live region:
```html
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
```
- [ ] **Step 4: Add `announce()` + call on settle in `app.js`**
```js
let lastAnnounced = "";
function announce(text) {
const el = $("sr-status");
if (!el || !text || text === lastAnnounced) return;
lastAnnounced = text;
el.textContent = text;
}
// Build the spoken summary: scale + top factual (left) label for the active clip.
function announceState() {
if (!ring) return;
const s = ring.scales[ringIndex];
const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
const clip = activeClip && activeClip(); // use existing accessor for the locked clip
let label = "";
if (clip) {
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
label = firstFactualLabel(strings) || "";
}
announce(label ? `${scaleName}. ${label}` : scaleName);
}
```
If no `activeClip()` accessor exists, read the locked clip via the existing `activeClipId` lookup used in `renderScaleReadout` (mirror that code). `firstFactualLabel` returns the first non-empty left/`LABELS`-style string from `strings`; if the shape makes this awkward, announce just `scaleName` (degrade gracefully — the scale name is the load-bearing part).
Call `announceState()` at the same settle point as `setDialAria()` (frac 0 in `setPos`) and at the end of `setLanguage()` so a language switch re-announces.
- [ ] **Step 5: Run the narration tests to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(a11y): aria-live narration of scale/label; hide decorative SVGs"
```
---
### Task 8: Apply the flash audit to real transitions
**Files:**
- Modify: `simulator/static/app.js` and/or `simulator/static/style.css` (clamp the audited transitions)
- Modify: `simulator/static/index.html` (load `flash.js` before `app.js`)
**Interfaces:**
- Consumes: `HEFFlash.clampDurationMs` (Task 3).
- [ ] **Step 1: Load `flash.js` in `index.html`**
Add before `app.js` (after `i18n.js`, `index.html:117`):
```html
<script src="flash.js"></script>
```
- [ ] **Step 2: Audit + enumerate the rapid-luminance transitions**
Inspect the three sources and record findings as a comment in `app.js` above the change:
1. Fast-spin blended dial pass — `autoScrub` total `ms = perAltMs * |dist|`; the per-altitude floor is 1200ms (well under 3/sec for one step), so a multi-step spin is already ≥3 transitions over ≥N×1.2s = safe. Confirm and note.
2. `#black` cover fade — `style.css` `transition: opacity 200ms`; a single fade is one transition, not a repeated flash — safe.
3. Audio-coupled crossfade — visual? If it drives an opacity swap, check its duration.
For any source that CAN repeat faster than 3/sec (e.g. a rapid wheel/keyboard repeat firing `autoScrub` back-to-back), clamp using `HEFFlash`:
```js
// Guard against a fast key/wheel repeat producing >3 luminance swings/sec.
const safeMs = HEFFlash.clampDurationMs(PER_ALTITUDE_MS, 1);
```
If the audit finds NO breach (the likely outcome given the 1200ms floor), record that conclusion in the comment and make no timing change beyond loading `flash.js` for the helper's availability — do not invent a clamp that isn't needed (YAGNI). The deliverable of this task is the documented audit + `flash.js` wired in.
- [ ] **Step 3: Run existing e2e to confirm no regression**
Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts`
Expected: PASS (12 tests, per project memory).
- [ ] **Step 4: Commit**
```bash
git add simulator/static/index.html simulator/static/app.js
git commit -m "feat(a11y): wire flash-clamp helper; document 3-flash/sec audit"
```
---
### Task 9: about.html + header link + static-build copy
**Files:**
- Create: `simulator/static/about.html`
- Modify: `simulator/static/index.html:18` (header link)
- Modify: `simulator/static/i18n.js` (about link label)
- Modify: `tools/build_static.py:38` (add `about.html`, `flash.js` to `PUBLIC_ASSETS`)
- Test: `simulator/e2e/tests/a11y.spec.ts`
**Interfaces:**
- Consumes: existing `.credits-page` / `.credits-wrap` / `.back-link` styles.
- [ ] **Step 1: Write the failing e2e test**
Append to `a11y.spec.ts`:
```ts
test("about page loads and links back to the experience", async ({ page }) => {
await page.goto("/about.html");
await expect(page.locator("h1")).toContainText(/about/i);
await expect(page.getByText(/imperfect/i)).toBeVisible();
await page.locator(".back-link").click();
await expect(page).toHaveURL(/index\.html|\/$/);
});
test("header links to the about page", async ({ page }) => {
await page.goto("/");
await expect(page.locator('a[href="about.html"]')).toBeVisible();
});
```
- [ ] **Step 2: Run to verify it fails**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
Expected: FAIL (no about.html / link).
- [ ] **Step 3: Create `about.html`**
Mirror `credits.html` structure exactly (body `class="credits-page"`, `main.credits-wrap`, back-link, `config.js`):
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About — Human Experience Filter</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
<main class="credits-wrap">
<header>
<h1>About this work</h1>
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
</header>
<section>
<h2>For everyone</h2>
<p>The <em>Human Experience Filter</em> is built to be accessible to — and
representative of — the full range of human experience. That is why it works
with a keyboard, with a screen reader, with reduced motion, and away from any
single screen or kiosk: an experience about being human should be open to as
many humans as possible.</p>
</section>
<section>
<h2>Places technology let us see</h2>
<p>The scales you move through — the deep sea, the coast, the sky, the orbit
of the Earth, the wider cosmos — are vantage points no unaided human could
ever witness. We can share them only because our tools evolved to reach them:
from <strong>underwater exploration</strong>, to <strong>drones and aerial
imaging</strong>, to <strong>space exploration</strong>. Each scale is a place
a machine went first so that a person could feel what it is like to be there.</p>
</section>
<section>
<h2>Built with LLMs</h2>
<p>This piece was itself built using large language models — an extension of
that same long arc of tools that widen what a single human can reach. The
software, the words, and much of the craft were shaped in collaboration with
a machine.</p>
</section>
<section>
<h2>Honest about its limits</h2>
<p>This implementation is imperfect. It is also more than I could have made
alone: better with these tools assisting than by my hand as a single human.
That tension is part of the point — technology does not replace the person
behind the work; it extends their reach, flaws and all.</p>
</section>
</main>
<script src="config.js"></script>
</body>
</html>
```
- [ ] **Step 4: Add the header link in `index.html`**
After the existing credits link (`index.html:18`), add:
```html
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
```
- [ ] **Step 5: Add the i18n label in `i18n.js`**
```js
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
```
- [ ] **Step 6: Add the new files to the static build**
In `tools/build_static.py:38`, extend `PUBLIC_ASSETS`:
```python
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
"credits.html", "credits.js", "about.html", "flash.js"]
```
- [ ] **Step 7: Run the about-page tests to verify pass**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
Expected: PASS.
- [ ] **Step 8: Verify the static build includes the new files**
Run: `cd simulator/e2e && npx playwright test static-build.spec.ts` (or run `python3 tools/build_static.py` and confirm `dist/.../about.html` + `flash.js` exist).
Expected: PASS / files present.
- [ ] **Step 9: Commit**
```bash
git add simulator/static/about.html simulator/static/index.html simulator/static/i18n.js tools/build_static.py simulator/e2e/tests/a11y.spec.ts
git commit -m "feat(content): about.html — intent, provenance, honest limits; static-build copy"
```
---
### Task 10: Full-suite green + finish
**Files:** none (verification).
- [ ] **Step 1: Run the full e2e a11y spec**
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts`
Expected: all PASS.
- [ ] **Step 2: Run the node helper tests**
Run: `cd simulator/static && node --test flash.test.js`
Expected: PASS.
- [ ] **Step 3: Run the existing suites that should stay green**
Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts i18n.spec.ts static-build.spec.ts`
Expected: PASS. (`loop-recovery.spec.ts` is known-red on a clean baseline here — do not block on it, but confirm it is no MORE broken.)
Run: `python3 -m pytest` from repo root for the Python suite.
Expected: PASS.
- [ ] **Step 4: Final review + branch finish**
Invoke `superpowers:finishing-a-development-branch` to choose merge/PR. Do NOT auto-merge to main without the operator's go (per session posture).
## Self-Review
**Spec coverage:** A1 reduced-motion → Task 5; A2 gate → Task 4, audit → Tasks 3+8; B keyboard/focus → Tasks 2+6; C contrast → Task 2; D SR narration → Task 7; E about.html → Task 9; F layout → Task 1. `<html lang>` correctly omitted (already done). All covered.
**Placeholder scan:** Code shown for every code step. Task 7's `firstFactualLabel`/`activeClip` note explicitly degrades to scale-name-only if the clip-strings shape is awkward — that is a defined fallback, not a placeholder. Task 8 explicitly allows a "no breach found → document and stop" outcome (YAGNI), which is a real deliverable.
**Type consistency:** `isReduced()`, `autoRaf`, `setPos`, `autoScrub`, `jumpToScale`, `setDialAria`, `announce`, `HEFFlash.clampDurationMs`, `WARN_KEY`/`RM_KEY` used consistently across tasks. `reduce-motion` element id matches between HTML (Task 5 Step 3) and JS (Task 5 Step 5) and test.
@@ -0,0 +1,706 @@
# Cloudflare Static Publish — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Publish the simulator as a fully-static site (no origin server) to `benstull.art/human-experience-simulator` on Cloudflare Pages, with media on R2.
**Architecture:** Port the only live server logic (`/api/alteration`, the delta=0 random pick) to client JS; serve the read-APIs as baked JSON; point media at a configurable R2 base with CORS; keep the existing graduated prefetch. A Python build script emits `dist/` (Pages) + a media sync tree (R2). The dev FastAPI server stays for localhost/authoring.
**Tech Stack:** Vanilla JS (UMD modules, `node --test`), Python 3 build script (imports existing `simulator`/`player` builders), Playwright E2E, `wrangler` (Pages + R2), Cloudflare redirect rule.
## Global Constraints
- **Path:** app served at `/human-experience-simulator`; apex `benstull.art` **redirects** there.
- **Media base:** `https://static.benstull.art/` (R2 custom domain), public bucket.
- **CORS is load-bearing:** R2 must send `Access-Control-Allow-Origin: https://benstull.art`; `<video>`/`<audio>` carry `crossOrigin="anonymous"` — else the WebGL dream shader taints.
- **Served media = manifest-referenced only:** `base.mp4` clip bases + 154 transition morphs + 10 `.mp3`. NEVER sync `master.mp4`/`mezzanine.mp4`.
- **Versioning preserved:** keep the `?v=<hash>` query; set those objects `Cache-Control: public, max-age=31536000, immutable` on the CDN.
- **No new runtime:** no Pages Functions / Worker; no Service Worker.
- **Project is exempt from flotilla/PPE/§9** (operator-set) — deploy directly to Cloudflare. Safety/hygiene rules (git-ssh, branch→PR, secrets) still hold.
- **Module idiom:** UMD — `(function(root,factory){ if (module?.exports) module.exports=factory(); else root.HEFx=factory(); })(...)` — matching `simulator/static/scrub.js`.
- **JS tests run:** `node --test simulator/unit`. **Python tests run:** `.venv/bin/python -m pytest`.
---
### Task 1: Client-side alteration engine (`HEFAlteration`)
Port `player/alteration.py::plan_alteration` (+ `render_plan_to_dict`) and `player/audio.py::resolve_visual`/`resolve_audio` to a pure UMD JS module, producing byte-for-byte the same shape `/api/alteration` returns. Calibration is always unity (`DEFAULT_CALIBRATION`; the client never sends one — `app.js:619`).
**Files:**
- Create: `simulator/static/alteration.js`
- Test: `simulator/unit/alteration.test.js`
**Interfaces:**
- Produces (browser global `HEFAlteration`, also `module.exports`):
- `plan(controls)``{ grade:{tone}, overlay:{level,intensity}, affect:{strength,intensity}, dream:{strength,intensity}, is_identity }`
- `renderAudio(source, scaleAudio, mediaBase)``{ source, url, altitude_coupled }`
- `alteration(controls, scaleAudio, mediaBase)``{ plan, render:{ video:{shown}, audio:{source,url,altitude_coupled} } }` (the `/api/alteration` response)
- `controls` shape (from `app.js:595` `controls()`): `{ visual:"on"|"off", audio:"soundtrack"|"off", left:int, right:int, dark:int, light:int }`
- [ ] **Step 1: Write the failing test**
```js
// simulator/unit/alteration.test.js
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const A = require("../static/alteration.js");
const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o });
test("identity at zero knobs", () => {
const p = A.plan(c({}));
assert.equal(p.grade.tone, 0);
assert.equal(p.overlay.level, 0);
assert.equal(p.dream.strength, 0);
assert.equal(p.is_identity, true);
});
test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => {
assert.equal(A.plan(c({ light: 4 })).grade.tone, 1); // full light
assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1); // full dark
assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5);
});
test("left drives overlay level + intensity; right drives dream + affect", () => {
const p = A.plan(c({ left: 2, right: 4 }));
assert.equal(p.overlay.level, 2);
assert.equal(p.overlay.intensity, 0.5);
assert.equal(p.dream.strength, 4);
assert.equal(p.dream.intensity, 1);
assert.equal(p.affect.strength, 4);
assert.equal(p.affect.intensity, 1);
assert.equal(p.is_identity, false);
});
test("renderAudio: off → null, no coupling", () => {
assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"),
{ source: "off", url: null, altitude_coupled: false });
});
test("renderAudio: soundtrack → mediaBase audio url, coupled", () => {
assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"),
{ source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true });
// no scale audio → null url but still coupled
assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"),
{ source: "soundtrack", url: null, altitude_coupled: true });
});
test("alteration() composes plan + render with video.shown from visual", () => {
const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/");
assert.equal(r.render.video.shown, false);
assert.equal(r.render.audio.url, "https://x/audio/reef.mp3");
assert.equal(r.plan.overlay.level, 1);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node --test simulator/unit/alteration.test.js`
Expected: FAIL — `Cannot find module '../static/alteration.js'`.
- [ ] **Step 3: Write the implementation**
```js
// simulator/static/alteration.js
// Client-side port of player/alteration.py + player/audio.py (the alteration
// engine). Replaces the live POST /api/alteration in the static build — pure math,
// unity calibration. UMD so the browser gets `HEFAlteration` and node --test can
// require() it. Keep IN SYNC with the Python (guarded by parity tests there).
(function (root, factory) {
const api = factory();
if (typeof module !== "undefined" && module.exports) module.exports = api;
else root.HEFAlteration = api;
})(typeof self !== "undefined" ? self : this, function () {
"use strict";
const KNOB_MAX = 4; // player/alteration.py:25
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
function plan(c) {
const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1);
const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1);
const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1);
const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1); // affect uses overlay_gain too
const is_identity = tone === 0 && c.left === 0 && c.right === 0;
return {
grade: { tone },
overlay: { level: c.left, intensity: overlayIntensity },
affect: { strength: c.right, intensity: affectIntensity },
dream: { strength: c.right, intensity: dreamIntensity },
is_identity,
};
}
function renderAudio(source, scaleAudio, mediaBase) {
if (source === "off") return { source: "off", url: null, altitude_coupled: false };
// soundtrack — couple to the current altitude's asset
const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null;
return { source: "soundtrack", url, altitude_coupled: true };
}
function alteration(controls, scaleAudio, mediaBase) {
return {
plan: plan(controls),
render: {
video: { shown: controls.visual === "on" },
audio: renderAudio(controls.audio, scaleAudio, mediaBase),
},
};
}
return { plan, renderAudio, alteration, KNOB_MAX };
});
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `node --test simulator/unit/alteration.test.js`
Expected: PASS (6 tests).
- [ ] **Step 5: Add a Python parity test (guards JS↔Python drift)**
Create `tests/test_alteration_js_parity.py`:
```python
"""The static build reimplements plan_alteration/resolve_audio in JS
(simulator/static/alteration.js). This pins the numeric contract so the Python
and JS cannot silently diverge — if you change the engine, change both."""
from hef.selection import Coordinate
from player.alteration import plan_alteration, render_plan_to_dict
from player.audio import resolve_audio, resolve_visual
def test_engine_contract_matches_js_expectations():
# mirrors the cases asserted in simulator/unit/alteration.test.js
p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0)))
assert p["overlay"] == {"level": 2, "intensity": 0.5}
assert p["dream"] == {"strength": 4, "intensity": 1.0}
assert p["affect"] == {"strength": 4, "intensity": 1.0}
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0
assert resolve_visual("off") is False
a = resolve_audio("soundtrack", scale_audio="cosmos.mp3")
assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True
```
- [ ] **Step 6: Run + commit**
Run: `node --test simulator/unit/alteration.test.js && .venv/bin/python -m pytest tests/test_alteration_js_parity.py -q`
Expected: all PASS.
```bash
git add simulator/static/alteration.js simulator/unit/alteration.test.js tests/test_alteration_js_parity.py
git commit -m "feat(static): client-side alteration engine (port of player.alteration/audio)"
```
---
### Task 2: Static config + boot rewiring in `app.js`
Introduce a runtime config the build injects, then make `app.js` (a) read media from a configurable base, (b) boot from baked JSON in static mode, (c) pick pool members client-side, (d) compute alteration locally, (e) tag media elements with `crossOrigin`, (f) skip the dev-version poll in static mode. All changes degrade to today's behavior when config is absent (dev server unaffected).
**Files:**
- Create: `simulator/static/config.js` (dev default; the build overwrites it in `dist/`)
- Modify: `simulator/static/app.js` (boot `loadData` ~59, `pickRandomMember` ~87, `mediaNetUrl` ~123, alteration fetch ~617, dev poll ~1041, video/audio element creation)
- Modify: `simulator/static/index.html:111` (load `config.js` before `app.js`)
**Interfaces:**
- Consumes: `HEFAlteration` (Task 1).
- Produces: global `HEF_CONFIG = { mediaBase: string, static: boolean }`. Dev default `{ mediaBase: "/media/", static: false }`.
- [ ] **Step 1: Create the dev-default config and load it**
`simulator/static/config.js`:
```js
// Runtime config. This dev default serves media from the local FastAPI /media
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
```
In `simulator/static/index.html`, add before `app.js` (line 111 area):
```html
<script src="/config.js"></script>
```
(Place it before `<script src="/app.js"></script>`.)
- [ ] **Step 2: Media base — modify `mediaNetUrl` (`app.js:123`)**
Replace:
```js
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
```
with:
```js
function mediaNetUrl(file) {
const base = (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/";
const v = mediaVersions[file];
return base + file + (v ? "?v=" + v : "");
}
```
Note: audio-layer absolute urls already begin with the configured base — keep the existing `file.startsWith("/media/")` guard in `mediaUrl`, and ALSO short-circuit absolute `http` urls:
```js
function mediaUrl(file) {
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
return mediaBlobs[file] || mediaNetUrl(file);
}
```
And in `HEFAlteration.alteration(...)` we pass `mediaBase`, so the audio `url` it returns is already absolute (R2) in static mode and `/media/...` in dev.
- [ ] **Step 3: Boot from baked JSON — modify `loadData` (`app.js:59`)**
Replace the three `fetch("/api/...")` calls with a base that depends on mode:
```js
async function loadData() {
const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
? { clips: "./clips.json", versions: "./media-versions.json", ring: "./ring.json" }
: { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
const clips = (await (await fetch(api.clips)).json()).clips || [];
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
catch (_) { mediaVersions = {}; }
const r = await fetch(api.ring);
serverRing = r.ok;
ring = r.ok ? await r.json() : null;
// ...rest unchanged (fallback ring synth, morphByPair build, ringIndex = 0)
}
```
(`clips.json` mirrors `{clips:[...]}`, `ring.json` mirrors `ring_to_dict(...)`, `media-versions.json` mirrors `{versions:{...}}` — Task 3 bakes them.)
- [ ] **Step 4: Client-side pick — modify `pickRandomMember` (`app.js:87`)**
The delta=0 server pick becomes a uniform client-side pool pick (matches `hef`'s `pick_clip_id`):
```js
async function pickRandomMember() {
const scale = ring && ring.scales[ringIndex];
if (!scale) return null;
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
return pool[Math.floor(Math.random() * pool.length)].clip_id;
}
```
(Drag/scroll navigation already resolves picks + morphs client-side via `scrub.js`; this removes the last `/api/ring/advance` dependency. `serverRing` stays as the "real ring vs synthesized" flag.)
- [ ] **Step 5: Local alteration — modify the fetch at `app.js:617`**
Replace:
```js
const resp = await fetch("/api/alteration", { /* ... */ body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }) });
const data = await resp.json();
```
with a mode-aware path that reuses the engine:
```js
let data;
if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
const scale = ring && ring.scales[ringIndex];
const scaleAudio = (scale && scale.audio) || "";
const base = window.HEF_CONFIG.mediaBase;
data = HEFAlteration.alteration(controls(), scaleAudio, base);
} else {
const resp = await fetch("/api/alteration", {
method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
});
data = await resp.json();
}
```
**Prerequisite:** `ring.json` must expose each scale's `audio` field. Verify `ring_to_dict` includes it; if not, that is a Task 3 bake concern (add it). The rest of the function (`data.render`, `data.plan.*`) is unchanged.
- [ ] **Step 6: crossOrigin on media elements (CORS for WebGL)**
Find where `<video>` and `<audio>` elements are created/used in `app.js` (the double-buffered `#vid`/loop videos and the audio layer). Set `crossOrigin` BEFORE assigning `src`:
```js
videoEl.crossOrigin = "anonymous";
```
Apply to: the visible video, the preload/double-buffer video, and the `<audio>` element. (In dev, same-origin `/media/` ignores it; in static it prevents GL-texture taint and lets ranged `fetch()` succeed under CORS.) Also set `crossorigin="anonymous"` on any static `<video>`/`<audio>` in `index.html`.
- [ ] **Step 7: Skip the dev-version poll in static mode (`app.js:1041`)**
Wrap the `/dev/version` poller so it no-ops when `HEF_CONFIG.static` (there is no dev server):
```js
if (!(window.HEF_CONFIG && window.HEF_CONFIG.static)) {
// existing setInterval(... fetch("/dev/version") ...) poll
}
```
- [ ] **Step 8: Manual dev smoke (no regression)**
Run (this env has only `python3`):
```bash
.venv/bin/python -m uvicorn simulator.app:app --port 8000
```
Open `http://localhost:8000/`, confirm boot, altitude changes, dream/overlay still work (dev mode: `HEF_CONFIG.static=false`, behavior identical to before).
- [ ] **Step 9: Commit**
```bash
git add simulator/static/config.js simulator/static/app.js simulator/static/index.html
git commit -m "feat(static): config-driven media base, baked-JSON boot, client pick/alteration, crossOrigin"
```
---
### Task 3: Static build script → `dist/` + media sync tree
A Python script that produces the deployable `dist/` (frontend + baked JSON + static config) and a separate media sync tree containing ONLY manifest-referenced files. Reuses the live app's loaders so the baked JSON is identical to the API output.
**Files:**
- Create: `tools/build_static.py`
- Create: `tests/test_build_static.py`
**Interfaces:**
- Consumes: `simulator.app.create_app` (to render the same JSON the API serves) OR the underlying loaders (`simulator.clips`/`build_pool_manifest` + `player`/`hef`). Prefer driving `create_app(manifest)` via `fastapi.testclient.TestClient` so the bake is byte-identical to the API.
- Produces: `build_static(out_dir, media_out, *, media_base, app_path) -> dict` (counts), and a CLI.
- [ ] **Step 1: Write the failing test**
```python
# tests/test_build_static.py
import json
from pathlib import Path
from tools.build_static import build_static
def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
out = tmp_path / "dist"
media = tmp_path / "media"
result = build_static(out, media, media_base="https://static.benstull.art/",
app_path="/human-experience-simulator")
# frontend present, dev/author surfaces excluded
assert (out / "index.html").exists()
assert (out / "app.js").exists()
assert (out / "scrub.js").exists()
assert (out / "alteration.js").exists()
assert (out / "config.js").exists()
assert not (out / "author.html").exists()
assert not list(out.glob("review*.html"))
# baked JSON matches the API shape
clips = json.loads((out / "clips.json").read_text())
assert "clips" in clips and clips["clips"]
ring = json.loads((out / "ring.json").read_text())
assert "scales" in ring and "transitions" in ring
assert all("audio" in s for s in ring["scales"]) # needed by client alteration
versions = json.loads((out / "media-versions.json").read_text())
assert "versions" in versions
# config.js points at R2 + static mode
cfg = (out / "config.js").read_text()
assert "https://static.benstull.art/" in cfg and "static: true" in cfg
# media tree holds ONLY referenced files — no masters/mezzanines
synced = {p.name for p in media.rglob("*.mp4")}
assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced)
# every versions key exists in the media tree
for f in versions["versions"]:
assert (media / f).exists(), f"missing synced media: {f}"
assert result["media_files"] == len(list(media.rglob("*")))
```
- [ ] **Step 2: Run to verify it fails**
Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
Expected: FAIL — `ModuleNotFoundError: tools.build_static`.
- [ ] **Step 3: Implement the build script**
```python
# tools/build_static.py
"""Build the fully-static deployable for Cloudflare (Pages + R2).
Emits `out_dir/` (frontend + baked API JSON + static config.js) and a `media_out/`
sync tree containing ONLY the files the manifest references (clip bases + transition
morphs + audio) — never the master/mezzanine pipeline sources. The baked JSON is
produced through the real app (TestClient) so it is byte-identical to the API.
Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in
deploy/cloudflare/ — this script only produces artifacts.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from fastapi.testclient import TestClient
from simulator.app import create_app, MEDIA_DIR
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css"]
def _bake_api(out: Path) -> dict:
app = create_app()
client = TestClient(app)
clips = client.get("/api/clips").json()
ring = client.get("/api/ring").json()
versions = client.get("/api/media-versions").json()
(out / "clips.json").write_text(json.dumps(clips, ensure_ascii=False))
(out / "ring.json").write_text(json.dumps(ring, ensure_ascii=False))
(out / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False))
return versions["versions"]
def _write_config(out: Path, media_base: str) -> None:
(out / "config.js").write_text(
"// GENERATED by tools/build_static.py — do not edit in dist/.\n"
f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n'
)
def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
out = Path(out_dir)
media = Path(media_out)
for d in (out, media):
if d.exists():
shutil.rmtree(d)
d.mkdir(parents=True)
for name in PUBLIC_ASSETS:
src = STATIC / name
if src.exists():
shutil.copy2(src, out / name)
versions = _bake_api(out)
_write_config(out, media_base)
# Sync ONLY referenced media (versions covers bases + morphs); add audio explicitly.
referenced = set(versions.keys())
ring = json.loads((out / "ring.json").read_text())
for s in ring.get("scales", []):
if s.get("audio"):
referenced.add(f"audio/{s['audio']}")
n = 0
for rel in sorted(referenced):
src = MEDIA_DIR / rel
if not src.exists():
raise FileNotFoundError(f"referenced media missing on disk: {rel}")
dst = media / rel
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
n += 1
return {"out": str(out), "media": str(media), "media_files": n, "app_path": app_path}
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--out", default="dist")
ap.add_argument("--media-out", default="dist-media")
ap.add_argument("--media-base", default="https://static.benstull.art/")
ap.add_argument("--app-path", default="/human-experience-simulator")
a = ap.parse_args()
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
print(json.dumps(r, indent=2))
```
**Verify before running the test:** confirm `simulator/app.py` exports `MEDIA_DIR` and that `ring_to_dict` includes `audio` per scale. If `audio` is absent from `ring.json`, add it to `ring_to_dict` (small change in `simulator/app.py` / the ring serializer) and add a Python test asserting `"audio" in scale dict`.
- [ ] **Step 4: Run the test to verify it passes**
Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
Expected: PASS. (Test asserts media tree excludes masters and includes every versioned file + audio.)
- [ ] **Step 5: Real build smoke**
Run: `.venv/bin/python tools/build_static.py`
Expected: prints counts; `dist/` has the frontend + 3 JSON + `config.js`; `dist-media/` ≈ ~2 GB, no `master.mp4`/`mezzanine.mp4`.
- [ ] **Step 6: Commit** (do NOT commit `dist/`/`dist-media/` — add to `.gitignore`)
```bash
printf '\n/dist/\n/dist-media/\n' >> .gitignore
git add tools/build_static.py tests/test_build_static.py .gitignore simulator/app.py
git commit -m "feat(static): build script — dist/ + manifest-only media sync tree, baked API JSON"
```
---
### Task 4: Cloudflare config artifacts + deploy runbook
Produce the version-controlled Cloudflare config (CORS policy, redirect, deploy commands). The actual deploy needs operator Cloudflare auth (`wrangler login` / API token) — `wrangler` is NOT installed here, so this task delivers artifacts + exact commands and the operator runs the authenticated steps.
**Files:**
- Create: `deploy/cloudflare/README.md` (runbook)
- Create: `deploy/cloudflare/r2-cors.json` (bucket CORS policy)
- Create: `deploy/cloudflare/_redirects` (copied into `dist/` for the apex/path redirect, or documented as a dashboard Redirect Rule)
**Interfaces:** none (ops artifacts).
- [ ] **Step 1: CORS policy**
`deploy/cloudflare/r2-cors.json`:
```json
[
{
"AllowedOrigins": ["https://benstull.art"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "Content-Type"],
"ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
"MaxAgeSeconds": 86400
}
]
```
- [ ] **Step 2: Redirect artifact**
`deploy/cloudflare/_redirects` (Pages `_redirects` form — apex/root to the app path):
```
/ /human-experience-simulator 308
```
(If the app is deployed as a Pages project bound to `benstull.art`, also document a dashboard **Redirect Rule**: `benstull.art/``/human-experience-simulator` as the authoritative alternative.)
- [ ] **Step 3: Runbook**
`deploy/cloudflare/README.md` — exact operator commands (run after `tools/build_static.py`):
```bash
npm i -g wrangler
wrangler login
wrangler r2 bucket create hef-media
wrangler r2 bucket cors put hef-media --rules deploy/cloudflare/r2-cors.json
# bind custom domain static.benstull.art to the bucket (dashboard: R2 > bucket > Settings > Custom Domains)
# upload media (preserves keys):
wrangler r2 bucket sync ./dist-media r2://hef-media
cp deploy/cloudflare/_redirects dist/_redirects
wrangler pages project create human-experience-simulator
wrangler pages deploy dist --project-name human-experience-simulator
# bind benstull.art to the Pages project; serve at /human-experience-simulator
# add the apex Redirect Rule if not using _redirects
```
Document: set the media objects' `Cache-Control: public, max-age=31536000, immutable` (sync flag or bucket lifecycle), and verify `wrangler r2 bucket sync` excludes nothing unexpected (the tree is already filtered by the build).
- [ ] **Step 4: Commit**
```bash
git add deploy/cloudflare/
git commit -m "chore(static): Cloudflare deploy artifacts — R2 CORS, apex redirect, runbook"
```
---
### Task 5: E2E against the static build
Verify the built `dist/` boots with **no `/api/*` server**, the dream shader survives cross-origin media (CORS, no taint), and scrub/morph/audio work. Serve `dist/` and the media from **two different origins** to exercise CORS the way R2 will.
**Files:**
- Create: `simulator/e2e/tests/static-build.spec.ts`
- Create: `simulator/e2e/serve-static.mjs` (tiny dual static server: app on one port, media w/ CORS on another)
**Interfaces:** consumes the `dist/` + `dist-media/` produced by Task 3.
- [ ] **Step 1: Dual static server with CORS**
`simulator/e2e/serve-static.mjs`:
```js
// Serves dist/ (app) and dist-media/ (media w/ CORS + Range) on two ports — a
// local stand-in for Pages + R2, so the static-build E2E exercises cross-origin
// media exactly as production will.
import http from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname } from "node:path";
const TYPES = { ".html":"text/html", ".js":"text/javascript", ".json":"application/json",
".css":"text/css", ".mp4":"video/mp4", ".mp3":"audio/mpeg" };
function serve(rootEnv, port, cors) {
const root = process.env[rootEnv];
http.createServer((req, res) => {
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
let p = decodeURIComponent(req.url.split("?")[0]);
if (p === "/") p = "/index.html";
const file = join(root, p);
try {
const st = statSync(file);
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
const range = req.headers.range; // honor Range for video scrub
if (range && /^bytes=/.test(range)) {
const [s, e] = range.replace("bytes=", "").split("-");
const start = +s, end = e ? +e : st.size - 1;
res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${st.size}`,
"Content-Length": end - start + 1 });
createReadStream(file, { start, end }).pipe(res);
} else { res.writeHead(200, { "Content-Length": st.size }); createReadStream(file).pipe(res); }
} catch { res.writeHead(404); res.end("nf"); }
}).listen(port);
}
serve("DIST_DIR", 8077, false);
serve("MEDIA_DIR_E2E", 8078, true);
console.log("static app :8077 media(cors) :8078");
```
- [ ] **Step 2: The spec**
`simulator/e2e/tests/static-build.spec.ts`:
```ts
import { test, expect } from "@playwright/test";
// Built dist/ is served on :8077; media (CORS) on :8078. The build's config.js
// must be overridden to point media at :8078 for this run (see Step 3).
test.use({ baseURL: "http://localhost:8077" });
test("boots fully static — no /api calls, ring + clips load from JSON", async ({ page }) => {
const apiCalls: string[] = [];
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
await page.goto("/");
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
expect(apiCalls, "no /api/* in static mode").toEqual([]);
});
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto("/");
// crank Right to engage the dream, let a few frames render
await page.evaluate(() => { const r = document.getElementById("right") as HTMLInputElement; r.value = "4"; r.dispatchEvent(new Event("input", { bubbles: true })); });
await page.waitForTimeout(2000);
// a tainted texture throws SecurityError on the GL draw; assert none surfaced
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
});
```
- [ ] **Step 3: Wire the run (build → point media → serve → test)**
Document in `simulator/e2e/README.md` and use a throwaway config for the run:
```bash
.venv/bin/python tools/build_static.py --media-base http://localhost:8078/
DIST_DIR=$PWD/dist MEDIA_DIR_E2E=$PWD/dist-media node simulator/e2e/serve-static.mjs &
cd simulator/e2e && npx playwright test static-build.spec.ts
```
(Building with `--media-base http://localhost:8078/` writes that base into `dist/config.js`, so the app fetches media cross-origin from the CORS server — the production CORS path, locally.)
- [ ] **Step 4: Run the E2E**
Expected: both tests PASS — zero `/api/*` requests; no `SecurityError` from the dream shader.
- [ ] **Step 5: Commit**
```bash
git add simulator/e2e/serve-static.mjs simulator/e2e/tests/static-build.spec.ts simulator/e2e/README.md
git commit -m "test(static): E2E — static boot with no API + dream shader survives cross-origin CORS"
```
---
## Self-Review
**Spec coverage:**
- No-origin static frontend → Tasks 2, 3. ✅
- Media on R2 + CORS + crossOrigin → Tasks 2 (crossOrigin), 4 (CORS policy). ✅
- Bake read-APIs to JSON → Task 3. ✅
- Random pick client-side → Task 2 Step 4. ✅
- `/api/alteration` client-side → Tasks 1, 2 Step 5. ✅
- Served = manifest-only (no masters) → Task 3 (test asserts). ✅
- `?v=` versioning + immutable caching → Task 2 (preserved), Task 4 (cache header). ✅
- Path `/human-experience-simulator` + apex redirect → Task 4. ✅
- E2E against static build (no API, dream survives CORS) → Task 5. ✅
- Prefetch unchanged → no task needed (only `mediaNetUrl` base changes, Task 2 Step 2). ✅
**Placeholder scan:** none — every code step has full content.
**Type consistency:** `HEF_CONFIG.{mediaBase,static}`, `HEFAlteration.{plan,renderAudio,alteration}`, `controls()` shape, and `build_static(out, media_out, *, media_base, app_path)` are used identically across tasks. ✅
**Open verify-at-execution items (flagged, not placeholders):** (a) confirm `ring_to_dict` emits per-scale `audio` — add it if missing (Task 3 Step 3); (b) confirm `simulator/app.py` exports `MEDIA_DIR` (used by the build); (c) locate the exact video/audio element creation sites for `crossOrigin` (Task 2 Step 6).
@@ -0,0 +1,125 @@
# Localization & Language Dropdown — Design
**Date:** 2026-06-29
**Status:** Approved design, pre-plan
**Scope:** The `simulator/` web experience — localize all UI controls and the
Left/Right brain annotations, selectable via a language dropdown.
---
## 1. Goal
Let a visitor switch the experience's language from a dropdown. Switching
re-renders **both** the UI chrome (controls, labels, hints) **and** the
Left/Right brain annotation content (object labels, facts, emotion words) into
the chosen language, live, without reloading the page or interrupting playback.
## 2. Languages (this pass)
Four languages, English as the source/default:
| code | native name | notes |
|------|-------------|-------|
| `en` | English | source of truth, fallback |
| `es` | Español | |
| `fr` | Français | |
| `ja` | 日本語 | CJK glyphs; no layout-direction change |
Hebrew and Chinese were considered and **deliberately deferred** to a later
pass. Hebrew was the only right-to-left language, so **no RTL / UI-mirror work
is in scope here.** The language set is data-driven, so adding more later
(including an RTL pass) is additive.
## 3. What needs translating — two very different costs
1. **UI chrome** — ~20 fixed control strings (`Loading Universe`, `Output`,
`Video`, `Audio`, `Altitude`, `Think`, `Feel`, `Mood`, the six scale names,
hints, dev-mode labels). Small, static.
2. **Annotations** — the bulk. The manifest holds **41 clips / 753 authored
English string-variants** (Left labels + facts + Right emotions, many as
tier-lists). Authoring `es/fr/ja` = **753 × 3 ≈ 2,260 strings**. This is
*content*, and it is the dominant effort.
## 4. Architecture — three isolated pieces
### 4.1 Language registry
A single small module (`simulator/static/i18n.js` or a sibling) exporting the
ordered list `[{code, nativeName}]` for `en, es, fr, ja`. It is the **one source
of truth** consumed by the dropdown and `<html lang>`. No `dir` field needed
this pass (no RTL).
### 4.2 UI string catalog
A `{ key: {en, es, fr, ja} }` map for the ~20 control strings, living alongside
the registry. `index.html`'s static text is tagged with `data-i18n="key"`
attributes (removing hardcoded English from the markup); an
`applyUiStrings(lang)` function fills every tagged node on load and on every
language change.
### 4.3 Annotation strings — no schema change
The manifest already stores `strings: {"en": {key: text}}` per clip. We add
`es / fr / ja` sibling keys to each clip's `strings` object. The two render
sites change from a hardcoded `.en`:
- `simulator/static/app.js:505` (Left labels/facts)
- `simulator/static/app.js:556` (Right emotions)
…to `clip.strings[activeLang] || clip.strings.en` — an **English fallback** so a
missing key never blanks the overlay. Tier-list structure (general→scientific,
emotion escalation) is preserved per language.
## 5. The dropdown & selection behavior
- A `<select>` in the control panel (under "Output"), populated from the
registry, showing **native names** (Español, 日本語, …).
- `activeLang` is a module-level variable defaulting to `"en"`.
- **No persistence** (no localStorage): every page load starts in English; a
visitor's selection lasts only the session. Fits an installation that resets
between visitors.
- On change: set `activeLang``applyUiStrings(lang)` → set
`document.documentElement.lang` → re-render the current frame's labels and
emotions. **No reload, no video interruption.**
## 6. Authoring the translations (one-shot tooling)
`tools/i18n/translate_manifest.py`:
- Reads each clip's `strings.en` from the manifest.
- Produces `es / fr / ja` translations, **preserving tier-list structure** (a
list stays a list of the same length) and the general→scientific /
emotion-escalation intent.
- Writes them back into the manifest in place.
- **Idempotent / re-runnable:** skips keys already present in a target language,
so it can be run incrementally.
- Output is committed as content.
**Authorship caveat:** translations are LLM-generated and committed as the real
content. A human spot-check of the Japanese pass precedes merge; this design
does not claim native-quality fluency.
## 7. Testing
- **Unit (node):**
- registry integrity (codes unique, English present);
- `applyUiStrings` swaps every `data-i18n` node and leaves none in English
when a non-English language is active;
- annotation picker falls back to `en` on a missing key;
- tier-list length is preserved across languages.
- **Manifest test (pytest):** every clip's `strings` has all four language keys,
with **identical key-sets** and **identical tier-counts** to `en` (catches a
translation that dropped a key or a tier).
- **E2E (Playwright):** select each language → control labels change to that
language; select back to English → original restored; annotations re-render on
switch.
## 8. Out of scope (this pass)
- Hebrew / RTL / full-UI-mirror.
- Chinese.
- Browser language auto-detection.
- Persisting the chosen language across loads.
- Localizing author-mode (`/author.html`) tooling UI.
## 9. Open follow-ups (noted, not built)
- A later RTL pass (Hebrew/Arabic) would add a `dir` field to the registry and a
logical-property CSS migration.
- Native-speaker review of the committed translations.
@@ -0,0 +1,187 @@
# Accessibility pass + About page — Solution Design
**Date:** 2026-06-30
**Branch:** `feat/accessibility-pass` (off `design/cloudflare-static-publish`)
**Status:** Draft — pending operator review
## Problem
The simulator was built for an attended **kiosk**: a known display, a person
nearby, no assistive technology in the loop. The Cloudflare static publish
(`design/cloudflare-static-publish`) puts the same experience on a public URL
(`benstull.art`) where none of those assumptions hold. That raises two bars at
once:
- **Legal/usability** — a public site is expected to meet WCAG 2.1 AA; today the
primary navigation control (the Altitude dial) is pointer-only, several text
colors fail contrast, and there is no screen-reader path into a piece whose
*meaning* is already textual.
- **Physical safety** — the piece is continuous motion (video morphs, auto-scrub,
crossfades, a "trippy at max" WebGL shader, black-cover fades) with no
`prefers-reduced-motion` support and no photosensitivity safeguard. Unattended,
that is a vestibular and seizure risk.
This work makes the *experience* (not just the chrome) usable away from the
kiosk, and adds an **About** page explaining what the piece is for.
## Goals
1. Honor reduced-motion and add seizure safety (the public-web-specific layer).
2. Make every control keyboard- and screen-reader-operable.
3. Fix low-vision contrast.
4. Add `about.html` — the project's intent and provenance.
5. A small Output-panel layout fix (language picker placement).
**Non-goals:** no engine/Python changes; no new languages (about page is
English-first, i18n-keyed for later); no RTL; no redesign of the visual art.
The `<html lang>` switch is **already implemented** (`applyUiStrings`,
`app.js:1325`) and is out of scope.
## Target
WCAG 2.1 **AA** as the baseline, plus a motion/seizure layer (WCAG 2.3.1 flash
threshold + 2.3.3 animation-from-interactions) on top, since those are the risks
that genuinely change when the piece leaves the kiosk.
## Design
All changes are client-side, in `simulator/static/`. Seven units.
### A1. Reduced-motion: freeze-to-stills
A single `reduceMotion` state, default-on when
`window.matchMedia('(prefers-reduced-motion: reduce)').matches`, plus a visible
toggle in the Output fieldset (so a visitor whose OS setting disagrees can
override either way). Persisted in `localStorage` (mirrors the existing `devMode`
pattern, `app.js:934`).
When `reduceMotion` is **on**:
- **Video holds a frame.** Pause `#vid` / `#vid-loop` (do not call `playLoop()` /
the `.play()` paths at `app.js:260,288`). The Kuwahara paint loop keeps running
but composites a *static* frame, so knob changes (mood grade, dream, labels)
still re-render — the image responds, it just doesn't animate on its own.
- **Transitions are instant, not animated.** `autoScrub()` (`app.js:678`) jumps
`pos` straight to the target (one assignment + a single settle render) instead
of driving the rAF tween. Dial drag still scrubs live under the finger (that is
a direct-manipulation gesture, not autonomous motion — allowed under 2.3.3),
but on release it settles without a spin.
- **Auto-scrub speed coupling is disabled** (the constant per-altitude auto-spin).
The toggle flips state live (no reload): turning it off resumes `playLoop()`;
turning it on pauses and holds.
### A2. Photosensitivity: warning gate + flash audit
- **Warning gate.** A one-time interstitial over the stage, shown with the
existing `#run-sim` flow before the experience begins: a short "contains motion
and flashing effects" notice with a **Continue** action. Dismissal is
remembered in `localStorage` so it shows once per visitor, not every load. It
reuses the run-sim z-layer (above the black cover) and does not block the rest
of the page (controls remain reachable).
- **Flash audit.** Review the three motion sources that can produce rapid
luminance swings — the fast-spin blended dial pass, the `#black` cover fades
(`app.js`/`style.css`), and audio-coupled crossfades — and clamp any that can
exceed **3 transitions/second** (WCAG 2.3.1). The clamp math (min transition
duration given a luminance delta) is a **pure function** in a small module so it
is unit-testable; the audit findings and any clamps are recorded in the
implementation plan.
### B. Keyboard + focus
- **Dial as a real slider.** The `#dial` SVG gets `role="slider"`, `tabindex="0"`,
and live `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext`
(the human scale name, e.g. "reef"). A `keydown` handler maps:
- `ArrowDown` / `ArrowRight` → descend one altitude (`+1`, matching wheel-down).
- `ArrowUp` / `ArrowLeft` → ascend one altitude (`-1`).
- `Home` → cosmos (index 0); `End` → the deepest scale.
- Each step calls the existing `autoScrub` / `jumpToScale` path (so reduced-motion
instant-jump is inherited for free).
- **Dial labels become buttons.** The tap-to-jump `.dial-label` nodes get
`role="button"` + `tabindex="0"` + Enter/Space activation, reusing `jumpToScale`.
- **Visible focus everywhere.** A global `:focus-visible` outline rule (today only
`.dev-switch` has one).
### C. Low-vision contrast
Bump the failing dark-on-dark text to meet AA (4.5:1 for body, 3:1 for large):
the panel `.hint` (`#789`), `.dial-caption` (`#4d6184`), `.dial-label` (`#789ac0`),
and any others a contrast check flags on the `#111` / `#0d1320` backgrounds.
Verify the 280px panel reflows and text scales to 200% without clipping.
### D. Screen-reader basics
- Ensure every control has an accessible name (sliders via their `<label>`; the
dial via `aria-valuetext`; the new toggle labeled).
- **Narrate the alteration.** A visually-hidden `aria-live="polite"` region
announces the current scale and the active clip's top factual (left-brain)
label as the altitude/knobs change — turning the visual alteration into words.
Updates are throttled/debounced so a drag doesn't flood the queue.
- The decorative HUD/affect SVG layers get `aria-hidden="true"`.
### E. about.html
A standalone page mirroring `credits.html` exactly (same `credits-page` /
`credits-wrap` styles, a `← Back to the experience` link, `config.js` loaded).
English prose, structured so it can be i18n-keyed later. Linked from the header
beside the existing Credits link (`index.html:18`). Narrative beats:
- **For everyone.** The piece aims to be accessible to, and representative of,
the full range of human experience — which is *why* this accessibility work
exists.
- **Vantage points technology gave us.** The scales — deep-sea, coast, sky,
drone/aerial, orbit, cosmos — are places no unaided human could witness. We can
share them only because technology evolved to reach them: **underwater
exploration → drones → space exploration.**
- **Built with LLMs.** The work itself was built using large language models — an
extension of that same arc of tools extending human reach.
- **Honest about its limits.** This implementation is **imperfect** — but it is
more than the author could make alone; better *with* LLMs assisting than by a
single human hand. The imperfection is part of the point: tools extend us, they
don't replace the human behind them.
### F. Layout fix (Output fieldset)
Move the language picker **below** the Audio control (currently it sits above
Video/Audio). Put the globe `🌐` inline to the **left** of the `<select>` via a
flex row — today `.lang-pick` has no CSS, so the full-width `select` rule
(`style.css` `select { width: 100% }`) pushes the globe onto its own line above.
Add a `.lang-pick { display:flex; align-items:center; gap }` rule and let the
select flex to fill the rest.
## Components / files
| File | Change |
| --- | --- |
| `index.html` | Reduced-motion toggle; warning-gate markup; aria-live region; dial a11y attrs; move lang picker below Audio; about-page header link |
| `style.css` | `.lang-pick` flex; contrast bumps; `:focus-visible`; visually-hidden util; warning-gate styles |
| `app.js` | `reduceMotion` state + freeze logic; dial keyboard handler + aria-value sync; label buttons; aria-live narration; gate dismissal |
| `flash.js` *(new, pure)* | Flash-clamp helper (min duration given luminance delta), UMD like `i18n.js`/`credits.js` |
| `i18n.js` | New UI keys for the toggle, warning gate, about link (EN populated; other langs may fall back) |
| `about.html` *(new)* | The page above |
| `tools/build_static.py` | Ensure `about.html` + `flash.js` are copied into `dist/` (verify the static build includes new static files) |
## Testing
- **Node unit tests** for the pure flash-clamp helper (`flash.js`) — boundary
cases around the 3/sec threshold.
- **Playwright e2e** (extends the existing suite): reduced-motion toggle pauses
video and makes a dial step instant; dial is keyboard-focusable and
Arrow/Home/End change the scale; the warning gate appears and dismisses once;
`about.html` loads and its back-link returns to `index.html`; the language
picker renders below Audio with the globe inline.
- **Manual/automated contrast check** on the bumped colors.
- Existing suites stay green: the current Playwright specs, node `--test`, and
`pytest`.
## Risks / open questions
- **Freeze-to-stills + WebGL.** Need to confirm the paint loop still composites a
paused-video frame (some browsers stop delivering frames to WebGL from a paused
`<video>`); fallback is to draw the last frame once to the canvas and stop the
loop. Resolved during implementation.
- **Flash audit is the fuzziest unit** — the clamp is mechanical, but deciding
which existing transitions actually breach 3/sec needs measurement; the plan
will enumerate them explicitly rather than hand-wave.
- E2E in this environment starts uvicorn via `python` (only `python3` exists) —
start the server by hand and use `reuseExistingServer`, per project memory.
@@ -0,0 +1,149 @@
# Publish to benstull.art — Cloudflare Pages + R2 (fully static) — Design
**Status:** Approved (brainstormed 2026-06-30). Ready for writing-plans.
## Goal
Publish the Human Experience **Simulator** publicly at
`benstull.art/human-experience-simulator`, hosted on Cloudflare, with the
`benstull.art` apex **redirecting** to that path for now.
The simulator today is a FastAPI server (`simulator/app.py`) serving ~92 KB of
static frontend plus video/audio from a local `/media/` mount. The public target
has **no origin server**: the frontend is **fully static** on **Cloudflare Pages**,
and the video/audio lives in a public **R2** bucket behind a custom domain. Nothing
runs, nothing to patch, nothing to keep alive. The existing GCE/flotilla deployment
stays as the dev/authoring surface — this is a *new, additional* public target, not
a migration of the dev box.
## Why this shape (the constraint that forces it)
Cloudflare Pages has a **hard 25 MiB per-file limit** (and 20,000 files free /
100,000 paid; no total-size cap). The repo carries **3.9 GB across 663 mp4 + 10
mp3**, with individual files up to ~117 MB. Pages cannot host the video. R2 has no
relevant per-file limit, **free egress**, and ~2 GB of served media sits inside its
10 GB-month free tier (≈ free). So: **Pages for the ~92 KB frontend, R2 for the
media.** (Verified against Cloudflare docs 2026-06-30.)
## Decisions (locked)
- **Path:** experience at `/human-experience-simulator`; **apex `benstull.art`
redirects** there (302/308 redirect, not a rewrite).
- **Fully static**, no Pages Functions / Worker — the dynamic logic is trivial
client-side (see Component 2).
- **Media on R2** at a custom domain `static.benstull.art` (public bucket binding).
- **No cross-session cache** (no Service Worker) for now — the existing in-memory
graduated prefetch is sufficient. Deferred, not rejected.
- **benstull.art is already a Cloudflare zone** — no nameserver migration needed.
- **Operates outside Wiggleverse deployment patterns (standing, operator-set):**
this **entire project** is exempt from the typical Wiggleverse deployment
conventions — `flotilla-only-provisioning`, the `ppe-before-prod` GCE pipeline,
and the §9 prod-promotion gate do not bind it. It is a static public art target
hosted directly on Cloudflare. (Safety/hygiene rules — `git-ssh`, the secrets
rule, branch→PR — still apply; only the *deployment* machinery is out of scope.)
## Served media — what actually ships
The on-disk 3.9 GB is mostly **pipeline source**, not served bytes. The manifest
(`simulator/sample_media/manifest.json`) references only **`base.mp4`** for clips —
the `master.mp4` (20 files, incl. the 91117 MB `coast_birdrock` set) and
`mezzanine.mp4` proxies are build inputs that **never reach the browser**. The
served set is:
- the `base.mp4` clip bases (one per pool member),
- the **154 transition morphs** (`*__*.mp4` / `*.rev.mp4`, ~1.6 GB, each already
kept < 25 MB),
- the **10 audio `.mp3`** layers (~12 MB).
**~2 GB** to R2. The build computes the exact set by walking the manifest — it
**must not** blindly sync `sample_media/` (that would push the masters too).
## Components
### 1. Static build (`tools/` — new build script → `dist/`)
Replaces the server with baked artifacts:
- **Bake the read endpoints to static JSON** the page loads at boot, replacing the
fetches to `/api/clips`, `/api/ring`, `/api/media-versions`:
- `clips.json``GET /api/clips`
- `ring.json``GET /api/ring`
- `media-versions.json``GET /api/media-versions` (file → content-hash)
Generate these by importing the existing builders (`simulator/clips.py`,
`build_pool_manifest.py`) directly — **single source of truth, no hand-copy**.
- **Copy only manifest-referenced media** into the R2 sync tree (see Served media).
- **Emit the public frontend** (`index.html`, `app.js`, `scrub.js`, `style.css`,
`i18n.js`) into `dist/`, **excluding** author/review/dev surfaces
(`author.*`, `review*.html`, `/api/author/*`).
- Output is deterministic and re-runnable; checked by the integration test below.
### 2. Client-side dynamic logic (`simulator/static/app.js`)
The only server-computed behavior is the **random clip pick on landing**
(`POST /api/ring/advance`). The scrub work (PR #28, spec 2026-06-27) **already
moved the pick client-side** for drag/scroll gestures via the pool + `morphByPair`
lookup — so the remaining task is to ensure **every** entry path (initial load, tap-
label jumps) uses the client-side pick and that **no code path depends on a live
`/api/*` call**. Boot loads the baked JSON instead of fetching the API.
`/api/alteration` is verified client-resolvable or dropped from the public build.
### 3. Media URL base + CORS (`simulator/static/app.js`)
- `mediaNetUrl(file)` changes its base from `"/media/" + file` to a configurable
**`MEDIA_BASE`** (`https://static.benstull.art/`), preserving the existing
`?v=<hash>` content-hash versioning. A single const, fed from the baked config so
localhost dev still works against the FastAPI `/media/` mount.
- **CORS is load-bearing** because of the WebGL dream shader: `app.js` preloads each
clip via `fetch()` (cross-origin → needs `Access-Control-Allow-Origin`) into a
blob, and any clip that falls back to the *direct network* `<video>` path feeding
WebGL **taints the GL texture** without CORS. So:
- R2 bucket gets a **CORS policy** allowing `https://benstull.art`.
- `<video>` elements carry **`crossOrigin="anonymous"`**.
Without this, plain playback works but the *dream effect* throws a security error.
### 4. Prefetch (unchanged)
Keep the existing graduated strategy verbatim — `preloadList()` (all bases),
`preloadOrder()` (current pool → neighbors outward → reachable morphs → sweep),
`refreshReachablePreload()` (per-landing), `preloadAllMedia(concurrency=4)` behind
the "Loading Universe…" bar. It improves on R2: Cloudflare edge-caches each object
after first fetch; egress is free, so the background fill is free. No code change
beyond the `MEDIA_BASE` swap (Component 3).
### 5. Caching headers
Server today sets `/media/` to `no-cache` (revalidate-always). On the CDN, set the
versioned media objects to long-lived **`immutable`** — the `?v=<hash>` query already
guarantees a new URL when bytes change, so a re-baked clip busts cache without a
manual purge. Pages assets keep normal Cloudflare defaults.
### 6. Deploy & redirect
- **Pages:** deploy `dist/` (Pages Git integration or `wrangler pages deploy`).
Serve the app under the `/human-experience-simulator` path.
- **R2:** sync the media tree to the bucket (`wrangler r2` or rclone), bind
`static.benstull.art` as the public custom domain, apply the CORS policy.
- **Apex redirect:** `benstull.art/``/human-experience-simulator` via a
Cloudflare redirect rule (or Pages `_redirects`).
## Testing
- **Build integration test:** run the build against the real manifest; assert
`dist/` contains the expected static files, the three baked JSONs match the live
API output, and the media sync tree contains **only** manifest-referenced files
(no masters/mezzanines, nothing > some sanity bound).
- **E2E (Playwright) against the static build:** point the existing suite at the
built `dist/` + an R2-equivalent media origin and verify: boot with **no `/api/*`
server**, the **dream/Kuwahara shader survives CORS** (texture not tainted),
scrub-driven morphs stay smooth, audio crossfade works, language dropdown works.
The E2E browser tier is required per the §9 pipeline (handbook).
- **Pre-deploy check:** a smoke run confirming CORS headers + range requests on the
R2 custom domain before flipping the apex redirect.
## Out of scope / deferred
- Service Worker / cross-session caching (deferred, not rejected).
- Migrating the dev/authoring GCE deployment (stays as-is on flotilla).
- Per-clip loop files for the reverse-landing frame jump (pre-existing residual,
tracked elsewhere).
- Cloudflare Stream (R2 is sufficient and cheaper for this payload).
@@ -6,11 +6,8 @@
> Posture: yolo
> Claude-Session: c6b9bebd-f94a-4fc0-982d-0a9dc00b3148
> 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 0026 for human-experience-filter-art. The driver replaces this
> body with the full transcript and renames the file to its final
> SESSION-0026.0-TRANSCRIPT-2026-06-27T15-26--<end>.md form at session end.
> End: 2026-06-27T18-48 (PST)
> Status: **FINALIZED** — merged to main (PR #28) and published at session end.
## Launch prompt
@@ -128,3 +125,36 @@ finalize. Empty if none._
eye-tweakable. Residual: a one-seek hitch at each loop wrap (~every 1720s) and scrub-START (vs landing) is
not made seamless (a looping clip is at arbitrary phase when a drag begins). If the wrap hitch bothers the
operator, bake per-clip loop files (`base[3:end]`) for native seamless looping.
## Closeout
**Shipped.** Branch `session-0026` (14 commits) → **PR #28 merged to `main`** (Gitea
merge commit `108e620`, branch deleted). Working tree clean on `main`.
**Arc of the session:** brainstorming was pre-done (approved design existed) → claimed
0026 (planning-and-executing, yolo) → wrote the implementation plan (reviewed: operator
chose "execute as-is") → executed all 8 plan tasks (Phase 1 scrub interaction + Phase 2
all-intra re-bake) → operator held for by-eye review → **4 eyeball rounds** of fixes
(landing frame, reef_snapper text card, video double-buffer, audio dial + fade-race +
transition speed; one bug solved via systematic-debugging with a fails-without-the-fix
regression test) → operator said merge → merged + finalized.
**§9 pipeline status:** localhost + E2E green (11 Playwright + 9 node unit + 302 pytest).
This app is **simulator-first with no PPE/prod deploy infra** (runs locally; hardware/Pi
deferred per the standing directive) — no deploy stage to run; the merge is the ship for
a locally-run simulator. (Plan archive skipped: app has **no content repo** in `app.json`;
the plan stays in-repo at `docs/superpowers/plans/2026-06-27-scrub-driven-altitude-transitions.md`.)
**Deferred decisions** (also in the section above): D1 canonical-forward-morph-per-segment
(scrub same file bidirectionally); D2 reef_snapper tracked test-label misaligned by the trim;
D3 reverse/turn-back frame asymmetry + loop-wrap seek (per-clip loop files would fully fix).
**Next-session prompt (operator's call — nothing outstanding blocks):**
```
/wgl-planning-and-executing Next HEF polish: pick one of —
(a) bake per-clip loop files base[3:end] for native seamless looping (fixes the D3
reverse-landing frame jump + the ~1720s loop-wrap seek), OR
(b) continue by-eye scrub/audio feel tweaks, OR
(c) re-author the reef_snapper tracked label for the trimmed footage (/author.html), OR
(d) register a content repo in app.json so plans/specs archive at finalize.
```
@@ -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.
@@ -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).
+30
View File
@@ -76,5 +76,35 @@
},
"0026": {
"title": ""
},
"0027": {
"title": ""
},
"0028": {
"title": ""
},
"0029": {
"title": ""
},
"0030": {
"title": ""
},
"0031": {
"title": ""
},
"0032": {
"title": ""
},
"0033": {
"title": ""
},
"0034": {
"title": ""
},
"0035": {
"title": ""
},
"0036": {
"title": ""
}
}
+538 -11
View File
@@ -38,21 +38,32 @@ MANIFEST = MEDIA / "manifest.json"
# --- Pools: scale id -> ordered clip ids (primary first) (content-candidate-pool.md) ---
POOLS: dict[str, list[str]] = {
"cosmos": ["cosmos", "cosmos_miri", "cosmos_galaxies", "cosmos_hudf", "cosmos_xdf"],
"orbit": ["orbit_planetearth", "orbit_crewobs", "orbit_bluemarble"],
"coast": ["coast_birdrock", "coast_surfgrass", "coast_elkbeach", "coast_drakesbeach"],
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_snapper", "reef_coralspacific"],
"abyss": ["abyss_wow", "abyss_midwaterexp", "abyss_hiding"],
"cosmos": ["cosmos", "cosmos_galaxies", "cosmos_orion", "cosmos_tarantula",
"cosmos_westerlund", "cosmos_southernring", "cosmos_carina_eso"],
"orbit": ["orbit_planetearth", "orbit_bluemarble", "orbit_aurora2025",
"orbit_citylights", "orbit_helene", "orbit_epic"],
"sky": ["sky_grca_templesa", "sky_greenland_landice", "sky_greenland_suture",
"sky_jungle_amazon", "sky_jungle_waterfall", "sky_coast_cliffspain",
"sky_mtn_castlecrags", "sky_mtn_rocky"],
"coast": ["coast_birdrock", "coast_surfgrass", "coast_kelp", "coast_otters",
"coast_kalaloch", "coast_seals", "coast_mist"],
"reef": ["reef_lionfish", "reef_spawning", "reef_hawkfish", "reef_coralspacific",
"reef_redsea", "reef_flowergarden"],
"abyss": ["abyss_wow", "abyss_midwaterexp", "abyss_hiding", "abyss_bigfin",
"abyss_dandelion", "abyss_octopus", "abyss_seapig"],
}
# Ring order (large -> small, wraps): cosmos -> orbit -> coast -> reef -> abyss -> cosmos.
RING_ORDER = ["cosmos", "orbit", "coast", "reef", "abyss"]
# Ring order (large -> small, wraps): cosmos -> orbit -> sky -> coast -> reef -> abyss -> cosmos.
# `sky` (the airplane-window / low-flight layer) sits between orbit (space) and coast (ground).
RING_ORDER = ["cosmos", "orbit", "sky", "coast", "reef", "abyss"]
# Per-scale soundtrack (audio spec §5.1) — the production-pass output of
# simulator/build_audio_media.py, served at /media/audio/<path>.
SCALE_AUDIO: dict[str, str] = {
"cosmos": "cosmos/pillars.loop.mp3",
"orbit": "orbit/spaceamb.loop.mp3",
# TODO(sky): wants its own wind/air ambience — reusing the orbit space-amb loop as a placeholder.
"sky": "orbit/spaceamb.loop.mp3",
"coast": "coast/waves.loop.mp3",
"reef": "reef/soundscape.loop.mp3",
"abyss": "abyss/whale.loop.mp3",
@@ -113,6 +124,71 @@ META: dict[str, tuple[str, str, str]] = {
"NOAA Ocean Exploration midwater-exploration-1920x1080-1; trim 1640s, crossfade-loop"),
"abyss_hiding": ("“Hiding in the Dark” (NOAA Ocean Exploration)", PD,
"NOAA Ocean Exploration dark-1280x720-1; trim 2044s, crossfade-loop"),
# --- Round-4 curated additions (2026-06-28, branch feat/sky-scale-expanded-pools) ---
# cosmos
"cosmos_orion": ("Orion Nebula — visible/IR flythrough (NASA SVS)", PD,
"NASA SVS 30957 orion_vis_ir_zoom_xfade-1920x1080p30; trim 4095s, crossfade-loop. Text-free."),
"cosmos_tarantula": ("Tarantula Nebula (30 Doradus) zoom (NASA/ESA/CSA/STScI)", CCBY_WEBB,
"ESA/Webb weic2212b; 1080p, trim 159s, crossfade-loop."),
"cosmos_westerlund": ("Flight through star cluster Westerlund 2 (ESA/Hubble)", "CC-BY 4.0 — credit ESA/Hubble",
"ESA/Hubble heic1509f; 1080p, trim 559s, crossfade-loop."),
"cosmos_southernring": ("Southern Ring Nebula zoom (NASA/ESA/CSA/STScI)", CCBY_WEBB,
"ESA/Webb weic2207a; 1080p, trim 159s, crossfade-loop."),
"cosmos_carina_eso": ("Carina Nebula zoom (ESO)", "CC-BY 4.0 — credit ESO",
"ESO eso1828c; 1080p, trim 048s, crossfade-loop."),
# orbit
"orbit_aurora2025": ("ISS aurora — Nov 2025 geomagnetic storm (NASA JSC)", PD,
"NASA JSC ESRS ISS_20251112_071350; 1080p, trim 560s, crossfade-loop."),
"orbit_citylights": ("ISS night pass — West Coast city lights (NASA JSC)", PD,
"NASA SVS 30180 iss028_nighttime_20110819; 720p, trim 054s, crossfade-loop."),
"orbit_helene": ("Hurricane Helene from the ISS — storm + atmospheric limb (NASA JSC)", PD,
"NASA JSC jsc2024m000168; trim 559s, crossfade-loop."),
"orbit_epic": ("DSCOVR/EPIC full-disk Earth time-lapse (NASA GSFC)", PD,
"NASA SVS 12118 EPIC_timelapse; trim 060s, crossfade-loop."),
"orbit_illuminated": ("“Earth Illuminated” — aurora + Milky Way over the limb (NASA JSC)", "CC0 / public domain",
"NASA JSC Earth_Illuminated_120808; trim 68113s, crossfade-loop."),
# sky (NEW scale — airplane-window / low-flight, between orbit and coast)
"sky_grca_templesa": ("Grand Canyon — banking aerial over cliffs & temples (NPS)", PD_NPS,
"NPS Grand Canyon b-roll hd10 0121cbe5; 1080p, trim 024s, crossfade-loop."),
"sky_greenland_landice": ("IceBridge — low flight over N. Greenland snowy land ice (NASA SVS)", PD,
"NASA SVS 13472 Panasonic 2016 Airborne LandIce; trim 2060s, vidstab-stabilized, crossfade-loop."),
"sky_greenland_suture": ("IceBridge — overhead N. Greenland sea-ice patterning (NASA SVS)", PD,
"NASA SVS 13472 4K SeaIce 2017 Suture; trim 1050s, vidstab-stabilized, crossfade-loop."),
"sky_jungle_amazon": ("Amazon rainforest canopy — amazon→canopy splice", "Pexels License",
"Pexels 31291128 (amazon canopy) → 29780524 (jungle canopy), 1s xfade splice; crossfade-loop."),
"sky_jungle_waterfall": ("Waterfall plunging out of dense jungle canopy", "Pexels License",
"Pexels 8334944; 4K source, trim 646s, crossfade-loop."),
"sky_coast_cliffspain": ("Dramatic Atlantic sea cliffs, Northern Spain — aerial", "Pexels License",
"Pexels 36668415; 4K source, trim 125s, crossfade-loop."),
"sky_mtn_castlecrags": ("Castle Crags — gliding past jagged granite spires (US Forest Service)", PD,
"USFS/DVIDS 998726 DOD_111563303; trim 120174s, crossfade-loop."),
"sky_mtn_rocky": ("Rocky Mountain NP — aerial over alpine summits & tundra (NPS)", PD_NPS,
"NPS Rocky Mountain b-roll 188046ff; trim 60110s, crossfade-loop."),
# coast
"coast_kelp": ("Giant kelp forest, underwater", "Pexels License",
"Pexels 30285973; 4K source, trim 046s, crossfade-loop."),
"coast_otters": ("Wild sea otters, Elkhorn Slough CA (USFWS)", PD,
"USFWS Pacific Southwest b-roll (Wikimedia 28833083527); trim 048s, crossfade-loop."),
"coast_kalaloch": ("Slow surf on rock at sunset, Kalaloch WA", "CC-BY-SA 4.0 — credit Joe Mabel",
"Wikimedia Waves crashing on rocks off Beach 4, Kalaloch; trim 046s, crossfade-loop."),
"coast_seals": ("Harbor seals hauled out on a rock", "Pexels License",
"Pexels 6497564; 4K source, trim 854s, crossfade-loop."),
"coast_mist": ("Sea mist over a rocky coastline", "Pexels License",
"Pexels 31036891; 4K source, trim 357s, crossfade-loop."),
# reef
"reef_redsea": ("Red Sea coral reef — clear blue, colorful", "CC-BY-SA 4.0 — credit Kora27",
"Wikimedia Korallenriff im Roten Meer DSCF1654; trim 6286s, crossfade-loop."),
"reef_flowergarden": ("Sunlit reef + sergeant majors + jack/scad schools (NOAA Fisheries)", PD,
"NOAA Fisheries b-roll VIDEO_ID 1553921798001; trim 868s, crossfade-loop."),
# abyss
"abyss_bigfin": ("Bigfin squid drifting in black water (NOAA Ocean Exploration)", PD,
"NOAA OER Windows to the Deep 2021 dive10-bigfin-squid; trim 3476s, crossfade-loop."),
"abyss_dandelion": ("Dandelion siphonophore — glowing orb + threads (NOAA Ocean Exploration)", PD,
"NOAA OER 2021 North Atlantic Stepping Stones dive13-dandelion; trim 1270s, crossfade-loop."),
"abyss_octopus": ("Deep-sea octopus (Graneledone) crawling (NOAA Ocean Exploration)", PD,
"NOAA OER 2023 EXPRESS West Coast; Wikimedia Graneledone_boreopacifica_close-up; trim 660s, crossfade-loop."),
"abyss_seapig": ("Swimming sea cucumber (Enypniastes), CC0 (NOAA)", "CC0 / public domain",
"NOAA Okeanos Gulf of Mexico 2014 Ex1402-dive06; trim 044s, crossfade-loop."),
}
# --- Affect vocabulary per SCALE (shared by all the scale's pool members) -------
@@ -131,6 +207,12 @@ AFFECT: dict[str, list[tuple]] = {
("feel.unity", [0.66, 0.58], 3, ["one", "unity", "belonging", "a borderless belonging"]),
("feel.distance", [0.42, 0.82], 4, ["far", "distance", "remoteness", "an exquisite remoteness"]),
],
"sky": [
("feel.exhilaration", [0.50, 0.44], 1, ["whee", "exhilaration", "elation", "a soaring elation"]),
("feel.freedom", [0.22, 0.68], 2, ["free", "freedom", "release", "a boundless release"]),
("feel.lightness", [0.66, 0.58], 3, ["light", "lightness", "buoyancy", "a weightless buoyancy"]),
("feel.vertigo", [0.42, 0.82], 4, ["whoa", "vertigo", "giddiness", "a giddy, falling vertigo"]),
],
"coast": [
("feel.ease", [0.50, 0.44], 1, ["nice", "ease", "calm", "an unhurried calm"]),
("feel.nostalgia", [0.22, 0.68], 2, ["miss", "nostalgia", "wistfulness", "a salt-air wistfulness"]),
@@ -151,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)."""
@@ -187,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),
],
@@ -209,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]),
],
@@ -254,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"],
@@ -271,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,
@@ -311,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"],
@@ -319,16 +673,189 @@ LABELS: dict[str, list[dict]] = {
),
measure("measure.depth", "2,140 m", [0.06, 0.06, 0.16, 0.08], 2),
],
# --- Round-5 left-brain fill (2026-06-30, session 0027): the pool members that
# shipped with affect (right-brain) but no factual LEFT labels. Static tiered
# labels (footage not eyeballed); the abyss/reef creature clips could later be
# upgraded to tracked labels via author mode. ---
# ---------- cosmos ----------
"cosmos_orion": [
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · M42, the Orion star-forming region"], [0.18, 0.30, 0.6, 0.5]),
static_label("detected.star", 2, ["star", "young star", "Trapezium star", "Trapezium · hot O-stars lighting the nebula"], [0.44, 0.40, 0.14, 0.16]),
measure("measure.distance", "≈1,344 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
"cosmos_tarantula": [
static_label("detected.nebula", 4, ["cloud", "nebula", "Tarantula Nebula", "30 Doradus · the Local Groups brightest star-forming region"], [0.16, 0.28, 0.64, 0.5]),
static_label("detected.cluster", 2, ["stars", "star cluster", "R136", "R136 · packs some of the most massive stars known"], [0.42, 0.40, 0.16, 0.18]),
measure("measure.distance", "≈160,000 ly", [0.06, 0.06, 0.22, 0.08], 3),
],
"cosmos_westerlund": [
static_label("detected.cluster", 4, ["stars", "star cluster", "Westerlund 2", "Westerlund 2 · a young cluster, ~12 Myr old"], [0.30, 0.30, 0.4, 0.4]),
static_label("detected.star", 2, ["star", "massive star", "O-type star", "O-type · blue-white, tens of solar masses"], [0.46, 0.42, 0.12, 0.14]),
measure("measure.distance", "≈20,000 ly", [0.06, 0.06, 0.22, 0.08], 3),
],
"cosmos_southernring": [
static_label("detected.nebula", 4, ["ring", "nebula", "planetary nebula", "NGC 3132 · gas shed by a dying Sun-like star"], [0.24, 0.24, 0.5, 0.5]),
static_label("detected.star", 2, ["star", "central star", "white dwarf", "white dwarf · the hot stellar core left behind"], [0.46, 0.44, 0.1, 0.12]),
measure("measure.distance", "≈2,500 ly", [0.06, 0.06, 0.22, 0.08], 3),
],
"cosmos_carina_eso": [
static_label("detected.nebula", 4, ["cloud", "nebula", "emission nebula", "emission nebula · the Carina star-forming complex"], [0.16, 0.30, 0.64, 0.5]),
static_label("detected.star", 2, ["star", "young star", "massive star", "massive star · the kind that sculpts the Carina cliffs"], [0.44, 0.40, 0.14, 0.16]),
measure("measure.distance", "≈7,500 ly", [0.06, 0.06, 0.2, 0.08], 3),
],
# ---------- orbit ----------
"orbit_aurora2025": [
static_label("detected.aurora", 4, ["glow", "aurora", "the aurora", "aurora · solar particles exciting upper-atmosphere oxygen"], [0.10, 0.45, 0.8, 0.35]),
static_label("detected.limb", 2, ["edge", "Earths limb", "atmospheric limb", "atmospheric limb · ~100 km of air, glowing on edge"], [0.05, 0.66, 0.9, 0.12]),
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
],
"orbit_citylights": [
static_label("detected.citylights", 4, ["lights", "city lights", "urban grid", "urban grid · sodium/LED glow tracing the coastline"], [0.20, 0.45, 0.6, 0.4]),
static_label("detected.airglow", 2, ["band", "airglow", "atmospheric airglow", "airglow · the faint nighttime emission of the upper air"], [0.05, 0.30, 0.9, 0.1]),
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
],
"orbit_helene": [
static_label("detected.hurricane", 4, ["storm", "hurricane", "tropical cyclone", "tropical cyclone · a warm-core spiral, eye at its center"], [0.22, 0.30, 0.5, 0.5]),
static_label("detected.limb", 2, ["edge", "Earths limb", "atmospheric limb", "atmospheric limb · the thin shell weather lives in"], [0.05, 0.10, 0.9, 0.12]),
measure("measure.altitude", "~408 km", [0.06, 0.06, 0.18, 0.08], 3),
],
"orbit_epic": [
static_label("detected.globe", 4, ["Earth", "the globe", "sunlit disk", "full disk · the whole daylit Earth, seen from L1"], [0.24, 0.14, 0.52, 0.66]),
static_label("detected.cloud_band", 2, ["clouds", "cloud band", "weather systems", "weather systems · swirling across a turning planet"], [0.30, 0.34, 0.34, 0.26]),
measure("measure.distance", "~1.5M km", [0.06, 0.06, 0.2, 0.08], 3),
],
# ---------- 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]),
],
# ---------- reef ----------
"reef_redsea": [
static_label("detected.coral", 4, ["coral", "reef coral", "scleractinian", "scleractinian · stony reef-builder in symbiosis with algae"], [0.15, 0.35, 0.7, 0.5]),
static_label("detected.reeffish", 2, ["fish", "reef fish", "anthias", "anthias · orange clouds of plankton-pickers over the reef"], [0.30, 0.20, 0.4, 0.3]),
measure("measure.depth", "10 m", [0.06, 0.06, 0.16, 0.08], 3),
],
"reef_flowergarden": [
static_label("detected.school", 4, ["fish", "fish school", "jack & scad school", "carangid school · jacks and scad sweeping the reef"], [0.20, 0.25, 0.55, 0.4]),
static_label("detected.sergeant", 2, ["fish", "sergeant major", "Abudefduf saxatilis", "Abudefduf · the striped “sergeant major” damselfish"], [0.55, 0.45, 0.18, 0.2]),
measure("measure.depth", "20 m", [0.06, 0.06, 0.16, 0.08], 3),
],
# ---------- abyss ----------
"abyss_bigfin": [
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.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.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.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),
],
}
# Human-readable strings for affect/measurement keys are produced from the tiered
# 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
@@ -347,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,
+21
View File
@@ -32,3 +32,24 @@ npm test
`playwright.config.ts` starts uvicorn on port 8099 automatically (`reuseExistingServer`
locally) and points the tests at it.
## Static-build E2E (Cloudflare publish)
`tests/static-build.spec.ts` verifies the **built static site** (Cloudflare publish
— see `deploy/cloudflare/README.md`): it boots with NO `/api/*` server (baked JSON),
and the WebGL dream shader runs on **cross-origin** media without tainting the GL
texture (the CORS path R2 will use in production).
It uses `playwright.static.config.ts` (no uvicorn `webServer`) and a dual static
server, `serve-static.mjs`, that serves the built `dist/` (app, :8077) and
`dist-media/` (media with CORS + Range, :8078):
```bash
# from the repo root: build with the media base pointed at the local CORS server
.venv/bin/python -m tools.build_static --media-base http://localhost:8078/
# from this directory (simulator/e2e): start the dual server, then run the spec
DIST_DIR="$PWD/../../dist" MEDIA_DIR_E2E="$PWD/../../dist-media" node serve-static.mjs &
npx playwright test --config playwright.static.config.ts
```
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "@playwright/test";
// Static-build E2E: NO webServer — the built dist/ + dist-media/ are served by
// serve-static.mjs (started manually, see README.md), so there is no uvicorn/API.
// Run: npx playwright test --config playwright.static.config.ts
export default defineConfig({
testDir: "./tests",
testMatch: /static-build\.spec\.ts/,
timeout: 60_000,
use: { actionTimeout: 10_000 },
});
+48
View File
@@ -0,0 +1,48 @@
// Serves the built dist/ (app) and dist-media/ (media, with CORS + Range) on two
// ports — a local stand-in for Pages + R2, so the static-build E2E exercises the
// cross-origin media path exactly as production will (CORS for the WebGL dream
// shader, Range for video scrubbing).
//
// Env: DIST_DIR (app root), MEDIA_DIR_E2E (media root). Ports: app 8077, media 8078.
import http from "node:http";
import { createReadStream, statSync } from "node:fs";
import { join, extname, normalize } from "node:path";
const TYPES = {
".html": "text/html", ".js": "text/javascript", ".json": "application/json",
".css": "text/css", ".mp4": "video/mp4", ".mp3": "audio/mpeg",
};
function serve(root, port, cors, indexFallback) {
http.createServer((req, res) => {
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
let p = decodeURIComponent(req.url.split("?")[0]);
if (p.endsWith("/")) p += "index.html";
// contain to root (no path traversal)
const file = normalize(join(root, p));
if (!file.startsWith(normalize(root))) { res.writeHead(403); return res.end("forbidden"); }
try {
const st = statSync(file);
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
const range = req.headers.range; // honor Range for video scrub
if (range && /^bytes=/.test(range)) {
const [s, e] = range.replace("bytes=", "").split("-");
const start = +s, end = e ? +e : st.size - 1;
res.writeHead(206, {
"Content-Range": `bytes ${start}-${end}/${st.size}`,
"Content-Length": end - start + 1,
});
createReadStream(file, { start, end }).pipe(res);
} else {
res.writeHead(200, { "Content-Length": st.size });
createReadStream(file).pipe(res);
}
} catch {
res.writeHead(404); res.end("not found");
}
}).listen(port);
}
serve(process.env.DIST_DIR, 8077, false);
serve(process.env.MEDIA_DIR_E2E, 8078, true);
console.log("static app :8077 media(cors) :8078");
+116
View File
@@ -0,0 +1,116 @@
import { test, expect } 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.
test.describe("Task 1 — Output panel layout", () => {
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 renders 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();
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 warning gate", () => {
test("motion warning gate shows once, then is remembered", async ({ page }) => {
await page.goto("/");
const gate = page.locator("#motion-warning");
await expect(gate).toBeVisible({ timeout: 30000 }); // shown after media preload + splash fade
await page.locator("#motion-warning-continue").click();
await expect(gate).toBeHidden();
// Reload in the SAME context: dismissal persisted, gate stays hidden.
await page.reload();
await expect(page.locator("#motion-warning")).toBeHidden();
await expect(page.locator("#run-sim")).toBeVisible();
});
});
test.describe("Task 5 — Reduced motion", () => {
test("reduce-motion toggle pauses video playback", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off
await page.goto("/");
await page.locator("#motion-warning-continue").click().catch(() => {});
const rm = page.locator("#reduce-motion");
await expect(rm).not.toBeChecked(); // default-off under no-preference
await page.locator("#run-sim").click();
await page.waitForTimeout(500);
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(false); // experience running → video plays
await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label
await expect(rm).toBeChecked();
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(true); // reduced motion → frozen
});
});
test.describe("Task 6 — Keyboard + ARIA dial", () => {
test("altitude dial is a keyboard-operable slider", async ({ page }) => {
await page.goto("/");
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("/");
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();
});
});
+87 -11
View File
@@ -10,7 +10,10 @@ 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");
localStorage.setItem("hef.motionWarnDismissed", "1"); // returning visitor: skip the photosensitivity gate
});
await page.goto("/");
// Wait for the "Loading Universe" preload to finish (splash gets class "done").
await page.waitForFunction(
@@ -72,25 +75,38 @@ test("a new volume fade supersedes a stale one (audio not silenced after landing
expect(vol).toBeGreaterThan(0.7); // ended near 0.8, not dragged to 0
});
test("audio is a 0-10 level dial, defaulting to 0", async ({ page }) => {
test("audio is a 0-10 level dial", async ({ page }) => {
await boot(page);
const ctl = await page.evaluate(() => {
const c = document.querySelector("#audio") as HTMLInputElement;
return { type: c.type, min: c.min, max: c.max, value: c.value };
return { type: c.type, min: c.min, max: c.max };
});
expect(ctl.type).toBe("range");
expect(ctl.min).toBe("0");
expect(ctl.max).toBe("10");
expect(ctl.value).toBe("0"); // silent until raised
});
test("turning video on for the first time lifts audio to 3/10", async ({ page }) => {
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");
await enableVideo(page); // first video-on couples audio at a gentle level
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
// The level label reflects it.
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
// "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);
});
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
@@ -134,7 +150,7 @@ test("auto-scrub speed is constant per altitude (a 2-step jump takes ~2x a 1-ste
return (await page.evaluate(() => performance.now())) - t0;
}
const t1 = await timeJump(60, "orbit"); // cosmos -> orbit (1 altitude)
const t2 = await timeJump(120, "reef"); // orbit -> reef (2 altitudes)
const t2 = await timeJump(120, "coast"); // orbit -> coast (2 altitudes; ring is cosmos·orbit·sky·coast·reef·abyss)
const ratio = t2 / t1;
expect(ratio).toBeGreaterThan(1.5); // proportional to distance, not a fixed total
expect(ratio).toBeLessThan(2.7); // ~2x, not more
@@ -163,6 +179,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())!;
@@ -228,7 +276,8 @@ test("crossing a detent commits and locks the destination clip", async ({ page }
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
// Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents),
// crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete).
// crossing integer 1 (orbit) so it commits, then hold past it WHILE STILL DRAGGING
// (the live blend holds wherever the knob is held; the snap happens only on release).
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 30, cy + 6, { steps: 14 });
@@ -245,3 +294,30 @@ test("crossing a detent commits and locks the destination clip", async ({ page }
expect(st2.activeClipId).toBe(st.activeClipId);
await page.mouse.up();
});
test("releasing a partial drag snaps to the closest altitude (no held mid-blend)", async ({ page }) => {
// The operator can grab the dial, turn it PART of a detent, and let go between two
// altitudes. On release the knob must auto-scrub to the CLOSEST altitude and settle
// there (integer position, frac 0) — landing on a discrete altitude exactly as if
// they had clicked it, rather than holding the half-finished blend.
await boot(page);
const box = (await page.locator("#dial").boundingBox())!;
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
await page.mouse.move(cx, cy - 30);
await page.mouse.down();
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // ~0.5 detent — parks BETWEEN altitudes
// While held we are mid-blend: a fractional knob position.
await page.waitForFunction(
() => { const p = (window as any).__hefState().pos; return Math.abs(p - Math.round(p)) > 0.05; },
null, { timeout: 5000 },
);
await page.mouse.up();
// After release the position settles on an integer altitude (the snap).
await page.waitForFunction(
() => Number.isInteger((window as any).__hefState().pos),
null, { timeout: 10000 },
);
const st = await page.evaluate(() => (window as any).__hefState());
expect(Number.isInteger(st.pos)).toBe(true); // landed on an altitude, not held mid-blend
expect(st.ringIndex).toBe(st.pos); // committed altitude == the position it landed on
});
+36
View File
@@ -0,0 +1,36 @@
import { test, expect, Page } from "@playwright/test";
// The language dropdown localizes the visitor-facing chrome live (and resets to
// English on reload — selection is session-only, no persistence). Annotation
// re-rendering is covered by the unit tests (resolveStrings) + the manifest
// invariant test; here we assert the DOM behavior of the dropdown itself.
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);
}
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
await boot(page);
const sel = page.locator("#lang-select");
// English selected by default
await expect(sel).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
await sel.selectOption("es");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
await expect(page.locator("html")).toHaveAttribute("lang", "es");
// Japanese
await sel.selectOption("ja");
await expect(page.locator('[data-i18n="knobs.think"]')).toHaveText("考える");
// reload returns to English (session-only, no persistence)
await page.reload();
await expect(page.locator("#lang-select")).toHaveValue("en");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
});
+29
View File
@@ -0,0 +1,29 @@
import { test, expect } from "@playwright/test";
// Regression: the steady-state loop (#vid-loop) restarts from the morph-tail
// 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.
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
await page.goto("/");
await page.waitForFunction(
() => document.querySelector("#loading")?.classList.contains("done"),
null,
{ timeout: 45_000 },
);
const r = await page.evaluate(async () => {
const lv = document.getElementById("vid-loop") as HTMLVideoElement;
// Reproduce the stuck state: parked on the last frame, as after a missed wrap.
lv.pause();
lv.currentTime = Math.max(0, lv.duration - 0.001);
// The browser emits `ended` here in real life; emit it deterministically.
lv.dispatchEvent(new Event("ended"));
await new Promise((res) => setTimeout(res, 800));
return { dur: +lv.duration.toFixed(2), ct: +lv.currentTime.toFixed(2), paused: lv.paused };
});
// Recovered: playing again, and seeked back near the loop tail (not stuck at end).
expect(r.paused).toBe(false);
expect(r.ct).toBeLessThan(5);
});
+30
View File
@@ -0,0 +1,30 @@
import { test, expect } 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/";
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 expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
expect(apiCalls, "static build must not call /api/*").toEqual([]);
});
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(APP);
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
// crank the Feel (Right) knob to engage the dream, let a few frames render
await page.evaluate(() => {
const r = document.getElementById("right") as HTMLInputElement;
r.value = "4";
r.dispatchEvent(new Event("input", { bubbles: true }));
});
await page.waitForTimeout(2000);
// a tainted GL texture throws SecurityError on draw; assert none surfaced
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
});
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More