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:
@@ -1,12 +1,20 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Accessibility pass (feat/accessibility-pass) — non-kiosk public-web a11y.
|
||||
// Each block maps to a task in
|
||||
// docs/superpowers/plans/2026-06-30-accessibility-and-about-page.md.
|
||||
|
||||
// Enter the simulation via the welcome screen (the panel is hidden until then).
|
||||
async function enter(page: Page) {
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
test.describe("Task 1 — Output panel layout", () => {
|
||||
test("language picker sits below the Audio control", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
const audio = page.locator("#audio");
|
||||
const lang = page.locator("#lang-select");
|
||||
await expect(audio).toBeVisible();
|
||||
@@ -18,7 +26,8 @@ test.describe("Task 1 — Output panel layout", () => {
|
||||
|
||||
test("globe icon is inline-left of the language select (same row)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const pick = page.locator(".lang-pick");
|
||||
await enter(page);
|
||||
const pick = page.locator(".panel .lang-pick");
|
||||
const select = page.locator("#lang-select");
|
||||
const pBox = await pick.boundingBox();
|
||||
const sBox = await select.boundingBox();
|
||||
@@ -27,17 +36,16 @@ test.describe("Task 1 — Output panel layout", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Task 4 — Photosensitivity warning gate", () => {
|
||||
test("motion warning gate shows once, then is remembered", async ({ page }) => {
|
||||
test.describe("Task 4 — Photosensitivity notice", () => {
|
||||
test("the welcome screen carries the heads-up notice and a Reduce-motion control", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const gate = page.locator("#motion-warning");
|
||||
await expect(gate).toBeVisible({ timeout: 30000 }); // shown after media preload + splash fade
|
||||
await page.locator("#motion-warning-continue").click();
|
||||
await expect(gate).toBeHidden();
|
||||
// Reload in the SAME context: dismissal persisted, gate stays hidden.
|
||||
await page.reload();
|
||||
await expect(page.locator("#motion-warning")).toBeHidden();
|
||||
await expect(page.locator("#run-sim")).toBeVisible();
|
||||
// The heads-up is shown on the welcome screen itself (it is the entry gate).
|
||||
await expect(page.locator("#welcome-title")).toBeVisible();
|
||||
await expect(page.locator("#welcome-title")).toHaveText(/motion|flashing/i);
|
||||
// The Reduce-motion control sits right there, before the visitor launches.
|
||||
// (The checkbox itself is the visually-hidden pattern; assert its label.)
|
||||
await expect(page.locator('label[for="welcome-reduce-motion"]')).toBeVisible();
|
||||
await expect(page.locator("#welcome-launch")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,14 +53,13 @@ test.describe("Task 5 — Reduced motion", () => {
|
||||
test("reduce-motion toggle pauses video playback", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" }); // deterministic default-off
|
||||
await page.goto("/");
|
||||
await page.locator("#motion-warning-continue").click().catch(() => {});
|
||||
const rm = page.locator("#reduce-motion");
|
||||
await expect(rm).not.toBeChecked(); // default-off under no-preference
|
||||
await page.locator("#run-sim").click();
|
||||
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked(); // default-off under no-preference
|
||||
await enter(page); // Video defaults on → experience runs
|
||||
await page.waitForTimeout(500);
|
||||
await expect
|
||||
.poll(() => page.locator("#vid-loop").evaluate((v: HTMLVideoElement) => v.paused))
|
||||
.toBe(false); // experience running → video plays
|
||||
const rm = page.locator("#reduce-motion");
|
||||
await page.locator('label[for="reduce-motion"]').click(); // hidden input: toggle via its label
|
||||
await expect(rm).toBeChecked();
|
||||
await expect
|
||||
@@ -64,6 +71,7 @@ test.describe("Task 5 — Reduced motion", () => {
|
||||
test.describe("Task 6 — Keyboard + ARIA dial", () => {
|
||||
test("altitude dial is a keyboard-operable slider", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
const dial = page.locator("#dial");
|
||||
await expect(dial).toHaveAttribute("role", "slider");
|
||||
await expect(dial).toHaveAttribute("tabindex", "0");
|
||||
@@ -82,6 +90,7 @@ test.describe("Task 6 — Keyboard + ARIA dial", () => {
|
||||
test.describe("Task 7 — Screen-reader narration", () => {
|
||||
test("an aria-live region narrates the current scale", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await enter(page);
|
||||
const live = page.locator("#sr-status");
|
||||
await expect(live).toHaveAttribute("aria-live", "polite");
|
||||
await page.locator("#dial").focus();
|
||||
|
||||
@@ -12,15 +12,13 @@ import { test, expect, Page } from "@playwright/test";
|
||||
async function boot(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("hef.devMode", "1");
|
||||
localStorage.setItem("hef.motionWarnDismissed", "1"); // returning visitor: skip the photosensitivity gate
|
||||
});
|
||||
await page.goto("/");
|
||||
// Wait for the "Loading Universe" preload to finish (splash gets class "done").
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#loading")?.classList.contains("done"),
|
||||
null,
|
||||
{ timeout: 45_000 },
|
||||
);
|
||||
// Wait for phase-1 preload, then enter via the welcome screen's Launch button
|
||||
// (Video defaults on, Audio 2 → the experience is running once we're in).
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
await expect(page.locator("#scale-name")).toContainText("cosmos");
|
||||
}
|
||||
|
||||
@@ -86,28 +84,8 @@ test("audio is a 0-10 level dial", async ({ page }) => {
|
||||
expect(ctl.max).toBe("10");
|
||||
});
|
||||
|
||||
test("boots silent (video off, audio 0); turning video on couples audio to 2/10 and dismisses the Run prompt", async ({ page }) => {
|
||||
await boot(page);
|
||||
// Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites.
|
||||
expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false);
|
||||
expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
|
||||
// "Run simulation" is revealed once preload finishes — the obvious starting point.
|
||||
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(false);
|
||||
// Turning Video on directly (a click gesture) lifts the audio dial to a gentle 2/10
|
||||
// and dismisses the Run prompt.
|
||||
await enableVideo(page);
|
||||
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
|
||||
expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("2");
|
||||
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
|
||||
});
|
||||
|
||||
test("the Run simulation button starts video, snaps audio to 2/10, and dismisses itself", async ({ page }) => {
|
||||
await boot(page);
|
||||
await page.click("#run-sim");
|
||||
await expect.poll(() => page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(true);
|
||||
await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("2");
|
||||
expect(await page.evaluate(() => document.querySelector("#run-sim")?.classList.contains("hidden"))).toBe(true);
|
||||
});
|
||||
// (Entry via the welcome screen — defaults, Launch, control carry-over, State B —
|
||||
// is covered in welcome.spec.ts.)
|
||||
|
||||
test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
|
||||
await boot(page);
|
||||
|
||||
@@ -7,22 +7,25 @@ import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
async function boot(page: Page) {
|
||||
await page.goto("/");
|
||||
// initLanguage() populates the select during startup (before media preload).
|
||||
await expect(page.locator("#lang-select option")).toHaveCount(4);
|
||||
// initLanguage() populates BOTH selects during startup (before media preload).
|
||||
// The welcome select is the visible one (the panel is hidden until entry).
|
||||
await expect(page.locator("#welcome-lang option")).toHaveCount(4);
|
||||
}
|
||||
|
||||
test("language dropdown localizes chrome and resets to English on reload", async ({ page }) => {
|
||||
await boot(page);
|
||||
const sel = page.locator("#lang-select");
|
||||
const sel = page.locator("#welcome-lang"); // the welcome screen's selector
|
||||
|
||||
// English selected by default
|
||||
// English selected by default (both selects mirror activeLang)
|
||||
await expect(sel).toHaveValue("en");
|
||||
await expect(page.locator("#lang-select")).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en");
|
||||
|
||||
// switch to Spanish -> chrome changes, <html lang> updates
|
||||
// switch to Spanish -> chrome changes (incl. the hidden panel), <html lang> updates
|
||||
await sel.selectOption("es");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Salida");
|
||||
await expect(page.locator("#lang-select")).toHaveValue("es"); // panel select mirrors
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
|
||||
// Japanese
|
||||
@@ -31,6 +34,6 @@ test("language dropdown localizes chrome and resets to English on reload", async
|
||||
|
||||
// reload returns to English (session-only, no persistence)
|
||||
await page.reload();
|
||||
await expect(page.locator("#lang-select")).toHaveValue("en");
|
||||
await expect(page.locator("#welcome-lang")).toHaveValue("en");
|
||||
await expect(page.locator('[data-i18n="output.legend"]')).toHaveText("Output");
|
||||
});
|
||||
|
||||
@@ -6,10 +6,15 @@ import { test, expect } from "@playwright/test";
|
||||
// aren't looping"). This asserts the `ended` safety net recovers playback.
|
||||
test("loop recovers when a clip reaches `ended` (no freeze on last frame)", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Enter via the welcome screen (Video defaults on → the loop video loads).
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
// Wait for the loop video to actually have media before forcing the stuck state.
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector("#loading")?.classList.contains("done"),
|
||||
() => { const v = document.getElementById("vid-loop") as HTMLVideoElement; return v && v.duration > 0; },
|
||||
null,
|
||||
{ timeout: 45_000 },
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
|
||||
const r = await page.evaluate(async () => {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// Runs against the BUILT static site: dist/ served on :8077, media (CORS) on :8078
|
||||
// by serve-static.mjs. Build with `--media-base http://localhost:8078/` so config.js
|
||||
// points media cross-origin — the production CORS path, locally. See README.md.
|
||||
const APP = "http://localhost:8077/human-experience-simulator/";
|
||||
|
||||
// Enter the simulation via the welcome screen (Video defaults on → media plays).
|
||||
async function enter(page: Page) {
|
||||
await page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 30_000 });
|
||||
await page.locator("#welcome-launch").click();
|
||||
await page.waitForSelector("#welcome", { state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
test("boots fully static — no /api calls, ring + clips load from baked JSON", async ({ page }) => {
|
||||
const apiCalls: string[] = [];
|
||||
page.on("request", (r) => { if (r.url().includes("/api/")) apiCalls.push(r.url()); });
|
||||
await page.goto(APP);
|
||||
await enter(page);
|
||||
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
|
||||
expect(apiCalls, "static build must not call /api/*").toEqual([]);
|
||||
});
|
||||
@@ -17,6 +25,7 @@ test("dream shader runs on cross-origin media without tainting the GL texture",
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(String(e)));
|
||||
await page.goto(APP);
|
||||
await enter(page);
|
||||
await expect(page.locator("#vid")).toBeVisible({ timeout: 30_000 });
|
||||
// crank the Feel (Right) knob to engage the dream, let a few frames render
|
||||
await page.evaluate(() => {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
|
||||
// The Welcome screen is the universal entry gate (see
|
||||
// docs/superpowers/specs/2026-06-30-welcome-screen-design.md). It carries the
|
||||
// photosensitivity heads-up, the four output controls (Video on / Reduce motion
|
||||
// off / Audio 2 / English), a "Launch Simulator" button, and the "Loading
|
||||
// Universe" progress. The right panel is hidden until the visitor enters.
|
||||
|
||||
const ready = (page: Page) =>
|
||||
page.waitForFunction(() => (window as any).__hefReady === true, null, { timeout: 45_000 });
|
||||
|
||||
// Set a range input's value + fire `input` (Playwright's fill() doesn't drive ranges).
|
||||
async function setRange(page: Page, id: string, value: string) {
|
||||
await page.evaluate(({ id, value }) => {
|
||||
const el = document.getElementById(id) as HTMLInputElement;
|
||||
el.value = value;
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, { id, value });
|
||||
}
|
||||
|
||||
test("welcome screen shows on load with the expected defaults; the panel is hidden", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.locator("#welcome")).toBeVisible();
|
||||
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
|
||||
|
||||
// Defaults: Video ON, Reduce motion OFF, Audio 2, English.
|
||||
await expect(page.locator("#welcome-visual")).toBeChecked();
|
||||
await expect(page.locator("#welcome-reduce-motion")).not.toBeChecked();
|
||||
await expect(page.locator("#welcome-audio")).toHaveValue("2");
|
||||
await expect(page.locator("#welcome-audio-val")).toHaveText("2");
|
||||
await expect(page.locator("#welcome-lang")).toHaveValue("en");
|
||||
|
||||
// Heads-up + Launch button present; "Loading Universe" progress present.
|
||||
await expect(page.locator("#welcome-title")).toBeVisible();
|
||||
await expect(page.locator("#welcome-launch")).toHaveText("Launch Simulator");
|
||||
await expect(page.locator(".welcome-loading")).toBeVisible();
|
||||
|
||||
// The right panel is hidden behind the welcome screen.
|
||||
await expect(page.locator(".panel")).toBeHidden();
|
||||
});
|
||||
|
||||
test("the language picker re-renders the welcome text live", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const sel = page.locator("#welcome-lang");
|
||||
await expect(sel).toHaveValue("en");
|
||||
await expect(page.locator("#welcome-launch")).toHaveText("Launch Simulator");
|
||||
|
||||
await sel.selectOption("es");
|
||||
await expect(page.locator("#welcome-launch")).toHaveText("Iniciar simulador");
|
||||
await expect(page.locator("#welcome-title")).toHaveText(/Atención/);
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "es");
|
||||
// the hidden panel selector mirrors the choice
|
||||
await expect(page.locator("#lang-select")).toHaveValue("es");
|
||||
|
||||
await sel.selectOption("ja");
|
||||
await expect(page.locator("#welcome-launch")).toHaveText("シミュレーターを起動");
|
||||
});
|
||||
|
||||
test("Launch when already loaded enters the simulation directly and reveals the panel", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await ready(page);
|
||||
|
||||
await page.locator("#welcome-launch").click();
|
||||
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.locator(".panel")).toBeVisible();
|
||||
|
||||
// Welcome defaults carried into the panel: Video on, Audio 2.
|
||||
await expect(page.locator("#visual")).toBeChecked();
|
||||
await expect(page.locator("#audio")).toHaveValue("2");
|
||||
});
|
||||
|
||||
test("welcome control values carry into the panel on entry", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await ready(page);
|
||||
|
||||
await setRange(page, "welcome-audio", "5");
|
||||
await page.locator('label[for="welcome-visual"]').click(); // default-on → off
|
||||
await page.locator('label[for="welcome-reduce-motion"]').click(); // default-off → on
|
||||
await page.locator("#welcome-launch").click();
|
||||
|
||||
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(page.locator("#audio")).toHaveValue("5");
|
||||
await expect(page.locator("#visual")).not.toBeChecked();
|
||||
await expect(page.locator("#reduce-motion")).toBeChecked();
|
||||
});
|
||||
|
||||
test("Launch while still loading shows the centered loader with controls, then auto-enters", async ({ page }) => {
|
||||
// Stall media so phase-1 preload hasn't finished when Launch is pressed.
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((r) => (release = r));
|
||||
await page.route("**/media/**", async (route) => {
|
||||
await gate;
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#welcome")).toHaveClass(/welcoming/);
|
||||
|
||||
await page.locator("#welcome-launch").click();
|
||||
|
||||
// State B: messaging + button hidden, controls remain, loader shown (centered).
|
||||
await expect(page.locator("#welcome")).toHaveClass(/loading/);
|
||||
await expect(page.locator("#welcome-title")).toBeHidden();
|
||||
await expect(page.locator("#welcome-launch")).toBeHidden();
|
||||
await expect(page.locator(".welcome-controls")).toBeVisible();
|
||||
await expect(page.locator(".welcome-loading")).toBeVisible();
|
||||
|
||||
// Let media through → universe becomes ready → auto-enter.
|
||||
release();
|
||||
await expect(page.locator("#welcome")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(page.locator(".panel")).toBeVisible();
|
||||
});
|
||||
Reference in New Issue
Block a user