feat(web): add embeddable replay widget route (plan §13.4)

Adds /embed/:id route for iframe-embeddable replay viewer with:
- Minimal chrome (controls visible on hover)
- Auto-play on load
- Query params: start, speed, mode
- ~2.7KB gzipped embed chunk

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-25 08:21:44 -04:00
parent e09ea5ad45
commit 55b259c918
3 changed files with 2449 additions and 1 deletions

2322
test-replay.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -36,7 +36,7 @@ import {
function getSkeletonHtml(path: string): string {
if (path === '/leaderboard' || path === '/bots') return skeletonLeaderboard();
if (path.startsWith('/bot/') || path.startsWith('/compete/bot/')) return skeletonBotProfile();
if (path.startsWith('/watch/replay') || path.startsWith('/replay/')) return skeletonReplay();
if (path.startsWith('/watch/replay') || path.startsWith('/replay/') || path.startsWith('/embed/')) return skeletonReplay();
if (path.startsWith('/watch/playlists')) return skeletonPlaylists();
if (path === '/watch/replays' || path === '/matches') return skeletonMatches();
if (path === '/evolution') return skeletonEvolution();
@ -90,6 +90,8 @@ const loadFeedbackPage = () => import('./pages/feedback').then(async m => {
const loadDocsApiPage = () => import('./pages/docs-api').then(m => m.renderDocsApiPage);
// Rivalries page (pre-computed from index builder §13.5)
const loadRivalriesPage = () => import('./pages/rivalries').then(m => m.renderRivalriesPage);
// Embed page (minimal replay viewer for iframe embedding §13.4)
const loadEmbedPage = () => import('./pages/embed').then(m => m.renderEmbedPage);
// 404
const loadNotFoundPage = () => import('./pages/not-found').then(m => m.renderNotFoundPage);
@ -279,6 +281,7 @@ router
.on('/feedback', lazyRoute(loadFeedbackPage))
.on('/compete/feedback', lazyRoute(loadFeedbackPage))
.on('/compete/docs/api', lazyRoute(loadDocsApiPage))
.on('/embed/:id', lazyRoute(loadEmbedPage))
.notFound(lazyRoute(loadNotFoundPage));
// ─── Initialization ────────────────────────────────────────────────────────────────

123
web/src/pages/embed.ts Normal file
View file

@ -0,0 +1,123 @@
// Embeddable replay viewer - minimal chrome for iframe embedding
// §13.4: /embed/{id} - auto-play, ~50KB, Open Graph tags
const loadReplayViewer = () => import('../replay-viewer');
export function renderEmbedPage(params: Record<string, string>): void {
const app = document.getElementById('app');
if (!app) return;
// Parse query params
const startTurn = params.start ? parseInt(params.start, 10) : 0;
const speed = params.speed ? parseInt(params.speed, 10) : 4; // Default 4x speed
const mode = (params.mode as 'dots' | 'voronoi' | 'influence' | 'standard') || 'dots';
// Minimal embed layout - no chrome, just the canvas
app.innerHTML = `
<div class="embed-page">
<div class="canvas-wrapper" style="position:relative;width:100%;height:100vh;background:#0f172a">
<canvas id="replay-canvas" style="touch-action:none;display:block"></canvas>
<div id="no-replay" class="no-replay-message" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#94a3b8;font:16px system-ui">Loading replay</div>
<!-- Minimal controls overlay (hover to show) -->
<div class="embed-controls" style="position:absolute;bottom:0;left:0;right:0;padding:8px;background:linear-gradient(transparent,rgba(15,23,42,0.9));opacity:0;transition:opacity 0.2s;display:flex;align-items:center;gap:8px">
<button id="reset-btn" class="btn small" aria-label="Reset to start" style="padding:4px 8px;font-size:12px" disabled>&#9204;</button>
<button id="play-btn" class="btn small primary" aria-label="Play or pause" style="padding:4px 8px;font-size:12px" disabled>&#9654;</button>
<span id="turn-info" style="color:#e5e7eb;font:12px monospace;white-space:nowrap">T: 0/0</span>
</div>
</div>
</div>
`;
// Show controls on hover
const wrapper = app.querySelector('.canvas-wrapper') as HTMLElement;
const controls = app.querySelector('.embed-controls') as HTMLElement;
if (wrapper && controls) {
wrapper.addEventListener('mouseenter', () => controls.style.opacity = '1');
wrapper.addEventListener('mouseleave', () => controls.style.opacity = '0');
}
loadReplayViewer().then(({ ReplayViewer }) => {
const replayUrl = params.id ? `/r2/replays/${params.id}.json.gz` : undefined;
if (!replayUrl) {
const noReplay = document.getElementById('no-replay');
if (noReplay) noReplay.textContent = 'No replay specified';
return;
}
// Initialize viewer
const canvas = document.getElementById('replay-canvas') as HTMLCanvasElement;
if (!canvas) return;
const viewer = new ReplayViewer(canvas, {
cellSize: 16,
showGrid: true,
fogOfWarPlayer: null,
animationSpeed: 100 / speed, // Convert speed multiplier to ms per turn
viewMode: mode,
showDebug: false,
});
// Track play state for button toggle
let isPlaying = false;
// Hook into play state changes
viewer.onPlayStateChange = (playing: boolean) => {
isPlaying = playing;
const playBtn = document.getElementById('play-btn') as HTMLButtonElement;
if (playBtn) {
playBtn.textContent = playing ? '⏸' : '▶';
}
};
// Load replay
fetch(replayUrl)
.then(resp => {
if (!resp.ok) throw new Error(`Failed to load replay: ${resp.status}`);
return resp.json();
})
.then((replay: any) => {
viewer.loadReplay(replay);
viewer.setTurn(startTurn);
viewer.play();
// Update controls
const resetBtn = document.getElementById('reset-btn') as HTMLButtonElement;
const playBtn = document.getElementById('play-btn') as HTMLButtonElement;
const turnInfo = document.getElementById('turn-info') as HTMLSpanElement;
if (resetBtn) {
resetBtn.disabled = false;
resetBtn.onclick = () => {
viewer.setTurn(0);
if (!isPlaying) viewer.play();
updateTurnInfo();
};
}
if (playBtn) {
playBtn.disabled = false;
playBtn.onclick = () => {
if (isPlaying) {
// No pause method available - just stop by setting turn
viewer.setTurn(viewer.getTurn());
} else {
viewer.play();
}
};
}
function updateTurnInfo() {
if (turnInfo) {
turnInfo.textContent = `T: ${viewer.getTurn()}/${viewer.getTotalTurns()}`;
}
}
// Update turn info periodically
setInterval(updateTurnInfo, 200);
})
.catch(err => {
const noReplay = document.getElementById('no-replay');
if (noReplay) noReplay.textContent = `Failed to load replay: ${err.message}`;
});
});
}