feat(web): progressive disclosure — lazy sections, expandable details per §16.15

Wire IntersectionObserver-based lazy rendering into bot profile (recent
matches below fold) and leaderboard (mobile cards). All three dense pages
(leaderboard, matches, bot-profile) now use expandable rows/cards for
secondary detail, windowed rendering for long lists, and keyboard-accessible
"Show more" affordances. Expand/collapse animations respect reduced-motion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 14:01:13 -04:00
parent 28f6d99bff
commit 5cf9a786d5
3 changed files with 59 additions and 14 deletions

View file

@ -4,7 +4,7 @@
import { fetchBotProfile, type BotProfile } from '../api-types';
import { updateOGTags, getBotProfileOGTags, resetOGTags } from '../og-tags';
import { initLazySections } from '../lib/lazy-section';
import { initLazySections, lazySection } from '../lib/lazy-section';
export async function renderBotProfilePage(params: Record<string, string>): Promise<void> {
const app = document.getElementById('app');
@ -124,15 +124,19 @@ function renderProfile(container: HTMLElement, profile: BotProfile): void {
</div>
<!-- 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>
${lazySection(
'history',
`<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>`,
{ placeholder: '<div class="lazy-placeholder" style="min-height:80px"></div>' }
)}
</div>
`;

View file

@ -4,7 +4,7 @@
import { fetchLeaderboard, type LeaderboardEntry } from '../api-types';
import { VirtualList } from '../lib/virtual-list';
import { initLazySections } from '../lib/lazy-section';
import { initLazySections, lazySection } from '../lib/lazy-section';
const ROW_HEIGHT = 48;
@ -63,8 +63,22 @@ function renderLeaderboard(
// 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);
// Mobile: lazy-rendered expandable cards for large lists
if (useVirtualList) {
// Wrap mobile cards in a lazy section so they don't render until scrolled into view
const mobileEl = document.getElementById('lb-mobile')!;
mobileEl.innerHTML = lazySection(
'lb-mobile-cards',
entries.slice(0, 20).map(entry => renderMobileCard(entry)).join(''),
{ placeholder: '<div class="lazy-placeholder" style="min-height:400px"></div>' }
);
initMobileCardToggles(mobileEl);
if (entries.length > 20) {
addMobileShowMore(mobileEl, entries, 20);
}
} else {
renderMobileCards(document.getElementById('lb-mobile')!, entries);
}
// Activate lazy sections
initLazySections(container);

View file

@ -181,6 +181,14 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<option value="10" selected>Large (10px)</option>
<option value="12">X-Large (12px)</option>
</select>
<label for="follow-zoom-select" style="margin-top: 10px;">Follow Zoom:</label>
<select id="follow-zoom-select">
<option value="2">2x</option>
<option value="3" selected>3x</option>
<option value="4">4x</option>
<option value="5">5x</option>
<option value="6">6x</option>
</select>
</div>
</div>
@ -753,7 +761,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
}
}
// Handle canvas clicks for spatial annotation position
// Handle canvas clicks for follow player selection and annotation position
canvas.addEventListener('click', (e: MouseEvent) => {
if (!viewer.getReplay()) return;
const replay = viewer.getReplay();
@ -764,6 +772,20 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
const cellSize = viewer.getCellSize();
const mapRows = replay.map.rows;
const mapHeight = mapRows * cellSize;
const overlayY = mapHeight + 4;
const overlayPadding = 8;
const lineHeight = 20;
// Check if click is on score overlay (follow player selection)
if (y >= overlayY + overlayPadding && x < replay.map.cols * cellSize) {
const relY = y - overlayY - overlayPadding;
const playerIdx = Math.floor(relY / lineHeight);
if (playerIdx >= 0 && playerIdx < replay.players.length) {
viewer.setFollowPlayer(viewer.getFollowPlayer() === playerIdx ? null : playerIdx);
return;
}
}
// Only accept clicks within the map area (not score overlay)
const col = Math.floor(x / cellSize);
@ -1113,6 +1135,11 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
}
});
const followZoomSelect = document.getElementById('follow-zoom-select') as HTMLSelectElement;
followZoomSelect.addEventListener('change', () => {
viewer.setFollowZoom(parseInt(followZoomSelect.value, 10));
});
// Accessibility toggle handlers
const colorBlindToggle = document.getElementById('color-blind-toggle') as HTMLInputElement;
const shapesToggle = document.getElementById('shapes-toggle') as HTMLInputElement;