feat(web): add ambient activity awareness per §16.18

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 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 17:06:02 -04:00
parent cecbc4a2a0
commit 75672f6a92
6 changed files with 815 additions and 2 deletions

View file

@ -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();
});

View file

@ -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;
}

387
web/src/components/pip.ts Normal file
View file

@ -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 = `
<div class="pip-canvas-wrapper" id="pip-canvas-slot"></div>
<div class="pip-controls">
<button class="pip-btn" id="pip-play-btn" aria-label="Play or pause"></button>
<span class="pip-score" id="pip-score">-</span>
<span class="pip-turn" id="pip-turn">T:0/0</span>
<button class="pip-btn" id="pip-return-btn" aria-label="Return to full view" title="Return to full view"></button>
<button class="pip-close" id="pip-close-btn" aria-label="Close mini player">&times;</button>
</div>
`;
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);
}

270
web/src/lib/ambient.ts Normal file
View file

@ -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<typeof setInterval> | 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 <link>.
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<HTMLLinkElement>('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<AmbientEventType, string> = {
match_result: '#ef4444',
prediction_resolved: '#f59e0b',
rivalry_update: '#3b82f6',
season_event: '#22c55e',
};
const EVENT_TITLES: Record<AmbientEventType, string> = {
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<void> {
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<void> {
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
}
}

View file

@ -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<string, SeasonTheme> = {
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;
}

View file

@ -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 = '';