PageSourceSearch

https://topdowntabletop.com/app/lb-decks.js

js topdowntabletop.com collected 2026-10-08 09:16:18 UTC 26,247 bytes, 595 lines download raw bytes

1/* /app Decks tab + deck archetype detail page — window.TDTT.lbDecksView
2 * (Task 9, TCG Decks B3).
3 *
4 * R1 (batch ruling): the shared PURE core twin generated from
5 * packages/shared/src/leaderboard/decks.ts already owns the name
6 * `window.TDTT.lbDecks` (leaderboard-decks-core.js — DECK_SORT_KEYS,
7 * sortDecks, matchesDeckQuery, shareDelta, deckInsights, topArchetypes,
8 * metaNotes, ...). This RENDER module is therefore `window.TDTT.lbDecksView`
9 * instead, matching the plan's original two-function shape
10 * `{ listHtml(ctx), pageHtml(ctx) }` plus a `wire(root, ctx)` this file adds
11 * for the same reason lb-hero-page.js has one (window/sort seg-tabs, a
12 * format select, a live search box and click-through breadcrumbs/rows — none
13 * of which fit the "everything is a generic `[data-deck-key]` click" seam
14 * lb-tcg-rail.js/lb-formats.js use).
15 *
16 * Same delegation seam as lb-hero-page.js: leaderboard.js owns every fetch
17 * (decks(game,window,format) for the grid; deckDetail/deckMatchups/deckGames
18 * for the archetype page) and builds the `ctx` object each function renders
19 * from — see leaderboard.js's own "Decks tab (Task 9)" comment block for the
20 * exact contract.
21 *
22 * Mirrors the desktop DecksView/DeckCard/DeckArchetypePage components
23 * section for section, in the same order, with the same copy verbatim —
24 * see those files' own header comments for the underlying design rationale.
25 * ONE difference, an explicit orchestrator resolution: desktop's empty-grid
26 * state links "Choose a deck in Quick Match" to `/matchmaking` (Webcam Play);
27 * `/app` has no webcam play at all (access matrix: desktop-app-only), so
28 * here it renders as plain hint text with no link.
29 *
30 * A deck/player/opponent name is ALWAYS run through esc() before it reaches
31 * markup — the card grid, the Top Archetypes rail (lb-tcg-rail.js, already
32 * escaped, unaffected here), the page header, top pilots, matchups and
33 * recent games. This is the one thing every review pass on this file checks
34 * first (Review Focus 2 / the bite proof).
35 *
36 * Deck-key buttons (grid cards, matchup rows, a public recent-game opponent)
37 * all carry `data-deck-key` and are wired GENERICALLY by leaderboard.js's own
38 * wireEvents() on every full render() — this file does not wire those itself,
39 * with ONE exception: the live search box patches `#lb-deck-gridarea`
40 * directly (see wire()'s comment) to avoid stealing focus on every keystroke,
41 * the same technique leaderboard.js's own hero search already uses, and MUST
42 * therefore re-wire the cards it just injected by hand (wireCardClicks()).
43 */
44(function () {
45  'use strict';
46
47  function esc(s) {
48    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
49      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
50    });
51  }
52
53  var WINDOW_OPTIONS = [
54    { value: 'all', label: 'All Time' },
55    { value: '90d', label: '90D' },
56    { value: '30d', label: '30D' },
57    { value: '7d', label: '7D' },
58  ];
59
60  var SORT_LABELS = { share: 'Play Share', winRate: 'Win Rate', games: 'Games', players: 'Pilots' };
61
62  function windowSegHtml(ctx) {
63    var tabs = '';
64    for (var i = 0; i < WINDOW_OPTIONS.length; i++) {
65      var o = WINDOW_OPTIONS[i];
66      tabs +=
67        '<button type="button" class="t' + (o.value === ctx.window ? ' on' : '') +
68        '" data-window="' + esc(o.value) + '">' + esc(o.label) + '</button>';
69    }
70    return '<div class="seg-slab" id="lb-deck-window">' + tabs + '</div>';
71  }
72
73  function sortSegHtml(ctx) {
74    var keys = window.TDTT.lbDecks.DECK_SORT_KEYS;
75    var tabs = '';
76    for (var i = 0; i < keys.length; i++) {
77      var k = keys[i];
78      tabs +=
79        '<button type="button" class="chip' + (k === ctx.sort ? ' on' : '') +
80        '" data-sort="' + esc(k) + '">' + esc(SORT_LABELS[k]) + '</button>';
81    }
82    return '<div class="chip-row" id="lb-deck-sort">' + tabs + '</div>';
83  }
84
85  function formatSelectHtml(ctx) {
86    var opts = '<option value="">All formats</option>';
87    var formats = ctx.formats || [];
88    for (var i = 0; i < formats.length; i++) {
89      var f = formats[i];
90      opts +=
91        '<option value="' + esc(f.id) + '"' + (f.id === ctx.format ? ' selected' : '') + '>
91' +
92        esc(f.name) + '</option>';
93    }
94    return '<select class="lb-select" id="lb-deck-format" aria-label="Format">' + opts + '</select>';
95  }
96
97  function searchInputHtml(ctx) {
98    return (
99      '<input type="search" id="lb-deck-search" class="lb-search-input" placeholder="Search decks…" ' +
100      'autocomplete="off" value="' + esc(ctx.query) + '">'
101    );
102  }
103
104  // Duplicated from leaderboard.js's private deckIdentityChipsHtml() on
105  // purpose — a browser IIFE can't reach another file's closure (same
106  // reasoning lb-tcg-rail.js's own header comment gives).
107  function identityChipsHtml(gameType, identity) {
108    var html = '';
109    var list = identity || [];
110    for (var i = 0; i < list.length; i++) {
111      var v = list[i];
112      var hex = window.TDTT.decks.identityColor(gameType, v);
113      var cls = 'tdtt-idchip' + (hex ? '' : ' is-neutral');
114      var style = hex ? ' style="--id-c:' + esc(hex) + '"' : '';
115      html += '<span class="' + cls + '"' + style + '>' + esc(window.TDTT.decks.identityLabel(gameType, v)) + '</span>';
116    }
117    return html;
118  }
119
120  function totalsLineHtml(totals) {
121    return esc(totals.trackedGames + ' games with a deck · ' + totals.decklessGames + ' without');
122  }
123
124  // ── Deck card (Decks landing grid) ───────────────────────────────────────
125  // Same trend-arrow rule as desktop's DeckCard: shareDelta() null (no
126  // previous-window data, e.g. the 'all' window) renders no arrow at all,
127  // never a stale one; >= +1 is green up, <= -1 is red down, otherwise none.
128  function deckCardHtml(row, gameType) {
129    var band = window.TDTT.bands.winRateBand(row.wins, row.losses);
130    var delta = window.TDTT.lbDecks.shareDelta(row);
131    var arrow = '';
132    if (delta !== null && delta >= 1) {
133      arrow = '<span class="lb-deck-arrow up" aria-hidden="true">&#9650;</span>';
134    } else if (delta !== null && delta <= -1) {
135      arrow = '<span class="lb-deck-arrow down" aria-hidden="true">&#9660;</span>';
136    }
137    return (
138      '<button type="button" class="tdtt-cardtile lb-deck-card" data-deck-key="' + esc(row.key) + '">' +
139      '<div class="lb-deck-card-chips">' + identityChipsHtml(gameType, row.identity) + '</div>' +
140      '<p class="lb-deck-card-name">' + esc(row.name) + '</p>' +
141      '<div class="lb-deck-card-row">' +
142      '<span class="lb-deck-card-share">' + row.share + '%' + arrow + '</span>' +
143      '<span class="lb-deck-card-wr ' + band + '">' + row.winRate + '%</span>' +
144      '</div>' +
145      '<div class="lb-deck-card-meta"><span>' + row.games + ' games</span><span>' + row.players + ' pilots</span></div>' +
146      '</button>'
147    );
148  }
149
150  function noQueryMatchHtml(query) {
151    return '<p class="lb-empty-sub lb-deck-center">No deck matches “' + esc(query) + '”</p>';
152  }
153
154  function emptyGridHtml(totals) {
155    return (
156      '<div class="lb-status lb-empty">' +
157      '<p class="lb-empty-title">No deck data yet</p>' +
158      '<p class="lb-empty-sub">' +
159      esc('Archetypes appear once ' + window.TDTT.decks.DECK_LIMITS.publicMinPlayers + ' players have played the same deck.') +
160      '</p>' +
161      '<p class="lb-deck-totals-sub">' + totalsLineHtml(totals) + '</p>' +
162      '<p class="lb-deck-quickmatch-hint">Choose a deck in Quick Match</p>' +
163      '</div>'
164    );
165  }
166
167  function filteredSortedDecks(ctx) {
168    var filtered = [];
169    for (var i = 0; i < ctx.decks.length; i++) {
170      if (window.TDTT.lbDecks.matchesDeckQuery(ctx.decks[i].name, ctx.query)) filtered.push(ctx.decks[i]);
171    }
172    return window.TDTT.lbDecks.sortDecks(filtered, ctx.sort);
173  }
174
175  function gridAreaHtml(sorted, query, gameType) {
176    if (!sorted.length) return '<div id="lb-deck-gridarea">' + noQueryMatchHtml(query) + '</div>';
177    var cards = '';
178    for (var i = 0; i < sorted.length; i++) cards += deckCardHtml(sorted[i], gameType);
179    return '<div id="lb-deck-gridarea"><div class="lb-deck-grid">' + cards + '</div></div>';
180  }
181
182  function gridBodyHtml(ctx) {
183    if (ctx.gridState === 'loading') return '<div class="lb-status lb-loading">Loading…</div>';
184    if (ctx.gridState === 'error') {
185      return '<div class="lb-status lb-error"><p class="lb-error-title">Couldn’t load deck archetypes</p></div>';
186    }
187    if (!ctx.decks.length) return emptyGridHtml(ctx.totals);
188
189    var sorted = filteredSortedDecks(ctx);
190    var controls =
191      '<div class="lb-filterrow lb-deck-controls">' +
192      '<div class="lb-deck-sortwrap"><span class="lb-deck-sort-label">Sort by</span>' + sortSegHtml(ctx) + '</div>' +
193      '<div class="lb-deck-controls-right">' + formatSelectHtml(ctx) +
194      '<div class="lb-search-wrap">' + searchInputHtml(ctx) + '</div></div>' +
195      '</div>';
196    var totalsLine = '<p class="lb-deck-totals">' + totalsLineHtml(ctx.totals) + '</p>';
197    return controls + totalsLine + gridAreaHtml(sorted, ctx.query, ctx.game);
198  }
199
200  /**
201   * `ctx` (leaderboard.js's buildDecksListCtx()):
202   *   { game, gameName, window, format, formats (id/name pairs), sort, query,
203   *     gridState, decks, totals, railHtml,
204   *     onWindow(w), onFormat(id), onSort(key), onQuery(q), onSelectDeck(key) }
205   * `gridState` is 'loading' | 'ready' | 'error'. `railHtml` is a fully
206   * rendered `<aside>` string (leaderboard.js's own tcgRailAsideHtml(),
207   * reused verbatim — the Decks tab shares the SAME all-time, all-format
208   * rail data the Players tab's insights rail shows, per the brief).
209   */
210  function listHtml(ctx) {
211    return (
212      '<div class="lb-deck-list">' +
213      windowSegHtml(ctx) +
214      '<div class="lb-tcg-layout"><div class="lb-tcg-main">' + gridBodyHtml(ctx) + '</div>' +
215      (ctx.railHtml || '') +
216      '</div>' +
217      '</div>'
218    );
219  }
220
221  // ── Deck archetype detail page ───────────────────────────────────────────
222
223  function breadcrumbHtml(name) {
224    return (
225      '<nav class="lb-deck-crumb">' +
226      '<button type="button" class="lb-deck-crumb-link" id="lb-deck-crumb-lb">Leaderboards</button>' +
227      '<span class="lb-deck-crumb-sep">&rsaquo;</span>' +
228      '<button type="button" class="lb-deck-crumb-link" id="lb-deck-crumb-decks">Decks</button>' +
229      (name != null
230        ? '<span class="lb-deck-crumb-sep">&rsaquo;</span><span class="lb-deck-crumb-current">' + esc(name) + '</span>'
231        : '') +
232      '</nav>'
233    );
234  }
235
236  function statTileHtml(value, label, accent) {
237    return (
238      '<div class="tdtt-stat lb-deck-tile" style="--sec-accent:' + accent + '">' +
239      '<span class="v">' + esc(value) + '</span>' +
240      '<span class="l">' + esc(label) + '</span>' +
241      '<span class="u"></span>' +
242      '</div>'
243    );
244  }
245
246  // `trend` is a 1/0 series, oldest -> newest — same cumulative-win-rate
247  // conversion as desktop's DeckArchetypePage.toTrendPoints(), so this page's
248  // trend line can never disagree with the desktop one for the same data.
249  function toTrendPoints(trend) {
250    var wins = 0;
251    var out = [];
252    for (var i = 0; i < trend.length; i++) {
253      if (trend[i]) wins++;
254      out.push({ index: i, won: trend[i] === 1, cumulativeWinRate: Math.round((wins / (i + 1)) * 100) });
255    }
256    return out;
257  }
258
259  // Same inline-SVG shape as lb-hero-page.js's trendSvgHtml() — duplicated on
260  // purpose (browser IIFEs can't share a closure); a 300x100 viewBox with
261  // 0/25/50/75/100 gridlines, one polyline point per trend point.
262  function trendSvgHtml(trend) {
263    var WIDTH = 300;
264    var HEIGHT = 100;
265    var GRIDLINES = [0, 25, 50, 75, 100];
266    var latest = trend[trend.length - 1];
267    var points = [];
268    var i;
269    for (i = 0; i < trend.length; i++) {
270      var x = trend.length > 1 ? (i / (trend.length - 1)) * WIDTH : WIDTH / 2;
271      var y = HEIGHT - (trend[i].cumulativeWinRate / 100) * HEIGHT;
272      points.push(x + ',' + y);
273    }
274    var lines = '';
275    for (i = 0; i < GRIDLINES.length; i++) {
276      var gy = HEIGHT - (GRIDLINES[i] / 100) * HEIGHT;
277      lines +=
278        '<line x1="0" y1="' + gy + '" x2="' + WIDTH + '" y2="' + gy +
279        '" stroke="rgba(255,255,255,0.08)" stroke-width="1"></line>';
280    }
281    return (
282      '<div class="lb-deck-trend-row">' +
283      '<svg viewBox="0 0 ' + WIDTH + ' ' + HEIGHT + '" preserveAspectRatio="none" class="lb-deck-trend-svg" ' +
284      'role="img" aria-label="Win rate trend, latest ' + latest.cumulativeWinRate + '%">' +
285      lines +
286      '<polyline points="' + points.join(' ') + '" fill="none" stroke="var(--cyan)" stroke-width="2" ' +
287      'stroke-linejoin="round" stroke-linecap="round"></polyline>' +
288      '</svg>' +
289      '<span class="lb-deck-trend-latest">' + latest.cumulativeWinRate + '%</span>' +
290      '</div>'
291    );
292  }
293
294  function resultChipParts(result) {
295    if (result === 'WIN') return { label: 'Win', cls: 'win' };
296    if (result === 'LOSS') return { label: 'Loss', cls: 'loss' };
297    if (result === 'DRAW') return { label: 'Draw', cls: 'draw' };
298    return { label: '—', cls: 'draw' };
299  }
300
301  function pilotsPanelHtml(pilots, ctx) {
302    var body;
303    if (!pilots.length) {
304      body = '<p class="lb-empty-sub lb-deck-center">No pilot data yet</p>';
305    } else {
306      var rows = '';
307      for (var i = 0; i < pilots.length; i++) {
308        var p = pilots[i];
309        var band = window.TDTT.bands.winRateBand(p.wins, p.losses);
310        var avatar = typeof ctx.avatarHtml === 'function'
311          ? ctx.avatarHtml({ avatarUrl: p.avatarUrl, displayName: p.displayName }, '')
312          : '';
313        rows +=
314          '<div class="lb-deck-pilot-row">' + avatar +
315          '<button type="button" class="lb-deck-pilot-name" data-pid="' + esc(p.playerId) + '">' +
316          esc(p.displayName) + '</button>' +
317          '<span class="tdtt-record"><span class="w">' + p.wins + '</span><span class="sep">–</span>' +
318          '<span class="l">' + p.losses + '</span></span>' +
319          '<span class="lb-deck-pilot-wr ' + band + '">' + p.winRate + '%</span>' +
320          '</div>';
321      }
322      body = '<div class="lb-deck-pilot-list">' + rows + '</div>';
323    }
324    return '<div class="tdtt-panel lb-deck-panel"><div class="tdtt-panel__head">Top Pilots</div>' + body + '</div>';
325  }
326
327  function formatsPanelHtml(formats) {
328    var body;
329    if (!formats.length) {
330      body = '<p class="lb-empty-sub lb-deck-center">No format data yet</p>';
331    } else {
332      var rows = '';
333      for (var i = 0; i < formats.length; i++) {
334        var f = formats[i];
335        var band = window.TDTT.bands.winRateBand(f.wins, f.losses);
336        rows +=
337          '<div class="lb-deck-format-row">' +
338          '<span class="lb-deck-format-name">' + esc(f.name) + '</span>' +
339          '<span class="tdtt-record"><span class="w">' + f.wins + '</span><span class="sep">–</span>' +
340          '<span class="l">' + f.losses + '</span></span>' +
341          '<span class="lb-deck-format-wr ' + band + '">' + f.winRate + '%</span>' +
342          '</div>';
343      }
344      body = '<div class="lb-deck-format-list">' + rows + '</div>';
345    }
346    return '<div class="tdtt-panel lb-deck-panel"><div class="tdtt-panel__head">Format Split</div>' + body + '</div>';
347  }
348
349  function matchupsPanelHtml(ctx) {
350    var body;
351    if (ctx.matchupsState === 'loading') {
352      body = '<p class="lb-status lb-loading">Loading&hellip;</p>';
353    } else if (ctx.matchupsState === 'error') {
354      body = '<p class="lb-error-detail lb-deck-center">Couldn’t load matchups.</p>';
355    } else if (!ctx.matchups || !ctx.matchups.length) {
356      body = '<p class="lb-empty-sub lb-deck-center">No matchup data yet</p>';
357    } else {
358      var rows = '';
359      for (var i = 0; i < ctx.matchups.length; i++) {
360        var mu = ctx.matchups[i];
361        var band = window.TDTT.bands.winRateBand(mu.wins, mu.losses);
362        rows +=
363          '<button type="button" class="lb-deck-matchup-row" data-deck-key="' + esc(mu.key) + '">' +
364          '<div class="lb-deck-matchup-chips">' + identityChipsHtml(ctx.game, mu.identity) + '</div>' +
365          '<span class="lb-deck-matchup-name">' + esc(mu.name) + '</span>' +
366          '<span class="lb-deck-matchup-wr ' + band + '">' + mu.winRate + '%</span>' +
367          '<span class="tdtt-record"><span class="w">' + mu.wins + '</span><span class="sep">–</span>' +
368          '<span class="l">' + mu.losses + '</span></span>' +
369          '</button>';
370      }
371      body = '<div class="lb-deck-matchup-list">' + rows + '</div>';
372    }
373    return '<div class="tdtt-panel lb-deck-panel"><div class="tdtt-panel__head">Matchups</div>' + body + '</div>';
374  }
375
376  function recentGamesPanelHtml(ctx) {
377    var body;
378    if (ctx.gamesState === 'loading') {
379      body = '<p class="lb-status lb-loading">Loading&hellip;</p>';
380    } else if (ctx.gamesState === 'error') {
381      body = '<p class="lb-error-detail lb-deck-center">Couldn’t load recent games.</p>';
382    } else if (!ctx.games || !ctx.games.length) {
383      body = '<p class="lb-empty-sub lb-deck-center">No games recorded yet</p>';
384    } else {
385      var rows = '';
386      for (var i = 0; i < ctx.games.length; i++) {
387        var g = ctx.games[i];
388        var chip = resultChipParts(g.result);
389        var when = g.endedAt && window.TDTT.fmt && typeof window.TDTT.fmt.formatRelativeDate === 'function'
390          ? window.TDTT.fmt.formatRelativeDate(g.endedAt)
391          : '—';
392        var opp = g.opponent || {};
393        var opponentHtml = opp.archetypeKey
394          ? '<button type="button" class="lb-deck-game-opp-link" data-deck-key="' + esc(opp.archetypeKey) + '">' +
395            esc(opp.archetypeName || opp.displayName) + '</button>'
396          : esc(opp.displayName);
397        rows +=
398          '<div class="lb-deck-game-row">' +
399          '<span class="lb-deck-game-chip ' + chip.cls + '">' + esc(chip.label) + '</span>' +
400          '<span class="lb-deck-game-vs">vs ' + opponentHtml + '</span>' +
401          '<span class="lb-deck-game-when">' + esc(when) + '</span>' +
402          '</div>';
403      }
404      body = '<div class="lb-deck-game-list">' + rows + '</div>';
405    }
406    return '<div class="tdtt-panel lb-deck-panel"><div class="tdtt-panel__head">Recent Games</div>' + body + '</div>';
407  }
408
409  /**
410   * `ctx` (leaderboard.js's buildDeckPageCtx()):
411   *   { game, window, detailState, detail (archetype/pilots/formats/trend),
412   *     matchupsState, matchups, gamesState, games, avatarHtml(row,cls),
413   *     onWindow(w), onLeaderboards(), onBack(), onOpenPlayer(id) }
414   * `detailState` is 'loading' | 'ready' | 'error' | 'notFound'. A 404 (an
415   * archetype that dropped below DECK_LIMITS.publicMinPlayers pilots in a
416   * narrower window, or simply doesn't exist) gets its OWN state here — an
417   * expected, common case (Review Focus 1), not a generic fetch failure.
418   */
419  function pageHtml(ctx) {
420    var windowChips = windowSegHtml(ctx);
421
422    if (ctx.detailState === 'loading') {
423      return '<div class="lb-deck-page">' + windowChips + breadcrumbHtml(null) +
424        '<p class="lb-status lb-loading">Loading&hellip;</p></div>';
425    }
426    if (ctx.detailState === 'notFound') {
427      return (
428        '<div class="lb-deck-page">' + windowChips + breadcrumbHtml(null) +
429        '<div class="lb-status lb-empty"><p class="lb-empty-title">This deck archetype isn’t public yet</p>' +
430        '<button type="button" class="lb-btn" id="lb-deck-back-notfound">Back</button></div>' +
431        '</div>'
432      );
433    }
434    if (ctx.detailState === 'error' || !ctx.detail) {
435      return (
436        '<div class="lb-deck-page">' + windowChips + breadcrumbHtml(null) +
437        '<div class="lb-status lb-error"><p class="lb-error-title">Couldn’t load this deck archetype.</p>' +
438        '<button type="button" class="lb-btn" id="lb-deck-back-error">Back</button></div>' +
439        '</div>'
440      );
441    }
442
443    var archetype = ctx.detail.archetype;
444    var pilots = ctx.detail.pilots || [];
445    var formats = ctx.detail.formats || [];
446    var trend = toTrendPoints(ctx.detail.trend || []);
447
448    var header =
449      '<div class="tdtt-panel lb-deck-header">' +
450      '<div class="lb-deck-header-body">' +
451      '<div class="lb-deck-header-chips">' + identityChipsHtml(ctx.game, archetype.identity) + '</div>' +
452      '<h1 class="lb-deck-name">' + esc(archetype.name) + '</h1>' +
453      '</div>' +
454      '<button type="button" class="lb-btn lb-deck-back-btn" id="lb-deck-back-header">Back</button>' +
455      '</div>';
456
457    var tiles =
458      '<div class="lb-deck-tiles">' +
459      statTileHtml(String(archetype.games), 'Games', 'var(--cyan)') +
460      statTileHtml(archetype.winRate + '%', 'Win Rate', 'var(--gold)') +
461      statTileHtml(archetype.share + '%', 'Play Share', 'var(--magenta)') +
462      statTileHtml(String(archetype.players), 'Pilots', 'var(--green)') +
463      '</div>';
464
465    var trendPanel =
466      '<div class="tdtt-panel lb-deck-panel"><div class="tdtt-panel__head">Win Rate Trend</div>' +
467      (trend.length === 0
468        ? '<p class="lb-empty-sub lb-deck-center">No decided games recorded yet</p>'
469        : trendSvgHtml(trend)) +
470      '</div>';
471
472    return (
473      '<div class="lb-deck-page">' + windowChips + breadcrumbHtml(archetype.name) +
474      header + tiles + trendPanel +
475      pilotsPanelHtml(pilots, ctx) +
476      formatsPanelHtml(formats) +
477      matchupsPanelHtml(ctx) +
478      recentGamesPanelHtml(ctx) +
479      '</div>'
480    );
481  }
482
483  // Re-wires the deck-key buttons a local DOM patch (the search handler
484  // below) just injected — the top-level generic `[data-deck-key]`
485  // delegation in leaderboard.js's wireEvents() only runs once per full
486  // render(), so anything created afterward via a direct innerHTML write has
487  // to be wired by hand, same as leaderboard.js's own wireHeroGridTriggers()
488  // re-run after its hero-search local patch.
489  function wireCardClicks(container, onSelectDeck) {
490    if (!container || typeof onSelectDeck !== 'function') return;
491    var cards = container.querySelectorAll('[data-deck-key]');
492    for (var i = 0; i < cards.length; i++) {
493      cards[i].addEventListener('click', function (e) {
494        onSelectDeck(e.currentTarget.getAttribute('data-deck-key'));
495      });
496    }
497  }
498
499  /**
500   * Wires the body html() just painted into `root` (the leaderboard
501   * section's whole container). Scoped to `.lb-deck-list`/`.lb-deck-page` so
502   * nothing here can double-wire an unrelated element elsewhere on the page.
503   */
504  function wire(root, ctx) {
505    if (!root) return;
506    var listScope = root.querySelector('.lb-deck-list');
507    var pageScope = root.querySelector('.lb-deck-page');
508    var scope = listScope || pageScope;
509    if (!scope) return;
510
511    var windowSeg = scope.querySelector('#lb-deck-window');
512    if (windowSeg) {
513      var wbtns = windowSeg.querySelectorAll('.t');
514      for (var wi = 0; wi < wbtns.length; wi++) {
515        wbtns[wi].addEventListener('click', function (e) {
516          ctx.onWindow(e.currentTarget.getAttribute('data-window'));
517        });
518      }
519    }
520
521    if (listScope) {
522      var sortSeg = listScope.querySelector('#lb-deck-sort');
523      if (sortSeg) {
524        var sbtns = sortSeg.querySelectorAll('.chip');
525        for (var si = 0; si < sbtns.length; si++) {
526          sbtns[si].addEventListener('click', function (e) {
527            ctx.onSort(e.currentTarget.getAttribute('data-sort'));
528          });
529        }
530      }
531
532      var formatSel = listScope.querySelector('#lb-deck-format');
533      if (formatSel) {
534        formatSel.addEventListener('change', function () {
535          ctx.onFormat(formatSel.value || '');
536        });
537      }
538
539      // Client-side only (matchesDeckQuery/sortDecks over the already-fetched
540      // ctx.decks) — kept OUTSIDE a full leaderboard.js render() on every
541      // keystroke, same reasoning leaderboard.js's own hero search gives: a
542      // full innerHTML rebuild would steal focus mid-word. ctx.onQuery only
543      // PERSISTS the value into leaderboard.js's state (no render()), so a
544      // later window/format change starts from the right query.
545      var searchInput = listScope.querySelector('#lb-deck-search');
546      if (searchInput) {
547        searchInput.addEventListener('input', function () {
548          var q = searchInput.value;
549          ctx.onQuery(q);
550          var gridArea = listScope.querySelector('#lb-deck-gridarea');
551          if (!gridArea) return;
552          var filtered = [];
553          for (var fi = 0; fi < ctx.decks.length; fi++) {
554            if (window.TDTT.lbDecks.matchesDeckQuery(ctx.decks[fi].name, q)) filtered.push(ctx.decks[fi]);
555          }
556          var sorted = window.TDTT.lbDecks.sortDecks(filtered, ctx.sort);
557          if (!sorted.length) {
558            gridArea.innerHTML = noQueryMatchHtml(q);
559            return;
560          }
561          var cards = '';
562          for (var ci = 0; ci < sorted.length; ci++) cards += deckCardHtml(sorted[ci], ctx.game);
563          gridArea.innerHTML = '<div class="lb-deck-grid">' + cards + '</div>';
564          wireCardClicks(gridArea, ctx.onSelectDeck);
565        });
566      }
567    }
568
569    if (pageScope) {
570      var crumbLb = pageScope.querySelector('#lb-deck-crumb-lb');
571      if (crumbLb) crumbLb.addEventListener('click', function () { ctx.onLeaderboards(); });
572      var crumbDecks = pageScope.querySelector('#lb-deck-crumb-decks');
573      if (crumbDecks) crumbDecks.addEventListener('click', function () { ctx.onBack(); });
574
575      var backBtns = pageScope.querySelectorAll('#lb-deck-back-header, #lb-deck-back-notfound, #lb-deck-back-error');
576      for (var bi = 0; bi < backBtns.length; bi++) {
577        backBtns[bi].addEventListener('click', function () { ctx.onBack(); });
578      }
579
580      var pilotBtns = pageScope.querySelectorAll('.lb-deck-pilot-name');
581      for (var pi = 0; pi < pilotBtns.length; pi++) {
582        pilotBtns[pi].addEventListener('click', function (e) {
583          ctx.onOpenPlayer(e.currentTarget.getAttribute('data-pid'));
584        });
585      }
586    }
587  }
588
589  window.TDTT = window.TDTT || {};
590  window.TDTT.lbDecksView = {
591    listHtml: listHtml,
592    pageHtml: pageHtml,
593    wire: wire,
594  };
595})();

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.