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>
This commit is contained in:
BenStullsBets
2026-06-30 11:14:35 -07:00
parent b9de6695a0
commit f077193df9
12 changed files with 408 additions and 207 deletions
+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, 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 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 (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(
+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)