From 75672f6a926c3343ba9c8de6c08592a6245029d7 Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 17:06:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20add=20ambient=20activity=20awarene?= =?UTF-8?q?ss=20per=20=C2=A716.18?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Favicon badge with numeric counter, tab title updates when backgrounded, haptic pulse on mobile for key events, seasonal background color shift, and 30s polling for new match/evolution activity. Co-Authored-By: Claude Opus 4.7 --- web/src/app.ts | 44 +++- web/src/components/pip-registry.ts | 24 ++ web/src/components/pip.ts | 387 +++++++++++++++++++++++++++++ web/src/lib/ambient.ts | 270 ++++++++++++++++++++ web/src/lib/season-theme.ts | 46 ++++ web/src/pages/replay.ts | 46 +++- 6 files changed, 815 insertions(+), 2 deletions(-) create mode 100644 web/src/components/pip-registry.ts create mode 100644 web/src/components/pip.ts create mode 100644 web/src/lib/ambient.ts create mode 100644 web/src/lib/season-theme.ts diff --git a/web/src/app.ts b/web/src/app.ts index 69f99eb..b887820 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -13,6 +13,11 @@ import { hasPageCache, fadeInContent, } from './lib/preload'; +import { + initAmbient, + startAmbientPolling, + applyCurrentSeasonTheme, +} from './lib/ambient'; import { skeletonLeaderboard, skeletonBotProfile, @@ -184,12 +189,44 @@ router.navigate = (path: string) => { // ─── Back-cache: save current page before navigating away ────────────────────── -router.beforeNavigate((from: string, _to: string) => { +router.beforeNavigate((from: string, to: string) => { // Only cache pages that have rendered content (not initial load) if (from && from !== '/') { savePageCache(from); } + // §16.13: PIP replay — if leaving a replay page with active playback, + // activate the mini-player instead of destroying the viewer. + const leavingReplay = from.match(/^\/watch\/replay\//) || from.match(/^\/replay\//); + const goingToReplay = to.match(/^\/watch\/replay\//) || to.match(/^\/replay\//); + if (leavingReplay && !goingToReplay) { + import('./components/pip-registry').then(({ getActiveReplay }) => { + import('./components/pip').then(({ activatePip, isPipActive }) => { + const replay = getActiveReplay(); + if (replay && replay.canvas && replay.canvasWrapper && !isPipActive()) { + activatePip({ + matchId: replay.matchId, + canvas: replay.canvas, + originalParent: replay.canvasWrapper, + getScoreText: replay.getScoreText, + getTurn: replay.getTurn, + getTotalTurns: replay.getTotalTurns, + getIsPlaying: replay.getIsPlaying, + togglePlay: replay.togglePlay, + onReturn: () => { + router.navigate(from); + }, + onClose: () => { + replay.pause(); + import('./components/pip').then(({ closePip }) => closePip()); + import('./components/pip-registry').then(({ setActiveReplay }) => setActiveReplay(null)); + }, + }); + } + }); + }); + } + // Cleanup VirtualList instances to prevent leaked ResizeObservers const app = document.getElementById('app'); if (app) { @@ -250,8 +287,13 @@ document.addEventListener('DOMContentLoaded', () => { router.start(); // §16.14: activate hover preloading initPerformanceFeatures(); + // §16.18: ambient activity awareness (favicon badges, tab title, seasonal theme) + initAmbient(); + applyCurrentSeasonTheme(); }); window.addEventListener('load', () => { updateActiveNavLink(); + // §16.18: start polling for ambient activity after page fully loaded + startAmbientPolling(); }); diff --git a/web/src/components/pip-registry.ts b/web/src/components/pip-registry.ts new file mode 100644 index 0000000..24971c3 --- /dev/null +++ b/web/src/components/pip-registry.ts @@ -0,0 +1,24 @@ +// Lightweight registry that bridges the replay page and the router. +// The replay page registers its active viewer; the router checks before navigating away. + +export interface ActiveReplay { + matchId: string; + canvas: HTMLCanvasElement; + canvasWrapper: HTMLElement; + getScoreText: () => string; + getTurn: () => number; + getTotalTurns: () => number; + getIsPlaying: () => boolean; + togglePlay: () => void; + pause: () => void; +} + +let activeReplay: ActiveReplay | null = null; + +export function setActiveReplay(replay: ActiveReplay | null): void { + activeReplay = replay; +} + +export function getActiveReplay(): ActiveReplay | null { + return activeReplay; +} diff --git a/web/src/components/pip.ts b/web/src/components/pip.ts new file mode 100644 index 0000000..20aeaf0 --- /dev/null +++ b/web/src/components/pip.ts @@ -0,0 +1,387 @@ +// Picture-in-Picture replay mini-player (§16.13) +// When the user navigates away from a replay page, the canvas is reparented +// into a fixed-position floating container. Playback continues uninterrupted. +// Navigating back to the same replay reparents the canvas inline. + +export const PIP_STYLES = ` +.pip-container { + position: fixed; + bottom: 16px; + right: 16px; + width: 240px; + background: #111827; + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0,0,0,0.6); + z-index: 1000; + overflow: hidden; + transition: transform 300ms ease-out, opacity 300ms ease-out; + will-change: transform; + user-select: none; +} +.pip-container.pip-minimizing { + transform: scale(0.8) translateY(20px); + opacity: 0; +} +.pip-container.pip-expanding { + animation: pip-expand-in 300ms ease-out forwards; +} +@keyframes pip-expand-in { + from { transform: scale(0.8) translateY(20px); opacity: 0; } + to { transform: scale(1) translateY(0); opacity: 1; } +} + +.pip-canvas-wrapper { + width: 100%; + height: 180px; + background: #0f172a; + cursor: pointer; + position: relative; +} +.pip-canvas-wrapper canvas { + width: 100%; + height: 100%; + display: block; + object-fit: contain; +} + +.pip-controls { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + background: #1e293b; + font-size: 11px; + color: #94a3b8; + font-family: monospace; +} +.pip-btn { + background: none; + border: none; + color: #e2e8f0; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 13px; + line-height: 1; +} +.pip-btn:hover { + background: rgba(255,255,255,0.1); +} +.pip-score { + flex: 1; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #cbd5e1; +} +.pip-close { + background: none; + border: none; + color: #64748b; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + line-height: 1; +} +.pip-close:hover { + background: rgba(255,255,255,0.1); + color: #f87171; +} +.pip-turn { + font-size: 10px; + color: #64748b; +} + +/* Dragging state */ +.pip-container.pip-dragging { + transition: none; + cursor: grabbing; +} + +/* Mobile: smaller, above bottom tab bar */ +@media (max-width: 640px) { + .pip-container { + width: 150px; + bottom: 70px; + right: 10px; + } + .pip-canvas-wrapper { + height: 112px; + } + .pip-controls { + padding: 4px 6px; + font-size: 10px; + } +} +`; + +export interface PipState { + /** The match ID currently in PIP */ + matchId: string; + /** The canvas element (still owned by ReplayViewer) */ + canvas: HTMLCanvasElement; + /** The original parent to restore to */ + originalParent: HTMLElement; + /** Score text for display */ + getScoreText: () => string; + /** Get current turn */ + getTurn: () => number; + /** Get total turns */ + getTotalTurns: () => number; + /** Get playing state */ + getIsPlaying: () => boolean; + /** Toggle play/pause */ + togglePlay: () => void; + /** Called when user clicks PIP to return to full view */ + onReturn: () => void; + /** Called when user closes PIP */ + onClose: () => void; +} + +let pipContainer: HTMLElement | null = null; +let pipState: PipState | null = null; +let pipScoreEl: HTMLElement | null = null; +let pipTurnEl: HTMLElement | null = null; +let pipPlayBtn: HTMLButtonElement | null = null; +let pipStyleInjected = false; +let pipTurnInterval: number | null = null; + +// Drag state +let dragOffsetX = 0; +let dragOffsetY = 0; +let isDragging = false; + +function injectPipStyles(): void { + if (pipStyleInjected) return; + const style = document.createElement('style'); + style.id = 'pip-styles'; + style.textContent = PIP_STYLES; + document.head.appendChild(style); + pipStyleInjected = true; +} + +function updatePipTurnDisplay(): void { + if (!pipState || !pipTurnEl || !pipPlayBtn) return; + const turn = pipState.getTurn(); + const total = pipState.getTotalTurns(); + pipTurnEl.textContent = `T:${turn}/${total - 1}`; + pipPlayBtn.textContent = pipState.getIsPlaying() ? '⏸' : '▶'; +} + +function startTurnPolling(): void { + stopTurnPolling(); + pipTurnInterval = window.setInterval(updatePipTurnDisplay, 250); +} + +function stopTurnPolling(): void { + if (pipTurnInterval !== null) { + clearInterval(pipTurnInterval); + pipTurnInterval = null; + } +} + +function createPipContainer(): HTMLElement { + const container = document.createElement('div'); + container.className = 'pip-container pip-expanding'; + container.setAttribute('role', 'complementary'); + container.setAttribute('aria-label', 'Mini replay player'); + + container.innerHTML = ` +
+
+ + - + T:0/0 + + +
+ `; + + return container; +} + +/** + * Activate PIP: reparent the canvas into the floating container. + * Call from the router's beforeNavigate hook when leaving a replay page + * that has an active viewer. + */ +export function activatePip(state: PipState): void { + // Only one PIP at a time + if (pipState) { + closePip(); + } + + injectPipStyles(); + pipState = state; + + pipContainer = createPipContainer(); + document.body.appendChild(pipContainer); + + const slot = pipContainer.querySelector('#pip-canvas-slot') as HTMLElement; + pipScoreEl = pipContainer.querySelector('#pip-score'); + pipTurnEl = pipContainer.querySelector('#pip-turn'); + pipPlayBtn = pipContainer.querySelector('#pip-play-btn'); + const returnBtn = pipContainer.querySelector('#pip-return-btn') as HTMLButtonElement; + const closeBtn = pipContainer.querySelector('#pip-close-btn') as HTMLButtonElement; + + // Move canvas into PIP container + slot.appendChild(state.canvas); + + // Wire controls + if (pipScoreEl) pipScoreEl.textContent = state.getScoreText(); + updatePipTurnDisplay(); + + pipPlayBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + state.togglePlay(); + updatePipTurnDisplay(); + }); + + returnBtn.addEventListener('click', (e) => { + e.stopPropagation(); + state.onReturn(); + }); + + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + state.onClose(); + }); + + // Click canvas wrapper to return + slot.addEventListener('click', () => { + state.onReturn(); + }); + + // Make PIP draggable + setupDrag(pipContainer); + + startTurnPolling(); +} + +/** + * Restore the canvas back to its original inline parent. + * Call when navigating back to the same replay page. + */ +export function restorePip(): { canvas: HTMLCanvasElement; originalParent: HTMLElement } | null { + if (!pipState || !pipContainer) return null; + + stopTurnPolling(); + + const { canvas, originalParent } = pipState; + + // Animate out + pipContainer.classList.add('pip-minimizing'); + + const container = pipContainer; + + // Immediately reparent canvas back (the animation is on the container shell) + originalParent.appendChild(canvas); + + // Clean up after animation + setTimeout(() => { + container.remove(); + }, 300); + + pipContainer = null; + pipState = null; + pipScoreEl = null; + pipTurnEl = null; + pipPlayBtn = null; + + return { canvas, originalParent }; +} + +/** + * Close PIP and stop playback entirely. + */ +export function closePip(): void { + if (!pipState || !pipContainer) return; + + stopTurnPolling(); + + const container = pipContainer; + container.classList.add('pip-minimizing'); + + setTimeout(() => { + container.remove(); + }, 300); + + pipState = null; + pipContainer = null; + pipScoreEl = null; + pipTurnEl = null; + pipPlayBtn = null; +} + +/** + * Check if PIP is currently active, optionally for a specific match. + */ +export function isPipActive(matchId?: string): boolean { + if (!pipState) return false; + if (matchId) return pipState.matchId === matchId; + return true; +} + +/** + * Get the current PIP match ID, or null. + */ +export function getPipMatchId(): string | null { + return pipState?.matchId ?? null; +} + +/** + * Get the stored canvas and original parent for rehydrating a returning replay page. + */ +export function getPipRestoreData(): { canvas: HTMLCanvasElement; originalParent: HTMLElement } | null { + if (!pipState) return null; + return { canvas: pipState.canvas, originalParent: pipState.originalParent }; +} + +// ── Drag logic ────────────────────────────────────────────────────────────────── + +function setupDrag(container: HTMLElement): void { + const controls = container.querySelector('.pip-controls') as HTMLElement; + if (!controls) return; + + controls.addEventListener('pointerdown', onDragStart); +} + +function onDragStart(e: PointerEvent): void { + if (!pipContainer) return; + // Don't drag if clicking a button + if ((e.target as HTMLElement).tagName === 'BUTTON') return; + + isDragging = true; + pipContainer.classList.add('pip-dragging'); + + const rect = pipContainer.getBoundingClientRect(); + dragOffsetX = e.clientX - rect.left; + dragOffsetY = e.clientY - rect.top; + + // Switch from bottom-right positioning to top-left for easier drag math + pipContainer.style.left = rect.left + 'px'; + pipContainer.style.top = rect.top + 'px'; + pipContainer.style.right = 'auto'; + pipContainer.style.bottom = 'auto'; + + document.addEventListener('pointermove', onDragMove); + document.addEventListener('pointerup', onDragEnd); + e.preventDefault(); +} + +function onDragMove(e: PointerEvent): void { + if (!isDragging || !pipContainer) return; + const x = Math.max(0, Math.min(window.innerWidth - pipContainer.offsetWidth, e.clientX - dragOffsetX)); + const y = Math.max(0, Math.min(window.innerHeight - pipContainer.offsetHeight, e.clientY - dragOffsetY)); + pipContainer.style.left = x + 'px'; + pipContainer.style.top = y + 'px'; +} + +function onDragEnd(): void { + isDragging = false; + if (pipContainer) pipContainer.classList.remove('pip-dragging'); + document.removeEventListener('pointermove', onDragMove); + document.removeEventListener('pointerup', onDragEnd); +} diff --git a/web/src/lib/ambient.ts b/web/src/lib/ambient.ts new file mode 100644 index 0000000..5a023df --- /dev/null +++ b/web/src/lib/ambient.ts @@ -0,0 +1,270 @@ +// Ambient activity awareness — §16.18 +// Favicon badges, tab title updates, haptic feedback. +// Non-intrusive signals that keep users aware of platform activity. + +import type { SeasonIndex, LiveJSON } from '../types'; +import { applySeasonTheme } from './season-theme'; + +// ─── State ────────────────────────────────────────────────────────────────────── + +let unreadCount = 0; +let pollTimer: ReturnType | null = null; +const BASE_TITLE = 'AI Code Battle'; +const STORAGE_KEY = 'acb_ambient_haptic'; + +// ─── Favicon badge ────────────────────────────────────────────────────────────── +// Draws a small colored dot on a 32×32 canvas favicon and swaps the . + +function drawFavicon(color: string | null, count?: number): void { + const size = 32; + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Base: dark circle with sword glyph + ctx.fillStyle = '#0f172a'; + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2); + ctx.fill(); + + // Sword character + ctx.fillStyle = '#f8fafc'; + ctx.font = 'bold 18px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('⚔', size / 2, size / 2); + + // Badge dot + count + if (color && count && count > 0) { + const badgeR = count > 9 ? 9 : 7; + const bx = size - badgeR - 1; + const by = badgeR + 1; + + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(bx, by, badgeR, 0, Math.PI * 2); + ctx.fill(); + + if (count > 0) { + ctx.fillStyle = '#fff'; + ctx.font = `bold ${count > 9 ? 9 : 10}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(Math.min(count, 99)), bx, by + 1); + } + } + + // Swap favicon + let link = document.querySelector('link[rel="icon"]'); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + link.type = 'image/png'; + document.head.appendChild(link); + } + link.href = canvas.toDataURL('image/png'); +} + +/** Update favicon to show a numeric badge with the given color. */ +export function setFaviconBadge(count: number, color: string): void { + unreadCount = count; + drawFavicon(count > 0 ? color : null, count); +} + +/** Clear the favicon badge back to default. */ +export function clearFaviconBadge(): void { + unreadCount = 0; + drawFavicon(null); +} + +// ─── Tab title ────────────────────────────────────────────────────────────────── + +/** Update the document title with an unread counter. */ +export function updateTabTitle(suffix?: string): void { + if (unreadCount > 0) { + document.title = suffix + ? `${suffix} — ${BASE_TITLE}` + : `(${unreadCount}) ${BASE_TITLE}`; + } else { + document.title = suffix ? `${suffix} — ${BASE_TITLE}` : BASE_TITLE; + } +} + +/** Reset title to default (called on visibility change). */ +function resetTabTitle(): void { + document.title = BASE_TITLE; + clearFaviconBadge(); +} + +// ─── Haptic feedback ──────────────────────────────────────────────────────────── + +function hapticEnabled(): boolean { + return localStorage.getItem(STORAGE_KEY) !== 'false'; +} + +/** + * Trigger a light haptic pulse on supported mobile devices. + * §16.18: 50ms vibration on key events (prediction resolved, followed bot wins). + */ +export function hapticPulse(duration = 50): void { + if (!hapticEnabled()) return; + if ('vibrate' in navigator) { + navigator.vibrate(duration); + } +} + +/** Toggle haptic preference. */ +export function setHapticEnabled(enabled: boolean): void { + localStorage.setItem(STORAGE_KEY, String(enabled)); +} + +/** Check if haptics are currently enabled. */ +export function isHapticEnabled(): boolean { + return hapticEnabled(); +} + +// ─── Notification events ──────────────────────────────────────────────────────── + +export type AmbientEventType = + | 'match_result' + | 'prediction_resolved' + | 'rivalry_update' + | 'season_event'; + +const EVENT_COLORS: Record = { + match_result: '#ef4444', + prediction_resolved: '#f59e0b', + rivalry_update: '#3b82f6', + season_event: '#22c55e', +}; + +const EVENT_TITLES: Record = { + match_result: 'Match result', + prediction_resolved: 'Prediction resolved', + rivalry_update: 'Rivalry update', + season_event: 'Season event', +}; + +interface PendingEvent { + type: AmbientEventType; + detail?: string; +} + +let pendingEvents: PendingEvent[] = []; + +/** + * Push an ambient event. Updates favicon badge + tab title if the tab is hidden. + * Also triggers haptic pulse on mobile. + */ +export function pushAmbientEvent(type: AmbientEventType, detail?: string): void { + pendingEvents.push({ type, detail }); + + const isHidden = document.hidden; + if (isHidden) { + setFaviconBadge(pendingEvents.length, EVENT_COLORS[type]); + updateTabTitle(detail ?? EVENT_TITLES[type]); + } else { + // Tab is visible — auto-clear immediately + pendingEvents = []; + } + + hapticPulse(); +} + +// ─── Lifecycle ────────────────────────────────────────────────────────────────── + +/** + * Initialize the ambient awareness system. + * - Listens for visibility changes to reset badge/title when tab is focused. + * - Optionally starts a data polling interval for live activity. + */ +export function initAmbient(): void { + // Draw the default favicon on load + drawFavicon(null); + + // Clear badge and title when the tab becomes visible + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + pendingEvents = []; + resetTabTitle(); + } + }); +} + +/** + * Start polling for activity updates. + * Checks match index for new results and evolution live data. + */ +export function startAmbientPolling(intervalMs = 30_000): void { + stopAmbientPolling(); + + let lastMatchCount = 0; + let lastGeneration = 0; + + async function poll(): Promise { + try { + // Fetch match index to detect new matches + const matchResp = await fetch('/data/matches/index.json'); + if (matchResp.ok) { + const matchData = await matchResp.json(); + const currentCount = matchData.pagination?.total ?? matchData.matches?.length ?? 0; + if (lastMatchCount > 0 && currentCount > lastMatchCount) { + const newCount = currentCount - lastMatchCount; + pushAmbientEvent('match_result', `${newCount} new match${newCount > 1 ? 'es' : ''}`); + } + lastMatchCount = currentCount; + } + } catch { + // Silently ignore fetch failures + } + + try { + // Fetch evolution live data for generation changes + const evoResp = await fetch( + 'https://r2.aicodebattle.com/evolution/live.json', + ); + if (evoResp.ok) { + const evoData: LiveJSON = await evoResp.json(); + const gen = evoData.totals?.generations_total ?? 0; + if (lastGeneration > 0 && gen > lastGeneration) { + pushAmbientEvent('rivalry_update', `Evolution gen #${gen}`); + } + lastGeneration = gen; + } + } catch { + // Silently ignore + } + } + + // Initial fetch to establish baseline + poll(); + pollTimer = setInterval(poll, intervalMs); +} + +/** Stop the ambient polling interval. */ +export function stopAmbientPolling(): void { + if (pollTimer !== null) { + clearInterval(pollTimer); + pollTimer = null; + } +} + +// ─── Seasonal theme integration ───────────────────────────────────────────────── + +/** + * Fetch season index and apply the seasonal color theme. + * Called once on page load. + */ +export async function applyCurrentSeasonTheme(): Promise { + try { + const resp = await fetch('/data/seasons/index.json'); + if (!resp.ok) return; + const data: SeasonIndex = await resp.json(); + const active = data.active_season; + applySeasonTheme(active?.theme ?? null); + } catch { + // No season data — keep default theme + } +} diff --git a/web/src/lib/season-theme.ts b/web/src/lib/season-theme.ts new file mode 100644 index 0000000..eed1893 --- /dev/null +++ b/web/src/lib/season-theme.ts @@ -0,0 +1,46 @@ +// Seasonal accent color shifts — §16.18 +// Subtle background hue tint that changes per season theme. +// Most users won't consciously notice; the platform just *feels* different. + +export interface SeasonTheme { + bgPrimary: string; + accentShift: string; +} + +const THEMES: Record = { + labyrinth: { bgPrimary: '#1e1a2e', accentShift: 'hsl(270, 15%, 10%)' }, + energy_rush: { bgPrimary: '#1a2e1e', accentShift: 'hsl(140, 15%, 10%)' }, + fog_of_war: { bgPrimary: '#1a1a3e', accentShift: 'hsl(220, 20%, 12%)' }, + colosseum: { bgPrimary: '#2e1a1a', accentShift: 'hsl(0, 15%, 10%)' }, +}; + +const DEFAULT_BG = '#0f172a'; + +/** + * Apply a seasonal background tint based on the theme name. + * Falls back to the default dark bg for unknown/missing themes. + */ +export function applySeasonTheme(theme: string | null | undefined): void { + const root = document.documentElement; + const t = theme ? THEMES[toKey(theme)] : null; + + root.style.setProperty('--bg-primary', t ? t.bgPrimary : DEFAULT_BG); + root.style.setProperty('--season-accent', t ? t.accentShift : 'transparent'); + + // Update body background to match + document.body.style.backgroundColor = t ? t.bgPrimary : DEFAULT_BG; +} + +function toKey(theme: string): string { + return theme.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''); +} + +/** + * Return the seasonal CSS background for a given theme. + * Used inline where CSS variables aren't available (e.g. iframe embeds). + */ +export function seasonBg(theme: string | null | undefined): string { + if (!theme) return DEFAULT_BG; + const t = THEMES[toKey(theme)]; + return t ? t.bgPrimary : DEFAULT_BG; +} diff --git a/web/src/pages/replay.ts b/web/src/pages/replay.ts index 2068852..5790b7d 100644 --- a/web/src/pages/replay.ts +++ b/web/src/pages/replay.ts @@ -26,6 +26,8 @@ import { type DurationPreset, } from '../components/director'; import { THEATER_STYLES, TheaterMode } from '../components/theater'; +import { setActiveReplay } from '../components/pip-registry'; +import { getPipMatchId, restorePip } from '../components/pip'; const loadReplayViewer = () => import('../replay-viewer'); @@ -457,7 +459,27 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { const viewModeSelect = document.getElementById('view-mode-select') as HTMLSelectElement; const mobileViewModeBtn = document.getElementById('mobile-view-mode-btn') as HTMLButtonElement; - let viewer = new ReplayViewerClass(canvas, { cellSize: 10 }); + // §16.13: If PIP is active, restore the canvas from the mini-player. + // The canvas-wrapper in the DOM is the inline target; we move the PIP canvas + // back into it and reuse the viewer instance that's still running. + const pipMatch = getPipMatchId(); + const canvasWrapper = document.querySelector('.canvas-wrapper') as HTMLElement; + let viewer: any; + + if (pipMatch && canvasWrapper && (initialUrl?.includes(pipMatch) || !initialUrl)) { + const restored = restorePip(); + if (restored) { + // Move canvas back into the inline wrapper + canvasWrapper.insertBefore(restored.canvas, canvasWrapper.firstChild); + restored.canvas.style.display = 'block'; + viewer = new ReplayViewerClass(restored.canvas, { cellSize: 10 }); + } else { + viewer = new ReplayViewerClass(canvas, { cellSize: 10 }); + } + } else { + viewer = new ReplayViewerClass(canvas, { cellSize: 10 }); + } + let criticalMoments: Array<{turn: number; delta: number; description: string}> = []; let commentaryEnabled = true; let debugPanelExpanded = false; @@ -648,6 +670,28 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void { initDebugPanel(replay); initAnnotations(replay); + // §16.13: Register active replay for PIP support + const pipCanvasWrapper = document.querySelector('.canvas-wrapper') as HTMLElement; + const pipCanvasEl = (document.getElementById('replay-canvas') as HTMLCanvasElement) + ?? document.querySelector('.canvas-wrapper canvas') as HTMLCanvasElement; + if (pipCanvasEl && pipCanvasWrapper) { + setActiveReplay({ + matchId: replay.match_id, + canvas: pipCanvasEl, + canvasWrapper: pipCanvasWrapper, + getScoreText: () => { + const r = viewer.getReplay() as Replay | null; + if (!r) return ''; + return r.players.map((p: any, i: number) => `${p.name}: ${r.result.scores?.[i] ?? 0}`).join(' '); + }, + getTurn: () => viewer.getTurn(), + getTotalTurns: () => viewer.getTotalTurns(), + getIsPlaying: () => viewer.getIsPlaying(), + togglePlay: () => viewer.togglePlay(), + pause: () => viewer.pause(), + }); + } + const hasAnyDebug = replay.turns.some(t => t.debug && Object.keys(t.debug).length > 0); if (hasAnyDebug) { debugPanel.style.display = '';