Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6bf970684 | |||
| 34fb48cdec | |||
| c8416ef2e9 | |||
| e666465d62 | |||
| 8c6778230b | |||
| 89cd6aa1dd | |||
| 04c8855ddc | |||
| da3581afea | |||
| 4f4f21a767 | |||
| 969236b3f7 | |||
| b0bd784305 | |||
| 04c3a133c3 | |||
| 028d02a033 | |||
| 08cc7c6355 | |||
| 9d42ed7fc4 | |||
| 88ebb07578 | |||
| 2f8641cb28 | |||
| f6b45b6cd7 | |||
| d161db8110 | |||
| ee7190bb4a | |||
| 6816e4ce75 | |||
| 5226a1ebd5 | |||
| 6695f32b74 | |||
| 7ec8b607d2 | |||
| b135cb5f5e | |||
| 950c1e6c8c | |||
| 958c3060d2 | |||
| c36f995463 | |||
| b63518bfbb | |||
| 4555bb29a8 | |||
| 8c63b14967 | |||
| ecd444b53b | |||
| fafb498d6f | |||
| e06191db32 | |||
| ccd40103a2 | |||
| 79f64e3f7c | |||
| 7cdf594794 | |||
| 5f9ae6b212 | |||
| 54cad607fb | |||
| 053bb5c1a3 | |||
| c176ae7231 | |||
| 6647390cb1 | |||
| e064a2e684 | |||
| df8b245af3 | |||
| 539618b1b8 | |||
| f077193df9 | |||
| 22bd152f33 | |||
| 5696b94328 | |||
| 8e790b4a95 | |||
| b9de6695a0 | |||
| 05328b1b5e | |||
| 1b646c8ade | |||
| 00fd9aab17 | |||
| 0334fc8bc4 | |||
| a4d3d831b0 | |||
| d711db6a2c | |||
| 17856cac32 | |||
| 34478bb3a3 | |||
| f7abb5c29b | |||
| 86ce291056 | |||
| e0964eae6c | |||
| 1a6ffc74a1 | |||
| 380c2d13e7 | |||
| ff06783173 | |||
| 87d605db39 | |||
| 75961ea18e | |||
| b83758fbca | |||
| 79105a3ef9 | |||
| 7c8c4b45cd | |||
| b9a52b1c41 | |||
| f1ce23c4fe | |||
| 0db602ebd0 | |||
| 717bf5b08b | |||
| ee7c65a0f2 | |||
| 6cf8fffc51 | |||
| 1f98b7bd8c | |||
| 6e2e65202e | |||
| fe77677d4c | |||
| a895e7139b | |||
| 8277417583 | |||
| d10ffca4e8 | |||
| a68d0bc4df | |||
| 7da944af0c | |||
| 768fee5ccd | |||
| 1c7f2b7785 | |||
| 3a45e338f3 | |||
| 8cc472c93c | |||
| bcae35b63e | |||
| a93c0d57c8 | |||
| 77cb04bb8c | |||
| 271586625c | |||
| be13248bdd | |||
| 7ef3e0ccc6 | |||
| 7a51d8f6d4 |
Executable
+51
@@ -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
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"worktree": {
|
||||
"bgIsolation": "worktree",
|
||||
"baseRef": "fresh"
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" notice"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" block"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+10
@@ -23,6 +23,16 @@ simulator/sample_media/**/*.m4a
|
||||
# Local-only candidate review galleries (re-buildable; not shipped content).
|
||||
simulator/static/review.html
|
||||
simulator/static/review_audio.html
|
||||
simulator/static/review_binaural.html
|
||||
simulator/static/review_music.html
|
||||
|
||||
# Editor annotation/comment-thread metadata (not project content)
|
||||
.threads/
|
||||
|
||||
# Static build outputs (tools/build_static.py)
|
||||
/dist/
|
||||
/dist-media/
|
||||
|
||||
# Playwright e2e artifacts (should never be committed)
|
||||
simulator/e2e/test-results/
|
||||
simulator/e2e/playwright-report/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Deploying to benstull.art — Cloudflare Pages + R2
|
||||
|
||||
Publishes the simulator as a fully-static site: the frontend on **Cloudflare
|
||||
Pages** (`benstull.art`, app at `/human-machine-strata`, apex redirects to
|
||||
it) and the video/audio on **R2** behind `static.benstull.art`. No origin server.
|
||||
|
||||
Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`.
|
||||
Plan: `docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`.
|
||||
|
||||
This project is **exempt from the flotilla/PPE/§9 pipeline** (operator-set) — it
|
||||
deploys directly to Cloudflare. Safety/hygiene rules still hold.
|
||||
|
||||
## Repeatable deploy (after the one-time setup below)
|
||||
|
||||
Once the bucket/CORS/custom-domains + `wrangler login` exist (one-time, §1–3), every
|
||||
later deploy is one command (`deploy/cloudflare/deploy.sh`, or the Make targets):
|
||||
|
||||
```bash
|
||||
make deploy # frontend only: build + Pages deploy (fast — most deploys)
|
||||
make deploy-media # also re-sync all media to R2 (~2 min, parallel) — when media changed
|
||||
./deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, skip Pages
|
||||
```
|
||||
|
||||
The script rebuilds `dist/`, (optionally) parallel-uploads `dist-media/` to R2, and
|
||||
deploys Pages — printing the live URL. It checks `wrangler` auth first and fails
|
||||
loudly. Override defaults via env: `BUCKET`, `PROJECT`, `MEDIA_BASE`, `APP_PATH`,
|
||||
`BRANCH`, `JOBS`. The sections below document the one-time setup the script assumes.
|
||||
|
||||
## Prerequisites (operator gestures — need Cloudflare auth)
|
||||
|
||||
`wrangler` is not installed in the dev box; the deploy runs from a machine with
|
||||
Cloudflare credentials.
|
||||
|
||||
```bash
|
||||
npm i -g wrangler
|
||||
wrangler login
|
||||
```
|
||||
|
||||
`benstull.art` is already a Cloudflare zone (no nameserver migration).
|
||||
|
||||
## 1. Build the artifacts
|
||||
|
||||
From the repo root (run as a MODULE — `tools/http.py` shadows stdlib `http` if run
|
||||
as a script):
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m tools.build_static
|
||||
```
|
||||
|
||||
Produces:
|
||||
- `dist/` — Pages output: `dist/human-machine-strata/` (frontend + baked
|
||||
`clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a
|
||||
root `dist/_redirects` (apex → app path, and no-slash → slashed).
|
||||
- `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only
|
||||
(clip bases + 154 transition morphs + 5 per-scale audio). No master/mezzanine
|
||||
sources.
|
||||
|
||||
## 2. R2 — bucket, CORS, custom domain, media
|
||||
|
||||
Bucket name: **`human-experience-simulator-media`**. The default `wrangler login`
|
||||
OAuth token already has R2 + Pages + zone scopes (no separate API token needed).
|
||||
|
||||
```bash
|
||||
wrangler r2 bucket create human-experience-simulator-media
|
||||
|
||||
# CORS — load-bearing (the WebGL dream shader taints without it). The wrangler CLI
|
||||
# wants its own schema, NOT the S3-style array; use the *.wrangler.json file:
|
||||
wrangler r2 bucket cors set human-experience-simulator-media --file deploy/cloudflare/r2-cors.wrangler.json
|
||||
# (deploy/cloudflare/r2-cors.json is the equivalent S3-style policy, for the
|
||||
# dashboard / S3 API; r2-cors.wrangler.json is the same rule in wrangler's
|
||||
# `{ "rules": [ { "allowed": {...} } ] }` form.)
|
||||
|
||||
# Custom domain — wrangler CAN do this (no dashboard needed); needs the zone id.
|
||||
ZONE=$(curl -s -H "Authorization: Bearer $(grep '^oauth_token' ~/.wrangler/config/default.toml | cut -d'"' -f2)" \
|
||||
"https://api.cloudflare.com/client/v4/zones?name=benstull.art" | python3 -c "import sys,json;print(json.load(sys.stdin)['result'][0]['id'])")
|
||||
wrangler r2 bucket domain add human-experience-simulator-media \
|
||||
--domain static.benstull.art --zone-id "$ZONE" --min-tls 1.2
|
||||
```
|
||||
|
||||
**Media upload — there is NO `wrangler r2 bucket sync`.** Upload per-file with
|
||||
`wrangler r2 object put ... --remote`, setting content-type + immutable cache. The
|
||||
URLs are content-hash-versioned (`?v=<hash>`), so `immutable` is safe — a re-baked
|
||||
clip gets a new hash → new URL → cache busts. ~604 files / ~2.3 GB, sequential:
|
||||
|
||||
```bash
|
||||
WR=$(command -v wrangler); BUCKET=human-experience-simulator-media
|
||||
find dist-media -type f | while read -r f; do
|
||||
key="${f#dist-media/}"
|
||||
case "${f##*.}" in mp4) ct=video/mp4;; mp3) ct=audio/mpeg;; json) ct=application/json;; *) ct=application/octet-stream;; esac
|
||||
"$WR" r2 object put "$BUCKET/$key" --file "$f" --remote \
|
||||
--content-type "$ct" --cache-control "public, max-age=31536000, immutable"
|
||||
done
|
||||
```
|
||||
|
||||
(If you have `rclone` + an R2 S3 API token, `rclone copy dist-media <remote>:bucket`
|
||||
is much faster than the loop.)
|
||||
|
||||
If you change the media base, rebuild with `--media-base https://<host>/` so
|
||||
`config.js` matches.
|
||||
|
||||
## 3. Pages — deploy + domain + redirect
|
||||
|
||||
```bash
|
||||
wrangler pages project create human-experience-simulator --production-branch main
|
||||
wrangler pages deploy dist --project-name human-experience-simulator --branch main
|
||||
```
|
||||
|
||||
Then **in the dashboard** (Workers & Pages → project → Custom domains) — there is
|
||||
no wrangler CLI for Pages custom domains:
|
||||
- Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind
|
||||
serves `522` until the cert/routing provisions.)
|
||||
- The app serves at `benstull.art/human-machine-strata/`; the shipped
|
||||
`dist/_redirects` sends `/` and the no-slash form to it.
|
||||
|
||||
## 4. Verify before/after going live
|
||||
|
||||
```bash
|
||||
# CORS + Range on the media origin (after step 2)
|
||||
curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
|
||||
https://static.benstull.art/<some>/base.mp4 | grep -iE "access-control-allow-origin|content-range|accept-ranges"
|
||||
```
|
||||
|
||||
Open `https://benstull.art/` → should redirect to
|
||||
`https://benstull.art/human-machine-strata/`. Turn the **Feel** knob to max
|
||||
and confirm the dream effect renders (no `SecurityError` in the console — that
|
||||
would mean CORS is misconfigured and the GL texture is tainted). The local
|
||||
static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same
|
||||
cross-origin path before you ship.
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Repeatable deploy of the HEF simulator to benstull.art (Cloudflare Pages + R2).
|
||||
#
|
||||
# deploy/cloudflare/deploy.sh # frontend only: build + Pages deploy (fast)
|
||||
# deploy/cloudflare/deploy.sh --media # also re-sync all media to R2 (~2 min, parallel)
|
||||
# deploy/cloudflare/deploy.sh --media-only # only the R2 media sync, no Pages deploy
|
||||
#
|
||||
# Assumes the ONE-TIME setup is already done (see README.md): wrangler logged in,
|
||||
# the R2 bucket + CORS + static.benstull.art custom domain, and the Pages project +
|
||||
# benstull.art custom domain. This script only does the repeatable part.
|
||||
#
|
||||
# Override any of these via env: BUCKET, PROJECT, MEDIA_BASE, APP_PATH, BRANCH, JOBS.
|
||||
set -euo pipefail
|
||||
|
||||
BUCKET="${BUCKET:-human-experience-simulator-media}"
|
||||
PROJECT="${PROJECT:-human-experience-simulator}"
|
||||
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
|
||||
APP_PATH="${APP_PATH:-/human-machine-strata}"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
JOBS="${JOBS:-10}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
OUT="$REPO_ROOT/dist"
|
||||
export MEDIA_OUT="$REPO_ROOT/dist-media"
|
||||
PY="${PY:-$REPO_ROOT/.venv/bin/python}"
|
||||
export WRANGLER="${WRANGLER:-$(command -v wrangler || echo "$HOME/.npm-global/bin/wrangler")}"
|
||||
|
||||
do_media=0
|
||||
do_pages=1
|
||||
case "${1:-}" in
|
||||
--media) do_media=1 ;;
|
||||
--media-only) do_media=1; do_pages=0 ;;
|
||||
"") ;;
|
||||
*) echo "unknown flag: $1 (use --media or --media-only)"; exit 2 ;;
|
||||
esac
|
||||
|
||||
[ -x "$WRANGLER" ] || { command -v "$WRANGLER" >/dev/null || { echo "wrangler not found ($WRANGLER) — npm i -g wrangler"; exit 1; }; }
|
||||
"$WRANGLER" whoami >/dev/null 2>&1 || { echo "not authenticated — run: $WRANGLER login"; exit 1; }
|
||||
|
||||
echo "▶ build ($MEDIA_BASE)"
|
||||
cd "$REPO_ROOT"
|
||||
"$PY" -m tools.build_static --out "$OUT" --media-out "$MEDIA_OUT" \
|
||||
--media-base "$MEDIA_BASE" --app-path "$APP_PATH" | tail -1
|
||||
|
||||
if [ "$do_media" = 1 ]; then
|
||||
echo "▶ R2 media sync → $BUCKET (parallel x$JOBS, no bulk-sync exists)"
|
||||
export BUCKET
|
||||
fails=$(find "$MEDIA_OUT" -type f -print0 \
|
||||
| xargs -0 -P "$JOBS" -I {} bash "$REPO_ROOT/deploy/cloudflare/r2-put-one.sh" {} \
|
||||
| tee /dev/stderr | grep -c '^FAIL ' || true)
|
||||
[ "$fails" = 0 ] || { echo "✘ $fails media uploads failed"; exit 1; }
|
||||
echo "✔ media synced"
|
||||
fi
|
||||
|
||||
if [ "$do_pages" = 1 ]; then
|
||||
echo "▶ Pages deploy → $PROJECT"
|
||||
"$WRANGLER" pages deploy "$OUT" --project-name "$PROJECT" --branch "$BRANCH" --commit-dirty=true | tail -3
|
||||
fi
|
||||
|
||||
echo "✔ done — https://benstull.art$APP_PATH/"
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+25
@@ -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
|
||||
@@ -9,8 +9,10 @@ model — random pick per landing, or pairable per-clip).
|
||||
The "audio + video" setting layers **two** kinds of audio per altitude:
|
||||
1. **Ambience / audio track** — the soundscape (waves, reef crackle, chorus, etc.).
|
||||
**This is what we're sourcing now.**
|
||||
2. **Music track** — a separate composed musical layer. **Deferred** until the
|
||||
ambience tracks are settled across all altitudes.
|
||||
2. **Music track** — a separate composed musical layer. **Shipped** (2026-07-01)
|
||||
as the Audio-dropdown **"Music"** option: unlike the earlier per-altitude idea,
|
||||
it is now **one single ethereal loop played the same at every altitude** (see
|
||||
§ Music below).
|
||||
|
||||
Pools below are the **ambience** layer unless marked `music`. **Distinction (operator):**
|
||||
the ambience/soundtrack layer is an *environmental, real-world soundscape* (waves, reef,
|
||||
@@ -48,6 +50,28 @@ recordings (NASA/NOAA) keep the "machines reshaping perception" thesis literal.
|
||||
| 🏖 coast | `coast/waves` | Sea waves + tern calls | BigSoundBank #0267 — https://bigsoundbank.com/sea-waves-and-seagulls-s0267.html (`/UPLOAD/mp3/0267.mp3`) | 57s | CC0 |
|
||||
| 🕳 abyss | `abyss/whale` | NE Pacific blue-whale "AB call" | NOAA PMEL — https://www.pmel.noaa.gov/acoustics/multimedia/NETS_bluWhale.wav | 13s | PD (NOAA) |
|
||||
|
||||
## Music (the Audio-dropdown "Music" option)
|
||||
|
||||
> **SUPERSEDED 2026-07-02:** the single ethereal loop below was replaced by a
|
||||
> **rotating crossfading playlist of mellow drumless synth** (Kevin MacLeod, CC BY) —
|
||||
> see `docs/music-candidate-pool.md`. The ethereal-loop record is kept for history.
|
||||
|
||||
### (history) ethereal loop — shipped 2026-07-01, then replaced
|
||||
|
||||
**One** ethereal ambient loop, played the same at every altitude (altitude-independent —
|
||||
NOT the per-altitude idea the "cosmos/music" / "Frozen Star" rows above reserved; those
|
||||
stay as historical candidates). The operator's brief: *ethereal, relaxing — like
|
||||
innerflo.me.* CC0 (operator's sourcing choice), so attribution is courtesy, not required.
|
||||
|
||||
| id | clip | source | dur | license |
|
||||
|---|---|---|---|---|
|
||||
| `music/ethereal` | **✓ KEEP** — "Ambient F Sharp Minor Ethereal Choir Pad 1": blurry, paulstretched layered choir — "peaceful, magical, soothing" | deadrobotmusic — https://freesound.org/people/deadrobotmusic/sounds/808032/ (hq preview: `cdn.freesound.org/previews/808/808032_*-hq.mp3`) | 3:38 | CC0 |
|
||||
|
||||
Production: `simulator/build_audio_media.py` `SOURCES["music"]` → seamless loudness-normalized
|
||||
loop at `audio/music/ethereal.loop.mp3`, served `/media/audio/music/ethereal.loop.mp3`
|
||||
(client fallback: `app.js` `MUSIC_FALLBACK`). Download the sound page's WAV/MP3 to
|
||||
`simulator/sample_media/audio/music/ethereal.mp3`, then `python simulator/build_audio_media.py`.
|
||||
|
||||
## Sourcing rule (learned 2026-06-26)
|
||||
|
||||
**Source only from a browsable page the operator can open and whose license is stated.**
|
||||
@@ -67,7 +91,10 @@ hubs: NASA SVS / Chandra, NOAA PMEL/SanctSound (PD), BigSoundBank (CC0), Pixabay
|
||||
- **reef** — ✅ round-4 6-layer bake (`reef/soundscape`): NOAA PD biological layers + a CC0
|
||||
submerged underwater "you're under" bed (DCSFX freesound #366159, hq preview). License clean.
|
||||
SanctSound clips: `files/SanctSound_*.mp4` at `https://sanctsound.ioos.us/files/` (extract audio).
|
||||
- **music layer** — separate composed music track per altitude, deferred (cosmos slot reserved).
|
||||
- **music layer** — ✅ SHIPPED 2026-07-01 as the Audio-dropdown "Music" option, but as
|
||||
ONE altitude-independent ethereal loop (`music/ethereal`, CC0), not the per-altitude
|
||||
track once envisioned. See § Music above. (The `cosmos/music` / "Frozen Star" rows are
|
||||
now historical per-altitude candidates, not planned work.)
|
||||
- **Production (after picks):** loop seams, length normalization, cross-altitude crossfade
|
||||
(mirror video); cosmos/abyss clips are short → loop.
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Binaural / entrainment candidate pool — stress-relief "Music" tracks (2026-07-02)
|
||||
|
||||
> **PARKED (2026-07-02):** the operator dropped the binaural direction in favor of
|
||||
> **mellow chordal synth music** — see `docs/music-candidate-pool.md`. This file is kept
|
||||
> as the record of the binaural exploration (and its NC-licensing dead-ends).
|
||||
|
||||
|
||||
Candidates for evolving the **Music** audio option into a stress-relief set of
|
||||
**binaural-beat / brainwave-entrainment** tracks that **crossfade between each
|
||||
other** (not a single loop). Operator brief: "binaural beats for stress relief."
|
||||
|
||||
**All 11 below are CC0** (public domain — no attribution legally required; we credit
|
||||
anyway as courtesy), **all stereo**, all sourced from freesound.org and license-
|
||||
verified by fetching each page. Review gallery (gitignored, local-only, like
|
||||
`review_audio.html`): `simulator/static/review_binaural.html` → open
|
||||
`/review_binaural.html` with the sim server running. It streams each track's public
|
||||
HQ preview so you can audition + tick keepers.
|
||||
|
||||
## Caveats (operator, read before picking)
|
||||
|
||||
- **True binaural (#1–7) needs headphones.** The beat *is* the L/R frequency
|
||||
difference, so on speakers it collapses to a steady carrier tone (no entrainment).
|
||||
**#8–11** are ambient sub-bass / pulsing drones that keep the calm feel on speakers.
|
||||
- **Band coverage:** heavy on **delta (0.5–1.5 Hz)** + one clean **theta (4 Hz, #2)**.
|
||||
**No CC0 alpha (8–12 Hz)** track surfaced — widen to CC-BY (FMA/ccMixter) if wanted.
|
||||
- **No clean CC0/CC-BY isochronic or monaural** (the speaker-correct entrainment type)
|
||||
was found — every hit was NC/SA or unlicensed (see rejects). For speaker playback,
|
||||
lean on #8–11 for the "beat" feel.
|
||||
- **Previews vs originals:** the `cdn.freesound.org/…-hq.mp3` URLs are public streamable
|
||||
previews (verified live, HTTP 206). The **originals** (WAV/AIF/full-MP3) need a free
|
||||
freesound login; CC0 permits self-hosting, so for keepers download the originals and
|
||||
host on R2 — do not hot-link the CDN in production.
|
||||
|
||||
## Candidates
|
||||
|
||||
| # | id | title | artist | source page | license | dur | type | beat / carrier |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 1 | `fs75309` | 60-64-delta | iantm | https://freesound.org/people/iantm/sounds/75309/ | CC0 | 0:30 | true-binaural | 4 Hz · 60/64 Hz |
|
||||
| 2 | `fs457654` | Binaural Beats (Theta Waves 4 Hz) | Fabrizio84 | https://freesound.org/people/Fabrizio84/sounds/457654/ | CC0 | 30:00 | true-binaural | 4 Hz theta |
|
||||
| 3 | `fs803742` | 1.23456789 Hz binaural beat (69L/70.23R) | itchytooth | https://freesound.org/people/itchytooth/sounds/803742/ | CC0 | 60:00 | true-binaural | 1.23 Hz delta · 69/70.23 Hz |
|
||||
| 4 | `fs414271` | Binaural Delta 440/440.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414271/ | CC0 | 10:00 | true-binaural | 0.5 Hz · 440/440.5 Hz |
|
||||
| 5 | `fs414272` | Binaural Delta 500/501.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414272/ | CC0 | 10:00 | true-binaural | 1.5 Hz · 500/501.5 Hz |
|
||||
| 6 | `fs414273` | Binaural Delta 52/52.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414273/ | CC0 | 10:00 | true-binaural | 0.5 Hz · 52/52.5 Hz |
|
||||
| 7 | `fs414274` | Binaural Delta 60/61.5 Hz | pbabin | https://freesound.org/people/pbabin/sounds/414274/ | CC0 | 10:00 | true-binaural | 1.5 Hz · 60/61.5 Hz |
|
||||
| 8 | `fs856225` | Delta Pulse Subharmonic Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856225/ | CC0 | 5:00 | ambient+beats (speaker-ok) | delta sub-bass (procedural) |
|
||||
| 9 | `fs854874` | Void Monolith Subharmonic Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/854874/ | CC0 | 5:00 | ambient (speaker-ok) | dark sub-drone |
|
||||
| 10 | `fs860122` | Expanding & Vibrating Stereo Pulsating Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/860122/ | CC0 | 2:00 | ambient+beats (speaker-ok) | slow stereo pulsation |
|
||||
| 11 | `fs493524` | Pulse Breath *(lower pref)* | phylobates | https://freesound.org/people/phylobates/sounds/493524/ | CC0 | 1:40 | ambient binaural | octaves of G / G♭ |
|
||||
|
||||
Preview MP3s (for the review page) follow `https://cdn.freesound.org/previews/<dir>/<id>_<uploader>-hq.mp3`
|
||||
— exact URLs are embedded in `review_binaural.html`.
|
||||
|
||||
## Round 2 — musical / evolving (the "not boring" set, 2026-07-02)
|
||||
|
||||
Operator found the pure-tone set (#1–7) too plain. **Reality:** truly *musical* tracks
|
||||
with a **confirmed** binaural layer are almost all NC (see rejects). Best legitimate
|
||||
options — pick for how they *sound*; treat binaural as a bonus:
|
||||
|
||||
| id | title | artist | source | license | dur | binaural? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `fs856223` | Theta Gateway Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856223/ | CC0 | 5:00 | capable (theta band; not certified on render) |
|
||||
| `fs856221` | Raga of Stillness Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856221/ | CC0 | 5:00 | capable (Δ/θ/α/β optional) |
|
||||
| `fs856220` | Ice Cathedral Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856220/ | CC0 | 5:00 | capable |
|
||||
| `fs856224` | Vowel of the Deep Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856224/ | CC0 | 5:00 | capable |
|
||||
| `fs856222` | Stellar Vowel Sacred Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856222/ | CC0 | 5:00 | capable |
|
||||
| `fs856329` | Harmonic Chamber High-Frequency Drone (MANTICE) | bassimat | https://freesound.org/people/bassimat/sounds/856329/ | CC0 | 5:00 | capable |
|
||||
| `fs854438` | Immersive Ethereal Generative Ambient (Floating Harmonics) | bassimat | https://freesound.org/people/bassimat/sounds/854438/ | CC0 | 11:33 | no (richest single piece) |
|
||||
| `fma_cosmic` | Cosmic Waves | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/cosmic-waves/ | CC0 | 33:04 | no (long-form melodic pads) |
|
||||
| `fma_dream` | DreamScape | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/dreamscape/ | CC0 | 21:59 | no |
|
||||
| `fma_rain` | Rain / Sleep / Meditation | HoliznaCC0 | https://freemusicarchive.org/music/holiznacc0/space-sleep-meditation/rain-sleep-meditation/ | CC0 | 14:35 | no (rain + pads) |
|
||||
| `ccm_shagrugge` | How to Get Rid of a Dream (real imagination mix) | shagrugge | http://dig.ccmixter.org/files/shagrugge/12629 | CC BY 3.0 | ~ | yes-ish (binaural-filtered vocals; experimental) |
|
||||
|
||||
- The MANTICE **Sacred Drone** series is the composed extension of Round-1 #8–10 (same
|
||||
generator; the generator *does* binaural entrainment across Δ/θ/α/β, but the individual
|
||||
freesound renders don't certify the beat is on — bassimat could confirm/render on request).
|
||||
- **HoliznaCC0** (FMA, CC0) = the strongest pure-*music* options; no binaural layer.
|
||||
- FMA `files.freemusicarchive.org/...` URLs verified live (206) — safe to self-host.
|
||||
The **ccMixter** shagrugge URL is **hotlink-protected** (403) — download in a browser and self-host.
|
||||
|
||||
### Opt-in (outside the CC0/CC-BY rule — needs operator OK)
|
||||
|
||||
- **"Hang Binaural" — Mindseye** — https://freemusicarchive.org/music/MindsEye/single/hang-binaural
|
||||
— **CC BY-SA 4.0**. The single best musical+*confirmed*-binaural fit (hang-drum music +
|
||||
real binaural beats, 3:36, stereo), but ShareAlike copyleft. Add only if SA is acceptable.
|
||||
|
||||
## Rejected / could not verify
|
||||
|
||||
- **archive.org "Binaural Beats – Alpha/Beta/Theta/Delta"** & **"Isochronic Tones &
|
||||
Binaural Beats Combined"** (loopool / Jean-Paul Garnier) — **CC BY-NC-SA 3.0** (NC).
|
||||
The most promising isochronic set — rejected for the NC clause.
|
||||
- **archive.org "Om Alpha Brainwave Entrainment Loop"** (Entraino / KS Chauhan;
|
||||
freesound chauhans/100370) — **CC BY-NC 3.0**. Rejected (NC).
|
||||
- **archive.org "1hr 30 Min Theta Binaural Beat (7 Hz)"** (Chari G) — no license shown.
|
||||
Rejected (unverifiable).
|
||||
- **freesound "deadthetawaves.wav"** (NoiseCollector/43295) — license fine (**CC BY 3.0**)
|
||||
but "evil sounding loop of the living dead" — rejected for *fit*, not license. Back
|
||||
pocket only if an unsettling deep-scale track is ever wanted.
|
||||
- **TunePocket / dialedggsound / miraclefrequencies / audio.com "Theta 7Hz"** —
|
||||
"royalty-free"/commercial pages, murky redistribution, no per-track CC license.
|
||||
- **Round 2 NC drops** (great content, non-commercial licence): "Fear Nothing (Relaxation
|
||||
Mix)" — Jeris (CC BY-NC), "On the Earth" — Quarkstar (CC BY-NC), "Timeless (Binaural
|
||||
Beatmix)" — duckett (CC BY-NC), "Ambient Meditation" — Scott Holmes (CC BY-NC). ccMixter's
|
||||
binaural tag is overwhelmingly NC; only shagrugge (CC BY) survived.
|
||||
|
||||
## Next
|
||||
|
||||
Operator auditions `review_binaural.html`, ticks keepers (Copy picks). Then we wire the
|
||||
playback: the **Music** source becomes a multi-track set that crossfades between the kept
|
||||
tracks (architecture decision — replace vs. join the ethereal loop — deferred to that
|
||||
step; see [[audio-music-option]]).
|
||||
@@ -0,0 +1,129 @@
|
||||
# Music candidate pool — mellow chordal synth (2026-07-02)
|
||||
|
||||
The chosen direction for the **Music** audio option: **mellow, soothing synth music with
|
||||
real chord movement** ("many chords" — progressions that evolve, not static drones or
|
||||
binaural tones). Supersedes the binaural exploration ([[binaural-candidate-pool]] — parked).
|
||||
We'll use **several** tracks and **crossfade between them** (not loop one).
|
||||
|
||||
All **CC0 or CC-BY** (verified by fetching each page + a live HTTP 206 check on every audio
|
||||
URL). Local review gallery (gitignored, like the other review pages):
|
||||
`simulator/static/review_music.html` → open `/review_music.html` with the sim server running.
|
||||
|
||||
**Honesty flag:** licenses + URLs are verified; the "harmony" notes are inferred from
|
||||
genre/reputation, not a by-ear check — audition before final selection.
|
||||
|
||||
## FINAL selection — WIRED 2026-07-02
|
||||
|
||||
After a Round-4 drumless pass, the operator's **final Music set = 5 Kevin MacLeod tracks**
|
||||
(CC BY 4.0), now **shipped as a rotating crossfading playlist**:
|
||||
|
||||
**Music for Manatees · Dewdrop Fantasy · Eastminster · Overheat · Concentration**
|
||||
|
||||
Wiring: `app.js` `MUSIC_PLAYLIST` — the Music source plays one track and every
|
||||
`MUSIC_ROTATE_MS` (5 min) crossfades to a random OTHER track (`pickNextIndex`, never an
|
||||
immediate repeat), altitude-independent, on the two `#aud`/`#aud-b` elements. Files
|
||||
loudness-normalized + transcoded under the 25MB LFS limit by `build_audio_media.py`
|
||||
(`MUSIC`), committed via LFS; the single ethereal loop was **retired**. Credited on
|
||||
`credits.html` (CC BY attribution). Earlier candidates below kept for record.
|
||||
|
||||
*(History: the operator first kept Dream Culture + Silver Blue Light, then — after the
|
||||
drumless Round-4 pass — chose the 5 long-form/floaty tracks above instead.)*
|
||||
|
||||
## Two license families
|
||||
|
||||
- **HoliznaCC0 = CC0** — zero attribution burden (simplest for the credits page).
|
||||
- **Kevin MacLeod = CC BY 4.0** — must credit, e.g. *"Chill Wave by Kevin MacLeod —
|
||||
incompetech.com — Licensed under Creative Commons: By Attribution 4.0."* The credits page
|
||||
already exists, so this is fine.
|
||||
- **Self-host everything** on R2 for the install — the `files.freemusicarchive.org` and
|
||||
`incompetech.com` URLs are live/hot-linkable now but shouldn't be depended on in prod.
|
||||
|
||||
## Round 4 — drumless, chord-moving (the refined set, 2026-07-02)
|
||||
|
||||
All **Kevin MacLeod, CC BY 4.0**, **drumless confirmed from his per-track `pieces.json`
|
||||
instrument metadata** (tonal instruments only — no drum kit/percussion), verified live (206).
|
||||
This is the authoritative candidate list; the lo-fi below (beat-driven) is dropped.
|
||||
|
||||
*(mp3 host `https://incompetech.com/music/royalty-free/mp3-royaltyfree/<Title>.mp3`; source page
|
||||
`https://incompetech.com/music/royalty-free/index.html?isrc=<code>`.)*
|
||||
|
||||
**Best matches — metered, chords clearly move** (safest bets, like the two keepers):
|
||||
| title | isrc | dur | instruments | harmony |
|
||||
|---|---|---|---|---|
|
||||
| Impact Lento | USUAN1100619 | 3:31 | Synths, Choir | "several striking chord progressions" |
|
||||
| Dreams Become Real | USUAN1500027 | 6:50 | Piano, Synths | dreamy, layered, evolving (closest to Dream Culture) |
|
||||
| Aretes | USUAN1100325 | 4:36 | Piano, Synths, Choir | warm major, clear motion |
|
||||
| When The Wind Blows | USUAN1100717 | 4:26 | Guitar, Synths | lilting, calm (Silver-Blue-Light recipe) |
|
||||
| Reawakening | USUAN1400017 | 3:34 | Piano, Cello | warm resolving progression |
|
||||
| Almost New | USUAN1800008 | 3:23 | Piano, Basses | gentle moving harmony (documented) |
|
||||
|
||||
**Long-form / floaty — drumless but slower-moving** (audition for "boring" risk):
|
||||
| title | isrc | dur | instruments | note |
|
||||
|---|---|---|---|---|
|
||||
| Music for Manatees | USUAN1400009 | 17:36 | Piano, EP, Strings, Synth | slow consonant shifts |
|
||||
| Clean Soul | USUAN1300033 | 5:07 | Electric Piano, Bass | spacious, sparser |
|
||||
| Soaring | USUAN1600040 | 6:20 | Synths | evolving pads, mystical-calm |
|
||||
| Dewdrop Fantasy | USUAN1700001 | 34:58 | Synths, Rain | slowly changing, lower density |
|
||||
| Eastminster | USUAN1100719 | 6:45 | Plucked Strings, Synths | drone + koto, some tension — least chordal |
|
||||
| Overheat | USUAN1100630 | 7:03 | Flute, Synth, Violin | trance-y minimalism |
|
||||
| Concentration | — | 29:53 | Synths, EP | floaty, lower density |
|
||||
|
||||
Reference: the kept **Dream Culture** is tagged "Piano, **Percussion**" (subtle beat); **Silver Blue
|
||||
Light** is "Guitar, Synths" (drumless). The Round-4 picks are strictly tonal-only. A ~40-track
|
||||
filtered pool of more drumless-calm-chordal KM tracks exists if a bigger rotation is wanted.
|
||||
|
||||
**Rejected this round (percussion / not soothing):** Angel Share (Drums), Dreamer (Percussion,
|
||||
bpm 100), Lightless Dawn (Percussion + unnerving), Anamalie (Percussion + dark), Chill Wave &
|
||||
all HoliznaCC0 lo-fi (beat-driven).
|
||||
|
||||
## Candidates — earlier mellow-synth round (lo-fi DROPPED, kept for record)
|
||||
|
||||
Ranked by chord movement.
|
||||
|
||||
### Lo-fi / dreamy — HoliznaCC0 (CC0)
|
||||
|
||||
| id | title | source page | mp3 (verified 206) | dur | harmony |
|
||||
|---|---|---|---|---|---|
|
||||
| `tokyo` | Tokyo Sunset | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/tokyo-sunset-lofi-peaceful-soft/ | .../Xnd9Hr5AVzB68IlWcImKtXPlwCePD2G2m8ZFSVj4.mp3 | 2:18 | jazzy extended-chord progression |
|
||||
| `reverie` | Dreamy Reverie | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/dreamy-reverie-lofi-nostalgic-chill/ | .../Bo2XWazV4kHpnPksjJGRRLr16EmOE44NZnMITZaB.mp3 | 2:23 | moving 7th/9th chords |
|
||||
| `complic` | Complicated Feelings | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/complicated-feelings-lofi-dreamy-relaxed/ | .../jEFkzWNxxtz76vjPoUOjZALHvdK3HkVMQAnGpgTn.mp3 | 2:39 | bittersweet changes |
|
||||
| `drift` | Peaceful Drift | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/peaceful-drift-lofi-nostalgic-calm/ | .../SQvtLguk6S1VSthv0oXWycoB6ipUS0pt8jzAxxPq.mp3 | 2:08 | calm nostalgic progression |
|
||||
| `currents`| Calm Currents | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/calm-currents-lofi-relax-calm/ | .../4rKapZUMNnNSPAOvpjlfSH6B5Ib8rgEWdvjnM7C6.mp3 | 2:27 | flowing chords |
|
||||
| `moon` | Moon Unit | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/moon-unit-lofi-reflection-dreamy/ | .../CbNZO1QUuJq1f50RHzZ5kykNj1hdqT04UaWOYSNf.mp3 | 2:52 | reflective dreamy chords |
|
||||
| `mist` | Into The Mist | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/into-the-mist-lofi-calm-relaxed/ | .../cI5iv9Xga0OOvGsS7mGZn9BlTshZdzAqLW7bmArA.mp3 | 2:09 | calm progression |
|
||||
| `wave` | Wave Maker | https://freemusicarchive.org/music/holiznacc0/public-domain-lofi/wave-maker-lofi-dreamy-retro/ | .../SbPJTWMnBmT5yBsXP4jLfp6RsvWLhfOnbw2PxXol.mp3 | 3:05 | warm analog retro chords |
|
||||
|
||||
*(mp3 host: `https://files.freemusicarchive.org/storage-freemusicarchive-org/tracks/`)*
|
||||
|
||||
### Calming synth — Kevin MacLeod (CC BY 4.0 — credit required)
|
||||
|
||||
| id | title | source page | mp3 (verified 206) | dur | harmony |
|
||||
|---|---|---|---|---|---|
|
||||
| `chillwave` | Chill Wave | https://archive.org/details/ChillWave_781 | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Chill%20Wave.mp3 | 3:57 | clear repeating 4-chord cycle |
|
||||
| `enchanted` | Enchanted Journey | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Enchanted%20Journey.mp3 | 4:29 | many changes, warm major |
|
||||
| `dreamcult` | Dream Culture | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Dream%20Culture.mp3 | 3:34 | evolving chord sequence |
|
||||
| `silverblue` | Silver Blue Light | https://freemusicarchive.org/music/Kevin_MacLeod/Calming | https://incompetech.com/music/royalty-free/mp3-royaltyfree/Silver%20Blue%20Light.mp3 | 5:50 | slow, deliberate changes |
|
||||
|
||||
### Spares
|
||||
|
||||
- **Clean Soul** — Kevin MacLeod — CC BY — 5:06 — `https://incompetech.com/music/royalty-free/mp3-royaltyfree/Clean%20Soul.mp3`
|
||||
- **Old Age** — HoliznaCC0 — CC0 — 3:33 — https://freemusicarchive.org/music/holiznacc0/only-in-the-milky-way-part-1/old-age/
|
||||
- **Deja Vu** — HoliznaCC0 — CC0 — 2:04 — https://freemusicarchive.org/music/holiznacc0/only-in-the-milky-way-part-1/deja-vu/
|
||||
- More Kevin MacLeod (all CC BY 4.0, incompetech): Dreamer, Reawakening, Lightless Dawn,
|
||||
Almost New, Angel Share. The whole **HoliznaCC0 "Public Domain Lofi"** album is 42 CC0
|
||||
tracks in this vibe — a ready-made rotation if wanted.
|
||||
|
||||
## Rejected / not used
|
||||
|
||||
- **Kevin MacLeod baroque/classical "public-domain" pieces** — the *composition* is PD but
|
||||
incompetech states the *recording* is CC BY 4.0; harpsichord/orchestral, off-brief anyway.
|
||||
- **NC finds** (great but non-commercial): Scott Holmes "Ambient Meditation" (CC BY-NC),
|
||||
ccMixter chordal ambient (Jeris/Quarkstar/duckett — CC BY-NC).
|
||||
- **"Hang Binaural" — Mindseye** — CC BY-SA (ShareAlike), excluded per the CC0/CC-BY rule.
|
||||
|
||||
## Next
|
||||
|
||||
Operator auditions `review_music.html`, ticks keepers (Copy picks). Then wire the **Music**
|
||||
source to a multi-track set that crossfades between the kept tracks (replaces the single
|
||||
ethereal loop; see [[audio-music-option]]). Kept CC-BY tracks get a credit line on
|
||||
`credits.html`; all kept files self-hosted on R2.
|
||||
@@ -0,0 +1,885 @@
|
||||
# Accessibility Pass + About Page Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make the simulator usable away from the attended kiosk — reduced-motion, seizure safety, keyboard/screen-reader access, AA contrast — and add an English-first `about.html`.
|
||||
|
||||
**Architecture:** All client-side in `simulator/static/`. One new pure helper (`flash.js`, UMD + node tests). One new page (`about.html`) mirroring `credits.html`. Behavior changes live in `app.js`; chrome/contrast in `style.css` and `index.html`. The Python static build (`tools/build_static.py`) copies the new static files.
|
||||
|
||||
**Tech Stack:** Vanilla JS (no framework, UMD modules like `i18n.js`/`credits.js`), CSS, Playwright e2e (`simulator/e2e/`), `node --test` for pure helpers, FastAPI dev server for e2e fixtures.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No engine/Python behavior changes — only `tools/build_static.py`'s copy list is touched.
|
||||
- New JS modules follow the existing **UMD** pattern (browser global + `module.exports`), like `i18n.js` and `credits.js`.
|
||||
- About page is **English-first**; i18n keys may be EN-only and fall back via `pickUiString` (returns `v.en`).
|
||||
- Target **WCAG 2.1 AA** (4.5:1 text / 3:1 large) plus WCAG 2.3.1 (≤3 flashes/sec) and 2.3.3 (no autonomous animation under reduced-motion).
|
||||
- Persist user toggles in `localStorage`, mirroring `DEV_KEY = "hef.devMode"` (`app.js:918`).
|
||||
- Reduced-motion toggle key: `RM_KEY = "hef.reduceMotion"`. Warning-gate dismissal key: `WARN_KEY = "hef.motionWarnDismissed"`.
|
||||
- `<html lang>` switching already exists (`applyUiStrings`, `app.js:1325`) — do NOT re-implement.
|
||||
- E2E in this env: start uvicorn by hand with the venv `python` (only `python3` exists on PATH) and run Playwright with `reuseExistingServer`. `loop-recovery.spec.ts` is known-red on a clean baseline (boots without video).
|
||||
- Direction convention: descend = `+1` (cosmos→abyss), matching wheel-down (`app.js:703`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Output-panel layout — language picker below Audio, globe inline-left
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html:37-51` (Output fieldset)
|
||||
- Modify: `simulator/static/style.css` (add `.lang-pick` rule)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: the `#lang-select` element ends up after `#audio` in DOM order; `.lang-pick` is a flex row.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Create `simulator/e2e/tests/a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("language picker sits below the Audio control", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const audio = page.locator("#audio");
|
||||
const lang = page.locator("#lang-select");
|
||||
await expect(audio).toBeVisible();
|
||||
await expect(lang).toBeVisible();
|
||||
const aBox = await audio.boundingBox();
|
||||
const lBox = await lang.boundingBox();
|
||||
expect(lBox!.y).toBeGreaterThan(aBox!.y); // lang is rendered lower than audio
|
||||
});
|
||||
|
||||
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const pick = page.locator(".lang-pick");
|
||||
const select = page.locator("#lang-select");
|
||||
const pBox = await pick.boundingBox();
|
||||
const sBox = await select.boundingBox();
|
||||
// select starts to the right of the label's left edge, and shares its row (height ~ one line)
|
||||
expect(sBox!.x).toBeGreaterThan(pBox!.x);
|
||||
expect(pBox!.height).toBeLessThan(40);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify the first test fails**
|
||||
|
||||
Start the dev server (separate shell, from `simulator/`):
|
||||
`../.venv/bin/python -m uvicorn server.app:app --port 8000` (adjust to the project's venv/module path).
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "below the Audio"`
|
||||
Expected: FAIL (lang currently renders above audio).
|
||||
|
||||
- [ ] **Step 3: Move the language picker in `index.html`**
|
||||
|
||||
In the Output `<fieldset>`, delete the `<label class="lang-pick">…</label>` block from its current spot (above the Video toggle) and re-insert it as the LAST child of the fieldset, after the Audio `<label class="audio-level">`. Result order: legend → Video toggle → Audio level → language picker.
|
||||
|
||||
- [ ] **Step 4: Add the `.lang-pick` flex rule in `style.css`**
|
||||
|
||||
Add near the other panel rules:
|
||||
|
||||
```css
|
||||
/* Globe + language select on one row (the select no longer goes full-width here). */
|
||||
.lang-pick { display: flex; align-items: center; gap: 0.4rem; margin: 0.4rem 0; }
|
||||
.lang-pick select { flex: 1; width: auto; }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run both layout tests to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "language picker|globe"`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/style.css simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): move language picker below Audio, globe inline-left"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Low-vision contrast + global focus rings + visually-hidden utility
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/style.css` (color bumps, `:focus-visible`, `.visually-hidden`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: a `.visually-hidden` utility class used by Task 4 (gate) and Task 7 (aria-live).
|
||||
|
||||
- [ ] **Step 1: Bump failing text colors to AA**
|
||||
|
||||
In `style.css`, change these declarations (values chosen to clear 4.5:1 on the dark backgrounds; verify with a contrast check in Step 3):
|
||||
|
||||
```css
|
||||
/* was #789 — too low on #111 */
|
||||
.hint { color: #9fb3c8; }
|
||||
/* dial labels/captions were #789ac0 / #4d6184 on #0d1320 */
|
||||
.dial-label { fill: #b8cfe6; }
|
||||
.dial-caption { fill: #8fa6c4; }
|
||||
```
|
||||
|
||||
(Keep every other property on those selectors unchanged — edit only the color/fill.)
|
||||
|
||||
- [ ] **Step 2: Add focus-visible + visually-hidden utilities**
|
||||
|
||||
```css
|
||||
/* Visible keyboard focus for every interactive element (was only on .dev-switch). */
|
||||
:focus-visible { outline: 2px solid #9af; outline-offset: 2px; }
|
||||
#dial:focus-visible { outline-offset: 4px; }
|
||||
/* Screen-reader-only text (announcements, labels) — present in a11y tree, off-screen. */
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify contrast**
|
||||
|
||||
Manually confirm with any WCAG contrast tool that `#9fb3c8` on `#111`, `#b8cfe6` on `#0d1320`, and `#8fa6c4` on `#0d1320` each meet ≥4.5:1 (≥3:1 acceptable for the dial caption if it reads as large). Adjust lighter if any fails.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/style.css
|
||||
git commit -m "feat(a11y): AA contrast bumps, global focus-visible, visually-hidden util"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `flash.js` — pure flash-clamp helper + node tests
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/flash.js`
|
||||
- Test: `simulator/static/flash.test.js` (new, `node --test`)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `HEFFlash.minSafeDurationMs(count)` → minimum total ms for `count` luminance transitions to stay ≤3/sec; `HEFFlash.clampDurationMs(requestedMs, count)` → `max(requestedMs, minSafeDurationMs(count))`. Consumed by Task 8 (audit).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `simulator/static/flash.test.js`:
|
||||
|
||||
```js
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const F = require("./flash.js");
|
||||
|
||||
test("minSafeDurationMs: N transitions need N/3 seconds", () => {
|
||||
assert.strictEqual(F.minSafeDurationMs(3), 1000); // 3 flashes in >=1s
|
||||
assert.strictEqual(F.minSafeDurationMs(6), 2000);
|
||||
assert.strictEqual(F.minSafeDurationMs(0), 0);
|
||||
assert.strictEqual(F.minSafeDurationMs(1), 1000 / 3);
|
||||
});
|
||||
|
||||
test("clampDurationMs: stretches only when too fast", () => {
|
||||
assert.strictEqual(F.clampDurationMs(2000, 3), 2000); // already safe
|
||||
assert.strictEqual(F.clampDurationMs(200, 3), 1000); // too fast → clamped up
|
||||
assert.strictEqual(F.clampDurationMs(500, 1), 500); // single transition, slow enough
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run it to verify it fails**
|
||||
|
||||
Run: `cd simulator/static && node --test flash.test.js`
|
||||
Expected: FAIL ("Cannot find module './flash.js'").
|
||||
|
||||
- [ ] **Step 3: Implement `flash.js`**
|
||||
|
||||
```js
|
||||
// Pure photosensitivity helper. WCAG 2.3.1: no more than 3 general flashes
|
||||
// (luminance transitions) per second. Given a transition COUNT, returns the
|
||||
// minimum total duration that keeps the rate at or below 3/sec, and a clamp
|
||||
// that only ever slows a transition down. UMD: browser `HEFFlash` + require().
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFFlash = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
const MAX_PER_SEC = 3;
|
||||
function minSafeDurationMs(count) {
|
||||
const n = Math.max(0, Number(count) || 0);
|
||||
return (n / MAX_PER_SEC) * 1000;
|
||||
}
|
||||
function clampDurationMs(requestedMs, count) {
|
||||
return Math.max(Number(requestedMs) || 0, minSafeDurationMs(count));
|
||||
}
|
||||
return { MAX_PER_SEC, minSafeDurationMs, clampDurationMs };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify pass**
|
||||
|
||||
Run: `cd simulator/static && node --test flash.test.js`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/flash.js simulator/static/flash.test.js
|
||||
git commit -m "feat(a11y): flash.js pure WCAG 2.3.1 flash-clamp helper + tests"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Photosensitivity warning gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html` (gate markup inside `.screen`)
|
||||
- Modify: `simulator/static/style.css` (gate styles)
|
||||
- Modify: `simulator/static/app.js` (gate logic around `run-sim` reveal, `app.js:1297`)
|
||||
- Modify: `simulator/static/i18n.js` (gate strings)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `WARN_KEY` localStorage flag; `maybeShowMotionWarning()` called before `run-sim` is revealed.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("motion warning gate shows once, then is remembered", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const gate = page.locator("#motion-warning");
|
||||
await expect(gate).toBeVisible();
|
||||
await page.locator("#motion-warning-continue").click();
|
||||
await expect(gate).toBeHidden();
|
||||
// Reload: dismissal persisted, gate stays hidden, run-sim is the entry point.
|
||||
await page.reload();
|
||||
await expect(page.locator("#motion-warning")).toBeHidden();
|
||||
await expect(page.locator("#run-sim")).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
|
||||
Expected: FAIL (no `#motion-warning`).
|
||||
|
||||
- [ ] **Step 3: Add gate markup in `index.html`**
|
||||
|
||||
Inside `<div class="screen">`, after the `#run-sim` button:
|
||||
|
||||
```html
|
||||
<div id="motion-warning" class="motion-warning hidden" role="dialog" aria-modal="true" aria-labelledby="mw-title">
|
||||
<div class="mw-card">
|
||||
<h2 id="mw-title" data-i18n="warn.title">Heads up — motion & flashing</h2>
|
||||
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin.</p>
|
||||
<button type="button" id="motion-warning-continue" class="run-sim" data-i18n="warn.continue">Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add gate styles in `style.css`**
|
||||
|
||||
```css
|
||||
.motion-warning { position: absolute; inset: 0; z-index: 70; display: flex;
|
||||
align-items: center; justify-content: center; padding: 1rem;
|
||||
background: rgba(2, 4, 10, 0.92); }
|
||||
.motion-warning.hidden { display: none; }
|
||||
.mw-card { max-width: 30rem; text-align: center; color: #dfeaff; }
|
||||
.mw-card h2 { font-size: 1.1rem; margin: 0 0 0.6rem; }
|
||||
.mw-card p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 1.2rem; color: #b9c8e0; }
|
||||
.mw-card .run-sim { position: static; transform: none; }
|
||||
.mw-card .run-sim:hover { transform: scale(1.04); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add i18n strings in `i18n.js`**
|
||||
|
||||
Add to `UI_STRINGS` (EN required; other langs may be added later, fallback is EN):
|
||||
|
||||
```js
|
||||
"warn.title": { en: "Heads up — motion & flashing" },
|
||||
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin." },
|
||||
"warn.continue": { en: "Continue" },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Wire gate logic in `app.js`**
|
||||
|
||||
Add near `DEV_KEY` (`app.js:918`):
|
||||
|
||||
```js
|
||||
const WARN_KEY = "hef.motionWarnDismissed";
|
||||
function motionWarnDismissed() {
|
||||
try { return localStorage.getItem(WARN_KEY) === "1"; } catch (_) { return false; }
|
||||
}
|
||||
function maybeShowMotionWarning() {
|
||||
const gate = $("motion-warning");
|
||||
if (!gate) return;
|
||||
if (motionWarnDismissed()) { gate.classList.add("hidden"); return; }
|
||||
gate.classList.remove("hidden");
|
||||
$("motion-warning-continue").addEventListener("click", () => {
|
||||
try { localStorage.setItem(WARN_KEY, "1"); } catch (_) {}
|
||||
gate.classList.add("hidden");
|
||||
$("motion-warning-continue").focus({ preventScroll: true });
|
||||
$("run-sim").focus({ preventScroll: true });
|
||||
}, { once: true });
|
||||
}
|
||||
```
|
||||
|
||||
In `main()`, immediately after `$("run-sim").classList.remove("hidden");` (`app.js:1297`), add:
|
||||
|
||||
```js
|
||||
maybeShowMotionWarning(); // photosensitivity notice on first visit (over the stage)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the gate test to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "motion warning"`
|
||||
Expected: PASS. (If `localStorage` carries across tests, the test clears it via `page.goto` fresh context — Playwright uses a fresh context per test by default.)
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/style.css simulator/static/app.js simulator/static/i18n.js simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): one-time photosensitivity warning gate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Reduced-motion freeze-to-stills + toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html` (toggle in Output fieldset)
|
||||
- Modify: `simulator/static/i18n.js` (toggle label)
|
||||
- Modify: `simulator/static/app.js` (`reduceMotion` state, freeze logic, `autoScrub` instant path)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `reduceMotion` boolean; `isReduced()` accessor used by `autoScrub` (Task 5) and dial keyboard (Task 6); `applyReduceMotion()` pauses/resumes playback.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("reduce-motion toggle pauses video playback", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Dismiss the gate and start the experience so video is playing.
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
await page.locator("#run-sim").click();
|
||||
await page.waitForTimeout(400);
|
||||
await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(false);
|
||||
await page.locator("#reduce-motion").check();
|
||||
await expect.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused)).toBe(true);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
|
||||
Expected: FAIL (no `#reduce-motion`).
|
||||
|
||||
- [ ] **Step 3: Add the toggle markup in `index.html`**
|
||||
|
||||
In the Output fieldset, after the Audio level label (and before the language picker added in Task 1), add a switch mirroring the existing `.dev-switch`:
|
||||
|
||||
```html
|
||||
<label class="dev-switch" for="reduce-motion">
|
||||
<input type="checkbox" id="reduce-motion" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the i18n label in `i18n.js`**
|
||||
|
||||
```js
|
||||
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add `reduceMotion` state + freeze logic in `app.js`**
|
||||
|
||||
Near `DEV_KEY`/`WARN_KEY`:
|
||||
|
||||
```js
|
||||
const RM_KEY = "hef.reduceMotion";
|
||||
let reduceMotion = false;
|
||||
function isReduced() { return reduceMotion; }
|
||||
function initReduceMotion() {
|
||||
const box = $("reduce-motion");
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
|
||||
// Default to the OS setting when the user hasn't chosen yet.
|
||||
const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
reduceMotion = stored === null ? !!prefers : stored === "1";
|
||||
if (box) {
|
||||
box.checked = reduceMotion;
|
||||
box.addEventListener("change", () => {
|
||||
reduceMotion = box.checked;
|
||||
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
|
||||
applyReduceMotion();
|
||||
});
|
||||
}
|
||||
applyReduceMotion();
|
||||
}
|
||||
function applyReduceMotion() {
|
||||
if (reduceMotion) {
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
vid.pause();
|
||||
loopVid.pause();
|
||||
} else if (videoEverOn && $("visual").checked) {
|
||||
playLoop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Make `autoScrub` instant under reduced motion**
|
||||
|
||||
At the top of `autoScrub` (`app.js:678`), after the `if (!ring …) return;` guard, add:
|
||||
|
||||
```js
|
||||
if (isReduced()) { // no autonomous tween — jump straight to the target
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
setPos(targetPos);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Keep playback paused when reduced-motion is on after landing**
|
||||
|
||||
In `beginExperience()` (`app.js:1250`) and wherever `playLoop()` is called on a settle, guard the play with `if (!isReduced()) playLoop();` — specifically wrap the settle-time `playLoop()` at `app.js:850` and the `beginExperience` start so turning the experience on while reduced does not animate. (Inspect each `playLoop()` call site; guard the autonomous ones, leave the explicit toggle-off resume in `applyReduceMotion`.)
|
||||
|
||||
- [ ] **Step 8: Call `initReduceMotion()` in `main()`**
|
||||
|
||||
After `initLanguage();` (`app.js:1266`) add:
|
||||
|
||||
```js
|
||||
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Run the reduced-motion test to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "reduce-motion toggle"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/i18n.js simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): reduced-motion freeze-to-stills with OS-default toggle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Keyboard + ARIA for the Altitude dial
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html:56` (dial svg attrs)
|
||||
- Modify: `simulator/static/app.js` (keydown handler, aria-value sync, label buttons)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `autoScrub`, `jumpToScale`, `ring`, `ringIndex`, `dialStep` (all in `app.js`).
|
||||
- Produces: `setDialAria()` updates `aria-valuenow/valuetext`; called wherever the needle settles.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("altitude dial is keyboard-operable", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
const dial = page.locator("#dial");
|
||||
await expect(dial).toHaveAttribute("role", "slider");
|
||||
await dial.focus();
|
||||
const before = await page.locator("#scale-name").textContent();
|
||||
await dial.press("ArrowDown"); // descend one altitude
|
||||
await expect.poll(async () => page.locator("#scale-name").textContent()).not.toBe(before);
|
||||
await dial.press("Home"); // jump to cosmos (top)
|
||||
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
|
||||
Expected: FAIL (dial has no `role`/key handling).
|
||||
|
||||
- [ ] **Step 3: Add ARIA attrs to the dial in `index.html`**
|
||||
|
||||
Change line 56 to:
|
||||
|
||||
```html
|
||||
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
|
||||
aria-label="Altitude — turn to change scale"
|
||||
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `setDialAria()` and call it on settle**
|
||||
|
||||
In `app.js`, add:
|
||||
|
||||
```js
|
||||
// Reflect the committed altitude into the dial's slider semantics for AT.
|
||||
function setDialAria() {
|
||||
if (!dial || !ring) return;
|
||||
dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
|
||||
dial.setAttribute("aria-valuenow", String(ringIndex));
|
||||
const s = ring.scales[ringIndex];
|
||||
if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
|
||||
}
|
||||
```
|
||||
|
||||
Call `setDialAria()` at the settle point in `setPos` where `setNeedle(ringIndex * dialStep())` runs on frac 0 (`app.js:852`), and once in `buildDial()` after the dial is drawn.
|
||||
|
||||
- [ ] **Step 5: Add the keydown handler**
|
||||
|
||||
```js
|
||||
function onDialKey(e) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break;
|
||||
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break;
|
||||
case "Home": jumpToScale(0); break;
|
||||
case "End": jumpToScale(ring.scales.length - 1); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) e.preventDefault();
|
||||
}
|
||||
```
|
||||
|
||||
Wire it in `main()` next to the other dial listeners (`app.js:1289`):
|
||||
|
||||
```js
|
||||
dial.addEventListener("keydown", onDialKey);
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Make the dial labels keyboard-activatable**
|
||||
|
||||
In `buildDial()` (where each `.dial-label` is created, ~`app.js:749`), add to the label's attributes: `role: "button"`, `tabindex: "0"`, and an `aria-label` of the scale name. Then in `main()` add a delegated keydown on the dial that activates a focused label:
|
||||
|
||||
```js
|
||||
dial.addEventListener("keydown", (e) => {
|
||||
const t = e.target;
|
||||
if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
jumpToScale(+t.getAttribute("data-index"));
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the keyboard test to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "keyboard-operable"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): keyboard + ARIA slider semantics for the Altitude dial"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Screen-reader narration (aria-live) + hide decorative SVG
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/index.html` (aria-live region; `aria-hidden` on decorative layers)
|
||||
- Modify: `simulator/static/app.js` (announce on settle, throttled)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `activeClipId`, the clip's resolved strings, `ring`, `ringIndex`.
|
||||
- Produces: `announce(text)`; `decorative SVGs are aria-hidden`.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("an aria-live region narrates the current scale", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
const live = page.locator("#sr-status");
|
||||
await expect(live).toHaveAttribute("aria-live", "polite");
|
||||
await page.locator("#dial").focus();
|
||||
await page.locator("#dial").press("Home");
|
||||
await expect.poll(async () => (await live.textContent())?.toLowerCase()).toContain("cosmos");
|
||||
});
|
||||
|
||||
test("decorative overlay SVGs are hidden from AT", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
|
||||
await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Add the live region + aria-hidden in `index.html`**
|
||||
|
||||
Add `aria-hidden="true"` to `#overlay`, `#affect`, `#tint`, `#paint`. Add inside `.panel` (top, after the opening tag) a live region:
|
||||
|
||||
```html
|
||||
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add `announce()` + call on settle in `app.js`**
|
||||
|
||||
```js
|
||||
let lastAnnounced = "";
|
||||
function announce(text) {
|
||||
const el = $("sr-status");
|
||||
if (!el || !text || text === lastAnnounced) return;
|
||||
lastAnnounced = text;
|
||||
el.textContent = text;
|
||||
}
|
||||
// Build the spoken summary: scale + top factual (left) label for the active clip.
|
||||
function announceState() {
|
||||
if (!ring) return;
|
||||
const s = ring.scales[ringIndex];
|
||||
const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
|
||||
const clip = activeClip && activeClip(); // use existing accessor for the locked clip
|
||||
let label = "";
|
||||
if (clip) {
|
||||
const strings = HEFi18n.resolveStrings(clip.strings, activeLang);
|
||||
label = firstFactualLabel(strings) || "";
|
||||
}
|
||||
announce(label ? `${scaleName}. ${label}` : scaleName);
|
||||
}
|
||||
```
|
||||
|
||||
If no `activeClip()` accessor exists, read the locked clip via the existing `activeClipId` lookup used in `renderScaleReadout` (mirror that code). `firstFactualLabel` returns the first non-empty left/`LABELS`-style string from `strings`; if the shape makes this awkward, announce just `scaleName` (degrade gracefully — the scale name is the load-bearing part).
|
||||
|
||||
Call `announceState()` at the same settle point as `setDialAria()` (frac 0 in `setPos`) and at the end of `setLanguage()` so a language switch re-announces.
|
||||
|
||||
- [ ] **Step 5: Run the narration tests to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "aria-live|decorative"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/app.js simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(a11y): aria-live narration of scale/label; hide decorative SVGs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Apply the flash audit to real transitions
|
||||
|
||||
**Files:**
|
||||
- Modify: `simulator/static/app.js` and/or `simulator/static/style.css` (clamp the audited transitions)
|
||||
- Modify: `simulator/static/index.html` (load `flash.js` before `app.js`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFFlash.clampDurationMs` (Task 3).
|
||||
|
||||
- [ ] **Step 1: Load `flash.js` in `index.html`**
|
||||
|
||||
Add before `app.js` (after `i18n.js`, `index.html:117`):
|
||||
|
||||
```html
|
||||
<script src="flash.js"></script>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Audit + enumerate the rapid-luminance transitions**
|
||||
|
||||
Inspect the three sources and record findings as a comment in `app.js` above the change:
|
||||
1. Fast-spin blended dial pass — `autoScrub` total `ms = perAltMs * |dist|`; the per-altitude floor is 1200ms (well under 3/sec for one step), so a multi-step spin is already ≥3 transitions over ≥N×1.2s = safe. Confirm and note.
|
||||
2. `#black` cover fade — `style.css` `transition: opacity 200ms`; a single fade is one transition, not a repeated flash — safe.
|
||||
3. Audio-coupled crossfade — visual? If it drives an opacity swap, check its duration.
|
||||
|
||||
For any source that CAN repeat faster than 3/sec (e.g. a rapid wheel/keyboard repeat firing `autoScrub` back-to-back), clamp using `HEFFlash`:
|
||||
|
||||
```js
|
||||
// Guard against a fast key/wheel repeat producing >3 luminance swings/sec.
|
||||
const safeMs = HEFFlash.clampDurationMs(PER_ALTITUDE_MS, 1);
|
||||
```
|
||||
|
||||
If the audit finds NO breach (the likely outcome given the 1200ms floor), record that conclusion in the comment and make no timing change beyond loading `flash.js` for the helper's availability — do not invent a clamp that isn't needed (YAGNI). The deliverable of this task is the documented audit + `flash.js` wired in.
|
||||
|
||||
- [ ] **Step 3: Run existing e2e to confirm no regression**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts`
|
||||
Expected: PASS (12 tests, per project memory).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/index.html simulator/static/app.js
|
||||
git commit -m "feat(a11y): wire flash-clamp helper; document 3-flash/sec audit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: about.html + header link + static-build copy
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/about.html`
|
||||
- Modify: `simulator/static/index.html:18` (header link)
|
||||
- Modify: `simulator/static/i18n.js` (about link label)
|
||||
- Modify: `tools/build_static.py:38` (add `about.html`, `flash.js` to `PUBLIC_ASSETS`)
|
||||
- Test: `simulator/e2e/tests/a11y.spec.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `.credits-page` / `.credits-wrap` / `.back-link` styles.
|
||||
|
||||
- [ ] **Step 1: Write the failing e2e test**
|
||||
|
||||
Append to `a11y.spec.ts`:
|
||||
|
||||
```ts
|
||||
test("about page loads and links back to the experience", async ({ page }) => {
|
||||
await page.goto("/about.html");
|
||||
await expect(page.locator("h1")).toContainText(/about/i);
|
||||
await expect(page.getByText(/imperfect/i)).toBeVisible();
|
||||
await page.locator(".back-link").click();
|
||||
await expect(page).toHaveURL(/index\.html|\/$/);
|
||||
});
|
||||
|
||||
test("header links to the about page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('a[href="about.html"]')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
|
||||
Expected: FAIL (no about.html / link).
|
||||
|
||||
- [ ] **Step 3: Create `about.html`**
|
||||
|
||||
Mirror `credits.html` structure exactly (body `class="credits-page"`, `main.credits-wrap`, back-link, `config.js`):
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>About — Human Experience Filter</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="credits-page">
|
||||
<main class="credits-wrap">
|
||||
<header>
|
||||
<h1>About this work</h1>
|
||||
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>For everyone</h2>
|
||||
<p>The <em>Human Experience Filter</em> is built to be accessible to — and
|
||||
representative of — the full range of human experience. That is why it works
|
||||
with a keyboard, with a screen reader, with reduced motion, and away from any
|
||||
single screen or kiosk: an experience about being human should be open to as
|
||||
many humans as possible.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Places technology let us see</h2>
|
||||
<p>The scales you move through — the deep sea, the coast, the sky, the orbit
|
||||
of the Earth, the wider cosmos — are vantage points no unaided human could
|
||||
ever witness. We can share them only because our tools evolved to reach them:
|
||||
from <strong>underwater exploration</strong>, to <strong>drones and aerial
|
||||
imaging</strong>, to <strong>space exploration</strong>. Each scale is a place
|
||||
a machine went first so that a person could feel what it is like to be there.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Built with LLMs</h2>
|
||||
<p>This piece was itself built using large language models — an extension of
|
||||
that same long arc of tools that widen what a single human can reach. The
|
||||
software, the words, and much of the craft were shaped in collaboration with
|
||||
a machine.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Honest about its limits</h2>
|
||||
<p>This implementation is imperfect. It is also more than I could have made
|
||||
alone: better with these tools assisting than by my hand as a single human.
|
||||
That tension is part of the point — technology does not replace the person
|
||||
behind the work; it extends their reach, flaws and all.</p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="config.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the header link in `index.html`**
|
||||
|
||||
After the existing credits link (`index.html:18`), add:
|
||||
|
||||
```html
|
||||
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add the i18n label in `i18n.js`**
|
||||
|
||||
```js
|
||||
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add the new files to the static build**
|
||||
|
||||
In `tools/build_static.py:38`, extend `PUBLIC_ASSETS`:
|
||||
|
||||
```python
|
||||
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css",
|
||||
"credits.html", "credits.js", "about.html", "flash.js"]
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run the about-page tests to verify pass**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts -g "about page|header links"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Verify the static build includes the new files**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test static-build.spec.ts` (or run `python3 tools/build_static.py` and confirm `dist/.../about.html` + `flash.js` exist).
|
||||
Expected: PASS / files present.
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/about.html simulator/static/index.html simulator/static/i18n.js tools/build_static.py simulator/e2e/tests/a11y.spec.ts
|
||||
git commit -m "feat(content): about.html — intent, provenance, honest limits; static-build copy"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Full-suite green + finish
|
||||
|
||||
**Files:** none (verification).
|
||||
|
||||
- [ ] **Step 1: Run the full e2e a11y spec**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test a11y.spec.ts`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 2: Run the node helper tests**
|
||||
|
||||
Run: `cd simulator/static && node --test flash.test.js`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Run the existing suites that should stay green**
|
||||
|
||||
Run: `cd simulator/e2e && npx playwright test altitude-lock.spec.ts i18n.spec.ts static-build.spec.ts`
|
||||
Expected: PASS. (`loop-recovery.spec.ts` is known-red on a clean baseline here — do not block on it, but confirm it is no MORE broken.)
|
||||
Run: `python3 -m pytest` from repo root for the Python suite.
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Final review + branch finish**
|
||||
|
||||
Invoke `superpowers:finishing-a-development-branch` to choose merge/PR. Do NOT auto-merge to main without the operator's go (per session posture).
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:** A1 reduced-motion → Task 5; A2 gate → Task 4, audit → Tasks 3+8; B keyboard/focus → Tasks 2+6; C contrast → Task 2; D SR narration → Task 7; E about.html → Task 9; F layout → Task 1. `<html lang>` correctly omitted (already done). All covered.
|
||||
|
||||
**Placeholder scan:** Code shown for every code step. Task 7's `firstFactualLabel`/`activeClip` note explicitly degrades to scale-name-only if the clip-strings shape is awkward — that is a defined fallback, not a placeholder. Task 8 explicitly allows a "no breach found → document and stop" outcome (YAGNI), which is a real deliverable.
|
||||
|
||||
**Type consistency:** `isReduced()`, `autoRaf`, `setPos`, `autoScrub`, `jumpToScale`, `setDialAria`, `announce`, `HEFFlash.clampDurationMs`, `WARN_KEY`/`RM_KEY` used consistently across tasks. `reduce-motion` element id matches between HTML (Task 5 Step 3) and JS (Task 5 Step 5) and test.
|
||||
@@ -0,0 +1,706 @@
|
||||
# Cloudflare Static Publish — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Publish the simulator as a fully-static site (no origin server) to `benstull.art/human-experience-simulator` on Cloudflare Pages, with media on R2.
|
||||
|
||||
**Architecture:** Port the only live server logic (`/api/alteration`, the delta=0 random pick) to client JS; serve the read-APIs as baked JSON; point media at a configurable R2 base with CORS; keep the existing graduated prefetch. A Python build script emits `dist/` (Pages) + a media sync tree (R2). The dev FastAPI server stays for localhost/authoring.
|
||||
|
||||
**Tech Stack:** Vanilla JS (UMD modules, `node --test`), Python 3 build script (imports existing `simulator`/`player` builders), Playwright E2E, `wrangler` (Pages + R2), Cloudflare redirect rule.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **Path:** app served at `/human-experience-simulator`; apex `benstull.art` **redirects** there.
|
||||
- **Media base:** `https://static.benstull.art/` (R2 custom domain), public bucket.
|
||||
- **CORS is load-bearing:** R2 must send `Access-Control-Allow-Origin: https://benstull.art`; `<video>`/`<audio>` carry `crossOrigin="anonymous"` — else the WebGL dream shader taints.
|
||||
- **Served media = manifest-referenced only:** `base.mp4` clip bases + 154 transition morphs + 10 `.mp3`. NEVER sync `master.mp4`/`mezzanine.mp4`.
|
||||
- **Versioning preserved:** keep the `?v=<hash>` query; set those objects `Cache-Control: public, max-age=31536000, immutable` on the CDN.
|
||||
- **No new runtime:** no Pages Functions / Worker; no Service Worker.
|
||||
- **Project is exempt from flotilla/PPE/§9** (operator-set) — deploy directly to Cloudflare. Safety/hygiene rules (git-ssh, branch→PR, secrets) still hold.
|
||||
- **Module idiom:** UMD — `(function(root,factory){ if (module?.exports) module.exports=factory(); else root.HEFx=factory(); })(...)` — matching `simulator/static/scrub.js`.
|
||||
- **JS tests run:** `node --test simulator/unit`. **Python tests run:** `.venv/bin/python -m pytest`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Client-side alteration engine (`HEFAlteration`)
|
||||
|
||||
Port `player/alteration.py::plan_alteration` (+ `render_plan_to_dict`) and `player/audio.py::resolve_visual`/`resolve_audio` to a pure UMD JS module, producing byte-for-byte the same shape `/api/alteration` returns. Calibration is always unity (`DEFAULT_CALIBRATION`; the client never sends one — `app.js:619`).
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/alteration.js`
|
||||
- Test: `simulator/unit/alteration.test.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (browser global `HEFAlteration`, also `module.exports`):
|
||||
- `plan(controls)` → `{ grade:{tone}, overlay:{level,intensity}, affect:{strength,intensity}, dream:{strength,intensity}, is_identity }`
|
||||
- `renderAudio(source, scaleAudio, mediaBase)` → `{ source, url, altitude_coupled }`
|
||||
- `alteration(controls, scaleAudio, mediaBase)` → `{ plan, render:{ video:{shown}, audio:{source,url,altitude_coupled} } }` (the `/api/alteration` response)
|
||||
- `controls` shape (from `app.js:595` `controls()`): `{ visual:"on"|"off", audio:"soundtrack"|"off", left:int, right:int, dark:int, light:int }`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```js
|
||||
// simulator/unit/alteration.test.js
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const A = require("../static/alteration.js");
|
||||
|
||||
const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o });
|
||||
|
||||
test("identity at zero knobs", () => {
|
||||
const p = A.plan(c({}));
|
||||
assert.equal(p.grade.tone, 0);
|
||||
assert.equal(p.overlay.level, 0);
|
||||
assert.equal(p.dream.strength, 0);
|
||||
assert.equal(p.is_identity, true);
|
||||
});
|
||||
|
||||
test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => {
|
||||
assert.equal(A.plan(c({ light: 4 })).grade.tone, 1); // full light
|
||||
assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1); // full dark
|
||||
assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5);
|
||||
});
|
||||
|
||||
test("left drives overlay level + intensity; right drives dream + affect", () => {
|
||||
const p = A.plan(c({ left: 2, right: 4 }));
|
||||
assert.equal(p.overlay.level, 2);
|
||||
assert.equal(p.overlay.intensity, 0.5);
|
||||
assert.equal(p.dream.strength, 4);
|
||||
assert.equal(p.dream.intensity, 1);
|
||||
assert.equal(p.affect.strength, 4);
|
||||
assert.equal(p.affect.intensity, 1);
|
||||
assert.equal(p.is_identity, false);
|
||||
});
|
||||
|
||||
test("renderAudio: off → null, no coupling", () => {
|
||||
assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"),
|
||||
{ source: "off", url: null, altitude_coupled: false });
|
||||
});
|
||||
|
||||
test("renderAudio: soundtrack → mediaBase audio url, coupled", () => {
|
||||
assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"),
|
||||
{ source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true });
|
||||
// no scale audio → null url but still coupled
|
||||
assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"),
|
||||
{ source: "soundtrack", url: null, altitude_coupled: true });
|
||||
});
|
||||
|
||||
test("alteration() composes plan + render with video.shown from visual", () => {
|
||||
const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/");
|
||||
assert.equal(r.render.video.shown, false);
|
||||
assert.equal(r.render.audio.url, "https://x/audio/reef.mp3");
|
||||
assert.equal(r.plan.overlay.level, 1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `node --test simulator/unit/alteration.test.js`
|
||||
Expected: FAIL — `Cannot find module '../static/alteration.js'`.
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```js
|
||||
// simulator/static/alteration.js
|
||||
// Client-side port of player/alteration.py + player/audio.py (the alteration
|
||||
// engine). Replaces the live POST /api/alteration in the static build — pure math,
|
||||
// unity calibration. UMD so the browser gets `HEFAlteration` and node --test can
|
||||
// require() it. Keep IN SYNC with the Python (guarded by parity tests there).
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFAlteration = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
const KNOB_MAX = 4; // player/alteration.py:25
|
||||
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
|
||||
|
||||
function plan(c) {
|
||||
const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1);
|
||||
const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1);
|
||||
const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1);
|
||||
const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1); // affect uses overlay_gain too
|
||||
const is_identity = tone === 0 && c.left === 0 && c.right === 0;
|
||||
return {
|
||||
grade: { tone },
|
||||
overlay: { level: c.left, intensity: overlayIntensity },
|
||||
affect: { strength: c.right, intensity: affectIntensity },
|
||||
dream: { strength: c.right, intensity: dreamIntensity },
|
||||
is_identity,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAudio(source, scaleAudio, mediaBase) {
|
||||
if (source === "off") return { source: "off", url: null, altitude_coupled: false };
|
||||
// soundtrack — couple to the current altitude's asset
|
||||
const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null;
|
||||
return { source: "soundtrack", url, altitude_coupled: true };
|
||||
}
|
||||
|
||||
function alteration(controls, scaleAudio, mediaBase) {
|
||||
return {
|
||||
plan: plan(controls),
|
||||
render: {
|
||||
video: { shown: controls.visual === "on" },
|
||||
audio: renderAudio(controls.audio, scaleAudio, mediaBase),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { plan, renderAudio, alteration, KNOB_MAX };
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `node --test simulator/unit/alteration.test.js`
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 5: Add a Python parity test (guards JS↔Python drift)**
|
||||
|
||||
Create `tests/test_alteration_js_parity.py`:
|
||||
|
||||
```python
|
||||
"""The static build reimplements plan_alteration/resolve_audio in JS
|
||||
(simulator/static/alteration.js). This pins the numeric contract so the Python
|
||||
and JS cannot silently diverge — if you change the engine, change both."""
|
||||
from hef.selection import Coordinate
|
||||
from player.alteration import plan_alteration, render_plan_to_dict
|
||||
from player.audio import resolve_audio, resolve_visual
|
||||
|
||||
|
||||
def test_engine_contract_matches_js_expectations():
|
||||
# mirrors the cases asserted in simulator/unit/alteration.test.js
|
||||
p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0)))
|
||||
assert p["overlay"] == {"level": 2, "intensity": 0.5}
|
||||
assert p["dream"] == {"strength": 4, "intensity": 1.0}
|
||||
assert p["affect"] == {"strength": 4, "intensity": 1.0}
|
||||
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0
|
||||
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0
|
||||
assert resolve_visual("off") is False
|
||||
a = resolve_audio("soundtrack", scale_audio="cosmos.mp3")
|
||||
assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run + commit**
|
||||
|
||||
Run: `node --test simulator/unit/alteration.test.js && .venv/bin/python -m pytest tests/test_alteration_js_parity.py -q`
|
||||
Expected: all PASS.
|
||||
|
||||
```bash
|
||||
git add simulator/static/alteration.js simulator/unit/alteration.test.js tests/test_alteration_js_parity.py
|
||||
git commit -m "feat(static): client-side alteration engine (port of player.alteration/audio)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Static config + boot rewiring in `app.js`
|
||||
|
||||
Introduce a runtime config the build injects, then make `app.js` (a) read media from a configurable base, (b) boot from baked JSON in static mode, (c) pick pool members client-side, (d) compute alteration locally, (e) tag media elements with `crossOrigin`, (f) skip the dev-version poll in static mode. All changes degrade to today's behavior when config is absent (dev server unaffected).
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/static/config.js` (dev default; the build overwrites it in `dist/`)
|
||||
- Modify: `simulator/static/app.js` (boot `loadData` ~59, `pickRandomMember` ~87, `mediaNetUrl` ~123, alteration fetch ~617, dev poll ~1041, video/audio element creation)
|
||||
- Modify: `simulator/static/index.html:111` (load `config.js` before `app.js`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `HEFAlteration` (Task 1).
|
||||
- Produces: global `HEF_CONFIG = { mediaBase: string, static: boolean }`. Dev default `{ mediaBase: "/media/", static: false }`.
|
||||
|
||||
- [ ] **Step 1: Create the dev-default config and load it**
|
||||
|
||||
`simulator/static/config.js`:
|
||||
```js
|
||||
// Runtime config. This dev default serves media from the local FastAPI /media
|
||||
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
|
||||
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
|
||||
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
|
||||
```
|
||||
|
||||
In `simulator/static/index.html`, add before `app.js` (line 111 area):
|
||||
```html
|
||||
<script src="/config.js"></script>
|
||||
```
|
||||
(Place it before `<script src="/app.js"></script>`.)
|
||||
|
||||
- [ ] **Step 2: Media base — modify `mediaNetUrl` (`app.js:123`)**
|
||||
|
||||
Replace:
|
||||
```js
|
||||
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
|
||||
```
|
||||
with:
|
||||
```js
|
||||
function mediaNetUrl(file) {
|
||||
const base = (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/";
|
||||
const v = mediaVersions[file];
|
||||
return base + file + (v ? "?v=" + v : "");
|
||||
}
|
||||
```
|
||||
Note: audio-layer absolute urls already begin with the configured base — keep the existing `file.startsWith("/media/")` guard in `mediaUrl`, and ALSO short-circuit absolute `http` urls:
|
||||
```js
|
||||
function mediaUrl(file) {
|
||||
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
|
||||
return mediaBlobs[file] || mediaNetUrl(file);
|
||||
}
|
||||
```
|
||||
And in `HEFAlteration.alteration(...)` we pass `mediaBase`, so the audio `url` it returns is already absolute (R2) in static mode and `/media/...` in dev.
|
||||
|
||||
- [ ] **Step 3: Boot from baked JSON — modify `loadData` (`app.js:59`)**
|
||||
|
||||
Replace the three `fetch("/api/...")` calls with a base that depends on mode:
|
||||
```js
|
||||
async function loadData() {
|
||||
const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
|
||||
? { clips: "./clips.json", versions: "./media-versions.json", ring: "./ring.json" }
|
||||
: { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
|
||||
const clips = (await (await fetch(api.clips)).json()).clips || [];
|
||||
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
|
||||
try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
|
||||
catch (_) { mediaVersions = {}; }
|
||||
const r = await fetch(api.ring);
|
||||
serverRing = r.ok;
|
||||
ring = r.ok ? await r.json() : null;
|
||||
// ...rest unchanged (fallback ring synth, morphByPair build, ringIndex = 0)
|
||||
}
|
||||
```
|
||||
(`clips.json` mirrors `{clips:[...]}`, `ring.json` mirrors `ring_to_dict(...)`, `media-versions.json` mirrors `{versions:{...}}` — Task 3 bakes them.)
|
||||
|
||||
- [ ] **Step 4: Client-side pick — modify `pickRandomMember` (`app.js:87`)**
|
||||
|
||||
The delta=0 server pick becomes a uniform client-side pool pick (matches `hef`'s `pick_clip_id`):
|
||||
```js
|
||||
async function pickRandomMember() {
|
||||
const scale = ring && ring.scales[ringIndex];
|
||||
if (!scale) return null;
|
||||
const pool = (scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }];
|
||||
return pool[Math.floor(Math.random() * pool.length)].clip_id;
|
||||
}
|
||||
```
|
||||
(Drag/scroll navigation already resolves picks + morphs client-side via `scrub.js`; this removes the last `/api/ring/advance` dependency. `serverRing` stays as the "real ring vs synthesized" flag.)
|
||||
|
||||
- [ ] **Step 5: Local alteration — modify the fetch at `app.js:617`**
|
||||
|
||||
Replace:
|
||||
```js
|
||||
const resp = await fetch("/api/alteration", { /* ... */ body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }) });
|
||||
const data = await resp.json();
|
||||
```
|
||||
with a mode-aware path that reuses the engine:
|
||||
```js
|
||||
let data;
|
||||
if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
|
||||
const scale = ring && ring.scales[ringIndex];
|
||||
const scaleAudio = (scale && scale.audio) || "";
|
||||
const base = window.HEF_CONFIG.mediaBase;
|
||||
data = HEFAlteration.alteration(controls(), scaleAudio, base);
|
||||
} else {
|
||||
const resp = await fetch("/api/alteration", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
|
||||
});
|
||||
data = await resp.json();
|
||||
}
|
||||
```
|
||||
**Prerequisite:** `ring.json` must expose each scale's `audio` field. Verify `ring_to_dict` includes it; if not, that is a Task 3 bake concern (add it). The rest of the function (`data.render`, `data.plan.*`) is unchanged.
|
||||
|
||||
- [ ] **Step 6: crossOrigin on media elements (CORS for WebGL)**
|
||||
|
||||
Find where `<video>` and `<audio>` elements are created/used in `app.js` (the double-buffered `#vid`/loop videos and the audio layer). Set `crossOrigin` BEFORE assigning `src`:
|
||||
```js
|
||||
videoEl.crossOrigin = "anonymous";
|
||||
```
|
||||
Apply to: the visible video, the preload/double-buffer video, and the `<audio>` element. (In dev, same-origin `/media/` ignores it; in static it prevents GL-texture taint and lets ranged `fetch()` succeed under CORS.) Also set `crossorigin="anonymous"` on any static `<video>`/`<audio>` in `index.html`.
|
||||
|
||||
- [ ] **Step 7: Skip the dev-version poll in static mode (`app.js:1041`)**
|
||||
|
||||
Wrap the `/dev/version` poller so it no-ops when `HEF_CONFIG.static` (there is no dev server):
|
||||
```js
|
||||
if (!(window.HEF_CONFIG && window.HEF_CONFIG.static)) {
|
||||
// existing setInterval(... fetch("/dev/version") ...) poll
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Manual dev smoke (no regression)**
|
||||
|
||||
Run (this env has only `python3`):
|
||||
```bash
|
||||
.venv/bin/python -m uvicorn simulator.app:app --port 8000
|
||||
```
|
||||
Open `http://localhost:8000/`, confirm boot, altitude changes, dream/overlay still work (dev mode: `HEF_CONFIG.static=false`, behavior identical to before).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/static/config.js simulator/static/app.js simulator/static/index.html
|
||||
git commit -m "feat(static): config-driven media base, baked-JSON boot, client pick/alteration, crossOrigin"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Static build script → `dist/` + media sync tree
|
||||
|
||||
A Python script that produces the deployable `dist/` (frontend + baked JSON + static config) and a separate media sync tree containing ONLY manifest-referenced files. Reuses the live app's loaders so the baked JSON is identical to the API output.
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/build_static.py`
|
||||
- Create: `tests/test_build_static.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `simulator.app.create_app` (to render the same JSON the API serves) OR the underlying loaders (`simulator.clips`/`build_pool_manifest` + `player`/`hef`). Prefer driving `create_app(manifest)` via `fastapi.testclient.TestClient` so the bake is byte-identical to the API.
|
||||
- Produces: `build_static(out_dir, media_out, *, media_base, app_path) -> dict` (counts), and a CLI.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```python
|
||||
# tests/test_build_static.py
|
||||
import json
|
||||
from pathlib import Path
|
||||
from tools.build_static import build_static
|
||||
|
||||
def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
|
||||
out = tmp_path / "dist"
|
||||
media = tmp_path / "media"
|
||||
result = build_static(out, media, media_base="https://static.benstull.art/",
|
||||
app_path="/human-experience-simulator")
|
||||
|
||||
# frontend present, dev/author surfaces excluded
|
||||
assert (out / "index.html").exists()
|
||||
assert (out / "app.js").exists()
|
||||
assert (out / "scrub.js").exists()
|
||||
assert (out / "alteration.js").exists()
|
||||
assert (out / "config.js").exists()
|
||||
assert not (out / "author.html").exists()
|
||||
assert not list(out.glob("review*.html"))
|
||||
|
||||
# baked JSON matches the API shape
|
||||
clips = json.loads((out / "clips.json").read_text())
|
||||
assert "clips" in clips and clips["clips"]
|
||||
ring = json.loads((out / "ring.json").read_text())
|
||||
assert "scales" in ring and "transitions" in ring
|
||||
assert all("audio" in s for s in ring["scales"]) # needed by client alteration
|
||||
versions = json.loads((out / "media-versions.json").read_text())
|
||||
assert "versions" in versions
|
||||
|
||||
# config.js points at R2 + static mode
|
||||
cfg = (out / "config.js").read_text()
|
||||
assert "https://static.benstull.art/" in cfg and "static: true" in cfg
|
||||
|
||||
# media tree holds ONLY referenced files — no masters/mezzanines
|
||||
synced = {p.name for p in media.rglob("*.mp4")}
|
||||
assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced)
|
||||
# every versions key exists in the media tree
|
||||
for f in versions["versions"]:
|
||||
assert (media / f).exists(), f"missing synced media: {f}"
|
||||
assert result["media_files"] == len(list(media.rglob("*")))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify it fails**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
|
||||
Expected: FAIL — `ModuleNotFoundError: tools.build_static`.
|
||||
|
||||
- [ ] **Step 3: Implement the build script**
|
||||
|
||||
```python
|
||||
# tools/build_static.py
|
||||
"""Build the fully-static deployable for Cloudflare (Pages + R2).
|
||||
|
||||
Emits `out_dir/` (frontend + baked API JSON + static config.js) and a `media_out/`
|
||||
sync tree containing ONLY the files the manifest references (clip bases + transition
|
||||
morphs + audio) — never the master/mezzanine pipeline sources. The baked JSON is
|
||||
produced through the real app (TestClient) so it is byte-identical to the API.
|
||||
|
||||
Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in
|
||||
deploy/cloudflare/ — this script only produces artifacts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from simulator.app import create_app, MEDIA_DIR
|
||||
|
||||
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
|
||||
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
|
||||
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "style.css"]
|
||||
|
||||
|
||||
def _bake_api(out: Path) -> dict:
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
clips = client.get("/api/clips").json()
|
||||
ring = client.get("/api/ring").json()
|
||||
versions = client.get("/api/media-versions").json()
|
||||
(out / "clips.json").write_text(json.dumps(clips, ensure_ascii=False))
|
||||
(out / "ring.json").write_text(json.dumps(ring, ensure_ascii=False))
|
||||
(out / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False))
|
||||
return versions["versions"]
|
||||
|
||||
|
||||
def _write_config(out: Path, media_base: str) -> None:
|
||||
(out / "config.js").write_text(
|
||||
"// GENERATED by tools/build_static.py — do not edit in dist/.\n"
|
||||
f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n'
|
||||
)
|
||||
|
||||
|
||||
def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
|
||||
out = Path(out_dir)
|
||||
media = Path(media_out)
|
||||
for d in (out, media):
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
d.mkdir(parents=True)
|
||||
|
||||
for name in PUBLIC_ASSETS:
|
||||
src = STATIC / name
|
||||
if src.exists():
|
||||
shutil.copy2(src, out / name)
|
||||
|
||||
versions = _bake_api(out)
|
||||
_write_config(out, media_base)
|
||||
|
||||
# Sync ONLY referenced media (versions covers bases + morphs); add audio explicitly.
|
||||
referenced = set(versions.keys())
|
||||
ring = json.loads((out / "ring.json").read_text())
|
||||
for s in ring.get("scales", []):
|
||||
if s.get("audio"):
|
||||
referenced.add(f"audio/{s['audio']}")
|
||||
n = 0
|
||||
for rel in sorted(referenced):
|
||||
src = MEDIA_DIR / rel
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"referenced media missing on disk: {rel}")
|
||||
dst = media / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
n += 1
|
||||
|
||||
return {"out": str(out), "media": str(media), "media_files": n, "app_path": app_path}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default="dist")
|
||||
ap.add_argument("--media-out", default="dist-media")
|
||||
ap.add_argument("--media-base", default="https://static.benstull.art/")
|
||||
ap.add_argument("--app-path", default="/human-experience-simulator")
|
||||
a = ap.parse_args()
|
||||
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
|
||||
print(json.dumps(r, indent=2))
|
||||
```
|
||||
|
||||
**Verify before running the test:** confirm `simulator/app.py` exports `MEDIA_DIR` and that `ring_to_dict` includes `audio` per scale. If `audio` is absent from `ring.json`, add it to `ring_to_dict` (small change in `simulator/app.py` / the ring serializer) and add a Python test asserting `"audio" in scale dict`.
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `.venv/bin/python -m pytest tests/test_build_static.py -q`
|
||||
Expected: PASS. (Test asserts media tree excludes masters and includes every versioned file + audio.)
|
||||
|
||||
- [ ] **Step 5: Real build smoke**
|
||||
|
||||
Run: `.venv/bin/python tools/build_static.py`
|
||||
Expected: prints counts; `dist/` has the frontend + 3 JSON + `config.js`; `dist-media/` ≈ ~2 GB, no `master.mp4`/`mezzanine.mp4`.
|
||||
|
||||
- [ ] **Step 6: Commit** (do NOT commit `dist/`/`dist-media/` — add to `.gitignore`)
|
||||
|
||||
```bash
|
||||
printf '\n/dist/\n/dist-media/\n' >> .gitignore
|
||||
git add tools/build_static.py tests/test_build_static.py .gitignore simulator/app.py
|
||||
git commit -m "feat(static): build script — dist/ + manifest-only media sync tree, baked API JSON"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Cloudflare config artifacts + deploy runbook
|
||||
|
||||
Produce the version-controlled Cloudflare config (CORS policy, redirect, deploy commands). The actual deploy needs operator Cloudflare auth (`wrangler login` / API token) — `wrangler` is NOT installed here, so this task delivers artifacts + exact commands and the operator runs the authenticated steps.
|
||||
|
||||
**Files:**
|
||||
- Create: `deploy/cloudflare/README.md` (runbook)
|
||||
- Create: `deploy/cloudflare/r2-cors.json` (bucket CORS policy)
|
||||
- Create: `deploy/cloudflare/_redirects` (copied into `dist/` for the apex/path redirect, or documented as a dashboard Redirect Rule)
|
||||
|
||||
**Interfaces:** none (ops artifacts).
|
||||
|
||||
- [ ] **Step 1: CORS policy**
|
||||
|
||||
`deploy/cloudflare/r2-cors.json`:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"AllowedOrigins": ["https://benstull.art"],
|
||||
"AllowedMethods": ["GET", "HEAD"],
|
||||
"AllowedHeaders": ["Range", "Content-Type"],
|
||||
"ExposeHeaders": ["Content-Length", "Content-Range", "ETag", "Accept-Ranges"],
|
||||
"MaxAgeSeconds": 86400
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Redirect artifact**
|
||||
|
||||
`deploy/cloudflare/_redirects` (Pages `_redirects` form — apex/root to the app path):
|
||||
```
|
||||
/ /human-experience-simulator 308
|
||||
```
|
||||
(If the app is deployed as a Pages project bound to `benstull.art`, also document a dashboard **Redirect Rule**: `benstull.art/` → `/human-experience-simulator` as the authoritative alternative.)
|
||||
|
||||
- [ ] **Step 3: Runbook**
|
||||
|
||||
`deploy/cloudflare/README.md` — exact operator commands (run after `tools/build_static.py`):
|
||||
```bash
|
||||
npm i -g wrangler
|
||||
wrangler login
|
||||
|
||||
wrangler r2 bucket create hef-media
|
||||
wrangler r2 bucket cors put hef-media --rules deploy/cloudflare/r2-cors.json
|
||||
# bind custom domain static.benstull.art to the bucket (dashboard: R2 > bucket > Settings > Custom Domains)
|
||||
# upload media (preserves keys):
|
||||
wrangler r2 bucket sync ./dist-media r2://hef-media
|
||||
|
||||
cp deploy/cloudflare/_redirects dist/_redirects
|
||||
wrangler pages project create human-experience-simulator
|
||||
wrangler pages deploy dist --project-name human-experience-simulator
|
||||
# bind benstull.art to the Pages project; serve at /human-experience-simulator
|
||||
# add the apex Redirect Rule if not using _redirects
|
||||
```
|
||||
Document: set the media objects' `Cache-Control: public, max-age=31536000, immutable` (sync flag or bucket lifecycle), and verify `wrangler r2 bucket sync` excludes nothing unexpected (the tree is already filtered by the build).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add deploy/cloudflare/
|
||||
git commit -m "chore(static): Cloudflare deploy artifacts — R2 CORS, apex redirect, runbook"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: E2E against the static build
|
||||
|
||||
Verify the built `dist/` boots with **no `/api/*` server**, the dream shader survives cross-origin media (CORS, no taint), and scrub/morph/audio work. Serve `dist/` and the media from **two different origins** to exercise CORS the way R2 will.
|
||||
|
||||
**Files:**
|
||||
- Create: `simulator/e2e/tests/static-build.spec.ts`
|
||||
- Create: `simulator/e2e/serve-static.mjs` (tiny dual static server: app on one port, media w/ CORS on another)
|
||||
|
||||
**Interfaces:** consumes the `dist/` + `dist-media/` produced by Task 3.
|
||||
|
||||
- [ ] **Step 1: Dual static server with CORS**
|
||||
|
||||
`simulator/e2e/serve-static.mjs`:
|
||||
```js
|
||||
// Serves dist/ (app) and dist-media/ (media w/ CORS + Range) on two ports — a
|
||||
// local stand-in for Pages + R2, so the static-build E2E exercises cross-origin
|
||||
// media exactly as production will.
|
||||
import http from "node:http";
|
||||
import { createReadStream, statSync } from "node:fs";
|
||||
import { join, extname } from "node:path";
|
||||
|
||||
const TYPES = { ".html":"text/html", ".js":"text/javascript", ".json":"application/json",
|
||||
".css":"text/css", ".mp4":"video/mp4", ".mp3":"audio/mpeg" };
|
||||
|
||||
function serve(rootEnv, port, cors) {
|
||||
const root = process.env[rootEnv];
|
||||
http.createServer((req, res) => {
|
||||
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
|
||||
let p = decodeURIComponent(req.url.split("?")[0]);
|
||||
if (p === "/") p = "/index.html";
|
||||
const file = join(root, p);
|
||||
try {
|
||||
const st = statSync(file);
|
||||
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
|
||||
const range = req.headers.range; // honor Range for video scrub
|
||||
if (range && /^bytes=/.test(range)) {
|
||||
const [s, e] = range.replace("bytes=", "").split("-");
|
||||
const start = +s, end = e ? +e : st.size - 1;
|
||||
res.writeHead(206, { "Content-Range": `bytes ${start}-${end}/${st.size}`,
|
||||
"Content-Length": end - start + 1 });
|
||||
createReadStream(file, { start, end }).pipe(res);
|
||||
} else { res.writeHead(200, { "Content-Length": st.size }); createReadStream(file).pipe(res); }
|
||||
} catch { res.writeHead(404); res.end("nf"); }
|
||||
}).listen(port);
|
||||
}
|
||||
serve("DIST_DIR", 8077, false);
|
||||
serve("MEDIA_DIR_E2E", 8078, true);
|
||||
console.log("static app :8077 media(cors) :8078");
|
||||
```
|
||||
|
||||
- [ ] **Step 2: The spec**
|
||||
|
||||
`simulator/e2e/tests/static-build.spec.ts`:
|
||||
```ts
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Built dist/ is served on :8077; media (CORS) on :8078. The build's config.js
|
||||
// must be overridden to point media at :8078 for this run (see Step 3).
|
||||
test.use({ baseURL: "http://localhost:8077" });
|
||||
|
||||
test("boots fully static — no /api calls, ring + clips load from JSON", async ({ page }) => {
|
||||
const apiCalls: string[] = [];
|
||||
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
|
||||
expect(apiCalls, "no /api/* in static mode").toEqual([]);
|
||||
});
|
||||
|
||||
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto("/");
|
||||
// crank Right to engage the dream, let a few frames render
|
||||
await page.evaluate(() => { const r = document.getElementById("right") as HTMLInputElement; r.value = "4"; r.dispatchEvent(new Event("input", { bubbles: true })); });
|
||||
await page.waitForTimeout(2000);
|
||||
// a tainted texture throws SecurityError on the GL draw; assert none surfaced
|
||||
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Wire the run (build → point media → serve → test)**
|
||||
|
||||
Document in `simulator/e2e/README.md` and use a throwaway config for the run:
|
||||
```bash
|
||||
.venv/bin/python tools/build_static.py --media-base http://localhost:8078/
|
||||
DIST_DIR=$PWD/dist MEDIA_DIR_E2E=$PWD/dist-media node simulator/e2e/serve-static.mjs &
|
||||
cd simulator/e2e && npx playwright test static-build.spec.ts
|
||||
```
|
||||
(Building with `--media-base http://localhost:8078/` writes that base into `dist/config.js`, so the app fetches media cross-origin from the CORS server — the production CORS path, locally.)
|
||||
|
||||
- [ ] **Step 4: Run the E2E**
|
||||
|
||||
Expected: both tests PASS — zero `/api/*` requests; no `SecurityError` from the dream shader.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add simulator/e2e/serve-static.mjs simulator/e2e/tests/static-build.spec.ts simulator/e2e/README.md
|
||||
git commit -m "test(static): E2E — static boot with no API + dream shader survives cross-origin CORS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- No-origin static frontend → Tasks 2, 3. ✅
|
||||
- Media on R2 + CORS + crossOrigin → Tasks 2 (crossOrigin), 4 (CORS policy). ✅
|
||||
- Bake read-APIs to JSON → Task 3. ✅
|
||||
- Random pick client-side → Task 2 Step 4. ✅
|
||||
- `/api/alteration` client-side → Tasks 1, 2 Step 5. ✅
|
||||
- Served = manifest-only (no masters) → Task 3 (test asserts). ✅
|
||||
- `?v=` versioning + immutable caching → Task 2 (preserved), Task 4 (cache header). ✅
|
||||
- Path `/human-experience-simulator` + apex redirect → Task 4. ✅
|
||||
- E2E against static build (no API, dream survives CORS) → Task 5. ✅
|
||||
- Prefetch unchanged → no task needed (only `mediaNetUrl` base changes, Task 2 Step 2). ✅
|
||||
|
||||
**Placeholder scan:** none — every code step has full content.
|
||||
|
||||
**Type consistency:** `HEF_CONFIG.{mediaBase,static}`, `HEFAlteration.{plan,renderAudio,alteration}`, `controls()` shape, and `build_static(out, media_out, *, media_base, app_path)` are used identically across tasks. ✅
|
||||
|
||||
**Open verify-at-execution items (flagged, not placeholders):** (a) confirm `ring_to_dict` emits per-scale `audio` — add it if missing (Task 3 Step 3); (b) confirm `simulator/app.py` exports `MEDIA_DIR` (used by the build); (c) locate the exact video/audio element creation sites for `crossOrigin` (Task 2 Step 6).
|
||||
@@ -0,0 +1,187 @@
|
||||
# Accessibility pass + About page — Solution Design
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Branch:** `feat/accessibility-pass` (off `design/cloudflare-static-publish`)
|
||||
**Status:** Draft — pending operator review
|
||||
|
||||
## Problem
|
||||
|
||||
The simulator was built for an attended **kiosk**: a known display, a person
|
||||
nearby, no assistive technology in the loop. The Cloudflare static publish
|
||||
(`design/cloudflare-static-publish`) puts the same experience on a public URL
|
||||
(`benstull.art`) where none of those assumptions hold. That raises two bars at
|
||||
once:
|
||||
|
||||
- **Legal/usability** — a public site is expected to meet WCAG 2.1 AA; today the
|
||||
primary navigation control (the Altitude dial) is pointer-only, several text
|
||||
colors fail contrast, and there is no screen-reader path into a piece whose
|
||||
*meaning* is already textual.
|
||||
- **Physical safety** — the piece is continuous motion (video morphs, auto-scrub,
|
||||
crossfades, a "trippy at max" WebGL shader, black-cover fades) with no
|
||||
`prefers-reduced-motion` support and no photosensitivity safeguard. Unattended,
|
||||
that is a vestibular and seizure risk.
|
||||
|
||||
This work makes the *experience* (not just the chrome) usable away from the
|
||||
kiosk, and adds an **About** page explaining what the piece is for.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Honor reduced-motion and add seizure safety (the public-web-specific layer).
|
||||
2. Make every control keyboard- and screen-reader-operable.
|
||||
3. Fix low-vision contrast.
|
||||
4. Add `about.html` — the project's intent and provenance.
|
||||
5. A small Output-panel layout fix (language picker placement).
|
||||
|
||||
**Non-goals:** no engine/Python changes; no new languages (about page is
|
||||
English-first, i18n-keyed for later); no RTL; no redesign of the visual art.
|
||||
The `<html lang>` switch is **already implemented** (`applyUiStrings`,
|
||||
`app.js:1325`) and is out of scope.
|
||||
|
||||
## Target
|
||||
|
||||
WCAG 2.1 **AA** as the baseline, plus a motion/seizure layer (WCAG 2.3.1 flash
|
||||
threshold + 2.3.3 animation-from-interactions) on top, since those are the risks
|
||||
that genuinely change when the piece leaves the kiosk.
|
||||
|
||||
## Design
|
||||
|
||||
All changes are client-side, in `simulator/static/`. Seven units.
|
||||
|
||||
### A1. Reduced-motion: freeze-to-stills
|
||||
|
||||
A single `reduceMotion` state, default-on when
|
||||
`window.matchMedia('(prefers-reduced-motion: reduce)').matches`, plus a visible
|
||||
toggle in the Output fieldset (so a visitor whose OS setting disagrees can
|
||||
override either way). Persisted in `localStorage` (mirrors the existing `devMode`
|
||||
pattern, `app.js:934`).
|
||||
|
||||
When `reduceMotion` is **on**:
|
||||
|
||||
- **Video holds a frame.** Pause `#vid` / `#vid-loop` (do not call `playLoop()` /
|
||||
the `.play()` paths at `app.js:260,288`). The Kuwahara paint loop keeps running
|
||||
but composites a *static* frame, so knob changes (mood grade, dream, labels)
|
||||
still re-render — the image responds, it just doesn't animate on its own.
|
||||
- **Transitions are instant, not animated.** `autoScrub()` (`app.js:678`) jumps
|
||||
`pos` straight to the target (one assignment + a single settle render) instead
|
||||
of driving the rAF tween. Dial drag still scrubs live under the finger (that is
|
||||
a direct-manipulation gesture, not autonomous motion — allowed under 2.3.3),
|
||||
but on release it settles without a spin.
|
||||
- **Auto-scrub speed coupling is disabled** (the constant per-altitude auto-spin).
|
||||
|
||||
The toggle flips state live (no reload): turning it off resumes `playLoop()`;
|
||||
turning it on pauses and holds.
|
||||
|
||||
### A2. Photosensitivity: warning gate + flash audit
|
||||
|
||||
- **Warning gate.** A one-time interstitial over the stage, shown with the
|
||||
existing `#run-sim` flow before the experience begins: a short "contains motion
|
||||
and flashing effects" notice with a **Continue** action. Dismissal is
|
||||
remembered in `localStorage` so it shows once per visitor, not every load. It
|
||||
reuses the run-sim z-layer (above the black cover) and does not block the rest
|
||||
of the page (controls remain reachable).
|
||||
- **Flash audit.** Review the three motion sources that can produce rapid
|
||||
luminance swings — the fast-spin blended dial pass, the `#black` cover fades
|
||||
(`app.js`/`style.css`), and audio-coupled crossfades — and clamp any that can
|
||||
exceed **3 transitions/second** (WCAG 2.3.1). The clamp math (min transition
|
||||
duration given a luminance delta) is a **pure function** in a small module so it
|
||||
is unit-testable; the audit findings and any clamps are recorded in the
|
||||
implementation plan.
|
||||
|
||||
### B. Keyboard + focus
|
||||
|
||||
- **Dial as a real slider.** The `#dial` SVG gets `role="slider"`, `tabindex="0"`,
|
||||
and live `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext`
|
||||
(the human scale name, e.g. "reef"). A `keydown` handler maps:
|
||||
- `ArrowDown` / `ArrowRight` → descend one altitude (`+1`, matching wheel-down).
|
||||
- `ArrowUp` / `ArrowLeft` → ascend one altitude (`-1`).
|
||||
- `Home` → cosmos (index 0); `End` → the deepest scale.
|
||||
- Each step calls the existing `autoScrub` / `jumpToScale` path (so reduced-motion
|
||||
instant-jump is inherited for free).
|
||||
- **Dial labels become buttons.** The tap-to-jump `.dial-label` nodes get
|
||||
`role="button"` + `tabindex="0"` + Enter/Space activation, reusing `jumpToScale`.
|
||||
- **Visible focus everywhere.** A global `:focus-visible` outline rule (today only
|
||||
`.dev-switch` has one).
|
||||
|
||||
### C. Low-vision contrast
|
||||
|
||||
Bump the failing dark-on-dark text to meet AA (4.5:1 for body, 3:1 for large):
|
||||
the panel `.hint` (`#789`), `.dial-caption` (`#4d6184`), `.dial-label` (`#789ac0`),
|
||||
and any others a contrast check flags on the `#111` / `#0d1320` backgrounds.
|
||||
Verify the 280px panel reflows and text scales to 200% without clipping.
|
||||
|
||||
### D. Screen-reader basics
|
||||
|
||||
- Ensure every control has an accessible name (sliders via their `<label>`; the
|
||||
dial via `aria-valuetext`; the new toggle labeled).
|
||||
- **Narrate the alteration.** A visually-hidden `aria-live="polite"` region
|
||||
announces the current scale and the active clip's top factual (left-brain)
|
||||
label as the altitude/knobs change — turning the visual alteration into words.
|
||||
Updates are throttled/debounced so a drag doesn't flood the queue.
|
||||
- The decorative HUD/affect SVG layers get `aria-hidden="true"`.
|
||||
|
||||
### E. about.html
|
||||
|
||||
A standalone page mirroring `credits.html` exactly (same `credits-page` /
|
||||
`credits-wrap` styles, a `← Back to the experience` link, `config.js` loaded).
|
||||
English prose, structured so it can be i18n-keyed later. Linked from the header
|
||||
beside the existing Credits link (`index.html:18`). Narrative beats:
|
||||
|
||||
- **For everyone.** The piece aims to be accessible to, and representative of,
|
||||
the full range of human experience — which is *why* this accessibility work
|
||||
exists.
|
||||
- **Vantage points technology gave us.** The scales — deep-sea, coast, sky,
|
||||
drone/aerial, orbit, cosmos — are places no unaided human could witness. We can
|
||||
share them only because technology evolved to reach them: **underwater
|
||||
exploration → drones → space exploration.**
|
||||
- **Built with LLMs.** The work itself was built using large language models — an
|
||||
extension of that same arc of tools extending human reach.
|
||||
- **Honest about its limits.** This implementation is **imperfect** — but it is
|
||||
more than the author could make alone; better *with* LLMs assisting than by a
|
||||
single human hand. The imperfection is part of the point: tools extend us, they
|
||||
don't replace the human behind them.
|
||||
|
||||
### F. Layout fix (Output fieldset)
|
||||
|
||||
Move the language picker **below** the Audio control (currently it sits above
|
||||
Video/Audio). Put the globe `🌐` inline to the **left** of the `<select>` via a
|
||||
flex row — today `.lang-pick` has no CSS, so the full-width `select` rule
|
||||
(`style.css` `select { width: 100% }`) pushes the globe onto its own line above.
|
||||
Add a `.lang-pick { display:flex; align-items:center; gap }` rule and let the
|
||||
select flex to fill the rest.
|
||||
|
||||
## Components / files
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `index.html` | Reduced-motion toggle; warning-gate markup; aria-live region; dial a11y attrs; move lang picker below Audio; about-page header link |
|
||||
| `style.css` | `.lang-pick` flex; contrast bumps; `:focus-visible`; visually-hidden util; warning-gate styles |
|
||||
| `app.js` | `reduceMotion` state + freeze logic; dial keyboard handler + aria-value sync; label buttons; aria-live narration; gate dismissal |
|
||||
| `flash.js` *(new, pure)* | Flash-clamp helper (min duration given luminance delta), UMD like `i18n.js`/`credits.js` |
|
||||
| `i18n.js` | New UI keys for the toggle, warning gate, about link (EN populated; other langs may fall back) |
|
||||
| `about.html` *(new)* | The page above |
|
||||
| `tools/build_static.py` | Ensure `about.html` + `flash.js` are copied into `dist/` (verify the static build includes new static files) |
|
||||
|
||||
## Testing
|
||||
|
||||
- **Node unit tests** for the pure flash-clamp helper (`flash.js`) — boundary
|
||||
cases around the 3/sec threshold.
|
||||
- **Playwright e2e** (extends the existing suite): reduced-motion toggle pauses
|
||||
video and makes a dial step instant; dial is keyboard-focusable and
|
||||
Arrow/Home/End change the scale; the warning gate appears and dismisses once;
|
||||
`about.html` loads and its back-link returns to `index.html`; the language
|
||||
picker renders below Audio with the globe inline.
|
||||
- **Manual/automated contrast check** on the bumped colors.
|
||||
- Existing suites stay green: the current Playwright specs, node `--test`, and
|
||||
`pytest`.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Freeze-to-stills + WebGL.** Need to confirm the paint loop still composites a
|
||||
paused-video frame (some browsers stop delivering frames to WebGL from a paused
|
||||
`<video>`); fallback is to draw the last frame once to the canvas and stop the
|
||||
loop. Resolved during implementation.
|
||||
- **Flash audit is the fuzziest unit** — the clamp is mechanical, but deciding
|
||||
which existing transitions actually breach 3/sec needs measurement; the plan
|
||||
will enumerate them explicitly rather than hand-wave.
|
||||
- E2E in this environment starts uvicorn via `python` (only `python3` exists) —
|
||||
start the server by hand and use `reuseExistingServer`, per project memory.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Publish to benstull.art — Cloudflare Pages + R2 (fully static) — Design
|
||||
|
||||
**Status:** Approved (brainstormed 2026-06-30). Ready for writing-plans.
|
||||
|
||||
## Goal
|
||||
|
||||
Publish the Human Experience **Simulator** publicly at
|
||||
`benstull.art/human-experience-simulator`, hosted on Cloudflare, with the
|
||||
`benstull.art` apex **redirecting** to that path for now.
|
||||
|
||||
The simulator today is a FastAPI server (`simulator/app.py`) serving ~92 KB of
|
||||
static frontend plus video/audio from a local `/media/` mount. The public target
|
||||
has **no origin server**: the frontend is **fully static** on **Cloudflare Pages**,
|
||||
and the video/audio lives in a public **R2** bucket behind a custom domain. Nothing
|
||||
runs, nothing to patch, nothing to keep alive. The existing GCE/flotilla deployment
|
||||
stays as the dev/authoring surface — this is a *new, additional* public target, not
|
||||
a migration of the dev box.
|
||||
|
||||
## Why this shape (the constraint that forces it)
|
||||
|
||||
Cloudflare Pages has a **hard 25 MiB per-file limit** (and 20,000 files free /
|
||||
100,000 paid; no total-size cap). The repo carries **3.9 GB across 663 mp4 + 10
|
||||
mp3**, with individual files up to ~117 MB. Pages cannot host the video. R2 has no
|
||||
relevant per-file limit, **free egress**, and ~2 GB of served media sits inside its
|
||||
10 GB-month free tier (≈ free). So: **Pages for the ~92 KB frontend, R2 for the
|
||||
media.** (Verified against Cloudflare docs 2026-06-30.)
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- **Path:** experience at `/human-experience-simulator`; **apex `benstull.art`
|
||||
redirects** there (302/308 redirect, not a rewrite).
|
||||
- **Fully static**, no Pages Functions / Worker — the dynamic logic is trivial
|
||||
client-side (see Component 2).
|
||||
- **Media on R2** at a custom domain `static.benstull.art` (public bucket binding).
|
||||
- **No cross-session cache** (no Service Worker) for now — the existing in-memory
|
||||
graduated prefetch is sufficient. Deferred, not rejected.
|
||||
- **benstull.art is already a Cloudflare zone** — no nameserver migration needed.
|
||||
- **Operates outside Wiggleverse deployment patterns (standing, operator-set):**
|
||||
this **entire project** is exempt from the typical Wiggleverse deployment
|
||||
conventions — `flotilla-only-provisioning`, the `ppe-before-prod` GCE pipeline,
|
||||
and the §9 prod-promotion gate do not bind it. It is a static public art target
|
||||
hosted directly on Cloudflare. (Safety/hygiene rules — `git-ssh`, the secrets
|
||||
rule, branch→PR — still apply; only the *deployment* machinery is out of scope.)
|
||||
|
||||
## Served media — what actually ships
|
||||
|
||||
The on-disk 3.9 GB is mostly **pipeline source**, not served bytes. The manifest
|
||||
(`simulator/sample_media/manifest.json`) references only **`base.mp4`** for clips —
|
||||
the `master.mp4` (20 files, incl. the 91–117 MB `coast_birdrock` set) and
|
||||
`mezzanine.mp4` proxies are build inputs that **never reach the browser**. The
|
||||
served set is:
|
||||
|
||||
- the `base.mp4` clip bases (one per pool member),
|
||||
- the **154 transition morphs** (`*__*.mp4` / `*.rev.mp4`, ~1.6 GB, each already
|
||||
kept < 25 MB),
|
||||
- the **10 audio `.mp3`** layers (~12 MB).
|
||||
|
||||
≈ **~2 GB** to R2. The build computes the exact set by walking the manifest — it
|
||||
**must not** blindly sync `sample_media/` (that would push the masters too).
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Static build (`tools/` — new build script → `dist/`)
|
||||
|
||||
Replaces the server with baked artifacts:
|
||||
|
||||
- **Bake the read endpoints to static JSON** the page loads at boot, replacing the
|
||||
fetches to `/api/clips`, `/api/ring`, `/api/media-versions`:
|
||||
- `clips.json` ← `GET /api/clips`
|
||||
- `ring.json` ← `GET /api/ring`
|
||||
- `media-versions.json` ← `GET /api/media-versions` (file → content-hash)
|
||||
Generate these by importing the existing builders (`simulator/clips.py`,
|
||||
`build_pool_manifest.py`) directly — **single source of truth, no hand-copy**.
|
||||
- **Copy only manifest-referenced media** into the R2 sync tree (see Served media).
|
||||
- **Emit the public frontend** (`index.html`, `app.js`, `scrub.js`, `style.css`,
|
||||
`i18n.js`) into `dist/`, **excluding** author/review/dev surfaces
|
||||
(`author.*`, `review*.html`, `/api/author/*`).
|
||||
- Output is deterministic and re-runnable; checked by the integration test below.
|
||||
|
||||
### 2. Client-side dynamic logic (`simulator/static/app.js`)
|
||||
|
||||
The only server-computed behavior is the **random clip pick on landing**
|
||||
(`POST /api/ring/advance`). The scrub work (PR #28, spec 2026-06-27) **already
|
||||
moved the pick client-side** for drag/scroll gestures via the pool + `morphByPair`
|
||||
lookup — so the remaining task is to ensure **every** entry path (initial load, tap-
|
||||
label jumps) uses the client-side pick and that **no code path depends on a live
|
||||
`/api/*` call**. Boot loads the baked JSON instead of fetching the API.
|
||||
`/api/alteration` is verified client-resolvable or dropped from the public build.
|
||||
|
||||
### 3. Media URL base + CORS (`simulator/static/app.js`)
|
||||
|
||||
- `mediaNetUrl(file)` changes its base from `"/media/" + file` to a configurable
|
||||
**`MEDIA_BASE`** (`https://static.benstull.art/`), preserving the existing
|
||||
`?v=<hash>` content-hash versioning. A single const, fed from the baked config so
|
||||
localhost dev still works against the FastAPI `/media/` mount.
|
||||
- **CORS is load-bearing** because of the WebGL dream shader: `app.js` preloads each
|
||||
clip via `fetch()` (cross-origin → needs `Access-Control-Allow-Origin`) into a
|
||||
blob, and any clip that falls back to the *direct network* `<video>` path feeding
|
||||
WebGL **taints the GL texture** without CORS. So:
|
||||
- R2 bucket gets a **CORS policy** allowing `https://benstull.art`.
|
||||
- `<video>` elements carry **`crossOrigin="anonymous"`**.
|
||||
Without this, plain playback works but the *dream effect* throws a security error.
|
||||
|
||||
### 4. Prefetch (unchanged)
|
||||
|
||||
Keep the existing graduated strategy verbatim — `preloadList()` (all bases),
|
||||
`preloadOrder()` (current pool → neighbors outward → reachable morphs → sweep),
|
||||
`refreshReachablePreload()` (per-landing), `preloadAllMedia(concurrency=4)` behind
|
||||
the "Loading Universe…" bar. It improves on R2: Cloudflare edge-caches each object
|
||||
after first fetch; egress is free, so the background fill is free. No code change
|
||||
beyond the `MEDIA_BASE` swap (Component 3).
|
||||
|
||||
### 5. Caching headers
|
||||
|
||||
Server today sets `/media/` to `no-cache` (revalidate-always). On the CDN, set the
|
||||
versioned media objects to long-lived **`immutable`** — the `?v=<hash>` query already
|
||||
guarantees a new URL when bytes change, so a re-baked clip busts cache without a
|
||||
manual purge. Pages assets keep normal Cloudflare defaults.
|
||||
|
||||
### 6. Deploy & redirect
|
||||
|
||||
- **Pages:** deploy `dist/` (Pages Git integration or `wrangler pages deploy`).
|
||||
Serve the app under the `/human-experience-simulator` path.
|
||||
- **R2:** sync the media tree to the bucket (`wrangler r2` or rclone), bind
|
||||
`static.benstull.art` as the public custom domain, apply the CORS policy.
|
||||
- **Apex redirect:** `benstull.art/` → `/human-experience-simulator` via a
|
||||
Cloudflare redirect rule (or Pages `_redirects`).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Build integration test:** run the build against the real manifest; assert
|
||||
`dist/` contains the expected static files, the three baked JSONs match the live
|
||||
API output, and the media sync tree contains **only** manifest-referenced files
|
||||
(no masters/mezzanines, nothing > some sanity bound).
|
||||
- **E2E (Playwright) against the static build:** point the existing suite at the
|
||||
built `dist/` + an R2-equivalent media origin and verify: boot with **no `/api/*`
|
||||
server**, the **dream/Kuwahara shader survives CORS** (texture not tainted),
|
||||
scrub-driven morphs stay smooth, audio crossfade works, language dropdown works.
|
||||
The E2E browser tier is required per the §9 pipeline (handbook).
|
||||
- **Pre-deploy check:** a smoke run confirming CORS headers + range requests on the
|
||||
R2 custom domain before flipping the apex redirect.
|
||||
|
||||
## Out of scope / deferred
|
||||
|
||||
- Service Worker / cross-session caching (deferred, not rejected).
|
||||
- Migrating the dev/authoring GCE deployment (stays as-is on flotilla).
|
||||
- Per-clip loop files for the reverse-landing frame jump (pre-existing residual,
|
||||
tracked elsewhere).
|
||||
- Cloudflare Stream (R2 is sufficient and cheaper for this payload).
|
||||
@@ -0,0 +1,142 @@
|
||||
# Playback Speed slider (replaces "Reduce motion") — design
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Status:** Shipped, then revised
|
||||
**Scope:** Frontend only (`simulator/static/`). No engine, pipeline, or media changes.
|
||||
|
||||
> **Revision (2026-06-30, post-eyeball):** Reverse playback was **dropped**. In a
|
||||
> real browser the below-0× reverse loop jittered — the base loop clips aren't
|
||||
> all-intra, so backward seeking can't stay smooth (exactly the risk flagged
|
||||
> below). The slider is now **0×–4× forward** (still continuous, `step="any"`).
|
||||
> `0×` still freezes; OS reduced-motion behavior is unchanged. The
|
||||
> `HEFScrub.reverseStep` helper and its tests were removed. Sections below
|
||||
> describing the −2…2 range and the manual reverse loop are superseded by this
|
||||
> note.
|
||||
|
||||
## Summary
|
||||
|
||||
Add a continuous **Playback Speed** slider (−2× … 2×) that controls the resting
|
||||
**altitude loop video** (`#vid-loop`) in real time as the user drags it. Below
|
||||
`0×` the loop plays **in reverse**. The slider **replaces** the "Reduce motion"
|
||||
toggle in the Output panel — but the reduced-motion *accessibility behavior* is
|
||||
preserved, now derived silently from the OS `prefers-reduced-motion` setting
|
||||
rather than a visible toggle.
|
||||
|
||||
## What it affects (and what it does not)
|
||||
|
||||
- **Affects:** the resting **altitude loop video** `#vid-loop` — the clip that
|
||||
loops while parked at an altitude. The slider sets its play speed/direction.
|
||||
- **Does NOT affect:** the **morph / transition video** `#vid`. That element is
|
||||
scrub-driven — the altitude dial sets its `currentTime` directly — so a
|
||||
playback rate has no meaning there. Speed control never touches it.
|
||||
|
||||
## UI
|
||||
|
||||
Replace the `reduce-motion` checkbox `<label>` in `index.html` with a range
|
||||
slider, styled like the existing Audio level control:
|
||||
|
||||
```
|
||||
Speed [ −2 ········●········ 2 ] 1.37×
|
||||
```
|
||||
|
||||
- `<input type="range" id="play-speed" min="-2" max="2" step="any" value="1">`
|
||||
— `step="any"` so dragging yields a **continuous** value, not 0.25 detents.
|
||||
- A live numeric **readout** (`<span id="play-speed-val">`) shows the current
|
||||
value formatted to 2 decimals with a `×` suffix (e.g. `1.37×`, `−0.75×`,
|
||||
`0.00×`).
|
||||
- Reference **tick labels** at the nice increments (`−2 −1.75 … 0 … 2`) via a
|
||||
`<datalist>` bound to the input, so the round numbers are visible without
|
||||
quantizing the actual applied value.
|
||||
- New i18n key `speed.label` ("Speed") in en/es/fr/ja. The `rm.label` key is
|
||||
retired.
|
||||
|
||||
## Accessibility (preserved, now invisible)
|
||||
|
||||
The reduce-motion *toggle* is removed, but its *behavior* is kept. `isReduced()`
|
||||
now derives **live from the OS setting only** —
|
||||
`matchMedia("(prefers-reduced-motion: reduce)")`, re-read on its `change` event —
|
||||
with no stored override and no UI. Every existing `isReduced()` call site keeps
|
||||
working unchanged:
|
||||
|
||||
- loop-play gate (`playLoop`),
|
||||
- instant (no-tween) auto altitude transitions (`autoScrub`),
|
||||
- the photosensitivity-flash luminance floor.
|
||||
|
||||
Consequences:
|
||||
|
||||
- The `hef.reduceMotion` localStorage key (`RM_KEY`) is **retired**.
|
||||
- When OS reduced-motion is **active**: the loop stays frozen and the speed
|
||||
slider is **disabled** (with a short hint), so a vestibular/photosensitive
|
||||
visitor never gets motion they did not ask for. If the OS pref toggles at
|
||||
runtime, the slider enables/disables to match.
|
||||
- `warn.body` copy (all 4 langs) is reworded: it currently tells users to "turn
|
||||
on Reduce motion in the panel," which no longer exists. New copy points to the
|
||||
system reduced-motion setting (now auto-applied).
|
||||
|
||||
## Speed behavior (`app.js`)
|
||||
|
||||
A new `applyPlaySpeed(speed)` drives `#vid-loop`, with the chosen value persisted
|
||||
to `localStorage["hef.playSpeed"]` (default `1`). Applied live on the slider's
|
||||
`input` event:
|
||||
|
||||
- **speed > 0** → `loopVid.playbackRate = speed`; native forward play. The
|
||||
existing duration→`loopStartFrame()` wrap is unchanged. Any active manual
|
||||
reverse loop is cancelled.
|
||||
- **|speed| ≈ 0** (within a small epsilon) → pause the element (freeze on the
|
||||
current frame). Reversible by moving the slider off zero. Cancels any reverse
|
||||
loop.
|
||||
- **speed < 0** → native negative `playbackRate` is unreliable (Chrome ignores
|
||||
it), so a `requestAnimationFrame` loop owns the element: the element is
|
||||
**paused** and each frame decrements `loopVid.currentTime` by `|speed|·dt`.
|
||||
When it crosses the bottom boundary it **wraps to the tail** (mirror of the
|
||||
forward wrap), reusing the direction-aware landing offset. The loop is
|
||||
cancelled when speed goes ≥ 0, on reduced-motion freeze, when video output is
|
||||
turned off, or when a new loop clip loads.
|
||||
|
||||
Interaction with existing motion:
|
||||
|
||||
- The manual reverse loop and the **auto-transition tween** (`autoRaf`, which
|
||||
drives the *morph* `#vid`) are independent — different elements, different
|
||||
rAF handles. The reverse loop only owns `#vid-loop`.
|
||||
- On landing on a new altitude (`loadLoop` + `playLoop`), the current slider
|
||||
speed is re-applied so the new clip inherits the chosen rate/direction.
|
||||
|
||||
## Test seam + tests
|
||||
|
||||
Mirrors the existing `scrub.js` / `node --test` pattern:
|
||||
|
||||
- A **pure** helper added to `scrub.js`:
|
||||
`reverseStep(t, absSpeed, dt, loopStart, duration) → { next, wrapped }`
|
||||
— computes the next `currentTime` and whether a bottom-boundary wrap occurred.
|
||||
This is the reverse-loop math with no DOM; `app.js` just applies `next` to
|
||||
`loopVid.currentTime`. Covered by a new `node --test` case (steps, wrap at the
|
||||
boundary, clamping).
|
||||
- A **Playwright** e2e added to the existing suite: the slider is present;
|
||||
forward value sets `#vid-loop.playbackRate`; `0×` pauses it; a negative value
|
||||
makes `currentTime` decrease over time. Also assert the slider is disabled
|
||||
when `prefers-reduced-motion` is emulated.
|
||||
|
||||
## Known risk (noted, not blocking)
|
||||
|
||||
Reverse smoothness depends on the **base loop clips'** keyframe density. The
|
||||
morphs are all-intra (smooth reverse), but the base altitude clips may not be;
|
||||
on some browsers reverse seeking could stutter. The manual loop stays *correct*
|
||||
regardless — smoothness is a visual-polish follow-up (re-encode base clips
|
||||
all-intra if needed), not a blocker for this change.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `simulator/static/index.html` — swap toggle → slider + datalist; reword warn.
|
||||
- `simulator/static/app.js` — `applyPlaySpeed`, reverse rAF loop, derive
|
||||
`isReduced()` from OS pref, retire `RM_KEY`, re-apply speed on landing.
|
||||
- `simulator/static/scrub.js` — add pure `reverseStep` helper.
|
||||
- `simulator/static/i18n.js` — add `speed.label`, reword `warn.body` (en/es/fr/ja),
|
||||
retire `rm.label`.
|
||||
- `simulator/static/styles.css` — slider styling (reuse audio-level pattern).
|
||||
- Tests: `node --test` for `reverseStep`; Playwright e2e for the slider.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Re-encoding base clips for smoother reverse (follow-up if stutter is visible).
|
||||
- Any speed control over morph/transition videos.
|
||||
- Per-altitude or audio speed coupling (audio level is its own control).
|
||||
@@ -0,0 +1,171 @@
|
||||
# Welcome screen — design
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Status:** Approved (brainstorming)
|
||||
**Surface:** `simulator/static/` (frontend only — `index.html`, `style.css`, `app.js`, `i18n.js`)
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the current bare boot flow with a single **Welcome screen**: the
|
||||
universal entry gate the visitor sees on every load. It carries the
|
||||
photosensitivity "Heads up" notice, the four output controls (Video, Reduce
|
||||
motion, Audio, Language) with welcome-appropriate defaults, a **Launch
|
||||
Simulator** button, and the "Loading Universe" progress. The right control panel is hidden
|
||||
until the visitor enters the simulation.
|
||||
|
||||
## Why
|
||||
|
||||
Today the universe loads behind a bare "Loading Universe" splash; the splash
|
||||
fades to reveal a "Run simulation" button over the black stage; a first-visit
|
||||
"Heads up" modal appears; clicking through begins the experience. The four
|
||||
output controls live in the right panel and are visible the whole time —
|
||||
including during loading, which the panel should not be.
|
||||
|
||||
We want a deliberate, configurable front door: the visitor reads the heads-up,
|
||||
sets their preferences, and presses one button to begin — while the universe
|
||||
loads in the background so entry is instant when they are ready.
|
||||
|
||||
## Current flow (being replaced)
|
||||
|
||||
- `#loading` — fullscreen splash (`Loading Universe` title + `#loading-fill`
|
||||
progress bar). `simulator/static/index.html:10-15`.
|
||||
- `#run-sim` — "Run simulation" button revealed over the black stage after
|
||||
phase-1 preload. `index.html:33`; wired at `app.js:1533`.
|
||||
- `#motion-warning` — first-visit-only "Heads up" modal, persisted dismissed via
|
||||
`WARN_KEY`. `index.html:34-40`; shown by `maybeShowMotionWarning()`
|
||||
(`app.js:1161`).
|
||||
- Four controls in `.panel` (`#visual`, `#audio`, `#reduce-motion`,
|
||||
`#lang-select`). `index.html:46-65`. The panel is visible throughout.
|
||||
- `beginExperience()` (`app.js:1505`) snaps Video on and Audio to
|
||||
`START_AUDIO_LEVEL = "2"` in the click gesture (Safari audio-unlock).
|
||||
|
||||
## Target design
|
||||
|
||||
### 1. One unified `#welcome` overlay
|
||||
|
||||
Fold `#loading`, `#motion-warning`, and `#run-sim` into a single fullscreen
|
||||
`#welcome` overlay layered above everything. It absorbs all three roles. The
|
||||
right `.panel` is **hidden** for the entire welcome + loading period and revealed
|
||||
only on entry to the simulation.
|
||||
|
||||
The overlay contains three blocks:
|
||||
|
||||
1. **Welcome messaging** — the "Heads up — motion & flashing" title + body
|
||||
(today's `warn.*` strings, reworded to point at the on-screen Reduce-motion
|
||||
control rather than "the panel"). Structured so additional welcome copy can be
|
||||
added later (see Forward notes).
|
||||
2. **Controls** — the four output controls (below).
|
||||
3. **Launch Simulator** — the single entry button.
|
||||
4. **Loading** — the "Loading Universe" title + progress bar.
|
||||
|
||||
### 2. Two visual states
|
||||
|
||||
**State A — Welcome (on load):** welcome messaging + controls + "Launch
|
||||
Simulator" button shown; the Loading block sits at the **bottom**, its bar
|
||||
filling as phase-1 preload runs in the background.
|
||||
|
||||
**State B — Loading (after Launch, only if not yet loaded):** the welcome
|
||||
messaging and Launch button **disappear**; the **controls remain**; the Loading
|
||||
block moves to **center**. When phase-1 preload completes, auto-enter the
|
||||
simulation.
|
||||
|
||||
**Launch when already loaded** → skip State B; enter the simulation directly.
|
||||
|
||||
State is driven by a class on `#welcome` (e.g. `.welcoming` → `.loading`), with
|
||||
the "loaded yet?" decision made from a flag set when phase-1 preload resolves.
|
||||
|
||||
### 3. Control defaults (welcome screen)
|
||||
|
||||
| Control | Welcome default | Notes |
|
||||
|---------------|-----------------|--------------------------------------------------|
|
||||
| Video | **ON** | Was OFF in the panel; on by default here. |
|
||||
| Reduce motion | **OFF** | Unless OS `prefers-reduced-motion` / stored pref.|
|
||||
| Audio | **2** (0–10) | Was 0 in the panel. |
|
||||
| Language | **English** | Global; changing it re-renders all welcome text. |
|
||||
|
||||
Reduce motion keeps deferring to a stored value / the OS media query when those
|
||||
exist (same precedence as `initReduceMotion()` today); "OFF" is the no-signal
|
||||
default.
|
||||
|
||||
### 4. Entry into the simulation
|
||||
|
||||
"Launch Simulator" is the user gesture that unlocks audio (the role `#run-sim`
|
||||
had). On Launch → enter (either immediately when loaded, or at end of State B):
|
||||
|
||||
1. Copy the four welcome values into the existing panel inputs (`#visual`,
|
||||
`#reduce-motion`, `#audio`, `#lang-select`).
|
||||
2. Fire the existing handlers / `beginExperience()` so the experience starts in
|
||||
the same code paths as today.
|
||||
3. Remove the `#welcome` overlay and reveal `.panel`.
|
||||
|
||||
Per decisions: the four controls **stay in the panel** for mid-experience
|
||||
adjustment; **Video-off at entry** starts with video off (audio still plays per
|
||||
the Audio level — `beginExperience` no longer forces Video on regardless).
|
||||
|
||||
### 5. Controls drive shared state
|
||||
|
||||
The welcome controls write the same underlying state as the panel controls — they
|
||||
are not a parallel system:
|
||||
|
||||
- **Language** routes through the global `setLanguage()`, which sets `activeLang`
|
||||
and runs `applyUiStrings()` over **all** `[data-i18n]` elements — so the
|
||||
heads-up copy, control labels, and Launch button re-render live, exactly like
|
||||
the panel selector does today. The panel `#lang-select` value is synced to
|
||||
`activeLang` on entry.
|
||||
- **Reduce motion** sets the same `reduceMotion` variable + `RM_KEY` localStorage
|
||||
and calls `applyReduceMotion()`; panel checkbox synced on entry.
|
||||
- **Audio / Video** values are applied on entry via the panel inputs +
|
||||
`beginExperience()`.
|
||||
|
||||
### 6. Behavior changes (intentional)
|
||||
|
||||
- The welcome screen shows on **every** load (it is the entry gate holding the
|
||||
controls), so the first-visit-only persistence of the heads-up (`WARN_KEY`) is
|
||||
**retired**.
|
||||
- Entry uses the **chosen** Audio level rather than always snapping to 2 (2 is the
|
||||
default, not a forced value).
|
||||
- `#run-sim` is removed; **"Launch Simulator"** is the single entry button.
|
||||
|
||||
## i18n
|
||||
|
||||
New / changed `UI_STRINGS` keys in `simulator/static/i18n.js` (all four langs —
|
||||
en/es/fr/ja, matching existing parity):
|
||||
|
||||
- A new `welcome.launch` key — **"Launch Simulator"** (the single entry button),
|
||||
translated in all four languages.
|
||||
- Reworded `warn.body` (point at the on-screen Reduce-motion control).
|
||||
- Welcome-screen control labels reuse the existing `output.video`,
|
||||
`output.audio`, `rm.label` keys (already translated). The language selector
|
||||
needs no string (native names from the registry).
|
||||
|
||||
All welcome text must live under `data-i18n` so `applyUiStrings()` re-renders it
|
||||
on language change.
|
||||
|
||||
## Out of scope / Forward notes
|
||||
|
||||
- **Additional welcome copy** beyond the heads-up (intro / framing text) comes in
|
||||
a later pass, once this flow works. The welcome-messaging block is structured so
|
||||
adding more `data-i18n` paragraphs is trivial.
|
||||
- No backend / API changes. No content-pipeline changes.
|
||||
|
||||
## Testing
|
||||
|
||||
Frontend lives behind Playwright e2e (`simulator/static/` tests) + node unit
|
||||
tests. Plan to:
|
||||
|
||||
- **Update** existing tests that reference `#loading`, `#run-sim`, and
|
||||
`#motion-warning` to the new `#welcome` flow.
|
||||
- **Add** coverage for:
|
||||
- Welcome defaults (Video on, Reduce motion off, Audio 2, English).
|
||||
- Language switch re-renders welcome text (heads-up + labels + Launch button)
|
||||
live.
|
||||
- Launch **while loading** → State B (messaging gone, controls remain,
|
||||
progress centered), then auto-enter when loaded.
|
||||
- Launch **when already loaded** → straight into the simulation.
|
||||
- `.panel` hidden during welcome + loading; revealed on entry.
|
||||
- Welcome control values carry into the panel inputs on entry.
|
||||
|
||||
Environment caveats (from prior sessions): this box has only `python3` (start
|
||||
uvicorn by hand; `reuseExistingServer`); a stale uvicorn is the recurring
|
||||
"no transitions" root cause; `loop-recovery.spec.ts` fails on a clean baseline
|
||||
here (boots without video).
|
||||
@@ -0,0 +1,113 @@
|
||||
# Audio "Music" option + "Volume" rename — design
|
||||
|
||||
*Date: 2026-07-01 · Status: approved design, awaiting spec review*
|
||||
|
||||
## Goal
|
||||
|
||||
Give the listener a choice of what to hear. Today the Output panel has a single
|
||||
0–10 slider labeled **Audio** that acts as the master gain for the per-altitude
|
||||
ambience crossfade. We split that into two controls:
|
||||
|
||||
- **Audio** — a dropdown selecting the audio *source*: **None · Soundtrack · Music**
|
||||
- **Volume** — the existing 0–10 slider (relabeled), master gain for whatever
|
||||
source is active
|
||||
|
||||
**Music** is one ethereal, relaxing ambient loop (the innerflo.me vibe) that
|
||||
plays continuously and does **not** change with altitude.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No per-altitude music set (Music is a single track, not a musical Soundtrack).
|
||||
- No change to the Soundtrack behavior itself (per-altitude crossfade stays exactly as-is).
|
||||
- No new audio elements — the existing `#aud` / `#aud-b` pair is reused.
|
||||
|
||||
## Control surface
|
||||
|
||||
The Output `<fieldset>` (`simulator/static/index.html`) gains a dropdown above the
|
||||
renamed slider:
|
||||
|
||||
```
|
||||
Output
|
||||
[ Video ] (toggle)
|
||||
Audio [ Soundtrack ▾ ] ← NEW: <select id="audio-source">
|
||||
Volume ●──────── 2/10 ← existing #audio slider, label → "Volume"
|
||||
Speed ●──────── 1.00×
|
||||
🌐 [ Language ▾ ]
|
||||
```
|
||||
|
||||
- The dropdown is a `<select id="audio-source">` with options `none` / `soundtrack`
|
||||
/ `music`, default **`soundtrack`** (preserves today's launch experience).
|
||||
- The slider keeps `id="audio"` (avoids touching every reference) but its visible
|
||||
label becomes **Volume** (new i18n key `output.volume`). The `output.audio` key
|
||||
("Audio") now labels the dropdown.
|
||||
|
||||
## Behavior by mode
|
||||
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| **None** | Both `#aud`/`#aud-b` fade to silence — the existing `audioLevel()===0` path. The Volume slider is disabled (greyed). |
|
||||
| **Soundtrack** | Unchanged: per-altitude ambience crossfading across the two elements as the dial moves (`blendAudio` / `restAudio`). |
|
||||
| **Music** | The single music loop plays on `#aud` at master volume; `#aud-b` fades out. Altitude changes do **not** crossfade or reload audio. |
|
||||
|
||||
Volume = 0 remains a hard mute in every mode.
|
||||
|
||||
## Code shape
|
||||
|
||||
All in `simulator/static/app.js`, following the existing audio-layer seams
|
||||
(`restAudio`, `blendAudio`, `ensurePlaying`, `scaleAudioUrl`):
|
||||
|
||||
- **`audioSource()`** — new helper reading `#audio-source` → `"none" | "soundtrack" | "music"`.
|
||||
- **`musicUrl()`** — returns the resolved music loop url via `mediaBase()`
|
||||
(mirrors `scaleAudioUrl`'s R2/local routing), e.g. `mediaBase()+"audio/music/<name>.loop.mp3"`.
|
||||
- **`restAudio(index)`** branches:
|
||||
- `none` (or `audioLevel()===0`) → existing fade-to-silence.
|
||||
- `music` → `ensurePlaying(aud, musicUrl(), audioVol())`; `fadeVolume(audB, 0, …)`.
|
||||
- `soundtrack` → unchanged per-altitude logic.
|
||||
- **`blendAudio(lo, frac)`** returns early (no crossfade) when source is `music` or
|
||||
`none`; only Soundtrack blends across the altitude boundary.
|
||||
- **Dropdown wiring**: `#audio-source` `change` → reconcile synchronously in the
|
||||
gesture (`applyAudio()`), same Safari-unlock discipline the slider already uses;
|
||||
toggles the Volume slider's `disabled` when None.
|
||||
- **`beginExperience()`**: unchanged except it snaps **Volume** (`#audio`) to the
|
||||
start level 2; default source stays Soundtrack.
|
||||
- **Diagnostic readout** (`audioStatusText`): prefix with the current source
|
||||
(`none`/`soundtrack`/`music`) so a "no sound" report stays legible.
|
||||
|
||||
No server/engine change — audio source is a pure client concern.
|
||||
|
||||
## Music asset & licensing
|
||||
|
||||
- One **CC0 / public-domain** ethereal ambient loop (innerflo.me-style: soft,
|
||||
evolving, no percussion, seamless loop). Sourced by the implementer; reviewed by
|
||||
ear before ship.
|
||||
- Stored at `simulator/.../media/audio/music/<name>.loop.mp3` alongside the
|
||||
per-scale soundtracks, and registered in the credits manifest exactly like every
|
||||
other audio asset (this project credits all CC/PD audio — see `credits.html`).
|
||||
- Fallback path constant mirrors the `SCALE_AUDIO_FALLBACK` pattern so Music works
|
||||
even against an older server process.
|
||||
|
||||
## i18n + a11y
|
||||
|
||||
- New key `output.volume` (en: "Volume", es: "Volumen", fr: "Volume", ja: "音量").
|
||||
- Dropdown option labels localized in en/es/fr/ja:
|
||||
- None (Ninguno / Aucun / なし)
|
||||
- Soundtrack (Banda sonora / Bande-son / サウンドトラック)
|
||||
- Music (Música / Musique / 音楽)
|
||||
- `<select id="audio-source">` gets an `aria-label` tied to the Audio label.
|
||||
- Reduced-motion behavior is unaffected (audio is orthogonal to motion).
|
||||
|
||||
## Tests
|
||||
|
||||
- **Node unit** (`simulator/unit/…`): `audioSource()` routing and the
|
||||
`blendAudio`/`restAudio` branch selection — none→silent, music→single-element +
|
||||
no crossfade on altitude change, soundtrack→unchanged.
|
||||
- **E2E** (`simulator/e2e/…`): dropdown switches modes; Music produces a single
|
||||
playing element that persists across an altitude change; None disables the Volume
|
||||
slider and silences audio; the slider label reads "Volume".
|
||||
|
||||
## Decisions (confirmed with operator)
|
||||
|
||||
1. **Music = one continuous, altitude-independent loop** (not per-altitude, not ducked).
|
||||
2. Default Audio source = **Soundtrack**; Music is opt-in.
|
||||
3. **None** disables the Volume slider.
|
||||
4. Music track is **operator-approved CC0** sourced by the implementer.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Session 0033.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T09-38 (PST)
|
||||
> End: 2026-06-30T09-43 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: c87a07d3-917c-44bc-82cf-c6d8edee952b
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Branch: design/cloudflare-static-publish (canonical); merge performed on main via worktree
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Operator asked "Have you merged to main?" → no (all work was on
|
||||
`design/cloudflare-static-publish`) → operator: "Yes" (merge it to main).
|
||||
|
||||
## Arc
|
||||
|
||||
1. Confirmed none of the credits work (nor the 17-commit static-publish line) was on
|
||||
`main`; surfaced the considerations (live branch, in-flight a11y session, brings all
|
||||
commits). Operator approved the merge.
|
||||
2. Claimed **session 0033**. Surveyed divergence: `main` and the branch had diverged —
|
||||
`main` carried real app work from the parallel labels/affect session (per-clip
|
||||
right-brain feelings `bf1013b`, i18n re-extract `2d54023`, affect/label fix
|
||||
`041fcde`), not just transcript publishes.
|
||||
3. Found only **two** overlapping files (`app.js`, `altitude-lock.spec.ts`).
|
||||
**Test-merged in a throwaway worktree** off `origin/main`: clean auto-merge, no
|
||||
conflicts. Ran the FULL suite on the merged tree — **node 29, pytest 315/2-skip,
|
||||
green** — before trusting the textual merge.
|
||||
4. Performed the real `--no-ff` merge on `main` in a worktree (keeping the canonical
|
||||
checkout on the live deploy branch), pushed `main` (`75961ea`→`87d605d`).
|
||||
5. Verified credits commits + `credits.html` are on `origin/main`, branch now 0 ahead.
|
||||
Removed the worktree; canonical checkout clean on `design/cloudflare-static-publish`.
|
||||
|
||||
## End state
|
||||
|
||||
- `origin/main` @ `87d605d` — now carries the entire Cloudflare static-publish line,
|
||||
including the CC-BY/BY-SA credits page + "License & reuse" CC BY-SA declaration.
|
||||
- `design/cloudflare-static-publish` is fully integrated (0 commits ahead of main).
|
||||
- The live site still serves the pre-merge build; **a redeploy** ships the merged main.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Merged via local `--no-ff` + push rather than a Gitea PR.** `gh` isn't wired (Gitea
|
||||
host) and the operator approved directly in autonomous mode; the work was developed on
|
||||
a branch and the merge produces the same merge-commit a PR would. (Alternative: open a
|
||||
Gitea-API PR for a review record — skipped for speed; can add retroactively if wanted.)
|
||||
- **Did not merge `main` back into `design/cloudflare-static-publish`.** The branch is 0
|
||||
ahead now; future static work can re-branch from main. Left as-is.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal redeploy benstull.art from main (now carries the CC-BY/BY-SA credits + "License & reuse" D1 declaration) per deploy/cloudflare/README.md; the About page / WCAG pass lands separately via the feat/accessibility-pass session
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# Session 0034.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T07-24 (PST)
|
||||
> End: 2026-06-30T10-01 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 08b95eb0-3ec5-400a-9236-da78f01139a0
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/fix+audio-and-tracking-labels
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> The audio isn't playing at all. Let's fix that and then let's make sure there are
|
||||
> left and right brain tracking labels for everything. Make sure you're working on a
|
||||
> worktree. There are other concurrent sessions that may have real merge conflicts to
|
||||
> handle.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- The simulator (`simulator/`) is a FastAPI + static-JS experience: an altitude dial
|
||||
over 6 scales (cosmos/orbit/sky/coast/reef/abyss), 41 rotating-pool clips, a
|
||||
real-time Kuwahara "dream" shader, two HUD label layers — **Think** (left-brain
|
||||
factual chips, `#overlay`) and **Feel** (right-brain affect words, `#affect`) —
|
||||
plus a per-altitude soundtrack with a 0–10 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 1–2) 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.08–0.24). The raw
|
||||
native-controls `#aud-test` player was ALSO silent on the operator's Safari while
|
||||
the element reported playing → isolated it BELOW the app. Operator confirmed:
|
||||
**their Safari sound was muted.** No code change.
|
||||
3. **"Labels not showing" on cosmos_tarantula.** Root cause: operator was on `:8000`
|
||||
(design branch), whose manifest had that clip's `annotations: []`. My branch
|
||||
renders all labels correctly; labels are knob-gated (none at Think/Feel 0).
|
||||
4. **Left (Think) enrichment.** Every clip → ≥3 factual labels (added one to each of
|
||||
23 thin clips; orbit_bluemarble 1→3), full es/fr/ja parity. Pipeline:
|
||||
`LABELS` in `build_pool_manifest.py` → build → catalogs → `translate_manifest.py
|
||||
merge`. (commit `8cfceff`)
|
||||
5. **Motion-tracking.** Converted the 4 still-static abyss creatures (octopus, bigfin
|
||||
squid, dandelion siphonophore, sea pig) static→tracked, 3-keyframe paths sampled
|
||||
by eye from frames (cv2 unavailable). Tracked clips 7→11. (`8cfceff`)
|
||||
6. **Think/Feel overlap avoidance.** `renderAffect` now nudges Feel words off Think
|
||||
chip plates (obstacles computed from manifest, tracked paths sampled → holds whole
|
||||
loop, no per-frame jitter; new `window.__hefNoDeconflict` seam). Verified 0
|
||||
overlaps across all 6 scales incl. a tracked clip over time. (commit `041fcde`)
|
||||
7. **Per-clip right-brain feelings.** Operator: feelings should match each ACTUAL
|
||||
video. Added `AFFECT_CLIP` (per-clip override, scale fallback) for all 41 clips →
|
||||
41 distinct feeling-sets. cosmos done by me as a reviewed sample; the other 5
|
||||
scales drafted by **5 parallel subagents** (one per scale) that each VIEWED footage
|
||||
frames and followed the cosmos recipe; I normalized + authored all es/fr/ja for the
|
||||
19 new nuance keys. 42 distinct feeling keys now. (commits `bf1013b` + `2d54023`)
|
||||
8. **Merge.** Merged branch → `origin/main` (clean; main had only advanced by
|
||||
`sessions/` transcript commits). Confirmed all 4 work commits are ancestors of
|
||||
`origin/main`; another session (`feat/accessibility-pass`) has since merged main +
|
||||
a11y on top (`380c2d1`).
|
||||
|
||||
## Cut state (final)
|
||||
|
||||
- All work committed, merged to `origin/main`, pushed. Working tree clean.
|
||||
- Verification: 34/34 catalog+i18n+build pytest, 16/16 node unit, live render shows
|
||||
≥3 Think chips + distinct Feel words per clip (incl. new keys) with overlap 0 and no
|
||||
JS errors. (3 unrelated pytest failures = cv2/ffmpeg absent in this env.)
|
||||
- Worktree RETAINED (not torn down) because it is serving the operator's by-eye review
|
||||
at `http://127.0.0.1:8011/author.html`; tear down + stop `:8011` when review done.
|
||||
- §9 deploy pipeline: N/A — project is exempt (static Cloudflare target).
|
||||
|
||||
## Deferred decisions (low-confidence calls)
|
||||
|
||||
- **Audio = no code change.** Chose to treat it as environmental after exhaustive
|
||||
repro rather than guess-patch a working engine; operator confirmed muted Safari.
|
||||
- **Left-label box positions authored WITHOUT viewing each frame** (23 enriched
|
||||
clips, except bluemarble) — factual text is grounded, positions are estimates that
|
||||
want by-eye review.
|
||||
- **Per-clip feelings drafted from a single MID-FRAME** (motion not seen) via parallel
|
||||
subagents — first-pass reads; emotions may want tuning once a clip is in motion.
|
||||
- **Left reef/coast moving-subject clips left STATIC** (reef_redsea/flowergarden
|
||||
schools, coast_otters/seals) — tracking deferred (diffuse subjects + cv2 absent).
|
||||
- **Merged directly to `main` via `git push HEAD:main`** (no Gitea PR) at operator's
|
||||
explicit request; gh is GitHub-only here and no `tea` CLI.
|
||||
|
||||
## Operator plate
|
||||
|
||||
- Review the first-pass label/track/feeling positions in `/author.html` on `:8011`.
|
||||
- For Think: check each chip sits on its subject + the 4 abyss tracks follow the
|
||||
creature. For Feel: check emotions ring true once clips are in motion.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal refine the first-pass left-label box positions and the 4 abyss creature tracks per operator by-eye review (grab a frame per clip, nudge each chip onto its subject), and motion-track the remaining moving-subject clips (reef schools, coast animals) — data-only via build_pool_manifest + i18n pipeline
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
# Session 0035.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T08-45 (PST)
|
||||
> End: 2026-06-30T10-08 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: ee3eaaac-d0a4-41a9-aa53-1919052fd8d3
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Not a `/goal` launch. Opened conversationally: "We need to make the entire
|
||||
simulator accessible, assuming it's not being used at a kiosk. What are the
|
||||
different types of accessibility and your suggestions (and difficulty)?" Then:
|
||||
build the first pass on a worktree, and add an `about.html` about the project's
|
||||
intent (accessible to all humans; vantage points from underwater→drones→space
|
||||
exploration; built with LLMs; honest that it's imperfect but more than the author
|
||||
alone could make).
|
||||
|
||||
Drove through the **superpowers** pipeline (brainstorming → writing-plans →
|
||||
executing-plans → finishing-a-development-branch), not a wgl init — so the wgl
|
||||
transcript ID was claimed at finalize (this file).
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Simulator built for an attended **kiosk**; the Cloudflare static publish
|
||||
(`design/cloudflare-static-publish`, already merged to `main` mid-session as
|
||||
`87d605d`) puts it on a public URL where no AT/attendant assumptions hold.
|
||||
- No `prefers-reduced-motion`, no keyboard on the SVG Altitude dial, several
|
||||
failing-contrast colors, no screen-reader path, no seizure safeguard — on a
|
||||
piece that is continuous motion + a "trippy at max" WebGL shader.
|
||||
|
||||
## Arc (turn-by-turn)
|
||||
|
||||
1. **Analysis** — read `index.html` / `style.css`, grepped `app.js` (zero aria /
|
||||
keyboard / reduced-motion). Enumerated 7 accessibility types grounded in the
|
||||
actual UI, with difficulty + a priority table. Recommended WCAG 2.1 AA + a
|
||||
motion/seizure layer.
|
||||
2. **Brainstorming** — operator chose: reduced-motion = **freeze-to-stills**;
|
||||
photosensitivity = **warning gate + flash audit**; about page = **English-first**.
|
||||
Added the about-page "imperfect but more than alone" beat. Wrote + committed the
|
||||
design spec.
|
||||
3. **Worktree** — first tried `EnterWorktree` (branches off `origin/main`); on
|
||||
inspecting the diff, the work belonged on `design/cloudflare-static-publish`
|
||||
(correct `app.js`, existing `credits.html` pattern). Operator: "new worktree off
|
||||
that, keep separate until ready for main." Created
|
||||
`feat/accessibility-pass` off the design branch under `.claude/worktrees/`.
|
||||
4. **Plan** — 10 bite-sized TDD tasks, committed.
|
||||
5. **Execution (TDD, test-first each)** — (1) layout: lang picker below Audio,
|
||||
globe inline-left; (2) AA contrast + focus-visible + visually-hidden; (3)
|
||||
`flash.js` pure WCAG-2.3.1 helper + node tests; (4) one-time photosensitivity
|
||||
gate; (5) reduced-motion freeze (single `playLoop()` guard + instant `autoScrub`);
|
||||
(6) keyboard/ARIA dial (`role=slider`, Arrow/Home/End, label buttons); (7)
|
||||
aria-live narration + `aria-hidden` decoratives; (8) wire flash-clamp + document
|
||||
audit (no breach found); (9) `about.html` + header link + `PUBLIC_ASSETS`.
|
||||
9 a11y e2e + 2 node tests, all green; fixed a gate-vs-run-sim regression in the
|
||||
shared `boot()` helper.
|
||||
6. **Finish** — operator: merge everything to main + push origin. `origin/main`
|
||||
had advanced 29 commits (static-publish already landed there) → divergent merge;
|
||||
one `app.js` conflict (main's D3 reverse-landing fix vs my reduced-motion guard)
|
||||
resolved keeping **both**. Re-verified on merged code (a11y 9/9, altitude-lock
|
||||
13/13, i18n 1/1, static-build 2/2, flash 2/2). Pushed `ff06783..380c2d1 main`.
|
||||
Removed the worktree; stopped bg servers.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **a11y pass + About page MERGED + PUSHED to `origin/main` (`380c2d1`).**
|
||||
- On `main` but **NOT deployed** — live benstull.art needs a Cloudflare redeploy.
|
||||
- App has **no content repo** → plan stays in-repo at
|
||||
`docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md`. No
|
||||
`submit-plan.sh`. Project is **exempt** from §9/flotilla/PPE (Cloudflare static).
|
||||
- A **concurrent** commit `1a6ffc7` ("phase-1 preload gate") sits on local `main`
|
||||
ahead of origin by 1 — **not this session's; left untouched/unpushed** per
|
||||
operator ("leave anything that isn't yours alone"). Other worktree
|
||||
`fix+audio-and-tracking-labels` also left alone.
|
||||
- Checkout now on `main` (was `design/cloudflare-static-publish` at start).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous low-confidence calls (no live placeholder existed, so logged here):_
|
||||
|
||||
- **SR narration = scale + clip title**, not the tiered factual (Think) HUD label —
|
||||
simpler/robust; the plan explicitly permitted the degrade. Could enrich later.
|
||||
- **Flash audit concluded no >3/sec breach** (1200ms per-altitude floor; fades are
|
||||
single, ≥1.2s apart); `flash.js` wired only as a guard floor (no-op today).
|
||||
- **Branched off the design branch, not main** — corrected after operator input.
|
||||
- **Merged the full divergent history to main** (static-publish was already on
|
||||
origin/main, so net-new was just the a11y work) — on explicit operator instruction.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal redeploy benstull.art/human-experience-simulator to Cloudflare so the accessibility pass + About page go live, per cloudflare-deploy-live (build with `python3 -m tools.build_static`, then the R2/Pages deploy gestures)
|
||||
```
|
||||
|
||||
Operator by-eye review of the a11y changes precedes the redeploy. Env gotchas for
|
||||
the next session: run `build_static` as a **module** (`python3 -m tools.build_static`)
|
||||
or `tools/http.py` shadows stdlib `http`; uvicorn dies mid-session and the e2e
|
||||
`webServer` fallback calls `python` (only `python3` here) → start it by hand on :8099;
|
||||
Playwright `test.use()` inside `test.describe()` breaks collection in PW 1.45.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Session 0036.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T06-30 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: a7814ad4-473f-4946-b158-a5fad35b4bf1
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Status: finalized
|
||||
|
||||
## Launch prompt
|
||||
|
||||
"Let's publish this to benstull.art. Ideally this would be hosted on Cloudflare
|
||||
Pages. Cloudflare Pages does have limits though, so we should check that against
|
||||
this project." (Started conversationally — no tracked init; finalized as
|
||||
planning-and-executing/yolo.)
|
||||
|
||||
## Pre-state
|
||||
|
||||
The simulator was a FastAPI server (`simulator/app.py`) serving a ~92 KB static
|
||||
frontend + 3.9 GB of media (1080p clip bases + 558 already-720p transition morphs +
|
||||
audio) via dynamic endpoints. Not yet public anywhere. Operator wanted it live at
|
||||
benstull.art on Cloudflare Pages. Concurrent sessions (0029/0031/0033/0034/0035)
|
||||
were active on the SAME checkout.
|
||||
|
||||
## Arc
|
||||
|
||||
**1. Brainstorm → design → plan (Cloudflare publish).** Established the blocker:
|
||||
Pages has a 25 MiB/file hard limit; the project has 26 files >25 MiB. Designed a
|
||||
fully-static target: Pages frontend + R2 media (no per-file limit, free egress) +
|
||||
read-APIs baked to JSON + client-side pick + `/api/alteration` ported to JS.
|
||||
Operator chose: both tiers irrelevant→dropped; path `/human-experience-simulator`
|
||||
with apex redirect; no cross-session cache (initially). Spec + plan written under
|
||||
`docs/superpowers/`. Operator declared the WHOLE project exempt from flotilla/PPE/§9.
|
||||
|
||||
**2. Executed the plan (5 tasks).** Client-side alteration engine (`alteration.js`
|
||||
+ node/py parity tests); config-driven `mediaBase` + baked-JSON boot + client pick +
|
||||
`crossOrigin` for the WebGL dream shader; `tools/build_static.py` (dist/ + manifest-
|
||||
only ~2.3 GB media tree + `_redirects`); Cloudflare deploy artifacts; static-build
|
||||
E2E. Hit + cleanly extracted a parallel session's in-flight "Run simulation" work
|
||||
that got swept into a commit (operator chose extraction).
|
||||
|
||||
**3. Live deploy.** Installed wrangler; operator did `wrangler login`. Created R2
|
||||
bucket `human-experience-simulator-media`, CORS (wrangler's `{rules:[...]}` schema,
|
||||
not S3-style), bound `static.benstull.art` via CLI (zone-id from API), parallel
|
||||
604-file upload (no `wrangler r2 sync` exists). Created + deployed Pages project;
|
||||
operator bound `benstull.art`. **Live.**
|
||||
|
||||
**4. Debugging (systematic).** "Morphs not showing" = upload still in progress
|
||||
(transitions sort last) → fixed by parallel upload. "Video + audio not playing" →
|
||||
drove the LIVE site with Playwright: video fine, **audio 404'd** because
|
||||
`scaleAudioUrl` hardcoded `/media/audio/` instead of `mediaBase()` — fixed.
|
||||
"Still no video after hard refresh" → **stale cached JS** (Pages caches .js
|
||||
max-age=14400, ignores `_headers`) → fixed with content-versioned asset URLs
|
||||
(`app.js?v=<hash>`) + no-cache `_headers`. Private-window confirmed fresh code worked.
|
||||
|
||||
**5. Preload algorithm.** "Next altitude's video doesn't start" = a buggy
|
||||
hold-last-frame settle gate I'd added (gated playLoop behind a canplay that doesn't
|
||||
re-fire) → reverted. Operator specified the algorithm: gate on 1 clip/altitude +
|
||||
connecting morphs (~126 MB, ~10-20s — measured), then grow the pool in the
|
||||
background; the random pick is restricted to fully-loaded (eligible) clips so
|
||||
navigation never stalls and new clips are reachable only once fully downloaded.
|
||||
Built `preload.js` (pure planner + node tests), wired phase-1 gate + eligible picks.
|
||||
|
||||
**6. Worktree isolation enforcement.** The shared checkout caused branch-switch
|
||||
chaos (my commits landed on a switched-to `main`). Operator: "make sure every
|
||||
session works off a fresh worktree not in the main directory." Built
|
||||
`.claude/settings.json` (worktree.bgIsolation + baseRef:fresh) + a PreToolUse hook
|
||||
(`worktree-guard.sh`) that DENIES edits to repo files while in the main checkout +
|
||||
SessionStart notice → EnterWorktree. Proven live (blocked my own edit); fixed an
|
||||
over-broad version that blocked external files too. Dogfooded the merge of guard +
|
||||
preload onto origin/main IN an isolated worktree.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **Live:** https://benstull.art/human-experience-simulator/ — Pages + R2, preload
|
||||
algorithm, audio fixed, cache-versioned. Operator confirmed video+audio work.
|
||||
- **origin/main (`a4d3d83`+):** has the Cloudflare publish, preload algorithm, and
|
||||
worktree-isolation guard, merged cleanly with parallel a11y work.
|
||||
- Plan archived in-repo (`docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`),
|
||||
on origin/main. **No `content` repo** → plan stays in-repo (app.json gap).
|
||||
- Local `main` checkout is behind origin/main (parallel session actively deploying).
|
||||
- Backup branches on origin: `chore/worktree-isolation`, `fix/preload-gate`,
|
||||
`design/cloudflare-static-publish` (content all merged to main; safe to delete).
|
||||
|
||||
## Pipeline (§9) status
|
||||
|
||||
Operator-set Cloudflare exception (no flotilla/PPE). localhost + **E2E green** (33
|
||||
node tests; 13 altitude-lock + 2 static-build Playwright); no PPE (exempt); **prod =
|
||||
live** via `make deploy`. Repeatable deploy: `make deploy` / `make deploy-media`
|
||||
(`deploy/cloudflare/`).
|
||||
|
||||
## Operator plate (next session)
|
||||
|
||||
- By-eye/ear review of the live site: load time, video, audio, rapid-altitude
|
||||
navigation smoothness (eligible-pick growth), the preload behavior under real
|
||||
network. The hold/grow path only triggers when media isn't cached — needs eyes.
|
||||
- Restart any still-running sessions so they pick up the worktree guard + move to
|
||||
worktrees. Clean up backup branches if desired.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- Estimated session start time (no tracked init) — used 2026-06-30T06-30.
|
||||
- Did NOT reconcile local `main` (behind origin/main) — a parallel session is
|
||||
actively deploying origin/main; left the shared checkout untouched to avoid
|
||||
disrupting it.
|
||||
- Cross-session Service-Worker disk cache and a 720p base tier were proposed and
|
||||
DEFERRED (morphs already 720p; bases dominate load but per-altitude growth covers it).
|
||||
@@ -0,0 +1,76 @@
|
||||
# Session 0037.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T10-42 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: aa7ac424-d797-4918-8837-2a904441d91e
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/session-0037-altitude-snap-on-release
|
||||
> End: 2026-06-30T10-52 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
|
||||
## Summary
|
||||
|
||||
Single-task UX session. Made the altitude dial **snap to the closest altitude on
|
||||
drag release** so a grab-and-release always lands on a discrete altitude, just
|
||||
like a tap/label-click.
|
||||
|
||||
### Arc
|
||||
|
||||
1. Claimed session 0037 (two stale `--INPROGRESS` placeholders 0015/0017 noted,
|
||||
not live). Worked in an isolated worktree
|
||||
(`session-0037-altitude-snap-on-release`) branched off `origin/main` (local
|
||||
`main` was 7 behind).
|
||||
2. Located the behavior: `onDialUp` (`simulator/static/app.js`) held the live
|
||||
blend wherever a real turn stopped — the "no auto-complete" continuous-encoder
|
||||
model. Tap path (`moved < 6` → `jumpToScale`) and wheel/label auto-scrub were
|
||||
already snap-to-altitude.
|
||||
3. TDD: added an `altitude-lock.spec.ts` e2e ("releasing a partial drag snaps to
|
||||
the closest altitude") — confirmed RED (held pos never became integer).
|
||||
4. Implemented: a real turn (`moved >= 6`) now `autoScrub(Math.round(pos))` →
|
||||
short scrub to the nearest altitude, settle + lock (frac 0). Reduced motion is
|
||||
handled inside `autoScrub` (jump-to-target). GREEN.
|
||||
5. Verified: full altitude-lock (14) + a11y + i18n e2e suites green. Committed,
|
||||
pushed, opened PR #31, merged to `main` (`5696b94`).
|
||||
|
||||
### Pipeline (§9)
|
||||
|
||||
This app is **exempt** from flotilla/PPE/§9 prod gate — public target is static
|
||||
Cloudflare (Pages + R2). Change is merged to `main`; **operator redeploy** of the
|
||||
static build is the remaining ship step. No plan artifact archived: the change
|
||||
was a trivial leaf `task`, planned inline (no separate plan file; app has no
|
||||
content repo).
|
||||
|
||||
### Next session
|
||||
|
||||
Operator by-eye review of the snap feel on the live sim, then redeploy to
|
||||
Cloudflare.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> When the user is dragging the slider (rather than clicking on an altitude),
|
||||
> let's transition to the closest altitude when they let go. That way, the user
|
||||
> will always land on an altitude as if they clicked one, even if they're
|
||||
> grabbing and releasing the slider.
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: leaf `task` (UX behavior tweak — directly plan-eligible, R2b).
|
||||
|
||||
Snap-on-release for the altitude dial. Today `onDialUp` (app.js) holds the live
|
||||
blend wherever a drag stops (the "no auto-complete" continuous-encoder model).
|
||||
Change: after a real turn (`moved >= 6`), `autoScrub(Math.round(pos))` so the
|
||||
knob settles on the CLOSEST altitude (frac 0 → lock), identical to a tap/click.
|
||||
Tap path (`moved < 6` → `jumpToScale`) unchanged. Reduced-motion is handled by
|
||||
`autoScrub` (jump-to-target, no tween).
|
||||
|
||||
Verification: new e2e in `altitude-lock.spec.ts` ("releasing a partial drag
|
||||
snaps to the closest altitude") — TDD red→green; full altitude-lock (14) + a11y
|
||||
+ i18n suites green. Project is exempt from §9/PPE (static Cloudflare target);
|
||||
ships branch → PR → merge → operator redeploy.
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous-mode low-confidence calls the driver made and would have
|
||||
liked operator input on. Appended as the session runs; surfaced at
|
||||
finalize. Empty if none._
|
||||
@@ -0,0 +1,102 @@
|
||||
# Session 0038.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T12-25 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 52b811fa-0926-4619-ae31-3c71d489c686
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> End: 2026-06-30T13-20 (PST)
|
||||
> Status: **FINALIZED.**
|
||||
|
||||
## Summary
|
||||
|
||||
Added a **Playback Speed slider** to the simulator's Output panel, replacing the
|
||||
"Reduce motion" toggle. Designed (brainstorm → spec), built TDD-first, shipped to
|
||||
`main`, then **revised the scale after operator eyeball**: the initial −2×…2×
|
||||
range with a manual reverse loop jittered in a real browser (base loop clips
|
||||
aren't all-intra), so it was narrowed to **0×–4× forward** and the reverse
|
||||
machinery removed.
|
||||
|
||||
### Arc
|
||||
|
||||
1. **Brainstorm** (no tracked init this session — claimed s0038 at finalize).
|
||||
Surfaced the real tradeoff: "Reduce motion" is the a11y safety control
|
||||
(`isReduced()`), doing three things beyond freezing the loop (OS-pref default,
|
||||
instant transitions, photosensitivity floor). Two operator decisions via
|
||||
AskUserQuestion: **keep a11y but move it** (derive from OS
|
||||
`prefers-reduced-motion`, no visible toggle) and **implement a true manual
|
||||
reverse loop** for the negative range.
|
||||
2. **Spec** written + committed to
|
||||
`docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md`.
|
||||
3. **Build (round 1, −2…2 + reverse), TDD:** worked in worktree
|
||||
`feat/playback-speed-slider` (off `origin/main`). Pure node-tested
|
||||
`HEFScrub.reverseStep` helper (RED→GREEN); slider + datalist in `index.html`;
|
||||
`applyPlaySpeed` + rAF reverse loop in `app.js`; `isReduced()` re-derived from
|
||||
OS pref; retired `hef.reduceMotion`; i18n `speed.label` + reworded `warn.body`.
|
||||
Verified 14/14 node unit, 13/13 a11y e2e (Task 5 rewritten), static-build e2e
|
||||
2/2 (built the dist bundle to confirm). Merged to `main` (`df8b245`) —
|
||||
concurrent session pushed mid-merge, so rebased onto the new tip and pushed.
|
||||
4. **Operator eyeball:** reverse was jittery; operator chose **0×–4×** as the
|
||||
scale.
|
||||
5. **Build (round 2, 0×–4× forward):** worktree `feat/speed-forward-0-4x`.
|
||||
Removed the reverse rAF loop + `reverseStep` helper + its 4 unit tests;
|
||||
slider `min=0 max=4`; datalist ticks at 0.5; spec annotated with a revision
|
||||
note. Verified 10/10 node unit, 12/12 a11y e2e (against the actually-served
|
||||
worktree code). Merged to `main` (`e064a2e`, clean FF) and pushed.
|
||||
6. Served the merged code locally at `http://localhost:8099/` for re-view both
|
||||
rounds.
|
||||
|
||||
### Pipeline (§9)
|
||||
|
||||
This app is **exempt** from flotilla/PPE/§9 prod gate — public target is static
|
||||
Cloudflare (Pages + R2). Change is merged to `main`; **operator redeploy** of the
|
||||
static build is the remaining ship step. E2E browser tests (Playwright) were run
|
||||
locally and are green. No plan artifact archived: design lived in the spec
|
||||
(already in this repo's `specs/`, merged); app has no `content` repo.
|
||||
|
||||
### Next session
|
||||
|
||||
Operator by-eye review of the 0×–4× speed feel on the live sim, then redeploy to
|
||||
Cloudflare to publish it.
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> Let's add a "Playback Speed" slider that goes from -2x (which would play
|
||||
> backwards) to 2x, with increments of 0.25. This only applies to the altitude
|
||||
> videos, not the morph videos. This can replace the "Reduce motion" toggle
|
||||
|
||||
_(Mid-session the operator refined: continuous drag rather than 0.25 detents;
|
||||
then, after eyeballing reverse jitter, narrowed the scale to 0×–4× forward.)_
|
||||
|
||||
## Plan
|
||||
|
||||
> Anchor: leaf `feature`/`task` (UI control on an existing surface). Design →
|
||||
> spec → TDD build → ship branch → PR/merge → operator redeploy.
|
||||
|
||||
Continuous Playback Speed slider driving ONLY the resting altitude loop video
|
||||
(`#vid-loop`); morph/transition video (`#vid`) is scrub-driven and untouched.
|
||||
Reduced-motion accessibility preserved but derived silently from OS
|
||||
`prefers-reduced-motion` (slider disabled when active). Shipped −2…2 with a
|
||||
manual reverse loop first (pure node-tested `reverseStep`); after operator
|
||||
eyeball found reverse jittery (base clips not all-intra), narrowed to 0×–4×
|
||||
forward and removed the reverse machinery. Verification: node unit + Playwright
|
||||
a11y e2e + static-build e2e, all green per round. Project exempt from §9/PPE
|
||||
(static Cloudflare).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- **Direct FF-merge to `main` (no PR).** Operator explicitly said "Merge to
|
||||
main," so both rounds landed via fast-forward (round 1 rebased over a
|
||||
concurrent push) rather than opening a Gitea PR. Alternative: branch → PR →
|
||||
merge (§5.4 norm). Operator-directed, so low risk.
|
||||
- **Readout shows the exact continuous value, ticks carry the round numbers.**
|
||||
The slider value is shown as e.g. `1.37×`; the "nice" increments (0.5 marks)
|
||||
live as `<datalist>` ticks rather than snapping the value. My call within the
|
||||
approved design; operator approved the spec.
|
||||
- **Datalist tick spacing 0.5 over 0–4** (vs 0.25 over −2…2 in round 1). Cosmetic
|
||||
choice to avoid clutter on the wider forward range. My call.
|
||||
- **Reverse fully removed, not kept as a smaller range.** Operator said "just go
|
||||
0x - 4x as the scale," so the `reverseStep` helper + tests were deleted rather
|
||||
than parked. If reverse is wanted later, re-encode base loop clips all-intra
|
||||
first (noted in memory + spec revision).
|
||||
@@ -0,0 +1,68 @@
|
||||
# Session 0039.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T10-40 (PST)
|
||||
> End: 2026-06-30T23-52 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 9c986be3-c1f9-4123-bf7a-d62dd34810dd
|
||||
> Checkout: worktree `welcome-screen-controls` (branch `worktree-welcome-screen-controls`, off origin/main)
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Goal:** Build a pre-load "Welcome" screen for the simulator, then iterate it live per operator feedback.
|
||||
**Outcome:** Shipped a large run of frontend work to production and **merged to `main` (`7ec8b60`, fast-forward)**. The project was renamed **Human/Machine Strata** and moved to **benstull.art/human-machine-strata/** (old URL 301-redirects). Live == main at close.
|
||||
|
||||
## Plan
|
||||
|
||||
Emergent, operator-driven (not a single up-front plan). The through-line:
|
||||
1. Welcome screen: hide the right pane during load; put Video / Reduce-motion / Audio / Language controls on the pre-load screen; heads-up before "Loading Universe"; two states (welcome → centered loader); "Launch Simulator" entry.
|
||||
2. Reconcile two `main` updates that landed mid-session (Playback Speed slider, −2..2 then 0×–4×).
|
||||
3. Rework welcome controls → Language / Speed / Audio; drop the video toggle (video assumed on); re-add a Reduce-motion toggle that turns OFF morph transitions only (loop stays on Speed).
|
||||
4. Publish to Cloudflare; then a stream of live tweaks: cap speed at 2×, cache hardening, panel reorder, rename, URL move.
|
||||
5. Merge to `main` + final deploy.
|
||||
|
||||
## Pre-session state
|
||||
|
||||
- Simulator is a fully-static Cloudflare deployment (Pages + R2) — exempt from the Wiggleverse §9/flotilla pipeline; deploys straight to benstull.art. Was live at `/human-experience-simulator/`.
|
||||
- Entry flow was: bare "Loading Universe" splash → fade → "Run simulation" button over a black stage → first-visit "Heads up" modal → begin (video on, audio→2). Controls (Video/Audio/Reduce-motion/Language) lived in the always-visible right panel.
|
||||
- Parallel sessions were active on the shared checkout; this session ran in an isolated worktree per the isolation rule.
|
||||
|
||||
## Turn-by-turn arc
|
||||
|
||||
1. **Brainstorm + spec.** Explored the loading screen, right pane, controls, and i18n via an Explore agent. Wrote/committed the design spec (`05328b1`, `docs/superpowers/specs/2026-06-30-welcome-screen-design.md`). Operator refined it live (State B centered loader; "Launch Simulator" as the single entry button).
|
||||
2. **Build the welcome screen** (`f077193`): folded `#loading` + `#motion-warning` + `#run-sim` into one `#welcome` overlay; State A (heads-up + controls + Launch, loader at bottom) → State B (Launch-while-loading: messaging gone, controls stay, loader centered) → auto-enter; panel hidden until entry via `body:has(#welcome)`; added `window.__hefReady` e2e seam; i18n `welcome.launch` + localized the previously en-only `warn.*`. Discovered + fixed a pre-existing i18n parity failure in passing. Node 35, e2e 27, static 2, pytest 301. Untracked stray `test-results/` artifacts (`7cdf594`).
|
||||
3. **Reconcile main (twice).** Mid-session `main` gained a Playbook Speed slider (`df8b245`, −2..2) then narrowed it to 0×–4× forward, dropping reverse (`e064a2e`). Merged both into the branch; resolved conflicts in app.js/i18n/a11y; generalized the speed machinery to drive both the welcome and panel sliders.
|
||||
4. **Welcome controls reworked** → Language / Speed / Audio (video assumed on at entry).
|
||||
5. **Reduce-motion returns** (`5f9ae6b`). Operator: "we actually do need Reduced motion to turn off the morph videos." Speed only drives the resting loop, so re-added a visible Reduce-motion toggle (welcome + panel, default from OS pref) that makes altitude changes **instant cuts** (no morph tween) while the loop keeps following Speed. Decoupled the loop from reduce-motion (main had them coupled).
|
||||
6. **Publish to benstull.art.** Verified wrangler auth, ran the frontend-only deploy; verified live + a headless smoke.
|
||||
7. **Live tweaks (a stream):** "Video speed" rename (`79f64e3`); cap speed at 0–2× — >2× stutters, same all-intra reason reverse was dropped (`ccd4010`); panel: Experience knobs above Altitude, drop the dial hint + "(0–4)" (`fafb498`); page title + h1 → then renamed to **Human/Machine Strata** (`958c306`); move Altitude to the TOP of the panel for mobile (`c36f995`).
|
||||
8. **Debugged "video + audio not playing on launch"** (systematic-debugging). Could not reproduce in Chromium headless, Chromium strict-autoplay, OR WebKit — everything played. Turned out to be a **stale Safari app-shell** (worked in a private window). Also found a stale uvicorn from another checkout squatting on :8099 serving old HTML, and `| tail` masking playwright's exit code — both wasted time.
|
||||
9. **Cache hardening.** app-shell was `no-cache`; a per-HTML `no-store` override did NOT stick (Cloudflare Pages let `/seg/*` win), so switched the whole app path to `no-store` (`8c63b14`).
|
||||
10. **Reduce-motion regression fixed** (`ecd444b`). Operator: "video doesn't change when a new altitude is selected with Reduce motion on." TDD: reproduced with a failing e2e, root-caused (the reduced-motion jump skips the segment that re-rolls the destination clip → `activeClipId` stayed on the old scale's clip), fixed with a self-heal at the `setPos` frac-0 landing (re-roll if the landed pool doesn't contain `activeClipId`).
|
||||
11. **Rename + URL move.** Named the project **Human/Machine Strata**; moved the URL to `/human-machine-strata/` (`950c1e6`) with a permanent 301 from the legacy path (`LEGACY_APP_PATH`); the bare-root case needed its own rule (`b135cb5`). R2 bucket + Pages project names kept (not in the URL → no media re-upload). Updated the build test, static-build e2e path, and README paths.
|
||||
12. **Merge + final deploy.** `main` had diverged by 2 commits (session-transcript files only) — merged origin/main in cleanly, then fast-forwarded `main` to the branch (`7ec8b60`). Deployed; verified new URL + old-URL 301.
|
||||
|
||||
## Cut state (end of session)
|
||||
|
||||
- **`main` = `origin/main` = branch `worktree-welcome-screen-controls` = `7ec8b60`.** Working tree clean; everything pushed and merged.
|
||||
- **Live: https://benstull.art/human-machine-strata/** serves "Human/Machine Strata"; old `/human-experience-simulator/` (root + subpaths) 301-redirects; app-shell `Cache-Control: no-store`.
|
||||
- Verification at close: node 35/35, Playwright dynamic e2e 33/33 (incl. new reduce-motion instant-cut clip test), static-build 2/2, pytest 301 passed (3 pre-existing ffmpeg/cv2 env-only failures, unrelated).
|
||||
- Key commits (all on `main` via `7ec8b60`): `05328b1` spec · `f077193` welcome screen · reconcile merges of main's speed feature · `5f9ae6b` reduce-motion toggle · `ccd4010` 2× cap · `8c63b14` no-store · `fafb498` knobs-above-altitude · `ecd444b` reduce-motion clip re-roll · `c36f995` altitude-on-top · `958c306` rename · `950c1e6` URL move · `b135cb5` bare-root redirect · `7ec8b60` merge.
|
||||
|
||||
## What lands on the operator's plate
|
||||
|
||||
- **By-eye review on real Safari** of the shipped experience (headless can't exercise real autoplay/GPU; the reduce-motion instant-cut and 2× cap are logic/test-verified only).
|
||||
- **Optional cleanup:** the dev-panel `#aud-test` element has a hardcoded `/media/...` src that 404s on the R2 site (dev-only, no user impact) — route it through `mediaBase()` if desired.
|
||||
- **Possible follow-up:** "more welcome copy beyond the heads-up" (operator flagged early; the welcome-message block is structured to take more `data-i18n` paragraphs).
|
||||
|
||||
## Prompt the operator can paste into the next session
|
||||
|
||||
```
|
||||
/goal Human/Machine Strata follow-ups. The welcome screen + rename + URL move are live and merged to main (benstull.art/human-machine-strata/). Next: (1) add the intro/framing welcome copy beyond the "Heads up" notice (welcome-message block is data-i18n-ready, 4 langs); (2) optionally clean up the dev-panel #aud-test hardcoded /media 404 by routing it through mediaBase(). Reminder env gotchas: only python3 on this box (start uvicorn by hand, __hefReady e2e seam replaces the old #loading wait); a stale uvicorn from another checkout can squat on :8099 (curl :8099 | grep welcome to confirm); don't trust `… | tail` exit codes.
|
||||
```
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
- Kept "Human/Machine Strata" as an **untranslated proper title** across all 4 languages (app.title), rather than localizing it — treated as the work's title. Low-confidence; easy to localize if the operator prefers.
|
||||
- Renamed the app **URL path** but **not** the Pages project / R2 bucket names (they aren't in the benstull.art URL; renaming them would force a 2.3 GB media re-upload). Judged not worth it.
|
||||
- Session started `careful`? No — no init ran; posture defaulted to `yolo` (planning-and-executing). All merges/deploys were operator-directed step by step.
|
||||
@@ -94,5 +94,26 @@
|
||||
},
|
||||
"0032": {
|
||||
"title": ""
|
||||
},
|
||||
"0033": {
|
||||
"title": ""
|
||||
},
|
||||
"0034": {
|
||||
"title": ""
|
||||
},
|
||||
"0035": {
|
||||
"title": ""
|
||||
},
|
||||
"0036": {
|
||||
"title": ""
|
||||
},
|
||||
"0037": {
|
||||
"title": ""
|
||||
},
|
||||
"0038": {
|
||||
"title": ""
|
||||
},
|
||||
"0039": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
"""Production pass for the 5 per-altitude soundtracks (audio spec §5.3). The audio
|
||||
analogue of build_pool_manifest.py --media: read the sourced ambience clips
|
||||
(re-downloadable per docs/audio-candidate-pool.md), and make each a seamless
|
||||
loudness-normalized loop.
|
||||
"""Production pass for the 5 per-altitude soundtracks + the Music playlist
|
||||
(audio spec §5.3; Music per docs/music-candidate-pool.md). The audio analogue of
|
||||
build_pool_manifest.py --media: read the sourced clips (re-downloadable per the
|
||||
candidate-pool docs) and make each web-ready.
|
||||
|
||||
Media is gitignored; this script is the reproducible record. Outputs land under
|
||||
simulator/sample_media/audio/<scale>/<name>.loop.mp3, served at /media/audio/...
|
||||
Run: python simulator/build_audio_media.py
|
||||
- Soundtracks: seamless loudness-normalized loops -> audio/<scale>/<name>.loop.mp3.
|
||||
- Music: the mellow drumless synth playlist (Kevin MacLeod, CC BY 4.0) — NOT looped
|
||||
(the client crossfades between tracks). Each is loudness-normalized and transcoded
|
||||
to a bitrate that keeps it under the ~25MB git-LFS/nginx limit (ambient tolerates
|
||||
it) -> audio/music/<name>.mp3. Put the downloaded original at audio/music/<name>.src.mp3
|
||||
(URLs below) first; this transcodes it.
|
||||
|
||||
(White-noise is deferred — the live Audio control is a simple on/off soundtrack
|
||||
toggle. `tools.pipeline.audio_run.generate_white_noise` remains for when it's
|
||||
wanted, but no noise bed is built here.)
|
||||
Media is committed via git-LFS; this script is the reproducible record.
|
||||
Run: python -m simulator.build_audio_media
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tools.pipeline.audio_run import process_soundtrack
|
||||
from tools.pipeline.run import resolve_ffmpeg
|
||||
|
||||
AUDIO = Path(__file__).parent / "sample_media" / "audio"
|
||||
|
||||
@@ -30,6 +34,29 @@ SOURCES: dict[str, tuple[str, str]] = {
|
||||
"abyss": ("whale.wav", "whale.loop.mp3"),
|
||||
}
|
||||
|
||||
# Music playlist (docs/music-candidate-pool.md § Round 4): name -> (incompetech source
|
||||
# URL, target bitrate). Kevin MacLeod, CC BY 4.0 — credited on credits.html. Drumless,
|
||||
# chord-moving ambient; the operator's picks. Longer tracks get a lower bitrate to stay
|
||||
# under the LFS size limit. Client playlist: app.js MUSIC_PLAYLIST.
|
||||
_KM = "https://incompetech.com/music/royalty-free/mp3-royaltyfree/"
|
||||
MUSIC: dict[str, tuple[str, str]] = {
|
||||
"manatees": (_KM + "Music%20for%20Manatees.mp3", "96k"),
|
||||
"dewdrop": (_KM + "Dewdrop%20Fantasy.mp3", "80k"),
|
||||
"eastminster": (_KM + "Eastminster.mp3", "128k"),
|
||||
"overheat": (_KM + "Overheat.mp3", "128k"),
|
||||
"concentration": (_KM + "Concentration.mp3", "80k"),
|
||||
}
|
||||
|
||||
|
||||
def _transcode(src: Path, dst: Path, bitrate: str, ff: str) -> Path:
|
||||
"""Loudness-normalize + transcode to mp3 at `bitrate` (no looping)."""
|
||||
subprocess.run(
|
||||
[ff, "-y", "-hide_banner", "-loglevel", "error", "-i", str(src),
|
||||
"-af", "loudnorm", "-c:a", "libmp3lame", "-b:a", bitrate, "-ar", "44100", str(dst)],
|
||||
check=True,
|
||||
)
|
||||
return dst
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for scale, (raw, out) in SOURCES.items():
|
||||
@@ -40,6 +67,15 @@ def main() -> None:
|
||||
dst = process_soundtrack(src, AUDIO / scale / out)
|
||||
print(f" produced {dst.relative_to(AUDIO.parent)}")
|
||||
|
||||
ff = resolve_ffmpeg()
|
||||
for name, (url, bitrate) in MUSIC.items():
|
||||
src = AUDIO / "music" / f"{name}.src.mp3"
|
||||
if not src.exists():
|
||||
print(f" SKIP music/{name}: source missing ({src}) — download {url}")
|
||||
continue
|
||||
dst = _transcode(src, AUDIO / "music" / f"{name}.mp3", bitrate, ff)
|
||||
print(f" produced {dst.relative_to(AUDIO.parent)} @ {bitrate}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Serves the built dist/ (app) and dist-media/ (media, with CORS + Range) on two
|
||||
// ports — a local stand-in for Pages + R2, so the static-build E2E exercises the
|
||||
// cross-origin media path exactly as production will (CORS for the WebGL dream
|
||||
// shader, Range for video scrubbing).
|
||||
//
|
||||
// Env: DIST_DIR (app root), MEDIA_DIR_E2E (media root). Ports: app 8077, media 8078.
|
||||
import http from "node:http";
|
||||
import { createReadStream, statSync } from "node:fs";
|
||||
import { join, extname, normalize } from "node:path";
|
||||
|
||||
const TYPES = {
|
||||
".html": "text/html", ".js": "text/javascript", ".json": "application/json",
|
||||
".css": "text/css", ".mp4": "video/mp4", ".mp3": "audio/mpeg",
|
||||
};
|
||||
|
||||
function serve(root, port, cors, indexFallback) {
|
||||
http.createServer((req, res) => {
|
||||
if (cors) { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Accept-Ranges", "bytes"); }
|
||||
let p = decodeURIComponent(req.url.split("?")[0]);
|
||||
if (p.endsWith("/")) p += "index.html";
|
||||
// contain to root (no path traversal)
|
||||
const file = normalize(join(root, p));
|
||||
if (!file.startsWith(normalize(root))) { res.writeHead(403); return res.end("forbidden"); }
|
||||
try {
|
||||
const st = statSync(file);
|
||||
res.setHeader("Content-Type", TYPES[extname(file)] || "application/octet-stream");
|
||||
const range = req.headers.range; // honor Range for video scrub
|
||||
if (range && /^bytes=/.test(range)) {
|
||||
const [s, e] = range.replace("bytes=", "").split("-");
|
||||
const start = +s, end = e ? +e : st.size - 1;
|
||||
res.writeHead(206, {
|
||||
"Content-Range": `bytes ${start}-${end}/${st.size}`,
|
||||
"Content-Length": end - start + 1,
|
||||
});
|
||||
createReadStream(file, { start, end }).pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, { "Content-Length": st.size });
|
||||
createReadStream(file).pipe(res);
|
||||
}
|
||||
} catch {
|
||||
res.writeHead(404); res.end("not found");
|
||||
}
|
||||
}).listen(port);
|
||||
}
|
||||
|
||||
serve(process.env.DIST_DIR, 8077, false);
|
||||
serve(process.env.MEDIA_DIR_E2E, 8078, true);
|
||||
console.log("static app :8077 media(cors) :8078");
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Accessibility pass (feat/accessibility-pass) — non-kiosk public-web a11y.
|
||||
// Each block maps to a task in
|
||||
// docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md.
|
||||
|
||||
// Enter the simulation via the welcome screen (the panel is hidden until then).
|
||||
async function enter(page: Page) {
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe("Task 1 — Output panel layout", () => {
|
||||
test("language picker sits below the Audio control", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
const audio = page.locator("#audio");
|
||||
const lang = page.locator("#lang-select");
|
||||
await expect(audio).toBeVisible();
|
||||
await expect(lang).toBeVisible();
|
||||
const aBox = await audio.boundingBox();
|
||||
const lBox = await lang.boundingBox();
|
||||
expect(lBox!.y).toBeGreaterThan(aBox!.y); // lang renders lower than audio
|
||||
});
|
||||
|
||||
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
const pick = page.locator(".panel .lang-pick");
|
||||
const select = page.locator("#lang-select");
|
||||
const pBox = await pick.boundingBox();
|
||||
const sBox = await select.boundingBox();
|
||||
expect(sBox!.x).toBeGreaterThan(pBox!.x); // select right of the label's left edge
|
||||
expect(pBox!.height).toBeLessThan(40); // one row, not stacked
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 4 — Photosensitivity notice", () => {
|
||||
test("the welcome screen carries the heads-up notice and the Speed control", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// The heads-up is shown on the welcome screen itself (it is the entry gate).
|
||||
await expect(page.locator("#welcome-title")).toBeVisible();
|
||||
await expect(page.locator("#welcome-title")).toHaveText(/motion|flashing/i);
|
||||
// The Speed control + Launch button sit right there, before the visitor launches.
|
||||
await expect(page.locator("#welcome-play-speed")).toBeVisible();
|
||||
await expect(page.locator("#welcome-launch")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
|
||||
test("Speed is a 0–2x forward slider; the loop follows it (default 1x)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
const slider = page.locator("#play-speed");
|
||||
await expect(slider).toBeVisible();
|
||||
await expect(slider).toHaveValue("1"); // default = normal speed
|
||||
await expect(slider).toHaveAttribute("min", "0");
|
||||
await expect(slider).toHaveAttribute("max", "2"); // capped at 2x — base clips aren't all-intra, so >2x stutters
|
||||
});
|
||||
|
||||
test("forward speed sets the loop video's playbackRate (up to 2x)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
await page.locator("#play-speed").fill("2");
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.playbackRate))
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
test("0x freezes the loop video", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
await page.locator("#play-speed").fill("0");
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("Reduce motion defaults from the OS setting and does NOT disable Speed", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.goto("/");
|
||||
// The welcome toggle reflects the OS preference before launch.
|
||||
await expect(page.locator("#welcome-reduce-motion")).toBeChecked();
|
||||
await enter(page);
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked(); // panel mirrors it
|
||||
await expect(page.locator("#play-speed")).toBeEnabled(); // Speed is independent of reduce-motion
|
||||
});
|
||||
|
||||
test("Reduce motion turns altitude changes into instant cuts (no morph tween)", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
await page.locator('label[for="reduce-motion"]').click(); // hidden checkbox → toggle via label
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked();
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
const t0 = await page.evaluate(() => performance.now());
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY: 60 }); // descend one altitude
|
||||
await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 5000 });
|
||||
const dt = (await page.evaluate(() => performance.now())) - t0;
|
||||
expect(dt).toBeLessThan(600); // instant cut, not the ~1200ms animated morph tween
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 6 — Keyboard + ARIA dial", () => {
|
||||
test("altitude dial is a keyboard-operable slider", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
const dial = page.locator("#dial");
|
||||
await expect(dial).toHaveAttribute("role", "slider");
|
||||
await expect(dial).toHaveAttribute("tabindex", "0");
|
||||
await dial.focus();
|
||||
const before = await page.locator("#scale-name").textContent();
|
||||
await dial.press("ArrowDown"); // descend one altitude
|
||||
await expect
|
||||
.poll(async () => page.locator("#scale-name").textContent())
|
||||
.not.toBe(before);
|
||||
await dial.press("Home"); // jump to the top scale (cosmos)
|
||||
await expect(page.locator("#scale-name")).toHaveText(/cosmos|宇宙/i);
|
||||
await expect(dial).toHaveAttribute("aria-valuenow", "0");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 7 — Screen-reader narration", () => {
|
||||
test("an aria-live region narrates the current scale", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
const live = page.locator("#sr-status");
|
||||
await expect(live).toHaveAttribute("aria-live", "polite");
|
||||
await page.locator("#dial").focus();
|
||||
await page.locator("#dial").press("Home");
|
||||
await expect
|
||||
.poll(async () => (await live.textContent())?.toLowerCase())
|
||||
.toContain("cosmos");
|
||||
});
|
||||
|
||||
test("decorative overlay SVGs are hidden from assistive tech", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#overlay")).toHaveAttribute("aria-hidden", "true");
|
||||
await expect(page.locator("#affect")).toHaveAttribute("aria-hidden", "true");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 9 — About page", () => {
|
||||
test("about page loads, names its themes, and links back", async ({ page }) => {
|
||||
await page.goto("/about.html");
|
||||
await expect(page.locator("h1")).toContainText(/about/i);
|
||||
await expect(page.getByText(/imperfect/i)).toBeVisible(); // honest-about-limits beat
|
||||
await expect(page.getByText(/space exploration/i)).toBeVisible(); // technology arc beat
|
||||
await expect(page.getByText(/large language models/i)).toBeVisible();
|
||||
await page.locator(".back-link").click();
|
||||
await expect(page).toHaveURL(/index\.html|\/$/);
|
||||
});
|
||||
|
||||
test("the header links to the about page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator('a[href="about.html"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -10,14 +10,15 @@ import { test, expect, Page } from "@playwright/test";
|
||||
// ("<scale> · <clip> (i/n)").
|
||||
|
||||
async function boot(page: Page) {
|
||||
await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("hef.devMode", "1");
|
||||
});
|
||||
await page.goto("/");
|
||||
// Wait for the "Loading Universe" preload to finish (splash gets class "done").
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#loading")?.classList.contains("done"),
|
||||
null,
|
||||
{ timeout: 45_000 },
|
||||
);
|
||||
// Wait for phase-1 preload, then enter via the welcome screen's Launch button
|
||||
// (Video defaults on, Audio 2 → the experience is running once we're in).
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
await expect(page.locator("#scale-name")).toContainText("cosmos");
|
||||
}
|
||||
|
||||
@@ -83,16 +84,8 @@ test("audio is a 0-10 level dial", async ({ page }) => {
|
||||
expect(ctl.max).toBe("10");
|
||||
});
|
||||
|
||||
test("boots silent (video off, audio 0); turning video on couples audio to 3/10", async ({ page }) => {
|
||||
await boot(page);
|
||||
// Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites.
|
||||
expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false);
|
||||
expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
|
||||
// Turning Video on (a click gesture) lifts the audio dial to a gentle 3/10.
|
||||
await enableVideo(page);
|
||||
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
|
||||
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
|
||||
});
|
||||
// (Entry via the welcome screen — defaults, Launch, control carry-over, State B —
|
||||
// is covered in welcome.spec.ts.)
|
||||
|
||||
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
|
||||
await boot(page);
|
||||
@@ -115,6 +108,20 @@ test("dragging the dial scrubs morph currentTime and audio gains", async ({ page
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test("reduce motion: selecting a new altitude still swaps to a fresh clip (instant cut)", async ({ page }) => {
|
||||
await boot(page);
|
||||
// #scale-name reads "<scale> · <clip> (i/n)"; extract the clip id.
|
||||
const clipId = (s: string) => (s.split("·")[1] || "").split("(")[0].trim();
|
||||
await page.locator('label[for="reduce-motion"]').click(); // engage reduce motion
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked();
|
||||
const before = (await page.locator("#scale-name").textContent())!;
|
||||
await wheelOnStage(page, 60); // descend one altitude (instant cut)
|
||||
await page.waitForFunction(() => /orbit/.test(document.querySelector("#scale-name")?.textContent || ""), null, { timeout: 20000 });
|
||||
const after = (await page.locator("#scale-name").textContent())!;
|
||||
expect(after).toContain("orbit");
|
||||
expect(clipId(after)).not.toBe(clipId(before)); // the clip actually changed (not stuck on the old scale's clip)
|
||||
});
|
||||
|
||||
test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
|
||||
await boot(page);
|
||||
const start = (await page.locator("#scale-name").textContent())!;
|
||||
@@ -261,7 +268,8 @@ test("crossing a detent commits and locks the destination clip", async ({ page }
|
||||
const box = (await page.locator("#dial").boundingBox())!;
|
||||
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
||||
// Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents),
|
||||
// crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete).
|
||||
// crossing integer 1 (orbit) so it commits, then hold past it WHILE STILL DRAGGING
|
||||
// (the live blend holds wherever the knob is held; the snap happens only on release).
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 30, cy + 6, { steps: 14 });
|
||||
@@ -278,3 +286,30 @@ test("crossing a detent commits and locks the destination clip", async ({ page }
|
||||
expect(st2.activeClipId).toBe(st.activeClipId);
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test("releasing a partial drag snaps to the closest altitude (no held mid-blend)", async ({ page }) => {
|
||||
// The operator can grab the dial, turn it PART of a detent, and let go between two
|
||||
// altitudes. On release the knob must auto-scrub to the CLOSEST altitude and settle
|
||||
// there (integer position, frac 0) — landing on a discrete altitude exactly as if
|
||||
// they had clicked it, rather than holding the half-finished blend.
|
||||
await boot(page);
|
||||
const box = (await page.locator("#dial").boundingBox())!;
|
||||
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 18, cy - 24, { steps: 8 }); // ~0.5 detent — parks BETWEEN altitudes
|
||||
// While held we are mid-blend: a fractional knob position.
|
||||
await page.waitForFunction(
|
||||
() => { const p = (window as any).__hefState().pos; return Math.abs(p - Math.round(p)) > 0.05; },
|
||||
null, { timeout: 5000 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
// After release the position settles on an integer altitude (the snap).
|
||||
await page.waitForFunction(
|
||||
() => Number.isInteger((window as any).__hefState().pos),
|
||||
null, { timeout: 10000 },
|
||||
);
|
||||
const st = await page.evaluate(() => (window as any).__hefState());
|
||||
expect(Number.isInteger(st.pos)).toBe(true); // landed on an altitude, not held mid-blend
|
||||
expect(st.ringIndex).toBe(st.pos); // committed altitude == the position it landed on
|
||||
});
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// The Audio-source feature (docs/superpowers/specs/2026-07-01-audio-music-option-design.md):
|
||||
// the old 0–10 "Audio" slider becomes "Volume", and a new Audio <select> chooses the
|
||||
// source — None / Soundtrack / Music. Music is a rotating playlist played the same at
|
||||
// every altitude (no per-altitude crossfade).
|
||||
|
||||
async function enter(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function selectSource(page: Page, v: string) {
|
||||
await page.selectOption("#audio-source", v);
|
||||
}
|
||||
|
||||
// The url the active <audio> element is loaded with (dataset.url is the raw path
|
||||
// app.js assigns; set regardless of whether play() is allowed to start).
|
||||
async function audUrls(page: Page): Promise<{ a: string; b: string }> {
|
||||
return await page.evaluate(() => ({
|
||||
a: (document.querySelector("#aud") as HTMLAudioElement).dataset.url || "",
|
||||
b: (document.querySelector("#aud-b") as HTMLAudioElement).dataset.url || "",
|
||||
}));
|
||||
}
|
||||
|
||||
async function wheelOnStage(page: Page, deltaY: number) {
|
||||
await page.locator("#stage").dispatchEvent("wheel", { deltaY });
|
||||
}
|
||||
|
||||
test.describe("Audio source dropdown + Volume rename", () => {
|
||||
test("the level slider is now labelled Volume and the Audio control is a 3-option dropdown", async ({ page }) => {
|
||||
await enter(page);
|
||||
await expect(page.locator('.panel label.audio-level span[data-i18n="output.volume"]')).toHaveText("Volume");
|
||||
await expect(page.locator('.panel label.audio-source span[data-i18n="output.audio"]')).toHaveText("Audio");
|
||||
const opts = page.locator("#audio-source option");
|
||||
await expect(opts).toHaveCount(3);
|
||||
await expect(opts).toHaveText(["None", "Soundtrack", "Music"]);
|
||||
});
|
||||
|
||||
test("selecting None disables the Volume slider; a real source re-enables it", async ({ page }) => {
|
||||
await enter(page);
|
||||
await selectSource(page, "none");
|
||||
await expect(page.locator("#audio")).toBeDisabled();
|
||||
await selectSource(page, "soundtrack");
|
||||
await expect(page.locator("#audio")).toBeEnabled();
|
||||
});
|
||||
|
||||
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
|
||||
|
||||
test("Music plays a playlist track that persists across an altitude change", async ({ page }) => {
|
||||
await enter(page);
|
||||
await selectSource(page, "music");
|
||||
let urls = await audUrls(page);
|
||||
expect(urls.a).toMatch(MUSIC_RE);
|
||||
// Move the dial: Music is altitude-independent, so #aud stays on a music track
|
||||
// (it does NOT switch to a per-altitude soundtrack). Default rotate interval is
|
||||
// 5 min, so it's the SAME track here.
|
||||
await wheelOnStage(page, 120);
|
||||
await wheelOnStage(page, 120);
|
||||
await page.waitForTimeout(300);
|
||||
urls = await audUrls(page);
|
||||
expect(urls.a).toMatch(MUSIC_RE);
|
||||
});
|
||||
|
||||
test("Soundtrack loads a per-altitude ambience, not a music track", async ({ page }) => {
|
||||
await enter(page);
|
||||
await selectSource(page, "soundtrack");
|
||||
await page.waitForTimeout(200);
|
||||
const urls = await audUrls(page);
|
||||
const loaded = urls.a + "|" + urls.b;
|
||||
expect(loaded).toContain("/audio/");
|
||||
expect(loaded).not.toContain("/audio/music/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Auto-advance moves to the next altitude when the altitude's loop VIDEO completes a
|
||||
// playthrough (not on a fixed timer). Descends one step + wraps; a default-on toggle
|
||||
// disables it; manual drag / a playing morph defer it. The e2e simulates a completed
|
||||
// loop by dispatching the loop element's "ended" (the wrap-point and ended share the
|
||||
// same advance path).
|
||||
|
||||
async function boot(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
await expect(page.locator("#scale-name")).toContainText("cosmos");
|
||||
}
|
||||
const state = (page: Page) => page.evaluate(() => (window as any).__hefState());
|
||||
const idx = async (page: Page) => (await state(page)).ringIndex as number;
|
||||
|
||||
// Simulate the loop video finishing one playthrough.
|
||||
async function completeLoopVideo(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const v = document.querySelector("#vid-loop") as HTMLVideoElement;
|
||||
v.dataset.loopTail = "1"; // armed, as after a real landing
|
||||
v.dispatchEvent(new Event("ended"));
|
||||
});
|
||||
}
|
||||
|
||||
test("a completed loop video advances one altitude (descending, wraps)", async ({ page }) => {
|
||||
await boot(page);
|
||||
const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length);
|
||||
const start = await idx(page);
|
||||
await completeLoopVideo(page);
|
||||
await page.waitForFunction((s) => (window as any).__hefState().ringIndex !== s, start, { timeout: 8000 });
|
||||
expect(await idx(page)).toBe((start + 1) % n);
|
||||
});
|
||||
|
||||
test("Auto-advance off: a completed video stays on the altitude", async ({ page }) => {
|
||||
await boot(page);
|
||||
await page.evaluate(() => { (document.querySelector("#auto-advance") as HTMLInputElement).checked = false; });
|
||||
const start = await idx(page);
|
||||
await completeLoopVideo(page);
|
||||
await page.waitForTimeout(1500);
|
||||
expect(await idx(page)).toBe(start);
|
||||
});
|
||||
|
||||
test("keeps touring: a second completed video advances again", async ({ page }) => {
|
||||
await boot(page);
|
||||
const n = await page.evaluate(() => document.querySelectorAll(".dial-label").length);
|
||||
const start = await idx(page);
|
||||
await completeLoopVideo(page);
|
||||
// wait until it has advanced AND settled (pos back on an integer detent)
|
||||
await page.waitForFunction((s) => { const st = (window as any).__hefState(); return st.ringIndex !== s && Number.isInteger(st.pos); }, start, { timeout: 8000 });
|
||||
await completeLoopVideo(page);
|
||||
await page.waitForFunction((a) => (window as any).__hefState().ringIndex === (a.start + 2) % a.n, { start, n }, { timeout: 8000 });
|
||||
expect(await idx(page)).toBe((start + 2) % n);
|
||||
});
|
||||
@@ -7,22 +7,25 @@ import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
async function boot(page: Page) {
|
||||
await page.goto("/");
|
||||
// initLanguage() populates the select during startup (before media preload).
|
||||
await expect(page.locator("#lang-select option")).toHaveCount(4);
|
||||
// initLanguage() populates BOTH selects during startup (before media preload).
|
||||
// The welcome select is the visible one (the panel is hidden until entry).
|
||||
await expect(page.locator("#welcome-lang option")).toHaveCount(4);
|
||||
}
|
||||
|
||||
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
|
||||
await boot(page);
|
||||
const sel = page.locator("#lang-select");
|
||||
const sel = page.locator("#welcome-lang"); // the welcome screen's selector
|
||||
|
||||
// English selected by default
|
||||
// English selected by default (both selects mirror activeLang)
|
||||
await expect(sel).toHaveValue("en");
|
||||
await expect(page.locator("#lang-select")).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
|
||||
// switch to Spanish -> chrome changes, <html lang> updates
|
||||
// switch to Spanish -> chrome changes (incl. the hidden panel), <html lang> updates
|
||||
await sel.selectOption("es");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
|
||||
await expect(page.locator("#lang-select")).toHaveValue("es"); // panel select mirrors
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
|
||||
// Japanese
|
||||
@@ -31,6 +34,6 @@ test("language dropdown localizes chrome and resets to English on reload", async
|
||||
|
||||
// reload returns to English (session-only, no persistence)
|
||||
await page.reload();
|
||||
await expect(page.locator("#lang-select")).toHaveValue("en");
|
||||
await expect(page.locator("#welcome-lang")).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
});
|
||||
|
||||
@@ -4,12 +4,21 @@ import { test, expect } from "@playwright/test";
|
||||
// offset via a narrow timeupdate window. If that window is ever missed the clip
|
||||
// fires `ended` and — without a safety net — freezes on its last frame ("videos
|
||||
// aren't looping"). This asserts the `ended` safety net recovers playback.
|
||||
// (With Auto-advance ON — the default — a completed video instead moves to the next
|
||||
// altitude; that path is covered by auto-advance.spec.ts. Here we disable it to test
|
||||
// the loop-back recovery in isolation.)
|
||||
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Enter via the welcome screen (Video defaults on → the loop video loads).
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
await page.evaluate(() => { const c = document.querySelector("#auto-advance") as HTMLInputElement; if (c) c.checked = false; });
|
||||
// Wait for the loop video to actually have media before forcing the stuck state.
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#loading")?.classList.contains("done"),
|
||||
() => { const v = document.getElementById("vid-loop") as HTMLVideoElement; return v && v.duration > 0; },
|
||||
null,
|
||||
{ timeout: 45_000 },
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
const r = await page.evaluate(async () => {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Bug: the Dark/Light Mood grade was dropped DURING an altitude morph — the morph
|
||||
// showed ungraded ("normal") and the grade snapped back only on landing. Both render
|
||||
// paths were at fault: the WebGL canvas set filter "none" while `busy`, and the
|
||||
// fallback set #vid's filter "none" when a morph loaded. The grade must ride through.
|
||||
|
||||
async function boot(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
await expect(page.locator("#scale-name")).toBeVisible();
|
||||
}
|
||||
|
||||
test("Dark Mood grade stays applied to the morph while scrubbing", async ({ page }) => {
|
||||
await boot(page);
|
||||
// Drive Mood hard dark; wait for the debounced render to build the grade.
|
||||
await page.evaluate(() => {
|
||||
const m = document.querySelector("#mood") as HTMLInputElement;
|
||||
m.value = "-4";
|
||||
m.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Begin a drag → a morph plays (busy = true). Sample the VISIBLE morph surface:
|
||||
// the paint canvas (WebGL path) or #vid (fallback). #vid-loop is hidden mid-morph,
|
||||
// so we deliberately exclude it.
|
||||
const box = (await page.locator("#dial").boundingBox())!;
|
||||
const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
|
||||
await page.mouse.move(cx, cy - 30);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(cx + 18, cy - 24, { steps: 8 });
|
||||
await page.waitForFunction(() => (document.querySelector("#vid") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
|
||||
|
||||
const filters = await page.evaluate(() => [
|
||||
(document.querySelector("#paint") as HTMLElement).style.filter,
|
||||
(document.querySelector("#vid") as HTMLElement).style.filter,
|
||||
]);
|
||||
await page.mouse.up();
|
||||
|
||||
// Dark grade is brightness(<1)… — at least one visible surface must carry it.
|
||||
// Before the fix both were "none" here.
|
||||
const graded = filters.some((f) => /brightness\(/.test(f) && !/brightness\(1(\.0+)?\)/.test(f));
|
||||
expect(graded, `visible morph surface must carry the dark grade; saw ${JSON.stringify(filters)}`).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Music is a shuffle: each track plays to its END, then a random OTHER track plays
|
||||
// (never an immediate repeat), gaplessly, for as long as Music is selected. The e2e
|
||||
// injects a short crossfade via window.__hefMusicXfadeMs and simulates a track finishing
|
||||
// by dispatching its "ended" event (real tracks are minutes long).
|
||||
|
||||
const MUSIC_RE = /audio\/music\/(manatees|dewdrop|eastminster|overheat|concentration)\.mp3/;
|
||||
|
||||
async function boot(page: Page, xfadeMs = 300) {
|
||||
await page.addInitScript((x) => { (window as any).__hefMusicXfadeMs = x; }, xfadeMs);
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
// id ("aud"/"aud-b") of the louder (active) music element, and the track it holds.
|
||||
async function active(page: Page): Promise<{ id: string; url: string }> {
|
||||
return await page.evaluate(() => {
|
||||
const a = document.querySelector("#aud") as HTMLAudioElement;
|
||||
const b = document.querySelector("#aud-b") as HTMLAudioElement;
|
||||
const el = b.volume > a.volume ? b : a;
|
||||
return { id: el.id, url: el.dataset.url || "" };
|
||||
});
|
||||
}
|
||||
// Simulate the active track finishing.
|
||||
async function endActive(page: Page, id: string) {
|
||||
await page.evaluate((i) => (document.querySelector("#" + i) as HTMLAudioElement).dispatchEvent(new Event("ended")), id);
|
||||
}
|
||||
|
||||
test("when the current track ends, a new RANDOM track plays", async ({ page }) => {
|
||||
await boot(page);
|
||||
await page.selectOption("#audio-source", "music");
|
||||
await page.waitForTimeout(200);
|
||||
const first = await active(page);
|
||||
expect(first.url).toMatch(MUSIC_RE);
|
||||
await endActive(page, first.id);
|
||||
await page.waitForFunction((f) => {
|
||||
const a = document.querySelector("#aud") as HTMLAudioElement;
|
||||
const b = document.querySelector("#aud-b") as HTMLAudioElement;
|
||||
const el = b.volume > a.volume ? b : a;
|
||||
return (el.dataset.url || "").includes("/audio/music/") && (el.dataset.url || "") !== f;
|
||||
}, first.url, { timeout: 6000 });
|
||||
const second = await active(page);
|
||||
expect(second.url).toMatch(MUSIC_RE);
|
||||
expect(second.url).not.toBe(first.url); // never an immediate repeat
|
||||
});
|
||||
|
||||
test("Music keeps playing across several track-ends", async ({ page }) => {
|
||||
await boot(page);
|
||||
await page.selectOption("#audio-source", "music");
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await page.waitForTimeout(400);
|
||||
const cur = await active(page);
|
||||
seen.add(cur.url);
|
||||
await endActive(page, cur.id);
|
||||
await page.waitForTimeout(400); // let the crossfade settle
|
||||
}
|
||||
expect([...seen].every((u) => MUSIC_RE.test(u))).toBe(true);
|
||||
expect(seen.size).toBeGreaterThan(1); // it moved through multiple tracks
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Guard: the "Video speed" control label must stay on ONE line in the Output panel
|
||||
// (it was wrapping to two rows at the old panel width).
|
||||
|
||||
async function enter(page: Page) {
|
||||
await page.goto("/");
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
test('the "Video speed" label does not wrap to a second line', async ({ page }) => {
|
||||
await enter(page);
|
||||
const info = await page.evaluate(() => {
|
||||
const span = document.querySelector('.panel .play-speed span[data-i18n="speed.label"]') as HTMLElement;
|
||||
const cs = getComputedStyle(span);
|
||||
let lh = parseFloat(cs.lineHeight);
|
||||
if (Number.isNaN(lh)) lh = parseFloat(cs.fontSize) * 1.3; // "normal"
|
||||
return { text: span.textContent, height: span.getBoundingClientRect().height, lineHeight: lh };
|
||||
});
|
||||
expect(info.text).toBe("Video speed");
|
||||
// A wrapped label is ~2× a single line; allow slack but stay well under two lines.
|
||||
expect(info.height).toBeLessThan(info.lineHeight * 1.6);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Runs against the BUILT static site: dist/ served on :8077, media (CORS) on :8078
|
||||
// by serve-static.mjs. Build with `--media-base http://localhost:8078/` so config.js
|
||||
// points media cross-origin — the production CORS path, locally. See README.md.
|
||||
const APP = "http://localhost:8077/human-machine-strata/";
|
||||
|
||||
// Enter the simulation via the welcome screen (Video defaults on → media plays).
|
||||
async function enter(page: Page) {
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 30_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
test("boots fully static — no /api calls, ring + clips load from baked JSON", async ({ page }) => {
|
||||
const apiCalls: string[] = [];
|
||||
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
|
||||
await page.goto(APP);
|
||||
await enter(page);
|
||||
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
|
||||
expect(apiCalls, "static build must not call /api/*").toEqual([]);
|
||||
});
|
||||
|
||||
test("dream shader runs on cross-origin media without tainting the GL texture", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto(APP);
|
||||
await enter(page);
|
||||
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
|
||||
// crank the Feel (Right) knob to engage the dream, let a few frames render
|
||||
await page.evaluate(() => {
|
||||
const r = document.getElementById("right") as HTMLInputElement;
|
||||
r.value = "4";
|
||||
r.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
await page.waitForTimeout(2000);
|
||||
// a tainted GL texture throws SecurityError on draw; assert none surfaced
|
||||
expect(errors.filter((e) => /SecurityError|tainted|cross-origin/i.test(e))).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load
|
||||
// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode.
|
||||
|
||||
async function ready(page: Page) {
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
}
|
||||
async function launch(page: Page) {
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
const srcVal = (page: Page) => page.locator("#audio-source").inputValue();
|
||||
|
||||
test("?audio=music preselects the Music source and survives launch", async ({ page }) => {
|
||||
await page.goto("/?audio=music");
|
||||
await ready(page);
|
||||
expect(await srcVal(page)).toBe("music"); // set at init, before launch
|
||||
await launch(page);
|
||||
expect(await srcVal(page)).toBe("music");
|
||||
});
|
||||
|
||||
test("?audio=soundtrack preselects Soundtrack", async ({ page }) => {
|
||||
await page.goto("/?audio=soundtrack");
|
||||
await ready(page);
|
||||
expect(await srcVal(page)).toBe("soundtrack");
|
||||
});
|
||||
|
||||
test("?audio=none preselects None and disables Volume", async ({ page }) => {
|
||||
await page.goto("/?audio=none");
|
||||
await ready(page);
|
||||
expect(await srcVal(page)).toBe("none");
|
||||
await expect(page.locator("#audio")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("an invalid ?audio value is ignored (stays default Soundtrack)", async ({ page }) => {
|
||||
await page.goto("/?audio=banana");
|
||||
await ready(page);
|
||||
expect(await srcVal(page)).toBe("soundtrack");
|
||||
});
|
||||
|
||||
test("?vol sets the master volume for the shared link", async ({ page }) => {
|
||||
await page.goto("/?audio=music&vol=7");
|
||||
await ready(page);
|
||||
await launch(page);
|
||||
expect(await page.locator("#audio").inputValue()).toBe("7");
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// The welcome (opening) screen mirrors the panel controls. It must offer the Audio
|
||||
// SOURCE choice (None/Soundtrack/Music) too — so a visitor can pick their sound mode
|
||||
// before launching — carried into the session on entry.
|
||||
|
||||
async function ready(page: Page) {
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
}
|
||||
|
||||
test("the welcome screen has an Audio source dropdown with 3 options", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await ready(page);
|
||||
await expect(page.locator("#welcome")).toBeVisible();
|
||||
const opts = page.locator("#welcome-audio-source option");
|
||||
await expect(opts).toHaveCount(3);
|
||||
await expect(opts).toHaveText(["None", "Soundtrack", "Music"]);
|
||||
});
|
||||
|
||||
test("choosing Music on the welcome screen carries into the session", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await ready(page);
|
||||
await page.selectOption("#welcome-audio-source", "music");
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
expect(await page.locator("#audio-source").inputValue()).toBe("music");
|
||||
await page.waitForTimeout(300);
|
||||
const url = await page.evaluate(() => (document.querySelector("#aud") as HTMLAudioElement).dataset.url || "");
|
||||
expect(url).toMatch(/audio\/music\/\w+\.mp3/);
|
||||
});
|
||||
|
||||
test("welcome None greys out the welcome Volume slider", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await ready(page);
|
||||
await page.selectOption("#welcome-audio-source", "none");
|
||||
await expect(page.locator("#welcome-audio")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("?audio=music preselects Music on the welcome screen too", async ({ page }) => {
|
||||
await page.goto("/?audio=music");
|
||||
await ready(page);
|
||||
expect(await page.locator("#welcome-audio-source").inputValue()).toBe("music");
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// The Welcome screen is the universal entry gate (see
|
||||
// docs/superpowers/specs/2026-06-30-welcome-screen-design.md). It carries the
|
||||
// photosensitivity heads-up, the four output controls (Video on / Reduce motion
|
||||
// off / Audio 2 / English), a "Launch Simulator" button, and the "Loading
|
||||
// Universe" progress. The right panel is hidden until the visitor enters.
|
||||
|
||||
const ready = (page: Page) =>
|
||||
page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
|
||||
// Set a range input's value + fire `input` (Playwright's fill() doesn't drive ranges).
|
||||
async function setRange(page: Page, id: string, value: string) {
|
||||
await page.evaluate(({ id, value }) => {
|
||||
const el = document.getElementById(id) as HTMLInputElement;
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, { id, value });
|
||||
}
|
||||
|
||||
test("welcome screen shows on load with the expected defaults; the panel is hidden", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.locator("#welcome")).toBeVisible();
|
||||
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
|
||||
|
||||
// Defaults: Language English, Speed 1, Audio 2, Reduce motion off (no OS pref).
|
||||
await expect(page.locator("#welcome-lang")).toHaveValue("en");
|
||||
await expect(page.locator("#welcome-play-speed")).toHaveValue("1");
|
||||
await expect(page.locator("#welcome-play-speed-val")).toHaveText("1.00×");
|
||||
await expect(page.locator("#welcome-audio")).toHaveValue("2");
|
||||
await expect(page.locator("#welcome-audio-val")).toHaveText("2");
|
||||
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked();
|
||||
|
||||
// Heads-up + Launch button present; "Loading Universe" progress present.
|
||||
await expect(page.locator("#welcome-title")).toBeVisible();
|
||||
await expect(page.locator("#welcome-launch")).toHaveText("Launch Simulator");
|
||||
await expect(page.locator(".welcome-loading")).toBeVisible();
|
||||
|
||||
// The right panel is hidden behind the welcome screen.
|
||||
await expect(page.locator(".panel")).toBeHidden();
|
||||
});
|
||||
|
||||
test("the language picker re-renders the welcome text live", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const sel = page.locator("#welcome-lang");
|
||||
await expect(sel).toHaveValue("en");
|
||||
await expect(page.locator("#welcome-launch")).toHaveText("Launch Simulator");
|
||||
|
||||
await sel.selectOption("es");
|
||||
await expect(page.locator("#welcome-launch")).toHaveText("Iniciar simulador");
|
||||
await expect(page.locator("#welcome-title")).toHaveText(/Atención/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
// the hidden panel selector mirrors the choice
|
||||
await expect(page.locator("#lang-select")).toHaveValue("es");
|
||||
|
||||
await sel.selectOption("ja");
|
||||
await expect(page.locator("#welcome-launch")).toHaveText("シミュレーターを起動");
|
||||
});
|
||||
|
||||
test("Launch when already loaded enters the simulation directly and reveals the panel", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await ready(page);
|
||||
|
||||
await page.locator("#welcome-launch").click();
|
||||
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.locator(".panel")).toBeVisible();
|
||||
|
||||
// Welcome assumes Video on; Audio default 2 carried into the panel.
|
||||
await expect(page.locator("#visual")).toBeChecked();
|
||||
await expect(page.locator("#audio")).toHaveValue("2");
|
||||
});
|
||||
|
||||
test("welcome control values carry into the panel on entry", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await ready(page);
|
||||
|
||||
await setRange(page, "welcome-audio", "5");
|
||||
await setRange(page, "welcome-play-speed", "2"); // mirrors into the panel slider live
|
||||
await page.locator("#welcome-launch").click();
|
||||
|
||||
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.locator("#audio")).toHaveValue("5");
|
||||
await expect(page.locator("#play-speed")).toHaveValue("2");
|
||||
await expect(page.locator("#visual")).toBeChecked(); // welcome assumes video on
|
||||
});
|
||||
|
||||
test("Launch while still loading shows the centered loader with controls, then auto-enters", async ({ page }) => {
|
||||
// Stall media so phase-1 preload hasn't finished when Launch is pressed.
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
await page.route("**/media/**", async (route) => {
|
||||
await gate;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
|
||||
|
||||
await page.locator("#welcome-launch").click();
|
||||
|
||||
// State B: messaging + button hidden, controls remain, loader shown (centered).
|
||||
await expect(page.locator("#welcome")).toHaveClass(/loading/);
|
||||
await expect(page.locator("#welcome-title")).toBeHidden();
|
||||
await expect(page.locator("#welcome-launch")).toBeHidden();
|
||||
await expect(page.locator(".welcome-controls")).toBeVisible();
|
||||
await expect(page.locator(".welcome-loading")).toBeVisible();
|
||||
|
||||
// Let media through → universe becomes ready → auto-enter.
|
||||
release();
|
||||
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.locator(".panel")).toBeVisible();
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,53 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>About — Human/Machine Strata</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="credits-page">
|
||||
<main class="credits-wrap">
|
||||
<header>
|
||||
<h1>About this work</h1>
|
||||
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>For everyone</h2>
|
||||
<p><em>Human/Machine Strata</em> is built to be accessible to — and
|
||||
representative of — the full range of human experience. That is why it works
|
||||
with a keyboard, with a screen reader, with reduced motion, and away from any
|
||||
single screen or kiosk: an experience about being human should be open to as
|
||||
many humans as possible.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Places technology let us see</h2>
|
||||
<p>The scales you move through — the deep sea, the coast, the sky, the orbit
|
||||
of the Earth, the wider cosmos — are vantage points no unaided human could
|
||||
ever witness. We can share them only because our tools evolved to reach them:
|
||||
from <strong>underwater exploration</strong>, to <strong>drones and aerial
|
||||
imaging</strong>, to <strong>space exploration</strong>. Each scale is a place
|
||||
a machine went first, so that a person could feel what it is like to be there.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Built with LLMs</h2>
|
||||
<p>This piece was itself built using large language models — an extension of
|
||||
that same long arc of tools that widen what a single human can reach. The
|
||||
software, the words, and much of the craft were shaped in collaboration with
|
||||
a machine.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Honest about its limits</h2>
|
||||
<p>This implementation is imperfect. It is also more than I could have made
|
||||
alone: better with these tools assisting than by my hand as a single human.
|
||||
That tension is part of the point — technology does not replace the person
|
||||
behind the work; it extends their reach, flaws and all.</p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="config.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
// Client-side port of player/alteration.py + player/audio.py (the alteration
|
||||
// engine). Replaces the live POST /api/alteration in the static build — pure math,
|
||||
// unity calibration (the client never sends a calibration, so DEFAULT_CALIBRATION
|
||||
// = all gains 1.0 always applies). UMD so the browser gets `HEFAlteration` and
|
||||
// `node --test` can require() it. Keep IN SYNC with the Python — the numeric
|
||||
// contract is guarded by tests/test_alteration_js_parity.py.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFAlteration = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
const KNOB_MAX = 4; // player/alteration.py:25
|
||||
const clamp = (x, lo, hi) => Math.max(lo, Math.min(hi, x));
|
||||
|
||||
// Mirror of player/alteration.py::plan_alteration with DEFAULT_CALIBRATION (unity).
|
||||
function plan(c) {
|
||||
const tone = clamp((c.light - c.dark) / KNOB_MAX, -1, 1);
|
||||
const overlayIntensity = clamp(c.left / KNOB_MAX, 0, 1);
|
||||
const dreamIntensity = clamp(c.right / KNOB_MAX, 0, 1);
|
||||
const affectIntensity = clamp(c.right / KNOB_MAX, 0, 1); // affect uses overlay_gain too
|
||||
const is_identity = tone === 0 && c.left === 0 && c.right === 0;
|
||||
return {
|
||||
grade: { tone },
|
||||
overlay: { level: c.left, intensity: overlayIntensity },
|
||||
affect: { strength: c.right, intensity: affectIntensity },
|
||||
dream: { strength: c.right, intensity: dreamIntensity },
|
||||
is_identity,
|
||||
};
|
||||
}
|
||||
|
||||
// Mirror of player/audio.py::resolve_audio. `scaleAudio` is the current scale's
|
||||
// `audio` filename (relative to <mediaBase>audio/); off → silence.
|
||||
function renderAudio(source, scaleAudio, mediaBase) {
|
||||
if (source === "off") return { source: "off", url: null, altitude_coupled: false };
|
||||
const url = scaleAudio ? mediaBase + "audio/" + scaleAudio : null;
|
||||
return { source: "soundtrack", url, altitude_coupled: true };
|
||||
}
|
||||
|
||||
// The full /api/alteration response shape, computed locally.
|
||||
function alteration(controls, scaleAudio, mediaBase) {
|
||||
return {
|
||||
plan: plan(controls),
|
||||
render: {
|
||||
video: { shown: controls.visual === "on" }, // player/audio.py::resolve_visual
|
||||
audio: renderAudio(controls.audio, scaleAudio, mediaBase),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { plan, renderAudio, alteration, KNOB_MAX };
|
||||
});
|
||||
+564
-85
@@ -57,14 +57,19 @@ let activeLang = "en"; // session-only; no persistence (resets to en each lo
|
||||
let lastAffect = null; // {strength, intensity, right} from the last renderAffect, for re-render on language switch
|
||||
|
||||
async function loadData() {
|
||||
const clips = (await (await fetch("/api/clips")).json()).clips || [];
|
||||
// Static build: boot from baked JSON (no server). Dev: the live API. The baked
|
||||
// files sit alongside index.html, so RELATIVE urls resolve under the deploy path.
|
||||
const api = (window.HEF_CONFIG && window.HEF_CONFIG.static)
|
||||
? { clips: "clips.json", versions: "media-versions.json", ring: "ring.json" }
|
||||
: { clips: "/api/clips", versions: "/api/media-versions", ring: "/api/ring" };
|
||||
const clips = (await (await fetch(api.clips)).json()).clips || [];
|
||||
clipsById = Object.fromEntries(clips.map((c) => [c.id, c]));
|
||||
// Per-file content-hash tokens → appended to /media URLs as ?v=<hash> so a
|
||||
// re-baked clip (new bytes, same path) gets a fresh URL the browser can't serve
|
||||
// stale. Best-effort: an empty map just yields un-versioned URLs.
|
||||
try { mediaVersions = (await (await fetch("/api/media-versions")).json()).versions || {}; }
|
||||
try { mediaVersions = (await (await fetch(api.versions)).json()).versions || {}; }
|
||||
catch (_) { mediaVersions = {}; }
|
||||
const r = await fetch("/api/ring");
|
||||
const r = await fetch(api.ring);
|
||||
serverRing = r.ok;
|
||||
ring = r.ok ? await r.json() : null;
|
||||
if (!ring && clips.length) {
|
||||
@@ -84,19 +89,30 @@ async function loadData() {
|
||||
// advance (content-pipeline §11.1, Python owns the randomness). The synthesized
|
||||
// fallback ring has a pool of one, so it just returns that member. Returns a
|
||||
// clip_id or null. Shared by the initial landing AND the Dev Mode re-roll button.
|
||||
// Lookups for the pure preload planner (HEFPreload): map clip/morph → media file and
|
||||
// test the in-memory blob cache. A clip is "loaded" only when its file is a cached blob.
|
||||
function _preloadDeps() {
|
||||
return {
|
||||
baseOf: (id) => (clipsById[id] || {}).base_file,
|
||||
morphFile: (a, b) => morphByPair[`${a}→${b}`] || null,
|
||||
isCached: (f) => !!f && !!mediaBlobs[f],
|
||||
};
|
||||
}
|
||||
|
||||
function poolIds(scale) {
|
||||
return ((scale.pool && scale.pool.length) ? scale.pool : [{ clip_id: scale.clip_id }]).map((m) => m.clip_id);
|
||||
}
|
||||
|
||||
async function pickRandomMember() {
|
||||
const scale = ring && ring.scales[ringIndex];
|
||||
if (!scale) return null;
|
||||
if (serverRing) {
|
||||
try {
|
||||
const resp = await fetch("/api/ring/advance", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ from_index: ringIndex, delta: 0 }),
|
||||
});
|
||||
if (resp.ok) return (await resp.json()).target_clip_id;
|
||||
} catch (_) { /* fall through to the primary member */ }
|
||||
}
|
||||
return scale.clip_id;
|
||||
// Show only a clip whose base is already loaded (eligible). Initially that's the
|
||||
// phase-1 first member; the pool grows as the background preload finishes. Fallback
|
||||
// to the first member (guaranteed loaded by the phase-1 gate).
|
||||
const ids = poolIds(scale);
|
||||
let elig = HEFPreload.eligibleMembers(ids, _preloadDeps());
|
||||
if (!elig.length) elig = [ids[0]];
|
||||
return HEFPreload.pick(elig, Math.random);
|
||||
}
|
||||
|
||||
// Land on the current scale: pick a random pool member and force its media to load.
|
||||
@@ -118,9 +134,11 @@ let mediaVersions = {}; // file -> content-hash token (from /api/media-version
|
||||
// Network path for a media file, content-hash-versioned so a re-baked clip's URL
|
||||
// changes with its bytes (permanent cache-bust). The blob cache, when present,
|
||||
// wins — those bytes are already the current ones for this session.
|
||||
function mediaNetUrl(file) { const v = mediaVersions[file]; return "/media/" + file + (v ? "?v=" + v : ""); }
|
||||
function mediaBase() { return (window.HEF_CONFIG && window.HEF_CONFIG.mediaBase) || "/media/"; }
|
||||
function mediaNetUrl(file) { const v = mediaVersions[file]; return mediaBase() + file + (v ? "?v=" + v : ""); }
|
||||
function mediaUrl(file) {
|
||||
if (file.startsWith("/media/")) return file; // already a resolved absolute url (audio layer)
|
||||
// Already a resolved absolute url (audio layer, or R2 in static mode): pass through.
|
||||
if (/^https?:\/\//.test(file) || file.startsWith("/media/")) return file;
|
||||
return mediaBlobs[file] || mediaNetUrl(file);
|
||||
}
|
||||
|
||||
@@ -168,10 +186,27 @@ function preloadOrder() {
|
||||
}
|
||||
}
|
||||
for (const f of reachableMorphFiles()) add(f);
|
||||
for (const f of preloadList()) add(f); // sweep up anything not on the ring
|
||||
for (const f of preloadList()) add(f); // sweep up every base not already on the ring
|
||||
// ALL morphs last: preloading every transition before interaction guarantees a
|
||||
// smooth knob turn anywhere (any random pool re-roll lands on an already-cached
|
||||
// morph). They're ordered last so the first scenes + nearest transitions cache
|
||||
// first and the loading bar fills meaningfully.
|
||||
for (const f of Object.values(morphByPair)) add(f);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Cache a fixed list of files (bounded concurrency), driving the "Loading Universe…"
|
||||
// bar. Used for the phase-1 gate (one clip/altitude + connecting morphs) before unlock.
|
||||
async function cacheMany(files, concurrency = 6) {
|
||||
const total = files.length || 1;
|
||||
let done = 0, i = 0;
|
||||
setLoadingProgress(0, total);
|
||||
async function worker() {
|
||||
while (i < files.length) { await cacheClip(files[i++]); done++; setLoadingProgress(done, total); }
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, files.length) }, worker));
|
||||
}
|
||||
|
||||
// Fetch one media file into the blob cache (idempotent — cached files are skipped).
|
||||
async function cacheClip(file) {
|
||||
if (!file || mediaBlobs[file]) return;
|
||||
@@ -277,7 +312,7 @@ function loadLoop(clip, landFrame) {
|
||||
}
|
||||
|
||||
function playLoop() {
|
||||
if (loopVid.paused) loopVid.play().catch(() => {});
|
||||
applyPlaySpeed(); // honor the chosen speed (forward, or 0x = freeze) — independent of reduce-motion
|
||||
}
|
||||
|
||||
function ensureClipMedia() {
|
||||
@@ -293,6 +328,7 @@ function ensureClipMedia() {
|
||||
// tail. The morph element (#vid) is driven directly by the scrub.
|
||||
loopVid.addEventListener("timeupdate", () => {
|
||||
if (loopVid.dataset.loopTail === "1" && loopVid.duration && loopVid.currentTime >= loopVid.duration - 0.08) {
|
||||
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
|
||||
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
|
||||
}
|
||||
});
|
||||
@@ -304,6 +340,7 @@ loopVid.addEventListener("timeupdate", () => {
|
||||
// rarely hits this; loaded real machines do.
|
||||
loopVid.addEventListener("ended", () => {
|
||||
if (loopVid.dataset.loopTail !== "1") return;
|
||||
if (autoAdvanceOnLoopComplete()) return; // completed a playthrough → next altitude
|
||||
try { loopVid.currentTime = loopStartFrame(); } catch (e) { /* not seekable */ }
|
||||
loopVid.play().catch(() => {});
|
||||
});
|
||||
@@ -416,11 +453,13 @@ function paintLoop() {
|
||||
if (paint.width !== w || paint.height !== h) { paint.width = w; paint.height = h; }
|
||||
gl.viewport(0, 0, w, h);
|
||||
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, src); } catch (_) {}
|
||||
// No painterly restyle during a ring transition (busy) — show it raw.
|
||||
// No painterly DREAM during a ring transition (busy) — show the morph crisp.
|
||||
// The Dark/Light mood GRADE, however, must ride through the transition too
|
||||
// (else the morph shows ungraded and the grade snaps back only on landing).
|
||||
gl.uniform1f(uAmt, busy ? 0 : dreamIntensity);
|
||||
gl.uniform2f(uTexel, 1 / w, 1 / h);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
paint.style.filter = busy ? "none" : gradeFilter;
|
||||
paint.style.filter = gradeFilter;
|
||||
}
|
||||
// Animate per-frame: re-place keyframed tracks AND re-evaluate time-windowed
|
||||
// labels (a label enters/leaves frame as the loop plays) against playback time.
|
||||
@@ -677,9 +716,28 @@ function renderScaleReadout() {
|
||||
const scaleName = HEFi18n.pickUiString("scale." + s.id, activeLang);
|
||||
$("scale-name").textContent = `${scaleName} · ${member} (${ringIndex + 1}/${ring.scales.length}${poolTag})`;
|
||||
renderDial();
|
||||
announceState(); // narrate the new altitude (scale + clip) for screen readers
|
||||
refreshDevClip(); // keep the Dev Mode pool picker + clip data in sync with the landing
|
||||
}
|
||||
|
||||
// Turn the visual alteration into words for assistive tech: the current scale plus
|
||||
// the clip you've landed on. Deduped so a settle on the same state stays quiet.
|
||||
let lastAnnounced = "";
|
||||
function announce(text) {
|
||||
const el = $("sr-status");
|
||||
if (!el || !text || text === lastAnnounced) return;
|
||||
lastAnnounced = text;
|
||||
el.textContent = text;
|
||||
}
|
||||
function announceState() {
|
||||
if (!ring) return;
|
||||
const s = ring.scales[ringIndex];
|
||||
const scaleName = s ? HEFi18n.pickUiString("scale." + s.id, activeLang) : "";
|
||||
const clip = activeClip();
|
||||
const title = clip && clip.title ? clip.title : "";
|
||||
announce(title ? `${scaleName}. ${title}` : scaleName);
|
||||
}
|
||||
|
||||
function controls() {
|
||||
// Video (on/off) and Audio (on/off) are orthogonal toggles (audio spec §2).
|
||||
// Audio on = the per-altitude soundtrack (white-noise is deferred). One bipolar
|
||||
@@ -702,12 +760,21 @@ function controls() {
|
||||
let timer = null;
|
||||
async function update() {
|
||||
if (busy) return;
|
||||
const resp = await fetch("/api/alteration", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
|
||||
});
|
||||
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
|
||||
const data = await resp.json();
|
||||
let data;
|
||||
if (window.HEF_CONFIG && window.HEF_CONFIG.static) {
|
||||
// Static build: compute the alteration locally (HEFAlteration is the JS port of
|
||||
// player.alteration/audio). Audio couples to the current altitude's asset.
|
||||
const scale = ring && ring.scales[ringIndex];
|
||||
const scaleAudio = (scale && scale.audio) || "";
|
||||
data = HEFAlteration.alteration(controls(), scaleAudio, mediaBase());
|
||||
} else {
|
||||
const resp = await fetch("/api/alteration", {
|
||||
method: "POST", headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ controls: controls(), altitude_index: ringIndex }),
|
||||
});
|
||||
if (!resp.ok) { readout.textContent = "invalid: " + resp.status; return; }
|
||||
data = await resp.json();
|
||||
}
|
||||
readout.textContent = JSON.stringify(data, null, 2);
|
||||
// Tolerate a stale server that still returns {content:{video}} instead of the
|
||||
// {render:{video:{shown}}} (visual/audio-split) shape, so video works either way.
|
||||
@@ -753,10 +820,22 @@ const PER_ALTITUDE_MS = 1200;
|
||||
let autoRaf = 0;
|
||||
function autoScrub(targetPos, perAltMs = PER_ALTITUDE_MS) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
if (isReduced()) { // reduced motion: no autonomous tween — jump to target + lock
|
||||
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
|
||||
setPos(targetPos);
|
||||
return;
|
||||
}
|
||||
if (autoRaf) cancelAnimationFrame(autoRaf);
|
||||
const fromPos = pos, dist = targetPos - fromPos;
|
||||
if (!dist) { setPos(targetPos); return; }
|
||||
const ms = perAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long
|
||||
// Photosensitivity floor (WCAG 2.3.1): each altitude step is one luminance
|
||||
// transition (a blended crossfade), so a single step must take >= 1/3 s to stay
|
||||
// <= 3 flashes/sec. The clamp is a no-op at today's 1200ms but guards the floor
|
||||
// if PER_ALTITUDE_MS is ever lowered. (Audit: the only other luminance sources —
|
||||
// the #black cover fade (200ms, single, >=1.2s apart) and the audio-coupled
|
||||
// crossfade — cannot repeat faster than 3/sec, so they need no clamp.)
|
||||
const safeAltMs = HEFFlash.clampDurationMs(perAltMs, 1);
|
||||
const ms = safeAltMs * Math.abs(dist); // constant per-altitude speed → N steps take N× as long
|
||||
let startTs = null;
|
||||
const tick = (ts) => {
|
||||
if (startTs === null) startTs = ts;
|
||||
@@ -780,6 +859,20 @@ function onWheel(e) {
|
||||
}, 90);
|
||||
}
|
||||
|
||||
// --- Auto-advance: when the altitude's base loop VIDEO completes one playthrough, drift
|
||||
// one altitude deeper (descend; past the deepest it wraps back to the top) — a slow kiosk
|
||||
// tour paced by the footage itself. A default-on toggle disables it; a manual drag or a
|
||||
// playing morph defers the move (it happens on the NEXT completion instead). Hooked into
|
||||
// the loop element's completion points (the timeupdate wrap + the ended safety net).
|
||||
function autoAdvanceOn() { const el = $("auto-advance"); return el ? el.checked : true; }
|
||||
function autoAdvanceOnLoopComplete() {
|
||||
if (autoAdvanceOn() && videoEverOn && !dialDrag && !busy) {
|
||||
autoScrub(Math.round(pos) + 1); // completed a playthrough → next altitude
|
||||
return true; // advanced — caller skips the normal loop-back
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- Altitude knob: the endless rotary encoder, drawn as a turnable dial ---
|
||||
// cosmos (highest) sits at the top; turning CLOCKWISE descends through the scales
|
||||
// (cosmos → orbit → coast → reef → abyss) and past the deepest WRAPS back to the
|
||||
@@ -823,6 +916,8 @@ function buildDial() {
|
||||
const t = svg("text", {
|
||||
x: lx, y: ly, "text-anchor": "middle", "dominant-baseline": "central",
|
||||
class: "dial-label", "data-index": String(i),
|
||||
role: "button", tabindex: "0",
|
||||
"aria-label": HEFi18n.pickUiString("scale." + ring.scales[i].id, activeLang),
|
||||
}, dial);
|
||||
t.textContent = ring.scales[i].id;
|
||||
}
|
||||
@@ -848,6 +943,16 @@ function renderDial() {
|
||||
for (const el of dial.querySelectorAll(".dial-label")) {
|
||||
el.classList.toggle("active", +el.getAttribute("data-index") === ringIndex);
|
||||
}
|
||||
setDialAria();
|
||||
}
|
||||
|
||||
// Reflect the committed altitude into the dial's slider semantics for assistive tech.
|
||||
function setDialAria() {
|
||||
if (!dial || !ring) return;
|
||||
dial.setAttribute("aria-valuemax", String(ring.scales.length - 1));
|
||||
dial.setAttribute("aria-valuenow", String(ringIndex));
|
||||
const s = ring.scales[ringIndex];
|
||||
if (s) dial.setAttribute("aria-valuetext", HEFi18n.pickUiString("scale." + s.id, activeLang));
|
||||
}
|
||||
|
||||
function dialAngle(e) {
|
||||
@@ -867,11 +972,18 @@ function angDelta(a, b) {
|
||||
// destination pick (was the server's job in /api/ring/advance; moved here so the
|
||||
// scrub responds without a round-trip. `pick_clip_id` in player/ring.py stays the
|
||||
// canonical pure helper for the Pi player).
|
||||
function pickPoolClip(index) {
|
||||
function pickPoolClip(index, fromId) {
|
||||
const n = ring.scales.length;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, n)];
|
||||
const pool = (s.pool && s.pool.length) ? s.pool : [{ clip_id: s.clip_id }];
|
||||
return pool[Math.floor(Math.random() * pool.length)].clip_id;
|
||||
const ids = poolIds(s);
|
||||
// Pick a DESTINATION only among clips fully loaded relative to fromId (base +
|
||||
// morphs both ways) — so the transition never stalls on un-downloaded media. The
|
||||
// eligible set grows as the background preload completes. Fallback to the first
|
||||
// pool member (the phase-1 gate guarantees it + its connecting morphs are loaded).
|
||||
let elig = fromId ? HEFPreload.eligibleDestinations(ids, fromId, _preloadDeps())
|
||||
: HEFPreload.eligibleMembers(ids, _preloadDeps());
|
||||
if (!elig.length) elig = [ids[0]];
|
||||
return HEFPreload.pick(elig, Math.random);
|
||||
}
|
||||
|
||||
// Build (or re-roll) the segment [lo, lo+1]. The end equal to the rested altitude
|
||||
@@ -881,15 +993,18 @@ function pickPoolClip(index) {
|
||||
function rebuildSegment(lo, enteredFrom) {
|
||||
const n = ring.scales.length;
|
||||
const atLo = HEFScrub.wrapIndex(lo, n), atHi = HEFScrub.wrapIndex(lo + 1, n);
|
||||
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo);
|
||||
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1);
|
||||
const loId = (enteredFrom === atLo) ? activeClipId : pickPoolClip(lo, activeClipId);
|
||||
const hiId = (enteredFrom === atHi) ? activeClipId : pickPoolClip(lo + 1, activeClipId);
|
||||
const file = morphByPair[`${loId}→${hiId}`] || null;
|
||||
activeSeg = { lo, clipLo: loId, clipHi: hiId, file };
|
||||
if (file) {
|
||||
overlay.style.opacity = "0";
|
||||
affectLayer.style.opacity = "0";
|
||||
tint.style.opacity = "0";
|
||||
vid.style.filter = "none";
|
||||
// WebGL path: the graded canvas sits on top, so #vid stays raw (no double-grade).
|
||||
// Fallback path: #vid IS the visible surface, so it must carry the mood grade
|
||||
// through the morph (setting "none" here was the "morph ignores Mood" bug).
|
||||
vid.style.filter = paintOK ? "none" : gradeFilter;
|
||||
vid.loop = false;
|
||||
if (vid.dataset.morph !== file) {
|
||||
vid.dataset.morph = file;
|
||||
@@ -921,6 +1036,12 @@ function setPos(next) {
|
||||
const { lo, frac } = HEFScrub.segmentOf(pos);
|
||||
if (frac === 0) { // settled exactly on an altitude → lock + base loop + single soundtrack
|
||||
busy = false;
|
||||
// If we JUMPED straight here (a reduced-motion instant cut skips the segment that
|
||||
// would re-roll the destination), activeClipId still points at the previous
|
||||
// altitude's clip. Re-roll from this altitude's pool so the video actually changes.
|
||||
if (!poolIds(ring.scales[ringIndex]).includes(activeClipId)) {
|
||||
activeClipId = pickPoolClip(ringIndex, activeClipId);
|
||||
}
|
||||
delete vid.dataset.morph; // clear so re-entering the same segment reloads the morph (not the base)
|
||||
currentClipId = null; // force ensureClipMedia to reload + loop the locked clip
|
||||
// Land the loop on the frame the morph last showed for THIS travel direction:
|
||||
@@ -928,7 +1049,7 @@ function setPos(next) {
|
||||
// (0). Explicit (not relying on the scrub's preload) so a reverse landing never
|
||||
// jumps even if the heading clip wasn't warmed in time. (D3 fix.)
|
||||
loadLoop(activeClip(), HEFScrub.loopLandFrame(scrubDir, LOOP_TAIL_S));
|
||||
playLoop(); // the loop element was preloaded to this clip during the scrub → instant, no reload
|
||||
playLoop(); // run the resting loop at the chosen Speed (0x freezes it) — independent of reduce-motion
|
||||
showActiveSource();
|
||||
setNeedle(ringIndex * dialStep());
|
||||
renderScaleReadout();
|
||||
@@ -980,7 +1101,11 @@ function onDialUp(e) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model).
|
||||
// Snap on release: a drag is a way of GRABBING the dial, not a free-standing
|
||||
// encoder detent — so when the operator lets go between altitudes, auto-scrub the
|
||||
// short way to the CLOSEST one and settle there (frac 0 locks). The result is the
|
||||
// same as a tap/label-click: a grab-and-release always lands on a discrete altitude.
|
||||
autoScrub(Math.round(pos));
|
||||
}
|
||||
|
||||
// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
|
||||
@@ -993,14 +1118,119 @@ function jumpToScale(idx) {
|
||||
if (d) autoScrub(Math.round(pos) + d);
|
||||
}
|
||||
|
||||
// Keyboard operation of the dial (role="slider"): arrows step one altitude, Home/End
|
||||
// jump to the ends. Enter/Space on a focused label jumps to that scale.
|
||||
function onDialKey(e) {
|
||||
if (!ring || ring.scales.length < 2) return;
|
||||
const t = e.target;
|
||||
if (t && t.classList && t.classList.contains("dial-label") && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
jumpToScale(+t.getAttribute("data-index"));
|
||||
return;
|
||||
}
|
||||
let handled = true;
|
||||
switch (e.key) {
|
||||
case "ArrowDown": case "ArrowRight": autoScrub(Math.round(pos) + 1); break; // descend
|
||||
case "ArrowUp": case "ArrowLeft": autoScrub(Math.round(pos) - 1); break; // ascend
|
||||
case "Home": jumpToScale(0); break;
|
||||
case "End": jumpToScale(ring.scales.length - 1); break;
|
||||
default: handled = false;
|
||||
}
|
||||
if (handled) e.preventDefault();
|
||||
}
|
||||
|
||||
// --- Dev Mode: pool picker + live analysis, all under one toggle ---
|
||||
// Off by default, state persisted in localStorage. Everything here is read from
|
||||
// data the renderer already has (clips, ring, the alteration response, the preload
|
||||
// cache) — no server endpoints. Editing labels stays in /author.html.
|
||||
const DEV_KEY = "hef.devMode";
|
||||
const SPEED_KEY = "hef.playSpeed";
|
||||
const RM_KEY = "hef.reduceMotion";
|
||||
let devMode = false;
|
||||
let devStatsTimer = null;
|
||||
|
||||
// --- Playback speed -----------------------------------------------------------
|
||||
// The Playback Speed slider (0x–4x forward) drives ONLY the resting altitude loop
|
||||
// video (#vid-loop); the morph/transition video (#vid) is scrub-driven and is
|
||||
// never speed-controlled. 0x freezes the loop on its current frame. (Reverse was
|
||||
// dropped: base loop clips aren't all-intra, so reverse seeking jitters.)
|
||||
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
|
||||
let playSpeed = 1;
|
||||
|
||||
// Apply the chosen slider speed to the resting loop video. When video output is
|
||||
// off we just keep the forward rate sane for when it resumes.
|
||||
function applyPlaySpeed() {
|
||||
if (!videoEverOn || !$("visual").checked) {
|
||||
loopVid.playbackRate = Math.max(0.0625, playSpeed || 1);
|
||||
return;
|
||||
}
|
||||
if (playSpeed > SPEED_EPS) {
|
||||
loopVid.playbackRate = playSpeed;
|
||||
if (loopVid.paused) loopVid.play().catch(() => {});
|
||||
} else {
|
||||
loopVid.pause(); // 0x → freeze on the current frame
|
||||
}
|
||||
}
|
||||
// Both the welcome screen's slider and the panel's drive the same playSpeed; each
|
||||
// mirrors the other and the shared "1.00×" readout.
|
||||
const SPEED_SLIDERS = ["welcome-play-speed", "play-speed"];
|
||||
const SPEED_VALS = ["welcome-play-speed-val", "play-speed-val"];
|
||||
function syncSpeedSliders() {
|
||||
for (const id of SPEED_SLIDERS) { const s = $(id); if (s && s.value !== String(playSpeed)) s.value = String(playSpeed); }
|
||||
}
|
||||
function updateSpeedLabel() {
|
||||
const txt = playSpeed.toFixed(2) + "×";
|
||||
for (const id of SPEED_VALS) { const el = $(id); if (el) el.textContent = txt; }
|
||||
}
|
||||
function initPlaySpeed() {
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem(SPEED_KEY); } catch (_) {}
|
||||
playSpeed = stored === null ? 1 : (parseFloat(stored) || 0);
|
||||
syncSpeedSliders();
|
||||
for (const id of SPEED_SLIDERS) {
|
||||
const slider = $(id);
|
||||
if (!slider) continue;
|
||||
slider.addEventListener("input", () => {
|
||||
playSpeed = parseFloat(slider.value) || 0;
|
||||
try { localStorage.setItem(SPEED_KEY, String(playSpeed)); } catch (_) {}
|
||||
syncSpeedSliders();
|
||||
updateSpeedLabel();
|
||||
applyPlaySpeed();
|
||||
});
|
||||
}
|
||||
updateSpeedLabel();
|
||||
}
|
||||
|
||||
// --- Reduce motion (morph transitions) ----------------------------------------
|
||||
// A visible toggle (welcome + panel, mirrored) that turns OFF the morph/transition
|
||||
// videos: when ON, altitude changes are instant cuts instead of an animated morph
|
||||
// tween (see autoScrub's reduced branch). It does NOT touch the resting loop —
|
||||
// that is the Speed slider's job (Speed 0 freezes the loop independently).
|
||||
// Defaults from the OS `prefers-reduced-motion` setting, then remembers the
|
||||
// visitor's choice.
|
||||
let reduceMotion = false;
|
||||
function isReduced() { return reduceMotion; }
|
||||
const RM_BOXES = ["welcome-reduce-motion", "reduce-motion"];
|
||||
function syncReduceMotionBoxes() {
|
||||
for (const id of RM_BOXES) { const b = $(id); if (b && b.checked !== reduceMotion) b.checked = reduceMotion; }
|
||||
}
|
||||
function initReduceMotion() {
|
||||
let stored = null;
|
||||
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
|
||||
const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
reduceMotion = stored === null ? !!prefers : stored === "1";
|
||||
syncReduceMotionBoxes();
|
||||
for (const id of RM_BOXES) {
|
||||
const box = $(id);
|
||||
if (!box) continue;
|
||||
box.addEventListener("change", () => {
|
||||
reduceMotion = box.checked; // governs FUTURE transitions (cut vs morph)
|
||||
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
|
||||
syncReduceMotionBoxes();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
@@ -1129,6 +1359,7 @@ async function reroll() {
|
||||
// from the live API and masks it otherwise). Reloads on my edits AND on a server
|
||||
// restart. Dev-only convenience; harmless if the endpoint is absent.
|
||||
function devLiveReload() {
|
||||
if (window.HEF_CONFIG && window.HEF_CONFIG.static) return; // no dev server in the static build
|
||||
let seen = null;
|
||||
setInterval(async () => {
|
||||
try {
|
||||
@@ -1159,6 +1390,40 @@ function updateAudioLevelLabel() {
|
||||
if (el) el.textContent = String(audioLevel());
|
||||
}
|
||||
|
||||
// The Audio dropdown source: "none" | "soundtrack" | "music". `restAudio`/
|
||||
// `blendAudio` consult HEFScrub.audioPlan(source, level, frac) for the mode branch.
|
||||
function audioSource() {
|
||||
const el = $("audio-source");
|
||||
return el ? el.value : "soundtrack";
|
||||
}
|
||||
// Volume is meaningless when there is no source — grey it out on None.
|
||||
function syncVolumeDisabled() {
|
||||
const slider = $("audio");
|
||||
if (slider) slider.disabled = audioSource() === "none";
|
||||
}
|
||||
|
||||
// Shareable deep-link: ?audio=none|soundtrack|music presets the Audio source on load
|
||||
// (and ?vol=0..10 the master volume), so a link opens straight into a chosen sound mode
|
||||
// — e.g. share …/?audio=music with someone who'd prefer music over the soundtrack. The
|
||||
// source is set on the (welcome-hidden) panel dropdown; the launch gesture then applies
|
||||
// it. Invalid values are ignored (the default Soundtrack stands).
|
||||
function applyUrlAudioParams() {
|
||||
const p = new URLSearchParams(location.search);
|
||||
const src = (p.get("audio") || "").toLowerCase();
|
||||
if (src === "none" || src === "soundtrack" || src === "music") {
|
||||
const el = $("audio-source"); if (el) el.value = src;
|
||||
const w = $("welcome-audio-source"); if (w) w.value = src; // reflect on the opening screen too
|
||||
}
|
||||
const vol = p.get("vol");
|
||||
if (vol !== null && vol !== "" && Number.isFinite(+vol)) {
|
||||
const v = String(Math.max(0, Math.min(10, Math.round(+vol))));
|
||||
// Seed BOTH the welcome mirror (carried into the panel on launch) and the panel
|
||||
// slider, so the shared level survives entry.
|
||||
const w = $("welcome-audio"); if (w) w.value = v;
|
||||
const a = $("audio"); if (a) a.value = v;
|
||||
}
|
||||
}
|
||||
|
||||
// The element currently carrying the most signal — for the diagnostic readout,
|
||||
// which used to assume a single `aud`. With two crossfading elements either may be
|
||||
// the audible one, so report on whichever is louder (ties → aud).
|
||||
@@ -1167,12 +1432,14 @@ function activeAudEl() { return audB.volume > aud.volume ? audB : aud; }
|
||||
// --- Live audio status readout (diagnostic) — so a "no sound" report is legible ---
|
||||
const AUD_ERR = ["", "aborted", "network", "decode", "format-not-supported"];
|
||||
function audioStatusText() {
|
||||
if (audioLevel() === 0) return "audio: off (level 0)";
|
||||
const src = audioSource();
|
||||
if (src === "none") return "audio: off (source None)";
|
||||
if (audioLevel() === 0) return "audio: off (volume 0)";
|
||||
const s = ring && ring.scales[ringIndex];
|
||||
const url = soundtrackUrl();
|
||||
const url = currentAudioUrl();
|
||||
const el = activeAudEl();
|
||||
const head = `L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
|
||||
if (!url) return "audio: " + head + " ← no soundtrack URL for this scale";
|
||||
const head = `${src} · L${audioLevel()}/10 · scale=${s ? s.id : "?"} · ring=${serverRing ? "server" : "SYNTH"} · url=${url || "NONE"}`;
|
||||
if (!url) return "audio: " + head + " ← no URL for this source/scale";
|
||||
if (el.error) return `audio: ${head} · MEDIA ERROR ${el.error.code} (${AUD_ERR[el.error.code] || "?"})`;
|
||||
if (audLastErr) return `audio: ${head} · ${audLastErr}`;
|
||||
if (el.paused) return `audio: ${head} · PAUSED rs=${el.readyState}`;
|
||||
@@ -1183,14 +1450,15 @@ function updateAudioStatus() {
|
||||
if (!el) return;
|
||||
el.textContent = audioStatusText();
|
||||
const a = activeAudEl();
|
||||
const on = audioLevel() > 0;
|
||||
const playing = on && !!soundtrackUrl() && !a.paused && !a.error && !audLastErr;
|
||||
const on = audioSource() !== "none" && audioLevel() > 0;
|
||||
const url = currentAudioUrl();
|
||||
const playing = on && !!url && !a.paused && !a.error && !audLastErr;
|
||||
el.classList.toggle("playing", playing);
|
||||
el.classList.toggle("blocked", on && (!soundtrackUrl() || !!a.error || !!audLastErr));
|
||||
el.classList.toggle("blocked", on && (!url || !!a.error || !!audLastErr));
|
||||
}
|
||||
aud.addEventListener("error", updateAudioStatus);
|
||||
audB.addEventListener("error", updateAudioStatus);
|
||||
setInterval(updateAudioStatus, 400);
|
||||
setInterval(() => { updateAudioStatus(); musicWatchdog(); }, 400);
|
||||
|
||||
// Cancel any in-flight fade on an element. A fade is a setInterval ramping volume;
|
||||
// if a stale one keeps running after a new fade or a direct volume set, it drags the
|
||||
@@ -1237,11 +1505,39 @@ function scaleAudioUrl(index) {
|
||||
if (!ring || !ring.scales.length) return null;
|
||||
const s = ring.scales[HEFScrub.wrapIndex(index, ring.scales.length)];
|
||||
const a = s.audio || SCALE_AUDIO_FALLBACK[s.id];
|
||||
return a ? "/media/audio/" + a : null;
|
||||
// Route through mediaBase() so audio loads from R2 in the static build (and the
|
||||
// local /media mount in dev) — NOT a hardcoded /media path, which 404s on the
|
||||
// static site. mediaUrl() then passes this absolute url straight through.
|
||||
return a ? mediaBase() + "audio/" + a : null;
|
||||
}
|
||||
// The current resting altitude's soundtrack url (kept for the status readout).
|
||||
function soundtrackUrl() { return scaleAudioUrl(ringIndex); }
|
||||
|
||||
// --- Music: a rotating playlist of mellow synth tracks that CROSSFADE between each
|
||||
// other (not one looping track), altitude-independent — the same relaxing rotation at
|
||||
// every altitude. Each track plays to its END, then a random OTHER one plays (never an
|
||||
// immediate repeat); a short crossfade a few seconds before the end keeps it GAPLESS, so
|
||||
// Music is always playing while selected. Reuses the two audio elements. The crossfade
|
||||
// duration is injectable for tests.
|
||||
const MUSIC_PLAYLIST = [
|
||||
"music/manatees.mp3", "music/dewdrop.mp3", "music/eastminster.mp3",
|
||||
"music/overheat.mp3", "music/concentration.mp3",
|
||||
];
|
||||
const MUSIC_XFADE_MS = (typeof window !== "undefined" && window.__hefMusicXfadeMs) || 6000;
|
||||
const MUSIC_XFADE_S = MUSIC_XFADE_MS / 1000;
|
||||
function musicTrackUrl(i) {
|
||||
return mediaBase() + "audio/" + MUSIC_PLAYLIST[HEFScrub.wrapIndex(i, MUSIC_PLAYLIST.length)];
|
||||
}
|
||||
let musicIdx = -1; // index of the track on the active music element (-1 = not started)
|
||||
let musicEl = null; // the element currently carrying the active music track
|
||||
let musicXfading = false; // true during a track→track crossfade (guards re-entry)
|
||||
|
||||
// The url the active source would play right now (for the diagnostic readout).
|
||||
function currentAudioUrl() {
|
||||
if (audioSource() === "music") return musicTrackUrl(musicIdx < 0 ? 0 : musicIdx);
|
||||
return soundtrackUrl();
|
||||
}
|
||||
|
||||
// Start (once) and set the volume of one element to a url. Safari needs the first
|
||||
// play() inside a user gesture; later programmatic plays reuse the unlocked element.
|
||||
// Only (re)loads `src` when the url actually changes (so a same-url call is a no-op
|
||||
@@ -1273,31 +1569,97 @@ function assignElements(urlLo, urlHi) {
|
||||
return { elLo: aud, elHi: audB };
|
||||
}
|
||||
|
||||
// Mid-segment: crossfade scale `loIndex` (gain 1-frac) against loIndex+1 (gain frac),
|
||||
// both scaled by the master audio level. No-op when the level is 0.
|
||||
// Both elements fade to silence (None, or Volume 0). Also ends any music rotation.
|
||||
function silenceAll() {
|
||||
stopMusic();
|
||||
fadeVolume(aud, 0, FADE_MS, () => aud.pause());
|
||||
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
|
||||
}
|
||||
|
||||
// Crossfade the finishing track out and a random OTHER track in on the other element.
|
||||
function advanceMusic() {
|
||||
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0) return;
|
||||
musicXfading = true;
|
||||
const next = HEFScrub.pickNextIndex(musicIdx, MUSIC_PLAYLIST.length, Math.random());
|
||||
const nextEl = musicEl === aud ? audB : aud;
|
||||
const prevEl = musicEl;
|
||||
ensurePlaying(nextEl, musicTrackUrl(next), 0); // incoming track, silent...
|
||||
fadeVolume(nextEl, audioVol(), MUSIC_XFADE_MS, () => { musicXfading = false; }); // ...fade it in,
|
||||
fadeVolume(prevEl, 0, MUSIC_XFADE_MS, () => prevEl.pause()); // ...fade the finishing one out.
|
||||
musicEl = nextEl; musicIdx = next;
|
||||
updateAudioStatus();
|
||||
}
|
||||
// timeupdate: a few seconds before the active track ends, crossfade to a random next
|
||||
// (gapless). Only the active element, in Music mode, once per track.
|
||||
function onMusicProgress(e) {
|
||||
if (musicXfading || audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
|
||||
const d = musicEl.duration;
|
||||
if (d && isFinite(d) && d > MUSIC_XFADE_S * 2 && musicEl.currentTime >= d - MUSIC_XFADE_S) advanceMusic();
|
||||
}
|
||||
// ended: safety net — a track that finishes without a crossfade (short track / missed
|
||||
// timeupdate) immediately rolls a new random one, so Music never stops while selected.
|
||||
function onMusicEnded(e) {
|
||||
if (audioSource() !== "music" || musicIdx < 0 || e.target !== musicEl) return;
|
||||
musicXfading = false;
|
||||
advanceMusic();
|
||||
}
|
||||
// Start playback (first call) or just re-apply the master volume to the active track. The
|
||||
// FIRST call runs inside the audio gesture, so we prime BOTH elements here to unlock them
|
||||
// on Safari (later crossfades play the 2nd element outside a gesture).
|
||||
function startMusic(vol) {
|
||||
if (musicIdx < 0) {
|
||||
musicXfading = false;
|
||||
musicIdx = Math.floor(Math.random() * MUSIC_PLAYLIST.length);
|
||||
musicEl = aud;
|
||||
ensurePlaying(aud, musicTrackUrl(musicIdx), vol);
|
||||
ensurePlaying(audB, musicTrackUrl(musicIdx), 0); // prime + unlock the 2nd element (silent)
|
||||
} else {
|
||||
cancelFade(musicEl);
|
||||
musicEl.volume = HEFScrub.clamp01(vol);
|
||||
}
|
||||
updateAudioStatus();
|
||||
}
|
||||
// Reset (on leaving Music, or going silent).
|
||||
function stopMusic() { musicXfading = false; musicIdx = -1; musicEl = null; }
|
||||
|
||||
// Keep Music playing while selected: resume the active track if it stalls/pauses unexpectedly.
|
||||
function musicWatchdog() {
|
||||
if (audioSource() !== "music" || audioVol() === 0 || musicIdx < 0 || !musicEl) return;
|
||||
if (musicEl.paused && !musicXfading) { const pr = musicEl.play(); if (pr) pr.catch(() => {}); }
|
||||
}
|
||||
|
||||
for (const el of [aud, audB]) {
|
||||
el.addEventListener("timeupdate", onMusicProgress);
|
||||
el.addEventListener("ended", onMusicEnded);
|
||||
}
|
||||
|
||||
// Mid-segment scrub. Soundtrack: crossfade scale `loIndex` (gain 1-frac) against
|
||||
// loIndex+1 (gain frac), scaled by the master volume. Music: hold the single loop.
|
||||
// Silent (None / Volume 0): leave the elements be — restAudio fades them on landing.
|
||||
function blendAudio(loIndex, frac) {
|
||||
const lvl = audioVol();
|
||||
if (lvl === 0) return;
|
||||
const plan = HEFScrub.audioPlan(audioSource(), audioVol(), frac);
|
||||
if (plan.mode === "silent") return;
|
||||
if (plan.mode === "music") { startMusic(plan.vol); return; }
|
||||
stopMusic(); // leaving Music for the soundtrack crossfade
|
||||
const urlLo = scaleAudioUrl(loIndex), urlHi = scaleAudioUrl(loIndex + 1);
|
||||
const g = HEFScrub.crossfadeGains(frac);
|
||||
const { elLo, elHi } = assignElements(urlLo, urlHi);
|
||||
ensurePlaying(elLo, urlLo, g.from * lvl);
|
||||
ensurePlaying(elHi, urlHi, g.to * lvl);
|
||||
ensurePlaying(elLo, urlLo, plan.lo);
|
||||
ensurePlaying(elHi, urlHi, plan.hi);
|
||||
updateAudioStatus();
|
||||
}
|
||||
|
||||
// At rest on a single altitude: the element holding it plays at the master level, the
|
||||
// other fades out. Level 0 → both fade to silence.
|
||||
// At rest on a single altitude. Silent → both fade out. Music → the single loop at
|
||||
// the master volume. Soundtrack → the element holding this altitude plays at the
|
||||
// master volume (frac 0 → lo = level), the other fades out.
|
||||
function restAudio(index) {
|
||||
if (audioLevel() === 0) {
|
||||
fadeVolume(aud, 0, FADE_MS, () => aud.pause());
|
||||
fadeVolume(audB, 0, FADE_MS, () => audB.pause());
|
||||
return;
|
||||
}
|
||||
const plan = HEFScrub.audioPlan(audioSource(), audioVol(), 0);
|
||||
if (plan.mode === "silent") { silenceAll(); return; }
|
||||
if (plan.mode === "music") { startMusic(plan.vol); return; }
|
||||
stopMusic(); // soundtrack → ensure the music rotation is stopped
|
||||
const url = scaleAudioUrl(index);
|
||||
const active = (audB.dataset.url === url) ? audB : aud;
|
||||
const idle = active === audB ? aud : audB;
|
||||
ensurePlaying(active, url, audioVol());
|
||||
ensurePlaying(active, url, plan.lo);
|
||||
fadeVolume(idle, 0, FADE_MS);
|
||||
updateAudioStatus();
|
||||
}
|
||||
@@ -1306,19 +1668,85 @@ function restAudio(index) {
|
||||
// synchronously from the toggle gesture the first time it plays (Safari unlock).
|
||||
function applyAudio() { restAudio(ringIndex); }
|
||||
|
||||
// "Loading Universe…" splash — hidden once media is preloaded; reflects progress.
|
||||
// "Loading Universe…" progress (lives in the welcome screen) — reflects preload.
|
||||
function setLoadingProgress(done, total) {
|
||||
const fill = document.getElementById("loading-fill");
|
||||
if (fill && total) fill.style.width = Math.round((done / total) * 100) + "%";
|
||||
}
|
||||
function hideLoading() {
|
||||
const el = document.getElementById("loading");
|
||||
|
||||
let videoEverOn = false; // has Video ever been switched on this session?
|
||||
let universeReady = false; // has phase-1 preload finished (safe to enter)?
|
||||
let launchPending = false; // did the visitor press Launch while still loading?
|
||||
|
||||
// Begin the experience: mark video-on, play the soundtrack at the chosen level,
|
||||
// and start the resting loop at the chosen speed. Called from the Launch gesture
|
||||
// so the audio play() unlocks on Safari (which blocks play() outside a user
|
||||
// gesture). Idempotent via videoEverOn.
|
||||
function beginExperience() {
|
||||
videoEverOn = true;
|
||||
updateAudioLevelLabel();
|
||||
applyAudio();
|
||||
applyPlaySpeed();
|
||||
}
|
||||
|
||||
// Welcome screen ----------------------------------------------------------------
|
||||
// The welcome controls mirror the panel controls. Speed and Language drive shared
|
||||
// state live (see initPlaySpeed / setLanguage). Video is assumed ON at entry; the
|
||||
// Audio level is copied into the panel input.
|
||||
function applyWelcomeToPanel() {
|
||||
$("visual").checked = true; // the welcome screen assumes Video on (no toggle)
|
||||
const wa = $("welcome-audio");
|
||||
if (wa) { $("audio").value = wa.value; updateAudioLevelLabel(); }
|
||||
const ws = $("welcome-audio-source"); // carry the chosen source into the panel dropdown
|
||||
if (ws) { $("audio-source").value = ws.value; syncVolumeDisabled(); }
|
||||
}
|
||||
function updateWelcomeAudioLabel() {
|
||||
const el = $("welcome-audio-val");
|
||||
if (el) el.textContent = String(+($("welcome-audio") || {}).value || 0);
|
||||
}
|
||||
// Volume is meaningless on None — grey the welcome slider too, mirroring the panel.
|
||||
function syncWelcomeVolumeDisabled() {
|
||||
const src = $("welcome-audio-source"), slider = $("welcome-audio");
|
||||
if (src && slider) slider.disabled = src.value === "none";
|
||||
}
|
||||
|
||||
// Remove the welcome overlay (fade out) — the panel reveals via `body:has(#welcome)`.
|
||||
function enterSimulation() {
|
||||
applyWelcomeToPanel(); // pick up any last-moment Video/Audio change made in State B
|
||||
beginExperience();
|
||||
update(); // render with the chosen Video/Audio state
|
||||
const el = $("welcome");
|
||||
if (!el) return;
|
||||
el.classList.add("done");
|
||||
setTimeout(() => el.remove(), 700);
|
||||
}
|
||||
|
||||
let videoEverOn = false; // has Video ever been switched on this session?
|
||||
// Launch button: the single entry gesture. If the universe is ready, go straight
|
||||
// in; otherwise switch the welcome screen to its centered-loading state and enter
|
||||
// automatically once phase-1 finishes. beginExperience() runs in THIS gesture so
|
||||
// audio unlocks on Safari even when entry is deferred.
|
||||
function onLaunch() {
|
||||
applyWelcomeToPanel();
|
||||
beginExperience();
|
||||
if (universeReady) {
|
||||
enterSimulation();
|
||||
} else {
|
||||
launchPending = true;
|
||||
const w = $("welcome");
|
||||
if (w) w.classList.replace("welcoming", "loading");
|
||||
}
|
||||
}
|
||||
|
||||
function initWelcome() {
|
||||
updateWelcomeAudioLabel();
|
||||
const wa = $("welcome-audio");
|
||||
if (wa) wa.addEventListener("input", updateWelcomeAudioLabel);
|
||||
const ws = $("welcome-audio-source");
|
||||
if (ws) ws.addEventListener("change", syncWelcomeVolumeDisabled);
|
||||
syncWelcomeVolumeDisabled();
|
||||
const launch = $("welcome-launch");
|
||||
if (launch) launch.addEventListener("click", onLaunch);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
devLiveReload();
|
||||
@@ -1327,7 +1755,10 @@ async function main() {
|
||||
await landScale(); // pick the initial scale's pool member before first render
|
||||
buildDial(); // draw the altitude knob from the ring's scales
|
||||
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
|
||||
initLanguage(); // populate the language dropdown + wire live switching
|
||||
initLanguage(); // populate both language dropdowns + wire live switching
|
||||
initPlaySpeed(); // playback-speed sliders (welcome + panel)
|
||||
initReduceMotion(); // reduce-motion toggles (welcome + panel) — morph transitions off
|
||||
initWelcome(); // wire the welcome-screen controls + Launch button
|
||||
renderScaleReadout();
|
||||
// Sliders stream on "input"; the toggles fire on "change".
|
||||
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
|
||||
@@ -1336,14 +1767,18 @@ async function main() {
|
||||
// play() outside a user gesture).
|
||||
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
|
||||
updateAudioLevelLabel();
|
||||
// Video toggle: the FIRST time video turns on, bring audio up to 3/10 with it (one
|
||||
// flip = the full experience at a gentle level). If audio is already raised, leave it.
|
||||
// Set + played in THIS gesture so it unlocks on Safari.
|
||||
// Audio source dropdown: reconcile SYNCHRONOUSLY in this change gesture so
|
||||
// switching to Soundtrack/Music unlocks + plays on Safari; None greys Volume.
|
||||
$("audio-source").addEventListener("change", () => { syncVolumeDisabled(); applyAudio(); debounced(); });
|
||||
applyUrlAudioParams(); // shareable ?audio=/?vol= deep-link presets
|
||||
syncVolumeDisabled();
|
||||
syncWelcomeVolumeDisabled(); // reflect a URL-set source on the welcome slider too
|
||||
updateWelcomeAudioLabel();
|
||||
// Video toggle (panel): the FIRST time video turns on without having entered via
|
||||
// Launch, begin the experience too — couple audio in. (Normally Launch entered
|
||||
// first, so videoEverOn is already true and this is a no-op beyond re-render.)
|
||||
$("visual").addEventListener("change", () => {
|
||||
if ($("visual").checked && !videoEverOn) {
|
||||
videoEverOn = true;
|
||||
if (audioLevel() === 0) { $("audio").value = "3"; updateAudioLevelLabel(); applyAudio(); }
|
||||
}
|
||||
if ($("visual").checked && !videoEverOn) beginExperience();
|
||||
debounced();
|
||||
});
|
||||
// Altitude knob: drag to turn (commit detents on release), scroll to step, tap a label to jump.
|
||||
@@ -1351,32 +1786,47 @@ async function main() {
|
||||
window.addEventListener("pointermove", onDialMove);
|
||||
window.addEventListener("pointerup", onDialUp);
|
||||
dial.addEventListener("wheel", onWheel, { passive: false });
|
||||
dial.addEventListener("keydown", onDialKey); // arrows/Home/End + Enter/Space on labels
|
||||
$("stage").addEventListener("wheel", onWheel, { passive: false });
|
||||
update(); // render the initial state (both toggles off → black, silent)
|
||||
await preloadAllMedia(); // download all media, updating the loading bar
|
||||
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
|
||||
// Phase 1 — gate the universe on a minimal, fully-loaded set: one clip per altitude
|
||||
// + the morphs connecting them (~126 MB). Fast to start; the knob is smooth from the
|
||||
// first turn because the random pick only ever lands on loaded clips (eligibility).
|
||||
await cacheMany(HEFPreload.phase1Files(ring.scales, _preloadDeps()));
|
||||
universeReady = true; // safe to enter — the welcome "Loading Universe" bar is full
|
||||
window.__hefReady = true; // diagnostic seam: e2e waits on this before pressing Launch
|
||||
// If the visitor already pressed Launch (and is watching the centered loader),
|
||||
// enter now that the universe is ready.
|
||||
if (launchPending) enterSimulation();
|
||||
// Phase 2 — grow the pool in the background: more clips per altitude + their morphs.
|
||||
// Each becomes eligible for the random pick only once fully loaded, so navigation
|
||||
// keeps using the already-loaded clips until the new ones are ready (no stall).
|
||||
preloadAllMedia(); // NO await — streams the rest behind the unlocked universe
|
||||
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
|
||||
// outside a user gesture (muted video survives, sound does not), so an auto-start
|
||||
// would show video but stay silent. Instead the experience waits for the operator to
|
||||
// turn Video on — that click IS the gesture, and its handler (above) lifts audio to
|
||||
// 3/10 and starts the soundtrack in-gesture, so sound reliably comes up with video.
|
||||
// outside a user gesture (muted video survives, sound does not). The Launch click IS
|
||||
// the gesture — beginExperience() plays the soundtrack in-gesture, so sound reliably
|
||||
// comes up with video whether entry is immediate or deferred until media is ready.
|
||||
}
|
||||
|
||||
// Populate the language dropdown from the registry and wire live switching.
|
||||
// English default, session-only (no persistence). Switching swaps UI chrome
|
||||
// and re-renders the current annotations/affect in place — no reload, no fetch.
|
||||
// Two selects share the same language state: the welcome screen's and the panel's.
|
||||
const LANG_SELECTS = ["welcome-lang", "lang-select"];
|
||||
function initLanguage() {
|
||||
const sel = $("lang-select");
|
||||
if (!sel) return;
|
||||
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
|
||||
const o = document.createElement("option");
|
||||
o.value = code;
|
||||
o.textContent = nativeName;
|
||||
sel.appendChild(o);
|
||||
for (const id of LANG_SELECTS) {
|
||||
const sel = $(id);
|
||||
if (!sel) continue;
|
||||
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
|
||||
const o = document.createElement("option");
|
||||
o.value = code;
|
||||
o.textContent = nativeName;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
sel.value = activeLang;
|
||||
sel.addEventListener("change", () => setLanguage(sel.value));
|
||||
}
|
||||
sel.value = activeLang;
|
||||
applyUiStrings(activeLang);
|
||||
sel.addEventListener("change", () => setLanguage(sel.value));
|
||||
}
|
||||
|
||||
// Fill every [data-i18n] node with its catalog string for `lang`.
|
||||
@@ -1389,6 +1839,7 @@ function applyUiStrings(lang) {
|
||||
|
||||
function setLanguage(lang) {
|
||||
activeLang = lang;
|
||||
for (const id of LANG_SELECTS) { const s = $(id); if (s && s.value !== lang) s.value = lang; }
|
||||
applyUiStrings(lang);
|
||||
renderScaleReadout();
|
||||
if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
|
||||
@@ -1396,3 +1847,31 @@ function setLanguage(lang) {
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
// Temporary ?debug overlay: an on-screen readout of media/render state, for
|
||||
// diagnosing real-browser playback that headless can't reproduce. Add ?debug to the
|
||||
// URL to show it. Harmless/no-op without the flag; safe to remove once resolved.
|
||||
(function debugOverlay() {
|
||||
if (!/[?&]debug\b/.test(location.search)) return;
|
||||
const box = document.createElement("div");
|
||||
box.style.cssText = "position:fixed;left:6px;bottom:6px;z-index:99999;max-width:96vw;" +
|
||||
"background:rgba(0,0,0,.82);color:#0f0;font:11px/1.35 monospace;padding:8px 10px;" +
|
||||
"white-space:pre-wrap;border:1px solid #0a0;border-radius:4px;";
|
||||
document.body.appendChild(box);
|
||||
let webgl = "?";
|
||||
try { const c = document.createElement("canvas"); webgl = c.getContext("webgl2") ? "webgl2" : (c.getContext("webgl") ? "webgl1" : "NONE"); }
|
||||
catch (e) { webgl = "throw:" + e.message; }
|
||||
const errs = [];
|
||||
window.addEventListener("error", (e) => { errs.push((e.message || e.error || "err").toString().slice(0, 80)); });
|
||||
const m = (el) => el ? `rs=${el.readyState} ${el.paused ? "PAUSED" : "play"} t=${(el.currentTime || 0).toFixed(1)} err=${el.error ? el.error.code : "-"} ${(el.currentSrc || el.src || "(no src)").slice(0, 46)}` : "absent";
|
||||
setInterval(() => {
|
||||
const cfg = window.HEF_CONFIG || {};
|
||||
box.textContent =
|
||||
`cfg: static=${cfg.static} base=${cfg.mediaBase}\n` +
|
||||
`webgl=${webgl} visual=${(document.getElementById("visual") || {}).checked}\n` +
|
||||
`loop : ${m(document.getElementById("vid-loop"))}\n` +
|
||||
`morph: ${m(document.getElementById("vid"))}\n` +
|
||||
`aud : ${m(document.getElementById("aud"))}\n` +
|
||||
`errs : ${errs.slice(-3).join(" | ") || "none"}`;
|
||||
}, 400);
|
||||
})();
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HEF — Label Author Mode</title>
|
||||
<title>Human/Machine Strata — Label Author Mode</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="/author.css" />
|
||||
</head>
|
||||
<body class="author">
|
||||
<header><h1>HEF — Label Author Mode</h1>
|
||||
<header><h1>Human/Machine Strata — Label Author Mode</h1>
|
||||
<p class="hint">Scrub a pool clip · drag a box on a subject · run the tracker · author the tiers · save to the manifest. Geometry is propagated; <strong>semantics are hand-authored</strong> (content-pipeline §11.5). <a href="/">← back to preview</a></p>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Runtime config. This dev default serves media from the local FastAPI /media
|
||||
// mount and uses the live API. The static build (tools/build_static.py) OVERWRITES
|
||||
// this file in dist/ with { mediaBase: "https://static.benstull.art/", static: true }.
|
||||
window.HEF_CONFIG = { mediaBase: "/media/", static: false };
|
||||
@@ -0,0 +1,70 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Credits — Human/Machine Strata</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="credits-page">
|
||||
<main class="credits-wrap">
|
||||
<header>
|
||||
<h1>Credits & licenses</h1>
|
||||
<p><a href="index.html" class="back-link">← Back to the experience</a></p>
|
||||
</header>
|
||||
|
||||
<!-- Option D1: the altered work's own license + the "modified" statement that
|
||||
CC BY / CC BY-SA attribution requires. Heading deliberately NOT "About" — the
|
||||
separate About page covers the project's intent. -->
|
||||
<section class="declaration">
|
||||
<h2>License & reuse</h2>
|
||||
<p>
|
||||
<em>Human/Machine Strata</em> alters its source footage in real time
|
||||
(overlays, an emotion/affect channel, and a WebGL “dream” shader), so every
|
||||
frame you see is a <strong>derivative</strong> of the original clip.
|
||||
</p>
|
||||
<p>
|
||||
This altered work is offered under
|
||||
<a href="https://creativecommons.org/licenses/by-sa/4.0/" rel="license">Creative
|
||||
Commons Attribution-ShareAlike 4.0 (CC BY-SA 4.0)</a>. The original sources it
|
||||
builds on are credited below. This is a non-commercial art installation.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Footage</h2>
|
||||
<p class="note">
|
||||
Original video sources and their licenses. Public-domain (US-government) and
|
||||
CC0 clips need no attribution; the names below for CC BY / CC BY-SA clips are
|
||||
required credit. All footage is altered as described above.
|
||||
</p>
|
||||
<div id="video-credits" class="credits-list">
|
||||
<p class="loading">Loading credits…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Audio is all public-domain / CC0, so attribution is courtesy, not required;
|
||||
it isn't tracked per-clip in the API, so it's acknowledged here as a block. -->
|
||||
<section>
|
||||
<h2>Sound</h2>
|
||||
<p class="note">
|
||||
Per-altitude soundtracks are public domain or CC0 — credit is courtesy. The
|
||||
<em>Music</em> option is Creative Commons <strong>BY</strong> — attribution required, given below.
|
||||
</p>
|
||||
<ul class="audio-credits">
|
||||
<li><strong>NASA / Chandra X-ray Observatory (CXC/SAO)</strong> — cosmos sonification (public domain).</li>
|
||||
<li><strong>NOAA</strong> SanctSound & PMEL hydrophone recordings — reef/abyss biological layers (public domain).</li>
|
||||
<li><strong>freesound.org</strong> contributors — Sonicfreak (station room-tone) and DCSFX (underwater bed) (CC0).</li>
|
||||
<li><strong>BigSoundBank</strong> — sea waves & gull calls (CC0).</li>
|
||||
<li><strong>Kevin MacLeod</strong> (<a href="https://incompetech.com" rel="noopener">incompetech.com</a>) — the
|
||||
<em>Music</em> option: “Music for Manatees”, “Dewdrop Fantasy”, “Eastminster”, “Overheat”, and
|
||||
“Concentration”. Licensed under
|
||||
<a href="https://creativecommons.org/licenses/by/4.0/" rel="license">Creative Commons: By Attribution 4.0</a>.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="config.js"></script>
|
||||
<script src="credits.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
// Credits/colophon data + render — no framework. UMD so the browser gets a
|
||||
// `HEFCredits` global and `node --test` can require() the pure functions.
|
||||
//
|
||||
// The legally load-bearing attribution is per VIDEO clip (CC BY / CC BY-SA need
|
||||
// the author + license shown to the viewer, and the footage is altered → a
|
||||
// derivative). That data already ships in clips.json (baked from /api/clips):
|
||||
// each clip carries free-text `license` + `source`. This module turns that into
|
||||
// the credits list the public colophon (credits.html) renders. Audio is all
|
||||
// PD/CC0 and credited as a static courtesy block in the page itself.
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFCredits = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// One attribution entry per clip, sorted by id (deterministic). A clip is
|
||||
// NEVER silently dropped — even one with an empty license appears, so a
|
||||
// missing credit is visible rather than hidden. License/source are the
|
||||
// free-text strings authored in the manifest.
|
||||
function creditEntries(clips) {
|
||||
return (clips || [])
|
||||
.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title || c.id,
|
||||
license: c.license || "",
|
||||
source: c.source || "",
|
||||
}))
|
||||
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
||||
}
|
||||
|
||||
function creditHtml(e) {
|
||||
const lic = e.license
|
||||
? `<span class="lic">${escapeHtml(e.license)}</span>`
|
||||
: `<span class="lic missing">license not recorded</span>`;
|
||||
const src = e.source ? `<div class="src">${escapeHtml(e.source)}</div>` : "";
|
||||
return (
|
||||
`<div class="credit">` +
|
||||
`<div class="title">${escapeHtml(e.title)}</div>` +
|
||||
lic + src +
|
||||
`</div>`
|
||||
);
|
||||
}
|
||||
|
||||
function creditsListHtml(clips) {
|
||||
return creditEntries(clips).map(creditHtml).join("");
|
||||
}
|
||||
|
||||
// Browser bootstrap: fetch the same clips the experience uses (baked
|
||||
// clips.json in the static build, the live API otherwise — the config.js
|
||||
// flag, loaded before this script, decides) and fill the container.
|
||||
function init(doc, cfg) {
|
||||
const fetchUrl = cfg && cfg.static ? "clips.json" : "/api/clips";
|
||||
const box = doc.getElementById("video-credits");
|
||||
if (!box) return;
|
||||
return fetch(fetchUrl)
|
||||
.then((r) => r.json())
|
||||
.then((d) => { box.innerHTML = creditsListHtml(d.clips || []); })
|
||||
.catch((err) => { box.innerHTML = `<p class="err">Could not load credits: ${escapeHtml(err.message)}</p>`; });
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
init(document, (typeof window !== "undefined" && window.HEF_CONFIG) || {});
|
||||
});
|
||||
}
|
||||
|
||||
return { escapeHtml, creditEntries, creditHtml, creditsListHtml, init };
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// Pure photosensitivity helper. WCAG 2.3.1: no more than 3 general flashes
|
||||
// (luminance transitions) per second. Given a transition COUNT, returns the
|
||||
// minimum total duration that keeps the rate at or below 3/sec, and a clamp
|
||||
// that only ever slows a transition down. UMD: browser `HEFFlash` + require().
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFFlash = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
const MAX_PER_SEC = 3;
|
||||
function minSafeDurationMs(count) {
|
||||
const n = Math.max(0, Number(count) || 0);
|
||||
return (n / MAX_PER_SEC) * 1000;
|
||||
}
|
||||
function clampDurationMs(requestedMs, count) {
|
||||
return Math.max(Number(requestedMs) || 0, minSafeDurationMs(count));
|
||||
}
|
||||
return { MAX_PER_SEC, minSafeDurationMs, clampDurationMs };
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const F = require("./flash.js");
|
||||
|
||||
test("minSafeDurationMs: N transitions need N/3 seconds", () => {
|
||||
assert.strictEqual(F.minSafeDurationMs(3), 1000); // 3 flashes in >=1s
|
||||
assert.strictEqual(F.minSafeDurationMs(6), 2000);
|
||||
assert.strictEqual(F.minSafeDurationMs(0), 0);
|
||||
assert.strictEqual(F.minSafeDurationMs(1), 1000 / 3);
|
||||
});
|
||||
|
||||
test("clampDurationMs: stretches only when too fast", () => {
|
||||
assert.strictEqual(F.clampDurationMs(2000, 3), 2000); // already safe
|
||||
assert.strictEqual(F.clampDurationMs(200, 3), 1000); // too fast -> clamped up
|
||||
assert.strictEqual(F.clampDurationMs(500, 1), 500); // single transition, slow enough
|
||||
});
|
||||
@@ -17,18 +17,29 @@
|
||||
|
||||
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
|
||||
const UI_STRINGS = {
|
||||
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
|
||||
"app.title": { en: "Human/Machine Strata", es: "Human/Machine Strata", fr: "Human/Machine Strata", ja: "Human/Machine Strata" },
|
||||
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", ja: "宇宙を読み込み中" },
|
||||
"welcome.launch": { en: "Launch Simulator", es: "Iniciar simulador", fr: "Lancer le simulateur", ja: "シミュレーターを起動" },
|
||||
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
|
||||
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
|
||||
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
|
||||
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
|
||||
"output.audio.none": { en: "None", es: "Ninguno", fr: "Aucun", ja: "なし" },
|
||||
"output.audio.soundtrack": { en: "Soundtrack", es: "Banda sonora", fr: "Bande-son", ja: "サウンドトラック" },
|
||||
"output.audio.music": { en: "Music", es: "Música", fr: "Musique", ja: "音楽" },
|
||||
"output.volume": { en: "Volume", es: "Volumen", fr: "Volume", ja: "音量" },
|
||||
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
|
||||
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer d’altitude — 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 (0–4)", es: "Mandos de experiencia (0–4)", fr: "Boutons d’expérience (0–4)", ja: "体験つまみ(0〜4)" },
|
||||
"altitude.autoadvance": { en: "Auto-advance", es: "Avance automático", fr: "Avance automatique", ja: "自動で移動" },
|
||||
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons d’expérience", ja: "体験つまみ" },
|
||||
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
|
||||
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
|
||||
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
|
||||
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
|
||||
"speed.label": { en: "Video speed", es: "Velocidad de vídeo", fr: "Vitesse vidéo", ja: "映像速度" },
|
||||
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
|
||||
"warn.title": { en: "Heads up — motion & flashing", es: "Atención — movimiento y destellos", fr: "Attention — mouvement et flashs", ja: "ご注意 — 動きと点滅" },
|
||||
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch — altitude changes will cut instantly instead of animating.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa «Reducir movimiento» abajo antes de iniciar — los cambios de altitud serán cortes instantáneos en vez de animarse.", fr: "Cette expérience contient des mouvements continus, des flashs et des images changeantes. Si vous êtes sensible au mouvement ou aux lumières clignotantes, activez « Réduire les animations » ci-dessous avant de lancer — les changements d’altitude seront des coupes instantanées au lieu de s’animer.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください — 高度の変化はアニメーションせず瞬時に切り替わります。" },
|
||||
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
|
||||
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
|
||||
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
|
||||
"scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" },
|
||||
|
||||
+106
-32
@@ -3,60 +3,130 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>HEF — Alteration Simulator</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<title>Human/Machine Strata</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">
|
||||
<div class="loading-inner">
|
||||
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
|
||||
<div class="loading-bar"><div id="loading-fill"></div></div>
|
||||
<!-- Welcome screen: the universal entry gate. State A (.welcoming) shows the
|
||||
heads-up + controls + Launch button with the loader at the bottom; State B
|
||||
(.loading, after Launch while media is still loading) hides the messaging +
|
||||
button, keeps the controls, and centers the loader. Removed on entry. -->
|
||||
<div id="welcome" class="welcoming" role="dialog" aria-modal="true" aria-labelledby="welcome-title">
|
||||
<div class="welcome-inner">
|
||||
<div class="welcome-message">
|
||||
<h2 id="welcome-title" data-i18n="warn.title">Heads up — motion & flashing</h2>
|
||||
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch — altitude changes will cut instantly instead of animating.</p>
|
||||
</div>
|
||||
<div class="welcome-controls">
|
||||
<label class="lang-pick">🌐
|
||||
<select id="welcome-lang" aria-label="Language"></select>
|
||||
</label>
|
||||
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
|
||||
<input type="range" id="welcome-play-speed" min="0" max="2" step="any" value="1"
|
||||
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
|
||||
<span id="welcome-play-speed-val">1.00×</span>
|
||||
</label>
|
||||
<label class="audio-source"><span data-i18n="output.audio">Audio</span>
|
||||
<select id="welcome-audio-source" aria-label="Audio source">
|
||||
<option value="none" data-i18n="output.audio.none">None</option>
|
||||
<option value="soundtrack" data-i18n="output.audio.soundtrack" selected>Soundtrack</option>
|
||||
<option value="music" data-i18n="output.audio.music">Music</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.volume">Volume</span>
|
||||
<input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" />
|
||||
<span id="welcome-audio-val">2</span>/10
|
||||
</label>
|
||||
<label class="dev-switch" for="welcome-reduce-motion">
|
||||
<input type="checkbox" id="welcome-reduce-motion" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="welcome-launch" class="run-sim" data-i18n="welcome.launch">Launch Simulator</button>
|
||||
<div class="welcome-loading">
|
||||
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
|
||||
<div class="loading-bar"><div id="loading-fill"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<header><h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1></header>
|
||||
<header>
|
||||
<h1 data-i18n="app.title">Human/Machine Strata</h1>
|
||||
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
|
||||
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits & licenses</a>
|
||||
</header>
|
||||
<main>
|
||||
<section class="stage" id="stage">
|
||||
<div class="screen">
|
||||
<video id="vid" loop muted playsinline></video>
|
||||
<video id="vid-loop" loop muted playsinline></video>
|
||||
<audio id="aud" loop preload="auto"></audio>
|
||||
<audio id="aud-b" loop preload="auto"></audio>
|
||||
<canvas id="paint"></canvas>
|
||||
<div id="tint"></div>
|
||||
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none"></svg>
|
||||
<video id="vid" loop muted playsinline crossorigin="anonymous"></video>
|
||||
<video id="vid-loop" loop muted playsinline crossorigin="anonymous"></video>
|
||||
<audio id="aud" loop preload="auto" crossorigin="anonymous"></audio>
|
||||
<audio id="aud-b" loop preload="auto" crossorigin="anonymous"></audio>
|
||||
<canvas id="paint" aria-hidden="true"></canvas>
|
||||
<div id="tint" aria-hidden="true"></div>
|
||||
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
|
||||
<div id="black" class="black hidden"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div id="sr-status" class="visually-hidden" role="status" aria-live="polite"></div>
|
||||
<fieldset>
|
||||
<legend data-i18n="altitude.legend">Altitude</legend>
|
||||
<div class="dial-wrap">
|
||||
<svg id="dial" viewBox="0 0 100 100" role="slider" tabindex="0"
|
||||
aria-label="Altitude — turn or use arrow keys to change scale"
|
||||
aria-valuemin="0" aria-valuenow="0" aria-valuetext="cosmos"></svg>
|
||||
</div>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<label class="dev-switch" for="auto-advance">
|
||||
<input type="checkbox" id="auto-advance" checked />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="altitude.autoadvance">Auto-advance</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend data-i18n="output.legend">Output</legend>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
</label>
|
||||
<label class="dev-switch" for="visual">
|
||||
<input type="checkbox" id="visual" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="output.video">Video</span>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
|
||||
<label class="audio-source"><span data-i18n="output.audio">Audio</span>
|
||||
<select id="audio-source" aria-label="Audio source">
|
||||
<option value="none" data-i18n="output.audio.none">None</option>
|
||||
<option value="soundtrack" data-i18n="output.audio.soundtrack" selected>Soundtrack</option>
|
||||
<option value="music" data-i18n="output.audio.music">Music</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.volume">Volume</span>
|
||||
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
|
||||
<span id="audio-level-val">0</span>/10
|
||||
</label>
|
||||
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
|
||||
<input type="range" id="play-speed" min="0" max="2" step="any" value="1"
|
||||
list="play-speed-ticks" aria-describedby="play-speed-val" />
|
||||
<span id="play-speed-val">1.00×</span>
|
||||
</label>
|
||||
<datalist id="play-speed-ticks">
|
||||
<option value="0"></option><option value="0.25"></option><option value="0.5"></option>
|
||||
<option value="0.75"></option><option value="1"></option><option value="1.25"></option>
|
||||
<option value="1.5"></option><option value="1.75"></option><option value="2"></option>
|
||||
</datalist>
|
||||
<label class="dev-switch" for="reduce-motion">
|
||||
<input type="checkbox" id="reduce-motion" />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
|
||||
</label>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="lang-select" aria-label="Language"></select>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend data-i18n="altitude.legend">Altitude</legend>
|
||||
<div class="dial-wrap">
|
||||
<svg id="dial" viewBox="0 0 100 100" aria-label="Altitude knob (turn to change scale)"></svg>
|
||||
</div>
|
||||
<span id="scale-name" class="scale-name">—</span>
|
||||
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend data-i18n="knobs.legend">Experience knobs (0–4)</legend>
|
||||
<legend data-i18n="knobs.legend">Experience knobs</legend>
|
||||
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
|
||||
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
|
||||
@@ -107,8 +177,12 @@
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/scrub.js"></script>
|
||||
<script src="/i18n.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
<script src="config.js"></script>
|
||||
<script src="scrub.js"></script>
|
||||
<script src="alteration.js"></script>
|
||||
<script src="preload.js"></script>
|
||||
<script src="flash.js"></script>
|
||||
<script src="i18n.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Pure preload planning — no DOM. The boot caches phase1Files() before unlocking the
|
||||
// universe; the random clip pick is then restricted to eligible clips (fully loaded
|
||||
// base + connecting morphs), so a transition never stalls on un-downloaded media and
|
||||
// the pool grows safely as the background preload finishes. UMD so the browser gets
|
||||
// `HEFPreload` and `node --test` can require() it.
|
||||
//
|
||||
// deps shape: { baseOf(clipId)->file, morphFile(fromId,toId)->file|null, isCached(file)->bool }
|
||||
(function (root, factory) {
|
||||
const api = factory();
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
else root.HEFPreload = api;
|
||||
})(typeof self !== "undefined" ? self : this, function () {
|
||||
"use strict";
|
||||
|
||||
// The minimal set to load before the simulator can run: the FIRST pool member of
|
||||
// every altitude + the morphs connecting adjacent first-members (both directions,
|
||||
// cyclic ring). One clip per altitude + corresponding morphs.
|
||||
function phase1Files(scales, deps) {
|
||||
const n = scales.length;
|
||||
const chosen = scales.map((s) => (s.pool && s.pool.length ? s.pool[0].clip_id : s.clip_id));
|
||||
const files = new Set();
|
||||
for (const c of chosen) {
|
||||
const bf = deps.baseOf(c);
|
||||
if (bf) files.add(bf);
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = chosen[i], b = chosen[(i + 1) % n];
|
||||
for (const f of [deps.morphFile(a, b), deps.morphFile(b, a)]) if (f) files.add(f);
|
||||
}
|
||||
return [...files];
|
||||
}
|
||||
|
||||
// Pool members eligible as a DESTINATION from `fromId`: base cached AND the morph
|
||||
// cached BOTH ways (so the forward transition and a turn-back are ready).
|
||||
function eligibleDestinations(poolIds, fromId, deps) {
|
||||
return poolIds.filter((c) =>
|
||||
deps.isCached(deps.baseOf(c)) &&
|
||||
deps.isCached(deps.morphFile(fromId, c)) &&
|
||||
deps.isCached(deps.morphFile(c, fromId)));
|
||||
}
|
||||
|
||||
// Pool members eligible to SHOW at their own altitude (no transition): base cached.
|
||||
function eligibleMembers(poolIds, deps) {
|
||||
return poolIds.filter((c) => deps.isCached(deps.baseOf(c)));
|
||||
}
|
||||
|
||||
function pick(list, rnd) {
|
||||
return list.length ? list[Math.floor(rnd() * list.length)] : null;
|
||||
}
|
||||
|
||||
return { phase1Files, eligibleDestinations, eligibleMembers, pick };
|
||||
});
|
||||
@@ -29,6 +29,31 @@
|
||||
return { from: 1 - f, to: f };
|
||||
}
|
||||
|
||||
// Pure audio-source routing: given the Audio dropdown source ("none" |
|
||||
// "soundtrack" | "music"), the master volume `level` (0..1), and the altitude
|
||||
// crossfade fraction `frac`, decide what the audio layer should do. app.js's
|
||||
// restAudio/blendAudio consult this for the mode branch; the physical <audio>
|
||||
// element assignment (which element carries which url) stays in app.js.
|
||||
// - silent: play nothing (None, or level 0 — a hard mute in every mode)
|
||||
// - music : ONE continuous track at the master level, altitude-independent
|
||||
// - blend : the per-altitude soundtrack crossfade (lo = frac 0 scale, hi = next)
|
||||
function audioPlan(source, level, frac) {
|
||||
if (!(level > 0) || source === "none") return { mode: "silent" };
|
||||
if (source === "music") return { mode: "music", vol: level };
|
||||
const g = crossfadeGains(frac);
|
||||
return { mode: "blend", lo: g.from * level, hi: g.to * level };
|
||||
}
|
||||
|
||||
// Pick the next track in a rotating playlist: a random index in [0,count) that is
|
||||
// NEVER `current` (so a rotation always changes track), given r in [0,1). Maps r
|
||||
// onto the (count-1) other slots. count<=1 stays on 0. Pure so the shuffle is
|
||||
// node-testable; the caller passes Math.random().
|
||||
function pickNextIndex(current, count, r) {
|
||||
if (count <= 1) return 0;
|
||||
const k = Math.min(count - 2, Math.floor(clamp01(r) * (count - 1)));
|
||||
return k >= current ? k + 1 : k;
|
||||
}
|
||||
|
||||
// The base-loop frame a landing must continue from so the steady loop picks up
|
||||
// the EXACT frame the morph last showed — no jump. A morph spans src@0 (frac 0)
|
||||
// -> dst@loopTailS (frac 1), scrubbed bidirectionally. Descending (dir>0) lands
|
||||
@@ -53,5 +78,5 @@
|
||||
return out;
|
||||
}
|
||||
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
|
||||
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, audioPlan, pickNextIndex, loopLandFrame, integerCrossings };
|
||||
});
|
||||
|
||||
+98
-12
@@ -1,7 +1,32 @@
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.4 system-ui, sans-serif; background: #111; color: #eee; }
|
||||
header { padding: 0.6rem 1rem; background: #000; }
|
||||
header { padding: 0.6rem 1rem; background: #000; display: flex; align-items: baseline;
|
||||
justify-content: space-between; gap: 1rem; }
|
||||
h1 { font-size: 1rem; margin: 0; font-weight: 600; }
|
||||
/* Always-visible link to the credits/colophon — reachable from the work, which is
|
||||
what CC BY / BY-SA attribution "reasonable to the medium" calls for. */
|
||||
.credits-link { color: #9af; text-decoration: none; font-size: 12px; white-space: nowrap; flex: none; }
|
||||
.credits-link:hover { text-decoration: underline; }
|
||||
|
||||
/* --- Credits / colophon page (credits.html) --- */
|
||||
.credits-page { background: #0c0f14; }
|
||||
.credits-wrap { max-width: 760px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }
|
||||
.credits-wrap h1 { font-size: 1.6rem; }
|
||||
.credits-wrap h2 { font-size: 1.05rem; color: #9af; margin: 1.8rem 0 0.5rem; }
|
||||
.credits-wrap a { color: #9af; }
|
||||
.credits-wrap .back-link { font-size: 13px; }
|
||||
.credits-wrap .note { color: #9ab; font-size: 13px; }
|
||||
.declaration { background: #131a24; border: 1px solid #233; border-radius: 6px;
|
||||
padding: 0.8rem 1rem; margin-top: 1rem; }
|
||||
.credits-list { display: grid; gap: 0.7rem; margin-top: 0.8rem; }
|
||||
.credit { background: #131820; border: 1px solid #1f2a3a; border-radius: 5px; padding: 0.6rem 0.8rem; }
|
||||
.credit .title { font-weight: 600; color: #dfe7f2; }
|
||||
.credit .lic { display: inline-block; margin-top: 0.2rem; font-size: 12px; color: #cbd; }
|
||||
.credit .lic.missing { color: #f97; }
|
||||
.credit .src { margin-top: 0.3rem; font-size: 12px; color: #8aa; line-height: 1.5; }
|
||||
.audio-credits { color: #b9c4d2; font-size: 13px; line-height: 1.6; padding-left: 1.2rem; }
|
||||
.credits-list .loading, .credits-list .err { color: #9ab; }
|
||||
.credits-list .err { color: #f97; }
|
||||
main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: flex-start; }
|
||||
/* Stage stays pinned in view while the right pane scrolls on its own. */
|
||||
.stage { flex: 1 1 640px; position: sticky; top: 1rem; }
|
||||
@@ -51,15 +76,33 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
|
||||
.black { position: absolute; inset: 0; background: #000; opacity: 1;
|
||||
z-index: 50; transform: translateZ(0); transition: opacity 200ms ease; }
|
||||
.hidden { display: none; }
|
||||
/* "Launch Simulator" — the single entry button on the welcome screen. Sits in
|
||||
the welcome overlay's flow (centered column), not absolutely positioned. */
|
||||
.run-sim {
|
||||
cursor: pointer; user-select: none;
|
||||
padding: 0.85rem 2rem; border: 0; border-radius: 999px;
|
||||
font: 600 18px/1 system-ui, sans-serif; letter-spacing: 0.03em;
|
||||
color: #04101f; background: linear-gradient(90deg, #4e9cff, #9af);
|
||||
box-shadow: 0 4px 24px rgba(78, 156, 255, 0.45);
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.run-sim:hover { transform: scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
|
||||
.run-sim:active { transform: scale(0.98); }
|
||||
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
|
||||
.dev-switch + .dev-switch { margin-top: 0.45rem; }
|
||||
/* Globe + language select on one row (the select no longer goes full-width here). */
|
||||
.lang-pick { display: flex; align-items: center; gap: 0.4rem; margin: 0.6rem 0 0.2rem; }
|
||||
.lang-pick select { flex: 1; width: auto; }
|
||||
/* Live audio status readout (diagnostic) — turns green when actually playing. */
|
||||
.audio-status { margin-top: 0.5rem; font: 11px/1.4 monospace; color: #9ab; }
|
||||
.audio-status.playing { color: #4e9; }
|
||||
.audio-status.blocked { color: #f97; }
|
||||
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem;
|
||||
.panel { flex: 0 0 300px; display: flex; flex-direction: column; gap: 0.8rem;
|
||||
max-height: calc(100vh - 2rem); overflow-y: auto;
|
||||
position: sticky; top: 1rem; }
|
||||
/* The control panel stays hidden behind the welcome screen and appears only once
|
||||
the welcome overlay is removed on entry. */
|
||||
body:has(#welcome) .panel { display: none; }
|
||||
fieldset { border: 1px solid #333; border-radius: 6px; }
|
||||
legend { color: #9af; padding: 0 0.4rem; }
|
||||
label { display: block; margin: 0.4rem 0; }
|
||||
@@ -73,17 +116,25 @@ input[type=range], select { width: 100%; }
|
||||
.dial-rim { fill: #0d1320; stroke: #243352; stroke-width: 1.2; }
|
||||
.dial-body { fill: #16203200; stroke: #2c3c5c; stroke-width: 1; }
|
||||
.dial-tick { stroke: #3a4d70; stroke-width: 0.8; }
|
||||
.dial-label { fill: #789ac0; font-size: 6px; font-family: ui-monospace, monospace;
|
||||
.dial-label { fill: #b8cfe6; font-size: 6px; font-family: ui-monospace, monospace;
|
||||
letter-spacing: 0.2px; cursor: pointer; }
|
||||
.dial-label:hover { fill: #cde; }
|
||||
.dial-label.active { fill: #9cf; font-weight: 700; }
|
||||
.dial-caption { fill: #4d6184; font-size: 4.4px; letter-spacing: 1.2px;
|
||||
.dial-caption { fill: #8fa6c4; font-size: 4.4px; letter-spacing: 1.2px;
|
||||
font-family: ui-monospace, monospace; }
|
||||
.dial-needle { fill: #9cf; }
|
||||
.dial-needle polygon { filter: drop-shadow(0 0 1px #9cf); }
|
||||
.dial-hub { fill: #2c3c5c; stroke: #9cf; stroke-width: 0.6; }
|
||||
.scale-name { display: block; text-align: center; font-size: 12px; color: #cde; }
|
||||
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #789; }
|
||||
.hint { margin: 0.3rem 0 0; font-size: 11px; color: #9fb3c8; }
|
||||
/* Visible keyboard focus for every interactive element (was only on .dev-switch). */
|
||||
:focus-visible { outline: 2px solid #9af; outline-offset: 2px; }
|
||||
#dial:focus-visible { outline-offset: 4px; border-radius: 50%; }
|
||||
/* Screen-reader-only text (announcements, labels): present in the a11y tree, off-screen. */
|
||||
.visually-hidden {
|
||||
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
|
||||
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
|
||||
}
|
||||
|
||||
/* --- Dev Mode --- a single switch; all dev controls/data live in #dev-panel below it. */
|
||||
.dev-switch { display: flex; align-items: center; gap: 0.5rem; cursor: pointer;
|
||||
@@ -100,8 +151,15 @@ input[type=range], select { width: 100%; }
|
||||
/* Audio level dial (0–10): label · slider · value on one row, matching the switch look. */
|
||||
.audio-level { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.45rem;
|
||||
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.audio-level input[type=range] { flex: 1; width: auto; }
|
||||
/* Keep the control label on one line ("Video speed" was wrapping); let the range
|
||||
shrink (min-width:0) instead of pushing the label to a second row. */
|
||||
.audio-level > span:first-child { white-space: nowrap; }
|
||||
.audio-level input[type=range] { flex: 1; width: auto; min-width: 0; }
|
||||
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
|
||||
/* Playback speed (−2×…2×) reuses the audio-level row look; wider value field for the sign + decimals. */
|
||||
#play-speed-val, #welcome-play-speed-val { font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; }
|
||||
.play-speed input[type=range]:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.play-speed:has(input:disabled) { opacity: 0.6; }
|
||||
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
|
||||
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
|
||||
win, so the panel truly collapses when Dev Mode is off. */
|
||||
@@ -128,17 +186,45 @@ input[type=range], select { width: 100%; }
|
||||
.dev-anno .anno-meta { color: #678; }
|
||||
.dev-anno .anno-empty { color: #567; font-style: italic; }
|
||||
|
||||
/* "Loading Universe…" splash — shown until all media is preloaded and the
|
||||
experience is ready to run smoothly, then faded out. */
|
||||
#loading {
|
||||
/* Welcome screen — the universal entry gate (fixed, above everything). Holds the
|
||||
heads-up notice, the four output controls, the Launch button, and the
|
||||
"Loading Universe" progress, which fills as media preloads in the background. */
|
||||
#welcome {
|
||||
position: fixed; inset: 0; z-index: 10000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(ellipse at center, #0a1230 0%, #02030a 70%);
|
||||
color: #cfe3ff; user-select: none;
|
||||
overflow-y: auto;
|
||||
transition: opacity 0.6s ease;
|
||||
}
|
||||
#loading.done { opacity: 0; pointer-events: none; }
|
||||
.loading-inner { display: flex; flex-direction: column; align-items: center; gap: 1.1rem; }
|
||||
#welcome.done { opacity: 0; pointer-events: none; }
|
||||
.welcome-inner {
|
||||
min-height: 100%; box-sizing: border-box;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 1.4rem; padding: 2.5rem 1.25rem 5.5rem;
|
||||
}
|
||||
.welcome-message { max-width: 32rem; text-align: center; }
|
||||
.welcome-message h2 { font-size: 1.25rem; margin: 0 0 0.6rem; color: #dfeaff; }
|
||||
.welcome-message p { font-size: 0.98rem; line-height: 1.55; margin: 0; color: #c3d2e8; }
|
||||
/* The four output controls, boxed for legibility over the dark overlay. Spacing
|
||||
comes from the column gap, so reset the controls' own in-panel margins. */
|
||||
.welcome-controls {
|
||||
display: flex; flex-direction: column; gap: 0.6rem;
|
||||
width: 100%; max-width: 18rem;
|
||||
padding: 1rem 1.1rem; border: 1px solid #2a3550; border-radius: 8px;
|
||||
background: rgba(10, 18, 48, 0.5);
|
||||
}
|
||||
.welcome-controls .dev-switch,
|
||||
.welcome-controls .audio-level,
|
||||
.welcome-controls .lang-pick { margin: 0; }
|
||||
/* "Loading Universe" — pinned to the bottom in State A (.welcoming); recentered in
|
||||
the column flow in State B (.loading), once messaging + button are hidden. */
|
||||
.welcome-loading {
|
||||
position: absolute; left: 0; right: 0; bottom: 2rem;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 1rem;
|
||||
}
|
||||
#welcome.loading .welcome-message,
|
||||
#welcome.loading #welcome-launch { display: none; }
|
||||
#welcome.loading .welcome-loading { position: static; }
|
||||
.loading-title { font: 600 30px/1.2 system-ui, sans-serif; letter-spacing: 0.04em; }
|
||||
.loading-dots::after {
|
||||
content: ""; animation: loading-dots 1.4s steps(4, end) infinite;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const A = require("../static/alteration.js");
|
||||
|
||||
const c = (o) => ({ visual: "on", audio: "off", left: 0, right: 0, dark: 0, light: 0, ...o });
|
||||
|
||||
test("identity at zero knobs", () => {
|
||||
const p = A.plan(c({}));
|
||||
assert.equal(p.grade.tone, 0);
|
||||
assert.equal(p.overlay.level, 0);
|
||||
assert.equal(p.dream.strength, 0);
|
||||
assert.equal(p.is_identity, true);
|
||||
});
|
||||
|
||||
test("mood tone is signed (light - dark)/KNOB_MAX, clamped", () => {
|
||||
assert.equal(A.plan(c({ light: 4 })).grade.tone, 1); // full light
|
||||
assert.equal(A.plan(c({ dark: 4 })).grade.tone, -1); // full dark
|
||||
assert.equal(A.plan(c({ light: 2 })).grade.tone, 0.5);
|
||||
});
|
||||
|
||||
test("left drives overlay level + intensity; right drives dream + affect", () => {
|
||||
const p = A.plan(c({ left: 2, right: 4 }));
|
||||
assert.equal(p.overlay.level, 2);
|
||||
assert.equal(p.overlay.intensity, 0.5);
|
||||
assert.equal(p.dream.strength, 4);
|
||||
assert.equal(p.dream.intensity, 1);
|
||||
assert.equal(p.affect.strength, 4);
|
||||
assert.equal(p.affect.intensity, 1);
|
||||
assert.equal(p.is_identity, false);
|
||||
});
|
||||
|
||||
test("renderAudio: off → null, no coupling", () => {
|
||||
assert.deepEqual(A.renderAudio("off", "cosmos.mp3", "https://x/"),
|
||||
{ source: "off", url: null, altitude_coupled: false });
|
||||
});
|
||||
|
||||
test("renderAudio: soundtrack → mediaBase audio url, coupled", () => {
|
||||
assert.deepEqual(A.renderAudio("soundtrack", "cosmos.mp3", "https://x/"),
|
||||
{ source: "soundtrack", url: "https://x/audio/cosmos.mp3", altitude_coupled: true });
|
||||
// no scale audio → null url but still coupled
|
||||
assert.deepEqual(A.renderAudio("soundtrack", "", "https://x/"),
|
||||
{ source: "soundtrack", url: null, altitude_coupled: true });
|
||||
});
|
||||
|
||||
test("alteration() composes plan + render with video.shown from visual", () => {
|
||||
const r = A.alteration(c({ visual: "off", audio: "soundtrack", left: 1 }), "reef.mp3", "https://x/");
|
||||
assert.equal(r.render.video.shown, false);
|
||||
assert.equal(r.render.audio.url, "https://x/audio/reef.mp3");
|
||||
assert.equal(r.plan.overlay.level, 1);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const S = require("../static/scrub.js");
|
||||
|
||||
// audioPlan decides, purely, what the audio layer should do for a given source
|
||||
// mode + master level + crossfade fraction. app.js's restAudio/blendAudio consult
|
||||
// it for the mode branch; the physical <audio> element assignment stays in app.js.
|
||||
|
||||
test("audioPlan: None source is silent regardless of level", () => {
|
||||
assert.deepEqual(S.audioPlan("none", 1, 0.5), { mode: "silent" });
|
||||
});
|
||||
|
||||
test("audioPlan: level 0 is silent even for soundtrack/music", () => {
|
||||
assert.deepEqual(S.audioPlan("soundtrack", 0, 0.5), { mode: "silent" });
|
||||
assert.deepEqual(S.audioPlan("music", 0, 0), { mode: "silent" });
|
||||
});
|
||||
|
||||
test("audioPlan: Music plays a single track at the master level, no blend", () => {
|
||||
assert.deepEqual(S.audioPlan("music", 0.8, 0.5), { mode: "music", vol: 0.8 });
|
||||
});
|
||||
|
||||
test("audioPlan: Music ignores altitude fraction (constant across the dial)", () => {
|
||||
const a = S.audioPlan("music", 0.6, 0);
|
||||
const b = S.audioPlan("music", 0.6, 0.9);
|
||||
assert.deepEqual(a, b);
|
||||
});
|
||||
|
||||
test("pickNextIndex: never returns the current track; covers the rest", () => {
|
||||
// r in [0,1) maps onto the (count-1) other indices, skipping `current`.
|
||||
assert.equal(S.pickNextIndex(0, 5, 0), 1);
|
||||
assert.equal(S.pickNextIndex(0, 5, 0.999), 4);
|
||||
assert.equal(S.pickNextIndex(2, 5, 0), 0);
|
||||
assert.equal(S.pickNextIndex(2, 5, 0.5), 3);
|
||||
assert.equal(S.pickNextIndex(4, 5, 0.999), 3);
|
||||
// sweep: for each current, every r lands somewhere valid and never on current
|
||||
for (let cur = 0; cur < 5; cur++) {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const n = S.pickNextIndex(cur, 5, i / 20);
|
||||
assert.ok(n >= 0 && n < 5 && n !== cur, `cur=${cur} r=${i / 20} -> ${n}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("pickNextIndex: single-track playlist stays on 0", () => {
|
||||
assert.equal(S.pickNextIndex(0, 1, 0.7), 0);
|
||||
});
|
||||
|
||||
test("audioPlan: Soundtrack blends lo/hi by fraction, scaled by level", () => {
|
||||
assert.deepEqual(S.audioPlan("soundtrack", 1, 0), { mode: "blend", lo: 1, hi: 0 });
|
||||
assert.deepEqual(S.audioPlan("soundtrack", 1, 1), { mode: "blend", lo: 0, hi: 1 });
|
||||
const mid = S.audioPlan("soundtrack", 0.5, 0.5);
|
||||
assert.equal(mid.mode, "blend");
|
||||
assert.ok(Math.abs(mid.lo - 0.25) < 1e-9);
|
||||
assert.ok(Math.abs(mid.hi - 0.25) < 1e-9);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const C = require("../static/credits.js");
|
||||
|
||||
const CLIPS = [
|
||||
{ id: "reef_x", title: "Reef X", license: "CC-BY-SA 4.0 — credit Joe Mabel", source: "Wikimedia <reef>" },
|
||||
{ id: "cosmos", title: "Cosmic Cliffs", license: "CC-BY-class — credit NASA, ESA, CSA, STScI", source: "NASA SVS 31348" },
|
||||
{ id: "pd_only", title: "PD clip", license: "public-domain (US Gov, 17 U.S.C. §105)", source: "NASA" },
|
||||
];
|
||||
|
||||
test("creditEntries returns one entry per clip, preserving license/source", () => {
|
||||
const out = C.creditEntries(CLIPS);
|
||||
assert.equal(out.length, CLIPS.length, "no clip is silently dropped from the credits");
|
||||
const reef = out.find((e) => e.id === "reef_x");
|
||||
assert.equal(reef.license, "CC-BY-SA 4.0 — credit Joe Mabel");
|
||||
assert.equal(reef.source, "Wikimedia <reef>");
|
||||
assert.equal(reef.title, "Reef X");
|
||||
});
|
||||
|
||||
test("creditEntries is sorted by id for deterministic output", () => {
|
||||
const ids = C.creditEntries(CLIPS).map((e) => e.id);
|
||||
assert.deepEqual(ids, ["cosmos", "pd_only", "reef_x"]);
|
||||
});
|
||||
|
||||
test("creditEntries falls back title->id and tolerates missing fields", () => {
|
||||
const out = C.creditEntries([{ id: "bare" }]);
|
||||
assert.deepEqual(out, [{ id: "bare", title: "bare", license: "", source: "" }]);
|
||||
});
|
||||
|
||||
test("creditEntries handles null/empty input", () => {
|
||||
assert.deepEqual(C.creditEntries(null), []);
|
||||
assert.deepEqual(C.creditEntries([]), []);
|
||||
});
|
||||
|
||||
test("creditsListHtml renders every clip's attribution and escapes HTML", () => {
|
||||
const html = C.creditsListHtml(CLIPS);
|
||||
assert.match(html, /Cosmic Cliffs/);
|
||||
assert.match(html, /Joe Mabel/);
|
||||
// free-text source "Wikimedia <reef>" must be escaped, not injected as a tag
|
||||
assert.match(html, /Wikimedia <reef>/);
|
||||
assert.doesNotMatch(html, /<reef>/);
|
||||
// one block per clip
|
||||
const blocks = html.match(/class="credit"/g) || [];
|
||||
assert.equal(blocks.length, CLIPS.length);
|
||||
});
|
||||
|
||||
test("credits.html wires config.js + credits.js, the attributions container, and a back link", () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
|
||||
assert.match(html, /<script src="config\.js">/); // static/live media-base + flag
|
||||
assert.match(html, /<script src="credits\.js">/);
|
||||
assert.match(html, /id="video-credits"/); // JS fills this
|
||||
assert.match(html, /href="index\.html"/); // back into the experience
|
||||
});
|
||||
|
||||
test("credits.html carries the CC BY-SA derivative declaration (Option D1)", () => {
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/credits.html"), "utf8");
|
||||
assert.match(html, /CC BY-SA 4\.0/); // the altered work's own license
|
||||
assert.match(html, /altered/i); // states the footage is modified
|
||||
// The section is NOT titled "About this work" — that name belongs to the separate
|
||||
// About page; here the declaration lives under "License & reuse".
|
||||
assert.doesNotMatch(html, /About this work/i);
|
||||
assert.match(html, /License & reuse/);
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const I = require("../static/i18n.js");
|
||||
const html = fs.readFileSync(path.join(__dirname, "../static/index.html"), "utf8");
|
||||
|
||||
test("index.html includes the i18n script and a language select", () => {
|
||||
assert.match(html, /<script src="\/i18n\.js">/);
|
||||
assert.match(html, /<script src="i18n\.js">/); // relative: works at dev-root and under the deploy path prefix
|
||||
assert.match(html, /id="lang-select"/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const P = require("../static/preload.js");
|
||||
|
||||
// Tiny fixture: 3-altitude cyclic ring, 2 pool members each. base_file = "<id>.b";
|
||||
// morph file = "<from>>-<to>" when it exists.
|
||||
const scales = [
|
||||
{ id: "a", pool: [{ clip_id: "a1" }, { clip_id: "a2" }] },
|
||||
{ id: "b", pool: [{ clip_id: "b1" }, { clip_id: "b2" }] },
|
||||
{ id: "c", pool: [{ clip_id: "c1" }, { clip_id: "c2" }] },
|
||||
];
|
||||
const baseOf = (id) => id + ".b";
|
||||
const morphFile = (a, b) => `${a}>-${b}`; // pretend every pair has a morph
|
||||
const cached = new Set();
|
||||
const deps = () => ({ baseOf, morphFile, isCached: (f) => cached.has(f) });
|
||||
|
||||
test("phase1Files = first member of each altitude + connecting morphs (both dirs, cyclic)", () => {
|
||||
const files = P.phase1Files(scales, deps());
|
||||
// bases: a1.b b1.b c1.b
|
||||
for (const b of ["a1.b", "b1.b", "c1.b"]) assert.ok(files.includes(b), `missing base ${b}`);
|
||||
assert.ok(!files.includes("a2.b"), "second members must NOT be in phase 1");
|
||||
// connecting morphs between adjacent first-members, both directions, cyclic (a-b, b-c, c-a)
|
||||
for (const m of ["a1>-b1", "b1>-a1", "b1>-c1", "c1>-b1", "c1>-a1", "a1>-c1"])
|
||||
assert.ok(files.includes(m), `missing morph ${m}`);
|
||||
assert.equal(files.length, 3 + 6);
|
||||
});
|
||||
|
||||
test("eligibleDestinations requires base + BOTH morphs cached", () => {
|
||||
cached.clear();
|
||||
// from a1, candidate b1: nothing cached → not eligible
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), []);
|
||||
cached.add("b1.b"); cached.add("a1>-b1"); // base + forward only
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), [], "reverse morph still missing");
|
||||
cached.add("b1>-a1"); // now reverse too
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()), ["b1"]);
|
||||
// b2 fully cached too → both eligible
|
||||
cached.add("b2.b"); cached.add("a1>-b2"); cached.add("b2>-a1");
|
||||
assert.deepEqual(P.eligibleDestinations(["b1", "b2"], "a1", deps()).sort(), ["b1", "b2"]);
|
||||
});
|
||||
|
||||
test("eligibleMembers requires only the base cached", () => {
|
||||
cached.clear();
|
||||
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), []);
|
||||
cached.add("a1.b");
|
||||
assert.deepEqual(P.eligibleMembers(["a1", "a2"], deps()), ["a1"]);
|
||||
});
|
||||
|
||||
test("pick returns null on empty, a member otherwise", () => {
|
||||
assert.equal(P.pick([], Math.random), null);
|
||||
assert.equal(P.pick(["x"], () => 0), "x");
|
||||
assert.equal(P.pick(["x", "y", "z"], () => 0.99), "z");
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
"""The static build reimplements plan_alteration/resolve_audio in JS
|
||||
(simulator/static/alteration.js). This pins the numeric contract so the Python
|
||||
and JS cannot silently diverge — if you change the engine, change both (the
|
||||
mirrored JS cases live in simulator/unit/alteration.test.js)."""
|
||||
from hef.selection import Coordinate
|
||||
from player.alteration import plan_alteration, render_plan_to_dict
|
||||
from player.audio import resolve_audio, resolve_visual
|
||||
|
||||
|
||||
def test_engine_contract_matches_js_expectations():
|
||||
# mirrors the cases asserted in simulator/unit/alteration.test.js
|
||||
p = render_plan_to_dict(plan_alteration(Coordinate(left=2, right=4, dark=0, light=0)))
|
||||
assert p["overlay"] == {"level": 2, "intensity": 0.5}
|
||||
assert p["dream"] == {"strength": 4, "intensity": 1.0}
|
||||
assert p["affect"] == {"strength": 4, "intensity": 1.0}
|
||||
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 4)))["grade"]["tone"] == 1.0
|
||||
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 4, 0)))["grade"]["tone"] == -1.0
|
||||
assert render_plan_to_dict(plan_alteration(Coordinate(0, 0, 0, 0)))["is_identity"] is True
|
||||
assert resolve_visual("off") is False
|
||||
a = resolve_audio("soundtrack", scale_audio="cosmos.mp3")
|
||||
assert a.url == "/media/audio/cosmos.mp3" and a.altitude_coupled is True
|
||||
@@ -0,0 +1,65 @@
|
||||
import json
|
||||
|
||||
from tools.build_static import build_static
|
||||
|
||||
|
||||
def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
|
||||
out = tmp_path / "dist"
|
||||
media = tmp_path / "media"
|
||||
result = build_static(
|
||||
out, media,
|
||||
media_base="https://static.benstull.art/",
|
||||
app_path="/human-machine-strata",
|
||||
)
|
||||
app_dir = out / "human-machine-strata"
|
||||
|
||||
# frontend present under the app path; dev/author surfaces excluded
|
||||
assert (app_dir / "index.html").exists()
|
||||
assert (app_dir / "app.js").exists()
|
||||
assert (app_dir / "scrub.js").exists()
|
||||
assert (app_dir / "alteration.js").exists()
|
||||
assert (app_dir / "config.js").exists()
|
||||
# Credits/colophon ships so CC-BY / CC-BY-SA attribution is reachable from the public site.
|
||||
assert (app_dir / "credits.html").exists()
|
||||
assert (app_dir / "credits.js").exists()
|
||||
assert not (app_dir / "author.html").exists()
|
||||
assert not list(app_dir.glob("review*.html"))
|
||||
|
||||
# apex + no-slash redirect rules, plus the permanent legacy-path redirect
|
||||
redirects = (out / "_redirects").read_text()
|
||||
assert "/ /human-machine-strata/ 308" in redirects
|
||||
assert "/human-machine-strata /human-machine-strata/ 308" in redirects
|
||||
assert "/human-experience-simulator/ /human-machine-strata/ 301" in redirects
|
||||
assert "/human-experience-simulator/* /human-machine-strata/:splat 301" in redirects
|
||||
|
||||
# baked JSON matches the API shape
|
||||
clips = json.loads((app_dir / "clips.json").read_text())
|
||||
assert "clips" in clips and clips["clips"]
|
||||
ring = json.loads((app_dir / "ring.json").read_text())
|
||||
assert "scales" in ring and "transitions" in ring
|
||||
assert all("audio" in s for s in ring["scales"]) # needed by client alteration
|
||||
versions = json.loads((app_dir / "media-versions.json").read_text())
|
||||
assert "versions" in versions
|
||||
|
||||
# config.js points at R2 + static mode
|
||||
cfg = (app_dir / "config.js").read_text()
|
||||
assert "https://static.benstull.art/" in cfg and "static: true" in cfg
|
||||
|
||||
# media tree holds ONLY referenced files — no masters/mezzanines
|
||||
synced = {p.name for p in media.rglob("*.mp4")}
|
||||
assert synced, "expected synced media"
|
||||
assert not any(n in ("master.mp4", "mezzanine.mp4") for n in synced)
|
||||
# every versioned file exists in the media tree
|
||||
for f in versions["versions"]:
|
||||
assert (media / f).exists(), f"missing synced media: {f}"
|
||||
assert result["media_files"] == sum(1 for _ in media.rglob("*") if _.is_file())
|
||||
|
||||
|
||||
def test_build_includes_the_music_playlist(tmp_path):
|
||||
# The Music-option tracks are referenced by app.js (MUSIC_PLAYLIST), not the
|
||||
# manifest — the build must copy them explicitly or Music 404s on the static site.
|
||||
out = tmp_path / "dist"
|
||||
media = tmp_path / "media"
|
||||
build_static(out, media, media_base="https://static.benstull.art/", app_path="/human-machine-strata")
|
||||
for name in ("manatees", "dewdrop", "eastminster", "overheat", "concentration"):
|
||||
assert (media / "audio" / "music" / f"{name}.mp3").exists(), f"music track not synced: {name}"
|
||||
+60
-21
@@ -2,15 +2,16 @@
|
||||
cleanly when Playwright or its browser binary is absent.
|
||||
|
||||
NOTE: headless engines relax BOTH autoplay and GPU compositing, so these assert
|
||||
the WIRING (boot-silent, video-on couples audio, dial→play/volume, video-off
|
||||
the WIRING (boot-silent, launch starts video+audio, dial→play/volume, video-off
|
||||
blanking) — real Safari/iOS autoplay and real-GPU compositing still need a device
|
||||
by-ear/eye check.
|
||||
|
||||
The experience BOOTS SILENT — video off, audio dial at 0 — so nothing plays until a
|
||||
real user gesture. Turning Video ON (a click) couples the audio dial up to 3/10 and
|
||||
starts the soundtrack IN that gesture, sidestepping the browser autoplay block that
|
||||
swallowed an un-gestured auto-start. Audio is a 0–10 range dial (not a checkbox);
|
||||
set it by writing `value` + firing an `input` event.
|
||||
The WELCOME screen is the entry gate: it carries the controls (Video on, Audio 2)
|
||||
and a "Launch Simulator" button. Until Launch, the panel stays black/silent (panel
|
||||
Video off, dial 0). Pressing Launch (a click gesture) copies the welcome values
|
||||
into the panel and starts the soundtrack IN that gesture, sidestepping the browser
|
||||
autoplay block. Audio is a 0–10 range dial (not a checkbox); set it by writing
|
||||
`value` + firing an `input` event.
|
||||
|
||||
Install: pip install -e '.[e2e]' && python -m playwright install chromium
|
||||
"""
|
||||
@@ -65,15 +66,23 @@ def page(app_url):
|
||||
pytest.skip(f"chromium not available: {exc}")
|
||||
pg = browser.new_page()
|
||||
pg.goto(app_url)
|
||||
# wait out the "Loading Universe…" splash (it overlays + blocks clicks); the
|
||||
# experience boots silent (video off, audio 0) as it dismisses.
|
||||
pg.wait_for_selector("#loading", state="detached", timeout=60000)
|
||||
# Wait for phase-1 preload to finish; the welcome screen (State A) is showing.
|
||||
# The panel stays black/silent until the visitor presses Launch.
|
||||
pg.wait_for_function("window.__hefReady === true", timeout=60000)
|
||||
yield pg
|
||||
browser.close()
|
||||
|
||||
|
||||
def _enter(page):
|
||||
"""Press the welcome screen's Launch button (the entry gesture) and wait for the
|
||||
overlay to be removed, revealing the panel + running the experience."""
|
||||
page.click("#welcome-launch")
|
||||
page.wait_for_selector("#welcome", state="detached", timeout=10000)
|
||||
|
||||
|
||||
def _toggle_visual(page):
|
||||
"""Click the visible Video toggle track (the checkbox input is hidden)."""
|
||||
"""Click the visible Video toggle track (the checkbox input is hidden). Only
|
||||
valid after _enter() — the panel is hidden behind the welcome screen."""
|
||||
page.click("label[for='visual'] .dev-switch-track")
|
||||
|
||||
|
||||
@@ -87,9 +96,18 @@ def _set_audio(page, level):
|
||||
)
|
||||
|
||||
|
||||
def test_app_boots_silent_video_off(page):
|
||||
# The experience boots SILENT: Video off, audio dial at 0, screen black, both
|
||||
# crossfade elements paused — nothing plays until a real user gesture.
|
||||
def _set_welcome_audio(page, level):
|
||||
"""Drive the welcome screen's 0–10 audio dial (before Launch)."""
|
||||
page.evaluate(
|
||||
"(lvl) => { const a = document.getElementById('welcome-audio');"
|
||||
" a.value = String(lvl); a.dispatchEvent(new Event('input', {bubbles:true})); }",
|
||||
level,
|
||||
)
|
||||
|
||||
|
||||
def test_panel_silent_until_launch(page):
|
||||
# Before Launch the panel stays SILENT: panel Video off, audio dial at 0, screen
|
||||
# black, both crossfade elements paused — nothing plays until the Launch gesture.
|
||||
assert page.is_checked("#visual") is False
|
||||
assert page.evaluate("document.getElementById('audio').value") == "0"
|
||||
assert page.evaluate("document.getElementById('audio-level-val').textContent") == "0"
|
||||
@@ -100,12 +118,21 @@ def test_app_boots_silent_video_off(page):
|
||||
) is True
|
||||
|
||||
|
||||
def test_video_on_couples_audio_to_three_and_plays(page):
|
||||
# Turning Video on (a click gesture) lifts the audio dial to 3/10 and starts the
|
||||
# soundtrack IN that gesture: video shows (black hidden), a scale soundtrack plays.
|
||||
_toggle_visual(page) # off -> on
|
||||
page.wait_for_function("document.getElementById('audio').value === '3'")
|
||||
page.wait_for_function("document.getElementById('audio-level-val').textContent === '3'")
|
||||
def test_welcome_launch_shown_after_load_with_defaults(page):
|
||||
# Once media is preloaded the welcome screen shows its Launch button with the
|
||||
# default controls (Speed 1, Audio 2) while the panel stays silent.
|
||||
assert page.is_visible("#welcome-launch")
|
||||
assert page.evaluate("document.getElementById('welcome-play-speed').value") == "1"
|
||||
assert page.evaluate("document.getElementById('welcome-audio').value") == "2"
|
||||
|
||||
|
||||
def test_launch_enters_and_starts_video_and_audio(page):
|
||||
# Pressing "Launch Simulator" (a click gesture) enters: Video on, the audio dial
|
||||
# carries the welcome default 2/10, the overlay is removed, the soundtrack plays.
|
||||
_enter(page)
|
||||
assert page.is_checked("#visual") is True
|
||||
page.wait_for_function("document.getElementById('audio').value === '2'")
|
||||
page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'")
|
||||
page.wait_for_selector("#black.hidden", state="attached") # video showing
|
||||
page.wait_for_function(
|
||||
"(() => { const a = document.getElementById('aud');"
|
||||
@@ -113,7 +140,17 @@ def test_video_on_couples_audio_to_three_and_plays(page):
|
||||
)
|
||||
|
||||
|
||||
def test_chosen_audio_level_carries_through_launch(page):
|
||||
# Lowering the welcome Audio dial before launching carries the chosen level into
|
||||
# the panel (entry uses the chosen level, not a forced 2).
|
||||
_set_welcome_audio(page, 4)
|
||||
_enter(page)
|
||||
page.wait_for_function("document.getElementById('audio').value === '4'")
|
||||
page.wait_for_function("document.getElementById('audio-level-val').textContent === '4'")
|
||||
|
||||
|
||||
def test_audio_dial_sets_level_and_label(page):
|
||||
_enter(page)
|
||||
_set_audio(page, 7)
|
||||
page.wait_for_function("document.getElementById('audio').value === '7'")
|
||||
page.wait_for_function("document.getElementById('audio-level-val').textContent === '7'")
|
||||
@@ -121,6 +158,7 @@ def test_audio_dial_sets_level_and_label(page):
|
||||
|
||||
def test_audio_dial_zero_silences(page):
|
||||
# Level 0 fades both crossfade elements to silence, then pauses them.
|
||||
_enter(page)
|
||||
_set_audio(page, 0)
|
||||
page.wait_for_function(
|
||||
"(() => { const a = document.getElementById('aud'), b = document.getElementById('aud-b');"
|
||||
@@ -130,9 +168,9 @@ def test_audio_dial_zero_silences(page):
|
||||
|
||||
|
||||
def test_video_off_blanks(page):
|
||||
# Turn Video on (boot is off), then off again: that blanks the screen and hides
|
||||
# Enter (Video defaults on), then turn Video off: that blanks the screen and hides
|
||||
# the GPU layers.
|
||||
_toggle_visual(page) # off -> on
|
||||
_enter(page)
|
||||
page.wait_for_selector("#black.hidden", state="attached")
|
||||
_toggle_visual(page) # on -> off
|
||||
page.wait_for_selector("#black:not(.hidden)")
|
||||
@@ -145,6 +183,7 @@ def test_soundtrack_fallback_when_ring_lacks_audio(page):
|
||||
# Client resilience: even if /api/ring omits the per-scale `audio` field, the
|
||||
# scale-id fallback map keeps a soundtrack playing. Drop the field, re-apply the
|
||||
# dial, and confirm the cosmos soundtrack still resolves + plays.
|
||||
_enter(page)
|
||||
page.evaluate("ring.scales.forEach(s => { delete s.audio; })")
|
||||
_set_audio(page, 5)
|
||||
page.wait_for_function(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"""E2E (Playwright) — the public credits/colophon page actually surfaces the
|
||||
CC-BY / CC-BY-SA video attribution to a visitor.
|
||||
|
||||
Unit tests pin the credits *logic* (creditEntries/creditsListHtml) and the build
|
||||
ships the files; this confirms the live integration: credits.js fetches the clips
|
||||
and fills the page in a real browser, and the experience links to it. The suite
|
||||
skips cleanly when Playwright/Chromium is absent (headless box)."""
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("playwright.sync_api")
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with closing(socket.socket()) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url():
|
||||
import uvicorn
|
||||
|
||||
from simulator.app import app
|
||||
|
||||
port = _free_port()
|
||||
server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
|
||||
thread = threading.Thread(target=server.run, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(50):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
if not server.started:
|
||||
pytest.skip("uvicorn did not start")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def browser_page(app_url):
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch()
|
||||
except Exception as exc: # no browser binary installed
|
||||
pytest.skip(f"chromium not available: {exc}")
|
||||
page = browser.new_page()
|
||||
page._app_url = app_url # stash for the tests
|
||||
yield page
|
||||
browser.close()
|
||||
|
||||
|
||||
def test_credits_page_lists_video_attributions(browser_page):
|
||||
page = browser_page
|
||||
page.goto(page._app_url + "/credits.html")
|
||||
# credits.js fetches /api/clips and fills the container with one block per clip.
|
||||
page.wait_for_selector("#video-credits .credit", timeout=30000)
|
||||
count = page.eval_on_selector_all("#video-credits .credit", "els => els.length")
|
||||
assert count >= 40, f"expected the full clip pool credited, got {count}"
|
||||
# the required CC-BY / CC-BY-SA credits are visible to the viewer
|
||||
body = page.inner_text("body")
|
||||
assert "NASA, ESA, CSA, STScI" in body
|
||||
assert "Joe Mabel" in body # a CC BY-SA clip's required author credit
|
||||
assert "CC BY-SA 4.0" in body # the Option-D1 derivative declaration ("License & reuse")
|
||||
|
||||
|
||||
def test_experience_links_to_credits(browser_page):
|
||||
page = browser_page
|
||||
page.goto(page._app_url + "/")
|
||||
page.wait_for_function("window.__hefReady === true", timeout=60000)
|
||||
# The Credits link is in the header. Use the href-specific selector (the About
|
||||
# link shares the .credits-link class), and enter past the welcome screen so the
|
||||
# header is no longer covered by the overlay before clicking.
|
||||
link = "a[href='credits.html']"
|
||||
assert page.get_attribute(link, "href") == "credits.html"
|
||||
page.click("#welcome-launch")
|
||||
page.wait_for_selector("#welcome", state="detached", timeout=10000)
|
||||
page.click(link)
|
||||
page.wait_for_selector("#video-credits .credit", timeout=30000)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Build the fully-static deployable for Cloudflare (Pages + R2).
|
||||
|
||||
Emits the Pages output tree and a separate R2 media sync tree:
|
||||
|
||||
out_dir/
|
||||
_redirects apex + no-slash → /<app>/ (308)
|
||||
<app>/ the app (served at benstull.art/<app>/)
|
||||
index.html app.js scrub.js i18n.js alteration.js style.css
|
||||
config.js GENERATED: { mediaBase, static: true }
|
||||
clips.json ring.json media-versions.json baked API responses
|
||||
media_out/ R2 upload tree — ONLY manifest-referenced files
|
||||
<clip base.mp4 / transition morphs / audio>
|
||||
|
||||
The app lives under a path segment (so apex can redirect to it) and uses RELATIVE
|
||||
asset urls, which resolve correctly under the `/<app>/` prefix. The baked JSON is
|
||||
produced through the real app (TestClient) so it is byte-identical to the API. The
|
||||
media tree contains ONLY referenced files — never the master/mezzanine pipeline
|
||||
sources. Cloudflare-side steps (R2 sync, Pages deploy, CORS, redirect) live in
|
||||
deploy/cloudflare/ — this script only produces artifacts.
|
||||
|
||||
Run as a MODULE so the repo root (not tools/) is on sys.path — otherwise the
|
||||
sibling tools/http.py shadows the stdlib `http` that fastapi/starlette import:
|
||||
|
||||
.venv/bin/python -m tools.build_static
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from simulator.app import MEDIA_DIR, create_app
|
||||
|
||||
STATIC = Path(__file__).resolve().parent.parent / "simulator" / "static"
|
||||
# Frontend files that ship; everything else in static/ (author*, review*) is dev-only.
|
||||
PUBLIC_ASSETS = ["index.html", "app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js",
|
||||
"style.css", "credits.html", "credits.js", "about.html", "flash.js"]
|
||||
|
||||
|
||||
def _bake_api(app_dir: Path) -> dict:
|
||||
app = create_app()
|
||||
client = TestClient(app)
|
||||
clips = client.get("/api/clips").json()
|
||||
ring = client.get("/api/ring").json()
|
||||
versions = client.get("/api/media-versions").json()
|
||||
(app_dir / "clips.json").write_text(json.dumps(clips, ensure_ascii=False))
|
||||
(app_dir / "ring.json").write_text(json.dumps(ring, ensure_ascii=False))
|
||||
(app_dir / "media-versions.json").write_text(json.dumps(versions, ensure_ascii=False))
|
||||
return versions["versions"]
|
||||
|
||||
|
||||
def _write_config(app_dir: Path, media_base: str) -> None:
|
||||
(app_dir / "config.js").write_text(
|
||||
"// GENERATED by tools/build_static.py — do not edit in dist/.\n"
|
||||
f'window.HEF_CONFIG = {{ mediaBase: "{media_base}", static: true }};\n'
|
||||
)
|
||||
|
||||
|
||||
# The app's original deploy path, kept as a permanent redirect so old links to
|
||||
# /human-experience-simulator/… still land on the renamed /human-machine-strata/….
|
||||
LEGACY_APP_PATH = "human-experience-simulator"
|
||||
|
||||
|
||||
def _write_redirects(out: Path, seg: str) -> None:
|
||||
# Apex → app path, and the no-trailing-slash form → slashed (so RELATIVE asset
|
||||
# urls resolve under the prefix). Plus a permanent (301) redirect from the legacy
|
||||
# path to the current one. Cloudflare Pages `_redirects` syntax.
|
||||
lines = [
|
||||
f"/ /{seg}/ 308\n",
|
||||
f"/{seg} /{seg}/ 308\n",
|
||||
]
|
||||
if LEGACY_APP_PATH and LEGACY_APP_PATH != seg:
|
||||
# Exact root (trailing slash) first — the `/*` splat does NOT match the empty
|
||||
# case, so the bare /legacy/ needs its own rule. Then the splat for subpaths,
|
||||
# then the no-trailing-slash form.
|
||||
lines.append(f"/{LEGACY_APP_PATH}/ /{seg}/ 301\n")
|
||||
lines.append(f"/{LEGACY_APP_PATH}/* /{seg}/:splat 301\n")
|
||||
lines.append(f"/{LEGACY_APP_PATH} /{seg}/ 301\n")
|
||||
(out / "_redirects").write_text("".join(lines))
|
||||
|
||||
|
||||
def _write_headers(out: Path, seg: str) -> None:
|
||||
# The app shell must NOT be browser-cached — otherwise a deploy is invisible to
|
||||
# returning visitors for hours (Pages' default is max-age=14400). `no-store`
|
||||
# (never stored → a guaranteed-fresh fetch every visit) for the whole app path:
|
||||
# `no-cache` (revalidate) was NOT enough — Safari served a STALE index from its
|
||||
# disk/bfcache, loaded the OLD versioned JS, and broke the page (black, silent).
|
||||
# A per-HTML no-store override didn't stick (Pages let the `/seg/*` rule win), so
|
||||
# we no-store the whole path. The app shell is tiny (~150 KB); the media is on R2
|
||||
# (immutable + content-hash, a separate domain) and is unaffected. The content-
|
||||
# hashed asset URLs (see _version_assets) remain as belt-and-suspenders for the
|
||||
# case where Pages caches .js/.css at the edge by type and ignores _headers.
|
||||
# Cloudflare Pages `_headers` syntax.
|
||||
(out / "_headers").write_text(
|
||||
f"/{seg}/*\n"
|
||||
" Cache-Control: no-store\n"
|
||||
)
|
||||
|
||||
|
||||
def _version_assets(app_dir: Path) -> None:
|
||||
# Content-version the app-shell asset URLs in the HTML so a new build = a new URL
|
||||
# = guaranteed-fresh fetch on a normal reload. The HTML itself is served no-cache
|
||||
# (_headers), but Cloudflare Pages caches .js/.css by type (max-age=14400) and
|
||||
# ignores _headers for them — so without this, returning visitors run stale JS.
|
||||
assets = ["app.js", "scrub.js", "i18n.js", "alteration.js", "preload.js", "flash.js",
|
||||
"style.css", "credits.js", "config.js"]
|
||||
tok = {}
|
||||
for a in assets:
|
||||
p = app_dir / a
|
||||
if p.exists():
|
||||
tok[a] = hashlib.sha1(p.read_bytes()).hexdigest()[:12]
|
||||
for html in ("index.html", "credits.html"):
|
||||
hp = app_dir / html
|
||||
if not hp.exists():
|
||||
continue
|
||||
s = hp.read_text()
|
||||
for a, h in tok.items():
|
||||
s = s.replace(f'src="{a}"', f'src="{a}?v={h}"').replace(f'href="{a}"', f'href="{a}?v={h}"')
|
||||
hp.write_text(s)
|
||||
|
||||
|
||||
def build_static(out_dir, media_out, *, media_base: str, app_path: str) -> dict:
|
||||
out = Path(out_dir)
|
||||
media = Path(media_out)
|
||||
seg = app_path.strip("/")
|
||||
app_dir = out / seg
|
||||
for d in (out, media):
|
||||
if d.exists():
|
||||
shutil.rmtree(d)
|
||||
app_dir.mkdir(parents=True)
|
||||
media.mkdir(parents=True)
|
||||
|
||||
for name in PUBLIC_ASSETS:
|
||||
src = STATIC / name
|
||||
if src.exists():
|
||||
shutil.copy2(src, app_dir / name)
|
||||
|
||||
versions = _bake_api(app_dir)
|
||||
_write_config(app_dir, media_base)
|
||||
_version_assets(app_dir) # after config.js exists, so it gets hashed too
|
||||
_write_redirects(out, seg)
|
||||
_write_headers(out, seg)
|
||||
|
||||
# Sync ONLY referenced media: `versions` covers clip bases + transition morphs;
|
||||
# add each scale's audio explicitly. Never the master/mezzanine pipeline sources.
|
||||
referenced = set(versions.keys())
|
||||
ring = json.loads((app_dir / "ring.json").read_text())
|
||||
for s in ring.get("scales", []):
|
||||
if s.get("audio"):
|
||||
referenced.add(f"audio/{s['audio']}")
|
||||
# The Music-option playlist is referenced by the client (app.js MUSIC_PLAYLIST),
|
||||
# not the manifest/ring — add it explicitly or Music 404s on the static site.
|
||||
for name in ("manatees", "dewdrop", "eastminster", "overheat", "concentration"):
|
||||
referenced.add(f"audio/music/{name}.mp3")
|
||||
n = 0
|
||||
for rel in sorted(referenced):
|
||||
src = MEDIA_DIR / rel
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"referenced media missing on disk: {rel}")
|
||||
dst = media / rel
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
n += 1
|
||||
|
||||
return {"out": str(out), "app_dir": str(app_dir), "media": str(media),
|
||||
"media_files": n, "app_path": app_path}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--out", default="dist")
|
||||
ap.add_argument("--media-out", default="dist-media")
|
||||
ap.add_argument("--media-base", default="https://static.benstull.art/")
|
||||
ap.add_argument("--app-path", default="/human-machine-strata")
|
||||
a = ap.parse_args()
|
||||
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
|
||||
print(json.dumps(r, indent=2))
|
||||
Reference in New Issue
Block a user