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:
parent
a06129132e
commit
28f6d99bff
9 changed files with 1250 additions and 152 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (120–200ms 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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 →</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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue