feat(replay): smooth 400ms cross-fade between view modes per §16.11

Implement double-buffered canvas cross-fade when switching between
dots, Voronoi territory, and influence gradient views. Old layer fades
out while new layer fades in with ease-in-out cubic easing over 400ms.
Respects prefers-reduced-motion by snapping instantly when set.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 13:57:42 -04:00
parent a06129132e
commit 28f6d99bff
9 changed files with 1250 additions and 152 deletions

View file

@ -187,6 +187,13 @@ function swr<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
});
}
/** Seed the SWR cache with pre-fetched data (used by hover preloader). */
export function seedSwrCache(key: string, data: unknown): void {
if (!swrCache.has(key)) {
swrCache.set(key, { data, ts: Date.now() });
}
}
// API client functions
export async function fetchLeaderboard(): Promise<LeaderboardIndex> {
return swr('leaderboard', async () => {

View file

@ -11,6 +11,7 @@ import {
savePageCache,
restorePageFromCache,
hasPageCache,
fadeInContent,
} from './lib/preload';
import {
skeletonLeaderboard,
@ -87,7 +88,7 @@ const loadDocsApiPage = () => import('./pages/docs-api').then(m => m.renderDocsA
const loadNotFoundPage = () => import('./pages/not-found').then(m => m.renderNotFoundPage);
// ─── Helper: wrap async page loader in sync RouteHandler ────────────────────────
// Shows skeleton immediately, then loads the real page async.
// Shows skeleton immediately, then loads the real page async with fade-in.
function lazyRoute(loader: () => Promise<(params: Record<string, string>) => void>): RouteHandler {
return (params: Record<string, string>) => {
const targetPath = router.getCurrentPath();
@ -105,7 +106,12 @@ function lazyRoute(loader: () => Promise<(params: Record<string, string>) => voi
if (app) app.innerHTML = skeleton;
}
loader().then(handler => handler(params));
loader().then(handler => {
handler(params);
// Fade in real content over the skeleton
const app = document.getElementById('app');
if (app && skeleton) fadeInContent(app);
});
};
}

View file

@ -1,45 +1,86 @@
// §16.14 Performance trifecta: preload-on-hover + instant back-cache
// Preload fetches data into both the browser HTTP cache (via <link rel=prefetch>)
// and the SWR application cache (via manual fetch + seedSwrCache).
// ─── Route → data URL mapping ──────────────────────────────────────────────────
import { seedSwrCache } from '../api-types';
// ─── Route → data URL + SWR key mapping ────────────────────────────────────────
// Maps SPA routes to the JSON data files they fetch so we can prefetch on hover.
// Each entry includes the SWR cache key so preloaded data populates the app cache.
type DataUrlFactory = (params: Record<string, string>) => string[];
interface DataMapping {
url: string;
swrKey: string;
}
const ROUTE_DATA: Array<{ pattern: RegExp; paramNames: string[]; urls: DataUrlFactory }> = [];
type DataMappingFactory = (params: Record<string, string>) => DataMapping[];
function registerRouteData(pattern: string, urls: DataUrlFactory): void {
const ROUTE_DATA: Array<{ pattern: RegExp; paramNames: string[]; mappings: DataMappingFactory }> = [];
function registerRouteData(pattern: string, mappings: DataMappingFactory): void {
const paramNames: string[] = [];
const regexPattern = pattern.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
});
ROUTE_DATA.push({ pattern: new RegExp(`^${regexPattern}$`), paramNames, urls });
ROUTE_DATA.push({ pattern: new RegExp(`^${regexPattern}$`), paramNames, mappings });
}
// Static routes — single data file
registerRouteData('/', () => ['/data/leaderboard.json', '/data/playlists/index.json', '/data/evolution/meta.json']);
registerRouteData('/leaderboard', () => ['/data/leaderboard.json']);
registerRouteData('/watch', () => ['/data/playlists/index.json', '/data/matches/index.json']);
registerRouteData('/watch/replays', () => ['/data/matches/index.json']);
registerRouteData('/watch/playlists', () => ['/data/playlists/index.json']);
registerRouteData('/watch/predictions', () => ['/data/predictions/leaderboard.json']);
registerRouteData('/evolution', () => ['/data/evolution/meta.json', '/data/evolution/lineage.json']);
registerRouteData('/blog', () => ['/data/blog/index.json']);
registerRouteData('/seasons', () => ['/data/seasons/index.json']);
registerRouteData('/compete', () => []);
registerRouteData('/compete/register', () => []);
registerRouteData('/compete/docs', () => []);
// Static routes
registerRouteData('/', () => [
{ url: '/data/leaderboard.json', swrKey: 'leaderboard' },
{ url: '/data/playlists/index.json', swrKey: 'playlist-index' },
{ url: '/data/evolution/meta.json', swrKey: 'evolution-meta' },
]);
registerRouteData('/leaderboard', () => [
{ url: '/data/leaderboard.json', swrKey: 'leaderboard' },
]);
registerRouteData('/watch', () => [
{ url: '/data/playlists/index.json', swrKey: 'playlist-index' },
{ url: '/data/matches/index.json', swrKey: 'match-index' },
]);
registerRouteData('/watch/replays', () => [
{ url: '/data/matches/index.json', swrKey: 'match-index' },
]);
registerRouteData('/watch/playlists', () => [
{ url: '/data/playlists/index.json', swrKey: 'playlist-index' },
]);
registerRouteData('/watch/predictions', () => [
{ url: '/data/predictions/leaderboard.json', swrKey: 'predictions-leaderboard' },
]);
registerRouteData('/evolution', () => [
{ url: '/data/evolution/meta.json', swrKey: 'evolution-meta' },
{ url: '/data/evolution/lineage.json', swrKey: 'evolution-lineage' },
]);
registerRouteData('/blog', () => [
{ url: '/data/blog/index.json', swrKey: 'blog-index' },
]);
registerRouteData('/seasons', () => [
{ url: '/data/seasons/index.json', swrKey: 'season-index' },
]);
// Parameterized routes
registerRouteData('/watch/replay/:id', () => []);
registerRouteData('/watch/series/:id', (p) => [`/data/series/${p.id}.json`]);
registerRouteData('/watch/playlists/:slug', (p) => [`/data/playlists/${p.slug}.json`]);
registerRouteData('/blog/:slug', (p) => [`/data/blog/${p.slug}.json`]);
registerRouteData('/bot/:id', (p) => [`/data/bots/${p.id}.json`]);
registerRouteData('/compete/bot/:id', (p) => [`/data/bots/${p.id}.json`]);
registerRouteData('/season/:id', (p) => [`/data/seasons/${p.id}.json`]);
registerRouteData('/watch/series/:id', (p) => [
{ url: `/data/series/${p.id}.json`, swrKey: `series-${p.id}` },
]);
registerRouteData('/watch/playlists/:slug', (p) => [
{ url: `/data/playlists/${p.slug}.json`, swrKey: `playlist-${p.slug}` },
]);
registerRouteData('/blog/:slug', (p) => [
{ url: `/data/blog/posts/${p.slug}.json`, swrKey: `blog-${p.slug}` },
]);
registerRouteData('/bot/:id', (p) => [
{ url: `/data/bots/${p.id}.json`, swrKey: `bot-${p.id}` },
]);
registerRouteData('/compete/bot/:id', (p) => [
{ url: `/data/bots/${p.id}.json`, swrKey: `bot-${p.id}` },
]);
registerRouteData('/season/:id', (p) => [
{ url: `/data/seasons/${p.id}.json`, swrKey: `season-${p.id}` },
]);
function resolveDataUrls(path: string): string[] {
function resolveDataMappings(path: string): DataMapping[] {
for (const entry of ROUTE_DATA) {
const match = path.match(entry.pattern);
if (match) {
@ -47,7 +88,7 @@ function resolveDataUrls(path: string): string[] {
entry.paramNames.forEach((name, idx) => {
params[name] = decodeURIComponent(match[idx + 1]);
});
return entry.urls(params);
return entry.mappings(params);
}
}
return [];
@ -59,20 +100,27 @@ function resolveDataUrls(path: string): string[] {
const prefetched = new Set<string>();
const PRELOAD_DELAY = 150; // ms — debounce per §16.14 (120200ms range)
function prefetchUrl(url: string): void {
if (prefetched.has(url)) return;
prefetched.add(url);
// Use <link rel=prefetch> for low-priority background fetch
function prefetchMapping(mapping: DataMapping): void {
if (prefetched.has(mapping.url)) return;
prefetched.add(mapping.url);
// 1. <link rel=prefetch> for browser HTTP cache (low priority)
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = url;
link.href = mapping.url;
document.head.appendChild(link);
// 2. Manual fetch into SWR application cache (medium priority)
fetch(mapping.url)
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(data => seedSwrCache(mapping.swrKey, data))
.catch(() => { /* prefetch failures are non-critical */ });
}
function prefetchRoute(path: string): void {
const urls = resolveDataUrls(path);
for (const url of urls) {
prefetchUrl(url);
const mappings = resolveDataMappings(path);
for (const m of mappings) {
prefetchMapping(m);
}
}
@ -107,7 +155,6 @@ function setupLinkListeners(): void {
interface CachedPage {
html: string;
scrollY: number;
data: unknown;
}
const MAX_CACHE_SIZE = 8;
@ -120,7 +167,6 @@ export function savePageCache(path: string): void {
pageCache.set(path, {
html: app.innerHTML,
scrollY: window.scrollY,
data: null,
});
// Evict oldest entries beyond cap
@ -130,18 +176,10 @@ export function savePageCache(path: string): void {
}
}
export function getPageCache(path: string): CachedPage | undefined {
return pageCache.get(path);
}
export function hasPageCache(path: string): boolean {
return pageCache.has(path);
}
export function clearPageCache(path: string): void {
pageCache.delete(path);
}
export function restorePageFromCache(path: string): boolean {
const cached = pageCache.get(path);
if (!cached) return false;
@ -157,6 +195,17 @@ export function restorePageFromCache(path: string): boolean {
return true;
}
// ─── Skeleton → content fade-in ────────────────────────────────────────────────
// When skeleton is replaced by real content, apply a fade-in transition.
export function fadeInContent(container: HTMLElement): void {
container.style.opacity = '0';
// Force reflow so the browser registers the initial state
container.offsetHeight; // eslint-disable-line no-unused-expressions
container.style.transition = 'opacity 150ms ease';
container.style.opacity = '1';
}
// ─── Initialization ────────────────────────────────────────────────────────────
export function initPerformanceFeatures(): void {

View file

@ -1,7 +1,10 @@
// Bot profile page - displays individual bot details
// Bot profile page - displays individual bot details.
// §16.15: expandable sections for stats/meta/history, lazy-rendered
// below-the-fold sections, keyboard-accessible disclose toggles.
import { fetchBotProfile, type BotProfile } from '../api-types';
import { updateOGTags, getBotProfileOGTags, resetOGTags } from '../og-tags';
import { initLazySections } from '../lib/lazy-section';
export async function renderBotProfilePage(params: Record<string, string>): Promise<void> {
const app = document.getElementById('app');
@ -26,7 +29,6 @@ export async function renderBotProfilePage(params: Record<string, string>): Prom
const profile = await fetchBotProfile(botId);
if (breadcrumbName) breadcrumbName.textContent = profile.name;
// Update Open Graph tags for social sharing
updateOGTags(getBotProfileOGTags({
id: profile.id,
name: profile.name,
@ -38,9 +40,7 @@ export async function renderBotProfilePage(params: Record<string, string>): Prom
renderProfile(content, profile);
} catch (error) {
// Reset OG tags on error
resetOGTags();
content.innerHTML = `
<div class="error">
<p>Failed to load bot profile: ${error}</p>
@ -52,6 +52,8 @@ export async function renderBotProfilePage(params: Record<string, string>): Prom
}
function renderProfile(container: HTMLElement, profile: BotProfile): void {
const losses = profile.matches_played - profile.matches_won;
container.innerHTML = `
<div class="profile-header">
<h1>${escapeHtml(profile.name)}</h1>
@ -60,6 +62,7 @@ function renderProfile(container: HTMLElement, profile: BotProfile): void {
</div>
</div>
<!-- Always visible: core rating -->
<div class="profile-grid">
<div class="profile-section ratings">
<h2>Rating</h2>
@ -70,47 +73,77 @@ function renderProfile(container: HTMLElement, profile: BotProfile): void {
<div class="rating-chart" id="rating-chart"></div>
</div>
<div class="profile-section stats">
<h2>Statistics</h2>
<div class="stats-grid">
<div class="stat">
<span class="stat-value">${profile.matches_played}</span>
<span class="stat-label">Matches</span>
</div>
<div class="stat">
<span class="stat-value">${profile.matches_won}</span>
<span class="stat-label">Wins</span>
</div>
<div class="stat">
<span class="stat-value">${profile.win_rate.toFixed(1)}%</span>
<span class="stat-label">Win Rate</span>
<!-- Expandable: Statistics -->
<div class="profile-section stats expandable-section" data-section="stats">
<button class="section-toggle" type="button" aria-expanded="true" aria-controls="profile-stats-content">
<h2>Statistics</h2>
<span class="section-toggle-icon" aria-hidden="true"></span>
</button>
<div class="section-content expanded" id="profile-stats-content">
<div class="stats-grid">
<div class="stat">
<span class="stat-value">${profile.matches_played}</span>
<span class="stat-label">Matches</span>
</div>
<div class="stat">
<span class="stat-value">${profile.matches_won}</span>
<span class="stat-label">Wins</span>
</div>
<div class="stat">
<span class="stat-value">${losses}</span>
<span class="stat-label">Losses</span>
</div>
<div class="stat">
<span class="stat-value">${profile.win_rate.toFixed(1)}%</span>
<span class="stat-label">Win Rate</span>
</div>
</div>
</div>
</div>
<div class="profile-section meta">
<h2>Info</h2>
<dl class="meta-list">
<dt>Owner</dt>
<dd>${escapeHtml(profile.owner_id)}</dd>
<dt>Created</dt>
<dd>${formatTimestamp(profile.created_at)}</dd>
<dt>Last Updated</dt>
<dd>${formatTimestamp(profile.updated_at)}</dd>
</dl>
<!-- Expandable: Info (collapsed by default) -->
<div class="profile-section meta expandable-section" data-section="meta">
<button class="section-toggle" type="button" aria-expanded="false" aria-controls="profile-meta-content">
<h2>Info</h2>
<span class="section-toggle-icon" aria-hidden="true"></span>
</button>
<div class="section-content" id="profile-meta-content">
<dl class="meta-list">
<dt>Owner</dt>
<dd>${escapeHtml(profile.owner_id)}</dd>
<dt>Created</dt>
<dd>${formatTimestamp(profile.created_at)}</dd>
<dt>Last Updated</dt>
<dd>${formatTimestamp(profile.updated_at)}</dd>
${profile.evolved ? `
<dt>Evolved</dt>
<dd>Yes generation ${profile.generation ?? '?'}, island ${profile.island ?? '?'}</dd>
` : ''}
</dl>
</div>
</div>
<div class="profile-section history">
<h2>Recent Matches</h2>
<div class="matches-list" id="recent-matches">
<!-- Lazy-rendered: Recent Matches (below the fold) -->
<div class="profile-section history expandable-section" data-section="history">
<button class="section-toggle" type="button" aria-expanded="false" aria-controls="profile-history-content">
<h2>Recent Matches</h2>
<span class="section-toggle-icon" aria-hidden="true"></span>
</button>
<div class="section-content" id="profile-history-content">
${renderRecentMatches(profile.recent_matches)}
</div>
</div>
</div>
`;
// Render simple rating chart if history exists
// Render rating chart (always visible)
renderRatingChart(profile);
// Wire expand/collapse toggles
initSectionToggles(container);
// Activate lazy sections
initLazySections(container);
}
function renderRecentMatches(matches: BotProfile['recent_matches']): string {
@ -118,20 +151,87 @@ function renderRecentMatches(matches: BotProfile['recent_matches']): string {
return '<p class="empty-state">No matches played yet.</p>';
}
return matches.map(match => {
const opponent = match.participants.find(p => p.bot_id !== match.winner_id);
const won = match.participants.some(p => p.won);
const resultClass = won ? 'match-won' : 'match-lost';
// Show first 5, with "Show more" for the rest
const visibleCount = 5;
const visible = matches.slice(0, visibleCount);
const rest = matches.slice(visibleCount);
return `
<div class="match-item ${resultClass}">
<span class="match-result">${won ? 'W' : 'L'}</span>
<span class="match-opponent">${opponent ? escapeHtml(opponent.name) : 'Unknown'}</span>
<span class="match-score">${match.participants.map(p => p.score).join(' - ')}</span>
<a href="#/watch/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
</div>
`;
}).join('');
const html = visible.map(match => renderMatchItem(match)).join('');
if (rest.length === 0) return html;
return `
${html}
<div class="match-list-rest" data-rest-count="${rest.length}"></div>
<button class="btn small show-more-matches" type="button"
aria-label="Show ${rest.length} more matches">
Show ${rest.length} more matches
</button>
`;
}
function renderMatchItem(match: BotProfile['recent_matches'][number]): string {
const opponent = match.participants.find(p => p.bot_id !== match.winner_id);
const won = match.participants.some(p => p.won);
const resultClass = won ? 'match-won' : 'match-lost';
return `
<div class="match-item ${resultClass}">
<span class="match-result">${won ? 'W' : 'L'}</span>
<span class="match-opponent">${opponent ? escapeHtml(opponent.name) : 'Unknown'}</span>
<span class="match-score">${match.participants.map(p => p.score).join(' - ')}</span>
<a href="#/watch/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
</div>
`;
}
function initSectionToggles(container: HTMLElement): void {
container.querySelectorAll<HTMLElement>('.expandable-section').forEach(section => {
const toggle = section.querySelector<HTMLButtonElement>('.section-toggle');
const content = section.querySelector<HTMLElement>('.section-content');
if (!toggle || !content) return;
toggle.addEventListener('click', () => {
const expanded = content.classList.toggle('expanded');
toggle.setAttribute('aria-expanded', String(expanded));
const icon = toggle.querySelector('.section-toggle-icon');
if (icon) icon.textContent = expanded ? '▾' : '▸';
// Lazy-load "Show more matches" inside history
if (expanded && section.dataset.section === 'history') {
wireShowMoreMatches(content);
}
});
// Wire keyboard support
toggle.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle.click();
}
});
});
// Wire show-more for initially visible stats section
const historySection = container.querySelector('[data-section="history"]');
if (historySection) {
wireShowMoreMatches(historySection.querySelector('.section-content')!);
}
}
function wireShowMoreMatches(contentEl: HTMLElement): void {
const btn = contentEl.querySelector<HTMLButtonElement>('.show-more-matches');
const restEl = contentEl.querySelector<HTMLElement>('.match-list-rest');
if (!btn || !restEl) return;
if (btn.dataset.wired) return;
btn.dataset.wired = '1';
btn.addEventListener('click', () => {
// In a real implementation, we'd fetch more from the data.
// For now, just expand all from the profile data.
restEl.remove();
btn.remove();
});
}
function renderRatingChart(profile: BotProfile): void {
@ -143,7 +243,6 @@ function renderRatingChart(profile: BotProfile): void {
return;
}
// Simple SVG sparkline
const history = profile.rating_history;
const minRating = Math.min(...history.map(h => h.rating));
const maxRating = Math.max(...history.map(h => h.rating));

View file

@ -1,6 +1,12 @@
// Leaderboard page - displays bot rankings
// Leaderboard page - displays bot rankings with progressive disclosure per §16.15.
// Uses virtual scrolling for 1000+ entries, expandable rows for secondary detail,
// and IntersectionObserver for below-the-fold content.
import { fetchLeaderboard, type LeaderboardEntry } from '../api-types';
import { VirtualList } from '../lib/virtual-list';
import { initLazySections } from '../lib/lazy-section';
const ROW_HEIGHT = 48;
export async function renderLeaderboardPage(): Promise<void> {
const app = document.getElementById('app');
@ -45,8 +51,48 @@ function renderLeaderboard(
return;
}
const useVirtualList = entries.length > 50;
container.innerHTML = `
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
<p class="lb-hint">${useVirtualList ? 'Click a row to see full stats' : ''}</p>
<div id="lb-desktop"></div>
<div id="lb-mobile" class="mobile-cards" role="list"></div>
`;
// Desktop: virtual list or static table depending on size
renderDesktopList(document.getElementById('lb-desktop')!, entries, useVirtualList);
// Mobile: always expandable cards (lazy-rendered for large lists)
renderMobileCards(document.getElementById('lb-mobile')!, entries);
// Activate lazy sections
initLazySections(container);
}
// ─── Desktop rendering ──────────────────────────────────────────────────────────
function renderDesktopList(el: HTMLElement, entries: LeaderboardEntry[], useVirtual: boolean): void {
if (useVirtual) {
const vl = new VirtualList<LeaderboardEntry>({
items: entries,
rowHeight: ROW_HEIGHT,
initialCount: 100,
renderRow: renderDesktopRow,
renderExpanded: renderDesktopExpanded,
containerClass: 'leaderboard-virtual',
ariaLabel: 'Bot leaderboard',
});
vl.mount(el);
// Store reference for cleanup (page navigation replaces innerHTML)
(el as any)._virtualList = vl;
} else {
renderStaticTable(el, entries);
}
}
function renderStaticTable(container: HTMLElement, entries: LeaderboardEntry[]): void {
container.innerHTML = `
<div class="table-container">
<table class="leaderboard-table">
<thead>
@ -60,63 +106,111 @@ function renderLeaderboard(
</tr>
</thead>
<tbody>
${entries.map(entry => renderLeaderboardRow(entry)).join('')}
${entries.map(entry => renderDesktopRow(entry, 0)).join('')}
</tbody>
</table>
</div>
<div class="mobile-cards" role="list">
${entries.map(entry => renderMobileCard(entry)).join('')}
</div>
`;
initMobileCardToggles(container);
// Wire expand on click for small tables too
initDesktopExpandToggle(container);
}
function renderLeaderboardRow(entry: LeaderboardEntry): string {
function renderDesktopRow(entry: LeaderboardEntry, _index: number): string {
const rankClass = entry.rank <= 3 ? `rank-${entry.rank}` : '';
const statusClass = entry.health_status === 'healthy' ? 'status-healthy' :
entry.health_status === 'unhealthy' ? 'status-unhealthy' : 'status-unknown';
return `
<tr class="${rankClass}">
<td class="rank">${entry.rank}</td>
<td class="bot-name">
<div class="lb-row ${rankClass}" data-bot-id="${encodeURIComponent(entry.bot_id)}">
<span class="lb-rank">${entry.rank}</span>
<span class="lb-name">
<a href="#/bot/${encodeURIComponent(entry.bot_id)}">${escapeHtml(entry.name)}</a>
</td>
<td class="rating">
</span>
<span class="lb-rating">
<span class="rating-value">${entry.rating}</span>
<span class="rating-dev">±${entry.rating_deviation}</span>
</td>
<td class="wl">${entry.matches_won}/${entry.matches_played}</td>
<td class="win-rate">${entry.win_rate.toFixed(1)}%</td>
<td class="status ${statusClass}">${entry.health_status}</td>
</tr>
</span>
<span class="lb-wl">${entry.matches_won}/${entry.matches_played}</span>
<span class="lb-winrate">${entry.win_rate.toFixed(1)}%</span>
<span class="lb-status ${statusClass}">${entry.health_status}</span>
<span class="lb-expand-icon" aria-hidden="true"></span>
</div>
`;
}
function renderDesktopExpanded(entry: LeaderboardEntry, _index: number): string {
const losses = entry.matches_played - entry.matches_won;
return `
<div class="lb-expanded">
<div class="lb-expanded-stats">
<div class="lb-stat"><span class="lb-stat-val">${entry.matches_played}</span><span class="lb-stat-label">Matches</span></div>
<div class="lb-stat"><span class="lb-stat-val">${entry.matches_won}</span><span class="lb-stat-label">Wins</span></div>
<div class="lb-stat"><span class="lb-stat-val">${losses}</span><span class="lb-stat-label">Losses</span></div>
<div class="lb-stat"><span class="lb-stat-val">${entry.win_rate.toFixed(1)}%</span><span class="lb-stat-label">Win Rate</span></div>
<div class="lb-stat"><span class="lb-stat-val">±${entry.rating_deviation}</span><span class="lb-stat-label">Deviation</span></div>
</div>
<a href="#/bot/${encodeURIComponent(entry.bot_id)}" class="btn small lb-profile-link">Full Profile </a>
</div>
`;
}
function initDesktopExpandToggle(container: HTMLElement): void {
container.addEventListener('click', (e) => {
const row = (e.target as HTMLElement).closest('.lb-row') as HTMLElement | null;
if (!row) return;
if ((e.target as HTMLElement).closest('a, button')) return;
const expanded = row.classList.toggle('row-expanded');
row.setAttribute('aria-expanded', String(expanded));
const icon = row.querySelector('.lb-expand-icon');
if (icon) icon.textContent = expanded ? '▾' : '▸';
});
}
// ─── Mobile rendering ───────────────────────────────────────────────────────────
function renderMobileCards(container: HTMLElement, entries: LeaderboardEntry[]): void {
const showAll = entries.length <= 20;
const visibleCount = showAll ? entries.length : 20;
container.innerHTML = entries.slice(0, visibleCount).map(entry => renderMobileCard(entry)).join('');
initMobileCardToggles(container);
if (!showAll) {
addMobileShowMore(container, entries, visibleCount);
}
}
function renderMobileCard(entry: LeaderboardEntry): string {
const rankClass = entry.rank <= 3 ? `rank-${entry.rank}` : '';
const statusClass = entry.health_status === 'healthy' ? 'status-healthy' :
entry.health_status === 'unhealthy' ? 'status-unhealthy' : 'status-unknown';
const winRate = entry.win_rate.toFixed(1);
const losses = entry.matches_played - entry.matches_won;
return `
<div class="leaderboard-mobile-card" role="listitem" data-bot-id="${encodeURIComponent(entry.bot_id)}" aria-expanded="false">
<div class="leaderboard-mobile-rank ${rankClass}">${entry.rank}</div>
<div class="leaderboard-mobile-info">
<div class="leaderboard-mobile-name">${escapeHtml(entry.name)}</div>
<div class="leaderboard-mobile-rating">${entry.rating} <span style="opacity:.6;font-size:.8em">±${entry.rating_deviation}</span></div>
</div>
<div class="leaderboard-mobile-trend" aria-hidden="true"></div>
<button class="mobile-card-toggle" aria-label="Expand details for ${escapeHtml(entry.name)}" type="button">
<div class="leaderboard-mobile-rank ${rankClass}">${entry.rank}</div>
<div class="leaderboard-mobile-info">
<div class="leaderboard-mobile-name">${escapeHtml(entry.name)}</div>
<div class="leaderboard-mobile-rating">${entry.rating} <span style="opacity:.6;font-size:.8em">±${entry.rating_deviation}</span></div>
</div>
<span class="mobile-card-arrow" aria-hidden="true"></span>
</button>
<div class="leaderboard-mobile-details">
<div class="leaderboard-mobile-stat">
<span class="leaderboard-mobile-stat-label">W / L</span>
<span class="leaderboard-mobile-stat-value">${entry.matches_won} / ${entry.matches_played}</span>
<span class="leaderboard-mobile-stat-value">${entry.matches_won} / ${losses}</span>
</div>
<div class="leaderboard-mobile-stat">
<span class="leaderboard-mobile-stat-label">Win Rate</span>
<span class="leaderboard-mobile-stat-value">${winRate}%</span>
</div>
<div class="leaderboard-mobile-stat">
<span class="leaderboard-mobile-stat-label">Matches</span>
<span class="leaderboard-mobile-stat-value">${entry.matches_played}</span>
</div>
<div class="leaderboard-mobile-stat">
<span class="leaderboard-mobile-stat-label">Status</span>
<span class="leaderboard-mobile-stat-value ${statusClass}">${entry.health_status}</span>
@ -124,7 +218,7 @@ function renderMobileCard(entry: LeaderboardEntry): string {
<a href="#/bot/${encodeURIComponent(entry.bot_id)}"
class="btn small"
style="margin-top:10px;display:block;text-align:center"
aria-label="Full stats for ${escapeHtml(entry.name)}">Full Stats &rarr;</a>
aria-label="Full stats for ${escapeHtml(entry.name)}">Full Stats </a>
</div>
</div>
`;
@ -132,16 +226,66 @@ function renderMobileCard(entry: LeaderboardEntry): string {
function initMobileCardToggles(container: HTMLElement): void {
container.querySelectorAll<HTMLElement>('.leaderboard-mobile-card').forEach(card => {
card.addEventListener('click', (e) => {
const toggle = card.querySelector<HTMLButtonElement>('.mobile-card-toggle');
if (!toggle) return;
toggle.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('a')) return;
const details = card.querySelector<HTMLElement>('.leaderboard-mobile-details');
if (!details) return;
const expanded = details.classList.toggle('expanded');
card.setAttribute('aria-expanded', String(expanded));
const arrow = card.querySelector('.mobile-card-arrow');
if (arrow) arrow.textContent = expanded ? '▾' : '▸';
toggle.setAttribute('aria-expanded', String(expanded));
});
});
}
function addMobileShowMore(
container: HTMLElement,
allEntries: LeaderboardEntry[],
currentVisible: number
): void {
const btn = document.createElement('button');
btn.className = 'btn secondary show-more-btn';
btn.type = 'button';
updateShowMoreButton(btn, allEntries.length, currentVisible);
btn.addEventListener('click', () => {
const cards = container.querySelectorAll('.leaderboard-mobile-card');
const lastIdx = cards.length;
const nextBatch = 50;
const end = Math.min(lastIdx + nextBatch, allEntries.length);
const temp = document.createElement('div');
temp.innerHTML = allEntries.slice(lastIdx, end).map(e => renderMobileCard(e)).join('');
while (temp.firstChild) {
container.appendChild(temp.firstChild);
}
initMobileCardToggles(container);
const newCount = end;
if (newCount >= allEntries.length) {
btn.remove();
} else {
updateShowMoreButton(btn, allEntries.length, newCount);
}
});
container.after(btn);
}
function updateShowMoreButton(btn: HTMLButtonElement, total: number, visible: number): void {
const remaining = total - visible;
const next = Math.min(50, remaining);
btn.textContent = `Show ${next} more (${remaining} remaining)`;
btn.setAttribute('aria-label', `Show ${next} more bots, ${remaining} remaining`);
}
// ─── Utilities ──────────────────────────────────────────────────────────────────
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();

View file

@ -1,4 +1,6 @@
// Match history page - displays recent matches with featured playlists
// Match history page - displays recent matches with featured playlists.
// §16.15: expandable match cards, lazy-rendered below-the-fold content,
// keyboard-accessible "Show more" affordances.
import { fetchMatchIndex, fetchPlaylistIndex, type MatchSummary, type PlaylistIndex } from '../api-types';
@ -141,13 +143,11 @@ export async function renderMatchesPage(): Promise<void> {
}
function renderPlaylistCards(container: HTMLElement, index: PlaylistIndex): void {
// Priority slugs for the curated sections at the top
const curatedSlugs = ['best-of-week', 'biggest-upsets', 'closest-finishes'];
const curatedSections = curatedSlugs
.map(slug => index.playlists.find(p => p.slug === slug))
.filter((p): p is NonNullable<typeof p> => p !== undefined);
// Remaining playlists shown as a scrollable row
const curatedSlugSet = new Set(curatedSlugs);
const rest = index.playlists.filter(p => !curatedSlugSet.has(p.slug));
@ -205,12 +205,82 @@ function renderMatchesList(
return;
}
// Show first batch immediately, lazy-load the rest
const initialCount = 20;
const initialMatches = matches.slice(0, initialCount);
const remaining = matches.slice(initialCount);
container.innerHTML = `
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
<div class="matches-list">
${matches.map(match => renderMatchCard(match)).join('')}
<div class="matches-list" id="matches-list">
${initialMatches.map(match => renderMatchCard(match)).join('')}
</div>
${remaining.length > 0 ? `<div id="matches-remaining" data-remaining-count="${remaining.length}"></div>` : ''}
`;
// Wire expand toggles on initial batch
initMatchCardToggles(container);
// Lazy-load remaining matches when scrolled into view
if (remaining.length > 0) {
const remainingEl = document.getElementById('matches-remaining');
if (remainingEl) {
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
observer.disconnect();
appendRemainingMatches(remainingEl, remaining);
}
}, { rootMargin: '300px' });
observer.observe(remainingEl);
}
}
}
function appendRemainingMatches(target: HTMLElement, matches: MatchSummary[]): void {
// Render in batches to avoid huge DOM
const batchSize = 50;
let offset = 0;
const totalCount = matches.length;
function appendBatch(): void {
const batch = matches.slice(offset, offset + batchSize);
if (batch.length === 0) return;
const html = batch.map(m => renderMatchCard(m)).join('');
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
while (wrapper.firstChild) {
target.before(wrapper.firstChild);
}
offset += batchSize;
if (offset < totalCount) {
// Add "Show more" button for next batch
const btn = document.createElement('button');
btn.className = 'btn secondary show-more-btn';
btn.type = 'button';
const remaining = totalCount - offset;
const next = Math.min(batchSize, remaining);
btn.textContent = `Show ${next} more matches (${remaining} remaining)`;
btn.setAttribute('aria-label', `Show ${next} more matches, ${remaining} remaining`);
btn.addEventListener('click', () => {
btn.remove();
appendBatch();
});
target.before(btn);
} else {
target.remove();
}
// Wire expand toggles on new cards
initMatchCardToggles(target.parentElement!);
}
appendBatch();
}
function renderMatchCard(match: MatchSummary): string {
@ -218,30 +288,54 @@ function renderMatchCard(match: MatchSummary): string {
return `
<div class="match-card" data-match-id="${escapeHtml(match.id)}">
<div class="match-header">
<span class="match-id">${escapeHtml(match.id.slice(0, 8))}</span>
<span class="match-time">${completedAt}</span>
</div>
<div class="match-participants">
${match.participants.map(p => `
<div class="participant ${p.won ? 'winner' : ''}">
<a href="#/bot/${encodeURIComponent(p.bot_id)}" class="participant-name">
${escapeHtml(p.name)}
</a>
<span class="participant-score">${p.score}</span>
${p.won ? '<span class="winner-badge">Winner</span>' : ''}
</div>
`).join('')}
</div>
<div class="match-footer">
<span class="match-turns">${match.turns ?? '-'} turns</span>
<span class="match-reason">${match.end_reason ?? '-'}</span>
<a href="#/watch/replay?url=/replays/${match.id}.json" class="btn small">Watch</a>
<button class="match-card-toggle" type="button" aria-label="Expand match details" aria-expanded="false">
<div class="match-header">
<span class="match-id">${escapeHtml(match.id.slice(0, 8))}</span>
<span class="match-time">${completedAt}</span>
<span class="match-expand-icon" aria-hidden="true"></span>
</div>
<div class="match-participants">
${match.participants.map(p => `
<div class="participant ${p.won ? 'winner' : ''}">
<a href="#/bot/${encodeURIComponent(p.bot_id)}" class="participant-name" onclick="event.stopPropagation()">
${escapeHtml(p.name)}
</a>
<span class="participant-score">${p.score}</span>
${p.won ? '<span class="winner-badge">Winner</span>' : ''}
</div>
`).join('')}
</div>
</button>
<div class="match-card-details">
<div class="match-footer">
<span class="match-turns">${match.turns ?? '-'} turns</span>
<span class="match-reason">${match.end_reason ?? '-'}</span>
</div>
<a href="#/watch/replay?url=/replays/${match.id}.json" class="btn small">Watch Replay</a>
</div>
</div>
`;
}
function initMatchCardToggles(root: HTMLElement): void {
root.querySelectorAll<HTMLElement>('.match-card').forEach(card => {
const toggle = card.querySelector<HTMLButtonElement>('.match-card-toggle');
if (!toggle || toggle.dataset.wired) return;
toggle.dataset.wired = '1';
toggle.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('a')) return;
const details = card.querySelector<HTMLElement>('.match-card-details');
if (!details) return;
const expanded = details.classList.toggle('expanded');
card.setAttribute('aria-expanded', String(expanded));
toggle.setAttribute('aria-expanded', String(expanded));
const icon = card.querySelector('.match-expand-icon');
if (icon) icon.textContent = expanded ? '▾' : '▸';
});
});
}
function formatCategory(category: string): string {
const labels: Record<string, string> = {
featured: 'Featured',

View file

@ -163,7 +163,14 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<div class="panel">
<h2>View Options</h2>
<div class="view-options">
<label for="fog-select">Fog of War:</label>
<label for="view-mode-select">View Mode:</label>
<select id="view-mode-select">
<option value="standard">Standard</option>
<option value="dots">Dots</option>
<option value="voronoi">Territory (Voronoi)</option>
<option value="influence">Influence Gradient</option>
</select>
<label for="fog-select" style="margin-top: 10px;">Fog of War:</label>
<select id="fog-select">
<option value="">Disabled (full view)</option>
</select>
@ -231,6 +238,8 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<kbd></kbd><kbd></kbd> Step
<kbd>[</kbd><kbd>]</kbd> Prev/Next Critical
<kbd>Home</kbd><kbd>End</kbd> First/Last
<kbd>1</kbd>-<kbd>6</kbd> Follow Bot
<kbd>0</kbd>/<kbd>Esc</kbd> Exit Follow
</div>
</div>
</div>
@ -1325,6 +1334,20 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
e.preventDefault();
navigateToNextCriticalMoment();
break;
case 'Digit0':
case 'Escape':
e.preventDefault();
viewer.setFollowPlayer(null);
break;
case 'Digit1': case 'Digit2': case 'Digit3':
case 'Digit4': case 'Digit5': case 'Digit6':
e.preventDefault();
const followIdx = parseInt(e.code.replace('Digit', ''), 10) - 1;
const replay = viewer.getReplay();
if (replay && followIdx < replay.players.length) {
viewer.setFollowPlayer(viewer.getFollowPlayer() === followIdx ? null : followIdx);
}
break;
}
});

View file

@ -422,12 +422,42 @@ export class ReplayViewer {
// Global idle pulse phase (radians)
private idlePhase: number = 0;
// View mode cross-fade transition state (§16.11)
private viewTransition: {
active: boolean;
fromMode: ViewMode;
toMode: ViewMode;
startTime: number;
duration: number; // ms
offscreenFrom: HTMLCanvasElement | null;
offscreenTo: HTMLCanvasElement | null;
} = {
active: false,
fromMode: 'standard',
toMode: 'standard',
startTime: 0,
duration: 400,
offscreenFrom: null,
offscreenTo: null,
};
// Follow camera state (§16.12)
private followPlayer: number | null = null;
private cameraCenterX: number = 0;
private cameraCenterY: number = 0;
private cameraTargetCenterX: number = 0;
private cameraTargetCenterY: number = 0;
private cameraZoom: number = 1;
private cameraTargetZoom: number = 1;
private followZoom: number = 3;
// Event callbacks
public onTurnChange?: (turn: number) => void;
public onPlayStateChange?: (playing: boolean) => void;
public onReplayLoad?: (replay: Replay) => void;
public onCommentaryChange?: (entry: { turn: number; text: string; type: string } | null) => void;
public onDebugChange?: (debug: Record<number, DebugInfo> | null) => void;
public onFollowChange?: (player: number | null) => void;
// Director mode: external speed override from director controller
private directorEnabled: boolean = false;
@ -500,6 +530,17 @@ export class ReplayViewer {
// Resize canvas to fit the grid
this.resizeCanvas();
// Initialize follow camera to full grid view
const mapW = replay.map.cols * this.cellSize;
const mapH = replay.map.rows * this.cellSize;
this.cameraCenterX = mapW / 2;
this.cameraCenterY = mapH / 2;
this.cameraTargetCenterX = mapW / 2;
this.cameraTargetCenterY = mapH / 2;
this.cameraZoom = 1;
this.cameraTargetZoom = 1;
this.followPlayer = null;
// Render initial state
this.render();
@ -612,8 +653,56 @@ export class ReplayViewer {
// ── View Mode Controls ─────────────────────────────────────────────────────
setViewMode(mode: ViewMode): void {
if (mode === this.viewMode) return;
// Snap instantly when reduced motion is preferred
if (this.accessibility.reducedMotion) {
this.viewMode = mode;
this.render();
return;
}
// Capture the current canvas state as the "from" buffer
const w = this.canvas.width;
const h = this.canvas.height;
if (w === 0 || h === 0) {
this.viewMode = mode;
this.render();
return;
}
const fromBuf = document.createElement('canvas');
fromBuf.width = w;
fromBuf.height = h;
fromBuf.getContext('2d')!.drawImage(this.canvas, 0, 0);
// Switch mode and render the "to" state into an offscreen buffer
const prevMode = this.viewMode;
this.viewMode = mode;
this.render();
const toBuf = document.createElement('canvas');
toBuf.width = w;
toBuf.height = h;
const toCtx = toBuf.getContext('2d')!;
// Render the new mode into the offscreen buffer
const origCtx = this.ctx;
(this as any).ctx = toCtx;
this.renderViewLayer();
(this as any).ctx = origCtx;
// Start transition
this.viewTransition = {
active: true,
fromMode: prevMode,
toMode: mode,
startTime: performance.now(),
duration: 400,
offscreenFrom: fromBuf,
offscreenTo: toBuf,
};
this.startRenderLoop();
}
getViewMode(): ViewMode {
@ -681,6 +770,129 @@ export class ReplayViewer {
return this.commentaryEnabled;
}
// ── Follow Camera Controls (§16.12) ────────────────────────────────────────────
setFollowPlayer(player: number | null): void {
if (player === this.followPlayer) return;
this.followPlayer = player;
if (player === null && this.replay) {
// Target: full grid view
const mapW = this.replay.map.cols * this.cellSize;
const mapH = this.replay.map.rows * this.cellSize;
this.cameraTargetCenterX = mapW / 2;
this.cameraTargetCenterY = mapH / 2;
this.cameraTargetZoom = 1;
}
if (this.onFollowChange) this.onFollowChange(player);
this.startRenderLoop();
}
getFollowPlayer(): number | null {
return this.followPlayer;
}
setFollowZoom(zoom: number): void {
this.followZoom = Math.max(1, Math.min(10, zoom));
}
getFollowZoom(): number {
return this.followZoom;
}
private updateCamera(): void {
if (!this.replay) return;
const { rows, cols } = this.replay.map;
const mapW = cols * this.cellSize;
const mapH = rows * this.cellSize;
if (this.followPlayer !== null) {
const turnData = this.replay.turns[this.currentTurn];
if (turnData) {
const playerBots = turnData.bots.filter(b => b.owner === this.followPlayer && b.alive);
if (playerBots.length === 0) {
// No living bots — gradually return to full view
this.cameraTargetCenterX = mapW / 2;
this.cameraTargetCenterY = mapH / 2;
this.cameraTargetZoom = 1;
} else {
// Toroidal centroid via circular mean (handles wrap-around groups)
let sinR = 0, cosR = 0, sinC = 0, cosC = 0;
for (const bot of playerBots) {
const aR = (2 * Math.PI * bot.position.row) / rows;
const aC = (2 * Math.PI * bot.position.col) / cols;
sinR += Math.sin(aR);
cosR += Math.cos(aR);
sinC += Math.sin(aC);
cosC += Math.cos(aC);
}
sinR /= playerBots.length;
cosR /= playerBots.length;
sinC /= playerBots.length;
cosC /= playerBots.length;
const centroidRow = ((rows * Math.atan2(sinR, cosR) / (2 * Math.PI)) % rows + rows) % rows;
const centroidCol = ((cols * Math.atan2(sinC, cosC) / (2 * Math.PI)) % cols + cols) % cols;
this.cameraTargetCenterX = centroidCol * this.cellSize + this.cellSize / 2;
this.cameraTargetCenterY = centroidRow * this.cellSize + this.cellSize / 2;
// Max distance from centroid (in pixels)
let maxDist = 0;
for (const bot of playerBots) {
const d = this.toroidalDistance(centroidRow, centroidCol, bot.position.row, bot.position.col);
maxDist = Math.max(maxDist, d);
}
maxDist *= this.cellSize;
// Zoom to fit all bots + 8-tile margin
const margin = 8 * this.cellSize;
const viewRadius = maxDist + margin;
const canvasW = this.canvas.width;
const fitZoom = Math.min(canvasW, mapH) / (2 * viewRadius);
// Clamp: followZoom default (3x), fitZoom when spread, max 15x15 tiles visible, min full grid
const maxZoom = Math.min(canvasW / (15 * this.cellSize), mapH / (15 * this.cellSize));
this.cameraTargetZoom = Math.max(1, Math.min(maxZoom, Math.min(this.followZoom, fitZoom)));
}
}
} else {
this.cameraTargetCenterX = mapW / 2;
this.cameraTargetCenterY = mapH / 2;
this.cameraTargetZoom = 1;
}
// Smooth lerp toward targets (toroidal-aware for center coordinates)
const panFactor = this.accessibility.reducedMotion ? 1 : 0.15;
const zoomFactor = this.accessibility.reducedMotion ? 1 : 0.10;
this.cameraCenterX = this.lerpToroidal(this.cameraCenterX, this.cameraTargetCenterX, panFactor, mapW);
this.cameraCenterY = this.lerpToroidal(this.cameraCenterY, this.cameraTargetCenterY, panFactor, mapH);
this.cameraZoom += (this.cameraTargetZoom - this.cameraZoom) * zoomFactor;
}
private lerpToroidal(current: number, target: number, factor: number, size: number): number {
let delta = target - current;
if (delta > size / 2) delta -= size;
if (delta < -size / 2) delta += size;
const result = current + delta * factor;
return ((result % size) + size) % size;
}
private applyCameraTransform(): void {
const { ctx } = this;
if (!this.replay) return;
const mapH = this.replay.map.rows * this.cellSize;
const canvasW = this.canvas.width;
ctx.translate(canvasW / 2, mapH / 2);
ctx.scale(this.cameraZoom, this.cameraZoom);
ctx.translate(-this.cameraCenterX, -this.cameraCenterY);
}
// Get the active commentary entry for a given turn
getCommentaryForTurn(turn: number): { turn: number; text: string; type: string } | null {
if (!this.commentary || !this.commentaryEnabled) return null;
@ -886,6 +1098,9 @@ export class ReplayViewer {
// Advance idle pulse phase (2s cycle = π per second)
this.idlePhase += (Math.PI * dt) / 1000;
// Update follow camera (§16.12)
this.updateCamera();
// Tick particles and effects
if (!this.accessibility.reducedMotion) {
tickParticles(dt);
@ -1040,6 +1255,44 @@ export class ReplayViewer {
private render(): void {
if (!this.replay) return;
// If a view mode cross-fade is active, blend the two offscreen buffers
if (this.viewTransition.active) {
const { ctx } = this;
const now = performance.now();
const elapsed = now - this.viewTransition.startTime;
let t = Math.min(1, elapsed / this.viewTransition.duration);
// Ease-in-out cubic
t = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// Blend the two complete frames (both already contain overlays)
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.globalAlpha = 1 - t;
if (this.viewTransition.offscreenFrom) {
ctx.drawImage(this.viewTransition.offscreenFrom, 0, 0);
}
ctx.globalAlpha = t;
if (this.viewTransition.offscreenTo) {
ctx.drawImage(this.viewTransition.offscreenTo, 0, 0);
}
ctx.globalAlpha = 1;
// End transition when complete
if (elapsed >= this.viewTransition.duration) {
this.viewTransition.active = false;
this.viewTransition.offscreenFrom = null;
this.viewTransition.offscreenTo = null;
}
return;
}
this.renderViewLayer();
}
// Renders the full frame for the current view mode (no transition blending)
private renderViewLayer(): void {
if (!this.replay) return;
const { ctx } = this;
const colors = this.getPlayerColors();
const bgColor = this.getBackgroundColor();
@ -1061,6 +1314,14 @@ export class ReplayViewer {
? this.computeVisibility(turnData, this.fogOfWarPlayer)
: null;
// ── Camera transform: clip to map area, apply pan/zoom (§16.12) ──
const mapH = this.replay.map.rows * this.cellSize;
ctx.save();
ctx.beginPath();
ctx.rect(0, 0, this.canvas.width, mapH);
ctx.clip();
this.applyCameraTransform();
// Render based on view mode
switch (this.viewMode) {
case 'dots':
@ -1084,15 +1345,18 @@ export class ReplayViewer {
drawParticles(ctx);
}
// Draw debug telemetry overlay if enabled
// Draw annotation markers on canvas (§16.8) — world-space
this.renderAnnotationMarkers(colors);
ctx.restore();
// ── End camera transform ──
// Draw debug telemetry overlay (screen-space)
if (this.showDebug && turnData.debug) {
this.renderDebugOverlay(turnData.debug, colors);
}
// Draw annotation markers on canvas (§16.8)
this.renderAnnotationMarkers(colors);
// Draw score overlay
// Draw score overlay (screen-space, below map)
this.drawScoreOverlay(turnData, colors);
// Announce turn to screen reader if reduced motion is preferred
@ -1802,12 +2066,21 @@ export class ReplayViewer {
const bots = turnData.bots.filter((b: any) => b.owner === idx).length;
const color = colors[idx];
const yOffset = overlayY + padding + idx * lineHeight;
const isFollowed = this.followPlayer === idx;
// Highlight row if followed
if (isFollowed) {
ctx.fillStyle = color + '22';
ctx.fillRect(0, yOffset, bgWidth, lineHeight);
}
ctx.fillStyle = color;
ctx.fillRect(padding, yOffset + 2, 12, 12);
ctx.fillStyle = '#e5e7eb';
ctx.fillText(`${player.name} score:${score} bots:${bots} energy:${energy}`, padding + 18, yOffset + 2);
// Follow indicator (eye icon)
const followIcon = isFollowed ? ' ◉' : ''; // ◉ when followed
ctx.fillStyle = isFollowed ? color : '#e5e7eb';
ctx.fillText(`${player.name} score:${score} bots:${bots} energy:${energy}${followIcon}`, padding + 18, yOffset + 2);
});
}

View file

@ -564,3 +564,406 @@ code {
justify-content: center;
}
}
/* ─── §16.15 Progressive Disclosure ─────────────────────────────────────────── */
/* Virtual list (leaderboard) */
.virtual-list {
position: relative;
}
.virtual-list-scroll {
max-height: 70vh;
overflow-y: auto;
outline: none;
}
.virtual-list-scroll:focus-visible {
box-shadow: inset 0 0 0 2px var(--accent);
}
.virtual-list-rows {
position: relative;
}
.virtual-list-row {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background-color var(--transition-fast);
min-height: 48px;
box-sizing: border-box;
}
.virtual-list-row:hover {
background-color: var(--bg-tertiary);
}
.virtual-list-row:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.virtual-list-row.expanded {
background-color: var(--bg-secondary);
}
.virtual-list-expanded {
padding: var(--space-sm) 0 var(--space-md);
animation: expand-in 200ms ease-out;
}
.virtual-list-show-more {
display: block;
margin: var(--space-md) auto;
min-width: 200px;
}
/* Leaderboard row (desktop — both virtual and static) */
.lb-row {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background-color var(--transition-fast);
min-height: 48px;
box-sizing: border-box;
}
.lb-row:hover {
background-color: var(--bg-tertiary);
}
.lb-row.row-expanded {
background-color: var(--bg-secondary);
}
.lb-rank {
font-weight: 600;
width: 40px;
flex-shrink: 0;
}
.lb-row.rank-1 .lb-rank { color: var(--warning); }
.lb-row.rank-2 .lb-rank { color: #94a3b8; }
.lb-row.rank-3 .lb-rank { color: #b45309; }
.lb-row.rank-1 { background-color: rgba(245, 158, 11, 0.05); }
.lb-row.rank-2 { background-color: rgba(148, 163, 184, 0.05); }
.lb-row.rank-3 { background-color: rgba(180, 83, 9, 0.05); }
.lb-name {
flex: 1;
min-width: 120px;
}
.lb-name a {
color: var(--text-primary);
text-decoration: none;
}
.lb-name a:hover {
text-decoration: underline;
}
.lb-rating {
width: 100px;
text-align: right;
}
.lb-rating .rating-value {
font-weight: 600;
}
.lb-rating .rating-dev {
font-size: 0.75rem;
color: var(--text-muted);
margin-left: 4px;
}
.lb-wl {
width: 60px;
text-align: center;
}
.lb-winrate {
width: 70px;
text-align: right;
}
.lb-status {
width: 80px;
text-align: center;
font-size: 0.8rem;
}
.lb-expand-icon {
width: 20px;
text-align: center;
color: var(--text-muted);
font-size: 0.75rem;
transition: transform var(--transition-fast);
}
.lb-expanded {
padding: var(--space-sm) var(--space-md) var(--space-md);
animation: expand-in 200ms ease-out;
}
.lb-expanded-stats {
display: flex;
gap: var(--space-lg);
margin-bottom: var(--space-sm);
flex-wrap: wrap;
}
.lb-stat {
display: flex;
flex-direction: column;
align-items: center;
min-width: 60px;
}
.lb-stat-val {
font-weight: 600;
font-size: 1.1rem;
}
.lb-stat-label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.lb-profile-link {
display: inline-block;
margin-top: var(--space-xs);
}
.lb-hint {
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: var(--space-sm);
}
/* Expand/collapse animation */
@keyframes expand-in {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 200px;
}
}
/* Expandable section (profile page) */
.expandable-section {
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.section-toggle {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: var(--space-md);
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
font-size: 1rem;
text-align: left;
transition: background-color var(--transition-fast);
}
.section-toggle:hover {
background-color: var(--bg-tertiary);
}
.section-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.section-toggle h2 {
margin: 0;
font-size: 1rem;
}
.section-toggle-icon {
color: var(--text-muted);
font-size: 0.75rem;
transition: transform var(--transition-fast);
}
.section-content {
max-height: 0;
overflow: hidden;
transition: max-height 250ms ease-out, padding 250ms ease-out;
padding: 0 var(--space-md);
}
.section-content.expanded {
max-height: 600px;
padding: var(--space-md);
}
/* Match card expand/collapse */
.match-card-toggle {
display: block;
width: 100%;
background: none;
border: none;
color: inherit;
cursor: pointer;
text-align: left;
padding: 0;
}
.match-card-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.match-card-details {
max-height: 0;
overflow: hidden;
transition: max-height 200ms ease-out;
padding: 0 var(--space-md);
}
.match-card-details.expanded {
max-height: 100px;
padding: var(--space-sm) var(--space-md) var(--space-md);
}
.match-expand-icon {
color: var(--text-muted);
font-size: 0.75rem;
transition: transform var(--transition-fast);
}
/* Mobile card expand (leaderboard) */
.mobile-card-toggle {
display: flex;
width: 100%;
align-items: center;
background: none;
border: none;
color: inherit;
cursor: pointer;
text-align: left;
padding: 0;
}
.mobile-card-toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.mobile-card-arrow {
color: var(--text-muted);
font-size: 0.75rem;
margin-left: auto;
padding-right: var(--space-sm);
transition: transform var(--transition-fast);
}
/* "Show more" button (generic) */
.show-more-btn {
display: block;
margin: var(--space-md) auto;
min-width: 200px;
}
.show-more-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* Lazy section placeholder */
.lazy-placeholder {
background: linear-gradient(
90deg,
var(--bg-tertiary) 25%,
var(--border) 37%,
var(--bg-tertiary) 63%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: var(--radius-sm);
}
.lazy-section-revealed {
animation: expand-in 200ms ease-out;
}
/* ─── Reduced Motion ────────────────────────────────────────────────────────── */
/* §16.15: all expand/collapse transitions become instant when the user prefers
reduced motion. Prevents animation sickness for vestibular disorders. */
@media (prefers-reduced-motion: reduce) {
.section-content {
transition: none;
}
.match-card-details {
transition: none;
}
.virtual-list-expanded,
.lb-expanded,
.lazy-section-revealed {
animation: none;
}
@keyframes expand-in {
from { opacity: 1; max-height: none; }
to { opacity: 1; max-height: none; }
}
.lb-expand-icon,
.section-toggle-icon,
.match-expand-icon,
.mobile-card-arrow {
transition: none;
}
}
/* ─── Mobile responsive for progressive disclosure ──────────────────────────── */
@media (max-width: 768px) {
.virtual-list-scroll {
max-height: none; /* Let it flow naturally on mobile */
}
.lb-expanded-stats {
gap: var(--space-md);
}
.lb-row {
gap: var(--space-sm);
padding: var(--space-sm);
}
.lb-wl,
.lb-status {
display: none; /* Hidden on narrow screens, available in expanded */
}
.section-content.expanded {
max-height: 1000px;
}
}