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
+25 -16
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,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();
+7 -29
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);
+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 () => {
+10 -1
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/";
// 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: 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
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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);
+3 -4
View File
@@ -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 dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"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: "映像" },
@@ -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
View File
@@ -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 &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.</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 &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>
+42 -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; }
@@ -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
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 (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)