1'use strict'; 2 3// Videos autoplay only while visible, unless reduced motion is enabled. 4const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); 5const videoStates = new Map(); 6const visibilityObserver = new IntersectionObserver(entries => { 7 for (const entry of entries) { 8 const video = entry.target; 9 const state = videoStates.get(video); 10 state.visible = entry.isIntersecting && entry.intersectionRatio >= 0.15; 11 if (!state.visible) pauseOffscreen(video, state); 12 else if (state.wantsPlayback && !document.hidden) attemptPlay(video, state); 13 } 14}, {threshold: [0, 0.15, 0.5]}); 15 16async function loadVideo(video) { 17 const source = video.querySelector('source[data-src]'); 18 if (source && !source.hasAttribute('src')) { 19 // Pages serves full MP4 responses without byte ranges. A local Blob gives 20 // native video controls a seekable source, while keeping the site static. 21 if (location.hostname.endsWith('.pages.dev')) { 22 const response = await fetch(source.dataset.src); 23 if (!response.ok) throw new Error('Video download failed'); 24 const blob = await response.blob(); 25 video.src = URL.createObjectURL(blob); 26 } 27 source.src = source.dataset.src; 28 video.load(); 29 } 30} 31 32async function attemptPlay(video, state) { 33 if (state.pending || !video.paused) return; 34 state.pending = true; 35 const button = state.frame.querySelector('.video-start'); 36 const label = button.innerHTML; 37 button.textContent = 'Loading videoâ¦'; 38 button.disabled = true; 39 try { 40 await loadVideo(video); 41 if (!state.visible || document.hidden || !state.wantsPlayback) return; 42 await video.play(); 43 if (!state.visible || document.hidden) pauseOffscreen(video, state); 44 } catch (error) { 45 // Autoplay restrictions leave the poster and explicit play button available. 46 if (error.name !== 'AbortError') state.frame.classList.remove('is-started'); 47 } finally { 48 state.pending = false; 49 button.disabled = false; 50 button.innerHTML = label; 51 } 52} 53 54function pauseOffscreen(video, state) { 55 if (!video.paused) { 56 state.environmentPause = true; 57 video.pause(); 58 } 59} 60 61document.querySelectorAll('.managed-video').forEach(video => { 62 const frame = video.closest('.media-frame'); 63 const state = {frame, visible:false, wantsPlayback:video.hasAttribute('data-autoplay') && !reduceMotion.matches, pending:false, environmentPause:false}; 64 videoStates.set(video, state); 65 const toggle = frame.querySelector('.video-toggle'); 66 const updateToggle = () => { 67 if (!toggle) return; 68 const action = video.ended ? 'Replay' : video.paused ? 'Play' : 'Pause'; 69 toggle.textContent = action; 70 toggle.setAttribute('aria-label', `${action} ${video.getAttribute('aria-label')}`); 71 }; 72 if (toggle) { 73 toggle.addEventListener('click', () => { 74 state.environmentPause = false; 75 if (!video.paused) { 76 state.wantsPlayback = false; 77 video.pause(); 78 } else { 79 state.wantsPlayback = true; 80 if (video.ended) video.currentTime = 0; 81 attemptPlay(video, state); 82 } 83 }); 84 video.addEventListener('play', updateToggle); 85 video.addEventListener('pause', updateToggle); 86 video.addEventListener('ended', updateToggle); 87 frame.addEventListener('contextmenu', event => event.preventDefault()); 88 const fullscreen = frame.querySelector('.video-fullscreen'); 89 fullscreen.hidden = !frame.requestFullscreen; 90 fullscreen.addEventListener('click', async () => { 91 try { 92 if (document.fullscreenElement === frame) await document.exitFullscreen(); 93 else await frame.requestFullscreen(); 94 } catch (_) { /* Fullscreen may be unavailable in embedded browsers. */ } 95 }); 96 updateToggle(); 97 } 98 frame.querySelector('.video-start').addEventListener('click', () => { 99 state.wantsPlayback = true; 100 state.visible = true; 101 state.environmentPause = false; 102 if (video.ended) video.currentTime = 0; 103 attemptPlay(video, state); 104 }); 105 video.addEventListener('playing', () => { 106 frame.classList.add('is-started'); 107 state.wantsPlayback = true; 108 }); 109 video.addEventListener('pause', () => { 110 if (state.environmentPause) state.environmentPause = false;
111 else state.wantsPlayback = false; 112 }); 113 video.addEventListener('ended', () => { 114 state.wantsPlayback = false; 115 if (!video.hasAttribute('data-hold-last-frame')) frame.classList.remove('is-started'); 116 }); 117 video.addEventListener('error', () => { 118 state.wantsPlayback = false; 119 const button = frame.querySelector('.video-start'); 120 frame.classList.remove('is-started'); 121 button.textContent = 'Video unavailable · retry'; 122 button.setAttribute('aria-label', 'Retry loading video'); 123 const source = video.querySelector('source'); 124 if (source) source.removeAttribute('src'); 125 }); 126 visibilityObserver.observe(video); 127}); 128 129// Comparison clips retain their own clocks; no speed matching is applied. 130document.querySelectorAll('.play-comparison').forEach(button => { 131 const videos = [...button.closest('.intervention-comparison').querySelectorAll('video')]; 132 const updateLabel = () => { button.textContent = videos.some(video => !video.paused) ? 'Pause both' : 'Play both'; }; 133 videos.forEach(video => { video.addEventListener('play', updateLabel); video.addEventListener('pause', updateLabel); }); 134 button.addEventListener('click', () => { 135 const pause = videos.some(video => !video.paused); 136 for (const video of videos) { 137 const state = videoStates.get(video); 138 state.wantsPlayback = !pause; 139 state.environmentPause = false; 140 if (pause) video.pause(); 141 else if (state.visible) attemptPlay(video, state); 142 } 143 updateLabel(); 144 }); 145}); 146 147document.addEventListener('visibilitychange', () => { 148 for (const [video, state] of videoStates) { 149 if (document.hidden) pauseOffscreen(video, state); 150 else if (state.visible && state.wantsPlayback) attemptPlay(video, state); 151 } 152}); 153reduceMotion.addEventListener('change', () => { 154 if (!reduceMotion.matches) return; 155 for (const [video, state] of videoStates) { 156 if (video.hasAttribute('data-autoplay')) { 157 state.wantsPlayback = false; 158 pauseOffscreen(video, state); 159 } 160 } 161}); 162 163const heroVideo = document.querySelector('#hero-video'); 164heroVideo.addEventListener('timeupdate', () => { 165 const scene = heroVideo.currentTime < 7 ? 0 : heroVideo.currentTime < 18 ? 1 : 2; 166 document.querySelector('#hero-scene').textContent = ['Contact, leverage, retrieve', 'Align the tool, actuate the trigger', 'Lose contact, adapt, recover'][scene]; 167 document.querySelector('#hero-index').textContent = `0${scene + 1} / 03`; 168}); 169 170const menuButton = document.querySelector('.menu-toggle'); 171const nav = document.querySelector('#nav-links'); 172function closeMenu() { 173 menuButton.setAttribute('aria-expanded', 'false'); 174 nav.classList.remove('is-open'); 175} 176menuButton.addEventListener('click', () => { 177 const open = menuButton.getAttribute('aria-expanded') !== 'true'; 178 menuButton.setAttribute('aria-expanded', String(open)); 179 nav.classList.toggle('is-open', open); 180}); 181nav.addEventListener('click', event => {if (event.target.closest('a')) closeMenu();}); 182document.addEventListener('keydown', event => { 183 if (event.key === 'Escape' && menuButton.getAttribute('aria-expanded') === 'true') { 184 closeMenu(); 185 menuButton.focus(); 186 } 187}); 188 189const links = [...nav.querySelectorAll('a')]; 190const sections = links.map(link => document.querySelector(link.hash)); 191let scrollPending = false; 192function updateScroll() { 193 const available = document.documentElement.scrollHeight - innerHeight; 194 document.querySelector('#progress').style.width = `${available > 0 ? Math.min(100, scrollY / available * 100) : 0}%`; 195 let active = -1; 196 sections.forEach((section, index) => {if (section.getBoundingClientRect().top < innerHeight * .35) active = index;}); 197 links.forEach((link, index) => { 198 link.classList.toggle('active', index === active); 199 if (index === active) link.setAttribute('aria-current', 'location'); 200 else link.removeAttribute('aria-current'); 201 }); 202 scrollPending = false; 203} 204function scheduleScroll() {if (!scrollPending) {scrollPending = true; requestAnimationFrame(updateScroll);}} 205addEventListener('scroll', scheduleScroll, {passive:true}); 206addEventListener('resize', scheduleScroll);
207document.querySelectorAll('details').forEach(details => details.addEventListener('toggle', scheduleScroll)); 208updateScroll(); 209 210// Keep previously shared links useful after removing the standalone evidence/resources chapters. 211const legacyAnchors = { 212 insights:'challenges', 213 'coordination-evidence':'challenge-skill', 214 'value-evidence':'challenge-critic', 215 'iaw-evidence':'challenge-iaw', 216 'training-cost':'highlights', 217 resources:'citation-section' 218}; 219function resolveLegacyAnchor() { 220 const replacement = legacyAnchors[location.hash.slice(1)]; 221 if (replacement) location.replace('#' + replacement); 222} 223addEventListener('hashchange', resolveLegacyAnchor); 224resolveLegacyAnchor(); 225 226const dialog = document.querySelector('#figure-dialog'); 227let figureTrigger = null; 228document.querySelectorAll('[data-zoom]').forEach(button => button.addEventListener('click', () => { 229 figureTrigger = button; 230 const image = document.querySelector('#enlarged-figure'); 231 image.src = button.dataset.zoom; 232 image.alt = button.querySelector('img').alt; 233 document.querySelector('#figure-dialog-title').textContent = button.getAttribute('aria-label').replace(/^Enlarge /, ''); 234 dialog.classList.toggle('is-panorama', button.closest('.recovery-sequence') !== null); 235 dialog.querySelector('.dialog-image-scroll').scrollLeft = 0; 236 dialog.showModal(); 237})); 238document.querySelector('#close-figure').addEventListener('click', () => dialog.close()); 239dialog.addEventListener('click', event => {if (event.target === dialog) dialog.close();}); 240dialog.addEventListener('close', () => {if (figureTrigger) figureTrigger.focus();}); 241 242document.querySelector('#copy-citation').addEventListener('click', async () => { 243 const button = document.querySelector('#copy-citation'); 244 const citation = document.querySelector('#citation'); 245 const status = document.querySelector('#copy-status'); 246 try { 247 await navigator.clipboard.writeText(citation.textContent); 248 button.textContent = 'Copied'; 249 status.textContent = 'BibTeX copied to clipboard.'; 250 setTimeout(() => {button.textContent = 'Copy BibTeX';}, 2000); 251 } catch { 252 const range = document.createRange(); 253 range.selectNodeContents(citation); 254 const selection = getSelection(); 255 selection.removeAllRanges(); 256 selection.addRange(range); 257 button.textContent = 'Selected · press Ctrl/Cmd+C'; 258 status.textContent = 'Citation selected. Press Control C or Command C to copy.'; 259 } 260});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.