Merge remote-tracking branch 'origin/main' into worktree-welcome-screen-controls

# Conflicts:
#	simulator/e2e/tests/a11y.spec.ts
#	simulator/static/app.js
#	simulator/static/i18n.js
This commit is contained in:
BenStullsBets
2026-06-30 12:54:06 -07:00
37 changed files with 4904 additions and 91 deletions
+111 -40
View File
@@ -313,7 +313,7 @@ function loadLoop(clip, landFrame) {
function playLoop() {
if (isReduced()) return; // reduced motion holds a still frame — never auto-play the loop
if (loopVid.paused) loopVid.play().catch(() => {});
applyPlaySpeed(); // honor the chosen speed/direction (forward, freeze, or reverse)
}
function ensureClipMedia() {
@@ -1076,7 +1076,11 @@ function onDialUp(e) {
}
return;
}
// No auto-complete: hold the blend wherever the knob stopped (continuous-encoder model).
// Snap on release: a drag is a way of GRABBING the dial, not a free-standing
// encoder detent — so when the operator lets go between altitudes, auto-scrub the
// short way to the CLOSEST one and settle there (frac 0 locks). The result is the
// same as a tap/label-click: a grab-and-release always lands on a discrete altitude.
autoScrub(Math.round(pos));
}
// Click a label → auto-scrub the SHORTEST signed way around the ring to that scale.
@@ -1115,47 +1119,112 @@ 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 RM_KEY = "hef.reduceMotion";
const SPEED_KEY = "hef.playSpeed";
let devMode = false;
let devStatsTimer = null;
// --- Reduced motion (freeze-to-stills) ---------------------------------------
// Default follows the OS `prefers-reduced-motion` until the visitor chooses, then
// their choice persists. When ON: video holds a frame (paused), auto transitions
// 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; }
// --- Playback speed + reduced motion -----------------------------------------
// "Reduce motion" is no longer a visible toggle — its accessibility behavior is
// derived LIVE from the OS `prefers-reduced-motion` setting (no persisted
// override). The visible control is the Playback Speed slider, which drives ONLY
// the resting altitude loop video (#vid-loop); the morph/transition video (#vid)
// is scrub-driven and is never speed-controlled. Below 0x the loop plays in
// reverse via a manual rAF loop (native negative playbackRate is unreliable —
// Chrome ignores it). When the OS asks for reduced motion the loop stays frozen
// and the slider is disabled, so a vestibular/photosensitive visitor never gets
// motion they did not ask for.
const SPEED_EPS = 0.02; // |speed| below this = freeze (treat as 0)
let playSpeed = 1;
let revRaf = 0; // rAF handle for the manual reverse loop
let revLast = 0; // last rAF timestamp (ms) for dt
const reduceMq = (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)")) || null;
function isReduced() { return !!(reduceMq && reduceMq.matches); }
function stopReverse() {
if (revRaf) { cancelAnimationFrame(revRaf); revRaf = 0; }
}
function initReduceMotion() {
// One reverse-loop frame: step #vid-loop's currentTime backward via the pure
// HEFScrub.reverseStep math, then schedule the next. dt is capped so a stalled
// tab can't make a giant jump.
function reverseTick(now) {
if (!revRaf) return;
const dt = revLast ? Math.min((now - revLast) / 1000, 0.1) : 0;
revLast = now;
const dur = loopVid.duration;
if (Number.isFinite(dur) && dur > 0 && dt > 0) {
const r = HEFScrub.reverseStep(loopVid.currentTime, Math.abs(playSpeed), dt, loopStartFrame(), dur);
try { loopVid.currentTime = r.next; } catch (_) { /* not seekable yet */ }
}
revRaf = requestAnimationFrame(reverseTick);
}
function startReverse() {
if (revRaf) return;
loopVid.pause(); // the rAF owns the element while reversing
revLast = 0;
revRaf = requestAnimationFrame(reverseTick);
}
// Apply the chosen slider speed to the resting loop video. When reduced motion
// holds or video output is off, the freeze/play decision belongs to
// syncReducedMotion / playLoop — here we only make sure no reverse loop is left
// running and the forward rate is sane for when it resumes.
function applyPlaySpeed() {
stopReverse();
if (isReduced() || !videoEverOn || !$("visual").checked) {
loopVid.playbackRate = Math.max(0.0625, Math.abs(playSpeed) || 1);
return;
}
if (playSpeed > SPEED_EPS) {
loopVid.playbackRate = playSpeed;
if (loopVid.paused) loopVid.play().catch(() => {});
} else if (playSpeed < -SPEED_EPS) {
startReverse();
} else {
loopVid.pause(); // ~0x → freeze on the current frame
}
}
// Both the welcome screen's slider and the panel's drive the same playSpeed; each
// mirrors the other and the shared "1.00×" readout.
const SPEED_SLIDERS = ["welcome-play-speed", "play-speed"];
const SPEED_VALS = ["welcome-play-speed-val", "play-speed-val"];
function syncSpeedSliders() {
for (const id of SPEED_SLIDERS) { const s = $(id); if (s && s.value !== String(playSpeed)) s.value = String(playSpeed); }
}
function updateSpeedLabel() {
const txt = playSpeed.toFixed(2).replace("-", "") + "×";
for (const id of SPEED_VALS) { const el = $(id); if (el) el.textContent = txt; }
}
function initPlaySpeed() {
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";
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();
try { stored = localStorage.getItem(SPEED_KEY); } catch (_) {}
playSpeed = stored === null ? 1 : (parseFloat(stored) || 0);
syncSpeedSliders();
for (const id of SPEED_SLIDERS) {
const slider = $(id);
if (!slider) continue;
slider.addEventListener("input", () => {
playSpeed = parseFloat(slider.value) || 0;
try { localStorage.setItem(SPEED_KEY, String(playSpeed)); } catch (_) {}
syncSpeedSliders();
updateSpeedLabel();
applyPlaySpeed();
});
}
applyReduceMotion();
updateSpeedLabel();
syncReducedMotion(); // initial enable/disable + freeze/play state
if (reduceMq && reduceMq.addEventListener) reduceMq.addEventListener("change", syncReducedMotion);
}
function applyReduceMotion() {
if (reduceMotion) {
// Reconcile to the OS prefers-reduced-motion setting: when reduced, freeze both
// videos and disable the speed slider; otherwise (re)apply the chosen speed.
function syncReducedMotion() {
const reduced = isReduced();
for (const id of SPEED_SLIDERS) { const s = $(id); if (s) s.disabled = reduced; }
if (reduced) {
stopReverse();
if (autoRaf) { cancelAnimationFrame(autoRaf); autoRaf = 0; }
vid.pause();
loopVid.pause();
} else if (videoEverOn && $("visual").checked) {
playLoop(); // resume the held loop on opt-out
applyPlaySpeed(); // resume the held loop at the chosen speed
}
}
@@ -1478,22 +1547,24 @@ 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.
// Begin the experience: mark video-on, play the soundtrack at the chosen level,
// and start the resting loop at the chosen speed. 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();
applyPlaySpeed();
}
// 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.
// The welcome controls mirror the panel controls. Speed and Language drive shared
// state live (see initPlaySpeed / setLanguage). Video is assumed ON at entry; the
// Audio level is copied into the panel input.
function applyWelcomeToPanel() {
const wv = $("welcome-visual"), wa = $("welcome-audio");
if (wv) $("visual").checked = wv.checked;
$("visual").checked = true; // the welcome screen assumes Video on (no toggle)
const wa = $("welcome-audio");
if (wa) { $("audio").value = wa.value; updateAudioLevelLabel(); }
}
function updateWelcomeAudioLabel() {
@@ -1544,7 +1615,7 @@ async function main() {
buildDial(); // draw the altitude knob from the ring's scales
initDev(); // wire the Dev Mode toggle + pool picker (reads persisted state)
initLanguage(); // populate both language dropdowns + wire live switching
initReduceMotion(); // reduced-motion state + both toggles (default from OS pref)
initPlaySpeed(); // playback-speed sliders (welcome + panel) + OS-derived reduced motion
initWelcome(); // wire the welcome-screen controls + Launch button
renderScaleReadout();
// Sliders stream on "input"; the toggles fire on "change".
+2 -2
View File
@@ -31,9 +31,9 @@
"knobs.feel": { en: "Feel", es: "Sentir", fr: "Ressentir", ja: "感じる" },
"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: "動きを減らす" },
"speed.label": { en: "Speed", es: "Velocidad", fr: "Vitesse", ja: "再生速度" },
"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: "この体験には連続した動き、点滅、変化する映像が含まれます。動きや点滅する光に敏感な方は、起動する前に下の「動きを減らす」をオンにしてください。" },
"warn.body": { en: "This experience contains continuous motion, flashing, and shifting imagery. If you are sensitive to motion or flashing light, turn on your devices “Reduce motion” setting before you launch — the experience honours it automatically.", es: "Esta experiencia contiene movimiento continuo, destellos e imágenes cambiantes. Si eres sensible al movimiento o a las luces parpadeantes, activa el ajuste «Reducir movimiento» de tu dispositivo antes de iniciar — la experiencia lo respeta automáticamente.", 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 le réglage « Réduire les animations » de votre appareil avant de lancer — lexpérience le respecte automatiquement.", 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: "軌道" },
+20 -17
View File
@@ -15,26 +15,21 @@
<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>
<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 your devices “Reduce motion” setting before you launch — the experience honours it automatically.</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 class="lang-pick">🌐
<select id="welcome-lang" aria-label="Language"></select>
</label>
<label class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="welcome-play-speed" min="-2" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="welcome-play-speed-val" />
<span id="welcome-play-speed-val">1.00×</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">
@@ -76,11 +71,19 @@
<input type="range" id="audio" min="0" max="10" value="0" step="1" />
<span id="audio-level-val">0</span>/10
</label>
<label class="dev-switch" for="reduce-motion">
<input type="checkbox" id="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 class="audio-level play-speed"><span data-i18n="speed.label">Speed</span>
<input type="range" id="play-speed" min="-2" max="2" step="any" value="1"
list="play-speed-ticks" aria-describedby="play-speed-val" />
<span id="play-speed-val">1.00×</span>
</label>
<datalist id="play-speed-ticks">
<option value="-2"></option><option value="-1.75"></option><option value="-1.5"></option>
<option value="-1.25"></option><option value="-1"></option><option value="-0.75"></option>
<option value="-0.5"></option><option value="-0.25"></option><option value="0"></option>
<option value="0.25"></option><option value="0.5"></option><option value="0.75"></option>
<option value="1"></option><option value="1.25"></option><option value="1.5"></option>
<option value="1.75"></option><option value="2"></option>
</datalist>
<label class="lang-pick">🌐
<select id="lang-select" aria-label="Language"></select>
</label>
+18 -1
View File
@@ -41,6 +41,23 @@
return dir < 0 ? 0 : loopTailS;
}
// One frame of REVERSE loop playback for the resting altitude video. Native
// negative playbackRate is unreliable (Chrome ignores it), so app.js drives
// currentTime by hand; this is the pure math. The visible loop region is
// [loopStart, duration] (same frames the forward loop shows). Step back by
// absSpeed*dt; if it crosses below loopStart, wrap around to the tail
// (duration), carrying the overshoot modulo the region length so a big dt
// can't escape the region. Returns the next currentTime and whether a
// bottom-boundary wrap happened (so the caller can re-arm its loop state).
function reverseStep(t, absSpeed, dt, loopStart, duration) {
const L = duration - loopStart;
if (!(L > 0)) return { next: duration, wrapped: false };
const next = t - Math.abs(absSpeed) * dt;
if (next >= loopStart) return { next, wrapped: false };
const overshoot = (loopStart - next) % L;
return { next: duration - overshoot, wrapped: true };
}
// Integers strictly crossed moving prevPos -> pos, in travel order. Landing
// exactly on an integer counts as crossing it (it commits that altitude).
function integerCrossings(prevPos, pos) {
@@ -53,5 +70,5 @@
return out;
}
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, integerCrossings };
return { clamp01, wrapIndex, segmentOf, accumToPos, fracToTime, crossfadeGains, loopLandFrame, reverseStep, integerCrossings };
});
+4
View File
@@ -153,6 +153,10 @@ input[type=range], select { width: 100%; }
color: #9af; font-weight: 600; letter-spacing: 0.3px; }
.audio-level input[type=range] { flex: 1; width: auto; }
#audio-level-val { font-variant-numeric: tabular-nums; min-width: 1.2em; text-align: right; }
/* Playback speed (2×…2×) reuses the audio-level row look; wider value field for the sign + decimals. */
#play-speed-val, #welcome-play-speed-val { font-variant-numeric: tabular-nums; min-width: 3.4em; text-align: right; }
.play-speed input[type=range]:disabled { opacity: 0.4; cursor: not-allowed; }
.play-speed:has(input:disabled) { opacity: 0.6; }
.dev-panel { display: flex; flex-direction: column; gap: 0.8rem; }
/* Beat the generic `.hidden` (defined earlier, equal specificity): two classes
win, so the panel truly collapses when Dev Mode is off. */