Compare commits

...

38 Commits

Author SHA1 Message Date
BenStullsBets 7ec8b607d2 Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls 2026-06-30 15:06:59 -07:00
BenStullsBets b135cb5f5e fix(deploy): redirect the bare legacy root too (/* splat misses the empty case)
Add an explicit /human-experience-simulator/ → /human-machine-strata/ 301 (the
subpath splat doesn't match the trailing-slash root).

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:08:11 -07:00
BenStullsBets 79f64e3f7c feat(welcome): rename "Speed" control to "Video speed" (4-lang)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:57:13 -07:00
BenStullsBets 7cdf594794 chore(e2e): untrack + gitignore Playwright test-results artifacts
These were accidentally committed (tracked on main); they are regenerated on
every run and shouldn't be in git.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:27:25 -07:00
BenStullsBets 5f9ae6b212 feat(welcome): reduce-motion toggle for morph transitions; decouple loop from it
Per operator: Speed only controls the resting loop, so reduce-motion is still
needed to turn off the morph/transition videos. Re-add a visible 'Reduce motion'
toggle on BOTH the welcome screen and the panel (mirrored, default from OS
prefers-reduced-motion, remembers the choice). When ON, altitude changes are
instant cuts instead of an animated morph tween (autoScrub's reduced branch);
the resting loop is unaffected — it follows the Speed slider (0x freezes it).

Decouples the loop from reduce-motion (was: main froze both videos + disabled
the slider under OS-reduced). playLoop()/landing/applyPlaySpeed no longer check
isReduced(); isReduced() now only gates the morph tween. Welcome controls are
now Language / Speed / Audio / Reduce motion. i18n: restore rm.label (4 langs),
reword warn.body to point at the on-screen control.

Tests: a11y Task 5 reworked (speed independent of reduce-motion; new instant-cut
behavior test); welcome defaults assert reduce-motion off. node 35, e2e 32,
static 2, pytest 301 (3 pre-existing ffmpeg/cv2 env fails).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:27:18 -07:00
BenStullsBets 54cad607fb add sessions/0038/SESSION-0038.0-TRANSCRIPT-2026-06-30T12-25--2026-06-30T13-20.md + replace placeholder/variant SESSION-0038.0-TRANSCRIPT-2026-06-30T12-25--INPROGRESS.md 2026-06-30 13:19:40 -07:00
BenStullsBets 053bb5c1a3 claim human-experience-filter-art session 0038 (placeholder) + sessions.json entry 2026-06-30 13:17:25 -07:00
BenStullsBets c176ae7231 Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls
# Conflicts:
#	simulator/e2e/tests/a11y.spec.ts
#	simulator/static/app.js
2026-06-30 12:55:42 -07:00
BenStullsBets 6647390cb1 Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls
# Conflicts:
#	simulator/e2e/tests/a11y.spec.ts
#	simulator/static/app.js
#	simulator/static/i18n.js
2026-06-30 12:54:06 -07:00
BenStullsBets e064a2e684 feat(speed): switch Playback Speed to 0x–4x forward (drop reverse)
Real-browser eyeball: below-0x reverse jittered because the base loop
clips aren't all-intra (the flagged risk). Narrow the slider to 0x–4x
forward (still continuous, step=any); 0x still freezes; OS reduced-motion
behavior unchanged. Removes the manual rAF reverse loop and the
HEFScrub.reverseStep helper + its unit tests.

Tests: node unit 10/10; a11y e2e 12/12 (Task 5 now 3 slider tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:48:14 -07:00
BenStullsBets df8b245af3 feat(speed): Playback Speed slider replaces Reduce motion toggle
Continuous -2x..2x slider drives ONLY the resting altitude loop video
(#vid-loop); the morph/transition video is scrub-driven and untouched.
Forward = native playbackRate; ~0x = freeze; below 0x = a manual rAF
reverse loop (native negative playbackRate is unreliable). Reverse math
is the pure, node-tested HEFScrub.reverseStep helper.

Reduced-motion accessibility is preserved but now derives live from the
OS prefers-reduced-motion setting (no visible toggle): when active the
loop stays frozen and the slider is disabled. Retires hef.reduceMotion.
i18n: speed.label (en/es/fr/ja); warn.body reworded to point at the OS
setting.

Tests: +4 node unit cases for reverseStep (14/14 green); a11y e2e Task 5
rewritten for the slider (13/13 green); static-build e2e green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:39:06 -07:00
BenStullsBets 539618b1b8 docs(spec): Playback Speed slider replaces Reduce motion toggle
Continuous -2x..2x speed control for the resting altitude loop video
(#vid-loop); manual rAF reverse loop below 0x; reduced-motion a11y
behavior preserved invisibly via OS prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 12:39:06 -07:00
BenStullsBets f077193df9 feat(welcome): pre-load Welcome screen with controls + Launch gate
Per docs/superpowers/specs/2026-06-30-welcome-screen-design.md. Fold the bare
#loading splash, the #motion-warning modal, and the #run-sim button into one
#welcome overlay — the universal entry gate:

- State A (.welcoming): heads-up notice + four output controls (Video ON,
  Reduce motion OFF, Audio 2, Language) + 'Launch Simulator' button, with the
  'Loading Universe' progress pinned at the bottom (media preloads behind it).
- State B (.loading): pressing Launch while still loading hides the messaging +
  button, keeps the controls, and centers the loader; entry happens automatically
  once phase-1 finishes. Launch when already loaded enters directly.
- The right .panel is hidden until entry (body:has(#welcome)); welcome control
  values carry into the panel inputs on entry. Language + Reduce motion drive the
  shared state live (both selects/checkboxes mirror); Video + Audio apply on entry.
- Launch is the single audio-unlock gesture (beginExperience runs in-gesture even
  when entry is deferred). Retire WARN_KEY (welcome shows every load); entry uses
  the chosen Audio level rather than forcing 2.
- i18n: new welcome.launch key + es/fr/ja for the heads-up (warn.title/body),
  reworded to point at the on-screen Reduce-motion control; drop dead run.button.

Tests: new welcome.spec.ts (defaults, live language re-render, State B
deferred-entry, straight-in when loaded, control carry-over, panel hidden);
update altitude-lock/a11y/i18n/loop-recovery/static-build e2e + the Python
audio/credits e2e to the welcome flow; add a window.__hefReady diagnostic seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 11:14:35 -07:00
BenStullsBets 22bd152f33 add sessions/0037/SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--2026-06-30T10-52.md + replace placeholder/variant SESSION-0037.0-TRANSCRIPT-2026-06-30T10-42--INPROGRESS.md 2026-06-30 10:54:38 -07:00
benstull 5696b94328 Merge pull request 'feat(dial): snap to the closest altitude on drag release' (#31) from worktree-session-0037-altitude-snap-on-release into main 2026-06-30 17:51:39 +00:00
BenStullsBets 8e790b4a95 feat(dial): snap to the closest altitude on drag release
A drag is a way of grabbing the altitude dial, not a free-standing encoder
detent. Until now onDialUp held the live blend wherever the knob stopped (the
"no auto-complete" model), so a grab-and-release could leave the experience
parked mid-morph between two altitudes.

Now a real turn (moved >= 6) auto-scrubs the short way to Math.round(pos) and
settles there (frac 0 locks) — a grab-and-release always lands on a discrete
altitude, exactly like a tap or label click. The tap path (moved < 6 ->
jumpToScale) is unchanged, and reduced motion is handled by autoScrub (jump to
target, no tween).

Adds an altitude-lock e2e asserting a partial drag settles on an integer
altitude after release. Full altitude-lock (14) + a11y + i18n suites green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:50:51 -07:00
BenStullsBets b9de6695a0 docs(welcome): entry button is 'Launch Simulator' (new welcome.launch key)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:48:43 -07:00
BenStullsBets 05328b1b5e docs(welcome): brainstorming spec for the Welcome screen + controls
Unify #loading / #motion-warning / #run-sim into one #welcome overlay:
heads-up notice + four output controls (Video on, Reduce motion off,
Audio 2, English) + Loading Universe progress. Two states (welcome →
centered loading); right panel hidden until entry; Continue is the single
entry gesture. Forward note: extra welcome copy lands later.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:15:57 -07:00
BenStullsBets 34478bb3a3 add sessions/0035/SESSION-0035.0-TRANSCRIPT-2026-06-30T08-45--2026-06-30T10-08.md + replace placeholder/variant SESSION-0035.0-TRANSCRIPT-2026-06-30T08-45--INPROGRESS.md 2026-06-30 10:15:43 -07:00
BenStullsBets f7abb5c29b claim human-experience-filter-art session 0035 (placeholder) + sessions.json entry 2026-06-30 10:10:03 -07:00
BenStullsBets 86ce291056 add sessions/0034/SESSION-0034.0-TRANSCRIPT-2026-06-30T07-24--2026-06-30T10-01.md + replace placeholder/variant SESSION-0034.0-TRANSCRIPT-2026-06-30T07-24--INPROGRESS.md 2026-06-30 10:06:07 -07:00
BenStullsBets e0964eae6c claim human-experience-filter-art session 0034 (placeholder) + sessions.json entry 2026-06-30 10:03:35 -07:00
31 changed files with 1562 additions and 288 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Worktree isolation guard. This repo is worked by MULTIPLE concurrent sessions; if
# they share the main checkout they clobber each other (branch switches, lost edits,
# commits on the wrong branch). Every session must work in its OWN git worktree
# OUTSIDE the main directory.
#
# worktree-guard.sh block PreToolUse(Edit|Write|NotebookEdit) — DENY edits to
# files INSIDE the main checkout while in it. Files
# outside the repo (memory, /tmp, scratchpad) are allowed.
# worktree-guard.sh notice SessionStart — tell the agent to enter a worktree.
#
# Isolated = the session sits in a git worktree, where `--git-dir` (…/.git/worktrees/
# <name>) differs from `--git-common-dir` (…/.git). Equal = the shared main checkout.
set -u
mode="${1:-notice}"
gd=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 # not a repo → nothing to guard
gc=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) || exit 0
[ "$gd" = "$gc" ] || exit 0 # already in a worktree → allow
root=$(git rev-parse --show-toplevel 2>/dev/null)
repo=$(basename "${root:-repo}")
guidance="You are in the SHARED main checkout ($root). Multiple sessions use it \
concurrently and WILL clobber each other. Work in an isolated worktree OUTSIDE the \
main directory: use the EnterWorktree tool (preferred — it relocates this session to \
a fresh worktree branched from origin/main), or run \
\`git worktree add -b <branch> ../${repo}-worktrees/<name> origin/main\` and cd there. \
Do NOT edit files in the main checkout."
esc() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
if [ "$mode" = "block" ]; then
# Only guard files INSIDE the main checkout's working tree; allow edits to files
# outside the repo (auto-memory, /tmp, scratchpad, other repos).
fp=$(python3 -c 'import json,sys
try:
d=json.load(sys.stdin); print(d.get("tool_input",{}).get("file_path","") or "")
except Exception: print("")' 2>/dev/null)
case "$fp" in
/*) abs="$fp" ;;
"") abs="$root" ;;
*) abs="$root/$fp" ;;
esac
case "$abs" in
"$root"/*|"$root") : ;; # inside the repo → fall through to deny
*) exit 0 ;; # outside the repo → allow
esac
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(esc "$guidance")"
else
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$(esc "⚠ WORKTREE ISOLATION REQUIRED. $guidance")"
fi
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"worktree": {
"bgIsolation": "worktree",
"baseRef": "fresh"
},
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" notice"
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" block"
}
]
}
]
}
}
+4
View File
@@ -30,3 +30,7 @@ simulator/static/review_audio.html
# Static build outputs (tools/build_static.py)
/dist/
/dist-media/
# Playwright e2e artifacts (should never be committed)
simulator/e2e/test-results/
simulator/e2e/playwright-report/
+4 -4
View File
@@ -1,7 +1,7 @@
# Deploying to benstull.art — Cloudflare Pages + R2
Publishes the simulator as a fully-static site: the frontend on **Cloudflare
Pages** (`benstull.art`, app at `/human-experience-simulator`, apex redirects to
Pages** (`benstull.art`, app at `/human-machine-strata`, apex redirects to
it) and the video/audio on **R2** behind `static.benstull.art`. No origin server.
Design: `docs/superpowers/specs/2026-06-30-cloudflare-static-publish-design.md`.
@@ -48,7 +48,7 @@ as a script):
```
Produces:
- `dist/` — Pages output: `dist/human-experience-simulator/` (frontend + baked
- `dist/` — Pages output: `dist/human-machine-strata/` (frontend + baked
`clips.json`/`ring.json`/`media-versions.json` + generated `config.js`) and a
root `dist/_redirects` (apex → app path, and no-slash → slashed).
- `dist-media/` — R2 upload tree, ~2.3 GB / 604 files, manifest-referenced only
@@ -109,7 +109,7 @@ Then **in the dashboard** (Workers & Pages → project → Custom domains) — t
no wrangler CLI for Pages custom domains:
- Bind **`benstull.art`** to the project. (Allow a few minutes — a fresh bind
serves `522` until the cert/routing provisions.)
- The app serves at `benstull.art/human-experience-simulator/`; the shipped
- The app serves at `benstull.art/human-machine-strata/`; the shipped
`dist/_redirects` sends `/` and the no-slash form to it.
## 4. Verify before/after going live
@@ -121,7 +121,7 @@ curl -sI -H "Origin: https://benstull.art" -H "Range: bytes=0-1" \
```
Open `https://benstull.art/` → should redirect to
`https://benstull.art/human-experience-simulator/`. Turn the **Feel** knob to max
`https://benstull.art/human-machine-strata/`. Turn the **Feel** knob to max
and confirm the dream effect renders (no `SecurityError` in the console — that
would mean CORS is misconfigured and the GL texture is tainted). The local
static-build E2E (`simulator/e2e/tests/static-build.spec.ts`) exercises this same
+1 -1
View File
@@ -15,7 +15,7 @@ set -euo pipefail
BUCKET="${BUCKET:-human-experience-simulator-media}"
PROJECT="${PROJECT:-human-experience-simulator}"
MEDIA_BASE="${MEDIA_BASE:-https://static.benstull.art/}"
APP_PATH="${APP_PATH:-/human-experience-simulator}"
APP_PATH="${APP_PATH:-/human-machine-strata}"
BRANCH="${BRANCH:-main}"
JOBS="${JOBS:-10}"
@@ -0,0 +1,142 @@
# Playback Speed slider (replaces "Reduce motion") — design
**Date:** 2026-06-30
**Status:** Shipped, then revised
**Scope:** Frontend only (`simulator/static/`). No engine, pipeline, or media changes.
> **Revision (2026-06-30, post-eyeball):** Reverse playback was **dropped**. In a
> real browser the below-0× reverse loop jittered — the base loop clips aren't
> all-intra, so backward seeking can't stay smooth (exactly the risk flagged
> below). The slider is now **0×–4× forward** (still continuous, `step="any"`).
> `0×` still freezes; OS reduced-motion behavior is unchanged. The
> `HEFScrub.reverseStep` helper and its tests were removed. Sections below
> describing the 2…2 range and the manual reverse loop are superseded by this
> note.
## Summary
Add a continuous **Playback Speed** slider (2× … 2×) that controls the resting
**altitude loop video** (`#vid-loop`) in real time as the user drags it. Below
`0×` the loop plays **in reverse**. The slider **replaces** the "Reduce motion"
toggle in the Output panel — but the reduced-motion *accessibility behavior* is
preserved, now derived silently from the OS `prefers-reduced-motion` setting
rather than a visible toggle.
## What it affects (and what it does not)
- **Affects:** the resting **altitude loop video** `#vid-loop` — the clip that
loops while parked at an altitude. The slider sets its play speed/direction.
- **Does NOT affect:** the **morph / transition video** `#vid`. That element is
scrub-driven — the altitude dial sets its `currentTime` directly — so a
playback rate has no meaning there. Speed control never touches it.
## UI
Replace the `reduce-motion` checkbox `<label>` in `index.html` with a range
slider, styled like the existing Audio level control:
```
Speed [ −2 ········●········ 2 ] 1.37×
```
- `<input type="range" id="play-speed" min="-2" max="2" step="any" value="1">`
`step="any"` so dragging yields a **continuous** value, not 0.25 detents.
- A live numeric **readout** (`<span id="play-speed-val">`) shows the current
value formatted to 2 decimals with a `×` suffix (e.g. `1.37×`, `0.75×`,
`0.00×`).
- Reference **tick labels** at the nice increments (`2 1.75 … 0 … 2`) via a
`<datalist>` bound to the input, so the round numbers are visible without
quantizing the actual applied value.
- New i18n key `speed.label` ("Speed") in en/es/fr/ja. The `rm.label` key is
retired.
## Accessibility (preserved, now invisible)
The reduce-motion *toggle* is removed, but its *behavior* is kept. `isReduced()`
now derives **live from the OS setting only**
`matchMedia("(prefers-reduced-motion: reduce)")`, re-read on its `change` event —
with no stored override and no UI. Every existing `isReduced()` call site keeps
working unchanged:
- loop-play gate (`playLoop`),
- instant (no-tween) auto altitude transitions (`autoScrub`),
- the photosensitivity-flash luminance floor.
Consequences:
- The `hef.reduceMotion` localStorage key (`RM_KEY`) is **retired**.
- When OS reduced-motion is **active**: the loop stays frozen and the speed
slider is **disabled** (with a short hint), so a vestibular/photosensitive
visitor never gets motion they did not ask for. If the OS pref toggles at
runtime, the slider enables/disables to match.
- `warn.body` copy (all 4 langs) is reworded: it currently tells users to "turn
on Reduce motion in the panel," which no longer exists. New copy points to the
system reduced-motion setting (now auto-applied).
## Speed behavior (`app.js`)
A new `applyPlaySpeed(speed)` drives `#vid-loop`, with the chosen value persisted
to `localStorage["hef.playSpeed"]` (default `1`). Applied live on the slider's
`input` event:
- **speed > 0** → `loopVid.playbackRate = speed`; native forward play. The
existing duration→`loopStartFrame()` wrap is unchanged. Any active manual
reverse loop is cancelled.
- **|speed| ≈ 0** (within a small epsilon) → pause the element (freeze on the
current frame). Reversible by moving the slider off zero. Cancels any reverse
loop.
- **speed < 0** → native negative `playbackRate` is unreliable (Chrome ignores
it), so a `requestAnimationFrame` loop owns the element: the element is
**paused** and each frame decrements `loopVid.currentTime` by `|speed|·dt`.
When it crosses the bottom boundary it **wraps to the tail** (mirror of the
forward wrap), reusing the direction-aware landing offset. The loop is
cancelled when speed goes ≥ 0, on reduced-motion freeze, when video output is
turned off, or when a new loop clip loads.
Interaction with existing motion:
- The manual reverse loop and the **auto-transition tween** (`autoRaf`, which
drives the *morph* `#vid`) are independent — different elements, different
rAF handles. The reverse loop only owns `#vid-loop`.
- On landing on a new altitude (`loadLoop` + `playLoop`), the current slider
speed is re-applied so the new clip inherits the chosen rate/direction.
## Test seam + tests
Mirrors the existing `scrub.js` / `node --test` pattern:
- A **pure** helper added to `scrub.js`:
`reverseStep(t, absSpeed, dt, loopStart, duration) → { next, wrapped }`
— computes the next `currentTime` and whether a bottom-boundary wrap occurred.
This is the reverse-loop math with no DOM; `app.js` just applies `next` to
`loopVid.currentTime`. Covered by a new `node --test` case (steps, wrap at the
boundary, clamping).
- A **Playwright** e2e added to the existing suite: the slider is present;
forward value sets `#vid-loop.playbackRate`; `0×` pauses it; a negative value
makes `currentTime` decrease over time. Also assert the slider is disabled
when `prefers-reduced-motion` is emulated.
## Known risk (noted, not blocking)
Reverse smoothness depends on the **base loop clips'** keyframe density. The
morphs are all-intra (smooth reverse), but the base altitude clips may not be;
on some browsers reverse seeking could stutter. The manual loop stays *correct*
regardless — smoothness is a visual-polish follow-up (re-encode base clips
all-intra if needed), not a blocker for this change.
## Files touched
- `simulator/static/index.html` — swap toggle → slider + datalist; reword warn.
- `simulator/static/app.js``applyPlaySpeed`, reverse rAF loop, derive
`isReduced()` from OS pref, retire `RM_KEY`, re-apply speed on landing.
- `simulator/static/scrub.js` — add pure `reverseStep` helper.
- `simulator/static/i18n.js` — add `speed.label`, reword `warn.body` (en/es/fr/ja),
retire `rm.label`.
- `simulator/static/styles.css` — slider styling (reuse audio-level pattern).
- Tests: `node --test` for `reverseStep`; Playwright e2e for the slider.
## Out of scope
- Re-encoding base clips for smoother reverse (follow-up if stutter is visible).
- Any speed control over morph/transition videos.
- Per-altitude or audio speed coupling (audio level is its own control).
@@ -0,0 +1,171 @@
# Welcome screen — design
**Date:** 2026-06-30
**Status:** Approved (brainstorming)
**Surface:** `simulator/static/` (frontend only — `index.html`, `style.css`, `app.js`, `i18n.js`)
## Summary
Replace the current bare boot flow with a single **Welcome screen**: the
universal entry gate the visitor sees on every load. It carries the
photosensitivity "Heads up" notice, the four output controls (Video, Reduce
motion, Audio, Language) with welcome-appropriate defaults, a **Launch
Simulator** button, and the "Loading Universe" progress. The right control panel is hidden
until the visitor enters the simulation.
## Why
Today the universe loads behind a bare "Loading Universe" splash; the splash
fades to reveal a "Run simulation" button over the black stage; a first-visit
"Heads up" modal appears; clicking through begins the experience. The four
output controls live in the right panel and are visible the whole time —
including during loading, which the panel should not be.
We want a deliberate, configurable front door: the visitor reads the heads-up,
sets their preferences, and presses one button to begin — while the universe
loads in the background so entry is instant when they are ready.
## Current flow (being replaced)
- `#loading` — fullscreen splash (`Loading Universe` title + `#loading-fill`
progress bar). `simulator/static/index.html:10-15`.
- `#run-sim` — "Run simulation" button revealed over the black stage after
phase-1 preload. `index.html:33`; wired at `app.js:1533`.
- `#motion-warning` — first-visit-only "Heads up" modal, persisted dismissed via
`WARN_KEY`. `index.html:34-40`; shown by `maybeShowMotionWarning()`
(`app.js:1161`).
- Four controls in `.panel` (`#visual`, `#audio`, `#reduce-motion`,
`#lang-select`). `index.html:46-65`. The panel is visible throughout.
- `beginExperience()` (`app.js:1505`) snaps Video on and Audio to
`START_AUDIO_LEVEL = "2"` in the click gesture (Safari audio-unlock).
## Target design
### 1. One unified `#welcome` overlay
Fold `#loading`, `#motion-warning`, and `#run-sim` into a single fullscreen
`#welcome` overlay layered above everything. It absorbs all three roles. The
right `.panel` is **hidden** for the entire welcome + loading period and revealed
only on entry to the simulation.
The overlay contains three blocks:
1. **Welcome messaging** — the "Heads up — motion & flashing" title + body
(today's `warn.*` strings, reworded to point at the on-screen Reduce-motion
control rather than "the panel"). Structured so additional welcome copy can be
added later (see Forward notes).
2. **Controls** — the four output controls (below).
3. **Launch Simulator** — the single entry button.
4. **Loading** — the "Loading Universe" title + progress bar.
### 2. Two visual states
**State A — Welcome (on load):** welcome messaging + controls + "Launch
Simulator" button shown; the Loading block sits at the **bottom**, its bar
filling as phase-1 preload runs in the background.
**State B — Loading (after Launch, only if not yet loaded):** the welcome
messaging and Launch button **disappear**; the **controls remain**; the Loading
block moves to **center**. When phase-1 preload completes, auto-enter the
simulation.
**Launch when already loaded** → skip State B; enter the simulation directly.
State is driven by a class on `#welcome` (e.g. `.welcoming``.loading`), with
the "loaded yet?" decision made from a flag set when phase-1 preload resolves.
### 3. Control defaults (welcome screen)
| Control | Welcome default | Notes |
|---------------|-----------------|--------------------------------------------------|
| Video | **ON** | Was OFF in the panel; on by default here. |
| Reduce motion | **OFF** | Unless OS `prefers-reduced-motion` / stored pref.|
| Audio | **2** (010) | Was 0 in the panel. |
| Language | **English** | Global; changing it re-renders all welcome text. |
Reduce motion keeps deferring to a stored value / the OS media query when those
exist (same precedence as `initReduceMotion()` today); "OFF" is the no-signal
default.
### 4. Entry into the simulation
"Launch Simulator" is the user gesture that unlocks audio (the role `#run-sim`
had). On Launch → enter (either immediately when loaded, or at end of State B):
1. Copy the four welcome values into the existing panel inputs (`#visual`,
`#reduce-motion`, `#audio`, `#lang-select`).
2. Fire the existing handlers / `beginExperience()` so the experience starts in
the same code paths as today.
3. Remove the `#welcome` overlay and reveal `.panel`.
Per decisions: the four controls **stay in the panel** for mid-experience
adjustment; **Video-off at entry** starts with video off (audio still plays per
the Audio level — `beginExperience` no longer forces Video on regardless).
### 5. Controls drive shared state
The welcome controls write the same underlying state as the panel controls — they
are not a parallel system:
- **Language** routes through the global `setLanguage()`, which sets `activeLang`
and runs `applyUiStrings()` over **all** `[data-i18n]` elements — so the
heads-up copy, control labels, and Launch button re-render live, exactly like
the panel selector does today. The panel `#lang-select` value is synced to
`activeLang` on entry.
- **Reduce motion** sets the same `reduceMotion` variable + `RM_KEY` localStorage
and calls `applyReduceMotion()`; panel checkbox synced on entry.
- **Audio / Video** values are applied on entry via the panel inputs +
`beginExperience()`.
### 6. Behavior changes (intentional)
- The welcome screen shows on **every** load (it is the entry gate holding the
controls), so the first-visit-only persistence of the heads-up (`WARN_KEY`) is
**retired**.
- Entry uses the **chosen** Audio level rather than always snapping to 2 (2 is the
default, not a forced value).
- `#run-sim` is removed; **"Launch Simulator"** is the single entry button.
## i18n
New / changed `UI_STRINGS` keys in `simulator/static/i18n.js` (all four langs —
en/es/fr/ja, matching existing parity):
- A new `welcome.launch` key — **"Launch Simulator"** (the single entry button),
translated in all four languages.
- Reworded `warn.body` (point at the on-screen Reduce-motion control).
- Welcome-screen control labels reuse the existing `output.video`,
`output.audio`, `rm.label` keys (already translated). The language selector
needs no string (native names from the registry).
All welcome text must live under `data-i18n` so `applyUiStrings()` re-renders it
on language change.
## Out of scope / Forward notes
- **Additional welcome copy** beyond the heads-up (intro / framing text) comes in
a later pass, once this flow works. The welcome-messaging block is structured so
adding more `data-i18n` paragraphs is trivial.
- No backend / API changes. No content-pipeline changes.
## Testing
Frontend lives behind Playwright e2e (`simulator/static/` tests) + node unit
tests. Plan to:
- **Update** existing tests that reference `#loading`, `#run-sim`, and
`#motion-warning` to the new `#welcome` flow.
- **Add** coverage for:
- Welcome defaults (Video on, Reduce motion off, Audio 2, English).
- Language switch re-renders welcome text (heads-up + labels + Launch button)
live.
- Launch **while loading** → State B (messaging gone, controls remain,
progress centered), then auto-enter when loaded.
- Launch **when already loaded** → straight into the simulation.
- `.panel` hidden during welcome + loading; revealed on entry.
- Welcome control values carry into the panel inputs on entry.
Environment caveats (from prior sessions): this box has only `python3` (start
uvicorn by hand; `reuseExistingServer`); a stale uvicorn is the recurring
"no transitions" root cause; `loop-recovery.spec.ts` fails on a clean baseline
here (boots without video).
@@ -0,0 +1,107 @@
# Session 0034.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T07-24 (PST)
> End: 2026-06-30T10-01 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 08b95eb0-3ec5-400a-9236-da78f01139a0
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/fix+audio-and-tracking-labels
> Status: FINALIZED
## Launch prompt
> The audio isn't playing at all. Let's fix that and then let's make sure there are
> left and right brain tracking labels for everything. Make sure you're working on a
> worktree. There are other concurrent sessions that may have real merge conflicts to
> handle.
## Pre-state
- The simulator (`simulator/`) is a FastAPI + static-JS experience: an altitude dial
over 6 scales (cosmos/orbit/sky/coast/reef/abyss), 41 rotating-pool clips, a
real-time Kuwahara "dream" shader, two HUD label layers — **Think** (left-brain
factual chips, `#overlay`) and **Feel** (right-brain affect words, `#affect`) —
plus a per-altitude soundtrack with a 010 audio dial and a built-in
`#audio-status` diagnostic.
- s0027 had brought all 41 clips to left+right annotation with 4-lang parity, but the
left labels were thin (many clips 12) and STATIC; affect was authored PER-SCALE
(every clip in an altitude shared 4 feelings).
- Operator was running a stale `:8000` server on the `design/cloudflare-static-publish`
branch (a concurrent session's WIP — "Run simulation" begin-experience flow, +
later a credits page). Project is EXEMPT from the Wiggleverse deploy pipeline
(flotilla/PPE/§9); public target is static Cloudflare. cv2/imageio NOT installed in
this env.
## Session arc
1. **Worktree.** Created native worktree `fix+audio-and-tracking-labels` off
`origin/main` (582183d). All work done here.
2. **Audio (systematic-debugging) — FALSE ALARM.** Could not reproduce "no audio" in
ANY automated condition: Chromium + WebKit, strict autoplay
(`--autoplay-policy=user-gesture-required`), clean-main server AND the operator's
`:8000` WIP server, via Video-toggle / Audio-dial / "Run simulation" button; all 5
soundtracks decode to real non-silent audio (RMS 0.080.24). The raw
native-controls `#aud-test` player was ALSO silent on the operator's Safari while
the element reported playing → isolated it BELOW the app. Operator confirmed:
**their Safari sound was muted.** No code change.
3. **"Labels not showing" on cosmos_tarantula.** Root cause: operator was on `:8000`
(design branch), whose manifest had that clip's `annotations: []`. My branch
renders all labels correctly; labels are knob-gated (none at Think/Feel 0).
4. **Left (Think) enrichment.** Every clip → ≥3 factual labels (added one to each of
23 thin clips; orbit_bluemarble 1→3), full es/fr/ja parity. Pipeline:
`LABELS` in `build_pool_manifest.py` → build → catalogs → `translate_manifest.py
merge`. (commit `8cfceff`)
5. **Motion-tracking.** Converted the 4 still-static abyss creatures (octopus, bigfin
squid, dandelion siphonophore, sea pig) static→tracked, 3-keyframe paths sampled
by eye from frames (cv2 unavailable). Tracked clips 7→11. (`8cfceff`)
6. **Think/Feel overlap avoidance.** `renderAffect` now nudges Feel words off Think
chip plates (obstacles computed from manifest, tracked paths sampled → holds whole
loop, no per-frame jitter; new `window.__hefNoDeconflict` seam). Verified 0
overlaps across all 6 scales incl. a tracked clip over time. (commit `041fcde`)
7. **Per-clip right-brain feelings.** Operator: feelings should match each ACTUAL
video. Added `AFFECT_CLIP` (per-clip override, scale fallback) for all 41 clips →
41 distinct feeling-sets. cosmos done by me as a reviewed sample; the other 5
scales drafted by **5 parallel subagents** (one per scale) that each VIEWED footage
frames and followed the cosmos recipe; I normalized + authored all es/fr/ja for the
19 new nuance keys. 42 distinct feeling keys now. (commits `bf1013b` + `2d54023`)
8. **Merge.** Merged branch → `origin/main` (clean; main had only advanced by
`sessions/` transcript commits). Confirmed all 4 work commits are ancestors of
`origin/main`; another session (`feat/accessibility-pass`) has since merged main +
a11y on top (`380c2d1`).
## Cut state (final)
- All work committed, merged to `origin/main`, pushed. Working tree clean.
- Verification: 34/34 catalog+i18n+build pytest, 16/16 node unit, live render shows
≥3 Think chips + distinct Feel words per clip (incl. new keys) with overlap 0 and no
JS errors. (3 unrelated pytest failures = cv2/ffmpeg absent in this env.)
- Worktree RETAINED (not torn down) because it is serving the operator's by-eye review
at `http://127.0.0.1:8011/author.html`; tear down + stop `:8011` when review done.
- §9 deploy pipeline: N/A — project is exempt (static Cloudflare target).
## Deferred decisions (low-confidence calls)
- **Audio = no code change.** Chose to treat it as environmental after exhaustive
repro rather than guess-patch a working engine; operator confirmed muted Safari.
- **Left-label box positions authored WITHOUT viewing each frame** (23 enriched
clips, except bluemarble) — factual text is grounded, positions are estimates that
want by-eye review.
- **Per-clip feelings drafted from a single MID-FRAME** (motion not seen) via parallel
subagents — first-pass reads; emotions may want tuning once a clip is in motion.
- **Left reef/coast moving-subject clips left STATIC** (reef_redsea/flowergarden
schools, coast_otters/seals) — tracking deferred (diffuse subjects + cv2 absent).
- **Merged directly to `main` via `git push HEAD:main`** (no Gitea PR) at operator's
explicit request; gh is GitHub-only here and no `tea` CLI.
## Operator plate
- Review the first-pass label/track/feeling positions in `/author.html` on `:8011`.
- For Think: check each chip sits on its subject + the 4 abyss tracks follow the
creature. For Feel: check emotions ring true once clips are in motion.
## Next /goal
```
/goal refine the first-pass left-label box positions and the 4 abyss creature tracks per operator by-eye review (grab a frame per clip, nudge each chip onto its subject), and motion-track the remaining moving-subject clips (reef schools, coast animals) — data-only via build_pool_manifest + i18n pipeline
```
@@ -0,0 +1,102 @@
# Session 0035.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T08-45 (PST)
> End: 2026-06-30T10-08 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: ee3eaaac-d0a4-41a9-aa53-1919052fd8d3
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: **FINALIZED**
## Launch prompt
Not a `/goal` launch. Opened conversationally: "We need to make the entire
simulator accessible, assuming it's not being used at a kiosk. What are the
different types of accessibility and your suggestions (and difficulty)?" Then:
build the first pass on a worktree, and add an `about.html` about the project's
intent (accessible to all humans; vantage points from underwater→drones→space
exploration; built with LLMs; honest that it's imperfect but more than the author
alone could make).
Drove through the **superpowers** pipeline (brainstorming → writing-plans →
executing-plans → finishing-a-development-branch), not a wgl init — so the wgl
transcript ID was claimed at finalize (this file).
## Pre-state
- Simulator built for an attended **kiosk**; the Cloudflare static publish
(`design/cloudflare-static-publish`, already merged to `main` mid-session as
`87d605d`) puts it on a public URL where no AT/attendant assumptions hold.
- No `prefers-reduced-motion`, no keyboard on the SVG Altitude dial, several
failing-contrast colors, no screen-reader path, no seizure safeguard — on a
piece that is continuous motion + a "trippy at max" WebGL shader.
## Arc (turn-by-turn)
1. **Analysis** — read `index.html` / `style.css`, grepped `app.js` (zero aria /
keyboard / reduced-motion). Enumerated 7 accessibility types grounded in the
actual UI, with difficulty + a priority table. Recommended WCAG 2.1 AA + a
motion/seizure layer.
2. **Brainstorming** — operator chose: reduced-motion = **freeze-to-stills**;
photosensitivity = **warning gate + flash audit**; about page = **English-first**.
Added the about-page "imperfect but more than alone" beat. Wrote + committed the
design spec.
3. **Worktree** — first tried `EnterWorktree` (branches off `origin/main`); on
inspecting the diff, the work belonged on `design/cloudflare-static-publish`
(correct `app.js`, existing `credits.html` pattern). Operator: "new worktree off
that, keep separate until ready for main." Created
`feat/accessibility-pass` off the design branch under `.claude/worktrees/`.
4. **Plan** — 10 bite-sized TDD tasks, committed.
5. **Execution (TDD, test-first each)** — (1) layout: lang picker below Audio,
globe inline-left; (2) AA contrast + focus-visible + visually-hidden; (3)
`flash.js` pure WCAG-2.3.1 helper + node tests; (4) one-time photosensitivity
gate; (5) reduced-motion freeze (single `playLoop()` guard + instant `autoScrub`);
(6) keyboard/ARIA dial (`role=slider`, Arrow/Home/End, label buttons); (7)
aria-live narration + `aria-hidden` decoratives; (8) wire flash-clamp + document
audit (no breach found); (9) `about.html` + header link + `PUBLIC_ASSETS`.
9 a11y e2e + 2 node tests, all green; fixed a gate-vs-run-sim regression in the
shared `boot()` helper.
6. **Finish** — operator: merge everything to main + push origin. `origin/main`
had advanced 29 commits (static-publish already landed there) → divergent merge;
one `app.js` conflict (main's D3 reverse-landing fix vs my reduced-motion guard)
resolved keeping **both**. Re-verified on merged code (a11y 9/9, altitude-lock
13/13, i18n 1/1, static-build 2/2, flash 2/2). Pushed `ff06783..380c2d1 main`.
Removed the worktree; stopped bg servers.
## Cut state
- **a11y pass + About page MERGED + PUSHED to `origin/main` (`380c2d1`).**
- On `main` but **NOT deployed** — live benstull.art needs a Cloudflare redeploy.
- App has **no content repo** → plan stays in-repo at
`docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md`. No
`submit-plan.sh`. Project is **exempt** from §9/flotilla/PPE (Cloudflare static).
- A **concurrent** commit `1a6ffc7` ("phase-1 preload gate") sits on local `main`
ahead of origin by 1 — **not this session's; left untouched/unpushed** per
operator ("leave anything that isn't yours alone"). Other worktree
`fix+audio-and-tracking-labels` also left alone.
- Checkout now on `main` (was `design/cloudflare-static-publish` at start).
## Deferred decisions
_Autonomous low-confidence calls (no live placeholder existed, so logged here):_
- **SR narration = scale + clip title**, not the tiered factual (Think) HUD label —
simpler/robust; the plan explicitly permitted the degrade. Could enrich later.
- **Flash audit concluded no >3/sec breach** (1200ms per-altitude floor; fades are
single, ≥1.2s apart); `flash.js` wired only as a guard floor (no-op today).
- **Branched off the design branch, not main** — corrected after operator input.
- **Merged the full divergent history to main** (static-publish was already on
origin/main, so net-new was just the a11y work) — on explicit operator instruction.
## Next /goal
```
/goal redeploy benstull.art/human-experience-simulator to Cloudflare so the accessibility pass + About page go live, per cloudflare-deploy-live (build with `python3 -m tools.build_static`, then the R2/Pages deploy gestures)
```
Operator by-eye review of the a11y changes precedes the redeploy. Env gotchas for
the next session: run `build_static` as a **module** (`python3 -m tools.build_static`)
or `tools/http.py` shadows stdlib `http`; uvicorn dies mid-session and the e2e
`webServer` fallback calls `python` (only `python3` here) → start it by hand on :8099;
Playwright `test.use()` inside `test.describe()` breaks collection in PW 1.45.
@@ -0,0 +1,108 @@
# Session 0036.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T06-30 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: a7814ad4-473f-4946-b158-a5fad35b4bf1
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> Status: finalized
## Launch prompt
"Let's publish this to benstull.art. Ideally this would be hosted on Cloudflare
Pages. Cloudflare Pages does have limits though, so we should check that against
this project." (Started conversationally — no tracked init; finalized as
planning-and-executing/yolo.)
## Pre-state
The simulator was a FastAPI server (`simulator/app.py`) serving a ~92 KB static
frontend + 3.9 GB of media (1080p clip bases + 558 already-720p transition morphs +
audio) via dynamic endpoints. Not yet public anywhere. Operator wanted it live at
benstull.art on Cloudflare Pages. Concurrent sessions (0029/0031/0033/0034/0035)
were active on the SAME checkout.
## Arc
**1. Brainstorm → design → plan (Cloudflare publish).** Established the blocker:
Pages has a 25 MiB/file hard limit; the project has 26 files >25 MiB. Designed a
fully-static target: Pages frontend + R2 media (no per-file limit, free egress) +
read-APIs baked to JSON + client-side pick + `/api/alteration` ported to JS.
Operator chose: both tiers irrelevant→dropped; path `/human-experience-simulator`
with apex redirect; no cross-session cache (initially). Spec + plan written under
`docs/superpowers/`. Operator declared the WHOLE project exempt from flotilla/PPE/§9.
**2. Executed the plan (5 tasks).** Client-side alteration engine (`alteration.js`
+ node/py parity tests); config-driven `mediaBase` + baked-JSON boot + client pick +
`crossOrigin` for the WebGL dream shader; `tools/build_static.py` (dist/ + manifest-
only ~2.3 GB media tree + `_redirects`); Cloudflare deploy artifacts; static-build
E2E. Hit + cleanly extracted a parallel session's in-flight "Run simulation" work
that got swept into a commit (operator chose extraction).
**3. Live deploy.** Installed wrangler; operator did `wrangler login`. Created R2
bucket `human-experience-simulator-media`, CORS (wrangler's `{rules:[...]}` schema,
not S3-style), bound `static.benstull.art` via CLI (zone-id from API), parallel
604-file upload (no `wrangler r2 sync` exists). Created + deployed Pages project;
operator bound `benstull.art`. **Live.**
**4. Debugging (systematic).** "Morphs not showing" = upload still in progress
(transitions sort last) → fixed by parallel upload. "Video + audio not playing" →
drove the LIVE site with Playwright: video fine, **audio 404'd** because
`scaleAudioUrl` hardcoded `/media/audio/` instead of `mediaBase()` — fixed.
"Still no video after hard refresh" → **stale cached JS** (Pages caches .js
max-age=14400, ignores `_headers`) → fixed with content-versioned asset URLs
(`app.js?v=<hash>`) + no-cache `_headers`. Private-window confirmed fresh code worked.
**5. Preload algorithm.** "Next altitude's video doesn't start" = a buggy
hold-last-frame settle gate I'd added (gated playLoop behind a canplay that doesn't
re-fire) → reverted. Operator specified the algorithm: gate on 1 clip/altitude +
connecting morphs (~126 MB, ~10-20s — measured), then grow the pool in the
background; the random pick is restricted to fully-loaded (eligible) clips so
navigation never stalls and new clips are reachable only once fully downloaded.
Built `preload.js` (pure planner + node tests), wired phase-1 gate + eligible picks.
**6. Worktree isolation enforcement.** The shared checkout caused branch-switch
chaos (my commits landed on a switched-to `main`). Operator: "make sure every
session works off a fresh worktree not in the main directory." Built
`.claude/settings.json` (worktree.bgIsolation + baseRef:fresh) + a PreToolUse hook
(`worktree-guard.sh`) that DENIES edits to repo files while in the main checkout +
SessionStart notice → EnterWorktree. Proven live (blocked my own edit); fixed an
over-broad version that blocked external files too. Dogfooded the merge of guard +
preload onto origin/main IN an isolated worktree.
## Cut state
- **Live:** https://benstull.art/human-experience-simulator/ — Pages + R2, preload
algorithm, audio fixed, cache-versioned. Operator confirmed video+audio work.
- **origin/main (`a4d3d83`+):** has the Cloudflare publish, preload algorithm, and
worktree-isolation guard, merged cleanly with parallel a11y work.
- Plan archived in-repo (`docs/superpowers/plans/2026-06-30-cloudflare-static-publish.md`),
on origin/main. **No `content` repo** → plan stays in-repo (app.json gap).
- Local `main` checkout is behind origin/main (parallel session actively deploying).
- Backup branches on origin: `chore/worktree-isolation`, `fix/preload-gate`,
`design/cloudflare-static-publish` (content all merged to main; safe to delete).
## Pipeline (§9) status
Operator-set Cloudflare exception (no flotilla/PPE). localhost + **E2E green** (33
node tests; 13 altitude-lock + 2 static-build Playwright); no PPE (exempt); **prod =
live** via `make deploy`. Repeatable deploy: `make deploy` / `make deploy-media`
(`deploy/cloudflare/`).
## Operator plate (next session)
- By-eye/ear review of the live site: load time, video, audio, rapid-altitude
navigation smoothness (eligible-pick growth), the preload behavior under real
network. The hold/grow path only triggers when media isn't cached — needs eyes.
- Restart any still-running sessions so they pick up the worktree guard + move to
worktrees. Clean up backup branches if desired.
## Deferred decisions
- Estimated session start time (no tracked init) — used 2026-06-30T06-30.
- Did NOT reconcile local `main` (behind origin/main) — a parallel session is
actively deploying origin/main; left the shared checkout untouched to avoid
disrupting it.
- Cross-session Service-Worker disk cache and a 720p base tier were proposed and
DEFERRED (morphs already 720p; bases dominate load but per-altitude growth covers it).
@@ -0,0 +1,76 @@
# Session 0037.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T10-42 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: aa7ac424-d797-4918-8837-2a904441d91e
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/session-0037-altitude-snap-on-release
> End: 2026-06-30T10-52 (PST)
> Status: **FINALIZED.**
## Summary
Single-task UX session. Made the altitude dial **snap to the closest altitude on
drag release** so a grab-and-release always lands on a discrete altitude, just
like a tap/label-click.
### Arc
1. Claimed session 0037 (two stale `--INPROGRESS` placeholders 0015/0017 noted,
not live). Worked in an isolated worktree
(`session-0037-altitude-snap-on-release`) branched off `origin/main` (local
`main` was 7 behind).
2. Located the behavior: `onDialUp` (`simulator/static/app.js`) held the live
blend wherever a real turn stopped — the "no auto-complete" continuous-encoder
model. Tap path (`moved < 6``jumpToScale`) and wheel/label auto-scrub were
already snap-to-altitude.
3. TDD: added an `altitude-lock.spec.ts` e2e ("releasing a partial drag snaps to
the closest altitude") — confirmed RED (held pos never became integer).
4. Implemented: a real turn (`moved >= 6`) now `autoScrub(Math.round(pos))`
short scrub to the nearest altitude, settle + lock (frac 0). Reduced motion is
handled inside `autoScrub` (jump-to-target). GREEN.
5. Verified: full altitude-lock (14) + a11y + i18n e2e suites green. Committed,
pushed, opened PR #31, merged to `main` (`5696b94`).
### Pipeline (§9)
This app is **exempt** from flotilla/PPE/§9 prod gate — public target is static
Cloudflare (Pages + R2). Change is merged to `main`; **operator redeploy** of the
static build is the remaining ship step. No plan artifact archived: the change
was a trivial leaf `task`, planned inline (no separate plan file; app has no
content repo).
### Next session
Operator by-eye review of the snap feel on the live sim, then redeploy to
Cloudflare.
## Launch prompt
> When the user is dragging the slider (rather than clicking on an altitude),
> let's transition to the closest altitude when they let go. That way, the user
> will always land on an altitude as if they clicked one, even if they're
> grabbing and releasing the slider.
## Plan
> Anchor: leaf `task` (UX behavior tweak — directly plan-eligible, R2b).
Snap-on-release for the altitude dial. Today `onDialUp` (app.js) holds the live
blend wherever a drag stops (the "no auto-complete" continuous-encoder model).
Change: after a real turn (`moved >= 6`), `autoScrub(Math.round(pos))` so the
knob settles on the CLOSEST altitude (frac 0 → lock), identical to a tap/click.
Tap path (`moved < 6``jumpToScale`) unchanged. Reduced-motion is handled by
`autoScrub` (jump-to-target, no tween).
Verification: new e2e in `altitude-lock.spec.ts` ("releasing a partial drag
snaps to the closest altitude") — TDD red→green; full altitude-lock (14) + a11y
+ i18n suites green. Project is exempt from §9/PPE (static Cloudflare target);
ships branch → PR → merge → operator redeploy.
## Deferred decisions
_Autonomous-mode low-confidence calls the driver made and would have
liked operator input on. Appended as the session runs; surfaced at
finalize. Empty if none._
@@ -0,0 +1,102 @@
# Session 0038.0 — Transcript
> App: human-experience-filter-art
> Start: 2026-06-30T12-25 (PST)
> Type: planning-and-executing
> Posture: yolo
> Claude-Session: 52b811fa-0926-4619-ae31-3c71d489c686
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
> End: 2026-06-30T13-20 (PST)
> Status: **FINALIZED.**
## Summary
Added a **Playback Speed slider** to the simulator's Output panel, replacing the
"Reduce motion" toggle. Designed (brainstorm → spec), built TDD-first, shipped to
`main`, then **revised the scale after operator eyeball**: the initial 2×…2×
range with a manual reverse loop jittered in a real browser (base loop clips
aren't all-intra), so it was narrowed to **0×–4× forward** and the reverse
machinery removed.
### Arc
1. **Brainstorm** (no tracked init this session — claimed s0038 at finalize).
Surfaced the real tradeoff: "Reduce motion" is the a11y safety control
(`isReduced()`), doing three things beyond freezing the loop (OS-pref default,
instant transitions, photosensitivity floor). Two operator decisions via
AskUserQuestion: **keep a11y but move it** (derive from OS
`prefers-reduced-motion`, no visible toggle) and **implement a true manual
reverse loop** for the negative range.
2. **Spec** written + committed to
`docs/superpowers/specs/2026-06-30-playback-speed-slider-design.md`.
3. **Build (round 1, 2…2 + reverse), TDD:** worked in worktree
`feat/playback-speed-slider` (off `origin/main`). Pure node-tested
`HEFScrub.reverseStep` helper (RED→GREEN); slider + datalist in `index.html`;
`applyPlaySpeed` + rAF reverse loop in `app.js`; `isReduced()` re-derived from
OS pref; retired `hef.reduceMotion`; i18n `speed.label` + reworded `warn.body`.
Verified 14/14 node unit, 13/13 a11y e2e (Task 5 rewritten), static-build e2e
2/2 (built the dist bundle to confirm). Merged to `main` (`df8b245`) —
concurrent session pushed mid-merge, so rebased onto the new tip and pushed.
4. **Operator eyeball:** reverse was jittery; operator chose **0×–4×** as the
scale.
5. **Build (round 2, 0×–4× forward):** worktree `feat/speed-forward-0-4x`.
Removed the reverse rAF loop + `reverseStep` helper + its 4 unit tests;
slider `min=0 max=4`; datalist ticks at 0.5; spec annotated with a revision
note. Verified 10/10 node unit, 12/12 a11y e2e (against the actually-served
worktree code). Merged to `main` (`e064a2e`, clean FF) and pushed.
6. Served the merged code locally at `http://localhost:8099/` for re-view both
rounds.
### Pipeline (§9)
This app is **exempt** from flotilla/PPE/§9 prod gate — public target is static
Cloudflare (Pages + R2). Change is merged to `main`; **operator redeploy** of the
static build is the remaining ship step. E2E browser tests (Playwright) were run
locally and are green. No plan artifact archived: design lived in the spec
(already in this repo's `specs/`, merged); app has no `content` repo.
### Next session
Operator by-eye review of the 0×–4× speed feel on the live sim, then redeploy to
Cloudflare to publish it.
## Launch prompt
> Let's add a "Playback Speed" slider that goes from -2x (which would play
> backwards) to 2x, with increments of 0.25. This only applies to the altitude
> videos, not the morph videos. This can replace the "Reduce motion" toggle
_(Mid-session the operator refined: continuous drag rather than 0.25 detents;
then, after eyeballing reverse jitter, narrowed the scale to 0×–4× forward.)_
## Plan
> Anchor: leaf `feature`/`task` (UI control on an existing surface). Design →
> spec → TDD build → ship branch → PR/merge → operator redeploy.
Continuous Playback Speed slider driving ONLY the resting altitude loop video
(`#vid-loop`); morph/transition video (`#vid`) is scrub-driven and untouched.
Reduced-motion accessibility preserved but derived silently from OS
`prefers-reduced-motion` (slider disabled when active). Shipped 2…2 with a
manual reverse loop first (pure node-tested `reverseStep`); after operator
eyeball found reverse jittery (base clips not all-intra), narrowed to 0×–4×
forward and removed the reverse machinery. Verification: node unit + Playwright
a11y e2e + static-build e2e, all green per round. Project exempt from §9/PPE
(static Cloudflare).
## Deferred decisions
- **Direct FF-merge to `main` (no PR).** Operator explicitly said "Merge to
main," so both rounds landed via fast-forward (round 1 rebased over a
concurrent push) rather than opening a Gitea PR. Alternative: branch → PR →
merge (§5.4 norm). Operator-directed, so low risk.
- **Readout shows the exact continuous value, ticks carry the round numbers.**
The slider value is shown as e.g. `1.37×`; the "nice" increments (0.5 marks)
live as `<datalist>` ticks rather than snapping the value. My call within the
approved design; operator approved the spec.
- **Datalist tick spacing 0.5 over 04** (vs 0.25 over 2…2 in round 1). Cosmetic
choice to avoid clutter on the wider forward range. My call.
- **Reverse fully removed, not kept as a smaller range.** Operator said "just go
0x - 4x as the scale," so the `reverseStep` helper + tests were deleted rather
than parked. If reverse is wanted later, re-encode base loop clips all-intra
first (noted in memory + spec revision).
+15
View File
@@ -97,5 +97,20 @@
},
"0033": {
"title": ""
},
"0034": {
"title": ""
},
"0035": {
"title": ""
},
"0036": {
"title": ""
},
"0037": {
"title": ""
},
"0038": {
"title": ""
}
}
@@ -1,4 +0,0 @@
{
"status": "passed",
"failedTests": []
}
+72 -26
View File
@@ -1,12 +1,20 @@
import { test, expect } from "@playwright/test";
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();
@@ -18,7 +26,8 @@ test.describe("Task 1 — Output panel layout", () => {
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
await page.goto("/");
const pick = page.locator(".lang-pick");
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();
@@ -27,43 +36,79 @@ test.describe("Task 1 — Output panel layout", () => {
});
});
test.describe("Task 4 — Photosensitivity warning gate", () => {
test("motion warning gate shows once, then is remembered", async ({ page }) => {
test.describe("Task 4 — Photosensitivity notice", () => {
test("the welcome screen carries the heads-up notice and the Speed control", async ({ page }) => {
await page.goto("/");
const gate = page.locator("#motion-warning");
await expect(gate).toBeVisible({ timeout: 30000 }); // shown after media preload + splash fade
await page.locator("#motion-warning-continue").click();
await expect(gate).toBeHidden();
// Reload in the SAME context: dismissal persisted, gate stays hidden.
await page.reload();
await expect(page.locator("#motion-warning")).toBeHidden();
await expect(page.locator("#run-sim")).toBeVisible();
// 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 — Reduced motion", () => {
test("reduce-motion toggle pauses video playback", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off
test.describe("Task 5 — Playback speed slider + Reduce motion", () => {
test("Speed is a 02x forward slider; the loop follows it (default 1x)", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await page.locator("#motion-warning-continue").click().catch(() => {});
const rm = page.locator("#reduce-motion");
await expect(rm).not.toBeChecked(); // default-off under no-preference
await page.locator("#run-sim").click();
await page.waitForTimeout(500);
await 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(false); // experience running → video plays
await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label
await expect(rm).toBeChecked();
await expect
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
.toBe(true); // reduced motion → frozen
.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");
@@ -82,6 +127,7 @@ test.describe("Task 6 — Keyboard + ARIA dial", () => {
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();
+50 -30
View File
@@ -12,15 +12,13 @@ import { test, expect, Page } from "@playwright/test";
async function boot(page: Page) {
await page.addInitScript(() => {
localStorage.setItem("hef.devMode", "1");
localStorage.setItem("hef.motionWarnDismissed", "1"); // returning visitor: skip the photosensitivity gate
});
await page.goto("/");
// Wait for the "Loading Universe" preload to finish (splash gets class "done").
await page.waitForFunction(
() => 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");
}
@@ -86,28 +84,8 @@ test("audio is a 0-10 level dial", async ({ page }) => {
expect(ctl.max).toBe("10");
});
test("boots silent (video off, audio 0); turning video on couples audio to 2/10 and dismisses the Run prompt", async ({ page }) => {
await boot(page);
// Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites.
expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false);
expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
// "Run simulation" is revealed once preload finishes — the obvious starting point.
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(false);
// Turning Video on directly (a click gesture) lifts the audio dial to a gentle 2/10
// and dismisses the Run prompt.
await enableVideo(page);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
});
test("the Run simulation button starts video, snaps audio to 2/10, and dismisses itself", async ({ page }) => {
await boot(page);
await page.click("#run-sim");
await expect.poll(() => page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(true);
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
});
// (Entry via the welcome screen — defaults, Launch, control carry-over, State B —
// is covered in welcome.spec.ts.)
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
await boot(page);
@@ -130,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())!;
@@ -276,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 });
@@ -293,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
});
+9 -6
View File
@@ -7,22 +7,25 @@ import { test, expect, Page } from "@playwright/test";
async function boot(page: Page) {
await page.goto("/");
// initLanguage() populates the select during startup (before media preload).
await expect(page.locator("#lang-select option")).toHaveCount(4);
// initLanguage() populates BOTH selects during startup (before media preload).
// The welcome select is the visible one (the panel is hidden until entry).
await expect(page.locator("#welcome-lang option")).toHaveCount(4);
}
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
await boot(page);
const sel = page.locator("#lang-select");
const sel = page.locator("#welcome-lang"); // the welcome screen's selector
// English selected by default
// English selected by default (both selects mirror activeLang)
await expect(sel).toHaveValue("en");
await expect(page.locator("#lang-select")).toHaveValue("en");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
await expect(page.locator("html")).toHaveAttribute("lang", "en");
// switch to Spanish -> chrome changes, <html lang> updates
// switch to Spanish -> chrome changes (incl. the hidden panel), <html lang> updates
await sel.selectOption("es");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
await expect(page.locator("#lang-select")).toHaveValue("es"); // panel select mirrors
await expect(page.locator("html")).toHaveAttribute("lang", "es");
// Japanese
@@ -31,6 +34,6 @@ test("language dropdown localizes chrome and resets to English on reload", async
// reload returns to English (session-only, no persistence)
await page.reload();
await expect(page.locator("#lang-select")).toHaveValue("en");
await expect(page.locator("#welcome-lang")).toHaveValue("en");
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
});
+7 -2
View File
@@ -6,10 +6,15 @@ import { test, expect } from "@playwright/test";
// aren't looping"). This asserts the `ended` safety net recovers playback.
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
await page.goto("/");
// 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 });
// 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 () => {
+11 -2
View File
@@ -1,14 +1,22 @@
import { test, expect } from "@playwright/test";
import { test, expect, Page } from "@playwright/test";
// Runs against the BUILT static site: dist/ served on :8077, media (CORS) on :8078
// by serve-static.mjs. Build with `--media-base http://localhost:8078/` so config.js
// points media cross-origin — the production CORS path, locally. See README.md.
const APP = "http://localhost:8077/human-experience-simulator/";
const APP = "http://localhost:8077/human-machine-strata/";
// Enter the simulation via the welcome screen (Video defaults on → media plays).
async function enter(page: Page) {
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 30_000 });
await page.locator("#welcome-launch").click();
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
}
test("boots fully static — no /api calls, ring + clips load from baked JSON", async ({ page }) => {
const apiCalls: string[] = [];
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
await page.goto(APP);
await enter(page);
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
expect(apiCalls, "static build must not call /api/*").toEqual([]);
});
@@ -17,6 +25,7 @@ test("dream shader runs on cross-origin media without tainting the GL texture",
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(String(e)));
await page.goto(APP);
await enter(page);
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
// crank the Feel (Right) knob to engage the dream, let a few frames render
await page.evaluate(() => {
+114
View File
@@ -0,0 +1,114 @@
import { test, expect, Page } from "@playwright/test";
// The Welcome screen is the universal entry gate (see
// docs/superpowers/specs/2026-06-30-welcome-screen-design.md). It carries the
// photosensitivity heads-up, the four output controls (Video on / Reduce motion
// off / Audio 2 / English), a "Launch Simulator" button, and the "Loading
// Universe" progress. The right panel is hidden until the visitor enters.
const ready = (page: Page) =>
page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
// Set a range input's value + fire `input` (Playwright's fill() doesn't drive ranges).
async function setRange(page: Page, id: string, value: string) {
await page.evaluate(({ id, value }) => {
const el = document.getElementById(id) as HTMLInputElement;
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
}, { id, value });
}
test("welcome screen shows on load with the expected defaults; the panel is hidden", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/");
await expect(page.locator("#welcome")).toBeVisible();
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
// Defaults: Language English, Speed 1, Audio 2, Reduce motion off (no OS pref).
await expect(page.locator("#welcome-lang")).toHaveValue("en");
await expect(page.locator("#welcome-play-speed")).toHaveValue("1");
await expect(page.locator("#welcome-play-speed-val")).toHaveText("1.00×");
await expect(page.locator("#welcome-audio")).toHaveValue("2");
await expect(page.locator("#welcome-audio-val")).toHaveText("2");
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked();
// Heads-up + Launch button present; "Loading Universe" progress present.
await expect(page.locator("#welcome-title")).toBeVisible();
await expect(page.locator("#welcome-launch")).toHaveText("Launch Simulator");
await expect(page.locator(".welcome-loading")).toBeVisible();
// The right panel is hidden behind the welcome screen.
await expect(page.locator(".panel")).toBeHidden();
});
test("the language picker re-renders the welcome text live", async ({ page }) => {
await page.goto("/");
const sel = page.locator("#welcome-lang");
await expect(sel).toHaveValue("en");
await expect(page.locator("#welcome-launch")).toHaveText("Launch Simulator");
await sel.selectOption("es");
await expect(page.locator("#welcome-launch")).toHaveText("Iniciar simulador");
await expect(page.locator("#welcome-title")).toHaveText(/Atención/);
await expect(page.locator("html")).toHaveAttribute("lang", "es");
// the hidden panel selector mirrors the choice
await expect(page.locator("#lang-select")).toHaveValue("es");
await sel.selectOption("ja");
await expect(page.locator("#welcome-launch")).toHaveText("シミュレーターを起動");
});
test("Launch when already loaded enters the simulation directly and reveals the panel", async ({ page }) => {
await page.goto("/");
await ready(page);
await page.locator("#welcome-launch").click();
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
await expect(page.locator(".panel")).toBeVisible();
// Welcome assumes Video on; Audio default 2 carried into the panel.
await expect(page.locator("#visual")).toBeChecked();
await expect(page.locator("#audio")).toHaveValue("2");
});
test("welcome control values carry into the panel on entry", async ({ page }) => {
await page.goto("/");
await ready(page);
await setRange(page, "welcome-audio", "5");
await setRange(page, "welcome-play-speed", "2"); // mirrors into the panel slider live
await page.locator("#welcome-launch").click();
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
await expect(page.locator("#audio")).toHaveValue("5");
await expect(page.locator("#play-speed")).toHaveValue("2");
await expect(page.locator("#visual")).toBeChecked(); // welcome assumes video on
});
test("Launch while still loading shows the centered loader with controls, then auto-enters", async ({ page }) => {
// Stall media so phase-1 preload hasn't finished when Launch is pressed.
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
await page.route("**/media/**", async (route) => {
await gate;
await route.continue();
});
await page.goto("/");
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
await page.locator("#welcome-launch").click();
// State B: messaging + button hidden, controls remain, loader shown (centered).
await expect(page.locator("#welcome")).toHaveClass(/loading/);
await expect(page.locator("#welcome-title")).toBeHidden();
await expect(page.locator("#welcome-launch")).toBeHidden();
await expect(page.locator(".welcome-controls")).toBeVisible();
await expect(page.locator(".welcome-loading")).toBeVisible();
// Let media through → universe becomes ready → auto-enter.
release();
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 30_000 });
await expect(page.locator(".panel")).toBeVisible();
});
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>About — Human Experience Filter</title>
<title>About — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
@@ -15,7 +15,7 @@
<section>
<h2>For everyone</h2>
<p>The <em>Human Experience Filter</em> is built to be accessible to — and
<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
+170 -91
View File
@@ -312,8 +312,7 @@ function loadLoop(clip, landFrame) {
}
function playLoop() {
if (isReduced()) return; // reduced motion holds a still frame — never auto-play the loop
if (loopVid.paused) loopVid.play().catch(() => {});
applyPlaySpeed(); // honor the chosen speed (forward, or 0x = freeze) — independent of reduce-motion
}
function ensureClipMedia() {
@@ -1016,6 +1015,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:
@@ -1023,8 +1028,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));
if (!isReduced()) playLoop(); // reduced motion holds the landing frame; else run the base loop
else { vid.pause(); loopVid.pause(); }
playLoop(); // run the resting loop at the chosen Speed (0x freezes it) — independent of reduce-motion
showActiveSource();
setNeedle(ringIndex * dialStep());
renderScaleReadout();
@@ -1076,7 +1080,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.
@@ -1115,60 +1123,91 @@ function onDialKey(e) {
// 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 WARN_KEY = "hef.motionWarnDismissed";
const SPEED_KEY = "hef.playSpeed";
const RM_KEY = "hef.reduceMotion";
let devMode = false;
let devStatsTimer = null;
// --- Reduced motion (freeze-to-stills) ---------------------------------------
// Default follows the OS `prefers-reduced-motion` until the visitor chooses, then
// their choice persists. When ON: video holds a frame (paused), auto transitions
// jump instantly instead of tweening — knob changes still re-grade the still.
// --- Playback speed -----------------------------------------------------------
// The Playback Speed slider (0x4x forward) drives ONLY the resting altitude loop
// video (#vid-loop); the morph/transition video (#vid) is scrub-driven and is
// never speed-controlled. 0x freezes the loop on its current frame. (Reverse was
// dropped: base loop clips aren't all-intra, so reverse seeking jitters.)
const SPEED_EPS = 0.02; // speed below this = freeze (treat as 0)
let playSpeed = 1;
// Apply the chosen slider speed to the resting loop video. When video output is
// off we just keep the forward rate sane for when it resumes.
function applyPlaySpeed() {
if (!videoEverOn || !$("visual").checked) {
loopVid.playbackRate = Math.max(0.0625, playSpeed || 1);
return;
}
if (playSpeed > SPEED_EPS) {
loopVid.playbackRate = playSpeed;
if (loopVid.paused) loopVid.play().catch(() => {});
} else {
loopVid.pause(); // 0x → freeze on the current frame
}
}
// Both the welcome screen's slider and the panel's drive the same playSpeed; each
// mirrors the other and the shared "1.00×" readout.
const SPEED_SLIDERS = ["welcome-play-speed", "play-speed"];
const SPEED_VALS = ["welcome-play-speed-val", "play-speed-val"];
function syncSpeedSliders() {
for (const id of SPEED_SLIDERS) { const s = $(id); if (s && s.value !== String(playSpeed)) s.value = String(playSpeed); }
}
function updateSpeedLabel() {
const txt = playSpeed.toFixed(2) + "×";
for (const id of SPEED_VALS) { const el = $(id); if (el) el.textContent = txt; }
}
function initPlaySpeed() {
let stored = null;
try { stored = localStorage.getItem(SPEED_KEY); } catch (_) {}
playSpeed = stored === null ? 1 : (parseFloat(stored) || 0);
syncSpeedSliders();
for (const id of SPEED_SLIDERS) {
const slider = $(id);
if (!slider) continue;
slider.addEventListener("input", () => {
playSpeed = parseFloat(slider.value) || 0;
try { localStorage.setItem(SPEED_KEY, String(playSpeed)); } catch (_) {}
syncSpeedSliders();
updateSpeedLabel();
applyPlaySpeed();
});
}
updateSpeedLabel();
}
// --- Reduce motion (morph transitions) ----------------------------------------
// A visible toggle (welcome + panel, mirrored) that turns OFF the morph/transition
// videos: when ON, altitude changes are instant cuts instead of an animated morph
// tween (see autoScrub's reduced branch). It does NOT touch the resting loop —
// that is the Speed slider's job (Speed 0 freezes the loop independently).
// Defaults from the OS `prefers-reduced-motion` setting, then remembers the
// visitor's choice.
let reduceMotion = false;
function isReduced() { return reduceMotion; }
const RM_BOXES = ["welcome-reduce-motion", "reduce-motion"];
function syncReduceMotionBoxes() {
for (const id of RM_BOXES) { const b = $(id); if (b && b.checked !== reduceMotion) b.checked = reduceMotion; }
}
function initReduceMotion() {
const box = $("reduce-motion");
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";
if (box) {
box.checked = reduceMotion;
syncReduceMotionBoxes();
for (const id of RM_BOXES) {
const box = $(id);
if (!box) continue;
box.addEventListener("change", () => {
reduceMotion = box.checked;
reduceMotion = box.checked; // governs FUTURE transitions (cut vs morph)
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
applyReduceMotion();
syncReduceMotionBoxes();
});
}
applyReduceMotion();
}
function applyReduceMotion() {
if (reduceMotion) {
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
vid.pause();
loopVid.pause();
} else if (videoEverOn && $("visual").checked) {
playLoop(); // resume the held loop on opt-out
}
}
// One-time photosensitivity notice shown over the stage before the experience
// begins. Independent of media load; gated visually by the loading splash, then
// dismissed-once (persisted) so returning visitors go straight to "Run simulation".
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");
const btn = $("run-sim");
if (btn) btn.focus({ preventScroll: true }); // move focus to the entry point
}, { once: true });
}
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
@@ -1480,35 +1519,74 @@ 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(); }
}
function updateWelcomeAudioLabel() {
const el = $("welcome-audio-val");
if (el) el.textContent = String(+($("welcome-audio") || {}).value || 0);
}
// 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");
}
}
// The gentle audio level the experience starts at — coupled in on the first
// Video-on (via the Run simulation button or the Video toggle directly).
const START_AUDIO_LEVEL = "2";
// Begin the experience: Video on, audio snapped to the gentle start level, and
// the "Run simulation" prompt dismissed. Audio is set + played in THIS gesture so
// it unlocks on Safari (which blocks play() outside a user gesture). One flip = the
// full experience at a gentle level. Idempotent across re-toggles via videoEverOn.
function beginExperience() {
videoEverOn = true;
const btn = $("run-sim");
if (btn) btn.classList.add("hidden");
$("audio").value = START_AUDIO_LEVEL;
updateAudioLevelLabel();
applyAudio();
function initWelcome() {
updateWelcomeAudioLabel();
const wa = $("welcome-audio");
if (wa) wa.addEventListener("input", updateWelcomeAudioLabel);
const launch = $("welcome-launch");
if (launch) launch.addEventListener("click", onLaunch);
}
async function main() {
@@ -1518,8 +1596,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
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
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);
@@ -1528,15 +1608,9 @@ async function main() {
// play() outside a user gesture).
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
updateAudioLevelLabel();
// "Run simulation" button: the obvious starting point. Turns Video on, snaps
// audio to the start level, and dismisses itself — all in this click gesture.
$("run-sim").addEventListener("click", () => {
$("visual").checked = true; // setting .checked does NOT fire "change", so we drive the rest here
beginExperience();
debounced();
});
// Video toggle: the FIRST time video turns on (without the Run simulation button),
// begin the experience too — couple audio in and dismiss the prompt.
// 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) beginExperience();
debounced();
@@ -1553,36 +1627,40 @@ async function main() {
// + 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()));
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
$("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage
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
maybeShowMotionWarning(); // first-visit photosensitivity notice, above the button
// 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
// press "Run simulation" (or turn Video on directly) — that click IS the gesture, and
// beginExperience() lifts audio to the start level and plays 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`.
@@ -1595,6 +1673,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);
+2 -2
View File
@@ -3,12 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Label Author Mode</title>
<title>Human/Machine Strata — Label Author Mode</title>
<link rel="stylesheet" href="/style.css" />
<link rel="stylesheet" href="/author.css" />
</head>
<body class="author">
<header><h1>HEF — Label Author Mode</h1>
<header><h1>Human/Machine Strata — Label Author Mode</h1>
<p class="hint">Scrub a pool clip · drag a box on a subject · run the tracker · author the tiers · save to the manifest. Geometry is propagated; <strong>semantics are hand-authored</strong> (content-pipeline §11.5). <a href="/">← back to preview</a></p>
</header>
<main>
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Credits — Human Experience Filter</title>
<title>Credits — Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body class="credits-page">
@@ -19,7 +19,7 @@
<section class="declaration">
<h2>License &amp; reuse</h2>
<p>
The <em>Human Experience Filter</em> alters its source footage in real time
<em>Human/Machine Strata</em> alters its source footage in real time
(overlays, an emotion/affect channel, and a WebGL “dream” shader), so every
frame you see is a <strong>derivative</strong> of the original clip.
</p>
+6 -7
View File
@@ -17,24 +17,23 @@
// Visitor-facing control chrome. Keys match `data-i18n` attributes in index.html.
const UI_STRINGS = {
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"app.title": { en: "Human/Machine Strata", es: "Human/Machine Strata", fr: "Human/Machine Strata", ja: "Human/Machine Strata" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", ja: "宇宙を読み込み中" },
"run.button": { en: "Run simulation", es: "Iniciar simulación", fr: "Lancer la simulation", ja: "シミュレーションを開始" },
"welcome.launch": { en: "Launch Simulator", es: "Iniciar simulador", fr: "Lancer le simulateur", ja: "シミュレーターを起動" },
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
"output.audio": { en: "Audio", es: "Audio", fr: "Audio", ja: "音声" },
"altitude.legend":{ en: "Altitude", es: "Altitud", fr: "Altitude", ja: "高度" },
"altitude.hint": { en: "Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.", es: "Gira el dial (arrástralo o desplázate) para cambiar la altitud — sin fin: tras lo más profundo vuelve a lo más alto. Haz clic en una etiqueta para saltar.", fr: "Tournez le cadran (glissez ou faites défiler) pour changer daltitude — sans fin : après le plus profond, on revient au plus haut. Cliquez sur une étiquette pour y aller.", ja: "ダイヤルを回して(ドラッグまたはスクロール)高度を変えます — 無限ループ:最も深い先は最も高い所へ戻ります。ラベルをクリックでそこへ移動。" },
"knobs.legend": { en: "Experience knobs (04)", es: "Mandos de experiencia (04)", fr: "Boutons dexpérience (04)", ja: "体験つまみ(0〜4" },
"knobs.legend": { en: "Experience knobs", es: "Mandos de experiencia", fr: "Boutons dexpérience", ja: "体験つまみ" },
"knobs.think": { en: "Think", es: "Pensar", fr: "Penser", ja: "考える" },
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
"speed.label": { en: "Video speed", es: "Velocidad de vídeo", fr: "Vitesse vidéo", ja: "映像速度" },
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
"warn.title": { en: "Heads up — motion & flashing" },
"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" },
"warn.title": { en: "Heads up — motion & flashing", es: "Atención — movimiento y destellos", fr: "Attention — mouvement et flashs", ja: "ご注意 — 動きと点滅" },
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch — altitude changes will cut instantly instead of animating.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa «Reducir movimiento» abajo antes de iniciar — los cambios de altitud serán cortes instantáneos en vez de animarse.", fr: "Cette expérience contient des mouvements continus, des flashs et des images changeantes. Si vous êtes sensible au mouvement ou aux lumières clignotantes, activez « Réduire les animations » ci-dessous avant de lancer — les changements daltitude seront des coupes instantanées au lieu de sanimer.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください — 高度の変化はアニメーションせず瞬時に切り替わります。" },
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
+57 -26
View File
@@ -3,18 +3,48 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HEF — Alteration Simulator</title>
<title>Human/Machine Strata</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="loading">
<div class="loading-inner">
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
<div class="loading-bar"><div id="loading-fill"></div></div>
<!-- Welcome screen: the universal entry gate. State A (.welcoming) shows the
heads-up + controls + Launch button with the loader at the bottom; State B
(.loading, after Launch while media is still loading) hides the messaging +
button, keeps the controls, and centers the loader. Removed on entry. -->
<div id="welcome" class="welcoming" role="dialog" aria-modal="true" aria-labelledby="welcome-title">
<div class="welcome-inner">
<div class="welcome-message">
<h2 id="welcome-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch — altitude changes will cut instantly instead of animating.</p>
</div>
<div class="welcome-controls">
<label class="lang-pick">🌐
<select id="welcome-lang" aria-label="Language"></select>
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Video speed</span>
<input type="range" id="welcome-play-speed" min="0" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
<span id="welcome-play-speed-val">1.00×</span>
</label>
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
<input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" />
<span id="welcome-audio-val">2</span>/10
</label>
<label class="dev-switch" for="welcome-reduce-motion">
<input type="checkbox" id="welcome-reduce-motion" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
</label>
</div>
<button type="button" id="welcome-launch" class="run-sim" data-i18n="welcome.launch">Launch Simulator</button>
<div class="welcome-loading">
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
<div class="loading-bar"><div id="loading-fill"></div></div>
</div>
</div>
</div>
<header>
<h1 data-i18n="app.title">Human Experience Filter — Alteration Preview</h1>
<h1 data-i18n="app.title">Human/Machine Strata</h1>
<a href="about.html" class="credits-link" data-i18n="about.link">About</a>
<a href="credits.html" class="credits-link" data-i18n="credits.link">ⓘ Credits &amp; licenses</a>
</header>
@@ -30,19 +60,21 @@
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
<div id="black" class="black hidden"></div>
<button type="button" id="run-sim" class="run-sim hidden" data-i18n="run.button">Run simulation</button>
<div id="motion-warning" class="motion-warning hidden" role="dialog" aria-modal="true" aria-labelledby="mw-title">
<div class="mw-card">
<h2 id="mw-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin.</p>
<button type="button" id="motion-warning-continue" class="run-sim" data-i18n="warn.continue">Continue</button>
</div>
</div>
</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>
</fieldset>
<fieldset>
<legend data-i18n="output.legend">Output</legend>
<label class="dev-switch" for="visual">
@@ -54,6 +86,16 @@
<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>
@@ -65,18 +107,7 @@
</fieldset>
<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>
<p class="hint" data-i18n="altitude.hint">Turn the knob (drag it, or scroll) to change altitude — endless: past the deepest it wraps back up to the highest. Click a label to jump there.</p>
</fieldset>
<fieldset>
<legend data-i18n="knobs.legend">Experience knobs (04)</legend>
<legend data-i18n="knobs.legend">Experience knobs</legend>
<label><span data-i18n="knobs.think">Think</span> <input type="range" id="left" min="0" max="4" value="0" /></label>
<label><span data-i18n="knobs.feel">Feel</span> <input type="range" id="right" min="0" max="4" value="0" /></label>
<label><span data-i18n="knobs.mood">Mood — dark ◀ 0 ▶ light</span> <input type="range" id="mood" min="-4" max="4" value="0" /></label>
+46 -25
View File
@@ -76,32 +76,18 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
.black { position: absolute; inset: 0; background: #000; opacity: 1;
z-index: 50; transform: translateZ(0); transition: opacity 200ms ease; }
.hidden { display: none; }
/* "Run simulation" the obvious starting point, shown over the (black) stage once
media is preloaded. Sits above the black cover (z-index 50). Dismissed when the
experience begins (the button, or turning Video on directly). */
/* "Launch Simulator" the single entry button on the welcome screen. Sits in
the welcome overlay's flow (centered column), not absolutely positioned. */
.run-sim {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 60; cursor: pointer; user-select: none;
cursor: pointer; user-select: none;
padding: 0.85rem 2rem; border: 0; border-radius: 999px;
font: 600 18px/1 system-ui, sans-serif; letter-spacing: 0.03em;
color: #04101f; background: linear-gradient(90deg, #4e9cff, #9af);
box-shadow: 0 4px 24px rgba(78, 156, 255, 0.45);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.run-sim:hover { transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
.run-sim:active { transform: translate(-50%, -50%) scale(0.98); }
/* One-time photosensitivity warning, over the stage (above run-sim's z-60). */
.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: #c3d2e8; }
/* The Continue button is centered in flow here, not absolutely positioned. */
.mw-card .run-sim { position: static; transform: none; }
.mw-card .run-sim:hover { transform: scale(1.04); }
.mw-card .run-sim:active { transform: scale(0.98); }
.run-sim:hover { transform: scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
.run-sim:active { transform: scale(0.98); }
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
.dev-switch + .dev-switch { margin-top: 0.45rem; }
/* Globe + language select on one row (the select no longer goes full-width here). */
@@ -114,6 +100,9 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
.panel { flex: 0 0 280px; 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; }
@@ -164,6 +153,10 @@ input[type=range], select { width: 100%; }
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
.audio-level input[type=range] { flex: 1; width: auto; }
#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. */
@@ -190,17 +183,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;
+7 -5
View File
@@ -9,9 +9,9 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
result = build_static(
out, media,
media_base="https://static.benstull.art/",
app_path="/human-experience-simulator",
app_path="/human-machine-strata",
)
app_dir = out / "human-experience-simulator"
app_dir = out / "human-machine-strata"
# frontend present under the app path; dev/author surfaces excluded
assert (app_dir / "index.html").exists()
@@ -25,10 +25,12 @@ def test_build_emits_frontend_baked_json_and_only_referenced_media(tmp_path):
assert not (app_dir / "author.html").exists()
assert not list(app_dir.glob("review*.html"))
# apex + no-slash redirect rules
# apex + no-slash redirect rules, plus the permanent legacy-path redirect
redirects = (out / "_redirects").read_text()
assert "/ /human-experience-simulator/ 308" in redirects
assert "/human-experience-simulator /human-experience-simulator/ 308" in redirects
assert "/ /human-machine-strata/ 308" in redirects
assert "/human-machine-strata /human-machine-strata/ 308" in redirects
assert "/human-experience-simulator/ /human-machine-strata/ 301" in redirects
assert "/human-experience-simulator/* /human-machine-strata/:splat 301" in redirects
# baked JSON matches the API shape
clips = json.loads((app_dir / "clips.json").read_text())
+53 -38
View File
@@ -2,15 +2,16 @@
cleanly when Playwright or its browser binary is absent.
NOTE: headless engines relax BOTH autoplay and GPU compositing, so these assert
the WIRING (boot-silent, video-on couples audio, dialplay/volume, video-off
the WIRING (boot-silent, launch starts video+audio, dialplay/volume, video-off
blanking) real Safari/iOS autoplay and real-GPU compositing still need a device
by-ear/eye check.
The experience BOOTS SILENT video off, audio dial at 0 so nothing plays until a
real user gesture. The "Run simulation" button (or turning Video ON directly) couples
the audio dial up to 2/10 and starts the soundtrack IN that gesture, sidestepping the
browser autoplay block that swallowed an un-gestured auto-start. Audio is a 010 range
dial (not a checkbox); set it by writing `value` + firing an `input` event.
The WELCOME screen is the entry gate: it carries the controls (Video on, Audio 2)
and a "Launch Simulator" button. Until Launch, the panel stays black/silent (panel
Video off, dial 0). Pressing Launch (a click gesture) copies the welcome values
into the panel and starts the soundtrack IN that gesture, sidestepping the browser
autoplay block. Audio is a 010 range dial (not a checkbox); set it by writing
`value` + firing an `input` event.
Install: pip install -e '.[e2e]' && python -m playwright install chromium
"""
@@ -65,15 +66,23 @@ def page(app_url):
pytest.skip(f"chromium not available: {exc}")
pg = browser.new_page()
pg.goto(app_url)
# wait out the "Loading Universe…" splash (it overlays + blocks clicks); the
# experience boots silent (video off, audio 0) as it dismisses.
pg.wait_for_selector("#loading", state="detached", timeout=60000)
# Wait for phase-1 preload to finish; the welcome screen (State A) is showing.
# The panel stays black/silent until the visitor presses Launch.
pg.wait_for_function("window.__hefReady === true", timeout=60000)
yield pg
browser.close()
def _enter(page):
"""Press the welcome screen's Launch button (the entry gesture) and wait for the
overlay to be removed, revealing the panel + running the experience."""
page.click("#welcome-launch")
page.wait_for_selector("#welcome", state="detached", timeout=10000)
def _toggle_visual(page):
"""Click the visible Video toggle track (the checkbox input is hidden)."""
"""Click the visible Video toggle track (the checkbox input is hidden). Only
valid after _enter() the panel is hidden behind the welcome screen."""
page.click("label[for='visual'] .dev-switch-track")
@@ -87,9 +96,18 @@ def _set_audio(page, level):
)
def test_app_boots_silent_video_off(page):
# The experience boots SILENT: Video off, audio dial at 0, screen black, both
# crossfade elements paused — nothing plays until a real user gesture.
def _set_welcome_audio(page, level):
"""Drive the welcome screen's 010 audio dial (before Launch)."""
page.evaluate(
"(lvl) => { const a = document.getElementById('welcome-audio');"
" a.value = String(lvl); a.dispatchEvent(new Event('input', {bubbles:true})); }",
level,
)
def test_panel_silent_until_launch(page):
# Before Launch the panel stays SILENT: panel Video off, audio dial at 0, screen
# black, both crossfade elements paused — nothing plays until the Launch gesture.
assert page.is_checked("#visual") is False
assert page.evaluate("document.getElementById('audio').value") == "0"
assert page.evaluate("document.getElementById('audio-level-val').textContent") == "0"
@@ -100,19 +118,18 @@ def test_app_boots_silent_video_off(page):
) is True
def test_run_simulation_button_shows_after_load(page):
# Once media is preloaded the "Run simulation" prompt is revealed over the (still
# black) stage — the obvious starting point — while the experience stays silent.
page.wait_for_selector("#run-sim:not(.hidden)")
assert page.is_checked("#visual") is False
assert page.evaluate("document.getElementById('audio').value") == "0"
def test_welcome_launch_shown_after_load_with_defaults(page):
# Once media is preloaded the welcome screen shows its Launch button with the
# default controls (Speed 1, Audio 2) while the panel stays silent.
assert page.is_visible("#welcome-launch")
assert page.evaluate("document.getElementById('welcome-play-speed').value") == "1"
assert page.evaluate("document.getElementById('welcome-audio').value") == "2"
def test_run_simulation_button_starts_video_and_audio(page):
# Pressing "Run simulation" (a click gesture) turns Video on, snaps the audio dial
# to 2/10, dismisses itself, and starts the soundtrack IN that gesture.
page.click("#run-sim")
page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed
def test_launch_enters_and_starts_video_and_audio(page):
# Pressing "Launch Simulator" (a click gesture) enters: Video on, the audio dial
# carries the welcome default 2/10, the overlay is removed, the soundtrack plays.
_enter(page)
assert page.is_checked("#visual") is True
page.wait_for_function("document.getElementById('audio').value === '2'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'")
@@ -123,21 +140,17 @@ def test_run_simulation_button_starts_video_and_audio(page):
)
def test_video_on_directly_couples_audio_to_two_and_dismisses_button(page):
# Turning Video on directly (skipping the Run simulation button) ALSO begins the
# experience: audio snaps to 2/10, the soundtrack plays, and the button goes away.
_toggle_visual(page) # off -> on
page.wait_for_function("document.getElementById('audio').value === '2'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '2'")
page.wait_for_selector("#run-sim.hidden", state="attached") # prompt dismissed
page.wait_for_selector("#black.hidden", state="attached") # video showing
page.wait_for_function(
"(() => { const a = document.getElementById('aud');"
" return /\\/media\\/audio\\/.+\\.mp3/.test(a.src) && !a.paused; })()"
)
def test_chosen_audio_level_carries_through_launch(page):
# Lowering the welcome Audio dial before launching carries the chosen level into
# the panel (entry uses the chosen level, not a forced 2).
_set_welcome_audio(page, 4)
_enter(page)
page.wait_for_function("document.getElementById('audio').value === '4'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '4'")
def test_audio_dial_sets_level_and_label(page):
_enter(page)
_set_audio(page, 7)
page.wait_for_function("document.getElementById('audio').value === '7'")
page.wait_for_function("document.getElementById('audio-level-val').textContent === '7'")
@@ -145,6 +158,7 @@ def test_audio_dial_sets_level_and_label(page):
def test_audio_dial_zero_silences(page):
# Level 0 fades both crossfade elements to silence, then pauses them.
_enter(page)
_set_audio(page, 0)
page.wait_for_function(
"(() => { const a = document.getElementById('aud'), b = document.getElementById('aud-b');"
@@ -154,9 +168,9 @@ def test_audio_dial_zero_silences(page):
def test_video_off_blanks(page):
# Turn Video on (boot is off), then off again: that blanks the screen and hides
# Enter (Video defaults on), then turn Video off: that blanks the screen and hides
# the GPU layers.
_toggle_visual(page) # off -> on
_enter(page)
page.wait_for_selector("#black.hidden", state="attached")
_toggle_visual(page) # on -> off
page.wait_for_selector("#black:not(.hidden)")
@@ -169,6 +183,7 @@ def test_soundtrack_fallback_when_ring_lacks_audio(page):
# Client resilience: even if /api/ring omits the per-scale `audio` field, the
# scale-id fallback map keeps a soundtrack playing. Drop the field, re-apply the
# dial, and confirm the cosmos soundtrack still resolves + plays.
_enter(page)
page.evaluate("ring.scales.forEach(s => { delete s.audio; })")
_set_audio(page, 5)
page.wait_for_function(
+9 -4
View File
@@ -74,8 +74,13 @@ def test_credits_page_lists_video_attributions(browser_page):
def test_experience_links_to_credits(browser_page):
page = browser_page
page.goto(page._app_url + "/")
page.wait_for_selector("#loading", state="detached", timeout=60000)
href = page.get_attribute(".credits-link", "href")
assert href == "credits.html"
page.click(".credits-link")
page.wait_for_function("window.__hefReady === true", timeout=60000)
# The Credits link is in the header. Use the href-specific selector (the About
# link shares the .credits-link class), and enter past the welcome screen so the
# header is no longer covered by the overlay before clicking.
link = "a[href='credits.html']"
assert page.get_attribute(link, "href") == "credits.html"
page.click("#welcome-launch")
page.wait_for_selector("#welcome", state="detached", timeout=10000)
page.click(link)
page.wait_for_selector("#video-credits .credit", timeout=30000)
+32 -11
View File
@@ -59,23 +59,44 @@ def _write_config(app_dir: Path, media_base: str) -> None:
)
# The app's original deploy path, kept as a permanent redirect so old links to
# /human-experience-simulator/… still land on the renamed /human-machine-strata/….
LEGACY_APP_PATH = "human-experience-simulator"
def _write_redirects(out: Path, seg: str) -> None:
# Apex → app path, and the no-trailing-slash form → slashed (so RELATIVE asset
# urls resolve under the prefix). Cloudflare Pages `_redirects` syntax.
(out / "_redirects").write_text(
f"/ /{seg}/ 308\n"
f"/{seg} /{seg}/ 308\n"
)
# urls resolve under the prefix). Plus a permanent (301) redirect from the legacy
# path to the current one. Cloudflare Pages `_redirects` syntax.
lines = [
f"/ /{seg}/ 308\n",
f"/{seg} /{seg}/ 308\n",
]
if LEGACY_APP_PATH and LEGACY_APP_PATH != seg:
# Exact root (trailing slash) first — the `/*` splat does NOT match the empty
# case, so the bare /legacy/ needs its own rule. Then the splat for subpaths,
# then the no-trailing-slash form.
lines.append(f"/{LEGACY_APP_PATH}/ /{seg}/ 301\n")
lines.append(f"/{LEGACY_APP_PATH}/* /{seg}/:splat 301\n")
lines.append(f"/{LEGACY_APP_PATH} /{seg}/ 301\n")
(out / "_redirects").write_text("".join(lines))
def _write_headers(out: Path, seg: str) -> None:
# The app shell (html/js/json) must NOT be browser-cached — otherwise a deploy is
# invisible to returning visitors for hours (Pages' default is max-age=14400).
# `no-cache` = store but revalidate every load (cheap 304 via etag). The media is
# on R2 (immutable + content-hash), unaffected. Cloudflare Pages `_headers` syntax.
# 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-cache\n"
" Cache-Control: no-store\n"
)
@@ -151,7 +172,7 @@ if __name__ == "__main__":
ap.add_argument("--out", default="dist")
ap.add_argument("--media-out", default="dist-media")
ap.add_argument("--media-base", default="https://static.benstull.art/")
ap.add_argument("--app-path", default="/human-experience-simulator")
ap.add_argument("--app-path", default="/human-machine-strata")
a = ap.parse_args()
r = build_static(a.out, a.media_out, media_base=a.media_base, app_path=a.app_path)
print(json.dumps(r, indent=2))