PageSourceSearch

https://topdowntabletop.com/app/leaderboard.js

js topdowntabletop.com collected 2026-10-08 09:16:13 UTC 135,506 bytes, 3,340 lines download raw bytes

1/* /app leaderboard. All data goes through window.TDTT.lb — this file does no
2 * fetching of its own and contains no pure logic worth testing; anything that
3 * qualifies belongs in packages/shared/src/leaderboard/normalize.ts instead.
4 *
5 * Markup shapes are copied from apps/server/public/shared/_preview.html (the
6 * committed design reference for .tdtt-banner / .tdtt-statrow / .tdtt-stat /
7 * .tdtt-table / .tdtt-table__name / .tdtt-table__num / .tdtt-table__medal /
8 * .tdtt-pager / .tdtt-avatar), not invented here. leaderboard.css holds only
9 * the layout this view needs that those shared primitives do not already
10 * provide.
11 *
12 * The three-column podium stays retired (2026-09-10). The player list's Top 5
13 * Heroes column was retired with it and RESTORED on 2026-09-22 by owner
14 * ruling, scoped to Dice Throne only — see showsTopCharacters(). It renders
15 * through window.TDTT.profileRender.heroStripHtml, the same primitive the
16 * drill-down panel uses; do not transliterate a second copy here. The public
17 * landing page and /m keep the column retired.
18 *
19 * The banner's scope filter (Ranked · Online / Online Tournaments /
20 * In-Person) is .chip, and the mode tabs (Players/Heroes/Drafts/Bans) are
21 * .seg-slab — a deliberate, non-unifiable distinction: a chip is a filter
22 * (which list you're looking at), a slab segment is a mode switch (within
23 * that list).
24 *
25 * Task 9 (2026-09-22) adds `?view=`/`?hero=` deep-link routing and the Heroes
26 * landing card grid. The hero DETAIL page and the Drafts/Bans views are
27 * separate modules this file only DELEGATES to —
28 * window.TDTT.lbHeroPage (Task 10, lb-hero-page.js) and
29 * window.TDTT.lbDraftsBans (Task 11, lb-drafts-bans.js). This file owns every
30 * fetch (heroes/matchups/heroGames/roster/draftStats/bans) and builds the
31 * `ctx` object each module renders from — see buildHeroPageCtx() and
32 * buildDraftsBansCtx() for the exact contracts. Neither module exists until
33 * its own task ships, so every delegation point guards for a missing module
34 * and falls back to loadingHtml() rather than throwing. */
35(function () {
36  'use strict';
37
38  var S = {
39    view: 'players', // 'players' | 'heroes' | 'drafts' | 'bans'
40    game: (function () {
41      try {
42        return localStorage.getItem('tdtt_lb_game') || 'DICE_THRONE';
43      } catch (e) {
44        return 'DICE_THRONE';
45      }
46    })(),
47    scope: 'ranked', // 'ranked' | 'tournament' | 'throne_room'
48    region: null,
49    sort: 'rating', // 'rating' | 'wins' | 'games' | 'winrate'
50    // Shared with desktop via lbCore so the two surfaces cannot report
51    // different page counts or a differently-scoped win-rate tile.
52    // leaderboard-core.js is loaded before this file in index.html.
53    limit: window.TDTT.lbCore.LEADERBOARD_PAGE_SIZE,
54    offset: 0,
55    rows: [],
56    total: null, // null = the endpoint cannot report one
57    totals: null, // { rankedGames } | null — the population aggregate for the stat tiles
58    loading: false,
59    error: null,
60    openPlayerId: null, // Task 9
61    profile: null, // the loaded /players/:id/profile payload for openPlayerId, or null
62    profileLoading: false,
63    profileErrorKind: null, // null | 'signin' (401) | 'notfound' (404) | 'generic'
64    profileErrorMessage: null, // detail text for the 'generic' kind
65    profileOpenGame: null, // which By-Game accordion row is expanded
66    myPlaceStatus: null, // null | 'loading' | a message to show (e.g. unranked/error)
67
68    // "Your Rank" card (main column, per E4.1 — see rankInfoRowHtml()).
69    // Independent of S.sort/S.scope — see loadMyRank()'s comment for why.
70    // Shape: null (not asked yet) or
71    // { status: 'signin' | 'loading' | 'unranked' | 'error' | 'ready', rank, total }.
72    myRank: null,
73
74    // ── Heroes landing (Task 9) ─────────────────────────────────────────────
75    heroSort: 'winRate', // one of the seven HeroSortKey values — see HERO_SORTS
76    heroQuery: '', // live text filter, matched via lb.matchesHeroQuery
77    heroes: [], // the all-time (unwindowed) HeroStatRow[] for the CURRENT S.game
78    heroTotalGames: 0,
79    // The game S.heroes was fetched for, or null when nothing is loaded. Doubles
80    // as the staleness check on a game change — a boolean `heroesLoaded` could
81    // not tell DICE_THRONE's heroes from another game's.
82    heroesGame: null,
83    heroesLoading: false,
84    heroesError: null,
85
86    // Top Hero Trends aside — an INDEPENDENT fetch from S.heroes above, its own
87    // .seg-slab window (7D/30D/90D/ALL, default 30D), matching desktop's
88    // HeroTrendsPanel.tsx. 'all' shares leaderboard-data.js's cache key with
89    // the bare heroes(game) call above, so switching to ALL never doubles the
90    // fetch. `data` is the raw { totalGames, characters } payload or null.
91    heroTrend: { window: '30d', loading: true, data: null },
92    // The game S.heroTrend was last fetched for — same staleness-check role
93    // as heroesGame above, so re-entering the Heroes tab for the SAME game
94    // doesn't spend a request the 60s cache would answer for free anyway, but
95    // switching games always refetches.
96    heroTrendGame: null,
97
98    // ── Hero detail page (Task 10, window.TDTT.lbHeroPage) ──────────────────
99    // Non-null only while a `?hero=<slug>` deep link is open — see
100    // buildHeroPageCtx()'s own comment for the exact contract this feeds
101    // Task 10. Opening/closing it never changes S.view away from 'heroes'.
102    heroSlug: null,
103    heroPage: null,
104
105    // ── Drafts / Bans (Task 11, window.TDTT.lbDraftsBans) ───────────────────
106    // See buildDraftsBansCtx()'s own comment for the exact contract. ONE
107    // shape serves both draftsHtml(ctx) and bansHtml(ctx) since only one is
108    // ever on screen at a time.
109    draftsBans: {
110      draft: null, // draftStats() payload { totalDrafts, characters } | null
111      draftState: 'loading', // 'loading' | 'ready' | 'error'
112      heroes: null, // HeroStatRow[] | null — fetched for Drafts only
113      // 'loading' | 'ready' | 'error' — lets Task 11 tell "still fetching the
114      // heroes list to join against draftRows()" apart from "fetched, and
115      // this hero genuinely has no games" (draftRows() already renders the
116      // latter as winRate:null, i.e. the SAME dash — this just lets the
117      // still-loading case reach that dash honestly instead of by accident).
118      heroesState: 'loading',
119      bans: null, // bans(game) payload { heroes, cards } | null — Bans only
120      bansState: 'loading',
121      rosterSize: null, // number | null — null until roster() resolves, renders '—'
122      sort: 'presence', // active DraftSortKey (Drafts only)
123      query: '', // active search string (Drafts only)
124    },
125
126    // The loaded player rows belong to a game/view that is no longer on
127    // screen — set whenever a non-Players view is active, since the player
128    // list is not on screen then and must not be refetched until it is.
129    playersDirty: false,
130
131    // ── TCG Players tab (Task 8, non-Dice-Throne games only — see
132    // showsTcgFeatures()) — format select, Favorite Deck column, Active Deck
133    // Archetypes tile, insights rail. Mirrors desktop's Leaderboard.tsx
134    // `format` state exactly: a format id (never null), '' meaning "All
135    // formats", so it goes straight onto players()'s params.
136    format: '',
137
138    // The rail (Insights / Top Archetypes / Recent Tournaments / Meta Notes)
139    // and the Active Deck Archetypes stat tile — ALWAYS the all-time,
140    // all-format picture (window 'all'), independent of S.format/S.offset,
141    // per the brief. `game` records which game this was last fetched for so
142    // a return to Players for the SAME game doesn't spend three requests the
143    // 60s response cache would answer for free anyway — see
144    // loadTcgRailIfStale().
145    tcgRail: {
146      game: null,
147      decksState: 'loading', // 'loading' | 'ready' | 'error'
148      decks: [], // DeckArchetypeRow[]
149      tournamentsState: 'loading',
150      tournaments: [], // RecentTournament[]
151      formats: undefined, // FormatStatRow[] | undefined — undefined on failure (optional rail prop)
152    },
153
154    // ── Formats tab (Task 8, non-Dice-Throne games only) ────────────────────
155    // Owns ONE window-scoped fetch (default 'all') that feeds both the card
156    // grid and the Formats rail — batch ruling, mirrors desktop's
157    // FormatsView owning its own window state above both the grid and the
158    // rail's Format Trends chips.
159    formatsTab: {
160      game: null,
161      window: 'all',
162      state: 'loading', // 'loading' | 'ready' | 'error'
163      data: [], // FormatStatsRow[]
164    },
165
166    // ── Decks tab (Task 9, non-Dice-Throne games only) ──────────────────────
167    // The landing grid and the archetype detail page share ONE window state
168    // (batch ruling, mirrors desktop's DecksView: "the archetype page's
169    // window follows the Decks view's window chips" — not an independent
170    // copy that merely starts in sync). `format`/`sort`/`query` are grid-only
171    // and purely client-side (window.TDTT.lbDecks.sortDecks/matchesDeckQuery
172    // over the already-fetched list) — no refetch on a sort/search change.
173    // `token` is the request-token guard acceptResponse() reads: bumped by
174    // every loadDecksGrid() call, so a late response for a game/window/format
175    // that's since changed is ignored (Review Focus 5).
176    decksTab: {
177      window: '30d',
178      format: '',
179      sort: 'share',
180      query: '',
181      game: null, // the (game,window,format) the grid was last fetched for —
182      fetchedWindow: null, // compared against S.game/window/format by
183      fetchedFormat: null, // loadDecksGridIfStale() to decide whether a
184      // return to the Decks tab (or closing the archetype page) needs a
185      // fresh fetch or can reuse what's already loaded.
186      gridState: 'loading', // 'loading' | 'ready' | 'error'
187      decks: [], // DeckArchetypeRow[]
188      totals: null, // { trackedGames, decklessGames } | null
189      token: 0,
190    },
191
192    // Non-null only while a `?deck=<key>` deep link is open — deep-linked the
193    // same way S.heroSlug is. Opening/closing it never changes S.view away
194    // from 'decks'.
195    deckKey: null,
196    deckPage: {
197      detailState: 'loading', // 'loading' | 'ready' | 'error' | 'notFound'
198      detail: null, // { archetype, pilots, formats, trend } | null
199      matchupsState: 'loading',
200      matchups: null,
201      gamesState: 'loading',
202      games: null,
203      token: 0, // shared by all three fetches — they all key off (game,key,window)
204    },
205  };
206
207  var root;
208  var regionGroups = null; // filled lazily from lb.regions()
209  var searchTimer = null;
210  var searchSeq = 0;
211  var escWired = false; // guards the document-level Escape listener below
212  // Bumped on every openPlayerProfile()/closePlayerProfile() call so a slow
213  // profile() response for a player the user has since navigated away from
214  // (opened someone else, or closed the panel) is dropped instead of painting
215  // over whatever is now on screen.
216  var profileSeq = 0;
217  // The same stale-response guard for the hero detail page (Task 10):
218  // switching hero A -> hero B, or leaving the page entirely, must not let a
219  // slower response for A paint over B (or over the grid). Bumped on every
220  // openHeroPage()/closeHeroPage() and on unmount.
221  var heroPageSeq = 0;
222  // A SECOND, narrower guard for just the window-scoped fetches inside the
223  // hero page (the windowed heroes-list lookup + matchups) — bumped on every
224  // onHeroWindow() call too, so switching windows without switching heroes
225  // doesn't need to invalidate the (window-independent) recent-games fetch.
226  var heroPageWindowSeq = 0;
227  // Stale-response guard for the Top Hero Trends aside's own window-scoped
228  // fetch — independent of heroPageWindowSeq, since the trends aside and an
229  // open hero page are unrelated fetches that can be in flight together.
230  var heroTrendSeq = 0;
231  // Stale-response guard for the Drafts/Bans fetches — bumped on every
232  // loadDraftsData()/loadBansData() call (including a game switch) so an
233  // older in-flight request can't paint over a newer one.
234  var draftsBansSeq = 0;
235  // The same stale-response guard for the primary list fetch (load()) and
236  // showMyPlace()'s own fetch chain — both mutate S.offset/S.rows/S.total, so
237  // a remount (mount() calls load(true) again) must not let an older request
238  // from before the unmount overwrite the fresh one that replaced it.
239  var loadSeq = 0;
240  // The same stale-response guard for loadMyRank() — a slower request from a
241  // game/region that has since been changed away from must not overwrite the
242  // rank card with an answer for the wrong context.
243  var myRankSeq = 0;
244  // Stale-response guard for the TCG rail's three fetches (Task 8) — bumped
245  // on every loadTcgRail() call (including a game switch) so an older
246  // in-flight request can't paint over a newer one.
247  var tcgRailSeq = 0;
248  // Stale-response guard for the Formats tab fetch (Task 8) — bumped on
249  // every loadFormatsTab() call.
250  var formatsSeq = 0;
251
252  // The game rail's own resize listener — destroyed before every re-wire (a
253  // full render() rebuild discards whatever it was wired to) and again in
254  // unmount(), so leaving Leaderboard never leaks a window listener pointed
255  // at detached nodes.
256  var railWire = null;
257
258  // Must match packages/shared/src/types/game.ts GameType exactly — these
259  // strings go straight into the API path. Labels mirror
260  // apps/server/public/landing/leaderboard.js's GAMES table.
261  //
262  // Duplicated in /shared/profile-render.js on purpose — see that file's
263  // top-of-file comment. Keep in sync by hand if either changes.
264  var GAMES = [
265    { id: 'DICE_THRONE', label: 'Dice Throne' },
266    { id: 'MTG', label: 'Magic: The Gathering' },
267    { id: 'POKEMON_TCG', label: 'Pokémon TCG' },
268    { id: 'LORCANA', label: 'Disney Lorcana' },
269    { id: 'YU_GI_OH', label: 'Yu-Gi-Oh!' },
270    { id: 'ONE_PIECE_TCG', label: 'One Piece' },
271    { id: 'STAR_WARS_UNLIMITED', label: 'SW: Unlimited' },
272    { id: 'RIFTBOUND', label: 'Riftbound' },
273  ];
274
275  // GAMES above carries only {id,label} — every other menu on this page
276  // (region/sort chips) shares that shape. The game rail additionally needs
277  // each game's logo and brand colour, resolved from window.TDTT.game
278  // (game-constants.js) at RENDER time rather than baked into GAMES itself,
279  // so a page that somehow renders before game-constants.js has finished
280  // loading degrades to a blank logo instead of throwing. Same `/m/logos/` +
281  // logoFile path codex.js's own GAMES table uses. /app has no
282  // installed-pack concept, so no tile is ever marked `dim` here (unlike
283  // desktop's game tab strip).
284  function gamesForRail() {
285    var table = window.TDTT && window.TDTT.game && window.TDTT.game.TABLE;
286    return GAMES.map(function (g) {
287      var meta = table && table[g.id];
288      return {
289        id: g.id,
290        label: g.label,
291        logo: meta ? '/m/logos/' + meta.logoFile : '',
292        tile: meta ? meta.brandVar : '',
293      };
294    });
295  }
296
297  // Labels match apps/desktop/src/routes/Leaderboard.tsx's PlayerScopeSelector
298  // verbatim (same scope ids, same wording) — this row is now rendered on the
299  // banner as .chip filters rather than a .seg-slab, per the brief's chip-vs-
300  // slab rule, but the copy itself should read identically on both surfaces.
301  var SCOPES = [
302    { id: 'ranked', label: 'Ranked · Online' },
303    { id: 'tournament', label: 'Online Tournaments' },
304    { id: 'throne_room', label: 'In-Person' },
305  ];
306
307  var SORTS = [
308    { id: 'rating', label: 'Rating' },
309    { id: 'wins', label: 'Wins' },
310    { id: 'games', label: 'Games' },
311    { id: 'winrate', label: 'Win Rate' },
312  ];
313
314  // Keys and labels are copied verbatim from the desktop app's SORT_OPTIONS
315  // (apps/desktop/src/components/leaderboard/HeroesView.tsx) so the same
316  // seven-key hero sort reads identically and orders identically (both go
317  // through the same lb.sortHeroes -> packages/shared HeroSortKey) on both
318  // clients.
319  var HERO_SORTS = [
320    { id: 'winRate', label: 'Win Rate' },
321    { id: 'pickRate', label: 'Pick Rate' },
322    { id: 'mostPlayed', label: 'Most Played' },
323    { id: 'avgDamage', label: 'Avg Damage' },
324    { id: 'mostWins', label: 'Most Wins' },
325    { id: 'mostUltimates', label: 'Most Ultimates' },
326    { id: 'name', label: 'Name' },
327  ];
328
329  // Leaderboard Insights rows — copied verbatim from desktop's INSIGHT_ROWS
330  // (HeroesView.tsx). `metric` renders the same secondary line desktop shows
331  // next to the hero name.
332  var HERO_INSIGHT_ROWS = [
333    { key: 'highestWinRate', label: 'Highest Win Rate', metric: function (h) { return h.winRate + '%'; } },
334    { key: 'mostPlayed', label: 'Most Played', metric: function (h) { return fmtNum(h.picks) + ' games'; } },
335    { key: 'mostWins', label: 'Most Wins', metric: function (h) { return fmtNum(h.wins) + ' wins'; } },
336    {
337      key: 'mostUltimates',
338      label: 'Most Ultimates',
339      metric: function (h) { return fmtNum(h.ultimatesLanded || 0) + ' landed'; },
340    },
341  ];
342
343  // Labels for the Top Hero Trends aside's .seg-slab, matching desktop's
344  // HeroTrendsPanel.tsx WINDOW_LABELS.
345  var TREND_WINDOW_LABELS = { '7d': '7D', '30d': '30D', '90d': '90D', all: 'ALL' };
346
347  // Per-scope empty-state copy, verbatim from the desktop app
348  // (apps/desktop/src/routes/Leaderboard.tsx) — three scopes are empty for
349  // different reasons and the copy is the only thing that explains which.
350  var SCOPE_EMPTY = {
351    ranked: { title: 'No matches played yet', sub: null },
352    tournament: {
353      title: 'No online tournament results yet',
354      sub: 'Ranks players by wins across networked (webcam) tournament matches',
355    },
356    throne_room: {
357      title: 'No in-person results yet',
358      sub: 'Tracks Throne Room tournament and confirmed Play Now results for linked accounts',
359    },
360  };
361
362  // currentColor SVG icons for the .tdtt-statrow tiles — never emoji, since
363  // every icon slot in that shared component tints via CSS `color` and an
364  // emoji would ignore it (see _preview.html's stat-row demo comment).
365  var STAT_ICON = {
366    rating:
367      '<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" ' +
368      'stroke-linecap="round" stroke-linejoin="round"><path d="M5 2h6v3.2a3 3 0 0 1-6 0V2Z"/>' +
369      '<path d="M5 3H3.2A1.8 1.8 0 0 0 5 5.4"/><path d="M11 3h1.8A1.8 1.8 0 0 1 11 5.4"/>' +
370      '<line x1="8" y1="8.2" x2="8" y2="11"/><path d="M5.5 14h5"/><path d="M6.3 11h3.4l.6 3H5.7z"/></svg>',
371    games:
372      '<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" ' +
373      'stroke-linecap="round" stroke-linejoin="round"><rect x="2.4" y="2.4" width="11.2" height="11.2" rx="2.2"/>' +
374      '<circle cx="5.6" cy="5.6" r=".9" fill="currentColor" stroke="none"/>' +
375      '<circle cx="10.4" cy="5.6" r=".9" fill="currentColor" stroke="none"/>' +
376      '<circle cx="5.6" cy="10.4" r=".9" fill="currentColor" stroke="none"/>' +
377      '<circle cx="10.4" cy="10.4" r=".9" fill="currentColor" stroke="none"/>' +
378      '<circle cx="8" cy="8" r=".9" fill="currentColor" stroke="none"/></svg>',
379    winrate:
380      '<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" ' +
381      'stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5 6.2 12 13 3.6"/></svg>',
382  };
383
384  function setState(patch) {
385    for (var k in patch) {
386      if (Object.prototype.hasOwnProperty.call(patch, k)) S[k] = patch[k];
387    }
388    render();
389  }
390
391  function esc(s) {
392    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
393      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
394    });
395  }
396
397  // T15: a player's avatarUrl is player-supplied and may legitimately be a
398  // same-origin upload path this server's own upload route minted (e.g.
399  // `/uploads/avatars/<name>`), not only an absolute http(s) URL --
400  // mirroring the server's isSafeAvatarUrl() allow-list
401  // (apps/server/src/http/url.ts). A pre-existing row may still hold a
402  // `javascript:`/`data:`/protocol-relative value, so any src built from it
403  // must pass through this allow-list first.
404  function safeImageUrl(u) {
405    if (typeof u !== 'string') return '';
406    var t = u.trim();
407    if (/^https?:\/\//i.test(t)) return t;
408    return /^\/uploads\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp|gif)$/i.test(t) ? t : '';
409  }
410
411  // Never render `undefined`/`null` for a numeric field — an em dash instead.
412  function numOrDash(v) {
413    return v === null || v === undefined ? '—' : v;
414  }
415
416  // Request-token guard for a late fetch response (Task 9, Review Focus 5) —
417  // `state` is a per-fetch state object carrying its own `token` counter
418  // (S.decksTab for the grid fetch, S.deckPage for the archetype page's three
419  // fetches). The caller captures `var mine = ++state.token;` before issuing
420  // the request and passes that same value back in here once it resolves.
421  // Pure — no globals, no closure reads beyond the two arguments — so it can
422  // be unit tested directly with plain object literals, no DOM or network.
423  function acceptResponse(state, token) {
424    return !!state && state.token === token;
425  }
426
427  // The key both existing clients (mobile auth-bridge.js, tournament flows)
428  // already use to identify the signed-in player. Wrapped in try/catch like
429  // every other localStorage read in this file (private mode / storage
430  // disabled must degrade to "signed out", not throw).
431  function getPlayerId() {
432    try {
433      return localStorage.getItem('dta_playerId') || null;
434    } catch (e) {
435      return null;
436    }
437  }
438
439  function initialOf(name) {
440    var n = String(name || '').trim();
441    return n ? n.charAt(0).toUpperCase() : '?';
442  }
443
444  // Renders an <img> plus a HIDDEN fallback-letter sibling and wires the
445  // error handler in JS (wireAvatarFallback) rather than an inline `onerror`
446  // attribute — an inline attribute would need the initial letter embedded as
447  // a JS string INSIDE an HTML attribute, a mixed-context escape that HTML
448  // decodes before the JS engine sees it. esc() escapes for HTML, not JS, so
449  // a display name starting with `'` would decode back to a bare quote and
450  // terminate the string early, breaking the handler (apps/server/public/
451  // landing/leaderboard.js:74-84 solved this the same way; not exploitable
452  // here since initialOf() takes one character, but still a real bug — the
453  // fallback letter would silently never render for that name).
454  function avatarHtml(row, extraClass) {
455    var cls = 'tdtt-avatar' + (extraClass ? ' ' + extraClass : '');
456    var initial = esc(initialOf(row.displayName));
457    var avatarUrl = safeImageUrl(row.avatarUrl);
458    if (avatarUrl) {
459      return (
460        '<div class="' + cls + '">' +
461        '<img class="lb-avatar-img" src="' + esc(avatarUrl) + '" alt="">' +
462        '<span class="lb-avatar-init" hidden>' + initial + '</span>' +
463        '</div>'
464      );
465    }
466    return '<div class="' + cls + '">' + initial + '</div>';
467  }
468
469  // Wires the error->fallback swap for every avatar <img> under `scope`. Must
470  // be called after any innerHTML write that can contain avatarHtml() output
471  // (the full render() and the search-results dropdown both do).
472  function wireAvatarFallback(scope) {
473    var imgs = scope.querySelectorAll('.lb-avatar-img');
474    for (var i = 0; i < imgs.length; i++) {
475      wireOneAvatarImg(imgs[i]);
476    }
477  }
478
479  function wireOneAvatarImg(img) {
480    img.addEventListener('error', function () {
481      img.hidden = true;
482      var init = img.nextElementSibling;
483      if (init) init.hidden = false;
484    });
485  }
486
487  // Every character portrait on either client needs the .jpg -> .png fallback:
488  // 9 of the 44 heroes ship PNG-only. Inline `onerror` rather than a wired
489  // listener because — unlike avatarHtml — nothing user-supplied is embedded in
490  // the attribute, so there is no mixed-context escaping hazard here.
491  function heroPortraitHtml(slug, cls) {
492    var src = '/assets/extracted/characters/' + esc(slug) + '/portrait.jpg';
493    return (
494      '<img' + (cls ? ' class="' + cls + '"' : '') + ' src="' + src + '" alt="" ' +
495      'data-fb-retry="jpg2png" data-fb-visibility-hidden>'
496    );
497  }
498
499  // Thousands separators for the aggregate combat counters, which run to seven
500  // figures. Never renders `NaN` for a missing field.
501  function fmtNum(n) {
502    var v = Number(n);
503    return isFinite(v) ? v.toLocaleString() : '—';
504  }
505
506  // ── Banner (Task 4) ─────────────────────────────────────────────────────
507  // The scope filter now lives on the banner itself, per the brief, as .chip
508  // buttons rather than the .seg-slab it used to render as — a chip is a
509  // filter (which list you're looking at), a slab segment is a mode switch
510  // (Players/Heroes/Drafts/Bans within that list). Rendered on every view,
511  // not gated to 'players', since the banner is page furniture; clicking a
512  // scope chip while on Heroes/Drafts/Bans still updates S.scope for when the
513  // user returns to Players (setFilter's existing load(true) call is a wasted
514  // fetch in that case, not a broken one — see the task report).
515  function bannerHtml() {
516    var scopeChips = SCOPES.map(function (s) {
517      return (
518        '<button type="button" class="chip' + (s.id === S.scope ? ' on' : '') + '" data-scope="' +
519        esc(s.id) + '">' + esc(s.label) + '</button>'
520      );
521    }).join('');
522    return (
523      '<div class="tdtt-banner" data-fx="bold" data-variant="colosseum" style="--ac: var(--p-colosseum)">' +
524      '<canvas class="tdtt-banner__fx"></canvas>' +
525      '<span class="tdtt-banner__eyebrow">THE COLOSSEUM</span>' +
526      '<h2 class="tdtt-banner__title">LEADER<span class="tdtt-banner__accent">BOARDS</span></h2>' +
527      '<p class="tdtt-banner__sub">See where you stand across every game, format and hero.</p>' +
528      '<div class="tdtt-banner__actions" id="lb-scopes"><div class="chip-row">' + scopeChips + '</div></div>' +
529      '</div>'
530    );
531  }
532
533  // ── Stat tile row (Task 10, 2026-09-22) ──────────────────────────────────
534  // Four tiles, matching the desktop implementation's labels exactly.
535  //
536  // Three are population figures from the server (S.total, S.totals.rankedGames,
537  // and the supported-game count). The fourth — average win rate — is
538  // DELIBERATELY scoped to the players currently listed, and its label says
539  // so. Do not "fix" it into a population aggregate: every completed 1v1 game
540  // writes exactly one WIN and one LOSS, so the population average is exactly
541  // 50% (and lower for pod formats) — a constant that tracks format mix
542  // rather than anything a reader would take it to mean.
543  //
544  // A null population figure renders an em dash, never 0 — unknown and zero
545  // are different claims.
546  function statTileHtml(icon, value, label, accent) {
547    return (
548      '<div class="tdtt-stat" style="--sec-accent:' + accent + '">' +
549      '<span class="i">' + icon + '</span>' +
550      '<span class="v">' + esc(value) + '</span>' +
551      '<span class="l">' + esc(label) + '</span>' +
552      '<span class="u"></span>' +
553      '</div>'
554    );
555  }
556
557  function summaryStatsHtml() {
558    if (S.view !== 'players' || S.scope !== 'ranked') return '';
559    var dash = '—';
560    var g = window.TDTT.lbCore.groupThousands;
561
562    var n = S.rows.length;
563    var sumWinRate = 0;
564    for (var i = 0; i < n; i++) sumWinRate += Number(S.rows[i].winRate) || 0;
565    var avgWinRate = n > 0 ? Math.round((sumWinRate / n) * 10) / 10 : null;
566
567    // Task 8: the TCG variant swaps the static "Supported Games" tile for the
568    // dynamic "Active Deck Archetypes" count (a population figure specific to
569    // deck-tracked games — mirrors desktop's tcgLeaderboardTiles()) AND moves
570    // it to the LAST slot, after Avg Win Rate — the two tile orders are not
571    // the same four tiles reordered by one swap, they differ in position too.
572    // `null` (S.tcgRail.decksState !== 'ready') renders an em dash, never 0.
573    if (showsTcgFeatures()) {
574      var archetypes = S.tcgRail.decksState === 'ready' ? S.tcgRail.decks.length : null;
575      return (
576        '<div class="tdtt-statrow">' +
577        statTileHtml(STAT_ICON.rating, S.total === null ? dash : g(S.total), 'Active Players', 'var(--gold)') +
578        statTileHtml(STAT_ICON.games, S.totals === null ? dash : g(S.totals.rankedGames), 'Ranked Games', 'var(--magenta)') +
579        statTileHtml(STAT_ICON.winrate, avgWinRate === null ? dash : avgWinRate + '%', 'Avg Win Rate · Top ' + S.limit, 'var(--green)') +
580        statTileHtml(STAT_ICON.games, archetypes === null ? dash : String(archetypes), 'Active Deck Archetypes', 'var(--cyan)') +
581        '</div>'
582      );
583    }
584
585    var supportedGames = Object.keys(window.TDTT.game.TABLE).length;
586
587    return (
588      '<div class="tdtt-statrow">' +
589      statTileHtml(STAT_ICON.rating, S.total === null ? dash : g(S.total), 'Active Players', 'var(--gold)') +
590      statTileHtml(STAT_ICON.games, S.totals === null ? dash : g(S.totals.rankedGames), 'Ranked Games', 'var(--magenta)') +
591      statTileHtml(STAT_ICON.games, String(supportedGames), 'Supported Games', 'var(--cyan)') +
592      statTileHtml(STAT_ICON.winrate, avgWinRate === null ? dash : avgWinRate + '%', 'Avg Win Rate · Top ' + S.limit, 'var(--green)') +
593      '</div>'
594    );
595  }
596
597  // Dice Throne only, and this is CORRECTNESS not taste: the server derives
598  // topCharacters by joining `characters` on game_players.character_id, and
599  // TCG rows point at synthetic per-GameType "system" characters, so on any
600  // other game the column renders one meaningless tile per player. See
601  // docs/superpowers/specs/2026-09-22-leaderboard-and-profile-design.md 1.3.
602  function showsTopCharacters() {
603    return S.game === 'DICE_THRONE';
604  }
605
606  // ── Ranked table (the podium's replacement) ────────────────────────────────
607  // Columns per the mockup: #, Player, {RATING_LABEL}, W-L, Win Rate, Games.
608  // `.tdtt-table__num` goes on the <th> here too, not just the data <td>s —
609  // under table-layout:fixed the FIRST row sets column widths, so a class
610  // that only lands on data cells never takes effect.
611  function tableHeadHtml() {
612    return (
613      '<thead><tr>' +
614      '<th></th>' +
615      '<th>Player</th>' +
616      '<th class="tdtt-table__num">' + esc(window.TDTT.game.RATING_LABEL) + '</th>' +
617      '<th class="tdtt-table__num">W-L</th>' +
618      '<th class="tdtt-table__num">Win Rate</th>' +
619      '<th class="tdtt-table__num">Games</th>' +
620      (showsTcgFeatures() ? '<th>Favorite Deck</th>' : '') +
621      (showsTopCharacters() ? '<th>Top 5 Heroes</th>' : '') +
622      '</tr></thead>'
623    );
624  }
625
626  // ── Favorite Deck column (Task 8) ────────────────────────────────────────
627  // Identity chips reuse `.tdtt-idchip` (shared/controls.css) — the SAME
628  // primitive the Decks tab (profile-decks.js) already renders through, not
629  // a page-local copy. Colour comes ONLY from window.TDTT.decks.identityColor
630  // (a fixed per-game hex table), still escaped per the batch ruling; a
631  // value outside that table (or none) falls back to the `.is-neutral`
632  // variant instead of an inline colour.
633  function deckIdentityChipsHtml(gameType, identity) {
634    var html = '';
635    for (var i = 0; i < identity.length; i++) {
636      var v = identity[i];
637      var hex = window.TDTT.decks.identityColor(gameType, v);
638      var cls = 'tdtt-idchip' + (hex ? '' : ' is-neutral');
639      var style = hex ? ' style="--id-c:' + esc(hex) + '"' : '';
640      html += '<span class="' + cls + '"' + style + '>' + esc(window.TDTT.decks.identityLabel(gameType, v)) + '</span>';
641    }
642    return html;
643  }
644
645  // `—` when the player has no public favorite deck at all; identity chips
646  // plus the deck name when it has one and the name is public; chips only
647  // (no name) for a favorite deck whose bucket isn't public yet (fewer than
648  // DECK_LIMITS.publicMinPlayers pilots) — `favoriteDeck.name` is null in
649  // that case even though `identity` still is (server contract, Task 2/3).
650  // A click opens the deck via the delegated `[data-deck-key]` handler in
651  // wireEvents() when `key` is set — mirrors desktop's FavoriteDeckCell.
652  function favoriteDeckCellHtml(gameType, favoriteDeck) {
653    if (!favoriteDeck) return '—';
654    var inner =
655      '<span class="lb-fav-deck">' + deckIdentityChipsHtml(gameType, favoriteDeck.identity) +
656      (favoriteDeck.name ? '<span class="lb-fav-deck-name">' + esc(favoriteDeck.name) + '</span>' : '') +
657      '</span>';
658    if (!favoriteDeck.key) return inner;
659    return (
660      '<button type="button" class="lb-fav-deck-btn" data-deck-key="' + esc(favoriteDeck.key) + '">' +
661      inner + '</button>'
662    );
663  }
664
665  // Unicode medal glyphs for the top three ranks, matching the shared design
666  // reference in _preview.html. Every other rank shows its plain number.
667  var MEDAL_GLYPH = { 1: '&#129351;', 2: '&#129352;', 3: '&#129353;' };
668
669  // `.tdtt-table__name` goes on a wrapper INSIDE the <td>, never on the <td>
670  // itself — it sets display:block, which would remove the cell from table
671  // layout. The reduced (anonymous) projection has no wins/losses/gamesPlayed
672  // at all, per isReducedRow() — those three cells render an em dash rather
673  // than "undefined-undefined".
674  function tableRowHtml(p) {
675    var rank = p.rank;
676    var medal = MEDAL_GLYPH[rank];
677    var reduced = p.wins === null || p.wins === undefined;
678    var record = reduced ? '—' : numOrDash(p.wins) + '–' + numOrDash(p.losses);
679    var wrClass = '';
680    var wrText = '—';
681    if (!reduced && p.winRate !== null && p.winRate !== undefined) {
682      wrClass = window.TDTT.bands.winRateBand(p.wins, p.losses);
683      wrText = p.winRate + '%';
684    }
685    var games = reduced ? '—' : numOrDash(p.gamesPlayed);
686    return (
687      '<tr class="tdtt-table__row' + (medal ? ' tdtt-table__medal' : '') + '"' +
688      ' data-pid="' + esc(p.playerId) + '" data-rank="' + numOrDash(rank) + '"' +
689      '>' +
690      '<td>' + (medal || numOrDash(rank)) + '</td>' +
691      '<td><div class="tdtt-table__player">' + avatarHtml(p, '') +
692      '<span class="tdtt-table__name">' + esc(p.displayName) + '</span></div></td>' +
693      '<td class="tdtt-table__num">' + numOrDash(p.skillRating) + '</td>' +
694      '<td class="tdtt-table__num">' + esc(record) + '</td>' +
695      '<td class="tdtt-table__num' + (wrClass ? ' rec ' + wrClass : '') + '">' + esc(wrText) + '</td>' +
696      '<td class="tdtt-table__num">' + games + '</td>' +
697      (showsTcgFeatures()
698        ? '<td>' + favoriteDeckCellHtml(S.game, p.favoriteDeck || null) + '</td>'
699        : '') +
700      (showsTopCharacters()
701        ? '<td>' + window.TDTT.profileRender.heroStripHtml(p.topCharacters, true) + '</td>'
702        : '') +
703      '</tr>'
704    );
705  }
706
707  // total is null for the tournament and in-person scopes (unpaged endpoints)
708  // — treated as "unknown", never as 0. footerHtml() falls back to a plain
709  // "N loaded" readout with no pager; pagerHtml() below independently refuses
710  // to render for the same reason, since a pager built from null total would
711  // read "Page 1 of NaN".
712  function footerHtml() {
713    var readout = window.TDTT.lbCore.pagerSummary(S.offset, S.rows.length, S.total);
714    if (readout === null) readout = S.rows.length + ' loaded';
715    return '<div class="lb-footer"><span class="lb-readout">' + esc(readout) + '</span></div>' + pagerHtml();
716  }
717
718  // Renders `.tdtt-pager` ONLY when S.total is a number and there is more
719  // than one page — never from a null total (the tournament/in-person
720  // scopes), which would otherwise render "Page 1 of NaN". Windowing and the
721  // ellipsis ('gap') come from the shared TypeScript helper (leaderboard-core.js)
722  // so /app and desktop cannot disagree about which page buttons exist.
723  function pagerHtml() {
724    if (S.total === null) return '';
725    var totalPages = Math.max(1, Math.ceil(S.total / S.limit));
726    if (totalPages <= 1) return '';
727    var current = Math.floor(S.offset / S.limit) + 1;
728    var pages = window.TDTT.lbCore.pagerPages(current, totalPages);
729    var html = '<div class="tdtt-pager">';
730    html +=
731      '<button type="button" class="tdtt-pager__page" data-page="' + (current - 1) + '"' +
732      (current <= 1 ? ' disabled' : '') +
733      '>&laquo;</button>';
734    html += pages
735      .map(function (n) {
736        if (n === 'gap') return '<span class="tdtt-pager__gap" aria-hidden="true">…</span>';
737        return (
738          '<button type="button" class="tdtt-pager__page' + (n === current ? ' on' : '') + '" data-page="' +
739          n + '">' + n + '</button>'
740        );
741      })
742      .join('');
743    html +=
744      '<button type="button" class="tdtt-pager__page" data-page="' + (current + 1) + '"' +
745      (current >= totalPages ? ' disabled' : '') +
746      '>&raquo;</button>';
747    html += '</div>';
748    return html;
749  }
750
751  function goToPage(n) {
752    if (!n || n < 1) return;
753    S.offset = (n - 1) * S.limit;
754    load(false);
755  }
756
757  function loadingHtml() {
758    return '<div class="lb-status lb-loading">Loading…</div>';
759  }
760
761  // The selected format's `{id, name}` from window.TDTT.decks.deckFormatOptions
762  // (decks-core.js), or null when no format is selected or the scope isn't
763  // ranked (in-format ranking is a ranked-only mode — see setFilter()).
764  // Drives the empty-format message and the "Ranked by record in…" caption
765  // below, matching desktop's `format` prop on PlayersTable exactly.
766  function currentFormatMeta() {
767    if (S.scope !== 'ranked' || !S.format) return null;
768    var opts = window.TDTT.decks ? window.TDTT.decks.deckFormatOptions(S.game) : [];
769    for (var i = 0; i < opts.length; i++) {
770      if (opts[i].id === S.format) return opts[i];
771    }
772    return null;
773  }
774
775  function emptyHtml() {
776    var fmt = currentFormatMeta();
777    if (fmt) {
778      return (
779        '<div class="lb-status lb-empty"><p class="lb-empty-title">' +
780        esc('No player has 5 games in ' + fmt.name + ' yet') + '</p></div>'
781      );
782    }
783    var copy = SCOPE_EMPTY[S.scope] || SCOPE_EMPTY.ranked;
784    var sub = copy.sub ? '<p class="lb-empty-sub">' + esc(copy.sub) + '</p>' : '';
785    return (
786      '<div class="lb-status lb-empty"><p class="lb-empty-title">' + esc(copy.title) + '</p>' + sub + '</div>'
787    );
788  }
789
790  function errorHtml() {
791    return (
792      '<div class="lb-status lb-error"><p class="lb-error-title">Couldn’t load the leaderboard</p>' +
793      '<p class="lb-error-detail">' + esc(S.error) + '</p>' +
794      '<button type="button" class="lb-btn" id="lb-retry">Retry</button></div>'
795    );
796  }
797
798  function resultsHtml() {
799    // The three-column podium is retired (2026-09-10) — the top three ranks
800    // are now medal rows inside the one ranked table below, not a separate
801    // component. See .superpowers/sdd/2026-09-10-ui-unification-phase-2a-leaderboards.
802    var html = '';
803    var fmt = currentFormatMeta();
804    if (fmt && S.rows.length) {
805      html += '<p class="lb-format-caption">' + esc('Ranked by record in ' + fmt.name + ' (min 5 games)') + '</p>';
806    }
807    if (S.rows.length) {
808      html +=
809        '<div class="lb-list"><table class="tdtt-table">' +
810        tableHeadHtml() +
811        '<tbody>' + S.rows.map(tableRowHtml).join('') + '</tbody>' +
812        '</table></div>';
813    }
814    html += footerHtml();
815    return html;
816  }
817
818  function listHtml() {
819    if (S.error) return errorHtml();
820    if (S.loading && S.rows.length === 0) return loadingHtml();
821    if (!S.loading && S.rows.length === 0) return emptyHtml();
822    return resultsHtml();
823  }
824
825  function regionOptionsHtml() {
826    var opts = '<option value="">All Regions</option>';
827    if (!regionGroups) return opts;
828    for (var i = 0; i < regionGroups.length; i++) {
829      var g = regionGroups[i];
830      opts += '<optgroup label="' + esc(g.group) + '">';
831      for (var j = 0; j < g.regions.length; j++) {
832        var r = g.regions[j];
833        opts += '<option value="' + esc(r) + '"' + (r === S.region ? ' selected' : '') + '>
833' + esc(r) + '</option>';
834      }
835      opts += '</optgroup>';
836    }
837    return opts;
838  }
839
840  // "Show my place" — only meaningful against the `ranked` scope: the rank
841  // endpoint always computes a player's position from completed ranked games
842  // (see apps/server/src/routes/leaderboard.ts `/players/:playerId/rank`),
843  // which has no relationship to the tournament/throne_room lists' own
844  // locally-numbered rows. Absent entirely when signed out, per the brief —
845  // not present-and-failing.
846  function myPlaceHtml() {
847    if (S.scope !== 'ranked' || !getPlayerId()) return '';
848    var loading = S.myPlaceStatus === 'loading';
849    var msg =
850      !loading && S.myPlaceStatus
851        ? '<div class="lb-signin-banner">' + esc(S.myPlaceStatus) + '</div>'
852        : '';
853    return (
854      '<div class="lb-filterrow">' +
855      '<button type="button" class="chip" id="lb-myplace"' +
856      (loading ? ' disabled' : '') +
857      '>' +
858      (loading ? 'Finding your place…' : 'Show my place') +
859      '</button>' +
860      '</div>' +
861      msg
862    );
863  }
864
865  // Finds the DOM node for `rank` in the just-rendered table — every rank,
866  // including the medal top three, carries `data-rank` on its
867  // `.tdtt-table__row` — and highlights + scrolls to it. Must run AFTER the
868  // render that placed that rank on screen. Reads the attribute rather than
869  // cell text because the top three render a medal glyph, not the numeral,
870  // in that cell.
871  function scrollToRank(rank) {
872    if (!root) return;
873    var rows = root.querySelectorAll('.tdtt-table__row[data-rank]');
874    for (var i = 0; i < rows.length; i++) {
875      if (Number(rows[i].getAttribute('data-rank')) === rank) {
876        rows[i].classList.add('me');
877        if (rows[i].scrollIntoView) rows[i].scrollIntoView({ block: 'center' });
878        return;
879      }
880    }
881  }
882
883  // Step 1 of the brief: read the signed-in id, ask the rank endpoint with the
884  // CURRENT region/sort (a different ordering answers with the wrong row),
885  // then either show the unranked message and stop, or jump to the page that
886  // contains the rank, load ONLY that page (not accumulated via Load More),
887  // and highlight the row.
888  function showMyPlace() {
889    var pid = getPlayerId();
890    if (!pid) return;
891    setState({ myPlaceStatus: 'loading' });
892    // Shares loadSeq with load(): this fetch chain ends up mutating the same
893    // S.offset/S.rows/S.total, so a remount (or a plain load() started after
894    // this began) must invalidate it too, not just a second showMyPlace() call.
895    var mine = ++loadSeq;
896    window.TDTT.lb
897      .playerRank({ game: S.game, playerId: pid, region: S.region, sort: S.sort })
898      .then(function (r) {
899        if (mine !== loadSeq) return; // stale — superseded by a newer load/showMyPlace
900        var rank = r && r.rank;
901        if (rank === null || rank === undefined) {
902          setState({ myPlaceStatus: "You're unranked in this game" });
903          return;
904        }
905        var pageOffset = Math.floor((rank - 1) / S.limit) * S.limit;
906        S.offset = pageOffset;
907        S.rows = [];
908        setState({ loading: true, error: null, myPlaceStatus: null });
909        return window.TDTT.lb
910          .players({
911            game: S.game,
912            scope: S.scope,
913            region: S.region,
914            sort: S.sort,
915            limit: S.limit,
916            offset: pageOffset,
917            paged: true,
918          })
919          .then(function (res) {
920            if (mine !== loadSeq) return;
921            setState({ rows: res.rows, total: res.total, totals: res.totals, loading: false });
922            scrollToRank(rank);
923          })
924          .catch(function (e) {
925            if (mine !== loadSeq) return;
926            // The rank WAS found — this second fetch (the actual page) is what
927            // broke, after loading:true/rows:[] already went into state above.
928            // Land in the same recoverable state any other failed load uses
929            // (loading cleared, error set) so the existing error UI + Retry
930            // renders, instead of leaving listHtml() stuck on loadingHtml()
931            // forever with no way out but changing a filter. Caught HERE, not
932            // by the outer .catch below, so it can't be confused with a
933            // playerRank failure (which never touched loading/rows).
934            setState({ loading: false, error: (e && e.message) || 'Failed to load that page of the leaderboard' });
935          });
936      })
937      .catch(function (e) {
938        if (mine !== loadSeq) return;
939        // playerRank() itself failed — nothing in the list was touched, so
940        // this is reported through myPlaceStatus (the banner), not S.error.
941        var msg =
942          e && e.status === 401 ? 'Sign in to see your rank.' : (e && e.message) || 'Could not find your rank';
943        setState({ myPlaceStatus: msg });
944      });
945  }
946
947  // ── "Your Rank" (main column, per E4.1) ──────────────────────────────────
948  // Fetches the signed-in player's position on the canonical Ranked · Online
949  // board for the CURRENTLY VIEWED game/region. Deliberately NOT tied to
950  // S.sort or S.scope the way showMyPlace() above is: "Your Rank" reads as a
951  // single stable number everywhere else in the app (PlayerRating, the
952  // profile drill-down), and letting it follow whatever column the TABLE
953  // happens to be sorted by (Wins, Games, ...) would make it jump around for
954  // a reason that has nothing to do with rank. It always asks with
955  // sort:'rating'. Tournament/In-Person scopes have no relationship to this
956  // endpoint (it only ever computes the ranked/online ordering), so the card
957  // does not react to scope changes either.
958  //
959  // No playerId in localStorage renders a sign-in prompt, never a rank of 0
960  // or a blank card — same rule the brief applies to the rank number itself.
961  function loadMyRank() {
962    var pid = getPlayerId();
963    if (!pid) {
964      setState({ myRank: { status: 'signin' } });
965      return;
966    }
967    setState({ myRank: { status: 'loading' } });
968    var mine = ++myRankSeq;
969    window.TDTT.lb
970      .playerRank({ game: S.game, playerId: pid, region: S.region, sort: 'rating' })
971      .then(function (r) {
972        if (mine !== myRankSeq) return; // stale — game/region changed since this began
973        var rank = r && r.rank;
974        if (rank === null || rank === undefined) {
975          setState({ myRank: { status: 'unranked' } });
976        } else {
977          setState({ myRank: { status: 'ready', rank: rank, total: r.total } });
978        }
979      })
980      .catch(function (e) {
981        if (mine !== myRankSeq) return;
982        setState({ myRank: { status: e && e.status === 401 ? 'signin' : 'error' } });
983      });
984  }
985
986  // Reuses a STAT_ICON svg at the right rail's smaller 14px size (the mockup
987  // reference in _preview.html's .tdtt-aside demo uses 14x14 icons, not the
988  // stat row's 18x18) rather than drawing a second icon set.
989  function asideIcon(svg) {
990    return svg.replace('width="18" height="18"', 'width="14" height="14"');
991  }
992
993  function yourRankCardHtml() {
994    var mr = S.myRank;
995    var body;
996    if (!mr || mr.status === 'signin') {
997      body = '<p class="lb-aside-copy">Sign in to see where you rank on the Ranked · Online leaderboard.</p>';
998    } else if (mr.status === 'loading') {
999      body = '<p class="lb-aside-copy">Finding your rank…</p>';
1000    } else if (mr.status === 'unranked') {
1001      body = '<p class="lb-aside-copy">Play a ranked online match to get on the board.</p>';
1002    } else if (mr.status === 'error') {
1003      body = '<p class="lb-aside-copy">Couldn’t load your rank right now.</p>';
1004    } else {
1005      body =
1006        '<div class="lb-myrank">' +
1007        '<span class="lb-myrank__num">#' + mr.rank + '</span>' +
1008        (mr.total ? '<span class="lb-myrank__of">of ' + fmtNum(mr.total) + ' ranked players</span>' : '') +
1009        '</div>';
1010    }
1011    return (
1012      '<div class="tdtt-asidecard" style="--ac: var(--p-colosseum)">' +
1013      '<div class="tdtt-asidecard__head"><span class="tdtt-asidecard__icon">' +
1014      asideIcon(STAT_ICON.rating) + '</span>Your Rank</div>' +
1015      body +
1016      '</div>'
1017    );
1018  }
1019
1020  // ── "Leaderboard Info" (now a main-column card — see rankInfoRowHtml()) ──
1021  // Static explainer, matching the mockup's card shape. The mockup's fourth
1022  // item (Season Rewards) is OMITTED, not worded around — see the "no season
1023  // card" comment above rankInfoRowHtml() below.
1024  var LEADERBOARD_INFO_ITEMS = null; // built lazily so RATING_LABEL is read once game-constants.js is loaded
1025
1026  function leaderboardInfoItems() {
1027    if (LEADERBOARD_INFO_ITEMS) return LEADERBOARD_INFO_ITEMS;
1028    var label = window.TDTT.game.RATING_LABEL;
1029    LEADERBOARD_INFO_ITEMS = [
1030      {
1031        icon: STAT_ICON.rating,
1032        title: label,
1033        body:
1034          'Every ranked player has an ' + label + ' rating. It rises with wins and falls with ' +
1035          'losses, weighted by the strength of your opponent.',
1036      },
1037      {
1038        icon: STAT_ICON.games,
1039        title: 'Ranked matches',
1040        body:
1041          'Only completed Ranked · Online webcam matches count toward your ' + label +
1042          '. Tournaments and in-person play are tracked on their own leaderboards.',
1043      },
1044      {
1045        icon: STAT_ICON.winrate,
1046        title: 'Fair play',
1047        body: 'Rankings reflect real matches played honestly. Suspected cheating is reviewed and can result in a ban.',
1048      },
1049    ];
1050    return LEADERBOARD_INFO_ITEMS;
1051  }
1052
1053  function leaderboardInfoCardHtml() {
1054    var itemsHtml = leaderboardInfoItems().map(function (it) {
1055      return (
1056        '<div class="lb-info-item">' +
1057        '<span class="lb-info-item__icon">' + asideIcon(it.icon) + '</span>' +
1058        '<div class="lb-info-item__body"><strong>' + esc(it.title) + '</strong><p>' + esc(it.body) + '</p></div>' +
1059        '</div>'
1060      );
1061    }).join('');
1062    return (
1063      '<div class="tdtt-asidecard" style="--ac: var(--p-community)">' +
1064      '<div class="tdtt-asidecard__head"><span class="tdtt-asidecard__icon">' +
1065      asideIcon(STAT_ICON.games) + '</span>Leaderboard Info</div>' +
1066      '<div class="lb-info-list">' + itemsHtml + '</div>' +
1067      '</div>'
1068    );
1069  }
1070
1071  // No Season card: the mockup pairs Your Rank / Leaderboard Info with a
1072  // Season card showing a live countdown ("12d 4h 32m") and a Season Rewards
1073  // tier list. There is no season service anywhere in this codebase — no
1074  // season end date, no rewards concept, nothing to compute a countdown
1075  // from. A hardcoded date would be wrong the moment real time passed it,
1076  // and would be a lie the page told every visitor from the day it shipped.
1077  // If a season service is ever built, ITS card goes here — this is not a
1078  // placeholder to "finish" by inventing a date.
1079  //
1080  // E4.1: this no longer renders a `.tdtt-aside` — the right rail is now
1081  // shell chrome (Forge Feed / Your Stats / Friends / Advertisement, see
1082  // app-shell.js), identical on every page, and a shared shell rail has no
1083  // way to know these two cards are leaderboard-specific. Both cards are
1084  // real leaderboard CONTENT (Your Rank is "the most useful thing on this
1085  // page" per the brief), so they move into the MAIN column instead of
1086  // disappearing — a two-card row using the same `.tdtt-asidecard` shell
1087  // (that class is a general bordered-card primitive, not scoped to
1088  // `.tdtt-aside`), styled by `.lb-rankinfo-row` below. All four of
1089  // yourRankCardHtml()'s states (signin/loading/unranked/error/ready) are
1090  // unchanged.
1091  function rankInfoRowHtml() {
1092    return '<div class="lb-rankinfo-row">' + yourRankCardHtml() + leaderboardInfoCardHtml() + '</div>';
1093  }
1094
1095  // ── Task 9: player drill-down panel ────────────────────────────────────────
1096
1097  // recordText/gameLabel/deriveMainGame/statCellHtml/surfaceSplitCellHtml/
1098  // surfaceDetailCellHtml/gameRowHtml/gameAccordionHtml/profileContentHtml
1099  // moved to /shared/profile-render.js (Task 6); this file reaches that
1100  // module in exactly one place, the contentHtml() call below.
1101
1102  // leaderboard-data.js's get() attaches `.status` for every non-ok response
1103  // (401 and everything else, including 404) — branch on that structurally
1104  // rather than matching the error message text.
1105  function classifyProfileError(e) {
1106    if (e && e.status === 401) return 'signin';
1107    if (e && e.status === 404) return 'notfound';
1108    return 'generic';
1109  }
1110
1111  function profileSigninHtml() {
1112    return (
1113      '<div class="lb-status lb-empty"><p class="lb-empty-title">Sign in to view this profile</p>' +
1114      '<p class="lb-empty-sub"><a href="/m">Sign in on the web companion</a></p></div>'
1115    );
1116  }
1117
1118  function profileNotFoundHtml() {
1119    return '<div class="lb-status lb-empty"><p class="lb-empty-title">Profile not available</p></div>';
1120  }
1121
1122  function profileGenericErrorHtml() {
1123    return (
1124      '<div class="lb-status lb-error"><p class="lb-error-title">Couldn’t load this profile</p>' +
1125      '<p class="lb-error-detail">' + esc(S.profileErrorMessage) + '</p>' +
1126      '<button type="button" class="lb-btn" id="lb-profile-retry">Retry</button></div>'
1127    );
1128  }
1129
1130  function profilePanelHtml() {
1131    if (!S.openPlayerId) return '';
1132    var body;
1133    if (S.profileErrorKind === 'signin') body = profileSigninHtml();
1134    else if (S.profileErrorKind === 'notfound') body = profileNotFoundHtml();
1135    else if (S.profileErrorKind === 'generic') body = profileGenericErrorHtml();
1136    else if (S.profileLoading || !S.profile) body = loadingHtml();
1137    else body = window.TDTT.profileRender.contentHtml(S.profile, S.profileOpenGame);
1138
1139    return (
1140      '<div class="lb-profile-overlay" id="lb-profile-overlay">' +
1141      '<div class="lb-profile-panel" role="dialog" aria-modal="true" aria-label="Player profile">' +
1142      '<div class="lb-profile-panel__head">' +
1143      '<button type="button" class="lb-profile-viewfull" id="lb-profile-viewfull" data-pid="' +
1144      esc(S.openPlayerId) + '">View Full Profile &rarr;</button>' +
1145      '<button type="button" class="lb-profile-close" id="lb-profile-close" aria-label="Close">&times;</button>' +
1146      '</div>' +
1147      body +
1148      '</div></div>'
1149    );
1150  }
1151
1152  // Phase 3, Task 2 -- the drill-down panel stays a peek (profileContentHtml
1153  // above is untouched); this is the ONE way out to the full
1154  // /app/profile?player=<id> page. `id` here is the raw player id read back
1155  // off the button's data-pid attribute (already HTML-attribute-escaped by
1156  // esc() in profilePanelHtml() above, for THAT context) -- encodeURIComponent
1157  // is a separate job, done here, for the query-string context. Both are
1158  // required; neither substitutes for the other.
1159  function viewFullProfile(id) {
1160    if (!id) return;
1161    if (window.TDTTRouter) window.TDTTRouter.go('profile', '?player=' + encodeURIComponent(id));
1162  }
1163
1164  // Opens (or re-targets) the drill-down panel and fetches the profile. A
1165  // request id guards against a slow response for a player the user has since
1166  // navigated away from (opened someone else, or closed the panel) painting
1167  // over whatever is on screen now.
1168  function openPlayerProfile(playerId) {
1169    if (!playerId) return;
1170    var seq = ++profileSeq;
1171    setState({
1172      openPlayerId: playerId,
1173      profile: null,
1174      profileLoading: true,
1175      profileErrorKind: null,
1176      profileErrorMessage: null,
1177      profileOpenGame: null,
1178    });
1179    window.TDTT.lb
1180      .profile(playerId)
1181      .then(function (p) {
1182        if (seq !== profileSeq) return; // stale — superseded by a newer open/close
1183        setState({ profile: p, profileLoading: false });
1184      })
1185      .catch(function (e) {
1186        if (seq !== profileSeq) return;
1187        setState({
1188          profileLoading: false,
1189          profileErrorKind: classifyProfileError(e),
1190          profileErrorMessage: (e && e.message) || 'Failed to load profile',
1191        });
1192      });
1193  }
1194
1195  function closePlayerProfile() {
1196    profileSeq++; // invalidate any in-flight request for the panel that's closing
1197    setState({
1198      openPlayerId: null,
1199      profile: null,
1200      profileLoading: false,
1201      profileErrorKind: null,
1202      profileErrorMessage: null,
1203      profileOpenGame: null,
1204    });
1205  }
1206
1207  // Clicking anywhere in a player row opens the profile ONLY when the click
1208  // actually landed on the avatar or the name (per the brief) — not e.g. a
1209  // rating/record/win-rate/games cell.
1210  function wirePlayerOpenTriggers(scope) {
1211    // Scoped to the table rows, not a bare `[data-pid]` — the search-hit
1212    // buttons (`.lb-hit`) also carry a `data-pid` and wire their own click
1213    // handler directly in renderSearchResults(); a generic selector here
1214    // would double-wire them if a render() ever ran while the dropdown's
1215    // results were still in the DOM.
1216    var containers = scope.querySelectorAll('.tdtt-table__row[data-pid]');
1217    for (var i = 0; i < containers.length; i++) {
1218      containers[i].addEventListener('click', function (e) {
1219        var trigger = e.target.closest ? e.target.closest('.tdtt-avatar, .tdtt-table__name') : null;
1220        if (!trigger) return;
1221        openPlayerProfile(e.currentTarget.getAttribute('data-pid'));
1222      });
1223    }
1224  }
1225
1226  // ── Heroes landing (Task 9) ─────────────────────────────────────────────────
1227  // Per-hero stats exist for Dice Throne only: every other game's
1228  // game_players.character_id is a synthetic system row, so /characters answers
1229  // with one meaningless entry. Both existing clients gate on this the same way
1230  // (leaderboard.html's isDiceThrone(), the desktop's isDiceThrone prop).
1231  function isHeroGame() {
1232    return S.game === 'DICE_THRONE';
1233  }
1234
1235  // Gate for every Task 8 TCG-only addition (format select, Favorite Deck
1236  // column, Active Deck Archetypes tile, insights rail, the Formats tab
1237  // itself): every game except Dice Throne. Mirrors desktop's
1238  // showsTcgLeaderboardFeatures() — the two must never disagree, since
1239  // Dice Throne has neither decks nor `?format=`-aware ranking on the
1240  // server.
1241  function showsTcgFeatures() {
1242    return !isHeroGame();
1243  }
1244
1245  function heroRecordHtml(wins, losses) {
1246    return (
1247      '<span class="tdtt-record"><span class="w">' + numOrDash(wins) + '</span>' +
1248      '<span class="sep">–</span><span class="l">' + numOrDash(losses) + '</span></span>'
1249    );
1250  }
1251
1252  // One tile in the Heroes landing card grid — same fields as desktop's
1253  // HeroCard.tsx (rank in the CURRENT sort, portrait, win-rate badge coloured
1254  // by winRateBand, name, record, games, a pick-rate bar scaled against the
1255  // largest `picks` in the WHOLE set, not against 100%, so the comparison
1256  // across a 44-hero roster stays legible). `data-hero-slug` is what the click
1257  // delegate in wireHeroGridTriggers() reads to open the hero page.
1258  function heroCardHtml(h, rank, maxPicks) {
1259    var band = window.TDTT.bands.winRateBand(h.wins, h.losses);
1260    var width = maxPicks > 0 ? Math.round((h.picks / maxPicks) * 100) : 0;
1261    return (
1262      '<button type="button" class="tdtt-cardtile lb-herotile" data-hero-slug="' +
1263      esc(h.characterSlug) + '">' +
1264      '<div class="lb-herotile-media">' + heroPortraitHtml(h.characterSlug, 'lb-herotile-img') + '</div>' +
1265      '<span class="lb-herotile-rank">#' + rank + '</span>' +
1266      '<span class="lb-herotile-wr ' + band + '">' + h.winRate + '%</span>' +
1267      '<div class="lb-herotile-body">' +
1268      '<p class="lb-herotile-name">' + esc(h.characterName) + '</p>' +
1269      '<div class="lb-herotile-row">' + heroRecordHtml(h.wins, h.losses) +
1270      '<span class="lb-herotile-games">' + fmtNum(h.picks) + ' games</span></div>' +
1271      '<span class="lb-herotile-bar"><span style="width:' + width + '%"></span></span>' +
1272      '</div>' +
1273      '</button>'
1274    );
1275  }
1276
1277  // Insight metrics are computed by the shared lb.heroInsights() derivation —
1278  // this file only supplies the label + how to render each hero's secondary
1279  // line, copied verbatim from desktop's INSIGHT_ROWS (see HERO_INSIGHT_ROWS).
1280  function heroInsightsCardHtml() {
1281    var insights = window.TDTT.lb.heroInsights(S.heroes);
1282    var rows = '';
1283    for (var i = 0; i < HERO_INSIGHT_ROWS.length; i++) {
1284      var def = HERO_INSIGHT_ROWS[i];
1285      var h = insights[def.key];
1286      if (!h) continue;
1287      rows +=
1288        '<div class="lb-insight-row"><span class="lb-insight-label">' + esc(def.label) + '</span>' +
1289        '<span class="lb-insight-val">' + esc(h.characterName) +
1290        ' <span class="lb-insight-metric">&middot; ' + esc(def.metric(h)) + '</span></span></div>';
1291    }
1292    return (
1293      '<div class="tdtt-asidecard" style="--ac: var(--p-colosseum)">' +
1294      '<div class="tdtt-asidecard__head"><span class="tdtt-asidecard__icon">' +
1295      asideIcon(STAT_ICON.winrate) + '</span>Leaderboard Insights</div>' +
1296      '<div class="lb-insight-list">' +
1297      (rows || '<p class="lb-aside-copy">Not enough games yet.</p>') +
1298      '</div></div>'
1299    );
1300  }
1301
1302  // Top Hero Trends — the aside's OWN window-scoped mini leaderboard (see
1303  // S.heroTrend and loadHeroTrend()). heroTrendRows() already sorts, filters
1304  // to HERO_MIN_GAMES and computes the ▲/▼ delta against the PREVIOUS window;
1305  // this only renders what it returns. Matches desktop's HeroTrendsPanel.tsx —
1306  // plain rows, no click-through (that panel doesn't link to the hero page
1307  // either).
1308  function heroTrendCardHtml() {
1309    var windows = window.TDTT.lbCore.STAT_WINDOWS;
1310    var tabs = '';
1311    for (var w = 0; w < windows.length; w++) {
1312      var win = windows[w];
1313      tabs +=
1314        '<button type="button" class="t' + (win === S.heroTrend.window ? ' on' : '') +
1315        '" data-trend-window="' + esc(win) + '">' + esc(TREND_WINDOW_LABELS[win] || win) + '</button>';
1316    }
1317
1318    var body;
1319    if (S.heroTrend.loading) {
1320      body = '<p class="lb-aside-copy lb-center">Loading&hellip;</p>';
1321    } else {
1322      var list = (S.heroTrend.data && S.heroTrend.data.characters) || [];
1323      var rows = window.TDTT.lb.heroTrendRows(list, 5);
1324      if (!rows.length) {
1325        body = '<p class="lb-aside-copy lb-center">Not enough games in this window</p>';
1326      } else {
1327        var rowsHtml = '';
1328        for (var i = 0; i < rows.length; i++) {
1329          var r = rows[i];
1330          var deltaHtml;
1331          if (r.delta === null) {
1332            deltaHtml = '';
1333          } else if (r.delta > 0) {
1334            deltaHtml = '<span class="lb-trend-delta good">&#9652; +' + r.delta + '%</span>';
1335          } else if (r.delta < 0) {
1336            deltaHtml = '<span class="lb-trend-delta bad">&#9662; ' + r.delta + '%</span>';
1337          } else {
1338            deltaHtml = '<span class="lb-trend-delta">0%</span>';
1339          }
1340          rowsHtml +=
1341            '<div class="lb-trend-row">' +
1342            '<span class="lb-trend-rank">' + (i + 1) + '</span>' +
1343            heroPortraitHtml(r.row.characterSlug, 'lb-trend-port') +
1344            '<span class="lb-trend-name">' + esc(r.row.characterName) + '</span>' +
1345            '<span class="lb-trend-wr">' + r.winRate + '%</span>' + deltaHtml +
1346            '</div>';
1347        }
1348        body = '<div class="lb-trend-list">' + rowsHtml + '</div>';
1349      }
1350    }
1351
1352    return (
1353      '<div class="tdtt-asidecard" style="--ac: var(--p-colosseum)">' +
1354      '<div class="tdtt-asidecard__head"><span class="tdtt-asidecard__icon">' +
1355      asideIcon(STAT_ICON.games) + '</span>Top Hero Trends</div>' +
1356      '<div class="seg-slab lb-trend-slab" id="lb-hero-trend-window">' + tabs + '</div>' +
1357      body +
1358      '</div>'
1359    );
1360  }
1361
1362  function heroesAsideHtml() {
1363    return (
1364      '<aside class="tdtt-aside lb-heroes-aside">' +
1365      '<div class="lb-heroes-tiles">' +
1366      statTileHtml(STAT_ICON.games, String(S.heroes.length), 'Total Heroes', 'var(--cyan)') +
1367      statTileHtml(STAT_ICON.games, fmtNum(S.heroTotalGames), 'Total Games', 'var(--gold)') +
1368      '</div>' +
1369      heroInsightsCardHtml() +
1370      heroTrendCardHtml() +
1371      '</aside>'
1372    );
1373  }
1374
1375  function heroesLandingHtml() {
1376    if (S.heroesError) {
1377      return (
1378        '<div class="lb-status lb-error"><p class="lb-error-title">Couldn’t load hero stats</p>' +
1379        '<p class="lb-error-detail">' + esc(S.heroesError) + '</p>' +
1380        '<button type="button" class="lb-btn" id="lb-hero-retry">Retry</button></div>'
1381      );
1382    }
1383    if (S.heroesLoading && !S.heroes.length) return loadingHtml();
1384    if (!S.heroesLoading && !S.heroes.length) {
1385      return (
1386        '<div class="lb-status lb-empty"><p class="lb-empty-title">No hero data yet</p>' +
1387        '<p class="lb-empty-sub">Hero stats appear once ranked games have been completed.</p></div>'
1388      );
1389    }
1390    var filtered = S.heroes.filter(function (h) {
1391      return window.TDTT.lb.matchesHeroQuery(h.characterName, S.heroQuery);
1392    });
1393    var sorted = window.TDTT.lb.sortHeroes(filtered, S.heroSort);
1394    var maxPicks = 0;
1395    for (var i = 0; i < S.heroes.length; i++) {
1396      if (S.heroes[i].picks > maxPicks) maxPicks = S.heroes[i].picks;
1397    }
1398    var cards = '';
1399    for (var j = 0; j < sorted.length; j++) {
1400      cards += heroCardHtml(sorted[j], j + 1, maxPicks);
1401    }
1402    return (
1403      '<div class="lb-heroes-layout">' +
1404      '<div class="lb-heroes-main">' +
1405      '<p class="lb-readout lb-heroes-count">Showing ' + sorted.length + ' of ' + S.heroes.length +
1406      ' heroes</p>' +
1407      '<div class="tdtt-cardgrid lb-hero-grid" id="lb-hero-grid">' + cards + '</div>' +
1408      '</div>' +
1409      heroesAsideHtml() +
1410      '</div>'
1411    );
1412  }
1413
1414  function loadHeroes() {
1415    var game = S.game;
1416    setState({ heroesLoading: true, heroesError: null });
1417    return window.TDTT.lb
1418      .heroes(game)
1419      .then(function (payload) {
1420        if (game !== S.game) return; // the game changed while this was in flight
1421        setState({
1422          heroes: (payload && payload.characters) || [],
1423          heroTotalGames: (payload && payload.totalGames) || 0,
1424          heroesGame: game,
1425          heroesLoading: false,
1426        });
1427      })
1428      .catch(function (e) {
1429        if (game !== S.game) return;
1430        setState({
1431          heroesLoading: false,
1432          heroesError:
1433            e && e.status === 401 ? 'Sign in to view hero stats.' : (e && e.message) || 'Failed to load',
1434        });
1435      });
1436  }
1437
1438  // Top Hero Trends' own fetch — INDEPENDENT of loadHeroes()/S.heroes above,
1439  // windowed by S.heroTrend.window. 'all' reuses lb.heroes(game)'s bare cache
1440  // key (leaderboard-data.js), so it never doubles the fetch loadHeroes()
1441  // already made.
1442  function loadHeroTrend() {
1443    var game = S.game;
1444    var w = S.heroTrend.window;
1445    var seq = ++heroTrendSeq;
1446    S.heroTrend.loading = true;
1447    S.heroTrend.data = null;
1448    S.heroTrendGame = game;
1449    render();
1450    window.TDTT.lb
1451      .heroes(game, w === 'all' ? undefined : w)
1452      .then(function (payload) {
1453        if (seq !== heroTrendSeq) return;
1454        S.heroTrend.data = payload;
1455        S.heroTrend.loading = false;
1456        render();
1457      })
1458      .catch(function () {
1459        if (seq !== heroTrendSeq) return;
1460        S.heroTrend.data = null;
1461        S.heroTrend.loading = false;
1462        render();
1463      });
1464  }
1465
1466  // Re-entering the Heroes tab for the SAME game the trend data was already
1467  // fetched for is a no-op — loadHeroTrend() itself is always called
1468  // unconditionally after a real game switch (setFilter resets heroTrendGame
1469  // to null first).
1470  function loadHeroTrendIfStale() {
1471    if (S.heroTrendGame !== S.game) loadHeroTrend();
1472  }
1473
1474  function setHeroTrendWindow(w) {
1475    if (S.heroTrend.window === w) return;
1476    S.heroTrend.window = w;
1477    loadHeroTrend();
1478  }
1479
1480  // ── Hero detail page (Task 10) ──────────────────────────────────────────────
1481  // leaderboard.js owns every fetch the hero page needs and builds the `ctx`
1482  // window.TDTT.lbHeroPage (lb-hero-page.js) renders/wires from — that module
1483  // does no fetching of its own. The exact contract, verbatim from the task
1484  // brief:
1485  //
1486  //   { game, slug, window, heroes /* HeroStatRow[] all-time list */, heroesState,
1487  //     windowRow /* row for current window or null */, windowState,
1488  //     matchups, matchupsState, games, gamesState,
1489  //     rosterEntry /* {name,set,difficulty} or null */, rosterState,
1490  //     portraitHtml(slug, cls), bestOrWorst: 'best'|'worst',
1491  //     onWindow(w), onBestWorst(k), onLeaderboards(), onBack(),
1492  //     onOpenPlayer(id), onOpenCodex(slug) }
1493  //
1494  // `heroesState`/`windowState`/`matchupsState`/`gamesState`/`rosterState`
1495  // are all 'loading' | 'ready' | 'error'. `heroes` starts as [] and
1496  // `windowRow`/`rosterEntry` as null until their respective fetches
1497  // resolve — these *State fields are what let Task 10 tell "still fetching"
1498  // apart from "fetched, and this roster hero (or this window) genuinely has
1499  // zero games" rather than reading the empty array/null itself as the
1500  // answer (never fabricated).
1501  //
1502  // `rosterState` specifically (fix round 1, added after roster() and
1503  // heroes() were found to be independent, unordered fetches): `!rosterEntry`
1504  // alone is NOT "hero not found" — it is equally true while roster() is
1505  // still in flight, or if it rejects outright. Task 10 waits for
1506  // `rosterState` to settle before rendering "Hero not found", and when
1507  // roster() fails but the hero has all-time stats (a real characterId,
1508  // found in `heroes`), it renders the page anyway using that row's own
1509  // `characterName`, with set/difficulty shown as `—` rather than invented.
1510  // `onLeaderboards()` (-> the Players view) and `onBack()` (-> the Heroes
1511  // landing grid) are the breadcrumb's two distinct destinations, matching
1512  // desktop's "Leaderboards" / "Dice Throne" crumbs.
1513
1514  function findHeroRow(list, slug) {
1515    if (!list) return null;
1516    for (var i = 0; i < list.length; i++) {
1517      if (list[i].characterSlug === slug) return list[i];
1518    }
1519    return null;
1520  }
1521
1522  function findRosterEntry(payload, slug) {
1523    var chars = (payload && payload.characters) || [];
1524    var sets = (payload && payload.sets) || [];
1525    var setNames = {};
1526    for (var i = 0; i < sets.length; i++) setNames[sets[i].id] = sets[i].name;
1527    for (var j = 0; j < chars.length; j++) {
1528      // Roster hero objects key by `.id` (the slug) — there is no `.slug`
1529      // field, per codex.js's own convention (see this file's leaderboard-
1530      // data.js roster() comment).
1531      if (chars[j].id === slug) {
1532        return {
1533          name: chars[j].name,
1534          set: setNames[chars[j].set] || chars[j].set || null,
1535          difficulty: chars[j].difficulty || null,
1536        };
1537      }
1538    }
1539    return null;
1540  }
1541
1542  function fetchHeroMatchups(game, characterId, w, seq, wseq) {
1543    S.heroPage.matchupsState = 'loading';
1544    window.TDTT.lb
1545      .matchups(game, characterId, w)
1546      .then(function (list) {
1547        if (seq !== heroPageSeq || wseq !== heroPageWindowSeq) return;
1548        S.heroPage.matchups = Array.isArray(list) ? list : [];
1549        S.heroPage.matchupsState = 'ready';
1550        render();
1551      })
1552      .catch(function () {
1553        if (seq !== heroPageSeq || wseq !== heroPageWindowSeq) return;
1554        S.heroPage.matchupsState = 'error';
1555        render();
1556      });
1557  }
1558
1559  function fetchHeroGames(game, characterId, seq) {
1560    window.TDTT.lb
1561      .heroGames(game, characterId, window.TDTT.lbCore.HERO_PAGE_TREND_LENGTH)
1562      .then(function (payload) {
1563        if (seq !== heroPageSeq) return;
1564        var list = (payload && payload.games) || (Array.isArray(payload) ? payload : []);
1565        S.heroPage.games = list;
1566        S.heroPage.gamesState = 'ready';
1567        render();
1568      })
1569      .catch(function () {
1570        if (seq !== heroPageSeq) return;
1571        S.heroPage.gamesState = 'error';
1572        render();
1573      });
1574  }
1575
1576  // The all-time heroes list + roster entry are fetched ONCE per hero (not
1577  // per window) — the all-time list drives the rank chip and resolves the
1578  // characterId that matchups/games need; a hero with no games at all short-
1579  // circuits both of those to a ready, empty state rather than loading
1580  // forever (there is nothing to fetch).
1581  function fetchHeroPage(slug) {
1582    var game = S.game;
1583    var seq = heroPageSeq;
1584    var wseq = heroPageWindowSeq;
1585
1586    window.TDTT.lb
1587      .roster()
1588      .then(function (payload) {
1589        if (seq !== heroPageSeq) return;
1590        S.heroPage.rosterEntry = findRosterEntry(payload, slug);
1591        S.heroPage.rosterState = 'ready';
1592        render();
1593      })
1594      .catch(function () {
1595        if (seq !== heroPageSeq) return;
1596        // rosterEntry stays null, but rosterState becomes 'error' — Task 10
1597        // falls back to the all-time row's own name/no-games state instead
1598        // of a fabricated "Hero not found" (fix round 1: roster() and
1599        // heroes() are independent, unordered fetches, so a slow/failed
1600        // roster() must never be read as "this hero doesn't exist").
1601        S.heroPage.rosterState = 'error';
1602        render();
1603      });
1604
1605    window.TDTT.lb
1606      .heroes(game)
1607      .then(function (payload) {
1608        if (seq !== heroPageSeq) return;
1609        var list = (payload && payload.characters) || [];
1610        S.heroPage.heroes = list;
1611        S.heroPage.heroesState = 'ready';
1612        var row = findHeroRow(list, slug);
1613        if (S.heroPage.window === 'all') {
1614          S.heroPage.windowRow = row;
1615          S.heroPage.windowState = 'ready';
1616        }
1617        render();
1618        if (!row) {
1619          S.heroPage.matchupsState = 'ready';
1620          S.heroPage.matchups = [];
1621          S.heroPage.gamesState = 'ready';
1622          S.heroPage.games = [];
1623          render();
1624          return;
1625        }
1626        fetchHeroMatchups(game, row.characterId, S.heroPage.window, seq, wseq);
1627        fetchHeroGames(game, row.characterId, seq);
1628      })
1629      .catch(function () {
1630        if (seq !== heroPageSeq) return;
1631        S.heroPage.heroesState = 'error';
1632        if (S.heroPage.window === 'all') S.heroPage.windowState = 'error';
1633        render();
1634      });
1635  }
1636
1637  function onHeroWindow(w) {
1638    if (!S.heroPage || S.heroPage.window === w) return;
1639    S.heroPage.window = w;
1640    heroPageWindowSeq++;
1641    var wseq = heroPageWindowSeq;
1642    var seq = heroPageSeq;
1643    var game = S.game;
1644    var row = findHeroRow(S.heroPage.heroes, S.heroSlug);
1645    if (w === 'all') {
1646      S.heroPage.windowRow = row;
1647      // Mirrors the already-known state of the SAME all-time fetch that
1648      // heroesState tracks — window 'all' never makes its own request.
1649      S.heroPage.windowState = S.heroPage.heroesState;
1650    } else {
1651      S.heroPage.windowRow = null; // unknown until the windowed fetch resolves
1652      S.heroPage.windowState = 'loading';
1653      window.TDTT.lb
1654        .heroes(game, w)
1655        .then(function (payload) {
1656          if (seq !== heroPageSeq || wseq !== heroPageWindowSeq) return;
1657          var list = (payload && payload.characters) || [];
1658          S.heroPage.windowRow = findHeroRow(list, S.heroSlug);
1659          S.heroPage.windowState = 'ready';
1660          render();
1661        })
1662        .catch(function () {
1663          if (seq !== heroPageSeq || wseq !== heroPageWindowSeq) return;
1664          S.heroPage.windowRow = null;
1665          S.heroPage.windowState = 'error';
1666          render();
1667        });
1668    }
1669    render();
1670    if (row) fetchHeroMatchups(game, row.characterId, w, seq, wseq);
1671  }
1672
1673  function onHeroBestWorst(k) {
1674    if (!S.heroPage || S.heroPage.bestOrWorst === k) return;
1675    S.heroPage.bestOrWorst = k;
1676    render();
1677  }
1678
1679  function onOpenCodexForHero(slug) {
1680    if (window.TDTTRouter) {
1681      window.TDTTRouter.go('codex', '?game=DICE_THRONE&hero=' + encodeURIComponent(slug));
1682    }
1683  }
1684
1685  function buildHeroPageCtx() {
1686    var hp = S.heroPage || {};
1687    return {
1688      game: S.game,
1689      slug: S.heroSlug,
1690      window: hp.window || 'all',
1691      heroes: hp.heroes || [],
1692      // 'loading' | 'ready' | 'error' — lets Task 10 tell "still fetching the
1693      // all-time list" apart from "fetched, and this roster hero genuinely
1694      // has zero games" (both start with heroes:[] / windowRow:null).
1695      heroesState: hp.heroesState || 'loading',
1696      windowRow: hp.windowRow || null,
1697      // Same three-state shape, scoped to the CURRENT window only — mirrors
1698      // heroesState while window is 'all' (same fetch), tracks its own
1699      // request otherwise. See onHeroWindow()/fetchHeroPage() above.
1700      windowState: hp.windowState || 'loading',
1701      matchups: hp.matchups,
1702      matchupsState: hp.matchupsState,
1703      games: hp.games,
1704      gamesState: hp.gamesState,
1705      rosterEntry: hp.rosterEntry || null,
1706      // 'loading' | 'ready' | 'error' (fix round 1) — roster() and heroes()
1707      // are independent, unordered fetches, so `!rosterEntry` alone cannot
1708      // mean "not found": it's also true while roster() is still in flight,
1709      // or if it fails outright. Task 10 waits for this to settle before
1710      // rendering "Hero not found", and falls back to the all-time row's
1711      // own name (set/difficulty shown as `—`) rather than fabricating
1712      // either state when only one of the two fetches came back.
1713      rosterState: hp.rosterState || 'loading',
1714      bestOrWorst: hp.bestOrWorst || 'best',
1715      // Task 10 renders portraits through this rather than a second .jpg->
1716      // .png fallback copy — see heroPortraitHtml()'s own comment.
1717      portraitHtml: heroPortraitHtml,
1718      onWindow: onHeroWindow,
1719      onBestWorst: onHeroBestWorst,
1720      // Two distinct breadcrumb destinations, matching desktop:
1721      // "Leaderboards" -> the Players view (setView, hoisted below);
1722      // "Dice Throne" -> back to the Heroes landing grid (closeHeroPage).
1723      onLeaderboards: function () {
1724        setView('players');
1725      },
1726      onBack: closeHeroPage,
1727      onOpenPlayer: viewFullProfile,
1728      onOpenCodex: onOpenCodexForHero,
1729    };
1730  }
1731
1732  // Opens the hero page for `slug`, always inside the Heroes tab — hero cards,
1733  // Top Hero Trends and Drafts/Bans rows all funnel through this one function
1734  // so `?view=heroes&hero=<slug>` is the only shape that ever reaches the URL.
1735  function openHeroPage(slug) {
1736    if (!slug) return;
1737    S.view = 'heroes';
1738    S.heroSlug = slug;
1739    S.heroPage = {
1740      window: 'all',
1741      bestOrWorst: 'best',
1742      heroes: [],
1743      heroesState: 'loading',
1744      windowRow: null,
1745      windowState: 'loading',
1746      matchups: null,
1747      matchupsState: 'loading',
1748      games: null,
1749      gamesState: 'loading',
1750      rosterEntry: null,
1751      rosterState: 'loading',
1752    };
1753    heroPageSeq++;
1754    heroPageWindowSeq++;
1755    syncUrl();
1756    render();
1757    fetchHeroPage(slug);
1758  }
1759
1760  function closeHeroPage() {
1761    S.heroSlug = null;
1762    S.heroPage = null;
1763    heroPageSeq++;
1764    heroPageWindowSeq++;
1765    syncUrl();
1766    render();
1767    if (S.heroesGame !== S.game && !S.heroesLoading) loadHeroes();
1768  }
1769
1770  // ── Drafts / Bans (Task 11) ──────────────────────────────────────────────────
1771  // Same delegation shape as the hero page: leaderboard.js fetches, Task 11's
1772  // window.TDTT.lbDraftsBans renders/wires. ONE ctx shape serves both
1773  // draftsHtml(ctx) and bansHtml(ctx) since only one is ever on screen:
1774  //
1775  //   { game, draft /* draftStats() {totalDrafts,characters} | null */, draftState,
1776  //     heroes /* HeroStatRow[] | null, Drafts only */,
1777  //     bans /* bans(game) {heroes,cards} | null, Bans only */, bansState,
1778  //     rosterSize /* number | null — null until roster() resolves, renders '—' */,
1779  //     sort /* active DraftSortKey, Drafts only */, query /* Drafts only */,
1780  //     onSort(k), onQuery(q), onOpenHero(slug) }
1781  //
1782  // draftState/bansState are 'loading' | 'ready' | 'error'.
1783
1784  function loadRosterSize(seq) {
1785    if (S.draftsBans.rosterSize !== null) return; // already known — roster() is itself cached
1786    window.TDTT.lb
1787      .roster()
1788      .then(function (payload) {
1789        if (seq !== draftsBansSeq) return;
1790        var count =
1791          (payload && typeof payload.count === 'number' && payload.count) ||
1792          (payload && payload.characters && payload.characters.length) ||
1793          null;
1794        S.draftsBans.rosterSize = count;
1795        render();
1796      })
1797      .catch(function () {
1798        /* rosterSize stays null — the roster-% readouts render '—' */
1799      });
1800  }
1801
1802  function loadDraftsData() {
1803    var game = S.game;
1804    var seq = ++draftsBansSeq;
1805    S.draftsBans.draftState = 'loading';
1806    S.draftsBans.heroesState = 'loading';
1807    render();
1808    window.TDTT.lb
1809      .draftStats()
1810      .then(function (payload) {
1811        if (seq !== draftsBansSeq) return;
1812        S.draftsBans.draft = payload;
1813        S.draftsBans.draftState = 'ready';
1814        render();
1815      })
1816      .catch(function () {
1817        if (seq !== draftsBansSeq) return;
1818        S.draftsBans.draftState = 'error';
1819        render();
1820      });
1821    window.TDTT.lb
1822      .heroes(game)
1823      .then(function (payload) {
1824        if (seq !== draftsBansSeq) return;
1825        S.draftsBans.heroes = (payload && payload.characters) || [];
1826        S.draftsBans.heroesState = 'ready';
1827        render();
1828      })
1829      .catch(function () {
1830        if (seq !== draftsBansSeq) return;
1831        S.draftsBans.heroesState = 'error';
1832        render();
1833      });
1834    loadRosterSize(seq);
1835  }
1836
1837  function loadBansData() {
1838    var game = S.game;
1839    var seq = ++draftsBansSeq;
1840    S.draftsBans.bansState = 'loading';
1841    S.draftsBans.draftState = 'loading';
1842    render();
1843    window.TDTT.lb
1844      .bans(game)
1845      .then(function (payload) {
1846        if (seq !== draftsBansSeq) return;
1847        S.draftsBans.bans = payload;
1848        S.draftsBans.bansState = 'ready';
1849        render();
1850      })
1851      .catch(function () {
1852        if (seq !== draftsBansSeq) return;
1853        S.draftsBans.bansState = 'error';
1854        render();
1855      });
1856    window.TDTT.lb
1857      .draftStats()
1858      .then(function (payload) {
1859        if (seq !== draftsBansSeq) return;
1860        S.draftsBans.draft = payload;
1861        S.draftsBans.draftState = 'ready';
1862        render();
1863      })
1864      .catch(function () {
1865        if (seq !== draftsBansSeq) return;
1866        S.draftsBans.draftState = 'error';
1867        render();
1868      });
1869    loadRosterSize(seq);
1870  }
1871
1872  function setDraftSort(k) {
1873    if (S.draftsBans.sort === k) return;
1874    S.draftsBans.sort = k;
1875    render();
1876  }
1877
1878  function setDraftQuery(q) {
1879    S.draftsBans.query = q || '';
1880    render();
1881  }
1882
1883  function buildDraftsBansCtx() {
1884    var db = S.draftsBans;
1885    return {
1886      game: S.game,
1887      draft: db.draft,
1888      draftState: db.draftState,
1889      heroes: db.heroes,
1890      heroesState: db.heroesState,
1891      bans: db.bans,
1892      bansState: db.bansState,
1893      rosterSize: db.rosterSize,
1894      sort: db.sort,
1895      query: db.query,
1896      // Task 11 renders portraits through this rather than a second .jpg->
1897      // .png fallback copy — see heroPortraitHtml()'s own comment (also used
1898      // by buildHeroPageCtx() above).
1899      portraitHtml: heroPortraitHtml,
1900      onSort: setDraftSort,
1901      onQuery: setDraftQuery,
1902      onOpenHero: openHeroPage,
1903    };
1904  }
1905
1906  // ── `?view=`/`?hero=` deep-link routing (Task 9, extended Task 8) ───────────
1907  // Read with the exact try/catch shape profile.js's viewedPlayerId() uses
1908  // (lines 151-159) — a malformed location.search degrades to the defaults
1909  // instead of throwing. `view` is validated against the five real views
1910  // ('formats' added Task 8) and `hero` is only honoured alongside
1911  // `view=heroes`.
1912  function readUrlState() {
1913    var view = null;
1914    var hero = null;
1915    var deck = null;
1916    var game = null;
1917    try {
1918      var params = new URLSearchParams(window.location.search);
1919      var v = params.get('view');
1920      if (v === 'players' || v === 'heroes' || v === 'drafts' || v === 'bans' || v === 'formats' || v === 'decks') {
1921        view = v;
1922      }
1923      hero = params.get('hero') || null;
1924      deck = params.get('deck') || null;
1925      // `?game=<GameType>` (a House chat leaderboard card links here): only a real GameType from
1926      // GAMES is honoured, anything else is ignored.
1927      var g = params.get('game');
1928      for (var gi = 0; gi < GAMES.length; gi++) {
1929        if (GAMES[gi].id === g) game = g;
1930      }
1931    } catch (e) {
1932      /* malformed location.search — fall through to the defaults */
1933    }
1934    return { view: view, hero: hero, deck: deck, game: game };
1935  }
1936
1937  function buildSearch() {
1938    var search = '?view=' + encodeURIComponent(S.view);
1939    if (S.view === 'heroes' && S.heroSlug) search += '&hero=' + encodeURIComponent(S.heroSlug);
1940    if (S.view === 'decks' && S.deckKey) search += '&deck=' + encodeURIComponent(S.deckKey);
1941    return search;
1942  }
1943
1944  // Same convention as codex.js's setGame(): `replaceState`, not `pushState` —
1945  // this is one section stepping through its own sub-views, not a page-to-page
1946  // navigation router.js's popstate handling needs a history entry for.
1947  function syncUrl() {
1948    try {
1949      window.history.replaceState({ section: 'leaderboard' }, '', '/app/leaderboard' + buildSearch());
1950    } catch (e) {
1951      /* history API unavailable — the in-memory S still drives render() */
1952    }
1953  }
1954
1955  // Non-Dice-Throne games have no hero data at all, and Dice Throne has no
1956  // Formats/Decks tab (the mirror image) — same fallback rule setFilter(
1957  // 'game', ...) already applies elsewhere in this file.
1958  function resolveUrlView(v) {
1959    if (v !== 'players' && v !== 'heroes' && v !== 'drafts' && v !== 'bans' && v !== 'formats' && v !== 'decks') {
1960      return 'players';
1961    }
1962    if ((v === 'heroes' || v === 'drafts' || v === 'bans') && !isHeroGame()) return 'players';
1963    if ((v === 'formats' || v === 'decks') && isHeroGame()) return 'players';
1964    return v;
1965  }
1966
1967  function applyInitialUrlState() {
1968    var u = readUrlState();
1969    // Before the view is resolved: whether Heroes/Drafts/Bans or Formats/Decks exist depends on
1970    // the game. A game that arrives in the URL is a link, not a saved choice, so it is not stored.
1971    if (u.game && u.game !== S.game) {
1972      S.game = u.game;
1973      S.format = '';
1974    }
1975    S.view = resolveUrlView(u.view);
1976    if (S.view === 'heroes' && u.hero) {
1977      S.heroSlug = u.hero;
1978      S.heroPage = {
1979        window: 'all',
1980        bestOrWorst: 'best',
1981        heroes: [],
1982        heroesState: 'loading',
1983        windowRow: null,
1984        windowState: 'loading',
1985        matchups: null,
1986        matchupsState: 'loading',
1987        games: null,
1988        gamesState: 'loading',
1989        rosterEntry: null,
1990        rosterState: 'loading',
1991      };
1992    }
1993    if (S.view === 'decks' && u.deck) {
1994      S.deckKey = u.deck;
1995    }
1996  }
1997
1998  // Handles the browser Back/Forward buttons — re-reads the URL and applies
1999  // whatever it now says, WITHOUT calling syncUrl() again (that would be
2000  // reacting to our own write). Registered in mount()/removed in unmount(),
2001  // same as profile.js's onTabPopState — this listener lives on `window`,
2002  // outside the router-owned #app-content, so nothing else will remove it.
2003  function onPopState() {
2004    var u = readUrlState();
2005    var v = resolveUrlView(u.view);
2006
2007    if (v === 'heroes' && u.hero) {
2008      if (S.view !== 'heroes' || S.heroSlug !== u.hero) {
2009        S.view = 'heroes';
2010        S.heroSlug = u.hero;
2011        S.heroPage = {
2012          window: 'all',
2013          bestOrWorst: 'best',
2014          heroes: [],
2015          heroesState: 'loading',
2016          windowRow: null,
2017          windowState: 'loading',
2018          matchups: null,
2019          matchupsState: 'loading',
2020          games: null,
2021          gamesState: 'loading',
2022          rosterEntry: null,
2023          rosterState: 'loading',
2024        };
2025        heroPageSeq++;
2026        heroPageWindowSeq++;
2027        render();
2028        fetchHeroPage(u.hero);
2029      }
2030      return;
2031    }
2032
2033    if (v === 'decks' && u.deck) {
2034      if (S.view !== 'decks' || S.deckKey !== u.deck) {
2035        S.view = 'decks';
2036        S.deckKey = u.deck;
2037        S.deckPage.detailState = 'loading';
2038        S.deckPage.detail = null;
2039        S.deckPage.matchupsState = 'loading';
2040        S.deckPage.matchups = null;
2041        S.deckPage.gamesState = 'loading';
2042        S.deckPage.games = null;
2043        render();
2044        loadDeckPage(u.deck);
2045      }
2046      return;
2047    }
2048
2049    var hadHero = !!S.heroSlug;
2050    var hadDeck = !!S.deckKey;
2051    S.heroSlug = null;
2052    S.heroPage = null;
2053    heroPageSeq++;
2054    heroPageWindowSeq++;
2055    S.deckKey = null;
2056    S.deckPage.token++;
2057
2058    if (S.view === v) {
2059      if (hadHero || hadDeck) render(); // closed a hero/deck page, same tab (e.g. Back from a deep link)
2060      return;
2061    }
2062    S.view = v;
2063    if (v === 'heroes') {
2064      render();
2065      if (S.heroesGame !== S.game && !S.heroesLoading) loadHeroes();
2066      loadHeroTrendIfStale();
2067      return;
2068    }
2069    if (v === 'drafts') {
2070      render();
2071      loadDraftsData();
2072      return;
2073    }
2074    if (v === 'bans') {
2075      render();
2076      loadBansData();
2077      return;
2078    }
2079    if (v === 'formats') {
2080      render();
2081      loadFormatsTabIfStale();
2082      return;
2083    }
2084    if (v === 'decks') {
2085      render();
2086      loadTcgRailIfStale();
2087      loadDecksGridIfStale();
2088      return;
2089    }
2090    if (v === 'players' && S.scope === 'ranked' && showsTcgFeatures()) loadTcgRailIfStale();
2091    if (S.playersDirty) {
2092      S.playersDirty = false;
2093      load(true);
2094      return;
2095    }
2096    render();
2097  }
2098
2099  function setView(view) {
2100    if (S.view === view && !S.heroSlug && !S.deckKey) return;
2101    S.view = view;
2102    // A hero/deck page left open behind another tab would reopen on return
2103    // with stale matchups/games belonging to a hero/archetype the user has
2104    // since forgotten.
2105    S.heroSlug = null;
2106    S.heroPage = null;
2107    heroPageSeq++;
2108    heroPageWindowSeq++;
2109    S.deckKey = null;
2110    S.deckPage.token++;
2111    if (view === 'heroes') {
2112      syncUrl();
2113      render();
2114      if (S.heroesGame !== S.game && !S.heroesLoading) loadHeroes();
2115      loadHeroTrendIfStale();
2116      return;
2117    }
2118    if (view === 'drafts') {
2119      syncUrl();
2120      render();
2121      loadDraftsData();
2122      return;
2123    }
2124    if (view === 'bans') {
2125      syncUrl();
2126      render();
2127      loadBansData();
2128      return;
2129    }
2130    if (view === 'formats') {
2131      syncUrl();
2132      render();
2133      loadFormatsTabIfStale();
2134      return;
2135    }
2136    if (view === 'decks') {
2137      syncUrl();
2138      render();
2139      loadTcgRailIfStale();
2140      loadDecksGridIfStale();
2141      return;
2142    }
2143    syncUrl();
2144    if (view === 'players' && S.scope === 'ranked' && showsTcgFeatures()) loadTcgRailIfStale();
2145    if (S.playersDirty) {
2146      S.playersDirty = false;
2147      load(true);
2148      return;
2149    }
2150    render();
2151  }
2152
2153  function filterBarHtml() {
2154    // Game logo rail batch, Task 3: the game chip row is now the shared
2155    // rail (window.TDTT.gameRail), matching Codex and Rules.
2156    var gameRailHtml = window.TDTT.gameRail
2157      ? window.TDTT.gameRail.html(gamesForRail(), S.game, { attr: 'data-game' })
2158      : '';
2159
2160    var sortChips = SORTS.map(function (s) {
2161      return (
2162        '<button type="button" class="chip' + (s.id === S.sort ? ' on' : '') + '" data-sort="' +
2163        esc(s.id) + '">' + esc(s.label) + '</button>'
2164      );
2165    }).join('');
2166
2167    var heroSortChips = HERO_SORTS.map(function (h) {
2168      return (
2169        '<button type="button" class="chip' + (h.id === S.heroSort ? ' on' : '') + '" data-hsort="' +
2170        esc(h.id) + '">' + esc(h.label) + '</button>'
2171      );
2172    }).join('');
2173
2174    // Region, sort, search and "show my place" are all properties of a PLAYER
2175    // ranking — none of them apply to the hero grid, an open hero page, the
2176    // Drafts/Bans views (Task 11 renders its own sort/search inside its
2177    // body), or the Formats tab (Task 8 renders its own window chips inside
2178    // its own body/rail), so those views carry their own single control row
2179    // (or none at all) rather than showing dead controls.
2180    var controls;
2181    if (S.view === 'heroes' && !S.heroSlug) {
2182      controls =
2183        '<div class="lb-filterrow">' +
2184        '<div class="chip-row" id="lb-hsorts">' + heroSortChips + '</div>' +
2185        '<div class="lb-search-wrap">' +
2186        '<input type="search" id="lb-hero-search" class="lb-search-input" placeholder="Search heroes…" ' +
2187        'autocomplete="off" value="' + esc(S.heroQuery) + '">' +
2188        '</div>' +
2189        '</div>';
2190    } else if (S.view === 'heroes' || S.view === 'drafts' || S.view === 'bans' || S.view === 'formats') {
2191      controls = '';
2192    } else {
2193      controls =
2194        myPlaceHtml() +
2195        '<div class="lb-filterrow">' +
2196        '<div class="chip-row" id="lb-sorts">' + sortChips + '</div>' +
2197        '<select class="lb-select" id="lb-region" aria-label="Filter by region">' + regionOptionsHtml() + '</select>' +
2198        '</div>';
2199    }
2200
2201    return (
2202      '<div class="lb-filterbar">' +
2203      gameRailHtml +
2204      tabsAndSearchRowHtml() +
2205      controls +
2206      '</div>'
2207    );
2208  }
2209
2210  // Bare .seg-slab markup (no wrapping row). Dice Throne keeps
2211  // Players/Heroes/Drafts/Bans (keyed by character); every other game has no
2212  // per-character data or hero-ban tracking, so it gets Players/Decks/Formats
2213  // instead (Task 8 + Task 9) — mirrors desktop's Leaderboard.tsx modeTabs
2214  // exactly, same order.
2215  function viewTabsSegHtml() {
2216    var tabDefs = isHeroGame()
2217      ? [
2218          { id: 'players', label: 'Players' },
2219          { id: 'heroes', label: 'Heroes' },
2220          { id: 'drafts', label: 'Drafts' },
2221          { id: 'bans', label: 'Bans' },
2222        ]
2223      : [
2224          { id: 'players', label: 'Players' },
2225          { id: 'decks', label: 'Decks' },
2226          { id: 'formats', label: 'Formats' },
2227        ];
2228    var tabs = tabDefs
2229      .map(function (v) {
2230        return (
2231          '<button type="button" class="t' + (v.id === S.view ? ' on' : '') + '" data-view="' +
2232          esc(v.id) + '">' + esc(v.label) + '</button>'
2233        );
2234      })
2235      .join('');
2236    return '<div class="seg-slab" id="lb-views">' + tabs + '</div>';
2237  }
2238
2239  // The player search box (Task 4 Step 4) — extracted so it can sit BESIDE
2240  // the mode-tab row per the brief, instead of stacked in its own row further
2241  // down with the sort chips.
2242  function searchWrapHtml() {
2243    return (
2244      '<div class="lb-search-wrap">' +
2245      '<input type="search" id="lb-search" class="lb-search-input" placeholder="Search players…" autocomplete="off">' +
2246      '<div id="lb-search-results" class="lb-search-results" hidden></div>' +
2247      '</div>'
2248    );
2249  }
2250
2251  // The format `<select>` (Task 8, non-Dice-Throne Players/ranked only) —
2252  // "All formats" plus every window.TDTT.decks.deckFormatOptions(S.game)
2253  // entry, mirroring desktop's FormatDropdown exactly. A plain `<select>`
2254  // rather than a chip row — the format list is long and per-game (MTG has
2255  // 6, Riftbound 0), unlike the small, fixed scope/mode rows those use.
2256  function formatSelectHtml() {
2257    var opts = window.TDTT.decks ? window.TDTT.decks.deckFormatOptions(S.game) : [];
2258    var html = '<option value="">All formats</option>';
2259    for (var i = 0; i < opts.length; i++) {
2260      html +=
2261        '<option value="' + esc(opts[i].id) + '"' + (opts[i].id === S.format ? ' selected' : '') + '>' +
2262        esc(opts[i].name) + '</option>';
2263    }
2264    return '<select class="lb-select" id="lb-format" aria-label="Format">' + html + '</select>';
2265  }
2266
2267  // Combines the mode tabs, the format select and the player search into one
2268  // row. Search is player-scoped (it filters the CURRENT scope's player list
2269  // via the server-side /leaderboard/search, per the brief), so it only
2270  // makes sense next to the Players tab — Heroes/Drafts/Bans/Formats hide it
2271  // rather than showing a control that does nothing on their view. The
2272  // format select is Players + ranked + TCG-game only, same gate desktop's
2273  // FormatDropdown uses.
2274  function tabsAndSearchRowHtml() {
2275    var tabs = viewTabsSegHtml();
2276    var isPlayers = S.view === 'players';
2277    var format = isPlayers && S.scope === 'ranked' && showsTcgFeatures() ? formatSelectHtml() : '';
2278    var search = isPlayers ? searchWrapHtml() : '';
2279    if (!tabs && !format && !search) return '';
2280    return '<div class="lb-filterrow">' + tabs + format + search + '</div>';
2281  }
2282
2283  // Drafts/Bans tab CONTENT is rendered by window.TDTT.lbDraftsBans (Task 11) —
2284  // see draftsBodyHtml()/bansBodyHtml() below, which guard for that module not
2285  // being loaded yet and fall back to loadingHtml() rather than throwing.
2286
2287  function renderSearchResults(el, list) {
2288    if (!list || !list.length) {
2289      el.hidden = false;
2290      el.innerHTML = '<div class="lb-search-empty">No players found.</div>';
2291      return;
2292    }
2293    el.hidden = false;
2294    el.innerHTML = list
2295      .map(function (p) {
2296        return (
2297          '<button type="button" class="lb-hit" data-pid="' + esc(p.id) + '">' +
2298          avatarHtml({ avatarUrl: p.avatarUrl, displayName: p.displayName }, '') +
2299          '<span class="lb-hit-b"><span class="lb-hit-nm">' + esc(p.displayName) + '</span>' +
2300          (p.region ? '<span class="lb-hit-sub">' + esc(p.region) + '</span>' : '') +
2301          '</span><span class="lb-hit-rt">' + numOrDash(p.skillRating) + '</span></button>'
2302        );
2303      })
2304      .join('');
2305    wireAvatarFallback(el);
2306    var hits = el.querySelectorAll('.lb-hit');
2307    for (var i = 0; i < hits.length; i++) {
2308      hits[i].addEventListener('click', function (e) {
2309        var pid = e.currentTarget.getAttribute('data-pid');
2310        el.hidden = true;
2311        el.innerHTML = '';
2312        openPlayerProfile(pid);
2313      });
2314    }
2315  }
2316
2317  // Debounced at 300ms with a 2-character minimum and a stale-keystroke
2318  // guard (matching the mobile page) — kept OUTSIDE the state/render cycle so
2319  // typing never triggers a full filter-bar/list re-render and loses focus.
2320  function wireSearch(inputEl, resultsEl) {
2321    if (!inputEl || !resultsEl) return;
2322    inputEl.addEventListener('input', function () {
2323      var q = inputEl.value.trim();
2324      if (searchTimer) clearTimeout(searchTimer);
2325      if (q.length < 2) {
2326        resultsEl.hidden = true;
2327        resultsEl.innerHTML = '';
2328        return;
2329      }
2330      var seq = ++searchSeq;
2331      searchTimer = setTimeout(function () {
2332        window.TDTT.lb
2333          .search(q, S.game)
2334          .then(function (list) {
2335            if (seq !== searchSeq) return; // a newer keystroke has already fired
2336            renderSearchResults(resultsEl, list);
2337          })
2338          .catch(function (e) {
2339            if (seq !== searchSeq) return;
2340            resultsEl.hidden = false;
2341            resultsEl.innerHTML =
2342              '<div class="lb-search-empty">' +
2343              (e && e.status === 401 ? 'Sign in to search players.' : 'Search failed.') +
2344              '</div>';
2345          });
2346      }, 300);
2347    });
2348  }
2349
2350  function setFilter(key, value) {
2351    if (S[key] === value) return;
2352    S[key] = value;
2353    S.myPlaceStatus = null; // stale "unranked"/error copy would misdescribe the new filters
2354    // The rank card is per game+region (see loadMyRank()'s comment) — a sort
2355    // change doesn't affect it, but a game or region change does.
2356    if (key === 'game' || key === 'region') loadMyRank();
2357    if (key === 'game') {
2358      try {
2359        localStorage.setItem('tdtt_lb_game', value);
2360      } catch (e) {
2361        /* private mode / storage disabled — filter still works this session */
2362      }
2363      // Heroes/Drafts/Bans exist for Dice Throne only — fall back to Players
2364      // when the new game has no per-character data / hero-keyed draft-ban
2365      // tracking, the rule both existing clients follow.
2366      if ((S.view === 'heroes' || S.view === 'drafts' || S.view === 'bans') && !isHeroGame()) {
2367        S.view = 'players';
2368        syncUrl();
2369      }
2370      // Formats/Decks are the mirror image (Task 8 + Task 9) — they only
2371      // exist for non-Dice-Throne games. Bounce back to Players so we never
2372      // strand the viewer on a tab that's about to disappear.
2373      if ((S.view === 'formats' || S.view === 'decks') && isHeroGame()) {
2374        S.view = 'players';
2375        syncUrl();
2376      }
2377      // The format dropdown is per-game (each game has its own format ids —
2378      // MTG's 'standard' is not a format on Pokémon), so a stale selection
2379      // must never carry over to the new game — mirrors desktop's
2380      // handleSelectGame resetting `format` unconditionally.
2381      S.format = '';
2382      S.heroes = [];
2383      S.heroTotalGames = 0;
2384      S.heroesGame = null;
2385      S.heroesError = null;
2386      S.heroQuery = '';
2387      S.heroTrend = { window: S.heroTrend.window, loading: true, data: null };
2388      // A hero page open for the OLD game's roster can't survive a game
2389      // switch — close it the same way setView() does.
2390      S.heroSlug = null;
2391      S.heroPage = null;
2392      heroPageSeq++;
2393      heroPageWindowSeq++;
2394      S.draftsBans = {
2395        draft: null, draftState: 'loading',
2396        heroes: null, heroesState: 'loading',
2397        bans: null, bansState: 'loading',
2398        rosterSize: null,
2399        sort: S.draftsBans.sort,
2400        query: S.draftsBans.query,
2401      };
2402      // Deck archetype buckets and format ids are both per-game (Task 9) —
2403      // a favourite/open deck from the OLD game can't survive a switch, same
2404      // reasoning as the hero page close above. `game: null` (rather than the
2405      // new game id) forces loadDecksGridIfStale() to treat this as never
2406      // having been fetched for the new game, so it always refetches.
2407      S.deckKey = null;
2408      S.deckPage.token++;
2409      S.decksTab.format = '';
2410      S.decksTab.decks = [];
2411      S.decksTab.totals = null;
2412      S.decksTab.game = null;
2413      S.decksTab.gridState = 'loading';
2414      if (S.view === 'heroes') {
2415        // The player list is off screen; mark it stale rather than spending a
2416        // request on rows nobody is looking at (setView reloads it on return).
2417        S.playersDirty = true;
2418        S.rows = [];
2419        S.offset = 0;
2420        render();
2421        loadHeroes();
2422        loadHeroTrend();
2423        return;
2424      }
2425      if (S.view === 'drafts') {
2426        S.playersDirty = true;
2427        S.rows = [];
2428        S.offset = 0;
2429        render();
2430        loadDraftsData();
2431        return;
2432      }
2433      if (S.view === 'bans') {
2434        S.playersDirty = true;
2435        S.rows = [];
2436        S.offset = 0;
2437        render();
2438        loadBansData();
2439        return;
2440      }
2441      if (S.view === 'formats') {
2442        S.playersDirty = true;
2443        S.rows = [];
2444        S.offset = 0;
2445        render();
2446        loadFormatsTab(S.formatsTab.window);
2447        return;
2448      }
2449      if (S.view === 'decks') {
2450        S.playersDirty = true;
2451        S.rows = [];
2452        S.offset = 0;
2453        render();
2454        loadTcgRailIfStale();
2455        loadDecksGrid();
2456        return;
2457      }
2458    }
2459    if (S.view === 'players' && S.scope === 'ranked' && showsTcgFeatures()) loadTcgRailIfStale();
2460    load(true);
2461  }
2462
2463  // Delegated click handler for hero cards — the Heroes landing grid, and any
2464  // future grid Task 11 renders inside `scope` (drafts/bans hero rows use
2465  // their own wiring in lb-drafts-bans.js, not this). Scoped to `[data-hero-
2466  // slug]` so it never fires for an unrelated tile that happens to share a
2467  // parent.
2468  function wireHeroGridTriggers(scope) {
2469    var tiles = scope.querySelectorAll('[data-hero-slug]');
2470    for (var i = 0; i < tiles.length; i++) {
2471      tiles[i].addEventListener('click', function (e) {
2472        openHeroPage(e.currentTarget.getAttribute('data-hero-slug'));
2473      });
2474    }
2475  }
2476
2477  // Calls into Task 10 (window.TDTT.lbHeroPage) / Task 11
2478  // (window.TDTT.lbDraftsBans) to wire the body THIS render() just painted —
2479  // guarded because neither module exists until its own task ships (see this
2480  // file's top-of-file comment). Must run AFTER root.innerHTML is set, same
2481  // as every other wiring call in this function.
2482  function wireDelegatedModules() {
2483    if (S.view === 'heroes' && S.heroSlug) {
2484      var heroMod = window.TDTT && window.TDTT.lbHeroPage;
2485      if (heroMod && typeof heroMod.wire === 'function') heroMod.wire(root, buildHeroPageCtx());
2486      return;
2487    }
2488    if (S.view === 'drafts' || S.view === 'bans') {
2489      var dbMod = window.TDTT && window.TDTT.lbDraftsBans;
2490      if (dbMod && typeof dbMod.wire === 'function') dbMod.wire(root, buildDraftsBansCtx());
2491      return;
2492    }
2493    if (S.view === 'decks') {
2494      var decksMod = window.TDTT && window.TDTT.lbDecksView;
2495      if (decksMod && typeof decksMod.wire === 'function') {
2496        decksMod.wire(root, S.deckKey ? buildDeckPageCtx() : buildDecksListCtx());
2497      }
2498    }
2499  }
2500
2501  function wireEvents() {
2502    if (!root) return;
2503
2504    wireAvatarFallback(root);
2505
2506    var viewRow = root.querySelector('#lb-views');
2507    if (viewRow) {
2508      var viewSegs = viewRow.querySelectorAll('.t');
2509      for (var v = 0; v < viewSegs.length; v++) {
2510        viewSegs[v].addEventListener('click', function (e) {
2511          setView(e.currentTarget.getAttribute('data-view'));
2512        });
2513      }
2514    }
2515
2516    var hsortRow = root.querySelector('#lb-hsorts');
2517    if (hsortRow) {
2518      var hsortChips = hsortRow.querySelectorAll('.chip');
2519      for (var h = 0; h < hsortChips.length; h++) {
2520        hsortChips[h].addEventListener('click', function (e) {
2521          var key = e.currentTarget.getAttribute('data-hsort');
2522          if (S.heroSort !== key) setState({ heroSort: key });
2523        });
2524      }
2525    }
2526
2527    // Hero search — kept OUTSIDE setState()/render() on every keystroke, same
2528    // reasoning as wireSearch() above: a full innerHTML re-render on every
2529    // character typed would steal focus from the input mid-word.
2530    var heroSearchInput = root.querySelector('#lb-hero-search');
2531    if (heroSearchInput) {
2532      heroSearchInput.addEventListener('input', function () {
2533        S.heroQuery = heroSearchInput.value;
2534        var gridWrap = root.querySelector('#lb-hero-grid');
2535        var countEl = root.querySelector('.lb-heroes-count');
2536        if (!gridWrap) return;
2537        var filtered = S.heroes.filter(function (h) {
2538          return window.TDTT.lb.matchesHeroQuery(h.characterName, S.heroQuery);
2539        });
2540        var sorted = window.TDTT.lb.sortHeroes(filtered, S.heroSort);
2541        var maxPicks = 0;
2542        for (var mp = 0; mp < S.heroes.length; mp++) {
2543          if (S.heroes[mp].picks > maxPicks) maxPicks = S.heroes[mp].picks;
2544        }
2545        var cards = '';
2546        for (var ci = 0; ci < sorted.length; ci++) {
2547          cards += heroCardHtml(sorted[ci], ci + 1, maxPicks);
2548        }
2549        gridWrap.innerHTML = cards;
2550        wireHeroGridTriggers(gridWrap);
2551        if (countEl) {
2552          countEl.textContent = 'Showing ' + sorted.length + ' of ' + S.heroes.length + ' heroes';
2553        }
2554      });
2555    }
2556
2557    wireHeroGridTriggers(root);
2558
2559    var heroRetry = root.querySelector('#lb-hero-retry');
2560    if (heroRetry) {
2561      heroRetry.addEventListener('click', function () {
2562        loadHeroes();
2563      });
2564    }
2565
2566    var trendWindowRow = root.querySelector('#lb-hero-trend-window');
2567    if (trendWindowRow) {
2568      var trendBtns = trendWindowRow.querySelectorAll('.t');
2569      for (var tw = 0; tw < trendBtns.length; tw++) {
2570        trendBtns[tw].addEventListener('click', function (e) {
2571          setHeroTrendWindow(e.currentTarget.getAttribute('data-trend-window'));
2572        });
2573      }
2574    }
2575
2576    wireDelegatedModules();
2577
2578    var gameRail = root.querySelector('[data-game-rail]');
2579    if (gameRail) {
2580      var gameChips = gameRail.querySelectorAll('[data-game]');
2581      for (var i = 0; i < gameChips.length; i++) {
2582        gameChips[i].addEventListener('click', function (e) {
2583          setFilter('game', e.currentTarget.getAttribute('data-game'));
2584        });
2585      }
2586    }
2587
2588    // Scope now lives on the banner as .chip buttons, not a .seg-slab — see
2589    // bannerHtml(). Same id, different child selector.
2590    var scopeRow = root.querySelector('#lb-scopes');
2591    if (scopeRow) {
2592      var scopeChipsEls = scopeRow.querySelectorAll('.chip');
2593      for (var j = 0; j < scopeChipsEls.length; j++) {
2594        scopeChipsEls[j].addEventListener('click', function (e) {
2595          setFilter('scope', e.currentTarget.getAttribute('data-scope'));
2596        });
2597      }
2598    }
2599
2600    var sortRow = root.querySelector('#lb-sorts');
2601    if (sortRow) {
2602      var sortChips = sortRow.querySelectorAll('.chip');
2603      for (var k = 0; k < sortChips.length; k++) {
2604        sortChips[k].addEventListener('click', function (e) {
2605          setFilter('sort', e.currentTarget.getAttribute('data-sort'));
2606        });
2607      }
2608    }
2609
2610    var regionSel = root.querySelector('#lb-region');
2611    if (regionSel) {
2612      regionSel.addEventListener('change', function () {
2613        setFilter('region', regionSel.value || null);
2614      });
2615    }
2616
2617    // Task 8 — format select (Players tab).
2618    var formatSel = root.querySelector('#lb-format');
2619    if (formatSel) {
2620      formatSel.addEventListener('change', function () {
2621        setFilter('format', formatSel.value || '');
2622      });
2623    }
2624
2625    // Task 8 — a Favorite Deck cell OR a Top Archetypes rail row click. Both
2626    // lb-tcg-rail.js and this file's own favoriteDeckCellHtml() mark their
2627    // clickable element `[data-deck-key]`; neither module has its own
2628    // `wire()`, so every click is delegated here generically, same technique
2629    // as wireHeroGridTriggers()'s `[data-hero-slug]`.
2630    var deckKeyEls = root.querySelectorAll('[data-deck-key]');
2631    for (var dk = 0; dk < deckKeyEls.length; dk++) {
2632      deckKeyEls[dk].addEventListener('click', function (e) {
2633        openTcgDeck(e.currentTarget.getAttribute('data-deck-key'));
2634      });
2635    }
2636
2637    // Task 8 — a format card's "View Leaderboard" click (Formats tab body,
2638    // window.TDTT.lbFormats — no `wire()` of its own, same delegation as above).
2639    var viewFormatEls = root.querySelectorAll('[data-view-format]');
2640    for (var vf = 0; vf < viewFormatEls.length; vf++) {
2641      viewFormatEls[vf].addEventListener('click', function (e) {
2642        onSelectFormatFromFormats(e.currentTarget.getAttribute('data-view-format'));
2643      });
2644    }
2645
2646    // Task 8 — the Formats rail's own window seg-tabs (7D/30D/90D/ALL).
2647    var formatsWindowRow = root.querySelector('#lb-formats-window');
2648    if (formatsWindowRow) {
2649      var formatsWindowBtns = formatsWindowRow.querySelectorAll('.t');
2650      for (var fw = 0; fw < formatsWindowBtns.length; fw++) {
2651        formatsWindowBtns[fw].addEventListener('click', function (e) {
2652          onFormatsWindow(e.currentTarget.getAttribute('data-formats-window'));
2653        });
2654      }
2655    }
2656
2657    wireSearch(root.querySelector('#lb-search'), root.querySelector('#lb-search-results'));
2658
2659    var myPlaceBtn = root.querySelector('#lb-myplace');
2660    if (myPlaceBtn) {
2661      myPlaceBtn.addEventListener('click', function () {
2662        showMyPlace();
2663      });
2664    }
2665
2666    var pagerEl = root.querySelector('.tdtt-pager');
2667    if (pagerEl) {
2668      var pageBtns = pagerEl.querySelectorAll('.tdtt-pager__page');
2669      for (var pg = 0; pg < pageBtns.length; pg++) {
2670        pageBtns[pg].addEventListener('click', function (e) {
2671          if (e.currentTarget.disabled) return;
2672          goToPage(parseInt(e.currentTarget.getAttribute('data-page'), 10));
2673        });
2674      }
2675    }
2676
2677    var retryBtn = root.querySelector('#lb-retry');
2678    if (retryBtn) {
2679      retryBtn.addEventListener('click', function () {
2680        load(false);
2681      });
2682    }
2683
2684    wirePlayerOpenTriggers(root);
2685
2686    var overlay = root.querySelector('#lb-profile-overlay');
2687    if (overlay) {
2688      overlay.addEventListener('click', function (e) {
2689        if (e.target === overlay) closePlayerProfile(); // backdrop only, not the panel itself
2690      });
2691    }
2692
2693    var profileViewFull = root.querySelector('#lb-profile-viewfull');
2694    if (profileViewFull) {
2695      profileViewFull.addEventListener('click', function () {
2696        viewFullProfile(profileViewFull.getAttribute('data-pid'));
2697      });
2698    }
2699
2700    var profileClose = root.querySelector('#lb-profile-close');
2701    if (profileClose) {
2702      profileClose.addEventListener('click', function () {
2703        closePlayerProfile();
2704      });
2705    }
2706
2707    var profileRetry = root.querySelector('#lb-profile-retry');
2708    if (profileRetry) {
2709      profileRetry.addEventListener('click', function () {
2710        openPlayerProfile(S.openPlayerId);
2711      });
2712    }
2713
2714    var gameHeads = root.querySelectorAll('.lb-profile-game-head');
2715    for (var m = 0; m < gameHeads.length; m++) {
2716      gameHeads[m].addEventListener('click', function (e) {
2717        var gid = e.currentTarget.getAttribute('data-game');
2718        setState({ profileOpenGame: S.profileOpenGame === gid ? null : gid });
2719      });
2720    }
2721  }
2722
2723  function loadRegions() {
2724    window.TDTT.lb
2725      .regions()
2726      .then(function (data) {
2727        if (Array.isArray(data)) {
2728          regionGroups = data;
2729          render();
2730        }
2731      })
2732      .catch(function () {
2733        /* region filter degrades to "All Regions" only — not fatal */
2734      });
2735  }
2736
2737  /* Re-fetch and re-render. `reset` returns to page 1 — every filter change
2738   * must pass true, or the pager keeps showing whatever page the previous
2739   * filter's results left it on. Always REPLACES S.rows (never concatenates):
2740   * navigation is page-based via the .tdtt-pager buttons (goToPage), not an
2741   * accumulating "Load more" list. */
2742  function load(reset) {
2743    if (reset) {
2744      S.offset = 0;
2745      S.rows = [];
2746    }
2747    setState({ loading: true, error: null });
2748    var mine = ++loadSeq;
2749    return window.TDTT.lb
2750      .players({
2751        game: S.game,
2752        scope: S.scope,
2753        region: S.region,
2754        sort: S.sort,
2755        limit: S.limit,
2756        offset: S.offset,
2757        paged: true,
2758        // In-format ranking (Task 8) is a ranked-scope-only mode server-side
2759        // — never sent for the tournament/throne-room scopes, same gate
2760        // currentFormatMeta() applies.
2761        format: S.scope === 'ranked' ? S.format : undefined,
2762      })
2763      .then(function (r) {
2764        if (mine !== loadSeq) return; // stale — superseded by a newer load (e.g. after a remount)
2765        setState({
2766          rows: r.rows,
2767          total: r.total,
2768          totals: r.totals,
2769          loading: false,
2770        });
2771      })
2772      .catch(function (e) {
2773        if (mine !== loadSeq) return;
2774        var msg =
2775          e && e.status === 401 ? 'Sign in to view this leaderboard.' : (e && e.message) || 'Failed to load';
2776        setState({ loading: false, error: msg });
2777      });
2778  }
2779
2780  // ── TCG Players-tab rail + Formats tab (Task 8) ─────────────────────────────
2781  // leaderboard.js owns every fetch these two delegated modules need and
2782  // builds the `ctx` objects they render from — same delegation seam as the
2783  // hero page / Drafts-Bans above. Neither module has a `wire()` — every
2784  // click inside their markup is wired generically in wireEvents() via
2785  // data-* attributes (`[data-deck-key]`, `[data-view-format]`,
2786  // `[data-formats-window]`), the same delegated-click technique
2787  // wireHeroGridTriggers()/wirePlayerOpenTriggers() already use.
2788
2789  // window.TDTT.lbTcgRail (lb-tcg-rail.js) renders from this — ALWAYS the
2790  // all-time, all-format picture (window 'all'), independent of S.format/
2791  // S.offset, per the brief.
2792  function loadTcgRail() {
2793    var game = S.game;
2794    var seq = ++tcgRailSeq;
2795    S.tcgRail.game = game;
2796    S.tcgRail.decksState = 'loading';
2797    S.tcgRail.tournamentsState = 'loading';
2798    render();
2799    window.TDTT.lb
2800      .decks(game, 'all')
2801      .then(function (data) {
2802        if (seq !== tcgRailSeq) return;
2803        if (data && Array.isArray(data.decks)) {
2804          S.tcgRail.decks = data.decks;
2805          S.tcgRail.decksState = 'ready';
2806        } else {
2807          S.tcgRail.decksState = 'error';
2808        }
2809        render();
2810      })
2811      .catch(function () {
2812        if (seq !== tcgRailSeq) return;
2813        S.tcgRail.decksState = 'error';
2814        render();
2815      });
2816    window.TDTT.lb
2817      .recentTournaments(game)
2818      .then(function (data) {
2819        if (seq !== tcgRailSeq) return;
2820        if (data && Array.isArray(data.tournaments)) {
2821          S.tcgRail.tournaments = data.tournaments;
2822          S.tcgRail.tournamentsState = 'ready';
2823        } else {
2824          S.tcgRail.tournamentsState = 'error';
2825        }
2826        render();
2827      })
2828      .catch(function () {
2829        if (seq !== tcgRailSeq) return;
2830        S.tcgRail.tournamentsState = 'error';
2831        render();
2832      });
2833    window.TDTT.lb
2834      .formats(game, 'all')
2835      .then(function (data) {
2836        if (seq !== tcgRailSeq) return;
2837        S.tcgRail.formats = data && Array.isArray(data.formats) ? data.formats : undefined;
2838        render();
2839      })
2840      .catch(function () {
2841        if (seq !== tcgRailSeq) return;
2842        S.tcgRail.formats = undefined;
2843        render();
2844      });
2845  }
2846
2847  // Re-entering Players (ranked, a TCG game) for the SAME game the rail was
2848  // already fetched for is a no-op — same "if stale" convention as
2849  // loadHeroTrendIfStale().
2850  function loadTcgRailIfStale() {
2851    if (S.tcgRail.game !== S.game) loadTcgRail();
2852  }
2853
2854  // A Favorite Deck cell / Top Archetypes row click, when `key` is set — also
2855  // every deck-key trigger INSIDE the Decks tab itself (a grid card, a
2856  // matchup row, a public recent-game opponent), all delegated through the
2857  // same generic `[data-deck-key]` handler in wireEvents(). Switches the
2858  // whole view to Decks and opens that archetype's page, deep-linked via
2859  // `?deck=<key>` — mirrors desktop's handleOpenDeck (Task 9).
2860  function openTcgDeck(key) {
2861    if (!key) return;
2862    S.view = 'decks';
2863    S.deckKey = key;
2864    S.deckPage.detailState = 'loading';
2865    S.deckPage.detail = null;
2866    S.deckPage.matchupsState = 'loading';
2867    S.deckPage.matchups = null;
2868    S.deckPage.gamesState = 'loading';
2869    S.deckPage.games = null;
2870    syncUrl();
2871    render();
2872    loadDeckPage(key);
2873  }
2874
2875  function buildTcgRailCtx() {
2876    return {
2877      game: S.game,
2878      gameName: window.TDTT.game.gameLabel(S.game),
2879      decksState: S.tcgRail.decksState,
2880      decks: S.tcgRail.decks,
2881      tournamentsState: S.tcgRail.tournamentsState,
2882      tournaments: S.tcgRail.tournaments,
2883      formats: S.tcgRail.formats,
2884    };
2885  }
2886
2887  // Guards for lb-tcg-rail.js not being loaded yet, same convention as the
2888  // Task 10/11 delegation below.
2889  function tcgRailAsideHtml() {
2890    var mod = window.TDTT && window.TDTT.lbTcgRail;
2891    var html = mod && typeof mod.html === 'function' ? mod.html(buildTcgRailCtx()) : '';
2892    return '<aside class="lb-tcg-aside">' + html + '</aside>';
2893  }
2894
2895  // The Players view for a TCG game (ranked scope) gets a two-column layout
2896  // — the ranked table beside the insights rail — same shape as the Heroes
2897  // landing grid + its aside (`.lb-tcg-layout`/`.lb-tcg-main`/`.lb-tcg-aside`
2898  // mirror `.lb-heroes-layout`/`.lb-heroes-main`/`.lb-heroes-aside`). Every
2899  // other Players case (Dice Throne, or a non-ranked scope) renders exactly
2900  // as before — single column, no rail.
2901  function playersBodyHtml() {
2902    if (showsTcgFeatures() && S.scope === 'ranked') {
2903      return (
2904        '<div class="lb-tcg-layout"><div class="lb-tcg-main">' + listHtml() + '</div>' +
2905        tcgRailAsideHtml() + '</div>'
2906      );
2907    }
2908    return listHtml();
2909  }
2910
2911  // ── Formats tab (Task 8) ─────────────────────────────────────────────────
2912  function loadFormatsTab(w) {
2913    var game = S.game;
2914    var win = w || S.formatsTab.window;
2915    var seq = ++formatsSeq;
2916    S.formatsTab.game = game;
2917    S.formatsTab.window = win;
2918    S.formatsTab.state = 'loading';
2919    render();
2920    window.TDTT.lb
2921      .formats(game, win)
2922      .then(function (payload) {
2923        if (seq !== formatsSeq) return;
2924        S.formatsTab.data = payload && Array.isArray(payload.formats) ? payload.formats : [];
2925        S.formatsTab.state = 'ready';
2926        render();
2927      })
2928      .catch(function () {
2929        if (seq !== formatsSeq) return;
2930        S.formatsTab.state = 'error';
2931        render();
2932      });
2933  }
2934
2935  // Re-entering the Formats tab for the SAME game is a no-op — same
2936  // "if stale" convention as loadTcgRailIfStale()/loadHeroTrendIfStale().
2937  function loadFormatsTabIfStale() {
2938    if (S.formatsTab.game !== S.game) loadFormatsTab(S.formatsTab.window);
2939  }
2940
2941  function onFormatsWindow(w) {
2942    if (S.formatsTab.window === w) return;
2943    loadFormatsTab(w);
2944  }
2945
2946  // A format card's "View Leaderboard" click — switches the Players tab to
2947  // this format, mirroring desktop's FormatsView onSelectFormat prop.
2948  function onSelectFormatFromFormats(formatId) {
2949    S.format = formatId;
2950    setView('players');
2951  }
2952
2953  function buildFormatsCtx() {
2954    var meta = window.TDTT.game.TABLE[S.game];
2955    return {
2956      game: S.game,
2957      windowVal: S.formatsTab.window,
2958      state: S.formatsTab.state,
2959      formats: S.formatsTab.data,
2960      art: meta ? '/m/logos/' + meta.logoFile : '',
2961    };
2962  }
2963
2964  function formatsBodyHtml() {
2965    var mod = window.TDTT && window.TDTT.lbFormats;
2966    if (!mod || typeof mod.render !== 'function') return loadingHtml();
2967    return mod.render(buildFormatsCtx());
2968  }
2969
2970  // ── Decks tab + archetype detail page (Task 9) ──────────────────────────
2971  // window.TDTT.lbDecksView (lb-decks.js) renders/wires from the ctx builders
2972  // below; this file owns every fetch (decks(), deckDetail(), deckMatchups(),
2973  // deckGames()). Every fetch is guarded by acceptResponse()/S.decksTab.token
2974  // or S.deckPage.token so a late response for a game/window/format/key
2975  // that's since changed is ignored (Review Focus 5).
2976
2977  function loadDecksGrid() {
2978    var game = S.game;
2979    var win = S.decksTab.window;
2980    var fmt = S.decksTab.format;
2981    var mine = ++S.decksTab.token;
2982    S.decksTab.gridState = 'loading';
2983    render();
2984    window.TDTT.lb
2985      .decks(game, win, fmt || undefined)
2986      .then(function (data) {
2987        if (!acceptResponse(S.decksTab, mine)) return;
2988        if (data && Array.isArray(data.decks) && data.totals) {
2989          S.decksTab.decks = data.decks;
2990          S.decksTab.totals = data.totals;
2991          S.decksTab.gridState = 'ready';
2992        } else {
2993          S.decksTab.gridState = 'error';
2994        }
2995        S.decksTab.game = game;
2996        S.decksTab.fetchedWindow = win;
2997        S.decksTab.fetchedFormat = fmt;
2998        render();
2999      })
3000      .catch(function () {
3001        if (!acceptResponse(S.decksTab, mine)) return;
3002        S.decksTab.gridState = 'error';
3003        S.decksTab.game = game;
3004        S.decksTab.fetchedWindow = win;
3005        S.decksTab.fetchedFormat = fmt;
3006        render();
3007      });
3008  }
3009
3010  // Re-entering the Decks tab (or closing the archetype page) when the grid
3011  // already carries data for the CURRENT game/window/format is a no-op —
3012  // same "if stale" convention as loadTcgRailIfStale()/loadFormatsTabIfStale().
3013  function loadDecksGridIfStale() {
3014    if (
3015      S.decksTab.game !== S.game ||
3016      S.decksTab.fetchedWindow !== S.decksTab.window ||
3017      S.decksTab.fetchedFormat !== S.decksTab.format
3018    ) {
3019      loadDecksGrid();
3020    }
3021  }
3022
3023  // Shared by the grid AND the archetype page (batch ruling: one window
3024  // state above both). Only the currently-visible half refetches — the other
3025  // picks up the new window value next time it becomes stale.
3026  function onDecksWindow(w) {
3027    if (S.decksTab.window === w) return;
3028    S.decksTab.window = w;
3029    if (S.deckKey) {
3030      loadDeckPage(S.deckKey);
3031    } else {
3032      loadDecksGrid();
3033    }
3034  }
3035
3036  function onDecksFormat(fmt) {
3037    if (S.decksTab.format === fmt) return;
3038    S.decksTab.format = fmt;
3039    loadDecksGrid();
3040  }
3041
3042  // Sort/search are purely client-side (window.TDTT.lbDecks.sortDecks/
3043  // matchesDeckQuery over the already-fetched S.decksTab.decks) — neither
3044  // triggers a fetch. setDecksSort() re-renders (a button click, no focus to
3045  // lose); setDecksQuery() deliberately does NOT call render() — lb-decks.js's
3046  // own wire() patches the grid locally on every keystroke to avoid stealing
3047  // input focus, and only persists the value here for later window/format
3048  // changes to read.
3049  function setDecksSort(k) {
3050    if (S.decksTab.sort === k) return;
3051    S.decksTab.sort = k;
3052    render();
3053  }
3054
3055  function setDecksQuery(q) {
3056    S.decksTab.query = q || '';
3057  }
3058
3059  function buildDecksListCtx() {
3060    return {
3061      game: S.game,
3062      gameName: window.TDTT.game.gameLabel(S.game),
3063      window: S.decksTab.window,
3064      format: S.decksTab.format,
3065      formats: window.TDTT.decks ? window.TDTT.decks.deckFormatOptions(S.game) : [],
3066      sort: S.decksTab.sort,
3067      query: S.decksTab.query,
3068      gridState: S.decksTab.gridState,
3069      decks: S.decksTab.decks,
3070      totals: S.decksTab.totals,
3071      railHtml: tcgRailAsideHtml(),
3072      onWindow: onDecksWindow,
3073      onFormat: onDecksFormat,
3074      onSort: setDecksSort,
3075      onQuery: setDecksQuery,
3076      onSelectDeck: openTcgDeck,
3077    };
3078  }
3079
3080  // The three-fetch chain for one archetype: detail first (its 404/notFound
3081  // gates the other two — an archetype that isn't public has no matchups or
3082  // recent games to show either, and firing those anyway would just race a
3083  // 404 of their own for no benefit, mirrors desktop's DeckArchetypePage),
3084  // then matchups + recent games in parallel once it resolves 'ready'.
3085  function loadDeckPage(key) {
3086    var game = S.game;
3087    var win = S.decksTab.window;
3088    var mine = ++S.deckPage.token;
3089    S.deckPage.detailState = 'loading';
3090    S.deckPage.detail = null;
3091    S.deckPage.matchupsState = 'loading';
3092    S.deckPage.matchups = null;
3093    S.deckPage.gamesState = 'loading';
3094    S.deckPage.games = null;
3095    render();
3096    window.TDTT.lb
3097      .deckDetail(game, key, win)
3098      .then(function (data) {
3099        if (!acceptResponse(S.deckPage, mine)) return;
3100        S.deckPage.detail = data;
3101        S.deckPage.detailState = 'ready';
3102        render();
3103
3104        window.TDTT.lb
3105          .deckMatchups(game, key, win)
3106          .then(function (list) {
3107            if (!acceptResponse(S.deckPage, mine)) return;
3108            S.deckPage.matchups = Array.isArray(list) ? list : [];
3109            S.deckPage.matchupsState = 'ready';
3110            render();
3111          })
3112          .catch(function () {
3113            if (!acceptResponse(S.deckPage, mine)) return;
3114            S.deckPage.matchupsState = 'error';
3115            render();
3116          });
3117
3118        window.TDTT.lb
3119          .deckGames(game, key, win)
3120          .then(function (payload) {
3121            if (!acceptResponse(S.deckPage, mine)) return;
3122            S.deckPage.games = (payload && Array.isArray(payload.games)) ? payload.games : [];
3123            S.deckPage.gamesState = 'ready';
3124            render();
3125          })
3126          .catch(function () {
3127            if (!acceptResponse(S.deckPage, mine)) return;
3128            S.deckPage.gamesState = 'error';
3129            render();
3130          });
3131      })
3132      .catch(function (e) {
3133        if (!acceptResponse(S.deckPage, mine)) return;
3134        S.deckPage.detailState = e && e.status === 404 ? 'notFound' : 'error';
3135        render();
3136      });
3137  }
3138
3139  function buildDeckPageCtx() {
3140    var dp = S.deckPage;
3141    return {
3142      game: S.game,
3143      window: S.decksTab.window,
3144      detailState: dp.detailState,
3145      detail: dp.detail,
3146      matchupsState: dp.matchupsState,
3147      matchups: dp.matchups,
3148      gamesState: dp.gamesState,
3149      games: dp.games,
3150      avatarHtml: avatarHtml,
3151      onWindow: onDecksWindow,
3152      onLeaderboards: function () {
3153        setView('players');
3154      },
3155      onBack: closeDeckPage,
3156      onOpenPlayer: viewFullProfile,
3157    };
3158  }
3159
3160  // "Decks" breadcrumb / a page's Back button — closes the archetype page and
3161  // returns to the grid, refetching only if the grid's own game/window/format
3162  // has since gone stale (e.g. the window was changed while the page was
3163  // open).
3164  function closeDeckPage() {
3165    S.deckKey = null;
3166    S.deckPage.token++;
3167    syncUrl();
3168    render();
3169    loadDecksGridIfStale();
3170  }
3171
3172  function decksBodyHtml() {
3173    var mod = window.TDTT && window.TDTT.lbDecksView;
3174    if (!mod) return loadingHtml();
3175    if (S.deckKey) {
3176      return typeof mod.pageHtml === 'function' ? mod.pageHtml(buildDeckPageCtx()) : loadingHtml();
3177    }
3178    return typeof mod.listHtml === 'function' ? mod.listHtml(buildDecksListCtx()) : loadingHtml();
3179  }
3180
3181  // Guards for the Task 10/11 modules not being loaded yet — see this file's
3182  // top-of-file comment. A missing module renders loadingHtml() rather than
3183  // throwing, so this file works standalone before either task ships.
3184  function heroPageBodyHtml() {
3185    var mod = window.TDTT && window.TDTT.lbHeroPage;
3186    if (!mod || typeof mod.html !== 'function') return loadingHtml();
3187    return mod.html(buildHeroPageCtx());
3188  }
3189
3190  function draftsBodyHtml() {
3191    var mod = window.TDTT && window.TDTT.lbDraftsBans;
3192    if (!mod || typeof mod.draftsHtml !== 'function') return loadingHtml();
3193    return mod.draftsHtml(buildDraftsBansCtx());
3194  }
3195
3196  function bansBodyHtml() {
3197    var mod = window.TDTT && window.TDTT.lbDraftsBans;
3198    if (!mod || typeof mod.bansHtml !== 'function') return loadingHtml();
3199    return mod.bansHtml(buildDraftsBansCtx());
3200  }
3201
3202  function bodyHtml() {
3203    if (S.view === 'heroes') return S.heroSlug ? heroPageBodyHtml() : heroesLandingHtml();
3204    if (S.view === 'drafts') return draftsBodyHtml();
3205    if (S.view === 'bans') return bansBodyHtml();
3206    if (S.view === 'formats') return formatsBodyHtml();
3207    if (S.view === 'decks') return decksBodyHtml();
3208    return playersBodyHtml();
3209  }
3210
3211  // E4.1: this section now renders ONLY its main column (no `.tdtt-aside`
3212  // sibling here anymore — see rankInfoRowHtml()'s comment above). The right
3213  // rail is shell chrome, rendered once by app-shell.js outside
3214  // #app-content, identical on every page.
3215  // Drop-in for `root.innerHTML = html` that keeps the existing banner node
3216  // (see stable-banner.js); plain assignment if that script is absent.
3217  function setRootHtml(html) {
3218    if (window.TDTT && window.TDTT.stableBanner) window.TDTT.stableBanner.setHtml(root, html);
3219    else root.innerHTML = html;
3220  }
3221
3222  function render() {
3223    if (!root) return;
3224    setRootHtml(
3225      '<div class="lb-main">' +
3226      bannerHtml() + summaryStatsHtml() + rankInfoRowHtml() + filterBarHtml() + bodyHtml() +
3227      '</div>' +
3228      profilePanelHtml()
3229    );
3230    wireEvents();
3231    // The rebuild above discarded whatever the game rail was wired to —
3232    // destroy that handle before wiring the fresh one.
3233    if (railWire) railWire.destroy();
3234    railWire = window.TDTT && window.TDTT.gameRail ? window.TDTT.gameRail.wire(root) : null;
3235    if (window.TDTT && window.TDTT.bannerFx) window.TDTT.bannerFx.refresh();
3236  }
3237
3238  window.TDTTLeaderboard = {
3239    mount: function (el) {
3240      root = el;
3241      // Step 1 of the brief: read `?view=`/`?hero=` before the first render,
3242      // exactly the try/catch shape profile.js's viewedPlayerId() uses (see
3243      // readUrlState()) — a malformed location.search degrades to Players
3244      // rather than throwing.
3245      applyInitialUrlState();
3246      loadRegions();
3247      loadMyRank();
3248      if (S.view === 'players') {
3249        load(true);
3250        if (S.scope === 'ranked' && showsTcgFeatures()) loadTcgRailIfStale();
3251      } else {
3252        // The player list is off screen for every other initial view — mark
3253        // it stale rather than spending a request on rows nobody is looking
3254        // at (setView()/onPopState() reload it on return to Players).
3255        S.playersDirty = true;
3256        S.rows = [];
3257        S.offset = 0;
3258        render();
3259        if (S.view === 'heroes') {
3260          if (S.heroSlug) {
3261            fetchHeroPage(S.heroSlug);
3262          } else {
3263            loadHeroes();
3264            loadHeroTrend();
3265          }
3266        } else if (S.view === 'drafts') {
3267          loadDraftsData();
3268        } else if (S.view === 'bans') {
3269          loadBansData();
3270        } else if (S.view === 'formats') {
3271          loadFormatsTab(S.formatsTab.window);
3272        } else if (S.view === 'decks') {
3273          loadTcgRailIfStale();
3274          if (S.deckKey) {
3275            loadDeckPage(S.deckKey);
3276          } else {
3277            loadDecksGridIfStale();
3278          }
3279        }
3280      }
3281      // Outside #app-content, so unmount() MUST remove this — same reasoning
3282      // as profile.js's onTabPopState (router.js's own popstate listener only
3283      // re-renders when the SECTION changes, never for an in-section view
3284      // change like this one).
3285      window.addEventListener('popstate', onPopState);
3286      // Wired once ever (guarded), rather than in wireEvents() (which reruns
3287      // on every render) or unconditionally here. mount() runs again every
3288      // time the router returns to this section, so without the guard each
3289      // visit would stack another document-level Escape handler.
3290      if (!escWired) {
3291        escWired = true;
3292        document.addEventListener('keydown', function (e) {
3293          if (e.key === 'Escape' && S.openPlayerId) closePlayerProfile();
3294        });
3295      }
3296    },
3297    // The router empties the container itself; this drops the state that would
3298    // otherwise be reused by the next mount, and invalidates every in-flight
3299    // response so a slow fetch cannot paint into a section the user has left.
3300    unmount: function () {
3301      searchSeq++;
3302      profileSeq++;
3303      loadSeq++;
3304      myRankSeq++;
3305      heroPageSeq++;
3306      heroPageWindowSeq++;
3307      heroTrendSeq++;
3308      draftsBansSeq++;
3309      tcgRailSeq++;
3310      formatsSeq++;
3311      S.decksTab.token++;
3312      S.deckPage.token++;
3313      if (searchTimer) {
3314        clearTimeout(searchTimer);
3315        searchTimer = null;
3316      }
3317      if (railWire) {
3318        railWire.destroy();
3319        railWire = null;
3320      }
3321      window.removeEventListener('popstate', onPopState);
3322      root = null;
3323      S.openPlayerId = null;
3324      S.profile = null;
3325      S.heroSlug = null;
3326      S.heroPage = null;
3327      S.deckKey = null;
3328      S.myPlaceStatus = null;
3329      S.myRank = null;
3330    },
3331    _state: S, // read-only handle for browser checks
3332  };
3333
3334  window.TDTT = window.TDTT || {};
3335  window.TDTT.appSections = window.TDTT.appSections || {};
3336  window.TDTT.appSections.leaderboard = {
3337    mount: window.TDTTLeaderboard.mount,
3338    unmount: window.TDTTLeaderboard.unmount,
3339  };
3340})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.