feat(welcome): pre-load Welcome screen with controls + Launch gate

Per docs/superpowers/specs/2026-06-30-welcome-screen-design.md. Fold the bare
#loading splash, the #motion-warning modal, and the #run-sim button into one
#welcome overlay — the universal entry gate:

- State A (.welcoming): heads-up notice + four output controls (Video ON,
  Reduce motion OFF, Audio 2, Language) + 'Launch Simulator' button, with the
  'Loading Universe' progress pinned at the bottom (media preloads behind it).
- State B (.loading): pressing Launch while still loading hides the messaging +
  button, keeps the controls, and centers the loader; entry happens automatically
  once phase-1 finishes. Launch when already loaded enters directly.
- The right .panel is hidden until entry (body:has(#welcome)); welcome control
  values carry into the panel inputs on entry. Language + Reduce motion drive the
  shared state live (both selects/checkboxes mirror); Video + Audio apply on entry.
- Launch is the single audio-unlock gesture (beginExperience runs in-gesture even
  when entry is deferred). Retire WARN_KEY (welcome shows every load); entry uses
  the chosen Audio level rather than forcing 2.
- i18n: new welcome.launch key + es/fr/ja for the heads-up (warn.title/body),
  reworded to point at the on-screen Reduce-motion control; drop dead run.button.

Tests: new welcome.spec.ts (defaults, live language re-render, State B
deferred-entry, straight-in when loaded, control carry-over, panel hidden);
update altitude-lock/a11y/i18n/loop-recovery/static-build e2e + the Python
audio/credits e2e to the welcome flow; add a window.__hefReady diagnostic seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BenStullsBets
2026-06-30 11:14:35 -07:00
parent b9de6695a0
commit f077193df9
12 changed files with 408 additions and 207 deletions
+95 -70
View File
@@ -1115,7 +1115,6 @@ function onDialKey(e) {
// data the renderer already has (clips, ring, the alteration response, the preload
// cache) — no server endpoints. Editing labels stays in /author.html.
const DEV_KEY = "hef.devMode";
const WARN_KEY = "hef.motionWarnDismissed";
const RM_KEY = "hef.reduceMotion";
let devMode = false;
let devStatsTimer = null;
@@ -1126,17 +1125,25 @@ let devStatsTimer = null;
// jump instantly instead of tweening — knob changes still re-grade the still.
let reduceMotion = false;
function isReduced() { return reduceMotion; }
// Two checkboxes drive the same reduceMotion state: the welcome screen's and the
// panel's. Either flips the flag, persists it, mirrors the other, and re-applies.
const RM_BOXES = ["welcome-reduce-motion", "reduce-motion"];
function syncReduceMotionBoxes() {
for (const id of RM_BOXES) { const b = $(id); if (b && b.checked !== reduceMotion) b.checked = reduceMotion; }
}
function initReduceMotion() {
const box = $("reduce-motion");
let stored = null;
try { stored = localStorage.getItem(RM_KEY); } catch (_) {}
const prefers = window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
reduceMotion = stored === null ? !!prefers : stored === "1";
if (box) {
box.checked = reduceMotion;
syncReduceMotionBoxes();
for (const id of RM_BOXES) {
const box = $(id);
if (!box) continue;
box.addEventListener("change", () => {
reduceMotion = box.checked;
try { localStorage.setItem(RM_KEY, reduceMotion ? "1" : "0"); } catch (_) {}
syncReduceMotionBoxes();
applyReduceMotion();
});
}
@@ -1152,25 +1159,6 @@ function applyReduceMotion() {
}
}
// One-time photosensitivity notice shown over the stage before the experience
// begins. Independent of media load; gated visually by the loading splash, then
// dismissed-once (persisted) so returning visitors go straight to "Run simulation".
function motionWarnDismissed() {
try { return localStorage.getItem(WARN_KEY) === "1"; } catch (_) { return false; }
}
function maybeShowMotionWarning() {
const gate = $("motion-warning");
if (!gate) return;
if (motionWarnDismissed()) { gate.classList.add("hidden"); return; }
gate.classList.remove("hidden");
$("motion-warning-continue").addEventListener("click", () => {
try { localStorage.setItem(WARN_KEY, "1"); } catch (_) {}
gate.classList.add("hidden");
const btn = $("run-sim");
if (btn) btn.focus({ preventScroll: true }); // move focus to the entry point
}, { once: true });
}
const escapeHtml = (s) => String(s).replace(/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
@@ -1480,35 +1468,72 @@ function restAudio(index) {
// synchronously from the toggle gesture the first time it plays (Safari unlock).
function applyAudio() { restAudio(ringIndex); }
// "Loading Universe…" splash — hidden once media is preloaded; reflects progress.
// "Loading Universe…" progress (lives in the welcome screen) — reflects preload.
function setLoadingProgress(done, total) {
const fill = document.getElementById("loading-fill");
if (fill && total) fill.style.width = Math.round((done / total) * 100) + "%";
}
function hideLoading() {
const el = document.getElementById("loading");
let videoEverOn = false; // has Video ever been switched on this session?
let universeReady = false; // has phase-1 preload finished (safe to enter)?
let launchPending = false; // did the visitor press Launch while still loading?
// Begin the experience: mark video-on and play the soundtrack at the chosen
// level. Called from the Launch gesture so the audio play() unlocks on Safari
// (which blocks play() outside a user gesture). Idempotent via videoEverOn.
function beginExperience() {
videoEverOn = true;
updateAudioLevelLabel();
applyAudio();
}
// Welcome screen ----------------------------------------------------------------
// The welcome controls are mirrors of the panel controls. Reduce-motion and
// language drive shared state live (see initReduceMotion / setLanguage); Video and
// Audio are copied into the panel inputs at entry.
function applyWelcomeToPanel() {
const wv = $("welcome-visual"), wa = $("welcome-audio");
if (wv) $("visual").checked = wv.checked;
if (wa) { $("audio").value = wa.value; updateAudioLevelLabel(); }
}
function updateWelcomeAudioLabel() {
const el = $("welcome-audio-val");
if (el) el.textContent = String(+($("welcome-audio") || {}).value || 0);
}
// Remove the welcome overlay (fade out) — the panel reveals via `body:has(#welcome)`.
function enterSimulation() {
applyWelcomeToPanel(); // pick up any last-moment Video/Audio change made in State B
beginExperience();
update(); // render with the chosen Video/Audio state
const el = $("welcome");
if (!el) return;
el.classList.add("done");
setTimeout(() => el.remove(), 700);
}
let videoEverOn = false; // has Video ever been switched on this session?
// Launch button: the single entry gesture. If the universe is ready, go straight
// in; otherwise switch the welcome screen to its centered-loading state and enter
// automatically once phase-1 finishes. beginExperience() runs in THIS gesture so
// audio unlocks on Safari even when entry is deferred.
function onLaunch() {
applyWelcomeToPanel();
beginExperience();
if (universeReady) {
enterSimulation();
} else {
launchPending = true;
const w = $("welcome");
if (w) w.classList.replace("welcoming", "loading");
}
}
// The gentle audio level the experience starts at — coupled in on the first
// Video-on (via the Run simulation button or the Video toggle directly).
const START_AUDIO_LEVEL = "2";
// Begin the experience: Video on, audio snapped to the gentle start level, and
// the "Run simulation" prompt dismissed. Audio is set + played in THIS gesture so
// it unlocks on Safari (which blocks play() outside a user gesture). One flip = the
// full experience at a gentle level. Idempotent across re-toggles via videoEverOn.
function beginExperience() {
videoEverOn = true;
const btn = $("run-sim");
if (btn) btn.classList.add("hidden");
$("audio").value = START_AUDIO_LEVEL;
updateAudioLevelLabel();
applyAudio();
function initWelcome() {
updateWelcomeAudioLabel();
const wa = $("welcome-audio");
if (wa) wa.addEventListener("input", updateWelcomeAudioLabel);
const launch = $("welcome-launch");
if (launch) launch.addEventListener("click", onLaunch);
}
async function main() {
@@ -1518,8 +1543,9 @@ async function main() {
await landScale(); // pick the initial scale's pool member before first render
buildDial(); // draw the altitude knob from the ring's scales
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
initLanguage(); // populate the language dropdown + wire live switching
initReduceMotion(); // reduced-motion state + toggle (default from OS pref)
initLanguage(); // populate both language dropdowns + wire live switching
initReduceMotion(); // reduced-motion state + both toggles (default from OS pref)
initWelcome(); // wire the welcome-screen controls + Launch button
renderScaleReadout();
// Sliders stream on "input"; the toggles fire on "change".
for (const id of ["left", "right", "mood"]) $(id).addEventListener("input", debounced);
@@ -1528,15 +1554,9 @@ async function main() {
// play() outside a user gesture).
$("audio").addEventListener("input", () => { updateAudioLevelLabel(); applyAudio(); debounced(); });
updateAudioLevelLabel();
// "Run simulation" button: the obvious starting point. Turns Video on, snaps
// audio to the start level, and dismisses itself — all in this click gesture.
$("run-sim").addEventListener("click", () => {
$("visual").checked = true; // setting .checked does NOT fire "change", so we drive the rest here
beginExperience();
debounced();
});
// Video toggle: the FIRST time video turns on (without the Run simulation button),
// begin the experience too — couple audio in and dismiss the prompt.
// Video toggle (panel): the FIRST time video turns on without having entered via
// Launch, begin the experience too — couple audio in. (Normally Launch entered
// first, so videoEverOn is already true and this is a no-op beyond re-render.)
$("visual").addEventListener("change", () => {
if ($("visual").checked && !videoEverOn) beginExperience();
debounced();
@@ -1553,36 +1573,40 @@ async function main() {
// + the morphs connecting them (~126 MB). Fast to start; the knob is smooth from the
// first turn because the random pick only ever lands on loaded clips (eligibility).
await cacheMany(HEFPreload.phase1Files(ring.scales, _preloadDeps()));
hideLoading(); // experience is ready — boots SILENT (video off, audio 0)
$("run-sim").classList.remove("hidden"); // reveal the obvious starting point over the black stage
universeReady = true; // safe to enter — the welcome "Loading Universe" bar is full
window.__hefReady = true; // diagnostic seam: e2e waits on this before pressing Launch
// If the visitor already pressed Launch (and is watching the centered loader),
// enter now that the universe is ready.
if (launchPending) enterSimulation();
// Phase 2 — grow the pool in the background: more clips per altitude + their morphs.
// Each becomes eligible for the random pick only once fully loaded, so navigation
// keeps using the already-loaded clips until the new ones are ready (no stall).
preloadAllMedia(); // NO await — streams the rest behind the unlocked universe
maybeShowMotionWarning(); // first-visit photosensitivity notice, above the button
// No un-gestured auto-start: a browser autoplay policy blocks an audio play() made
// outside a user gesture (muted video survives, sound does not), so an auto-start
// would show video but stay silent. Instead the experience waits for the operator to
// press "Run simulation" (or turn Video on directly) — that click IS the gesture, and
// beginExperience() lifts audio to the start level and plays the soundtrack in-gesture,
// so sound reliably comes up with video.
// outside a user gesture (muted video survives, sound does not). The Launch click IS
// the gesture — beginExperience() plays the soundtrack in-gesture, so sound reliably
// comes up with video whether entry is immediate or deferred until media is ready.
}
// Populate the language dropdown from the registry and wire live switching.
// English default, session-only (no persistence). Switching swaps UI chrome
// and re-renders the current annotations/affect in place — no reload, no fetch.
// Two selects share the same language state: the welcome screen's and the panel's.
const LANG_SELECTS = ["welcome-lang", "lang-select"];
function initLanguage() {
const sel = $("lang-select");
if (!sel) return;
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
const o = document.createElement("option");
o.value = code;
o.textContent = nativeName;
sel.appendChild(o);
for (const id of LANG_SELECTS) {
const sel = $(id);
if (!sel) continue;
for (const { code, nativeName } of HEFi18n.LANGUAGES) {
const o = document.createElement("option");
o.value = code;
o.textContent = nativeName;
sel.appendChild(o);
}
sel.value = activeLang;
sel.addEventListener("change", () => setLanguage(sel.value));
}
sel.value = activeLang;
applyUiStrings(activeLang);
sel.addEventListener("change", () => setLanguage(sel.value));
}
// Fill every [data-i18n] node with its catalog string for `lang`.
@@ -1595,6 +1619,7 @@ function applyUiStrings(lang) {
function setLanguage(lang) {
activeLang = lang;
for (const id of LANG_SELECTS) { const s = $(id); if (s && s.value !== lang) s.value = lang; }
applyUiStrings(lang);
renderScaleReadout();
if (lastOverlay) renderOverlay(lastOverlay.level, lastOverlay.intensity);
+3 -4
View File
@@ -19,7 +19,7 @@
const UI_STRINGS = {
"app.title": { en: "Human Experience Filter — Alteration Preview", es: "Filtro de Experiencia Humana — Vista previa de alteración", fr: "Filtre dExpérience Humaine — Aperçu daltération", ja: "ヒューマン・エクスペリエンス・フィルター — 変容プレビュー" },
"loading.title": { en: "Loading Universe", es: "Cargando el universo", fr: "Chargement de lunivers", ja: "宇宙を読み込み中" },
"run.button": { en: "Run simulation", es: "Iniciar simulación", fr: "Lancer la simulation", ja: "シミュレーションを開始" },
"welcome.launch": { en: "Launch Simulator", es: "Iniciar simulador", fr: "Lancer le simulateur", ja: "シミュレーターを起動" },
"credits.link": { en: "ⓘ Credits & licenses", es: "ⓘ Créditos y licencias", fr: "ⓘ Crédits et licences", ja: "ⓘ クレジットとライセンス" },
"output.legend": { en: "Output", es: "Salida", fr: "Sortie", ja: "出力" },
"output.video": { en: "Video", es: "Vídeo", fr: "Vidéo", ja: "映像" },
@@ -32,9 +32,8 @@
"knobs.mood": { en: "Mood — dark ◀ 0 ▶ light", es: "Ánimo — oscuro ◀ 0 ▶ claro", fr: "Humeur — sombre ◀ 0 ▶ clair", ja: "ムード — 暗 ◀ 0 ▶ 明" },
"devmode.label": { en: "Dev Mode", es: "Modo desarrollo", fr: "Mode dév", ja: "開発モード" },
"rm.label": { en: "Reduce motion", es: "Reducir movimiento", fr: "Réduire les animations", ja: "動きを減らす" },
"warn.title": { en: "Heads up — motion & flashing" },
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin." },
"warn.continue": { en: "Continue" },
"warn.title": { en: "Heads up — motion & flashing", es: "Atención — movimiento y destellos", fr: "Attention — mouvement et flashs", ja: "ご注意 — 動きと点滅" },
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa «Reducir movimiento» abajo antes de iniciar.", fr: "Cette expérience contient des mouvements continus, des flashs et des images changeantes. Si vous êtes sensible au mouvement ou aux lumières clignotantes, activez « Réduire les animations » ci-dessous avant de lancer.", ja: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください。" },
"about.link": { en: "About", es: "Acerca de", fr: "À propos", ja: "概要" },
"scale.cosmos": { en: "cosmos", es: "cosmos", fr: "cosmos", ja: "宇宙" },
"scale.orbit": { en: "orbit", es: "órbita", fr: "orbite", ja: "軌道" },
+34 -12
View File
@@ -7,10 +7,40 @@
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="loading">
<div class="loading-inner">
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
<div class="loading-bar"><div id="loading-fill"></div></div>
<!-- Welcome screen: the universal entry gate. State A (.welcoming) shows the
heads-up + controls + Launch button with the loader at the bottom; State B
(.loading, after Launch while media is still loading) hides the messaging +
button, keeps the controls, and centers the loader. Removed on entry. -->
<div id="welcome" class="welcoming" role="dialog" aria-modal="true" aria-labelledby="welcome-title">
<div class="welcome-inner">
<div class="welcome-message">
<h2 id="welcome-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” below before you launch.</p>
</div>
<div class="welcome-controls">
<label class="dev-switch" for="welcome-visual">
<input type="checkbox" id="welcome-visual" checked />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="output.video">Video</span>
</label>
<label class="audio-level"><span data-i18n="output.audio">Audio</span>
<input type="range" id="welcome-audio" min="0" max="10" value="2" step="1" />
<span id="welcome-audio-val">2</span>/10
</label>
<label class="dev-switch" for="welcome-reduce-motion">
<input type="checkbox" id="welcome-reduce-motion" />
<span class="dev-switch-track"><span class="dev-switch-thumb"></span></span>
<span class="dev-switch-label" data-i18n="rm.label">Reduce motion</span>
</label>
<label class="lang-pick">🌐
<select id="welcome-lang" aria-label="Language"></select>
</label>
</div>
<button type="button" id="welcome-launch" class="run-sim" data-i18n="welcome.launch">Launch Simulator</button>
<div class="welcome-loading">
<div class="loading-title"><span data-i18n="loading.title">Loading Universe</span><span class="loading-dots"></span></div>
<div class="loading-bar"><div id="loading-fill"></div></div>
</div>
</div>
</div>
<header>
@@ -30,14 +60,6 @@
<svg id="overlay" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
<svg id="affect" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true"></svg>
<div id="black" class="black hidden"></div>
<button type="button" id="run-sim" class="run-sim hidden" data-i18n="run.button">Run simulation</button>
<div id="motion-warning" class="motion-warning hidden" role="dialog" aria-modal="true" aria-labelledby="mw-title">
<div class="mw-card">
<h2 id="mw-title" data-i18n="warn.title">Heads up — motion &amp; flashing</h2>
<p data-i18n="warn.body">This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on “Reduce motion” in the panel before you begin.</p>
<button type="button" id="motion-warning-continue" class="run-sim" data-i18n="warn.continue">Continue</button>
</div>
</div>
</div>
</section>
+42 -25
View File
@@ -76,32 +76,18 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
.black { position: absolute; inset: 0; background: #000; opacity: 1;
z-index: 50; transform: translateZ(0); transition: opacity 200ms ease; }
.hidden { display: none; }
/* "Run simulation" — the obvious starting point, shown over the (black) stage once
media is preloaded. Sits above the black cover (z-index 50). Dismissed when the
experience begins (the button, or turning Video on directly). */
/* "Launch Simulator" — the single entry button on the welcome screen. Sits in
the welcome overlay's flow (centered column), not absolutely positioned. */
.run-sim {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
z-index: 60; cursor: pointer; user-select: none;
cursor: pointer; user-select: none;
padding: 0.85rem 2rem; border: 0; border-radius: 999px;
font: 600 18px/1 system-ui, sans-serif; letter-spacing: 0.03em;
color: #04101f; background: linear-gradient(90deg, #4e9cff, #9af);
box-shadow: 0 4px 24px rgba(78, 156, 255, 0.45);
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.run-sim:hover { transform: translate(-50%, -50%) scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
.run-sim:active { transform: translate(-50%, -50%) scale(0.98); }
/* One-time photosensitivity warning, over the stage (above run-sim's z-60). */
.motion-warning { position: absolute; inset: 0; z-index: 70; display: flex;
align-items: center; justify-content: center; padding: 1rem;
background: rgba(2, 4, 10, 0.92); }
.motion-warning.hidden { display: none; }
.mw-card { max-width: 30rem; text-align: center; color: #dfeaff; }
.mw-card h2 { font-size: 1.1rem; margin: 0 0 0.6rem; }
.mw-card p { font-size: 0.95rem; line-height: 1.5; margin: 0 0 1.2rem; color: #c3d2e8; }
/* The Continue button is centered in flow here, not absolutely positioned. */
.mw-card .run-sim { position: static; transform: none; }
.mw-card .run-sim:hover { transform: scale(1.04); }
.mw-card .run-sim:active { transform: scale(0.98); }
.run-sim:hover { transform: scale(1.04); box-shadow: 0 6px 30px rgba(78, 156, 255, 0.6); }
.run-sim:active { transform: scale(0.98); }
/* Stack the two Output toggles (Video / Audio) with a little breathing room. */
.dev-switch + .dev-switch { margin-top: 0.45rem; }
/* Globe + language select on one row (the select no longer goes full-width here). */
@@ -114,6 +100,9 @@ main { display: flex; gap: 1rem; padding: 1rem; flex-wrap: wrap; align-items: fl
.panel { flex: 0 0 280px; display: flex; flex-direction: column; gap: 0.8rem;
max-height: calc(100vh - 2rem); overflow-y: auto;
position: sticky; top: 1rem; }
/* The control panel stays hidden behind the welcome screen and appears only once
the welcome overlay is removed on entry. */
body:has(#welcome) .panel { display: none; }
fieldset { border: 1px solid #333; border-radius: 6px; }
legend { color: #9af; padding: 0 0.4rem; }
label { display: block; margin: 0.4rem 0; }
@@ -190,17 +179,45 @@ input[type=range], select { width: 100%; }
.dev-anno .anno-meta { color: #678; }
.dev-anno .anno-empty { color: #567; font-style: italic; }
/* "Loading Universe…" splash — shown until all media is preloaded and the
experience is ready to run smoothly, then faded out. */
#loading {
/* Welcome screen — the universal entry gate (fixed, above everything). Holds the
heads-up notice, the four output controls, the Launch button, and the
"Loading Universe" progress, which fills as media preloads in the background. */
#welcome {
position: fixed; inset: 0; z-index: 10000;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(ellipse at center, #0a1230 0%, #02030a 70%);
color: #cfe3ff; user-select: none;
overflow-y: auto;
transition: opacity 0.6s ease;
}
#loading.done { opacity: 0; pointer-events: none; }
.loading-inner { display: flex; flex-direction: column; align-items: center; gap: 1.1rem; }
#welcome.done { opacity: 0; pointer-events: none; }
.welcome-inner {
min-height: 100%; box-sizing: border-box;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 1.4rem; padding: 2.5rem 1.25rem 5.5rem;
}
.welcome-message { max-width: 32rem; text-align: center; }
.welcome-message h2 { font-size: 1.25rem; margin: 0 0 0.6rem; color: #dfeaff; }
.welcome-message p { font-size: 0.98rem; line-height: 1.55; margin: 0; color: #c3d2e8; }
/* The four output controls, boxed for legibility over the dark overlay. Spacing
comes from the column gap, so reset the controls' own in-panel margins. */
.welcome-controls {
display: flex; flex-direction: column; gap: 0.6rem;
width: 100%; max-width: 18rem;
padding: 1rem 1.1rem; border: 1px solid #2a3550; border-radius: 8px;
background: rgba(10, 18, 48, 0.5);
}
.welcome-controls .dev-switch,
.welcome-controls .audio-level,
.welcome-controls .lang-pick { margin: 0; }
/* "Loading Universe" — pinned to the bottom in State A (.welcoming); recentered in
the column flow in State B (.loading), once messaging + button are hidden. */
.welcome-loading {
position: absolute; left: 0; right: 0; bottom: 2rem;
display: flex; flex-direction: column; align-items: center; gap: 1rem;
}
#welcome.loading .welcome-message,
#welcome.loading #welcome-launch { display: none; }
#welcome.loading .welcome-loading { position: static; }
.loading-title { font: 600 30px/1.2 system-ui, sans-serif; letter-spacing: 0.04em; }
.loading-dots::after {
content: ""; animation: loading-dots 1.4s steps(4, end) infinite;