From 6cf8fffc51304c15245de5ddca4d71cfd9a3519d Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Tue, 30 Jun 2026 09:18:04 -0700 Subject: [PATCH] feat(a11y): one-time photosensitivity warning gate Co-Authored-By: Claude Opus 4.8 (1M context) --- simulator/e2e/tests/a11y.spec.ts | 14 ++++++++++++++ simulator/static/app.js | 21 +++++++++++++++++++++ simulator/static/i18n.js | 3 +++ simulator/static/index.html | 7 +++++++ simulator/static/style.css | 12 ++++++++++++ 5 files changed, 57 insertions(+) diff --git a/simulator/e2e/tests/a11y.spec.ts b/simulator/e2e/tests/a11y.spec.ts index 8229e6b..11654b2 100644 --- a/simulator/e2e/tests/a11y.spec.ts +++ b/simulator/e2e/tests/a11y.spec.ts @@ -26,3 +26,17 @@ test.describe("Task 1 — Output panel layout", () => { expect(pBox!.height).toBeLessThan(40); // one row, not stacked }); }); + +test.describe("Task 4 — Photosensitivity warning gate", () => { + test("motion warning gate shows once, then is remembered", 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(); + }); +}); diff --git a/simulator/static/app.js b/simulator/static/app.js index f843d44..3fdc87a 100644 --- a/simulator/static/app.js +++ b/simulator/static/app.js @@ -916,9 +916,29 @@ function jumpToScale(idx) { // 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"; let devMode = false; let devStatsTimer = null; +// 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])); @@ -1295,6 +1315,7 @@ async function main() { await preloadAllMedia(); // download all media, updating the loading bar hideLoading(); // experience is ready — boots SILENT (video off, audio 0) $("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage + 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 diff --git a/simulator/static/i18n.js b/simulator/static/i18n.js index 497b34d..77f433f 100644 --- a/simulator/static/i18n.js +++ b/simulator/static/i18n.js @@ -32,6 +32,9 @@ "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" }, "scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" }, "scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" }, "scale.sky": { en: "sky", es: "cielo", fr: "ciel", ja: "空" }, diff --git a/simulator/static/index.html b/simulator/static/index.html index dc91fbe..5784125 100644 --- a/simulator/static/index.html +++ b/simulator/static/index.html @@ -30,6 +30,13 @@ + diff --git a/simulator/static/style.css b/simulator/static/style.css index 314f43c..18d48d1 100644 --- a/simulator/static/style.css +++ b/simulator/static/style.css @@ -90,6 +90,18 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl } .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); } /* 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). */