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