(function () {
'use strict';
var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!document.getElementById('hero-sticky')) return;
/* ── cinematic hero scroll ────────────────────────────────
The section supplies the scroll distance; its sticky child remains
pinned while these values interpolate from the user's real scroll.
No scroll is captured or eased, so trackpads and touch keep their
native feel. */
var heroCinematic = document.getElementById('top');
var heroSticky = document.getElementById('hero-sticky');
var heroCue = heroSticky.querySelector('.hero-cue');
var heroLocation = heroSticky.querySelector('.hero-location');
var heroLead = heroSticky.querySelector('.hero-lead');
var heroActions = document.getElementById('hero-actions');
var heroMedia = heroSticky.querySelector('.hero-media');
var heroVideo = document.getElementById('hero-video');
var heroVideoReady = false;
var heroVideoPrimed = false;
var heroVideoPrimePending = false;
var heroTouchStarted = false;
var heroUsesTouch = ('ontouchstart' in window) || navigator.maxTouchPoints > 0;
var heroVideoVisible = false;
var heroVideoRevealPending = false;
var heroVideoProgress = 0;
var heroVideoObjectUrl = '';
function clamp01(value) {
return Math.max(0, Math.min(1, value));
}
function smoothstep(value) {
value = clamp01(value);
return value * value * (3 - (2 * value));
}
function paintReveal(element, progress, travel) {
element.style.opacity = progress.toFixed(4);
element.style.transform = 'translate3d(0,' + ((1 - progress) * travel).toFixed(2) + 'px,0)';
}
/* The optimized file has a seek point every three frames. Keep the
video paused and seek its playhead to the user's actual scroll
position; there is no autonomous playback or scroll hijacking. */
function syncHeroVideo(progress, force) {
heroVideoProgress = progress;
if (reduceMotion || !heroVideoReady || !Number.isFinite(heroVideo.duration)) return;
var usableDuration = Math.max(heroVideo.duration - (1 / 24), 0);
// Safari can clear the poster before it has decoded a seek at exactly
// zero. Start a fraction into the first frame and keep a real image
// beneath the video until a decoded frame has actually been painted.
var targetTime = Math.max(1 / 48, usableDuration * heroVideoProgress);
if (!force && (heroVideo.seeking || Math.abs(heroVideo.currentTime - targetTime) < (1 / 48))) return;
heroVideo.currentTime = targetTime;
}
function revealHeroVideo() {
if (reduceMotion || heroVideoVisible || heroVideoRevealPending || heroVideo.readyState < 2) return;
heroVideoRevealPending = true;
var reveal = function () {
heroVideoRevealPending = false;
if (reduceMotion || heroVideoVisible || heroVideo.readyState < 2) return;
heroVideoVisible = true;
heroMedia.classList.add('is-video-ready');
};
if (typeof heroVideo.requestVideoFrameCallback === 'function') {
heroVideo.requestVideoFrameCallback(reveal);
// A paused iOS video can finish seeking before a queued frame
// callback is delivered. By this point seeked + HAVE_CURRENT_DATA
// make a delayed reveal safe, while the poster covers the handoff.
window.setTimeout(reveal, 160);
} else {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(reveal);
});
}
}
function finishHeroVideoPrime() {
heroVideo.pause();
heroVideoPrimePending = false;
heroVideoPrimed = true;
heroVideoReady = true;
window.removeEventListener('touchstart', primeHeroVideoFromTouch);
syncHeroVideo(heroVideoProgress, true);
}
function usePausedSeekFallback() {
heroVideo.pause();
heroVideoPrimePending = false;
heroVideoReady = true;
syncHeroVideo(heroVideoProgress, true);
}
// iOS may load metadata for a muted video without activating its decoder.
// On touch devices, a brief hidden play/pause on the first scroll touch
// primes frame presentation without autoplay or visible player chrome.
function primeHeroVideo() {
if (reduceMotion || heroVideoPrimed || heroVideoPrimePending || heroVideo.readyState < 1) return;
heroVideoPrimePending = true;
heroVideo.muted = true;
heroVideo.defaultMuted = true;
var playAttempt = heroVideo.play();
if (playAttempt && typeof playAttempt.then === 'function') {
playAttempt.then(finishHeroVideoPrime).catch(usePausedSeekFallback);
} else {
finishHeroVideoPrime();
}
}
function primeHeroVideoFromTouch() {
heroTouchStarted = true;
primeHeroVideo();
}
function prepareHeroVideo() {
if (!heroUsesTouch || heroTouchStarted) primeHeroVideo();
}
function attachHeroVideoSource(source) {
heroVideo.src = source;
heroVideo.load();
}
/* The origin currently serves MP4 files without byte-range responses.
Mobile's smaller encode can finish downloading before that becomes
noticeable, but desktop seeks otherwise snap back to frame zero.
Buffer the desktop file into a blob so its full timeline is locally
seekable, keeping the poster visible until the first frame is ready. */
function loadHeroVideo() {
var mobileSource = window.innerWidth <= 720;
var source = mobileSource
? heroVideo.getAttribute('data-mobile-src')
: heroVideo.getAttribute('data-desktop-src');
if (mobileSource || typeof window.fetch !== 'function' || typeof window.URL.createObjectURL !== 'function') {
attachHeroVideoSource(source);
return;
}
window.fetch(source, { cache: 'force-cache' })
.then(function (response) {
if (!response.ok) throw new Error('Hero video request failed');
return response.blob();
})
.then(function (blob) {
heroVideoObjectUrl = window.URL.createObjectURL(blob);
attachHeroVideoSource(heroVideoObjectUrl);
})
.catch(function () {
attachHeroVideoSource(source);
});
}
if (reduceMotion) {
heroVideo.pause();
} else {
heroVideo.addEventListener('loadedmetadata', prepareHeroVideo);
if (heroUsesTouch) {
window.addEventListener('touchstart', primeHeroVideoFromTouch, { passive: true });
}
// Cached media can reach metadata before this bottom-of-page script runs.
if (heroVideo.readyState >= 1) prepareHeroVideo();
loadHeroVideo();
}
window.addEventListener('beforeunload', function () {
if (heroVideoObjectUrl) window.URL.revokeObjectURL(heroVideoObjectUrl);
});
// If the user scrolls again while a seek is being decoded, catch the
// playhead up to the most recent position as soon as that frame lands.
heroVideo.addEventListener('seeked', function () {
revealHeroVideo();
syncHeroVideo(heroVideoProgress, false);
});
heroVideo.addEventListener('error', function () {
heroVideoReady = false;
heroVideoPrimed = false;
heroVideoPrimePending = false;
heroVideoVisible = false;
heroVideoRevealPending = false;
heroMedia.classList.remove('is-video-ready');
});
function paintHeroScroll() {
if (reduceMotion) {
heroActions.inert = false;
heroActions.setAttribute('aria-hidden', 'false');
return;
}
var runway = Math.max(heroCinematic.offsetHeight - window.innerHeight, 1);
var progress = clamp01(-heroCinematic.getBoundingClientRect().top / runway);
syncHeroVideo(progress, false);
var locationIn = smoothstep((progress - 0.1) / 0.15);
var leadIn = smoothstep((progress - 0.28) / 0.18);
var actionsIn = smoothstep((progress - 0.42) / 0.18);
var cueOut = smoothstep(progress / 0.12);
var scrimIn = smoothstep((progress - 0.08) / 0.42);
heroSticky.style.setProperty('--hero-media-scale', (1.03 + (0.08 * smoothstep(progress))).toFixed(4));
heroSticky.style.setProperty('--hero-scrim-opacity', (0.3 + (0.7 * scrimIn)).toFixed(4));
heroSticky.style.setProperty('--hero-cue-opacity', (1 - cueOut).toFixed(4));
paintReveal(heroLocation, locationIn, 20);
paintReveal(heroLead, leadIn, 30);
paintReveal(heroActions, actionsIn, 24);
var cueActive = cueOut < 0.92;
var actionsActive = actionsIn > 0.8;
heroCue.style.pointerEvents = cueActive ? '' : 'none';
heroCue.inert = !cueActive;
heroCue.setAttribute('aria-hidden', String(!cueActive));
heroActions.style.pointerEvents = actionsActive ? '' : 'none';
heroActions.inert = !actionsActive;
heroActions.setAttribute('aria-hidden', String(!actionsActive));
}
window.voldPaintHero = paintHeroScroll;
})();