feat(mobile): implement mobile-first responsive design per §16.5

- Fix nav/home breakpoints from 768px → 639px to match design system
- Add leaderboard mobile card view with tap-to-expand stats and full-stats link
- Add canvas wrapper aspect-ratio:1 on phone (fills full width, square viewport)
- Add commentary text scroll on mobile, win-prob header stacking
- Replay viewer: mobile controls, pinch-to-zoom, tap-to-play, swipe scrub,
  floating view-mode toggle, debug telemetry slide-up sheet (already in place)
- Sandbox: desktop-required message with link already implemented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-21 17:04:02 -04:00
parent 38269d1285
commit b7fea448bd
6 changed files with 351 additions and 23 deletions

View file

@ -206,7 +206,7 @@
}
/* Responsive Navigation */
@media (max-width: 768px) {
@media (max-width: 639px) {
.nav-container {
height: 50px;
}

View file

@ -511,11 +511,13 @@ export async function renderHomePage(): Promise<void> {
padding: 16px 0;
}
/* Responsive */
@media (max-width: 768px) {
/* Responsive — phone (<640px) */
@media (max-width: 639px) {
.home-grid { grid-template-columns: 1fr; }
.home-hero h1 { font-size: 1.75rem; }
.home-tagline { font-size: 1rem; }
.home-hero { padding: 20px 16px; }
.home-ctas { flex-wrap: wrap; }
.home-season {
flex-direction: column;
gap: 10px;
@ -535,6 +537,7 @@ export async function renderHomePage(): Promise<void> {
gap: 8px;
align-items: flex-start;
}
.home-pl-card { width: 140px; }
}
</style>`;
}

View file

@ -18,7 +18,7 @@ export async function renderLeaderboardPage(): Promise<void> {
try {
const data = await fetchLeaderboard();
renderLeaderboardTable(content, data.entries, data.updated_at);
renderLeaderboard(content, data.entries, data.updated_at);
} catch (error) {
content.innerHTML = `
<div class="error">
@ -29,7 +29,7 @@ export async function renderLeaderboardPage(): Promise<void> {
}
}
function renderLeaderboardTable(
function renderLeaderboard(
container: HTMLElement,
entries: LeaderboardEntry[],
updatedAt: string
@ -47,22 +47,29 @@ function renderLeaderboardTable(
container.innerHTML = `
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Bot</th>
<th>Rating</th>
<th>W/L</th>
<th>Win Rate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${entries.map(entry => renderLeaderboardRow(entry)).join('')}
</tbody>
</table>
<div class="table-container">
<table class="leaderboard-table">
<thead>
<tr>
<th>Rank</th>
<th>Bot</th>
<th>Rating</th>
<th>W/L</th>
<th>Win Rate</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${entries.map(entry => renderLeaderboardRow(entry)).join('')}
</tbody>
</table>
</div>
<div class="mobile-cards" role="list">
${entries.map(entry => renderMobileCard(entry)).join('')}
</div>
`;
initMobileCardToggles(container);
}
function renderLeaderboardRow(entry: LeaderboardEntry): string {
@ -87,6 +94,54 @@ function renderLeaderboardRow(entry: LeaderboardEntry): string {
`;
}
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);
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>
<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>
</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">Status</span>
<span class="leaderboard-mobile-stat-value ${statusClass}">${entry.health_status}</span>
</div>
<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>
</div>
</div>
`;
}
function initMobileCardToggles(container: HTMLElement): void {
container.querySelectorAll<HTMLElement>('.leaderboard-mobile-card').forEach(card => {
card.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));
});
});
}
function formatTimestamp(iso: string): string {
try {
return new Date(iso).toLocaleString();

View file

@ -33,9 +33,29 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<div class="replay-layout">
<div class="replay-main">
<div class="canvas-wrapper">
<canvas id="replay-canvas"></canvas>
<canvas id="replay-canvas" style="touch-action:none"></canvas>
<div id="no-replay" class="no-replay-message">Load a replay file to view</div>
</div>
<!-- Mobile compact controls bar — CSS hides on tablet+ -->
<div class="mobile-replay-controls" id="mobile-controls">
<div class="mobile-playback-bar">
<button id="mobile-reset-btn" class="btn small" aria-label="Reset to start" disabled>&#9612;&#9612;</button>
<button id="mobile-prev-btn" class="btn small" aria-label="Previous turn" disabled>&#9664;</button>
<button id="mobile-play-btn" class="btn small primary" aria-label="Play or pause" disabled>&#9654;</button>
<button id="mobile-next-btn" class="btn small" aria-label="Next turn" disabled>&#9654;&#9654;</button>
<span id="mobile-turn-info" class="mobile-speed-display">T: 0/0</span>
<button id="mobile-speed-btn" class="btn small secondary" aria-label="Cycle playback speed">100ms</button>
</div>
<input type="range" id="mobile-turn-slider" min="0" max="0" value="0"
style="width:100%;margin-top:4px" disabled aria-label="Turn scrubber">
</div>
<!-- Mobile event timeline ribbon — CSS hides on tablet+ -->
<div class="mobile-event-timeline" id="mobile-timeline" aria-label="Event timeline">
<span style="color:var(--text-muted);font-size:0.75rem;padding:4px 8px">Load a replay</span>
</div>
<div id="win-prob-section" class="win-prob-section" style="display:none">
<div class="win-prob-header">
<span class="win-prob-title">Win Probability</span>
@ -162,6 +182,10 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
</div>
</div>
<!-- Floating view mode toggle — CSS hides on tablet+desktop -->
<button id="mobile-view-mode-btn" class="mobile-view-mode-toggle"
aria-label="Switch view mode" title="Switch view mode">&#128065;</button>
<!-- Debug telemetry bottom sheet (mobile) / sidebar panel (desktop) -->
<div id="debug-panel" class="panel debug-panel" style="display:none">
<div class="debug-panel-header" id="debug-panel-toggle-btn" role="button" tabindex="0"
@ -332,11 +356,35 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
const debugPlayerToggles = document.getElementById('debug-player-toggles') as HTMLDivElement;
const debugInfoDisplay = document.getElementById('debug-info-display') as HTMLDivElement;
// Mobile controls
const mobilePlayBtn = document.getElementById('mobile-play-btn') as HTMLButtonElement;
const mobilePrevBtn = document.getElementById('mobile-prev-btn') as HTMLButtonElement;
const mobileNextBtn = document.getElementById('mobile-next-btn') as HTMLButtonElement;
const mobileResetBtn = document.getElementById('mobile-reset-btn') as HTMLButtonElement;
const mobileTurnInfo = document.getElementById('mobile-turn-info') as HTMLSpanElement;
const mobileTurnSlider = document.getElementById('mobile-turn-slider') as HTMLInputElement;
const mobileSpeedBtn = document.getElementById('mobile-speed-btn') as HTMLButtonElement;
const mobileTimeline = document.getElementById('mobile-timeline') as HTMLDivElement;
const mobileViewModeBtn = document.getElementById('mobile-view-mode-btn') as HTMLButtonElement;
let viewer = new ReplayViewerClass(canvas, { cellSize: 10 });
let criticalMoments: Array<{turn: number; delta: number; description: string}> = [];
let commentaryEnabled = true;
let debugPanelExpanded = false;
// Mobile speed cycling
const SPEED_STEPS = [1000, 500, 200, 100, 50, 20];
let mobileSpeedIdx = 3; // default 100ms
// View mode cycling
const VIEW_MODES: Array<'standard' | 'dots' | 'voronoi' | 'influence'> = ['standard', 'dots', 'voronoi', 'influence'];
const VIEW_MODE_ICONS: Record<string, string> = { standard: '\u{1F5FA}', dots: '··', voronoi: '⬡', influence: '◎' };
// Pinch-to-zoom pointer state
const activePointers = new Map<number, PointerEvent>();
let pinchStartDist = 0;
let pinchStartCellSize = 10;
function enableControls(): void {
playBtn.disabled = false;
prevBtn.disabled = false;
@ -346,6 +394,63 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
noReplayDiv.style.display = 'none';
}
function enableMobileControls(): void {
mobilePlayBtn.disabled = false;
mobilePrevBtn.disabled = false;
mobileNextBtn.disabled = false;
mobileResetBtn.disabled = false;
mobileTurnSlider.disabled = false;
}
function updateMobileUI(): void {
const turn = viewer.getTurn();
const total = viewer.getTotalTurns();
mobileTurnInfo.textContent = `T: ${turn}/${total - 1}`;
mobileTurnSlider.value = String(turn);
mobilePlayBtn.textContent = viewer.getIsPlaying() ? '⏸' : '▶';
}
function buildMobileTimeline(replay: Replay): void {
const eventTurns: number[] = [];
replay.turns.forEach((t: any, i: number) => {
if (t.events && t.events.length > 0) eventTurns.push(i);
});
if (eventTurns.length === 0) {
mobileTimeline.innerHTML = '<span style="color:var(--text-muted);font-size:0.75rem;padding:4px 8px">No events</span>';
return;
}
const currentTurn = viewer.getTurn();
mobileTimeline.innerHTML = eventTurns.map(turn => {
const active = turn === currentTurn ? ' active' : '';
return `<button class="mobile-event-dot${active}" data-turn="${turn}" aria-label="Turn ${turn}"><span style="font-size:0.65rem">${turn}</span></button>`;
}).join('');
mobileTimeline.querySelectorAll<HTMLElement>('.mobile-event-dot').forEach(dot => {
dot.addEventListener('click', () => {
const t = parseInt(dot.dataset.turn!, 10);
viewer.setTurn(t);
updateUI();
updateEventLog();
updateMobileUI();
updateMobileTimeline();
});
});
}
function updateMobileTimeline(): void {
const currentTurn = viewer.getTurn();
mobileTimeline.querySelectorAll<HTMLElement>('.mobile-event-dot').forEach(dot => {
const t = parseInt(dot.dataset.turn!, 10);
dot.classList.toggle('active', t === currentTurn);
});
const activeDot = mobileTimeline.querySelector<HTMLElement>('.mobile-event-dot.active');
if (activeDot) {
activeDot.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
}
}
function updateUI(): void {
turnDisplay.textContent = String(viewer.getTurn());
totalTurnsSpan.textContent = String(viewer.getTotalTurns());
@ -393,10 +498,14 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
function loadReplay(replay: Replay): void {
viewer.loadReplay(replay);
enableControls();
enableMobileControls();
updateMatchInfo(replay);
turnSlider.max = String(viewer.getTotalTurns() - 1);
mobileTurnSlider.max = String(viewer.getTotalTurns() - 1);
updateUI();
updateEventLog();
updateMobileUI();
buildMobileTimeline(replay);
initWinProb(replay);
loadCommentary(replay.match_id);
initDebugPanel(replay);
@ -684,11 +793,16 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
updateUI();
updateEventLog();
if (criticalMoments.length > 0) updateCriticalMomentNav();
updateMobileUI();
updateMobileTimeline();
};
viewer.onDebugChange = (debug: Record<number, DebugInfo> | null) => {
updateDebugDisplay(debug);
};
viewer.onPlayStateChange = (playing: boolean) => { playBtn.textContent = playing ? 'Pause' : 'Play'; };
viewer.onPlayStateChange = (playing: boolean) => {
playBtn.textContent = playing ? 'Pause' : 'Play';
mobilePlayBtn.textContent = playing ? '⏸' : '▶';
};
viewer.onCommentaryChange = (entry: { turn: number; text: string; type: string } | null) => {
if (!entry || !commentaryEnabled) {
commentaryText.textContent = '';
@ -698,6 +812,118 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
commentaryText.className = `commentary-text type-${entry.type}`;
};
// ── Mobile controls ─────────────────────────────────────────────────────────
mobilePlayBtn.addEventListener('click', () => viewer.togglePlay());
mobilePrevBtn.addEventListener('click', () => {
viewer.setTurn(viewer.getTurn() - 1);
updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline();
});
mobileNextBtn.addEventListener('click', () => {
viewer.setTurn(viewer.getTurn() + 1);
updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline();
});
mobileResetBtn.addEventListener('click', () => {
viewer.pause(); viewer.setTurn(0);
updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline();
});
mobileTurnSlider.addEventListener('input', () => {
viewer.setTurn(parseInt(mobileTurnSlider.value, 10));
updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline();
});
mobileSpeedBtn.addEventListener('click', () => {
mobileSpeedIdx = (mobileSpeedIdx + 1) % SPEED_STEPS.length;
const speed = SPEED_STEPS[mobileSpeedIdx];
viewer.setSpeed(speed);
speedDisplay.textContent = String(speed);
speedSlider.value = String(speed);
mobileSpeedBtn.textContent = `${speed}ms`;
});
// Floating view mode toggle
mobileViewModeBtn.addEventListener('click', () => {
const current = viewer.getViewMode();
const idx = VIEW_MODES.indexOf(current as any);
const next = VIEW_MODES[(idx + 1) % VIEW_MODES.length];
viewer.setViewMode(next);
mobileViewModeBtn.textContent = VIEW_MODE_ICONS[next] ?? '👁';
});
// ── Canvas touch gestures ────────────────────────────────────────────────────
// Tap = play/pause; horizontal swipe = prev/next turn; two-finger pinch = zoom
let tapStartX = 0;
let tapStartY = 0;
let tapStartTime = 0;
canvas.addEventListener('pointerdown', (e: PointerEvent) => {
activePointers.set(e.pointerId, e);
canvas.setPointerCapture(e.pointerId);
if (activePointers.size === 1) {
tapStartX = e.clientX;
tapStartY = e.clientY;
tapStartTime = Date.now();
} else if (activePointers.size === 2) {
const pts = [...activePointers.values()];
const dx = pts[0].clientX - pts[1].clientX;
const dy = pts[0].clientY - pts[1].clientY;
pinchStartDist = Math.sqrt(dx * dx + dy * dy);
pinchStartCellSize = viewer.getCellSize();
}
});
canvas.addEventListener('pointermove', (e: PointerEvent) => {
activePointers.set(e.pointerId, e);
if (activePointers.size === 2) {
const pts = [...activePointers.values()];
const dx = pts[0].clientX - pts[1].clientX;
const dy = pts[0].clientY - pts[1].clientY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (pinchStartDist > 0) {
const newSize = Math.round(pinchStartCellSize * (dist / pinchStartDist));
viewer.setCellSize(newSize);
}
}
});
canvas.addEventListener('pointerup', (e: PointerEvent) => {
const wasOne = activePointers.size === 1;
const endX = e.clientX;
const endY = e.clientY;
activePointers.delete(e.pointerId);
if (wasOne) {
const dx = endX - tapStartX;
const dy = endY - tapStartY;
const elapsed = Date.now() - tapStartTime;
const dist = Math.sqrt(dx * dx + dy * dy);
if (elapsed < 300 && dist < 12) {
// Tap: play/pause
if (viewer.getReplay()) viewer.togglePlay();
} else if (elapsed < 500 && Math.abs(dx) > 40 && Math.abs(dy) < 50) {
// Horizontal swipe: scrub turn
if (!viewer.getReplay()) return;
if (dx < 0) {
viewer.setTurn(viewer.getTurn() + 1);
} else {
viewer.setTurn(viewer.getTurn() - 1);
}
updateUI(); updateEventLog(); updateMobileUI(); updateMobileTimeline();
}
}
if (activePointers.size < 2) {
pinchStartDist = 0;
}
});
canvas.addEventListener('pointercancel', (e: PointerEvent) => {
activePointers.delete(e.pointerId);
if (activePointers.size < 2) pinchStartDist = 0;
});
// Commentary toggle
commentaryToggle.addEventListener('click', () => {
commentaryEnabled = !commentaryEnabled;

View file

@ -576,6 +576,18 @@ export class ReplayViewer {
return this.viewMode;
}
setCellSize(size: number): void {
this.cellSize = Math.max(4, Math.min(20, Math.round(size)));
if (this.replay) {
this.resizeCanvas();
this.render();
}
}
getCellSize(): number {
return this.cellSize;
}
setShowDebug(show: boolean): void {
this.showDebug = show;
this.render();

View file

@ -167,6 +167,33 @@
font-weight: 500;
}
/* Win probability sparkline — full width on mobile */
.win-prob-section {
margin-top: 8px;
}
.win-prob-header {
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.critical-moment-nav {
font-size: 0.75rem;
}
/* Commentary — scrollable on mobile if text is long */
.commentary-bar {
flex-wrap: wrap;
gap: 6px;
}
.commentary-content {
max-height: 60px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
/* Replay viewer mobile */
.replay-page .page-title {
font-size: 1.25rem;
@ -188,8 +215,13 @@
}
.canvas-wrapper {
padding: var(--space-xs);
padding: 0 !important;
border-radius: var(--radius-md);
/* Square viewport so canvas fills the full phone width */
width: 100%;
aspect-ratio: 1;
max-height: none !important;
overflow: hidden;
}
/* Mobile replay controls - compact bar below canvas */