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:
+95
-70
@@ -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);
|
||||
|
||||
@@ -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: "軌道" },
|
||||
|
||||
+34
-12
@@ -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 & 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 & 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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user