feat(web): progressive disclosure — lazy-load, expand details, windowed lists per §16.15

Virtual list tracks expanded row heights for correct spacer calculations.
Leaderboard mobile cards use event delegation so toggles work inside lazy
sections. Mobile card details animate with max-height instead of display
toggle. Reduced-motion rules cover all expand/collapse patterns.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-22 15:06:55 -04:00
parent c618f0b7a1
commit 04927a76b0
8 changed files with 140 additions and 39 deletions

View file

@ -370,8 +370,8 @@ export class PlaylistCarousel {
private async fetchReplay(matchId: string): Promise<Replay> {
const urls = [
`${R2_BASE}/replays/${matchId}.json`,
`${B2_FALLBACK}/replays/${matchId}.json`,
`${R2_BASE}/replays/${matchId}.json.gz`,
`${B2_FALLBACK}/replays/${matchId}.json.gz`,
];
for (const url of urls) {
try {
@ -379,7 +379,7 @@ export class PlaylistCarousel {
if (resp.ok) return await resp.json();
} catch { /* try next */ }
}
const resp = await fetch(`/replays/${matchId}.json`);
const resp = await fetch(`/replays/${matchId}.json.gz`);
if (!resp.ok) throw new Error(`Failed to fetch replay ${matchId}`);
return resp.json();
}
@ -431,7 +431,7 @@ export class PlaylistCarousel {
btn.addEventListener('click', () => {
const id = (btn as HTMLElement).dataset.matchId!;
this.destroy();
window.location.hash = `/watch/replay?url=/replays/${id}.json`;
window.location.hash = `/watch/replay?url=/replays/${id}.json.gz`;
});
}
}

View file

@ -1,6 +1,8 @@
// §16.15 Virtual list — renders only visible rows for large datasets.
// Keeps the DOM small even with 1000+ entries. Each row has a fixed height
// Keeps the DOM small even with 1000+ entries. Each row has a base height
// and only rows in the viewport (plus a buffer) are materialised.
// Expanded rows are tracked with their actual measured height so spacer
// calculations remain correct.
export interface VirtualListOptions<T> {
items: T[];
@ -21,6 +23,7 @@ interface VirtualListState {
visibleStart: number;
visibleEnd: number;
expandedIndex: number | null;
expandedHeight: number;
showMoreCount: number;
}
@ -44,6 +47,7 @@ export class VirtualList<T> {
visibleStart: 0,
visibleEnd: Math.min(initial, opts.items.length),
expandedIndex: null,
expandedHeight: 0,
showMoreCount: initial,
};
}
@ -119,13 +123,50 @@ export class VirtualList<T> {
});
}
private computeWindow(): void {
/** Compute the height of a row at the given index, accounting for expansion. */
private rowHeightAt(idx: number): number {
if (this.state.expandedIndex === idx && this.state.expandedHeight > 0) {
return this.opts.rowHeight + this.state.expandedHeight;
}
return this.opts.rowHeight;
}
/** Compute the total height of rows from index 0 up to (but not including) `end`. */
private totalHeight(end: number): number {
const { rowHeight } = this.opts;
if (this.state.expandedIndex === null || this.state.expandedHeight === 0) {
// Fast path: no expanded row, all same height
return end * rowHeight;
}
let h = 0;
for (let i = 0; i < end; i++) {
h += this.rowHeightAt(i);
}
return h;
}
/** Find the row index that contains the given scroll offset. */
private indexAtOffset(offset: number): number {
const { rowHeight, items } = this.opts;
const maxIdx = Math.min(this.state.showMoreCount, items.length);
if (this.state.expandedIndex === null || this.state.expandedHeight === 0) {
// Fast path: uniform row heights
return Math.floor(offset / rowHeight);
}
let acc = 0;
for (let i = 0; i < maxIdx; i++) {
acc += this.rowHeightAt(i);
if (acc > offset) return i;
}
return maxIdx;
}
private computeWindow(): void {
const buffer = this.opts.buffer ?? 5;
const maxIdx = this.state.showMoreCount;
const rawStart = Math.floor(this.state.scrollTop / rowHeight) - buffer;
const rawEnd = Math.ceil((this.state.scrollTop + this.state.viewportHeight) / rowHeight) + buffer;
const rawStart = this.indexAtOffset(this.state.scrollTop) - buffer;
const rawEnd = this.indexAtOffset(this.state.scrollTop + this.state.viewportHeight) + buffer;
this.state.visibleStart = Math.max(0, rawStart);
this.state.visibleEnd = Math.min(maxIdx, rawEnd);
@ -133,7 +174,7 @@ export class VirtualList<T> {
private render(): void {
if (!this.rowContainer || !this.spacerAbove || !this.spacerBelow) return;
const { items, rowHeight, renderRow, renderExpanded } = this.opts;
const { items, renderRow, renderExpanded } = this.opts;
const { visibleStart, visibleEnd, expandedIndex, showMoreCount } = this.state;
const frags: string[] = [];
@ -150,15 +191,16 @@ export class VirtualList<T> {
}
this.rowContainer.innerHTML = frags.join('');
this.spacerAbove.style.height = `${visibleStart * rowHeight}px`;
const belowStart = visibleEnd;
const belowCount = showMoreCount - belowStart;
this.spacerBelow.style.height = `${Math.max(0, belowCount) * rowHeight}px`;
// Set total scrollable height hint
if (this.scrollEl) {
this.scrollEl.style.setProperty('--vl-total-height', `${showMoreCount * rowHeight}px`);
// Spacer above = total height of all rows before visibleStart
this.spacerAbove.style.height = `${this.totalHeight(visibleStart)}px`;
// Spacer below = total height of all rows after visibleEnd up to showMoreCount
let belowHeight = 0;
for (let i = visibleEnd; i < showMoreCount; i++) {
belowHeight += this.rowHeightAt(i);
}
this.spacerBelow.style.height = `${Math.max(0, belowHeight)}px`;
// Wire expand toggles
this.rowContainer.querySelectorAll<HTMLElement>('.virtual-list-row').forEach(row => {
@ -168,10 +210,38 @@ export class VirtualList<T> {
this.toggleExpand(idx);
});
});
// Measure expanded row height after DOM update
if (expandedIndex !== null) {
const expandedRow = this.rowContainer.querySelector(`[data-idx="${expandedIndex}"]`);
const expandedEl = expandedRow?.querySelector<HTMLElement>('.virtual-list-expanded');
if (expandedEl) {
const measured = expandedEl.offsetHeight;
if (measured !== this.state.expandedHeight) {
this.state.expandedHeight = measured;
// Recalculate spacers with the new height
this.spacerAbove.style.height = `${this.totalHeight(visibleStart)}px`;
let belowHeight2 = 0;
for (let i = visibleEnd; i < showMoreCount; i++) {
belowHeight2 += this.rowHeightAt(i);
}
this.spacerBelow.style.height = `${Math.max(0, belowHeight2)}px`;
}
}
}
}
private toggleExpand(idx: number): void {
this.state.expandedIndex = this.state.expandedIndex === idx ? null : idx;
const prevExpanded = this.state.expandedIndex;
if (prevExpanded === idx) {
// Collapse current
this.state.expandedIndex = null;
this.state.expandedHeight = 0;
} else {
// Expand new (height measured after render)
this.state.expandedIndex = idx;
this.state.expandedHeight = 0;
}
this.render();
}

View file

@ -401,8 +401,7 @@ function initFeedback(): void {
// ─── Utilities ────────────────────────────────────────────────────────────────
function replayUrlForMatch(m: MatchSummary): string {
// Replays are stored in R2 at /replays/{match_id}.json
return `/replays/${m.id}.json`;
return `/replays/${m.id}.json.gz`;
}
function formatDate(s: string | null): string {

View file

@ -147,7 +147,7 @@ export async function renderHomePage(): Promise<void> {
? `${featuredReplay!.participants.map((p) => `<strong>${esc(p.name)}</strong>`).join(' vs ')}${featuredReplay!.winner_id ? ` — Winner: <strong>${esc(featuredReplay!.participants.find((p) => p.bot_id === featuredReplay!.winner_id)?.name || 'Unknown')}</strong>` : ''}`
: 'Demo Replay — Watch a sample battle';
const replayLink = hasLiveReplay
? `#/watch/replay?url=/replays/${featuredReplay!.id}.json`
? `#/watch/replay?url=/replays/${featuredReplay!.id}.json.gz`
: '#/watch/replays';
// Build lazy-loaded content for below-the-fold sections

View file

@ -239,19 +239,20 @@ function renderMobileCard(entry: LeaderboardEntry): string {
}
function initMobileCardToggles(container: HTMLElement): void {
container.querySelectorAll<HTMLElement>('.leaderboard-mobile-card').forEach(card => {
const toggle = card.querySelector<HTMLButtonElement>('.mobile-card-toggle');
// Use event delegation so toggles work even when cards are inside lazy sections
container.addEventListener('click', (e) => {
const toggle = (e.target as HTMLElement).closest<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));
});
if ((e.target as HTMLElement).closest('a')) return;
const card = toggle.closest<HTMLElement>('.leaderboard-mobile-card');
if (!card) 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));
});
}
@ -278,8 +279,6 @@ function addMobileShowMore(
container.appendChild(temp.firstChild);
}
initMobileCardToggles(container);
const newCount = end;
if (newCount >= allEntries.length) {
btn.remove();

View file

@ -61,6 +61,7 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
<canvas id="replay-canvas" style="touch-action:none"></canvas>
<div id="no-replay" class="no-replay-message">Load a replay file to view</div>
<button id="theater-btn" class="theater-btn" aria-label="Toggle theater mode" title="Theater mode (F)" style="position:absolute;top:8px;right:8px">&#x26F6;</button>
<div id="follow-indicator" class="follow-indicator" style="display:none;position:absolute;top:8px;left:8px;background:rgba(0,0,0,0.75);color:#e5e7eb;font:12px monospace;padding:4px 10px;border-radius:4px;pointer-events:auto;z-index:10;cursor:pointer" role="status" aria-live="polite"></div>
</div>
<!-- Mobile compact controls bar — CSS hides on tablet+ -->
@ -1230,6 +1231,30 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
playBtn.textContent = playing ? 'Pause' : 'Play';
mobilePlayBtn.textContent = playing ? '⏸' : '▶';
};
// Follow camera indicator (§16.12)
const followIndicator = document.getElementById('follow-indicator') as HTMLDivElement;
viewer.onFollowChange = (player: number | null) => {
const replay = viewer.getReplay() as Replay | null;
if (player !== null && replay && player < replay.players.length) {
const name = replay.players[player].name;
followIndicator.innerHTML = `Following: ${name} <span style="color:#94a3b8;margin-left:6px;cursor:pointer" title="Exit follow mode (Esc)">[×]</span>`;
followIndicator.style.display = 'block';
// Auto-pair fog of war with followed player
fogSelect.value = String(player);
viewer.setFogOfWar(player);
} else {
followIndicator.style.display = 'none';
followIndicator.innerHTML = '';
// Clear auto-paired fog of war
fogSelect.value = '';
viewer.setFogOfWar(null);
}
};
followIndicator.addEventListener('click', () => {
// Click the × or the whole bar to exit follow
viewer.setFollowPlayer(null);
});
viewer.onCommentaryChange = (entry: { turn: number; text: string; type: string } | null) => {
if (!entry || !commentaryEnabled) {
commentaryText.textContent = '';

View file

@ -940,6 +940,10 @@ code {
.mobile-card-arrow {
transition: none;
}
.leaderboard-mobile-details {
transition: none;
}
}
/* ─── Mobile responsive for progressive disclosure ──────────────────────────── */

View file

@ -141,14 +141,18 @@
}
.leaderboard-mobile-details {
display: none;
padding-top: var(--space-sm);
margin-top: var(--space-sm);
border-top: 1px solid var(--border);
max-height: 0;
overflow: hidden;
transition: max-height 250ms ease-out, padding 250ms ease-out;
padding: 0;
border-top: none;
}
.leaderboard-mobile-details.expanded {
display: block;
max-height: 300px;
padding-top: var(--space-sm);
margin-top: var(--space-sm);
border-top: 1px solid var(--border);
}
.leaderboard-mobile-stat {