Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f077193df9 | |||
| b9de6695a0 | |||
| 05328b1b5e | |||
| a4d3d831b0 | |||
| d711db6a2c | |||
| 17856cac32 | |||
| 34478bb3a3 | |||
| f7abb5c29b | |||
| 86ce291056 | |||
| e0964eae6c |
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Worktree isolation guard. This repo is worked by MULTIPLE concurrent sessions; if
|
||||
# they share the main checkout they clobber each other (branch switches, lost edits,
|
||||
# commits on the wrong branch). Every session must work in its OWN git worktree
|
||||
# OUTSIDE the main directory.
|
||||
#
|
||||
# worktree-guard.sh block PreToolUse(Edit|Write|NotebookEdit) — DENY edits to
|
||||
# files INSIDE the main checkout while in it. Files
|
||||
# outside the repo (memory, /tmp, scratchpad) are allowed.
|
||||
# worktree-guard.sh notice SessionStart — tell the agent to enter a worktree.
|
||||
#
|
||||
# Isolated = the session sits in a git worktree, where `--git-dir` (…/.git/worktrees/
|
||||
# <name>) differs from `--git-common-dir` (…/.git). Equal = the shared main checkout.
|
||||
set -u
|
||||
mode="${1:-notice}"
|
||||
|
||||
gd=$(git rev-parse --absolute-git-dir 2>/dev/null) || exit 0 # not a repo → nothing to guard
|
||||
gc=$(cd "$(git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null && pwd -P) || exit 0
|
||||
[ "$gd" = "$gc" ] || exit 0 # already in a worktree → allow
|
||||
|
||||
root=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
repo=$(basename "${root:-repo}")
|
||||
guidance="You are in the SHARED main checkout ($root). Multiple sessions use it \
|
||||
concurrently and WILL clobber each other. Work in an isolated worktree OUTSIDE the \
|
||||
main directory: use the EnterWorktree tool (preferred — it relocates this session to \
|
||||
a fresh worktree branched from origin/main), or run \
|
||||
\`git worktree add -b <branch> ../${repo}-worktrees/<name> origin/main\` and cd there. \
|
||||
Do NOT edit files in the main checkout."
|
||||
|
||||
esc() { printf '%s' "$1" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; }
|
||||
|
||||
if [ "$mode" = "block" ]; then
|
||||
# Only guard files INSIDE the main checkout's working tree; allow edits to files
|
||||
# outside the repo (auto-memory, /tmp, scratchpad, other repos).
|
||||
fp=$(python3 -c 'import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin); print(d.get("tool_input",{}).get("file_path","") or "")
|
||||
except Exception: print("")' 2>/dev/null)
|
||||
case "$fp" in
|
||||
/*) abs="$fp" ;;
|
||||
"") abs="$root" ;;
|
||||
*) abs="$root/$fp" ;;
|
||||
esac
|
||||
case "$abs" in
|
||||
"$root"/*|"$root") : ;; # inside the repo → fall through to deny
|
||||
*) exit 0 ;; # outside the repo → allow
|
||||
esac
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":%s}}\n' "$(esc "$guidance")"
|
||||
else
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "$(esc "⚠ WORKTREE ISOLATION REQUIRED. $guidance")"
|
||||
fi
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"worktree": {
|
||||
"bgIsolation": "worktree",
|
||||
"baseRef": "fresh"
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" notice"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/worktree-guard.sh\" block"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
# Welcome screen — design
|
||||
|
||||
**Date:** 2026-06-30
|
||||
**Status:** Approved (brainstorming)
|
||||
**Surface:** `simulator/static/` (frontend only — `index.html`, `style.css`, `app.js`, `i18n.js`)
|
||||
|
||||
## Summary
|
||||
|
||||
Replace the current bare boot flow with a single **Welcome screen**: the
|
||||
universal entry gate the visitor sees on every load. It carries the
|
||||
photosensitivity "Heads up" notice, the four output controls (Video, Reduce
|
||||
motion, Audio, Language) with welcome-appropriate defaults, a **Launch
|
||||
Simulator** button, and the "Loading Universe" progress. The right control panel is hidden
|
||||
until the visitor enters the simulation.
|
||||
|
||||
## Why
|
||||
|
||||
Today the universe loads behind a bare "Loading Universe" splash; the splash
|
||||
fades to reveal a "Run simulation" button over the black stage; a first-visit
|
||||
"Heads up" modal appears; clicking through begins the experience. The four
|
||||
output controls live in the right panel and are visible the whole time —
|
||||
including during loading, which the panel should not be.
|
||||
|
||||
We want a deliberate, configurable front door: the visitor reads the heads-up,
|
||||
sets their preferences, and presses one button to begin — while the universe
|
||||
loads in the background so entry is instant when they are ready.
|
||||
|
||||
## Current flow (being replaced)
|
||||
|
||||
- `#loading` — fullscreen splash (`Loading Universe` title + `#loading-fill`
|
||||
progress bar). `simulator/static/index.html:10-15`.
|
||||
- `#run-sim` — "Run simulation" button revealed over the black stage after
|
||||
phase-1 preload. `index.html:33`; wired at `app.js:1533`.
|
||||
- `#motion-warning` — first-visit-only "Heads up" modal, persisted dismissed via
|
||||
`WARN_KEY`. `index.html:34-40`; shown by `maybeShowMotionWarning()`
|
||||
(`app.js:1161`).
|
||||
- Four controls in `.panel` (`#visual`, `#audio`, `#reduce-motion`,
|
||||
`#lang-select`). `index.html:46-65`. The panel is visible throughout.
|
||||
- `beginExperience()` (`app.js:1505`) snaps Video on and Audio to
|
||||
`START_AUDIO_LEVEL = "2"` in the click gesture (Safari audio-unlock).
|
||||
|
||||
## Target design
|
||||
|
||||
### 1. One unified `#welcome` overlay
|
||||
|
||||
Fold `#loading`, `#motion-warning`, and `#run-sim` into a single fullscreen
|
||||
`#welcome` overlay layered above everything. It absorbs all three roles. The
|
||||
right `.panel` is **hidden** for the entire welcome + loading period and revealed
|
||||
only on entry to the simulation.
|
||||
|
||||
The overlay contains three blocks:
|
||||
|
||||
1. **Welcome messaging** — the "Heads up — motion & flashing" title + body
|
||||
(today's `warn.*` strings, reworded to point at the on-screen Reduce-motion
|
||||
control rather than "the panel"). Structured so additional welcome copy can be
|
||||
added later (see Forward notes).
|
||||
2. **Controls** — the four output controls (below).
|
||||
3. **Launch Simulator** — the single entry button.
|
||||
4. **Loading** — the "Loading Universe" title + progress bar.
|
||||
|
||||
### 2. Two visual states
|
||||
|
||||
**State A — Welcome (on load):** welcome messaging + controls + "Launch
|
||||
Simulator" button shown; the Loading block sits at the **bottom**, its bar
|
||||
filling as phase-1 preload runs in the background.
|
||||
|
||||
**State B — Loading (after Launch, only if not yet loaded):** the welcome
|
||||
messaging and Launch button **disappear**; the **controls remain**; the Loading
|
||||
block moves to **center**. When phase-1 preload completes, auto-enter the
|
||||
simulation.
|
||||
|
||||
**Launch when already loaded** → skip State B; enter the simulation directly.
|
||||
|
||||
State is driven by a class on `#welcome` (e.g. `.welcoming` → `.loading`), with
|
||||
the "loaded yet?" decision made from a flag set when phase-1 preload resolves.
|
||||
|
||||
### 3. Control defaults (welcome screen)
|
||||
|
||||
| Control | Welcome default | Notes |
|
||||
|---------------|-----------------|--------------------------------------------------|
|
||||
| Video | **ON** | Was OFF in the panel; on by default here. |
|
||||
| Reduce motion | **OFF** | Unless OS `prefers-reduced-motion` / stored pref.|
|
||||
| Audio | **2** (0–10) | Was 0 in the panel. |
|
||||
| Language | **English** | Global; changing it re-renders all welcome text. |
|
||||
|
||||
Reduce motion keeps deferring to a stored value / the OS media query when those
|
||||
exist (same precedence as `initReduceMotion()` today); "OFF" is the no-signal
|
||||
default.
|
||||
|
||||
### 4. Entry into the simulation
|
||||
|
||||
"Launch Simulator" is the user gesture that unlocks audio (the role `#run-sim`
|
||||
had). On Launch → enter (either immediately when loaded, or at end of State B):
|
||||
|
||||
1. Copy the four welcome values into the existing panel inputs (`#visual`,
|
||||
`#reduce-motion`, `#audio`, `#lang-select`).
|
||||
2. Fire the existing handlers / `beginExperience()` so the experience starts in
|
||||
the same code paths as today.
|
||||
3. Remove the `#welcome` overlay and reveal `.panel`.
|
||||
|
||||
Per decisions: the four controls **stay in the panel** for mid-experience
|
||||
adjustment; **Video-off at entry** starts with video off (audio still plays per
|
||||
the Audio level — `beginExperience` no longer forces Video on regardless).
|
||||
|
||||
### 5. Controls drive shared state
|
||||
|
||||
The welcome controls write the same underlying state as the panel controls — they
|
||||
are not a parallel system:
|
||||
|
||||
- **Language** routes through the global `setLanguage()`, which sets `activeLang`
|
||||
and runs `applyUiStrings()` over **all** `[data-i18n]` elements — so the
|
||||
heads-up copy, control labels, and Launch button re-render live, exactly like
|
||||
the panel selector does today. The panel `#lang-select` value is synced to
|
||||
`activeLang` on entry.
|
||||
- **Reduce motion** sets the same `reduceMotion` variable + `RM_KEY` localStorage
|
||||
and calls `applyReduceMotion()`; panel checkbox synced on entry.
|
||||
- **Audio / Video** values are applied on entry via the panel inputs +
|
||||
`beginExperience()`.
|
||||
|
||||
### 6. Behavior changes (intentional)
|
||||
|
||||
- The welcome screen shows on **every** load (it is the entry gate holding the
|
||||
controls), so the first-visit-only persistence of the heads-up (`WARN_KEY`) is
|
||||
**retired**.
|
||||
- Entry uses the **chosen** Audio level rather than always snapping to 2 (2 is the
|
||||
default, not a forced value).
|
||||
- `#run-sim` is removed; **"Launch Simulator"** is the single entry button.
|
||||
|
||||
## i18n
|
||||
|
||||
New / changed `UI_STRINGS` keys in `simulator/static/i18n.js` (all four langs —
|
||||
en/es/fr/ja, matching existing parity):
|
||||
|
||||
- A new `welcome.launch` key — **"Launch Simulator"** (the single entry button),
|
||||
translated in all four languages.
|
||||
- Reworded `warn.body` (point at the on-screen Reduce-motion control).
|
||||
- Welcome-screen control labels reuse the existing `output.video`,
|
||||
`output.audio`, `rm.label` keys (already translated). The language selector
|
||||
needs no string (native names from the registry).
|
||||
|
||||
All welcome text must live under `data-i18n` so `applyUiStrings()` re-renders it
|
||||
on language change.
|
||||
|
||||
## Out of scope / Forward notes
|
||||
|
||||
- **Additional welcome copy** beyond the heads-up (intro / framing text) comes in
|
||||
a later pass, once this flow works. The welcome-messaging block is structured so
|
||||
adding more `data-i18n` paragraphs is trivial.
|
||||
- No backend / API changes. No content-pipeline changes.
|
||||
|
||||
## Testing
|
||||
|
||||
Frontend lives behind Playwright e2e (`simulator/static/` tests) + node unit
|
||||
tests. Plan to:
|
||||
|
||||
- **Update** existing tests that reference `#loading`, `#run-sim`, and
|
||||
`#motion-warning` to the new `#welcome` flow.
|
||||
- **Add** coverage for:
|
||||
- Welcome defaults (Video on, Reduce motion off, Audio 2, English).
|
||||
- Language switch re-renders welcome text (heads-up + labels + Launch button)
|
||||
live.
|
||||
- Launch **while loading** → State B (messaging gone, controls remain,
|
||||
progress centered), then auto-enter when loaded.
|
||||
- Launch **when already loaded** → straight into the simulation.
|
||||
- `.panel` hidden during welcome + loading; revealed on entry.
|
||||
- Welcome control values carry into the panel inputs on entry.
|
||||
|
||||
Environment caveats (from prior sessions): this box has only `python3` (start
|
||||
uvicorn by hand; `reuseExistingServer`); a stale uvicorn is the recurring
|
||||
"no transitions" root cause; `loop-recovery.spec.ts` fails on a clean baseline
|
||||
here (boots without video).
|
||||
@@ -0,0 +1,107 @@
|
||||
# Session 0034.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T07-24 (PST)
|
||||
> End: 2026-06-30T10-01 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: 08b95eb0-3ec5-400a-9236-da78f01139a0
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art/.claude/worktrees/fix+audio-and-tracking-labels
|
||||
> Status: FINALIZED
|
||||
|
||||
## Launch prompt
|
||||
|
||||
> The audio isn't playing at all. Let's fix that and then let's make sure there are
|
||||
> left and right brain tracking labels for everything. Make sure you're working on a
|
||||
> worktree. There are other concurrent sessions that may have real merge conflicts to
|
||||
> handle.
|
||||
|
||||
## Pre-state
|
||||
|
||||
- The simulator (`simulator/`) is a FastAPI + static-JS experience: an altitude dial
|
||||
over 6 scales (cosmos/orbit/sky/coast/reef/abyss), 41 rotating-pool clips, a
|
||||
real-time Kuwahara "dream" shader, two HUD label layers — **Think** (left-brain
|
||||
factual chips, `#overlay`) and **Feel** (right-brain affect words, `#affect`) —
|
||||
plus a per-altitude soundtrack with a 0–10 audio dial and a built-in
|
||||
`#audio-status` diagnostic.
|
||||
- s0027 had brought all 41 clips to left+right annotation with 4-lang parity, but the
|
||||
left labels were thin (many clips 1–2) and STATIC; affect was authored PER-SCALE
|
||||
(every clip in an altitude shared 4 feelings).
|
||||
- Operator was running a stale `:8000` server on the `design/cloudflare-static-publish`
|
||||
branch (a concurrent session's WIP — "Run simulation" begin-experience flow, +
|
||||
later a credits page). Project is EXEMPT from the Wiggleverse deploy pipeline
|
||||
(flotilla/PPE/§9); public target is static Cloudflare. cv2/imageio NOT installed in
|
||||
this env.
|
||||
|
||||
## Session arc
|
||||
|
||||
1. **Worktree.** Created native worktree `fix+audio-and-tracking-labels` off
|
||||
`origin/main` (582183d). All work done here.
|
||||
2. **Audio (systematic-debugging) — FALSE ALARM.** Could not reproduce "no audio" in
|
||||
ANY automated condition: Chromium + WebKit, strict autoplay
|
||||
(`--autoplay-policy=user-gesture-required`), clean-main server AND the operator's
|
||||
`:8000` WIP server, via Video-toggle / Audio-dial / "Run simulation" button; all 5
|
||||
soundtracks decode to real non-silent audio (RMS 0.08–0.24). The raw
|
||||
native-controls `#aud-test` player was ALSO silent on the operator's Safari while
|
||||
the element reported playing → isolated it BELOW the app. Operator confirmed:
|
||||
**their Safari sound was muted.** No code change.
|
||||
3. **"Labels not showing" on cosmos_tarantula.** Root cause: operator was on `:8000`
|
||||
(design branch), whose manifest had that clip's `annotations: []`. My branch
|
||||
renders all labels correctly; labels are knob-gated (none at Think/Feel 0).
|
||||
4. **Left (Think) enrichment.** Every clip → ≥3 factual labels (added one to each of
|
||||
23 thin clips; orbit_bluemarble 1→3), full es/fr/ja parity. Pipeline:
|
||||
`LABELS` in `build_pool_manifest.py` → build → catalogs → `translate_manifest.py
|
||||
merge`. (commit `8cfceff`)
|
||||
5. **Motion-tracking.** Converted the 4 still-static abyss creatures (octopus, bigfin
|
||||
squid, dandelion siphonophore, sea pig) static→tracked, 3-keyframe paths sampled
|
||||
by eye from frames (cv2 unavailable). Tracked clips 7→11. (`8cfceff`)
|
||||
6. **Think/Feel overlap avoidance.** `renderAffect` now nudges Feel words off Think
|
||||
chip plates (obstacles computed from manifest, tracked paths sampled → holds whole
|
||||
loop, no per-frame jitter; new `window.__hefNoDeconflict` seam). Verified 0
|
||||
overlaps across all 6 scales incl. a tracked clip over time. (commit `041fcde`)
|
||||
7. **Per-clip right-brain feelings.** Operator: feelings should match each ACTUAL
|
||||
video. Added `AFFECT_CLIP` (per-clip override, scale fallback) for all 41 clips →
|
||||
41 distinct feeling-sets. cosmos done by me as a reviewed sample; the other 5
|
||||
scales drafted by **5 parallel subagents** (one per scale) that each VIEWED footage
|
||||
frames and followed the cosmos recipe; I normalized + authored all es/fr/ja for the
|
||||
19 new nuance keys. 42 distinct feeling keys now. (commits `bf1013b` + `2d54023`)
|
||||
8. **Merge.** Merged branch → `origin/main` (clean; main had only advanced by
|
||||
`sessions/` transcript commits). Confirmed all 4 work commits are ancestors of
|
||||
`origin/main`; another session (`feat/accessibility-pass`) has since merged main +
|
||||
a11y on top (`380c2d1`).
|
||||
|
||||
## Cut state (final)
|
||||
|
||||
- All work committed, merged to `origin/main`, pushed. Working tree clean.
|
||||
- Verification: 34/34 catalog+i18n+build pytest, 16/16 node unit, live render shows
|
||||
≥3 Think chips + distinct Feel words per clip (incl. new keys) with overlap 0 and no
|
||||
JS errors. (3 unrelated pytest failures = cv2/ffmpeg absent in this env.)
|
||||
- Worktree RETAINED (not torn down) because it is serving the operator's by-eye review
|
||||
at `http://127.0.0.1:8011/author.html`; tear down + stop `:8011` when review done.
|
||||
- §9 deploy pipeline: N/A — project is exempt (static Cloudflare target).
|
||||
|
||||
## Deferred decisions (low-confidence calls)
|
||||
|
||||
- **Audio = no code change.** Chose to treat it as environmental after exhaustive
|
||||
repro rather than guess-patch a working engine; operator confirmed muted Safari.
|
||||
- **Left-label box positions authored WITHOUT viewing each frame** (23 enriched
|
||||
clips, except bluemarble) — factual text is grounded, positions are estimates that
|
||||
want by-eye review.
|
||||
- **Per-clip feelings drafted from a single MID-FRAME** (motion not seen) via parallel
|
||||
subagents — first-pass reads; emotions may want tuning once a clip is in motion.
|
||||
- **Left reef/coast moving-subject clips left STATIC** (reef_redsea/flowergarden
|
||||
schools, coast_otters/seals) — tracking deferred (diffuse subjects + cv2 absent).
|
||||
- **Merged directly to `main` via `git push HEAD:main`** (no Gitea PR) at operator's
|
||||
explicit request; gh is GitHub-only here and no `tea` CLI.
|
||||
|
||||
## Operator plate
|
||||
|
||||
- Review the first-pass label/track/feeling positions in `/author.html` on `:8011`.
|
||||
- For Think: check each chip sits on its subject + the 4 abyss tracks follow the
|
||||
creature. For Feel: check emotions ring true once clips are in motion.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal refine the first-pass left-label box positions and the 4 abyss creature tracks per operator by-eye review (grab a frame per clip, nudge each chip onto its subject), and motion-track the remaining moving-subject clips (reef schools, coast animals) — data-only via build_pool_manifest + i18n pipeline
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
# Session 0035.0 — Transcript
|
||||
|
||||
> App: human-experience-filter-art
|
||||
> Start: 2026-06-30T08-45 (PST)
|
||||
> End: 2026-06-30T10-08 (PST)
|
||||
> Type: planning-and-executing
|
||||
> Posture: yolo
|
||||
> Claude-Session: ee3eaaac-d0a4-41a9-aa53-1919052fd8d3
|
||||
> Checkout: /Users/benstull/git/benstull.org/benstull/human-experience-filter-art
|
||||
> Status: **FINALIZED**
|
||||
|
||||
## Launch prompt
|
||||
|
||||
Not a `/goal` launch. Opened conversationally: "We need to make the entire
|
||||
simulator accessible, assuming it's not being used at a kiosk. What are the
|
||||
different types of accessibility and your suggestions (and difficulty)?" Then:
|
||||
build the first pass on a worktree, and add an `about.html` about the project's
|
||||
intent (accessible to all humans; vantage points from underwater→drones→space
|
||||
exploration; built with LLMs; honest that it's imperfect but more than the author
|
||||
alone could make).
|
||||
|
||||
Drove through the **superpowers** pipeline (brainstorming → writing-plans →
|
||||
executing-plans → finishing-a-development-branch), not a wgl init — so the wgl
|
||||
transcript ID was claimed at finalize (this file).
|
||||
|
||||
## Pre-state
|
||||
|
||||
- Simulator built for an attended **kiosk**; the Cloudflare static publish
|
||||
(`design/cloudflare-static-publish`, already merged to `main` mid-session as
|
||||
`87d605d`) puts it on a public URL where no AT/attendant assumptions hold.
|
||||
- No `prefers-reduced-motion`, no keyboard on the SVG Altitude dial, several
|
||||
failing-contrast colors, no screen-reader path, no seizure safeguard — on a
|
||||
piece that is continuous motion + a "trippy at max" WebGL shader.
|
||||
|
||||
## Arc (turn-by-turn)
|
||||
|
||||
1. **Analysis** — read `index.html` / `style.css`, grepped `app.js` (zero aria /
|
||||
keyboard / reduced-motion). Enumerated 7 accessibility types grounded in the
|
||||
actual UI, with difficulty + a priority table. Recommended WCAG 2.1 AA + a
|
||||
motion/seizure layer.
|
||||
2. **Brainstorming** — operator chose: reduced-motion = **freeze-to-stills**;
|
||||
photosensitivity = **warning gate + flash audit**; about page = **English-first**.
|
||||
Added the about-page "imperfect but more than alone" beat. Wrote + committed the
|
||||
design spec.
|
||||
3. **Worktree** — first tried `EnterWorktree` (branches off `origin/main`); on
|
||||
inspecting the diff, the work belonged on `design/cloudflare-static-publish`
|
||||
(correct `app.js`, existing `credits.html` pattern). Operator: "new worktree off
|
||||
that, keep separate until ready for main." Created
|
||||
`feat/accessibility-pass` off the design branch under `.claude/worktrees/`.
|
||||
4. **Plan** — 10 bite-sized TDD tasks, committed.
|
||||
5. **Execution (TDD, test-first each)** — (1) layout: lang picker below Audio,
|
||||
globe inline-left; (2) AA contrast + focus-visible + visually-hidden; (3)
|
||||
`flash.js` pure WCAG-2.3.1 helper + node tests; (4) one-time photosensitivity
|
||||
gate; (5) reduced-motion freeze (single `playLoop()` guard + instant `autoScrub`);
|
||||
(6) keyboard/ARIA dial (`role=slider`, Arrow/Home/End, label buttons); (7)
|
||||
aria-live narration + `aria-hidden` decoratives; (8) wire flash-clamp + document
|
||||
audit (no breach found); (9) `about.html` + header link + `PUBLIC_ASSETS`.
|
||||
9 a11y e2e + 2 node tests, all green; fixed a gate-vs-run-sim regression in the
|
||||
shared `boot()` helper.
|
||||
6. **Finish** — operator: merge everything to main + push origin. `origin/main`
|
||||
had advanced 29 commits (static-publish already landed there) → divergent merge;
|
||||
one `app.js` conflict (main's D3 reverse-landing fix vs my reduced-motion guard)
|
||||
resolved keeping **both**. Re-verified on merged code (a11y 9/9, altitude-lock
|
||||
13/13, i18n 1/1, static-build 2/2, flash 2/2). Pushed `ff06783..380c2d1 main`.
|
||||
Removed the worktree; stopped bg servers.
|
||||
|
||||
## Cut state
|
||||
|
||||
- **a11y pass + About page MERGED + PUSHED to `origin/main` (`380c2d1`).**
|
||||
- On `main` but **NOT deployed** — live benstull.art needs a Cloudflare redeploy.
|
||||
- App has **no content repo** → plan stays in-repo at
|
||||
`docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md`. No
|
||||
`submit-plan.sh`. Project is **exempt** from §9/flotilla/PPE (Cloudflare static).
|
||||
- A **concurrent** commit `1a6ffc7` ("phase-1 preload gate") sits on local `main`
|
||||
ahead of origin by 1 — **not this session's; left untouched/unpushed** per
|
||||
operator ("leave anything that isn't yours alone"). Other worktree
|
||||
`fix+audio-and-tracking-labels` also left alone.
|
||||
- Checkout now on `main` (was `design/cloudflare-static-publish` at start).
|
||||
|
||||
## Deferred decisions
|
||||
|
||||
_Autonomous low-confidence calls (no live placeholder existed, so logged here):_
|
||||
|
||||
- **SR narration = scale + clip title**, not the tiered factual (Think) HUD label —
|
||||
simpler/robust; the plan explicitly permitted the degrade. Could enrich later.
|
||||
- **Flash audit concluded no >3/sec breach** (1200ms per-altitude floor; fades are
|
||||
single, ≥1.2s apart); `flash.js` wired only as a guard floor (no-op today).
|
||||
- **Branched off the design branch, not main** — corrected after operator input.
|
||||
- **Merged the full divergent history to main** (static-publish was already on
|
||||
origin/main, so net-new was just the a11y work) — on explicit operator instruction.
|
||||
|
||||
## Next /goal
|
||||
|
||||
```
|
||||
/goal redeploy benstull.art/human-experience-simulator to Cloudflare so the accessibility pass + About page go live, per cloudflare-deploy-live (build with `python3 -m tools.build_static`, then the R2/Pages deploy gestures)
|
||||
```
|
||||
|
||||
Operator by-eye review of the a11y changes precedes the redeploy. Env gotchas for
|
||||
the next session: run `build_static` as a **module** (`python3 -m tools.build_static`)
|
||||
or `tools/http.py` shadows stdlib `http`; uvicorn dies mid-session and the e2e
|
||||
`webServer` fallback calls `python` (only `python3` here) → start it by hand on :8099;
|
||||
Playwright `test.use()` inside `test.describe()` breaks collection in PW 1.45.
|
||||
@@ -97,5 +97,11 @@
|
||||
},
|
||||
"0033": {
|
||||
"title": ""
|
||||
},
|
||||
"0034": {
|
||||
"title": ""
|
||||
},
|
||||
"0035": {
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,17 +36,16 @@ 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 a Reduce-motion 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 Reduce-motion control sits right there, before the visitor launches.
|
||||
// (The checkbox itself is the visually-hidden pattern; assert its label.)
|
||||
await expect(page.locator('label[for="welcome-reduce-motion"]')).toBeVisible();
|
||||
await expect(page.locator("#welcome-launch")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,14 +53,13 @@ test.describe("Task 5 — Reduced motion", () => {
|
||||
test("reduce-motion toggle pauses video playback", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off
|
||||
await page.goto("/");
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
const rm = page.locator("#reduce-motion");
|
||||
await expect(rm).not.toBeChecked(); // default-off under no-preference
|
||||
await page.locator("#run-sim").click();
|
||||
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked(); // default-off under no-preference
|
||||
await enter(page); // Video defaults on → experience runs
|
||||
await page.waitForTimeout(500);
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(false); // experience running → video plays
|
||||
const rm = page.locator("#reduce-motion");
|
||||
await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label
|
||||
await expect(rm).toBeChecked();
|
||||
await expect
|
||||
@@ -64,6 +71,7 @@ test.describe("Task 5 — Reduced motion", () => {
|
||||
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 +90,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/";
|
||||
|
||||
// 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(() => {
|
||||
|
||||
@@ -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: Video ON, Reduce motion OFF, Audio 2, English.
|
||||
await expect(page.locator("#welcome-visual")).toBeChecked();
|
||||
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked();
|
||||
await expect(page.locator("#welcome-audio")).toHaveValue("2");
|
||||
await expect(page.locator("#welcome-audio-val")).toHaveText("2");
|
||||
await expect(page.locator("#welcome-lang")).toHaveValue("en");
|
||||
|
||||
// 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 defaults carried into the panel: Video on, Audio 2.
|
||||
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 page.locator('label[for="welcome-visual"]').click(); // default-on → off
|
||||
await page.locator('label[for="welcome-reduce-motion"]').click(); // default-off → on
|
||||
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("#visual")).not.toBeChecked();
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
+95
-70
@@ -1115,7 +1115,6 @@ 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 RM_KEY = "hef.reduceMotion";
|
||||
let devMode = false;
|
||||
let devStatsTimer = null;
|
||||
@@ -1126,17 +1125,25 @@ let devStatsTimer = null;
|
||||
// jump instantly instead of tweening — knob changes still re-grade the still.
|
||||
let reduceMotion = false;
|
||||
function isReduced() { return reduceMotion; }
|
||||
// Two checkboxes drive the same reduceMotion state: the welcome screen's and the
|
||||
// panel's. Either flips the flag, persists it, mirrors the other, and re-applies.
|
||||
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;
|
||||
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
|
||||
syncReduceMotionBoxes();
|
||||
applyReduceMotion();
|
||||
});
|
||||
}
|
||||
@@ -1152,25 +1159,6 @@ function applyReduceMotion() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
@@ -1480,35 +1468,72 @@ 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 and play the soundtrack at the chosen
|
||||
// level. 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();
|
||||
}
|
||||
|
||||
// Welcome screen ----------------------------------------------------------------
|
||||
// The welcome controls are mirrors of the panel controls. Reduce-motion and
|
||||
// language drive shared state live (see initReduceMotion / setLanguage); Video and
|
||||
// Audio are copied into the panel inputs at entry.
|
||||
function applyWelcomeToPanel() {
|
||||
const wv = $("welcome-visual"), wa = $("welcome-audio");
|
||||
if (wv) $("visual").checked = wv.checked;
|
||||
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 +1543,9 @@ 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
|
||||
initReduceMotion(); // reduced-motion state + both toggles (default from OS pref)
|
||||
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 +1554,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 +1573,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 +1619,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);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
const UI_STRINGS = {
|
||||
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre d’Expérience Humaine — Aperçu d’altération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
|
||||
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de l’univers", 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: "映像" },
|
||||
@@ -32,9 +32,8 @@
|
||||
"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: "開発モード" },
|
||||
"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.", 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.", 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.", 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: "軌道" },
|
||||
|
||||
+34
-12
@@ -7,10 +7,40 @@
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="loading">
|
||||
<div class="loading-inner">
|
||||
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
|
||||
<div class="loading-bar"><div id="loading-fill"></div></div>
|
||||
<!-- Welcome screen: the universal entry gate. State A (.welcoming) shows the
|
||||
heads-up + controls + Launch button with the loader at the bottom; State B
|
||||
(.loading, after Launch while media is still loading) hides the messaging +
|
||||
button, keeps the controls, and centers the loader. Removed on entry. -->
|
||||
<div id="welcome" class="welcoming" role="dialog" aria-modal="true" aria-labelledby="welcome-title">
|
||||
<div class="welcome-inner">
|
||||
<div class="welcome-message">
|
||||
<h2 id="welcome-title" data-i18n="warn.title">Heads up — motion & flashing</h2>
|
||||
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch.</p>
|
||||
</div>
|
||||
<div class="welcome-controls">
|
||||
<label class="dev-switch" for="welcome-visual">
|
||||
<input type="checkbox" id="welcome-visual" checked />
|
||||
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
|
||||
<span class="dev-switch-label" data-i18n="output.video">Video</span>
|
||||
</label>
|
||||
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
|
||||
<input type="range" id="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>
|
||||
<label class="lang-pick">🌐
|
||||
<select id="welcome-lang" aria-label="Language"></select>
|
||||
</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>
|
||||
@@ -30,14 +60,6 @@
|
||||
<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 & 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>
|
||||
|
||||
|
||||
+42
-25
@@ -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; }
|
||||
@@ -190,17 +179,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;
|
||||
|
||||
+53
-38
@@ -2,15 +2,16 @@
|
||||
cleanly when Playwright or its browser binary is absent.
|
||||
|
||||
NOTE: headless engines relax BOTH autoplay and GPU compositing, so these assert
|
||||
the WIRING (boot-silent, video-on couples audio, dial→play/volume, video-off
|
||||
the WIRING (boot-silent, launch starts video+audio, dial→play/volume, video-off
|
||||
blanking) — real Safari/iOS autoplay and real-GPU compositing still need a device
|
||||
by-ear/eye check.
|
||||
|
||||
The experience BOOTS SILENT — video off, audio dial at 0 — so nothing plays until a
|
||||
real user gesture. 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 0–10 range
|
||||
dial (not a checkbox); set it by writing `value` + firing an `input` event.
|
||||
The WELCOME screen is the entry gate: it carries the controls (Video on, Audio 2)
|
||||
and a "Launch Simulator" button. Until Launch, the panel stays black/silent (panel
|
||||
Video off, dial 0). Pressing Launch (a click gesture) copies the welcome values
|
||||
into the panel and starts the soundtrack IN that gesture, sidestepping the browser
|
||||
autoplay block. Audio is a 0–10 range dial (not a checkbox); set it by writing
|
||||
`value` + firing an `input` event.
|
||||
|
||||
Install: pip install -e '.[e2e]' && python -m playwright install chromium
|
||||
"""
|
||||
@@ -65,15 +66,23 @@ def page(app_url):
|
||||
pytest.skip(f"chromium not available: {exc}")
|
||||
pg = browser.new_page()
|
||||
pg.goto(app_url)
|
||||
# wait out the "Loading Universe…" splash (it overlays + blocks clicks); the
|
||||
# experience boots silent (video off, audio 0) as it dismisses.
|
||||
pg.wait_for_selector("#loading", state="detached", timeout=60000)
|
||||
# Wait for phase-1 preload to finish; the welcome screen (State A) is showing.
|
||||
# The panel stays black/silent until the visitor presses Launch.
|
||||
pg.wait_for_function("window.__hefReady === true", timeout=60000)
|
||||
yield pg
|
||||
browser.close()
|
||||
|
||||
|
||||
def _enter(page):
|
||||
"""Press the welcome screen's Launch button (the entry gesture) and wait for the
|
||||
overlay to be removed, revealing the panel + running the experience."""
|
||||
page.click("#welcome-launch")
|
||||
page.wait_for_selector("#welcome", state="detached", timeout=10000)
|
||||
|
||||
|
||||
def _toggle_visual(page):
|
||||
"""Click the visible Video toggle track (the checkbox input is hidden)."""
|
||||
"""Click the visible Video toggle track (the checkbox input is hidden). Only
|
||||
valid after _enter() — the panel is hidden behind the welcome screen."""
|
||||
page.click("label[for='visual'] .dev-switch-track")
|
||||
|
||||
|
||||
@@ -87,9 +96,18 @@ def _set_audio(page, level):
|
||||
)
|
||||
|
||||
|
||||
def test_app_boots_silent_video_off(page):
|
||||
# The experience boots SILENT: Video off, audio dial at 0, screen black, both
|
||||
# crossfade elements paused — nothing plays until a real user gesture.
|
||||
def _set_welcome_audio(page, level):
|
||||
"""Drive the welcome screen's 0–10 audio dial (before Launch)."""
|
||||
page.evaluate(
|
||||
"(lvl) => { const a = document.getElementById('welcome-audio');"
|
||||
" a.value = String(lvl); a.dispatchEvent(new Event('input', {bubbles:true})); }",
|
||||
level,
|
||||
)
|
||||
|
||||
|
||||
def test_panel_silent_until_launch(page):
|
||||
# Before Launch the panel stays SILENT: panel Video off, audio dial at 0, screen
|
||||
# black, both crossfade elements paused — nothing plays until the Launch gesture.
|
||||
assert page.is_checked("#visual") is False
|
||||
assert page.evaluate("document.getElementById('audio').value") == "0"
|
||||
assert page.evaluate("document.getElementById('audio-level-val').textContent") == "0"
|
||||
@@ -100,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 (Video on, Audio 2) while the panel stays silent.
|
||||
assert page.is_visible("#welcome-launch")
|
||||
assert page.is_checked("#welcome-visual") is True
|
||||
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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user