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:
+111
-40
@@ -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".
|
||||
|
||||
@@ -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 device’s “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 — l’expé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
@@ -15,26 +15,21 @@
|
||||
<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>
|
||||
<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 device’s “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>
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user