diff --git a/simulator/e2e/tests/a11y.spec.ts b/simulator/e2e/tests/a11y.spec.ts index add613c..43a21a9 100644 --- a/simulator/e2e/tests/a11y.spec.ts +++ b/simulator/e2e/tests/a11y.spec.ts @@ -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(); diff --git a/simulator/e2e/tests/altitude-lock.spec.ts b/simulator/e2e/tests/altitude-lock.spec.ts index b86c830..1b316f5 100644 --- a/simulator/e2e/tests/altitude-lock.spec.ts +++ b/simulator/e2e/tests/altitude-lock.spec.ts @@ -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); diff --git a/simulator/e2e/tests/i18n.spec.ts b/simulator/e2e/tests/i18n.spec.ts index 14c330f..9b6fc2f 100644 --- a/simulator/e2e/tests/i18n.spec.ts +++ b/simulator/e2e/tests/i18n.spec.ts @@ -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, updates + // switch to Spanish -> chrome changes (incl. the hidden panel), 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"); }); diff --git a/simulator/e2e/tests/loop-recovery.spec.ts b/simulator/e2e/tests/loop-recovery.spec.ts index 5455612..0352614 100644 --- a/simulator/e2e/tests/loop-recovery.spec.ts +++ b/simulator/e2e/tests/loop-recovery.spec.ts @@ -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 () => { diff --git a/simulator/e2e/tests/static-build.spec.ts b/simulator/e2e/tests/static-build.spec.ts index 662b2e3..83ef31e 100644 --- a/simulator/e2e/tests/static-build.spec.ts +++ b/simulator/e2e/tests/static-build.spec.ts @@ -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(() => { diff --git a/simulator/e2e/tests/welcome.spec.ts b/simulator/e2e/tests/welcome.spec.ts new file mode 100644 index 0000000..d190932 --- /dev/null +++ b/simulator/e2e/tests/welcome.spec.ts @@ -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((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(); +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index 3023f57..0f6b543 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -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); diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index 497e276..455e5ce 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -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: "軌道" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index 4ad8c95..7e44693 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -7,10 +7,40 @@ -
-
-
Loading Universe
-
+ +
@@ -30,14 +60,6 @@ - -
diff --git a/simulator/static/style.css b/simulator/static/style.css index 18d48d1..e8914c3 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -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; diff --git a/tests/test_e2e_audio.py b/tests/test_e2e_audio.py index 7f67af6..213153a 100644 --- a/tests/test_e2e_audio.py +++ b/tests/test_e2e_audio.py @@ -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( diff --git a/tests/test_e2e_credits.py b/tests/test_e2e_credits.py index 3023039..4e17d03 100644 --- a/tests/test_e2e_credits.py +++ b/tests/test_e2e_credits.py @@ -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)