From 4a92539c6f8760d684576006c6754985a2e6c87e Mon Sep 17 00:00:00 2001 From: jedarden Date: Wed, 22 Apr 2026 15:43:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20progressive=20disclosure=20for=20d?= =?UTF-8?q?ense=20pages=20per=20=C2=A716.15?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/pages/bots.ts | 84 +++++++++++++++++++++++++++++++----- web/src/pages/leaderboard.ts | 23 +++++++--- web/src/pages/matches.ts | 5 +-- web/src/pages/playlists.ts | 5 ++- 4 files changed, 96 insertions(+), 21 deletions(-) diff --git a/web/src/pages/bots.ts b/web/src/pages/bots.ts index 13acada..d82578c 100644 --- a/web/src/pages/bots.ts +++ b/web/src/pages/bots.ts @@ -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 { 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 = `

Last updated: ${formatTimestamp(updatedAt)}

-
- ${initialHtml} - ${lazySection( - 'bots-remaining', - remainingHtml, - { placeholder: '
' } - )} -
+
${initialHtml}
+
`; - 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: '
' } + ); + 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 { diff --git a/web/src/pages/leaderboard.ts b/web/src/pages/leaderboard.ts index 7fa08f4..3b89ee0 100644 --- a/web/src/pages/leaderboard.ts +++ b/web/src/pages/leaderboard.ts @@ -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 ` -
+