diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index d89abaa..49656ff 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -29,6 +29,7 @@ import { THEATER_STYLES, TheaterMode } from '../components/theater'; import { setActiveReplay } from '../components/pip-registry'; import { getPipMatchId, restorePip } from '../components/pip'; import { fetchReplayFromUrl } from '../lib/replay-data'; +import { hapticPulse, isHapticEnabled, setHapticEnabled } from '../lib/ambient'; const loadReplayViewer = () => import('../replay-viewer'); @@ -220,6 +221,10 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string): Reduced motion + @@ -1496,6 +1501,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { const shapesToggle = document.getElementById('shapes-toggle') as HTMLInputElement; const highContrastToggle = document.getElementById('high-contrast-toggle') as HTMLInputElement; const reducedMotionToggle = document.getElementById('reduced-motion-toggle') as HTMLInputElement; + const hapticToggle = document.getElementById('haptic-toggle') as HTMLInputElement; function updateAccessibility(): void { viewer.setAccessibility({ @@ -1511,6 +1517,12 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { highContrastToggle.addEventListener('change', updateAccessibility); reducedMotionToggle.addEventListener('change', updateAccessibility); + // Haptic feedback toggle (§16.18) + hapticToggle.checked = isHapticEnabled(); + hapticToggle.addEventListener('change', () => { + setHapticEnabled(hapticToggle.checked); + }); + // Initialize accessibility from system preferences if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { reducedMotionToggle.checked = true; @@ -1536,6 +1548,40 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { viewer.refreshWinProbSparkline(); updateAnnotationOverlay(); updateTranscript(); + + // Haptic feedback at critical moments (§16.18) + if (!isHapticEnabled()) return; + const turn = viewer.getTurn(); + const replay = viewer.getReplay() as Replay | null; + if (!replay || !replay.turns[turn]) return; + + const turnData = replay.turns[turn]; + const events = turnData.events ?? []; + let hasCriticalEvent = false; + + // Check for combat deaths and core captures + for (const event of events) { + if (event.type === 'bot_died' || event.type === 'core_captured') { + hasCriticalEvent = true; + break; + } + } + + // Check for win probability shift >15% + if (!hasCriticalEvent && replay.win_prob && turn > 0) { + const prevProbs = replay.win_prob[turn - 1]; + const currProbs = replay.win_prob[turn]; + if (prevProbs && currProbs && prevProbs.length >= 2 && currProbs.length >= 2) { + const delta = Math.abs(currProbs[0] - prevProbs[0]); + if (delta > 0.15) { + hasCriticalEvent = true; + } + } + } + + if (hasCriticalEvent) { + hapticPulse(50); + } }; viewer.onDebugChange = (debug: Record | null) => { updateDebugDisplay(debug);