feat(web): progressive disclosure for dense pages per §16.15

Leaderboard, bot directory, match history, and playlist pages now use
expandable rows/cards, IntersectionObserver lazy rendering, windowed
virtual scrolling (for 1000+ leaderboard entries), and batched "Show
more" affordances. All expand/collapse transitions respect
prefers-reduced-motion. Keyboard accessibility (Enter/Space to toggle)
and ARIA attributes (aria-expanded, aria-controls) added throughout.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 15:43:50 -04:00
parent e90d2e37c9
commit 4a92539c6f
4 changed files with 96 additions and 21 deletions

View file

@ -6,6 +6,7 @@ import { fetchBotDirectory, type BotDirectoryEntry } from '../api-types';
import { initLazySections, lazySection } from '../lib/lazy-section';
const INITIAL_COUNT = 30;
const BATCH_SIZE = 30;
export async function renderBotsPage(): Promise<void> {
const app = document.getElementById('app');
@ -65,21 +66,82 @@ function renderBotsList(
return;
}
// Use lazySection for below-the-fold bot cards
const remainingHtml = remaining.map((bot, i) => renderBotCard(bot, INITIAL_COUNT + i + 1)).join('');
// For remaining bots, wrap in a lazy section that shows a "Show more" button
// instead of rendering everything at once when revealed
container.innerHTML = `
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
<div class="bots-grid">
${initialHtml}
${lazySection(
'bots-remaining',
remainingHtml,
{ placeholder: '<div class="lazy-placeholder" style="min-height:200px"></div>' }
)}
</div>
<div class="bots-grid" id="bots-grid">${initialHtml}</div>
<div id="bots-remaining-anchor"></div>
`;
initLazySections(container);
const grid = document.getElementById('bots-grid')!;
const anchor = document.getElementById('bots-remaining-anchor')!;
// Use IntersectionObserver to trigger "Show more" batching when user scrolls near bottom
let offset = 0;
const total = remaining.length;
function updateShowMoreBtn(btn: HTMLButtonElement): void {
const left = total - offset;
if (left <= 0) { btn.remove(); return; }
const next = Math.min(BATCH_SIZE, left);
btn.textContent = `Show ${next} more bots (${left} remaining)`;
btn.setAttribute('aria-label', `Show ${next} more bots, ${left} remaining`);
}
if (total > BATCH_SIZE) {
// Lazy-load with IntersectionObserver + "Show more" button
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
observer.disconnect();
// Render first batch of remaining
appendBatch();
}
}, { rootMargin: '300px' });
observer.observe(anchor);
// Cleanup on navigation
window.addEventListener('hashchange', () => observer.disconnect(), { once: true });
} else {
// Small remainder — render in a lazy section
const remainingHtml = remaining.map((bot, i) => renderBotCard(bot, INITIAL_COUNT + i + 1)).join('');
anchor.innerHTML = lazySection(
'bots-remaining',
remainingHtml,
{ placeholder: '<div class="lazy-placeholder" style="min-height:200px"></div>' }
);
initLazySections(anchor);
}
function appendBatch(): void {
const batch = remaining.slice(offset, offset + BATCH_SIZE);
if (batch.length === 0) return;
const html = batch.map((bot, i) => renderBotCard(bot, INITIAL_COUNT + offset + i + 1)).join('');
const temp = document.createElement('div');
temp.innerHTML = html;
while (temp.firstChild) {
grid.appendChild(temp.firstChild);
}
offset += batch.length;
if (offset < total) {
const btn = document.createElement('button');
btn.className = 'btn secondary show-more-btn';
btn.type = 'button';
updateShowMoreBtn(btn);
btn.addEventListener('click', () => {
btn.remove();
appendBatch();
});
grid.after(btn);
} else {
anchor.remove();
}
}
}
function renderBotCard(bot: BotDirectoryEntry, rank: number): string {

View file

@ -135,7 +135,7 @@ function renderDesktopRow(entry: LeaderboardEntry, _index: number): string {
const statusClass = entry.health_status === 'healthy' ? 'status-healthy' :
entry.health_status === 'unhealthy' ? 'status-unhealthy' : 'status-unknown';
return `
<div class="lb-row ${rankClass}" data-bot-id="${encodeURIComponent(entry.bot_id)}">
<div class="lb-row ${rankClass}" data-bot-id="${encodeURIComponent(entry.bot_id)}" tabindex="0" role="button" aria-expanded="false">
<span class="lb-rank">${entry.rank}</span>
<span class="lb-name">
<a href="#/bot/${encodeURIComponent(entry.bot_id)}">${escapeHtml(entry.name)}</a>
@ -173,11 +173,24 @@ function initDesktopExpandToggle(container: HTMLElement): void {
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 ? '▾' : '▸';
toggleRowExpand(row);
});
// Keyboard support for static table rows
container.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const row = (e.target as HTMLElement).closest('.lb-row') as HTMLElement | null;
if (!row) return;
e.preventDefault();
toggleRowExpand(row);
});
}
function toggleRowExpand(row: HTMLElement): void {
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 ───────────────────────────────────────────────────────────

View file

@ -293,7 +293,7 @@ function renderMatchCard(match: MatchSummary): string {
return `
<div class="match-card" data-match-id="${escapeHtml(match.id)}">
<button class="match-card-toggle" type="button" aria-label="Expand match details" aria-expanded="false">
<button class="match-card-toggle" type="button" aria-label="Expand match details" aria-expanded="false" aria-controls="match-details-${escapeHtml(match.id)}">
<div class="match-header">
<span class="match-id">${escapeHtml(match.id.slice(0, 8))}</span>
<span class="match-time">${completedAt}</span>
@ -311,7 +311,7 @@ function renderMatchCard(match: MatchSummary): string {
`).join('')}
</div>
</button>
<div class="match-card-details">
<div class="match-card-details" id="match-details-${escapeHtml(match.id)}">
<div class="match-footer">
<span class="match-turns">${match.turns ?? '-'} turns</span>
<span class="match-reason">${match.end_reason ?? '-'}</span>
@ -333,7 +333,6 @@ function initMatchCardToggles(root: HTMLElement): void {
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 ? '▾' : '▸';

View file

@ -440,7 +440,8 @@ function renderPlaylistMatchHtml(m: PlaylistMatch): string {
<div class="playlist-match" data-match-id="${m.match_id}">
<button class="playlist-match-toggle" type="button"
aria-label="Expand details for ${escapeHtml(m.title || `Match ${m.order + 1}`)}"
aria-expanded="false">
aria-expanded="false"
aria-controls="playlist-match-details-${m.match_id}">
<span class="match-order">${m.order + 1}</span>
<div class="match-info">
<div class="match-title">${escapeHtml(m.title || `Match ${m.order + 1}`)}</div>
@ -449,7 +450,7 @@ function renderPlaylistMatchHtml(m: PlaylistMatch): string {
</div>
<span class="match-expand-icon" aria-hidden="true"></span>
</button>
<div class="playlist-match-details">
<div class="playlist-match-details" id="playlist-match-details-${m.match_id}">
<div class="playlist-match-actions">
<button class="watch-btn" data-match-id="${m.match_id}">Watch</button>
<button class="embed-btn" data-match-id="${m.match_id}">Embed</button>