PageSourceSearch

https://topdowntabletop.com/app/hero-detail.js

js topdowntabletop.com collected 2026-10-08 09:16:23 UTC 115,239 bytes, 2,303 lines download raw bytes

1/* /app hero detail — content for the Dice Throne hero detail panel codex.js
2 * mounts inline (see codex.js's openHeroPanel()/heroPanelSectionHtml()).
3 * E5.1 retired the shared `.tdtt-overlay` modal this used to render inside;
4 * an owner correction to that same item (E5 fix round 1) then moved the
5 * panel into the carousel's own slot — codex.js swaps the carousel for the
6 * panel and back, rather than stacking the panel below it. Neither change
7 * touched what THIS file renders, only where codex.js puts it.
8 *
9 * ── Why this is a separate file from codex.js (C5a) ─────────────────────
10 * codex.js is already ~2000 lines (roster carousel + TCG faceted browser).
11 * The hero detail is a whole seven-tab view, and three more stages (C5b/c/d)
12 * add Abilities, Cards+Assets, and Matchups on top of what ships here —
13 * that would push codex.js past shops.js (1838 lines), the largest
14 * comparable /app module. Splitting now, before any of that content exists,
15 * keeps codex.js scoped to "the index" (carousel + TCG browser + opening the
16 * panel) and this file scoped to "the detail" (everything that renders
17 * INSIDE the panel once open). `/app` has no module system, so the seam is
18 * the same `window.TDTT.*` convention every other file here already uses:
19 * codex.js owns opening/closing the panel (roster state, deep links, the
20 * carousel itself) and calls into `window.TDTT.heroDetail` only for "build
21 * the body HTML" and "wire the tab strip" — this file knows nothing about
22 * the roster, the carousel, or S.rosterOpenSlug.
23 *
24 * ── The tab strip (all seven, per the owner's "full parity with desktop"
25 * decision) ───────────────────────────────────────────────────────────────
26 * Desktop's tab set (Characters.tsx's `TABS` array): overview, rulesheets,
27 * abilities, cards, strategy, matchups, assets — note the last is "Assets",
28 * not "Gallery". C5a shipped Overview/Rulesheets/Strategy with a clearly-
29 * worded "coming soon" panel naming each of the other four tabs rather than
30 * a hidden or missing button — the seven-tab shape was itself part of parity
31 * from day one, and C5b/C5c/C5d have since filled in the remaining four
32 * (Abilities; Cards + Assets; Matchups) without ever changing the strip.
33 *
34 * ── Data note ─────────────────────────────────────────────────────────────
35 * Every field these seven tabs read is already present, unchanged, on each
36 * roster() hero object codex.js holds in S.roster — see encyclopedia-data.ts's
37 * mergeCharacter()/build(), which the /characters list endpoint and the
38 * single-character endpoint both read through. No fetch happens in THIS file
39 * at all. Two sections do read data that is not on the roster hero object —
40 * Cards/Assets' shared components (C5c) and Community Stats (C6) — but
41 * codex.js owns both fetches and pushes the results in via setShared() /
42 * setRankings().
43 *
44 * DO NOT assume a field name from this comment, a type, or another surface.
45 * The pack spells several fields in camelCase (`diceFaces`, `statusEffects`,
46 * ...) and encyclopedia-data.ts does not normalize them to snake_case or
47 * back — it passes each character object through unchanged. Desktop and
48 * `/m` each map/dual-read what they need themselves; nothing here does it
49 * for you. Two real bugs shipped from assuming otherwise: this file read
50 * `hero.dice_faces` and `hero.status_effects` (snake_case) for years while
51 * the real payload only ever carried `diceFaces`/`statusEffects`
52 * (camelCase) — both panels silently rendered nothing, for every hero, on
53 * this surface only, because an omitted panel looks identical to a design
54 * choice (Task 10 fix rounds 1-2). Before reading a new field here, check
55 * its real key in `data/packs/dice-throne/characters.json` (or
56 * `dicepedia.json` for matchup data), not this comment. When a field really
57 * doesn't exist under any spelling — e.g. `hero.archetype_matchups`, which
58 * has no `archetypeMatchups` counterpart either, across all 44 heroes — the
59 * panel correctly self-omits for a different reason: the data genuinely
60 * isn't there, not a spelling mismatch. Don't "fix" that one.
61 *
62 * `hero.assets.rulesheets` is a SERVER-ENUMERATED array of real image URLs
63 * (readCharacterAssets() lists the actual `rulesheets/` folder on disk), not
64 * a hardcoded pair of filenames the way desktop's own RulesheetsTab assumes
65 * (RULESHEET_FILES = ['rulesheet_1.png','rulesheet_2.png']) — using the real
66 * list here means an empty array is a genuine "no scans yet" case, not dead
67 * code, so this file renders a real empty state for it (see
68 * rulesheetsTabHtml()) that desktop's own version can never reach.
69 *
70 * comingSoonHtml() below stays as panelBodyHtml()'s defensive fallback for
71 * an unrecognised tab id, not because any of the seven tabs still needs it —
72 * C5d gave Matchups its own real builder, so every tab has one.
73 *
74 * ── Community Stats (C6) — the second async-backed section in this file,
75 * and the first one inside Overview ─────────────────────────────────────
76 * The Cards and Assets tabs above already read `sharedData`, fetched via
77 * C5c's loadSharedDataOnce() and pushed in through setShared() — that is
78 * this file's first async seam. Community Stats (inside the Overview tab,
79 * see communityStatsHtml()) is the second: it ports desktop's
80 * `CharacterRankings` (Characters.tsx), which needs two MORE
81 * `/api/leaderboard/*` endpoints beyond the character payload. codex.js's
82 * loadRankingsOnce() fetches them lazily (mirroring loadSharedDataOnce()
83 * exactly) and pushes the result in via setRankings().
84 * Per the owner's ruling, the Best/Worst matchup tables are OUT of scope —
85 * C5d's own Matchups tab already owns the word "Matchups" here, and
86 * dropping them removes the only dependency on the DB Character.id UUID
87 * (CLAUDE.md: regenerates on every characters reseed). This section reads
88 * characterSlug only, never a characterId.
89 *
90 * Also not ported: RulesheetsTab's click-to-zoom lightbox — a second modal
91 * stacked on top of this content is its own small feature, not a
92 * shell-and-three-tabs one. C3 renders every rulesheet as a bare image in a
93 * centered, wrapping row instead (rulesheetsTabHtml() below,
94 * hero-detail.css's `.hd-rulesheet-row`) — not the full-size single column
95 * this file originally shipped, and not the panel-wrapped grid that
96 * replaced it.
97 *
98 * Load order: after overlay-core.js (this file doesn't touch it directly,
99 * but codex.js wires the two together), before codex.js (its one consumer).
100 */
101(function () {
102  'use strict';
103
104  // Matches Characters.tsx's `TABS` array exactly, including "Assets" (not
105  // "Gallery") as the seventh tab's label.
106  var TABS = [
107    { id: 'overview', label: 'Overview' },
108    { id: 'rulesheets', label: 'Rulesheets' },
109    { id: 'abilities', label: 'Abilities' },
110    { id: 'statusEffects', label: 'Status Effects' },
111    { id: 'cards', label: 'Cards' },
112    { id: 'strategy', label: 'Strategy' },
113    { id: 'matchups', label: 'Matchups' },
114    { id: 'assets', label: 'Assets' },
115  ];
116
117  // The currently-open tab. Module-scoped, not per-hero: only one hero
118  // detail can be open at a time (the carousel and the panel are mutually
119  // exclusive — see codex.js's rosterResultsHtml()), and open() below resets
120  // this to 'overview' every time a hero is shown, so a new hero never
121  // inherits whatever tab the previous one left active.
122  var activeTab = 'overview';
123
124  // `/api/encyclopedia/shared` payload (C5c) — general cards + shared
125  // component art, hero-independent (see this file's header comment and
126  // codex.js's loadSharedDataOnce()). null until codex.js's lazy fetch
127  // resolves; once set it stays set for the rest of the session (a hero
128  // switch never clears it — only activeTab/generalCardsExpanded reset per
129  // hero, see open() below).
130  var sharedData = null;
131
132  // Collapse state for the Cards tab's General Cards section — module scope
133  // (not per-hero) so switching tabs away and back mid-session doesn't reset
134  // it, but open() resets it to collapsed, matching desktop's default, every
135  // time a different hero is shown.
136  var generalCardsExpanded = false;
137
138  // Task 9 (codex_character_cards.png mockup) — which card-pool category the
139  // Cards tab's filter chips currently show: 'all' | 'hero' | 'general' |
140  // 'upgrades'. Same module-scope-but-reset-on-open() contract as
141  // generalCardsExpanded above, so a hero switch always lands back on "All".
142  var cardsFilter = 'all';
143
144  // Combined `/api/leaderboard/DICE_THRONE/characters` +
145  // `/api/leaderboard/draft-stats` payload (C6, codex.js's
146  // loadRankingsOnce()) — `{ characters: [...], draftCharacters: [...] }`,
147  // both hero-INDEPENDENT full lists filtered by characterSlug at render
148  // time (see communityStatsHtml() below). null until that lazy fetch
149  // resolves; once set it stays set for the rest of the session, same
150  // non-reset-on-hero-switch contract as `sharedData` above, because this
151  // data is equally hero-independent.
152  var rankingsData = null;
153
154  // ── Dice Throne hero tiers (Task 9, dt-hero-tiers batch) ─────────────────
155  // Client-side vote state for the FIVE tier buttons in the Overview tab's
156  // Tier panel (tierPanelHtml() below). The real cached payload lives in
157  // window.TDTT.heroTiers (hero-tiers-data.js) — this file never fetches it
158  // itself, only reads window.TDTT.heroTiers.peek() at render time and calls
159  // .vote()/.clear() on a click (see wire()'s [data-hero-tier] handler).
160  //
161  // tierVoteState tracks ONE in-flight optimistic vote at a time — only one
162  // hero panel is ever open, so only one hero's buttons are ever visible.
163  // `tier: null` means "clearing"; `inFlight` guards a double-tap on the SAME
164  // hero until the request settles (success or failure). Reset whenever a
165  // DIFFERENT hero is opened (open() below) so an override never leaks from
166  // a switched-away hero.
167  var tierVoteState = { slug: null, tier: null, inFlight: false };
168
169  // Set true by a 403 EMAIL_UNVERIFIED rejection from vote()/clear(); once
170  // set, heroTiersCanVote() disables voting regardless of what window.BFAuth
171  // itself reports, for the rest of this page load. Deliberately NOT reset
172  // in open() — a genuinely unverified account stays that way across a hero
173  // switch; only a real verify-then-reload clears it.
174  var tierUnverified = false;
175
176  // Whole-branch review fix 2 — set true by a vote/clear rejection that is
177  // NOT EMAIL_UNVERIFIED (the 403 case gets `tierUnverified` above instead,
178  // and the two never show at once). Cleared at the START of the next
179  // vote/clear attempt (handleTierVoteClick() below), same lifecycle as
180  // tierVoteState itself, so a retry always clears the stale message even
181  // before its own request settles.
182  var tierVoteError = false;
183
184  function esc(s) {
185    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
186      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
187    });
188  }
189
190  // The pure hero-detail helpers (abilityDetailModel / splitHeroCards /
191  // statusEffectsModel) come from the shared twin
192  // /shared/codex-hero-detail.js, loaded before this file. It publishes on the
193  // same `window.TDTT.heroDetail` name this file exports under, so the twin is
194  // captured here first and its functions are re-exported below.
195  var twin = (window.TDTT && window.TDTT.heroDetail) || {};
196
197  // Image sink guard (per-file copy, same idea as shops.js's safeImageUrl):
198  // an absolute http(s) URL, or a same-origin server asset / upload path with
199  // an image extension. Anything else (javascript:, data:, protocol-relative
200  // //host, traversal, svg) becomes '' and the caller renders no image.
201  function safeImageUrl(u) {
202    if (typeof u !== 'string') return '';
203    var t = u.trim();
204    if (!t || t.indexOf('..') !== -1) return '';
205    if (/^https?:\/\/[^\s"'<>]+$/i.test(t)) return t;
206    if (/^\/assets\/[A-Za-z0-9_%. \/-]+\.(png|jpg|jpeg|webp|gif)$/i.test(t) && t.indexOf('//') === -1) return t;
207    if (/^\/uploads\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.(jpg|jpeg|png|webp|gif)$/i.test(t)) return t;
208    return '';
209  }
210
211  // Local thousands-separator formatter — the same small helper codex.js,
212  // home.js and leaderboard.js each already define for themselves on this
213  // surface (there is no shared module for it). `window.TDTT.fmt` (format.js)
214  // IS loaded on /app, but it only exports formatWinRate/formatRecord/
215  // formatElo/formatRankOrdinal/formatRelativeDate — none of which formats an
216  // arbitrary large integer with thousands separators, so it isn't a fit for
217  // the totals below that desktop marks `fmt: true`. This mirrors the
218  // established local pattern instead of hand-rolling something new.
219  function fmtNum(n) {
220    var v = Number(n);
221    return isFinite(v) ? v.toLocaleString() : '—';
222  }
223
224  function heroSlug(hero) {
225    return String((hero && hero.id) || '');
226  }
227
228  function icon(name) {
229    return typeof window.bfIcon === 'function' ? window.bfIcon(name, 15) : '';
230  }
231
232  function panelHtml(title, iconName, innerHtml, extraClass) {
233    return (
234      '<div class="tdtt-panel' + (extraClass ? ' ' + extraClass : '') + '">' +
235      '<div class="tdtt-panel__head"><span class="tdtt-panel__icon">' + icon(iconName) + '</span>' +
236      esc(title) + '</div>' + innerHtml + '</div>'
237    );
238  }
239
240  function paragraphsHtml(paragraphs) {
241    return (
242      '<div class="hd-stack">' +
243      paragraphs.map(function (p) {
244        return '<p class="hd-text">' + esc(p) + '</p>';
245      }).join('') +
246      '</div>'
247    );
248  }
249
250  // ── Overview tab ─────────────────────────────────────────────────────────
251
252  // ── Tier panel (Task 9, dt-hero-tiers batch, design doc §7.3) ────────────
253  // Community Rating / Your Rating / Online win rate, rendered FIRST inside
254  // the Overview tab, above Hero Identity — see overviewTabHtml() below.
255  // `tierPanelHtml()` itself is PURE (same inputs, same output, exported on
256  // window.TDTT.heroDetail like the other tab builders) so it can be unit
257  // tested directly; everything that reads a global (window.TDTT.heroTiers,
258  // window.BFAuth, the tierVoteState/tierUnverified vars above) lives in the
259  // impure callers around it (overviewTabHtml(), handleTierVoteClick()).
260  //
261  // Distinct `hd-cr*`/`hd-voterow` class prefix, NOT `hd-tier*` — that
262  // prefix already means something else on this exact page
263  // (abilityTierHtml()'s activation-dice tier rows, "T1"/"T2"/"T3"), and
264  // CLAUDE.md calls out exactly this collision as a gotcha to avoid. The big
265  // letters and the five vote buttons reuse the GLOBAL `.tdtt-tier`/
266  // `.tdtt-tier--lg` badge (controls.css) instead — the one primitive
267  // already shared with the carousel tile badge and the header pill
268  // (codex.js).
269  var TIER_LETTERS = ['S', 'A', 'B', 'C', 'D'];
270
271  function emptyTierDistribution() {
272    return { S: 0, A: 0, B: 0, C: 0, D: 0 };
273  }
274
275  function communityVotesCopy(community) {
276    if (community.votes > 0) {
277      return 'Based on ' + community.votes + ' vote' + (community.votes === 1 ? '' : 's');
278    }
279    return community.tier ? 'Starting tier · no votes yet' : 'No votes yet';
280  }
281
282  function communityDistributionRowsHtml(distribution) {
283    var dist = distribution || emptyTierDistribution();
284    var i;
285    var max = 1;
286    for (i = 0; i < TIER_LETTERS.length; i++) {
287      var n = Number(dist[TIER_LETTERS[i]]) || 0;
288      if (n > max) max = n;
289    }
290    var rows = '';
291    for (i = 0; i < TIER_LETTERS.length; i++) {
292      var t = TIER_LETTERS[i];
293      var count = Number(dist[t]) || 0;
294      var pct = (count / max) * 100;
295      rows += (
296        '<div class="hd-crbar">' +
297        '<span class="tdtt-tier" data-tier="' + t + '">' + t + '</span>' +
298        '<div class="hd-crbar__track"><div class="hd-crbar__fill" data-tier="' + t + '" style="width:' + pct + '%"></div></div>' +
299        '<span class="hd-crbar__count">' + esc(count) + '</span>' +
300        '</div>'
301      );
302    }
303    return '<div class="hd-crbars">' + rows + '</div>';
304  }
305
306  function heroTierVoteButtonsHtml(mine, canVoteFlag) {
307    var buttons = '';
308    for (var i = 0; i < TIER_LETTERS.length; i++) {
309      var t = TIER_LETTERS[i];
310      var on = mine === t;
311      buttons += (
312        '<button type="button" class="hd-voterow__btn' + (on ? ' on' : '') + '" ' +
313        'data-hero-tier="' + t + '" aria-pressed="' + (on ? 'true' : 'false') + '" aria-label="Rate ' + t + '"' +
314        (canVoteFlag ? '' : ' disabled') + '>' +
315        '<span class="tdtt-tier tdtt-tier--lg" data-tier="' + t + '">' + t + '</span>' +
316        '</button>'
317      );
318    }
319    return '<div class="hd-voterow">' + buttons + '</div>';
320  }
321
322  // Pure — see this section's header comment. `slug` looks up `payload.
323  // heroes` itself rather than trusting a pre-found entry, mirroring
324  // desktop's `payload.heroes.find(h => h.slug === slug)` (heroTierStore.ts
325  // via TierPanel.tsx) exactly, including its same defaults for a missing
326  // entry (null tier/score, 0 votes, an empty distribution) — a hero absent
327  // from `payload.heroes` (not yet loaded, or simply not in the pack) reads
328  // identically to a real hero with zero votes and no prior tier.
329  function tierPanelHtml(slug, payload, canVoteFlag, voteErrorFlag) {
330    var heroes = (payload && Array.isArray(payload.heroes)) ? payload.heroes : [];
331    var entry = null;
332    for (var i = 0; i < heroes.length; i++) {
333      if (heroes[i] && heroes[i].slug === slug) {
334        entry = heroes[i];
335        break;
336      }
337    }
338    var community = (entry && entry.community) || {
339      tier: null,
340      score: null,
341      votes: 0,
342      distribution: emptyTierDistribution(),
343    };
344    var winRate = entry ? entry.winRate : null;
345    var mine = (payload && payload.mine && payload.mine[slug]) || null;
346    var minGames = (payload && payload.minGames) || 0;
347    var games = entry ? (entry.games || 0) : 0;
348
349    var tierAttr = community.tier ? ' data-tier="' + esc(community.tier) + '"' : '';
350    var communityBody = (
351      '<div class="hd-crhead">' +
352      '<span class="tdtt-tier tdtt-tier--lg"' + tierAttr + '>' + (community.tier ? esc(community.tier) : '—') + '</span>' +
353      '<p class="hd-text hd-text--sm">' + esc(communityVotesCopy(community)) + '</p>' +
354      '</div>' +
355      communityDistributionRowsHtml(community.distribution)
356    );
357
358    var yourBody = (
359      heroTierVoteButtonsHtml(mine, canVoteFlag) +
360      (canVoteFlag ? '' : '<p class="hd-text hd-text--sm">Verify your email to vote</p>') +
361      // Mutually exclusive with the verify-email note above via the
362      // canVoteFlag guard — EMAIL_UNVERIFIED always shows that note instead,
363      // never this one.
364      (canVoteFlag && voteErrorFlag ? '<p class="hd-text hd-text--sm">Couldn\'t save your vote. Try again.</p>' : '')
365    );
366
367    var winRateBody = winRate
368      ? (
369        '<div class="hd-crhead">' +
370        '<span class="tdtt-tier tdtt-tier--lg" data-tier="' + esc(winRate.tier) + '">' + esc(winRate.tier) + '</span>' +
371        '<p class="hd-text hd-text--sm">
371' + esc(winRate.rate.toFixed(1)) + '% · ' + esc(fmtNum(winRate.games)) + ' games</p>' +
372        '</div>'
373      )
374      : (
375        '<p class="hd-text hd-text--sm">Needs ' + esc(minGames) + ' online games — ' + esc(fmtNum(games)) + ' so far</p>'
376      );
377
378    // The three panels sit in one row (`.hd-tierrow`, 3 columns from 900px).
379    return (
380      '<div class="hd-tierrow">' +
381      panelHtml('Community Rating', 'star', communityBody) +
382      panelHtml('Your Rating', 'crown', yourBody) +
383      panelHtml('Online win rate', 'trophy', winRateBody) +
384      '</div>'
385    );
386  }
387
388  // True only when the viewer can vote RIGHT NOW: signed in, not explicitly
389  // reported unverified by window.BFAuth (unknown/undefined — the profile
390  // hasn't been fetched yet — counts as allowed; the server's own 403 is the
391  // real gate), and not locally flagged unverified by a previous
392  // EMAIL_UNVERIFIED rejection this session. Mirrors desktop's canVote()
393  // (heroTierStore.ts).
394  function heroTiersCanVote() {
395    if (!(window.BFAuth && typeof window.BFAuth.isLoggedIn === 'function' && window.BFAuth.isLoggedIn())) {
396      return false;
397    }
398    var user = (window.BFAuth && typeof window.BFAuth.getBFUser === 'function') ? window.BFAuth.getBFUser() : null;
399    if (user && user.emailVerified === false) return false;
400    if (tierUnverified) return false;
401    return true;
402  }
403
404  // Bakes tierVoteState's optimistic override into a SHALLOW clone of
405  // `payload.mine` (never mutating the shared cache hero-tiers-data.js
406  // owns) so tierPanelHtml() itself can stay pure. A no-op for every hero
407  // OTHER than the one currently mid-request, and once the request settles
408  // (success or failure) tierVoteState.inFlight goes false and this stops
409  // touching anything at all — see handleTierVoteClick() below.
410  function effectiveHeroTiersPayload(payload, slug) {
411    if (!payload || !tierVoteState.inFlight || tierVoteState.slug !== slug) return payload;
412    var mine = {};
413    for (var key in payload.mine) {
414      if (Object.prototype.hasOwnProperty.call(payload.mine, key)) mine[key] = payload.mine[key];
415    }
416    if (tierVoteState.tier) {
417      mine[slug] = tierVoteState.tier;
418    } else {
419      delete mine[slug];
420    }
421    return { heroes: payload.heroes, mine: mine, minGames: payload.minGames };
422  }
423
424  // [data-hero-tier] click handler (wired in wire() below). Tapping the
425  // viewer's OWN current vote clears it; any other tier votes for it.
426  // Optimistically re-renders the panel (via onTabChange(), the same full
427  // body-rebuild-and-rewire callback every other control in this file
428  // already uses) before the request settles, then re-renders again once it
429  // does — on success the shared cache (hero-tiers-data.js) already carries
430  // the new truth, so tierPanelHtml() picks it up straight from
431  // window.TDTT.heroTiers.peek(); on failure nothing in that cache changed,
432  // so the re-render reverts to whatever it already said, and an
433  // EMAIL_UNVERIFIED rejection additionally disables every future vote this
434  // session (see tierUnverified above).
435  function handleTierVoteClick(tier, hero, onTabChange) {
436    if (!tier || !window.TDTT.heroTiers) return;
437    if (!heroTiersCanVote()) return;
438    var slug = heroSlug(hero);
439    // Double-tap guard — ignore a second click on the SAME hero while its
440    // first request is still in flight.
441    if (tierVoteState.inFlight && tierVoteState.slug === slug) return;
442
443    var payload = (typeof window.TDTT.heroTiers.peek === 'function') ? window.TDTT.heroTiers.peek() : null;
444    var currentMine = (payload && payload.mine && payload.mine[slug]) || null;
445    var clearing = currentMine === tier;
446
447    tierVoteState = { slug: slug, tier: clearing ? null : tier, inFlight: true };
448    tierVoteError = false;
449    onTabChange();
450
451    var request = clearing ? window.TDTT.heroTiers.clear(slug) : window.TDTT.heroTiers.vote(slug, tier);
452    request
453      .then(function () {
454        tierVoteState = { slug: null, tier: null, inFlight: false };
455        onTabChange();
456      })
457      .catch(function (err) {
458        if (err && err.code === 'EMAIL_UNVERIFIED') {
459          tierUnverified = true;
460        } else {
461          tierVoteError = true;
462        }
463        tierVoteState = { slug: null, tier: null, inFlight: false };
464        onTabChange();
465      });
466  }
467
468  // Task 10 (codex-hero-detail-parity, docs/UI_UX/Dice_Throne_Codex/
469  // codex_character_overview.png's "HERO IDENTITY" panel) — the first panel
470  // in the Overview tab's `.hd-stack`, above Dice Faces (see
471  // overviewTabHtml() below). A portrait, a short identity row list, then
472  // the overview's first paragraph as a lead-in.
473  //
474  // Two product rules this must never violate:
475  //   1. Starting health is NEVER rendered anywhere in the codex — all 44
476  //      heroes start at 50; it's a game-start value, not a character trait
477  //      (CLAUDE.md). No row here reads hero.starting_health.
478  //   2. No "Strengths vs Weaknesses" panel. The mockup shows one, but
479  //      hero.matchups only carries strong/weak HERO LISTS and a `general`
480  //      blurb, never bullet prose — inventing bullets from that shape would
481  //      be the exact "real data or nothing" violation this surface already
482  //      forbids elsewhere (see communityStatsHtml()'s header comment).
483  //      Omitted entirely, not attempted.
484  //
485  // Row set: Class / Set / Archetype / Difficulty. The mockup also labels a
486  // "Season" row, but there is no separate season field anywhere in the
487  // pack/encyclopedia schema — codex.js's own heroPanelIdentityPillsHtml()
488  // (Task 5, the panel HEADER's own pills) already made this exact call:
489  // "the 'Season / Set' pill the brief names is rendered as the one `.set`
490  // pill." Same field, same reasoning, rendered here as a labelled row
491  // instead of a bare pill. "Class" reads the hero's own name — Dice Throne
492  // has no separate class/name split (the hero's name IS the class), so
493  // this deliberately repeats the header title rather than fabricating a
494  // field that doesn't exist.
495  //
496  // Every row is independently omitted when its own field is absent — never
497  // a blank value, never the string "undefined". Difficulty renders the raw
498  // 1-6 number: the friendly label (window.TDTT.roster.DIFFICULTY_LABELS)
499  // lives in codex-roster.js, already read by codex.js's own header pill
500  // right above this panel — hero-detail.js has never taken a dependency on
501  // that global (only on window.TDTT.heroStats), and a second, redundant
502  // difficulty label here isn't worth adding one.
503  //
504  // Panel title is the literal uppercase string 'HERO IDENTITY', not title
505  // case + CSS text-transform (every other panelHtml() call here uses title
506  // case) — matching this surface's existing precedent for a label a test
507  // asserts on literally (see the Cards tab's 'TOTAL CARDS'/'HERO CARDS'/
508  // 'GENERAL CARDS' tile labels above). Harmless either way since
509  // text-transform: uppercase is idempotent on already-uppercase text.
510  function heroIdentityRowHtml(label, value) {
511    if (value === null || value === undefined || value === '') return '';
512    return (
513      '<div class="hd-identity__row">' +
514      '<span class="hd-identity__label">' + esc(label) + '</span>' +
515      '<span class="hd-identity__value">' + esc(value) + '</span>' +
516      '</div>'
517    );
518  }
519
520  function heroIdentityPanelHtml(hero) {
521    var portrait = '';
522    if (hero.assets && hero.assets.portrait) {
523      // hero.assets.portrait is server-enumerated (readCharacterAssets()) —
524      // already the correct extension, no .jpg/.png fallback needed (see
525      // this file's header comment's "Data facts"). onerror only guards a
526      // pack that's mid-install.
527      portrait = (
528        '<img class="hd-identity__portrait" src="' + esc(hero.assets.portrait) + '" alt="" loading="lazy" ' +
529        'data-fb-hide>'
530      );
531    }
532    // The Class row is built inline (not via heroIdentityRowHtml, which
533    // esc()s its own `value` param internally) so this file's guard test
534    // for the hero's own name field being escaped everywhere it's read can
535    // see the call directly, matching how every other spot in this file
536    // that reads that field already does. Gated on the ESCAPED value (not a
537    // bare truthiness check on the raw field) so the literal call site is
538    // the only place this reads that field.
539    var heroNameHtml = esc(hero.name);
540    var classRow = heroNameHtml
541      ? '<div class="hd-identity__row"><span class="hd-identity__label">Class</span>' +
542        '<span class="hd-identity__value">' + heroNameHtml + '</span></div>'
543      : '';
544    var rows =
545      classRow +
546      heroIdentityRowHtml('Set', hero.set) +
547      heroIdentityRowHtml('Archetype', hero.archetype) +
548      heroIdentityRowHtml('Difficulty', hero.difficulty);
549    var paras = Array.isArray(hero.overview) ? hero.overview : [];
550    var lead = paras.length && paras[0] ? '<p class="hd-text">' + esc(paras[0]) + '</p>' : '';
551    if (!portrait && !rows && !lead) return '';
552    return panelHtml(
553      'HERO IDENTITY',
554      'user',
555      '<div class="hd-identity">' +
556      '<div class="hd-identity__top">' + portrait +
557      (rows ? '<div class="hd-identity__rows">' + rows + '</div>' : '') +
558      '</div>' +
559      lead +
560      '</div>'
561    );
562  }
563
564  // Fix round 1 (pre-existing bug, found in the owner's browser pass — same
565  // class as E5.8, which hit hero.abilities[].activation_requirements the
566  // same way): the pack ships `diceFaces` (camelCase); `hero.dice_faces`
567  // (snake_case) is never produced anywhere in encyclopedia-data.ts. Reading
568  // only the snake_case spelling made `faces` always `[]`, so this panel hit
569  // its own `if (!faces.length) return ''` guard and silently vanished for
570  // every hero on THIS surface only — desktop reads `char.diceFaces`
571  // explicitly (Characters.tsx) and `/m` dual-reads both spellings already
572  // (bf-heroselect.js). This was invisible in the test suite because every
573  // fixture in this file hand-writes `dice_faces` to match what the code
574  // read — a fixture can only prove the code handles the shape IT supplies,
575  // never that the shape matches what the real payload actually sends. Dual
576  // read, same idiom as `ability.activationRequirements ||
577  // ability.activation_requirements` a few hundred lines below — tolerates
578  // either spelling reaching this function from any path, and the server
579  // payload's shape is out of scope to change here (this task's own brief:
580  // /m reads the same payload, so a server rename's blast radius is bigger
581  // than this file).
582  function diceFacesPanelHtml(hero) {
583    var faces = Array.isArray(hero.diceFaces)
584      ? hero.diceFaces
585      : (Array.isArray(hero.dice_faces) ? hero.dice_faces : []);
586    if (!faces.length) return '';
587    var slug = heroSlug(hero);
588    // 3 x 2 grid: the die image plus its leaflet label (the face's symbol
589    // name). No "Face N" text. A broken image hides itself; the label stays.
590    var cells = faces.map(function (f) {
591      var src = safeImageUrl('/assets/extracted/characters/' + slug + '/' + (f.icon_path || ''));
592      var img = src
593        ? '<img src="' + esc(src) + '" alt="' + esc(f.symbol || '') + '" loading="lazy" data-fb-hide>'
594        : '';
595      return (
596        '<div class="hd-face">' + img +
597        '<span class="hd-face__symbol">' + esc(f.symbol || '') + '</span>' +
598        '</div>'
599      );
600    }).join('');
601    return panelHtml('Dice Faces', 'target', '<div class="hd-facegrid">' + cells + '</div>');
602  }
603
604  // ── Community Stats (C6) ─────────────────────────────────────────────────
605  // Reference: apps/desktop/src/routes/Characters.tsx's `CharacterRankings`,
606  // rendered inside desktop's `OverviewTab` between Dice Faces and the
607  // Character Analysis paragraphs — same position here (see
608  // overviewTabHtml() below).
609  //
610  // Owner rulings (binding, see the C6 brief):
611  //   1. Full parity on the report card — keep the win-rate donut ring and
612  //      the S/A/B/C/D/F grade cards.
613  //   2. Do NOT port the Best/Worst matchup tables. C5d's own Matchups tab
614  //      already owns the word "Matchups" on this surface, and dropping them
615  //      removes the only dependency on the DB Character.id UUID (which
616  //      CLAUDE.md warns regenerates on every characters reseed) — this
617  //      section never fetches or resolves a characterId, only
618  //      characterSlug.
619  //
620  // "Real data or nothing", not desktop's "No community data yet. Play some
621  // games to see stats!" promo box — the standing surface rule since C5a. An
622  // empty promo box inside a reference codex reads as broken; this section
623  // renders nothing at all when the hero has no row in either list, and each
624  // of its three sub-sections independently renders nothing when its own
625  // numbers are absent (matching desktop's own per-section gates where they
626  // exist, e.g. Combat Performance's `gamesPlayed > 0 && avgDamagePerGame >
627  // 0`).
628  function findBySlug(list, slug) {
629    var arr = Array.isArray(list) ? list : [];
630    for (var i = 0; i < arr.length; i++) {
631      if (arr[i] && arr[i].characterSlug === slug) return arr[i];
632    }
633    return null;
634  }
635
636  // Copies desktop's CharacterRankings gradeFor() thresholds VERBATIM
637  // (Characters.tsx) — an arbitrary heuristic, not a principled scale. Do not
638  // adjust these numbers: this surface and desktop must keep agreeing on
639  // what grade a given hero has, or the two report cards silently disagree.
640  // A non-finite `pct` (NaN, e.g. from a missing/string field) fails every
641  // `>=` comparison and falls through to 'F' — a safe, visible default
642  // rather than a spuriously high grade.
643  var GRADE_CLASSES = {
644    S: 'hd-grade--s',
645    A: 'hd-grade--a',
646    B: 'hd-grade--b',
647    C: 'hd-grade--c',
648    D: 'hd-grade--d',
649    F: 'hd-grade--f',
650  };
651
652  function gradeFor(pct) {
653    var letter;
654    if (pct >= 80) letter = 'S';
655    else if (pct >= 65) letter = 'A';
656    else if (pct >= 50) letter = 'B';
657    else if (pct >= 35) letter = 'C';
658    else if (pct >= 20) letter = 'D';
659    else letter = 'F';
660    return { letter: letter, cls: GRADE_CLASSES[letter] };
661  }
662
663  function gradeCardHtml(grade, value, label) {
664    return (
665      '<div class="hd-grade">' +
666      '<span class="hd-grade__badge ' + grade.cls + '">' + esc(grade.letter) + '</span>' +
667      '<div><p class="hd-grade__value">' + esc(value) + '</p>' +
668      '<p class="hd-grade__label">' + esc(label) + '</p></div>' +
669      '</div>'
670    );
671  }
672
673  // winRate can arrive as null/undefined/''/a non-numeric string from the
674  // API. Number() on a non-numeric string (or undefined) yields NaN, and an
675  // SVG `stroke-dasharray="NaN 263.9"` renders as a FULL ring (100%), not an
676  // empty or broken one — but Number(null) and Number('') both coerce
677  // cleanly to 0, which is indistinguishable from a genuine 0% win rate
678  // unless those absent-value cases are excluded up front. Under this
679  // surface's "real data or nothing" rule, a fabricated 0% is worse than
680  // showing nothing — null/undefined/'' all mean the server didn't send a
681  // value, so all three return null here, same as any other non-finite
682  // input. A real numeric (or numeric-string) 0 is not caught by this check
683  // and still renders a real 0%.
684  //
685  // Used for BOTH the ring and the grade cards. Fix round 1 guarded only the
686  // ring, which left the worse half on screen: the ring vanished while the
687  // fabricated "0%" stayed, graded F — a number the server never sent,
688  // stripped of the visual that gave it context.
689  function toFiniteStat(v) {
690    if (v === null || v === undefined || v === '') return null;
691    var n = Number(v);
692    return isFinite(n) ? n : null;
693  }
694
695  // Win-rate donut ring — ported from desktop's inline SVG (CharacterRankings,
696  // Characters.tsx): 100x100 viewBox, r=42, rotated -90deg (the whole <svg>
697  // rotates via CSS here, not a per-shape transform — same visual result as
698  // desktop's `-rotate-90` on the <svg>), a translucent red track plus a
699  // green arc whose dash length is computed here in JS, never in CSS.
700  // Decorative reinforcement of the `{wr}%` text already beside it, so the
701  // <svg> is `aria-hidden` rather than getting its own ARIA meter role.
702  function winRateRingHtml(winRate, gamesPlayed) {
703    var wr = toFiniteStat(winRate);
704    if (wr === null) return '';
705    var r = 42;
706    var circumference = 2 * Math.PI * r;
707    var dash = (wr / 100) * circumference;
708    var pctClass = wr >= 50 ? 'hd-ring__pct--win' : 'hd-ring__pct--loss';
709    return (
710      '<div class="hd-ring">' +
711      '<div class="hd-ring__wrap">' +
712      '<svg viewBox="0 0 100 100" class="hd-ring__svg" aria-hidden="true">' +
713      '<circle class="hd-ring__track" cx="50" cy="50" r="' + r + '"></circle>' +
714      '<circle class="hd-ring__arc" cx="50" cy="50" r="' + r + '" ' +
715      'stroke-dasharray="' + dash.toFixed(2) + ' ' + circumference.toFixed(2) + '"></circle>' +
716      '</svg>' +
717      '<div class="hd-ring__mid"><span class="hd-ring__pct ' + pctClass + '">' + esc(wr) + '%</span>' +
718      '<span class="hd-ring__wr">WR</span></div>' +
719      '</div>' +
720      '<span class="hd-ring__games">' + esc(gamesPlayed) + ' games</span>' +
721      '</div>'
722    );
723  }
724
725  // Report Card — the win-rate ring plus four grade cards (Win Rate, Pick
726  // Rate, Avg Dmg, Dmg Efficiency). Gated on `gamesPlayed > 0`, matching
727  // desktop's own `charStats && gamesPlayed > 0` guard.
728  function reportCardHtml(stats, gamesPlayed) {
729    if (!stats || !(gamesPlayed > 0)) return '';
730    var winRate = toFiniteStat(stats.winRate);
731    var pickRate = toFiniteStat(stats.pickRate);
732    var avgDamagePerGame = Number(stats.avgDamagePerGame);
733    var totalDamageDealt = Number(stats.totalDamageDealt) || 0;
734    var totalDamageReceived = Number(stats.totalDamageReceived) || 0;
735
736    var cards = '';
737    // A missing rate skips its whole card rather than grading a fabricated 0%
738    // as an F — see toFiniteStat() above. A real 0 still renders and still
739    // grades F, which is a true statement about the hero.
740    if (winRate !== null) {
741      cards += gradeCardHtml(gradeFor(winRate), winRate + '%', 'Win Rate');
742    }
743    // Desktop's own heuristic scalings, copied verbatim — do not "fix" them.
744    if (pickRate !== null) {
745      cards += gradeCardHtml(gradeFor(pickRate * 2), pickRate + '%', 'Pick Rate');
746    }
747    if (avgDamagePerGame > 0) {
748      cards += gradeCardHtml(
749        gradeFor(Math.min(100, avgDamagePerGame * 2)),
750        String(avgDamagePerGame),
751        'Avg Dmg'
752      );
753    }
754    // dmgEfficiency: totalDamageDealt / totalDamageReceived, fixed to 2dp,
755    // rendered only when totalDamageReceived > 0 — exactly desktop's guard.
756    var dmgEfficiency = totalDamageReceived > 0 ? (totalDamageDealt / totalDamageReceived).toFixed(2) : null;
757    if (dmgEfficiency !== null) {
758      cards += gradeCardHtml(
759        gradeFor(Math.min(100, parseFloat(dmgEfficiency) * 50)),
760        dmgEfficiency,
761        'Dmg Eff.'
762      );
763    }
764
765    return panelHtml(
766      'Community Stats',
767      'chart',
768      '<div class="hd-reportcard">' + winRateRingHtml(stats.winRate, gamesPlayed) +
769      '<div class="hd-gradegrid">' + cards + '</div></div>'
770    );
771  }
772
773  // Combat Performance — six stat tiles reusing `.tdtt-statrow`/`.tdtt-stat`
774  // verbatim (the same primitive leaderboard.js's own summary stat row
775  // uses), rather than inventing a new tile class. Colour accents mostly
776  // mirror desktop's per-stat Tailwind colours 1:1 (orange/gold/cyan/red/
777  // green); desktop's sixth ("Effects Applied") uses `text-purple-400`,
778  // which has no token equivalent here (CLAUDE.md: `--purple` in tokens.css
779  // is a documented DUPLICATE of `--cyan`, and Abilities Used already uses
780  // cyan) — mapped to `--magenta` instead, the same "nearest violet-family
781  // token" substitution the Matchups tab's Slow Burn archetype chip already
782  // makes for the identical reason.
783  function statTileHtml(iconName, value, label, accent) {
784    return (
785      '<div class="tdtt-stat" style="--sec-accent:' + accent + '">' +
786      '<span class="i">' + icon(iconName) + '</span>' +
787      '<span class="v">' + esc(value) + '</span>' +
788      '<span class="l">' + esc(label) + '</span>' +
789      '</div>'
790    );
791  }
792
793  function combatPerformancePanelHtml(stats, gamesPlayed) {
794    if (!stats) return '';
795    var avgDamagePerGame = Number(stats.avgDamagePerGame) || 0;
796    // Matches desktop's own gate exactly — CLAUDE.md/the brief both call
797    // this out explicitly.
798    if (!(gamesPlayed > 0 && avgDamagePerGame > 0)) return '';
799    var tiles =
800      statTileHtml('flame', avgDamagePerGame, 'Avg Dmg/Game', 'var(--orange)') +
801      statTileHtml('zap', Number(stats.ultimatesLanded) || 0, 'Ultimates', 'var(--gold)') +
802      statTileHtml('target', fmtNum(stats.abilitiesActivated), 'Abilities Used', 'var(--cyan)') +
803      statTileHtml('swords', fmtNum(stats.totalDamageDealt), 'Total Dmg Dealt', 'var(--red)') +
804      statTileHtml('shield', fmtNum(stats.totalHealed), 'Total Healed', 'var(--green)') +
805      statTileHtml('flag', Number(stats.statusEffectsApplied) || 0, 'Effects Applied', 'var(--magenta)');
806    return panelHtml('Combat Performance', 'swords', '<div class="tdtt-statrow">' + tiles + '</div>');
807  }
808
809  // Draft & Pick Rates — reuses `.hd-minicard`/`.hd-minicard-grid` verbatim
810  // (Overview's own Status Effects/Companions primitive above) rather than
811  // desktop's progress-bar treatment, which has no existing primitive on
812  // this surface and the brief scopes new CSS to only the ring and the grade
813  // badge. Renders whichever of `stats` (in-game pick rate)/`draft` (draft
814  // pick/ban rate) is present — matches desktop's own `(charStats ||
815  // draftStats)` gate, and each card is independently guarded by its own
816  // source object being present.
817  // `negative` swaps the LABEL's accent from green to red — used only for
818  // Draft Ban below, where a HIGH rate is bad, unlike Pick Rate/Draft Pick
819  // where a high rate is good. The accent sits on the label, not the
820  // percentage, for two reasons: desktop does the same (its Draft Ban label
821  // is red and its percentage stays white), and every other mini-card in
822  // this file (Status Effects, Companions) already accents the NAME. A
823  // per-card override via a second class, not a change to the shared
824  // `.hd-minicard` primitive those sections also use.
825  function draftMiniCardHtml(label, rate, count, countLabel, negative) {
826    var pct = isFinite(Number(rate)) ? Number(rate) : 0;
827    var accentClass = negative ? 'hd-minicard__name--accent-neg' : 'hd-minicard__name--accent';
828    return (
829      '<div class="hd-minicard">' +
830      '<div class="hd-minicard__head">' +
831      '<span class="hd-minicard__name ' + accentClass + '">' + esc(label) + '</span>' +
832      '<span class="hd-minicard__name">' + esc(pct) + '%</span>' +
833      '</div>' +
834      '<p class="hd-text hd-text--sm">' + esc(fmtNum(count)) + ' ' + esc(countLabel) + '</p>' +
835      '</div>'
836    );
837  }
838
839  function draftRatesPanelHtml(stats, draft) {
840    if (!stats && !draft) return '';
841    var cards = '';
842    if (stats) cards += draftMiniCardHtml('Pick Rate', stats.pickRate, stats.picks, 'picks');
843    if (draft) {
844      cards += draftMiniCardHtml('Draft Pick', draft.pickRate, draft.picks, 'picks');
845      cards += draftMiniCardHtml('Draft Ban', draft.banRate, draft.bans, 'bans', true);
846    }
847    return panelHtml('Draft & Pick Rates', 'flag', '<div class="hd-minicard-grid">' + cards + '</div>');
848  }
849
850  // Entry point — looks this hero up by slug (characterSlug === hero.id) in
851  // BOTH hero-independent lists (see rankingsData's own comment above),
852  // renders nothing at all if neither has a row for this hero, otherwise
853  // renders whichever of the three sections have real data.
854  function communityStatsHtml(hero) {
855    if (!rankingsData) return '';
856    var slug = heroSlug(hero);
857    var stats = findBySlug(rankingsData.characters, slug);
858    var draft = findBySlug(rankingsData.draftCharacters, slug);
859    if (!stats && !draft) return '';
860    var gamesPlayed = stats ? (Number(stats.wins) || 0) + (Number(stats.losses) || 0) : 0;
861    return (
862      reportCardHtml(stats, gamesPlayed) +
863      combatPerformancePanelHtml(stats, gamesPlayed) +
864      draftRatesPanelHtml(stats, draft)
865    );
866  }
867
868  function analysisPanelHtml(hero) {
869    var paras = Array.isArray(hero.overview) ? hero.overview : [];
870    if (!paras.length) return '';
871    return panelHtml('Character Analysis', 'scroll', paragraphsHtml(paras));
872  }
873
874  // Status Effects tab (R9): token image, name, type, stack limit, what it
875  // does, and a link to the relevant rule. The model comes from the shared
876  // twin, which already validates the rules descriptor (anchor against
877  // /^[a-z0-9-]+$/, sheet as a non-negative integer) - this function maps a
878  // validated descriptor to a route and never reads `rulesRef` itself. Every
879  // server string is escaped; the token url goes through safeImageUrl. Reads
880  // statusEffects only (Alchemist potions are Companions, rendered once, on
881  // the Overview tab).
882  var STATUS_TYPE_CLASSES = { negative: 'hd-status__type--negative', positive: 'hd-status__type--positive' };
883
884  function statusRulesLinkHtml(rules) {
885    if (!rules) return '';
886    if (rules.kind === 'rule') {
887      return (
888        '<a class="hd-status__rules" href="/app/rules?game=DICE_THRONE&amp;section=' + esc(rules.anchor) + '" ' +
889        'data-hero-rule="' + esc(rules.anchor) + '">See the rule</a>'
890      );
891    }
892    if (rules.kind === 'leaflet') {
893      return (
894        '<button type="button" class="hd-status__rules" data-hero-leaflet="' + esc(rules.sheet) + '">' +
895        'See the leaflet</button>'
896      );
897    }
898    return '';
899  }
900
901  function statusEffectsTabHtml(hero) {
902    var model = typeof twin.statusEffectsModel === 'function' ? twin.statusEffectsModel(hero) : [];
903    if (!model.length) {
904      return (
905        '<div class="tdtt-panel hd-panel--center">No status effects for ' + esc(hero.name) + '.</div>'
906      );
907    }
908    var items = model.map(function (se) {
909      var tokenSrc = se.token ? safeImageUrl(se.token.url) : '';
910      var token = tokenSrc
911        ? '<img class="hd-status__token" src="' + esc(tokenSrc) + '" alt="' + esc(se.token.label || se.name) + '" ' +
912          'loading="lazy" data-fb-hide>'
913        : '';
914      var typeCls = Object.prototype.hasOwnProperty.call(STATUS_TYPE_CLASSES, se.type)
915        ? ' ' + STATUS_TYPE_CLASSES[se.type]
916        : '';
917      var type = se.type ? '<span class="hd-status__type' + typeCls + '">' + esc(se.type) + '</span>' : '';
918      var stack = se.stackLabel ? '<span class="hd-status__stack">' + esc(se.stackLabel) + '</span>' : '';
919      var text = se.text ? '<p class="hd-status__text">' + esc(se.text) + '</p>' : '';
920      return (
921        '<div class="hd-status">' + token +
922        '<div class="hd-status__body">' +
923        '<div class="hd-status__head"><span class="hd-status__name">' + esc(se.name) + '</span>' + type + stack + '</div>' +
924        text + statusRulesLinkHtml(se.rules) +
925        '</div></div>'
926      );
927    }).join('');
928    return '<div class="hd-status-list">' + items + '</div>';
929  }
930
931  // Extra dice (R4), generic: any hero whose payload carries `extraDice`
932  // gets one panel per die, keyed by nothing but the data (no slug check).
933  // `image` is a server-resolved URL or null; a null image (Raveness's
934  // Nevermore Die) renders the text-only variant. Faces are optional.
935  function extraDicePanelsHtml(hero) {
936    var list = Array.isArray(hero.extraDice)
937      ? hero.extraDice
938      : (Array.isArray(hero.extra_dice) ? hero.extra_dice : []);
939    var out = '';
940    for (var i = 0; i < list.length; i++) {
941      var die = list[i];
942      if (!die || typeof die !== 'object' || typeof die.name !== 'string' || !die.name) continue;
943      var src = safeImageUrl(die.image);
944      var img = src
945        ? '<img class="hd-extradie__img" src="' + esc(src) + '" alt="' + esc(die.name) + '" loading="lazy" data-fb-hide>'
946        : '';
947      var desc = typeof die.description === 'string' && die.description
948        ? '<p class="hd-extradie__text">' + esc(die.description) + '</p>'
949        : '';
950      var faces = '';
951      if (Array.isArray(die.faces) && die.faces.length) {
952        faces = '<ul class="hd-extradie__faces">' + die.faces.map(function (f) {
953          if (!f || typeof f.label !== 'string') return '';
954          var fsrc = safeImageUrl(f.image);
955          var fimg = fsrc
956            ? '<img class="hd-extradie__faceimg" src="' + esc(fsrc) + '" alt="" loading="lazy" data-fb-hide>'
957            : '';
958          return '<li class="hd-extradie__face">' + fimg + '<span>' + esc(f.label) + '</span></li>';
959        }).join('') + '</ul>';
960      }
961      out += panelHtml(
962        die.name,
963        'dice',
964        '<div class="hd-extradie">
964' + img + '<div class="hd-extradie__body">' + desc + faces + '</div></div>'
965      );
966    }
967    return out;
968  }
969
970  function companionsPanelHtml(hero) {
971    var list = Array.isArray(hero.companions) ? hero.companions : [];
972    if (!list.length) return '';
973    var cells = list.map(function (c) {
974      var desc = c.description ? '<p class="hd-text hd-text--sm">' + esc(c.description) + '</p>' : '';
975      return (
976        '<div class="hd-minicard">' +
977        '<span class="hd-minicard__name hd-minicard__name--accent">' + esc(c.name) + '</span>' +
978        desc +
979        '</div>'
980      );
981    }).join('');
982    return panelHtml('Companions', 'users', '<div class="hd-minicard-grid">' + cells + '</div>');
983  }
984
985  function overviewTabHtml(hero) {
986    var slug = heroSlug(hero);
987    var rawTierPayload = (window.TDTT.heroTiers && typeof window.TDTT.heroTiers.peek === 'function')
988      ? window.TDTT.heroTiers.peek()
989      : null;
990    var tierPayload = effectiveHeroTiersPayload(rawTierPayload, slug);
991    return (
992      '<div class="hd-stack">' +
993      tierPanelHtml(slug, tierPayload, heroTiersCanVote(), tierVoteError) +
994      '<div class="hd-overview-row">' +
995      heroIdentityPanelHtml(hero) +
996      diceFacesPanelHtml(hero) +
997      '</div>' +
998      extraDicePanelsHtml(hero) +
999      communityStatsHtml(hero) +
1000      analysisPanelHtml(hero) +
1001      companionsPanelHtml(hero) +
1002      '</div>'
1003    );
1004  }
1005
1006  // ── Rulesheets tab ───────────────────────────────────────────────────────
1007  // C3 — owner: "Rule sheets should not have so many lines and margin spaces
1008  // within margin spaces. Just have the rule sheets be side by side in the
1009  // center, if there are more then add those in to fill the line up." Each
1010  // sheet used to sit inside its own `.tdtt-panel` (padding + border) with a
1011  // `.tdtt-panel__head` label, laid out in a fixed 2-column grid with gaps —
1012  // panel padding inside grid gap inside panel border, exactly the nested
1013  // margins the owner is describing. Now it's bare `<img class="hd-
1014  // rulesheet">` elements in one `.hd-rulesheet-row` — a centered, wrapping
1015  // flex row (see hero-detail.css). Sheet counts vary per hero (Druid ships
1016  // 4, Wolverine 3, most heroes 2 — see f3f10c4's sibling finding); a
1017  // wrapping centered row handles 1, 2, 3 and 4 without any special-case CSS
1018  // for odd counts.
1019  function rulesheetsTabHtml(hero) {
1020    var sheets = hero.assets && Array.isArray(hero.assets.rulesheets) ? hero.assets.rulesheets : [];
1021    if (!sheets.length) {
1022      return (
1023        '<div class="lb-status lb-empty"><p class="lb-empty-title">No rulesheet images available</p>' +
1024        '<p class="lb-empty-sub">' + esc(hero.name) + '’s rulesheet scans aren’t in the pack yet.</p></div>'
1025      );
1026    }
1027    // Borderless, two per row (CSS); each sheet is a zoom button that opens
1028    // the shared overlay with the full-size image.
1029    var cards = sheets.map(function (raw, i) {
1030      var src = safeImageUrl(raw);
1031      if (!src) return '';
1032      var labelHtml = esc(hero.name) + ' rulesheet ' + (i + 1);
1033      return (
1034        '<button type="button" class="hd-ability-tile" data-zoom-src="' + esc(src) + '" ' +
1035        'data-zoom-title="' + labelHtml + '">' +
1036        '<img class="hd-rulesheet" src="' + esc(src) + '" alt="' + labelHtml + '" loading="lazy">' +
1037        '</button>'
1038      );
1039    }).join('');
1040    return '<div class="hd-rulesheet-row">' + cards + '</div>';
1041  }
1042
1043  // ── Strategy tab ─────────────────────────────────────────────────────────
1044  function strategyTabHtml(hero) {
1045    var strategy = Array.isArray(hero.strategy) ? hero.strategy : [];
1046    var counter = Array.isArray(hero.counter_play) ? hero.counter_play : [];
1047    if (!strategy.length && !counter.length) {
1048      return '<div class="tdtt-panel hd-panel--center">Strategy data not yet available for ' + esc(hero.name) + '.</div>';
1049    }
1050    var parts = '';
1051    if (strategy.length) parts += panelHtml('How to Play', 'flag', paragraphsHtml(strategy));
1052    if (counter.length) parts += panelHtml('How to Counter', 'ban', paragraphsHtml(counter), 'hd-panel--danger');
1053    return '<div class="hd-stack">' + parts + '</div>';
1054  }
1055
1056  // ── Abilities tab ────────────────────────────────────────────────────────
1057  // Reference: apps/desktop/src/routes/Characters.tsx's `AbilitiesTab` and
1058  // apps/desktop/src/utils/abilityRows.ts's `abilityRows()` (ultimate-first
1059  // ordering). This is the tab the owner explicitly asked for ("abilities
1060  // list ultimate, passive, defensive, offensive"), so the four kinds each
1061  // get their own pill via the shared `.hd-pill` base (hero-detail.css
1062  // defines --neg/--pos/--neutral already; these add --ultimate/--offensive/
1063  // --defensive/--passive the same way, in that same file).
1064  //
1065  // Deliberately smaller than desktop's version: no click-to-expand selected
1066  // state (everything below renders inline, matching this file's existing
1067  // always-expanded Overview/Strategy panels), and no analysis/
1068  // upgrade_analysis/upgrade-card/dicepedia sections — the brief scoped this
1069  // tab to image, name, kind, cost/symbol requirements, damage where
1070  // present, and rules text (effects). `ultimate.secondaryDamage` (1/44
1071  // heroes) is likewise not rendered — out of that same scope.
1072  var ABILITY_KIND_LABELS = {
1073    ultimate: 'Ultimate',
1074    offensive: 'Offensive',
1075    defensive: 'Defensive',
1076    passive: 'Passive',
1077  };
1078
1079  var ABILITY_KIND_CLASSES = {
1080    ultimate: 'hd-pill--ultimate',
1081    offensive: 'hd-pill--offensive',
1082    defensive: 'hd-pill--defensive',
1083    passive: 'hd-pill--passive',
1084  };
1085
1086  function abilityKindPillHtml(kind) {
1087    var cls = ABILITY_KIND_CLASSES[kind] || 'hd-pill--neutral';
1088    var label = ABILITY_KIND_LABELS[kind] || kind;
1089    return '<span class="hd-pill ' + cls + '">' + esc(label) + '</span>';
1090  }
1091
1092  // Task 9 fix round 1, item F — owner-reported defect. STRAIGHT_SMALL,
1093  // STRAIGHT_LARGE and DEFENSIVE_ROLL are not hero dice FACES at all — they
1094  // are generic roll types shared across the whole game, plus two one-off
1095  // spelling/naming variants real data actually ships: DEFENSE_ROLL
1096  // (scarlet-witch) and PASSIVE (black-widow). There is no per-hero image for
1097  // any of these (each hero's dice_symbols/ directory only has real face
1098  // symbols) and no shared copy anywhere in assets/extracted/ either — a
1099  // request for e.g. "characters/paladin/dice_symbols/symbol_STRAIGHT_SMALL
1100  // .png" can never succeed, for any hero. Verified against the real pack
1101  // (data/packs/dice-throne/characters.json): 137 of 623 rendered dice-symbol
1102  // references across all 44 heroes are one of these five. Requesting them as
1103  // images left the neighbouring word with no icon (the `onerror` silently
1104  // hid the broken image), so these render directly as a styled text chip
1105  // instead — no request for an image that cannot exist.
1106  var NON_FACE_DICE_LABELS = {
1107    STRAIGHT_SMALL: 'SMALL STRAIGHT',
1108    STRAIGHT_LARGE: 'LARGE STRAIGHT',
1109    DEFENSIVE_ROLL: 'DEFENSIVE ROLL',
1110    DEFENSE_ROLL: 'DEFENSIVE ROLL',
1111    PASSIVE: 'PASSIVE'
1112  };
1113
1114  // `[{symbol, count}]` — shared shape for both an ultimate's `activation`
1115  // and a tier's `dice`. CLAUDE.md/the brief calls out `ultimate.activation`
1116  // by name as an array that must never be rendered directly as a string
1117  // (produces "[object Object]"); this always walks it as {symbol, count}
1118  // pairs — that contract is unchanged by the chip branch below, which still
1119  // reads `d.symbol`/`d.count` off each pair, never the array itself. The
1120  // dice-symbol image mirrors desktop's TierList (`dice_symbols/
1121  // symbol_<symbol>.png`) with the readable symbol text kept alongside it
1122  // (not only on image error) — the same belt-and-suspenders approach this
1123  // file's own diceFacesPanelHtml already uses above. The owner asked
1124  // explicitly to leave that word-beside-a-working-icon pairing alone; only
1125  // the five known non-face symbols above skip the image entirely, and any
1126  // OTHER symbol that happens to 404 (a variant nobody has found yet) still
1127  // degrades to the same chip look via onerror, rather than silently
1128  // vanishing.
1129  function diceSymbolsHtml(dice, slug) {
1130    var list = Array.isArray(dice) ? dice : [];
1131    if (!list.length) return '';
1132    return list.map(function (d, i) {
1133      var plus = i > 0 ? '<span class="hd-dice__plus">+</span>' : '';
1134      var count = '<span class="hd-dice__count">' + esc(d.count) + 'x</span>';
1135      if (d && Object.prototype.hasOwnProperty.call(NON_FACE_DICE_LABELS, d.symbol)) {
1136        return (
1137          '<span class="hd-dice">' + plus + count +
1138          '<span class="hd-dice__symbol hd-dice__symbol--chip">' +
1139          esc(NON_FACE_DICE_LABELS[d.symbol]) + '</span>' +
1140          '</span>'
1141        );
1142      }
1143      var src = '/assets/extracted/characters/' + esc(slug) + '/dice_symbols/symbol_' + esc(d.symbol) + '.png';
1144      return (
1145        '<span class="hd-dice">' + plus + count +
1146        '<img class="hd-dice__icon" src="' + src + '" alt="' + esc(d.symbol) + '" loading="lazy" ' +
1147        'data-fb-hide data-fb-sibling-add-class="hd-dice__symbol--chip">' +
1148        '<span class="hd-dice__symbol">' + esc(d.symbol) + '</span>' +
1149        '</span>'
1150      );
1151    }).join('');
1152  }
1153
1154  // Abilities tab (R6/R7): the ultimate's art at full width, then an
1155  // image-only grid of the abilities. Text appears only in the enlarged view
1156  // (TDTT.overlay), built by abilityDetailHtml() from the shared
1157  // abilityDetailModel twin, in the tcgCardDetailHtml style: art beside the
1158  // text, every server string escaped, every src through safeImageUrl.
1159  function abilityArtUrl(hero, entry, isUltimate) {
1160    var slug = heroSlug(hero);
1161    if (isUltimate) {
1162      return safeImageUrl('/assets/extracted/characters/' + slug + '/' + (entry.image_path || 'ultimate.png'));
1163    }
1164    return safeImageUrl('/assets/extracted/characters/' + slug + '/abilities/ability_' + entry.slot + '.png');
1165  }
1166
1167  // Ultimate first, then abilities[] in order - the index space the tiles'
1168  // data-hero-ability attribute and wire()'s overlay lookup both use.
1169  function abilityEntriesOf(hero) {
1170    var out = [];
1171    if (hero.ultimate) out.push({ entry: hero.ultimate, isUltimate: true });
1172    var abilities = Array.isArray(hero.abilities) ? hero.abilities : [];
1173    for (var i = 0; i < abilities.length; i++) {
1174      if (abilities[i]) out.push({ entry: abilities[i], isUltimate: false });
1175    }
1176    return out;
1177  }
1178
1179  // One row per tier (dice + damage), shared by the ability itself and each of its upgrades.
1180  function tierRowsHtml(tiers, slug) {
1181    return tiers.map(function (t) {
1182      var parts = [];
1183      if (t.tier != null && tiers.length > 1) parts.push('<span class="hd-tier__badge">T' + esc(t.tier) + '</span>');
1184      var dice = diceSymbolsHtml(t.dice, slug);
1185      if (dice) parts.push(dice);
1186      if (t.damage != null && t.damage > 0) parts.push('<span class="hd-abilitydetail__dmg">' + esc(t.damage) + ' dmg</span>');
1187      return parts.length ? '<div class="hd-abilitydetail__tier">' + parts.join('') + '</div>' : '';
1188    }).join('');
1189  }
1190
1191  // Local YYYY-MM-DD from local date parts (never toISOString): the errata switch day.
1192  function localDayKey() {
1193    var d = new Date();
1194    var m = d.getMonth() + 1;
1195    var day = d.getDate();
1196    return d.getFullYear() + '-' + (m < 10 ? '0' : '') + m + '-' + (day < 10 ? '0' : '') + day;
1197  }
1198
1199  // The plain-text "Errata from <date>: ..." note, escaped like every other server string.
1200  function errataNoteHtml(upcoming) {
1201    if (!upcoming || typeof twin.errataNoteText !== 'function') return '';
1202    return '<p class="hd-abilitydetail__errata">' + esc(twin.errataNoteText(upcoming)) + '</p>';
1203  }
1204
1205  function effectListHtml(effects) {
1206    if (!effects.length) return '';
1207    return '<ul class="hd-abilitydetail__effects">' + effects.map(function (e) {
1208      return '<li>' + esc(e) + '</li>';
1209    }).join('') + '</ul>';
1210  }
1211
1212  // An upgrade card's URL: the hero's own cardImages entry whose path contains the upgrade's
1213  // `cardImage` (same match as the Upgrade Cards section), through safeImageUrl. '' when none.
1214  function upgradeCardUrl(hero, u) {
1215    if (!u || !u.cardImage) return '';
1216    var cardImages = Array.isArray(hero.cardImages) ? hero.cardImages : [];
1217    return safeImageUrl(resolveUpgradeImage(cardImages, u.cardImage));
1218  }
1219
1220  // The Upgrades section of the enlarged view: one column per upgrade (level pill, card name, CP
1221  // cost, changed tiers/effects, then the card at a readable size). Hidden when there are none.
1222  function abilityUpgradesHtml(hero, model, slug) {
1223    if (!model.upgrades || !model.upgrades.length) return '';
1224    var cols = model.upgrades.map(function (u, i) {
1225      var src = upgradeCardUrl(hero, u);
1226      var head = '<div class="hd-abilitydetail__upline">' +
1227        (u.label ? '<span class="hd-pill hd-pill--upgrade">' + esc(u.label) + '</span>' : '') +
1228        '<span class="hd-abilitydetail__upname">' + esc(u.cardName) + '</span>' +
1229        (u.cpCost != null ? '<span class="hd-abilitydetail__upcost">' + esc(u.cpCost) + ' CP</span>' : '') +
1230        '</div>';
1231      var card = src
1232        ? '<button type="button" class="hd-abilitydetail__upcard" data-hero-upcard="' + i + '" ' +
1233          'aria-label="Enlarge ' + esc(u.cardName) + '">' +
1234          '<img src="' + esc(src) + '" alt="' + esc(u.cardName) + '" loading="lazy" data-fb-hide></button>'
1235        : '';
1236      var bonus = (u.bonusAbilities || []).map(function (b) {
1237        return '<div class="hd-abilitydetail__bonus"><span class="hd-abilitydetail__bonuslabel">Bonus ability</span>' +
1238          '<span class="hd-abilitydetail__bonusname">' + esc(b.name) + '</span>' +
1239          tierRowsHtml(b.tiers, slug) + effectListHtml(b.effects) + errataNoteHtml(b.upcomingErrata) + '</div>';
1240      }).join('');
1241      return '<div class="hd-abilitydetail__upgrade">' + head +
1242        tierRowsHtml(u.tiers, slug) + effectListHtml(u.effects) + errataNoteHtml(u.upcomingErrata) + bonus + card + '</div>';
1243    }).join('');
1244    return '<div class="hd-abilitydetail__upgrades"><div class="hd-abilitydetail__uphead">Upgrades</div>' +
1245      '<div class="hd-abilitydetail__uplist">' + cols + '</div></div>';
1246  }
1247
1248  function abilityDetailHtml(hero, item) {
1249    var model = typeof twin.abilityDetailModel === 'function'
1250      ? twin.abilityDetailModel(item.entry, { heroSlug: heroSlug(hero), todayKey: localDayKey() })
1251      : { name: '', kind: '', tiers: [], effects: [], upgrades: [] };
1252    var slug = heroSlug(hero);
1253    var artSrc = abilityArtUrl(hero, item.entry, item.isUltimate);
1254    var art = artSrc
1255      ? '<img src="' + esc(artSrc) + '" alt="' + esc(model.name) + '" loading="lazy" ' +
1256        'style="width:100%;max-width:320px;height:auto;border-radius:10px;" data-fb-hide>'
1257      : '';
1258    var kind = item.isUltimate ? 'ultimate' : model.kind;
1259    var tiers = tierRowsHtml(model.tiers, slug);
1260    var effects = effectListHtml(model.effects) + errataNoteHtml(model.upcomingErrata);
1261    return (
1262      '<div style="display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;">' +
1263      (art ? '<div style="flex:0 1 320px;">' + art + '</div>' : '') +
1264      '<div class="hd-abilitydetail" style="flex:1 1 260px;min-width:0;">
1264' +
1265      '<div class="hd-abilitydetail__head"><span class="hd-abilitydetail__name">' + esc(model.name) + '</span>' +
1266      (kind ? abilityKindPillHtml(kind) : '') + '</div>' +
1267      tiers + effects + abilityUpgradesHtml(hero, model, slug) +
1268      '</div></div>'
1269    );
1270  }
1271
1272  // Opens the enlarged ability view, then binds its upgrade-card buttons. Opening again while it
1273  // is showing re-renders in place (overlay.js), which is how the card view and Back both work.
1274  function openAbilityOverlay(hero, item) {
1275    if (typeof window.TDTT === 'undefined' || !window.TDTT.overlay) return;
1276    window.TDTT.overlay.open({
1277      title: item.entry.name || '',
1278      bodyHtml: abilityDetailHtml(hero, item),
1279    });
1280    if (typeof document === 'undefined' || !document.querySelector) return;
1281    var body = document.querySelector('.tdtt-overlay__body');
1282    if (!body) return;
1283    var buttons = body.querySelectorAll('[data-hero-upcard]');
1284    for (var b = 0; b < buttons.length; b++) {
1285      buttons[b].addEventListener('click', function (ev) {
1286        var model = typeof twin.abilityDetailModel === 'function' ? twin.abilityDetailModel(item.entry, { heroSlug: heroSlug(hero), todayKey: localDayKey() }) : null;
1287        var u = model && model.upgrades ? model.upgrades[Number(ev.currentTarget.getAttribute('data-hero-upcard'))] : null;
1288        var src = upgradeCardUrl(hero, u);
1289        if (!src) return;
1290        window.TDTT.overlay.open({
1291          title: u.cardName,
1292          bodyHtml: (
1293            '<div style="display:flex;flex-direction:column;align-items:center;gap:12px;">' +
1294            '<img src="' + esc(src) + '" alt="' + esc(u.cardName) + '" ' +
1295            'style="max-width:100%;max-height:70vh;object-fit:contain;border-radius:10px;">' +
1296            '<button type="button" class="tdtt-btn tdtt-btn--ghost" data-hero-upback>Back to ' + esc(item.entry.name || '') + '</button>' +
1297            '</div>'
1298          ),
1299        });
1300        var back = document.querySelector('.tdtt-overlay__body [data-hero-upback]');
1301        if (back) back.addEventListener('click', function () { openAbilityOverlay(hero, item); });
1302      });
1303    }
1304  }
1305
1306  function abilityTileHtml(hero, item, idx) {
1307    var src = abilityArtUrl(hero, item.entry, item.isUltimate);
1308    if (!src) return '';
1309    var name = item.entry.name || '';
1310    return (
1311      '<button type="button" class="hd-ability-tile" data-hero-ability="' + idx + '" aria-label="' + esc(name) + '">' +
1312      '<img src="' + esc(src) + '" alt="' + esc(name) + '" loading="lazy" data-fb-hide>' +
1313      '</button>'
1314    );
1315  }
1316
1317  function abilitiesTabHtml(hero) {
1318    var items = abilityEntriesOf(hero);
1319    if (!items.length) {
1320      return (
1321        '<div class="lb-status lb-empty"><p class="lb-empty-title">No ability data available</p>' +
1322        '<p class="lb-empty-sub">' + esc(hero.name) + '\u2019s abilities aren\u2019t in the pack yet.</p></div>'
1323      );
1324    }
1325    var ultimate = '';
1326    var grid = '';
1327    for (var i = 0; i < items.length; i++) {
1328      var tile = abilityTileHtml(hero, items[i], i);
1329      if (items[i].isUltimate) ultimate = tile;
1330      else grid += tile;
1331    }
1332    return (
1333      '<div class="hd-ability-list">' + ultimate +
1334      '<div class="hd-ability-grid hd-ability-grid--images">' + grid + '</div></div>'
1335    );
1336  }
1337
1338  // ── Cards tab ────────────────────────────────────────────────────────────
1339  // Reference: apps/desktop/src/routes/Characters.tsx's `CardsTab`.
1340  //
1341  // NOT ported: the "Card Pool Summary" prose panel. Desktop reads it off its
1342  // OWN separately-fetched `cardData` as `card_pool_summary`. The pack's own
1343  // cards.json DOES carry that field (cards.json[slug].card_pool_summary),
1344  // but encyclopedia-data.ts's mergeCharacter() only copies `cards[slug].cards`
1345  // onto the merged hero object — `card_pool_summary` is read off the pack file
1346  // and then discarded, never reaching /api/encyclopedia/characters. Per the
1347  // brief: it is genuinely not on the hero object, so this section is omitted
1348  // rather than adding a new fetch for one string. See the C5c stage report.
1349  //
1350  // UPDATE (Bug C fix, 2026-09-17): a lightbox WAS added — see
1351  // cardTileOpenTag()/cardTileCloseTag() above and wire()'s data-zoom-src
1352  // handler below, opening the shared `.tdtt-overlay`. Card rules text still
1353  // renders inline under each image on the tile itself, unchanged; the
1354  // enlarged view just repeats the same image full-size. (This reverses the
1355  // "NOT ported: the lightbox" decision C5a made — the header comment's
1356  // "Also not ported" note is stale and kept only for history.)
1357  //
1358  // `general !== true` is the same split desktop's splitCardImages() does —
1359  // general-card copies embedded in a hero's own manifest are excluded from
1360  // the Hero Cards gallery because the General Cards section above already
1361  // shows them (from setShared(), not from this hero's own cardImages).
1362  //
1363  // Task 9 (codex_character_cards.png mockup) added the Card Pool tile row,
1364  // the All/Hero/General/Upgrades filter chips, and the Upgrade Cards
1365  // section below — see cardPoolPanelHtml()/cardFilterBarHtml()/
1366  // upgradeCardsSectionHtml(). Read the spec's data table before touching
1367  // any of this: hero-card images carry NO name (0 of 44 heroes) and NO
1368  // main/instant/roll type (40 of 44 heroes) in the scan manifest, so this
1369  // surface must never invent one. `window.TDTT.heroStats.cardPoolCounts()`
1370  // (packages/shared/src/codex/hero-stats.ts) is the single source for what
1371  // IS derivable, and its `null` (not derivable, omit the tile/chip) vs `0`
1372  // (genuinely zero, render it) distinction is load-bearing — collapsing the
1373  // two prints a fabricated zero. The one hero-card naming real data DOES
1374  // support is an upgrade's `cardName`, via `upgradeCardEntries()` — that's
1375  // what the Upgrade Cards section renders, matched back to a real image
1376  // through the hero's own `cardImages` where possible.
1377
1378  // Card text lookup, keyed by trimmed-lowercased name — mirrors desktop's
1379  // `textByName` Map exactly, including its "first writer wins" precedence
1380  // (hero.cards checked before hero.unique_cards). Object.create(null) avoids
1381  // any Object.prototype key collision (e.g. a card literally named
1382  // "constructor"), same defensive pattern codex-data.js's own cache uses.
1383  function buildCardTextIndex(primary, fallback) {
1384    var map = Object.create(null);
1385    function put(list) {
1386      var arr = Array.isArray(list) ? list : [];
1387      for (var i = 0; i < arr.length; i++) {
1388        var c = arr[i];
1389        var key = c && c.name ? String(c.name).trim().toLowerCase() : '';
1390        if (key && !(key in map)) map[key] = c;
1391      }
1392    }
1393    put(primary);
1394    put(fallback);
1395    return map;
1396  }
1397
1398  // `hero.cards` entries with `type === 'info'` are Forge Master's
1399  // instructional Mining/Crafting cards — excluded from both the Hero Cards
1400  // text pool and its lightbox-equivalent text match, exactly as desktop's
1401  // isInfoCard() filter does, and handled by their own Info Cards section
1402  // below instead.
1403  // Desktop's `CardsTab` filters `c._status !== 'template'` as the very
1404  // first line of the component body. The shipped pack has zero template rows
1405  // today, so this changes nothing right now, but a future pack shipping
1406  // stub rows would otherwise render them as real cards here while desktop
1407  // hides them — a template stub is not real data.
1408  function realCardsOf(hero) {
1409    var list = Array.isArray(hero.cards) ? hero.cards : [];
1410    var out = [];
1411    for (var i = 0; i < list.length; i++) {
1412      if (list[i] && list[i]._status !== 'template') out.push(list[i]);
1413    }
1414    return out;
1415  }
1416
1417  function nonInfoCardsOf(hero) {
1418    var list = realCardsOf(hero);
1419    var out = [];
1420    for (var i = 0; i < list.length; i++) {
1421      if (list[i].type !== 'info') out.push(list[i]);
1422    }
1423    return out;
1424  }
1425
1426  function infoTextCardsOf(hero) {
1427    var list = realCardsOf(hero);
1428    var out = [];
1429    for (var i = 0; i < list.length; i++) {
1430      if (list[i].type === 'info') out.push(list[i]);
1431    }
1432    return out;
1433  }
1434
1435  // Bug C fix (2026-09-17) — owner: "Make the cards clickable to enlarge
1436  // them." A card tile with a real image becomes a clickable <button>
1437  // carrying data-zoom-src/data-zoom-title (wired below in wire(), opening
1438  // the shared `.tdtt-overlay` via window.TDTT.overlay.open() — the SAME
1439  // primitive openTcgCardOverlay() already uses for the TCG card detail
1440  // overlay, reused rather than inventing a second modal on this surface).
1441  // A tile with no image (e.g. an Upgrade Card whose cardImage never matched
1442  // a manifest entry) stays a plain, non-interactive <div> — nothing to
1443  // enlarge. `hasSrc` distinguishes the two; `src`/`title` are pre-escaped by
1444  // the caller exactly as every other esc()'d attribute in this file is.
1445  function cardTileOpenTag(src, title) {
1446    if (!src) return '<div class="tdtt-cardtile hd-cardtile--static">';
1447    return (
1448      '<button type="button" class="tdtt-cardtile" ' +
1449      'data-zoom-src="' + esc(src) + '" data-zoom-title="' + esc(title || '') + '">'
1450    );
1451  }
1452
1453  function cardTileCloseTag(src) {
1454    return src ? '</button>' : '</div>';
1455  }
1456
1457  // General Cards — from setShared()'s generalCards, collapsed by default.
1458  // Omitted entirely when shared data hasn't loaded OR (once loaded) the
1459  // list is genuinely empty — a "General Cards (0)" header would be exactly
1460  // the zero-count header the surface rules forbid.
1461  function generalCardsSectionHtml() {
1462    if (!sharedData) return '';
1463    var list = Array.isArray(sharedData.generalCards) ? sharedData.generalCards : [];
1464    if (!list.length) return '';
1465    var body = '';
1466    if (generalCardsExpanded) {
1467      body = '<div class="tdtt-cardgrid">' + list.map(function (gc) {
1468        // Guarded exactly like the Hero Cards count badge below — an
1469        // unlabelled "CP" badge for a card missing cpCost would be the same
1470        // invented-value failure mode "real data or nothing" forbids, even
1471        // though all 18 shipped general cards have one today.
1472        var cpBadge = (typeof gc.cpCost === 'number')
1473          ? '<span class="hd-cardtile__count">' + esc(gc.cpCost) + ' CP</span>'
1474          : '';
1475        return (
1476          cardTileOpenTag(gc.image, gc.name) +
1477          '<img src="' + esc(gc.image) + '" alt="' + esc(gc.name) + '" loading="lazy" ' +
1478          'data-fb-hide>' +
1479          '<div class="tdtt-cardtile__body">' +
1480          '<div class="hd-minicard__name">' + esc(gc.name) + '</div>' +
1481          cpBadge +
1482          (gc.description ? '<p class="hd-text hd-text--sm">' + esc(gc.description) + '</p>' : '') +
1483          '</div>' + cardTileCloseTag(gc.image)
1484        );
1485      }).join('') + '</div>';
1486    }
1487    var head =
1488      '<button type="button" class="tdtt-panel__head hd-toggle-btn" data-hero-toggle="general-cards" ' +
1489      'aria-expanded="' + (generalCardsExpanded ? 'true' : 'false') + '">' +
1490      '<span class="tdtt-panel__icon">' + icon('grid') + '</span>General Cards (' + esc(list.length) + ')' +
1491      '<span class="tdtt-panel__more">' + (generalCardsExpanded ? 'Hide' : 'Show') + '</span>' +
1492      '</button>';
1493    return '<div class="tdtt-panel">' + head + body + '</div>';
1494  }
1495
1496  // Hero Cards — hero.cardImages (general copies excluded) when present,
1497  // else a text-only fallback grid built from whichever of hero.cards /
1498  // hero.unique_cards has entries (desktop's `heroTextCards` fallback).
1499  function heroCardsSectionHtml(heroImages, heroTextCards, textByName) {
1500    if (!heroImages.length && !heroTextCards.length) return '';
1501    var count = heroImages.length || heroTextCards.length;
1502    var body;
1503    if (heroImages.length) {
1504      body = '<div class="tdtt-cardgrid">' + heroImages.map(function (c) {
1505        var key = c.name ? String(c.name).trim().toLowerCase() : '';
1506        var match = key ? textByName[key] : null;
1507        var title = (match && match.name) || c.name || '';
1508        var countBadge = (typeof c.count === 'number' && c.count > 1)
1509          ? '<span class="hd-cardtile__count">×' + esc(c.count) + '</span>'
1510          : '';
1511        var desc = match && match.description
1512          ? '<p class="hd-text hd-text--sm">' + esc(match.description) + '</p>'
1513          : '';
1514        return (
1515          cardTileOpenTag(c.image, title) +
1516          '<img src="' + esc(c.image) + '" alt="' + esc(title) + '" loading="lazy" ' +
1517          'data-fb-hide>' +
1518          '<div class="tdtt-cardtile__body">' +
1519          (title ? '<div class="hd-minicard__name">' + esc(title) + '</div>' : '') +
1520          countBadge + desc +
1521          '</div>' + cardTileCloseTag(c.image)
1522        );
1523      }).join('') + '</div>';
1524    } else {
1525      body = '<div class="hd-minicard-grid">' + heroTextCards.map(function (c) {
1526        return (
1527          '<div class="hd-minicard">' +
1528          '<span class="hd-minicard__name hd-minicard__name--accent">' + esc(c.name) + '</span>' +
1529          (c.description ? '<p class="hd-text hd-text--sm">' + esc(c.description) + '</p>' : '') +
1530          '</div>'
1531        );
1532      }).join('') + '</div>';
1533    }
1534    return panelHtml('Hero Cards (' + count + ')', 'image', body);
1535  }
1536
1537  // Info Cards — hero.assets.infoCards (real image files, Forge Master only)
1538  // paired with hero.cards[type='info'] entries by a substring match on the
1539  // asset's cleaned-up label, exactly as desktop's
1540  // `text.name.toLowerCase().includes(img.label.toLowerCase())` does.
1541  function findInfoCardText(textCards, label) {
1542    var needle = String(label || '').toLowerCase();
1543    for (var i = 0; i < textCards.length; i++) {
1544      var name = textCards[i] && textCards[i].name ? String(textCards[i].name).toLowerCase() : '';
1545      if (name.indexOf(needle) !== -1) return textCards[i];
1546    }
1547    return null;
1548  }
1549
1550  function infoCardsSectionHtml(hero) {
1551    var images = (hero.assets && Array.isArray(hero.assets.infoCards)) ? hero.assets.infoCards : [];
1552    var textCards = infoTextCardsOf(hero);
1553    if (!images.length && !textCards.length) return '';
1554    var body;
1555    if (images.length) {
1556      body = '<div class="tdtt-cardgrid">' + images.map(function (img) {
1557        var match = findInfoCardText(textCards, img.label);
1558        var title = (match && match.name) || img.label;
1559        var desc = match && match.description
1560          ? '<p class="hd-text hd-text--sm">' + esc(match.description) + '</p>'
1561          : '';
1562        return (
1563          cardTileOpenTag(img.url, title) +
1564          '<img src="' + esc(img.url) + '" alt="' + esc(img.label) + '" loading="lazy" ' +
1565          'data-fb-hide>' +
1566          '<div class="tdtt-cardtile__body">' +
1567          '<div class="hd-minicard__name">' + esc(title) + '</div>' +
1568          desc +
1569          '</div>' + cardTileCloseTag(img.url)
1570        );
1571      }).join('') + '</div>';
1572    } else {
1573      body = '<div class="hd-minicard-grid">' + textCards.map(function (c) {
1574        return (
1575          '<div class="hd-minicard">' +
1576          '<span class="hd-minicard__name hd-minicard__name--accent">' + esc(c.name) + '</span>' +
1577          (c.description ? '<p class="hd-text hd-text--sm">' + esc(c.description) + '</p>' : '') +
1578          '</div>'
1579        );
1580      }).join('') + '</div>';
1581    }
1582    // Only Forge Master has these — absent for 43 of 44 heroes, which is
1583    // correct, not a bug.
1584    return panelHtml('Info Cards', 'flame', body, 'hd-panel--gold');
1585  }
1586
1587  // Card Pool tile row (Task 9) — reuses statTileHtml()/`.tdtt
1587-statrow`
1588  // verbatim, the same primitive Combat Performance already uses above,
1589  // rather than inventing a second stat-tile shape. `counts` is
1590  // `window.TDTT.heroStats.cardPoolCounts(hero, generalCards)`'s return
1591  // value: each tile is gated on its OWN field being non-null, independent
1592  // of the others, so a hero with total/hero/general but no upgrades still
1593  // gets three tiles, not zero. Labels are passed already-uppercase (not
1594  // relying on `.tdtt-stat .l`'s CSS text-transform) so the real value shows
1595  // up verbatim in the served source, matching how this section is tested.
1596  //
1597  // Task 9 fix round 1, item A — the owner's literal ask ("how many main
1598  // phase roll cards, instant cards and roll phase cards") can't be answered
1599  // for HERO cards (40 of 44 heroes have no per-card type at all) but CAN be
1600  // answered for GENERAL cards, which are typed and complete for every hero
1601  // (they're the same 18 cards everywhere) — `counts.generalByType` was
1602  // already computed by Task 3 and never read until now. Rendered as its own
1603  // line, clearly labelled "General Cards by type" so it can never be
1604  // mistaken for a claim about the hero's own cards, gated independently on
1605  // `generalByType` (not on the General Cards tile) since it comes from the
1606  // shared catalog, not this hero's `cardImages`.
1607  function cardPoolPanelHtml(counts) {
1608    var tiles = '';
1609    if (counts.total !== null) tiles += statTileHtml('copy', counts.total, 'TOTAL CARDS', 'var(--cyan)');
1610    if (counts.hero !== null) tiles += statTileHtml('star', counts.hero, 'HERO CARDS', 'var(--gold)');
1611    if (counts.general !== null) tiles += statTileHtml('grid', counts.general, 'GENERAL CARDS', 'var(--green)');
1612    if (counts.upgrades !== null) tiles += statTileHtml('zap', counts.upgrades, 'UPGRADES', 'var(--magenta)');
1613
1614    var breakdown = '';
1615    if (counts.generalByType) {
1616      breakdown = (
1617        '<p class="hd-text hd-text--sm hd-cardpool__breakdown">General Cards by type &mdash; ' +
1618        esc(counts.generalByType.main) + ' Main Phase<span class="hd-tier__sep">&middot;</span>' +
1619        esc(counts.generalByType.instant) + ' Instant<span class="hd-tier__sep">&middot;</span>' +
1620        esc(counts.generalByType.roll) + ' Roll Phase</p>'
1621      );
1622    }
1623
1624    if (!tiles && !breakdown) return '';
1625    var tileRow = tiles ? '<div class="tdtt-statrow">' + tiles + '</div>' : '';
1626    return panelHtml('Card Pool', 'copy', tileRow + breakdown);
1627  }
1628
1629  // Filter chips (Task 9) — All / Hero / General / Upgrades, reusing the
1630  // shared `.chip`/`.chip-row` primitive community.js/leaderboard.js/shops.js
1631  // already use for the same job. A category chip renders ONLY when its
1632  // count tile above also renders (`counts.hero`/`counts.general`/
1633  // `counts.upgrades` non-null) — a filter for a category the hero has none
1634  // of is dead UI, and the "no upgrade data" test asserts the word "UPGRADE"
1635  // is absent entirely for such a hero, so this and cardPoolPanelHtml() must
1636  // stay gated on the exact same values. Wired through the existing
1637  // `[data-hero-toggle]` mechanism (wire(), below) with a `cards-filter-`
1638  // prefixed key, rather than a second selector scan.
1639  function cardFilterChipHtml(key, label, count, active) {
1640    var countLabel = typeof count === 'number' ? ' (' + count + ')' : '';
1641    return (
1642      '<button type="button" class="chip' + (active ? ' on' : '') + '" ' +
1643      'data-hero-toggle="cards-filter-' + key + '">' + esc(label) + countLabel + '</button>'
1644    );
1645  }
1646
1647  function cardFilterBarHtml(counts, effectiveFilter) {
1648    if (counts.hero === null && counts.general === null && counts.upgrades === null) return '';
1649    var chips = cardFilterChipHtml('all', 'All Cards', counts.total, effectiveFilter === 'all');
1650    if (counts.hero !== null) {
1651      chips += cardFilterChipHtml('hero', 'Hero Cards', counts.hero, effectiveFilter === 'hero');
1652    }
1653    if (counts.general !== null) {
1654      chips += cardFilterChipHtml('general', 'General Cards', counts.general, effectiveFilter === 'general');
1655    }
1656    if (counts.upgrades !== null) {
1657      chips += cardFilterChipHtml('upgrades', 'Upgrades', counts.upgrades, effectiveFilter === 'upgrades');
1658    }
1659    return '<div class="chip-row">' + chips + '</div>';
1660  }
1661
1662  // Resolves an upgrade's relative `cardImage` (e.g. "cards/card_19.png",
1663  // from the character JSON) against the hero's own `cardImages` manifest
1664  // entries, whose `image` is the full asset-relative path (e.g.
1665  // "characters/paladin/cards/card_19.png") — a substring match, since the
1666  // manifest path is the fragment PLUS a "characters/<slug>/" prefix. Falls
1667  // back to the raw fragment itself (still real data, just possibly
1668  // unresolvable to an actual served file) when no manifest entry matches,
1669  // and to '' when the upgrade carries no cardImage at all.
1670  function resolveUpgradeImage(cardImages, fragment) {
1671    if (!fragment) return '';
1672    for (var i = 0; i < cardImages.length; i++) {
1673      var img = cardImages[i] && cardImages[i].image;
1674      if (img && img.indexOf(fragment) !== -1) return img;
1675    }
1676    return fragment;
1677  }
1678
1679  // Upgrade Cards (Task 9) — built from `window.TDTT.heroStats.
1680  // upgradeCardEntries(hero)`, NOT from hero.cardImages, because that's the
1681  // one hero-card naming source real data supports (see this tab's header
1682  // comment). Independent of cardImages matching succeeding — a hero whose
1683  // upgrade `cardImage` never resolves to a real manifest entry still shows
1684  // the real card NAME, just without art.
1685  function upgradeCardsSectionHtml(hero) {
1686    if (typeof window.TDTT === 'undefined' || !window.TDTT.heroStats) return '';
1687    var entries = window.TDTT.heroStats.upgradeCardEntries(hero);
1688    if (!entries.length) return '';
1689    var cardImages = Array.isArray(hero.cardImages) ? hero.cardImages : [];
1690    var body = '<div class="tdtt-cardgrid">' + entries.map(function (e) {
1691      var src = resolveUpgradeImage(cardImages, e.cardImage);
1692      var imgHtml = src
1693        ? '<img src="' + esc(src) + '" alt="' + esc(e.cardName) + '" loading="lazy" ' +
1694          'data-fb-hide>'
1695        : '';
1696      return (
1697        cardTileOpenTag(src, e.cardName) +
1698        imgHtml +
1699        '<div class="tdtt-cardtile__body">' +
1700        '<span class="hd-pill hd-pill--upgrade">Upgrade</span>' +
1701        '<div class="hd-minicard__name">' + esc(e.cardName) + '</div>' +
1702        '</div>' + cardTileCloseTag(src)
1703      );
1704    }).join('') + '</div>';
1705    return panelHtml('Upgrade Cards (' + entries.length + ')', 'zap', body, 'hd-panel--magenta');
1706  }
1707
1708  function cardsTabHtml(hero) {
1709    var heroCardsPool = nonInfoCardsOf(hero);
1710    var textByName = buildCardTextIndex(heroCardsPool, hero.unique_cards);
1711    var cardImages = Array.isArray(hero.cardImages) ? hero.cardImages : [];
1712    // Hero Cards excludes upgrade, info and general cards (shared twin; the
1713    // upgrade test is the card's own type or an upgrade-name match). Upgrade
1714    // Cards is its own section below, so every surface lists the same cards.
1715    var upgradeEntries = (window.TDTT && window.TDTT.heroStats && window.TDTT.heroStats.upgradeCardEntries)
1716      ? window.TDTT.heroStats.upgradeCardEntries(hero)
1717      : [];
1718    var heroImages = typeof twin.splitHeroCards === 'function'
1719      ? twin.splitHeroCards(cardImages, null, upgradeEntries).hero
1720      : [];
1721    var heroTextCards = heroCardsPool.length ? heroCardsPool : (Array.isArray(hero.unique_cards) ? hero.unique_cards : []);
1722
1723    // An explicitly-empty shared payload (`generalCards: []`, a degraded/
1724    // incomplete fetch) is treated as "not loaded" here too, same as
1725    // generalCardsSectionHtml()'s own `if (!list.length) return '';` gate
1726    // just above — cardPoolCounts()'s own null/0 contract for total/hero/
1727    // general already folds empty into null for the SAME reason; only
1728    // generalByType doesn't (Task 3 left it a real {0,0,0} for a genuinely-
1729    // empty array, by design), so this file supplies `null` instead of `[]`
1730    // itself rather than showing a fabricated "0 Main Phase / 0 Instant / 0
1731    // Roll Phase" line alongside the panel's own "no General Cards" state.
1732    var generalCards = (sharedData && Array.isArray(sharedData.generalCards) && sharedData.generalCards.length)
1733      ? sharedData.generalCards
1734      : null;
1735    var counts = (typeof window.TDTT !== 'undefined' && window.TDTT.heroStats)
1736      ? window.TDTT.heroStats.cardPoolCounts(hero, generalCards)
1737      : { total: null, hero: null, general: null, uniqueActions: null, upgrades: null, generalByType: null };
1738
1739    var generalSection = generalCardsSectionHtml();
1740    var heroSection = heroCardsSectionHtml(heroImages, heroTextCards, textByName);
1741    var upgradeSection = upgradeCardsSectionHtml(hero);
1742    var infoSection = infoCardsSectionHtml(hero);
1743
1744    if (!generalSection && !heroSection && !upgradeSection && !infoSection) {
1745      return (
1746        '<div class="lb-status lb-empty"><p class="lb-empty-title">No card data available</p>' +
1747        '<p class="lb-empty-sub">' + esc(hero.name) + '’s cards aren’t in the pack yet.</p></div>'
1748      );
1749    }
1750
1751    // A previously-selected filter can go stale when a different hero has no
1752    // data for that category (e.g. "Upgrades" was active, the next hero has
1753    // none) — fall back to "All" rather than rendering a filtered view with
1754    // nothing in it while real data sits one click away.
1755    var filter = cardsFilter;
1756    if (filter === 'hero' && !heroSection) filter = 'all';
1757    if (filter === 'general' && !generalSection) filter = 'all';
1758    if (filter === 'upgrades' && !upgradeSection) filter = 'all';
1759
1760    var gallerySections;
1761    if (filter === 'hero') gallerySections = heroSection;
1762    else if (filter === 'general') gallerySections = generalSection;
1763    else if (filter === 'upgrades') gallerySections = upgradeSection;
1764    else gallerySections = generalSection + heroSection + upgradeSection + infoSection;
1765
1766    var sections = cardPoolPanelHtml(counts) + cardFilterBarHtml(counts, filter) + gallerySections;
1767    return '<div class="hd-stack">' + sections + '</div>';
1768  }
1769
1770  // ── Assets tab ───────────────────────────────────────────────────────────
1771  // Reference: apps/desktop/src/routes/Characters.tsx's `AssetsTab`.
1772  //
1773  // Dice Faces reuses this file's OWN diceFacesPanelHtml() (Overview tab,
1774  // C5a) rather than a second copy — desktop also renders dice faces in
1775  // both its `OverviewTab` and `AssetsTab` (each has its own Dice Faces
1776  // block), so SOME duplication is precedented. Note desktop's two
1777  // renderings actually differ (Overview: number + symbol text per face;
1778  // Assets: a bare image grid) — desktop does not have a precedent for
1779  // literally identical markup in both spots. This file's two are identical
1780  // because both call the same helper; that is this stage's own call, not
1781  // something desktop's reference implementation forced.
1782  //
1783  // Hero Rulesheet is DELIBERATELY NOT ported here — see the C5c stage
1784  // report for the full reasoning. Short version: desktop duplicates
1785  // hero.assets.rulesheets in both its dedicated `RulesheetsTab` AND its
1786  // `AssetsTab` (its "Hero Rulesheet" section) — desktop has the identical
1787  // seven-tab strip /app does (Characters.tsx's `TABS` array), so this is
1788  // NOT a case of desktop lacking an equivalent affordance. `/app` declines
1789  // the duplication anyway because, with no lightbox on this surface (see
1790  // this file's header comment), the Assets copy would be BYTE-IDENTICAL to
1791  // what the adjacent Rulesheets tab already renders full-size, labelled,
1792  // one click away — it would add no affordance desktop's copy does
1793  // (desktop's own copy at least sits inside a click-to-zoom lightbox its
1794  // Rulesheets tab also has, so neither of desktop's two copies is a strict
1795  // subset of the other the way ours would be).
1796
1797  function assetTileGridHtml(list) {
1798    return '<div class="hd-assetgrid">' + list.map(function (a) {
1799      return (
1800        '<div class="hd-asset">' +
1801        '<img src="' + esc(a.url) + '" alt="' + esc(a.label) + '" loading="lazy" ' +
1802        // Hides the whole tile, not just the image — mirrors desktop's own
1803        // `Tile` component (defined inside `AssetsTab`, Characters.tsx),
1804        // which hides `parentElement` for exactly this reason: leaving the
1805        // label floating alone in an otherwise-empty grid cell looks broken.
1806        'data-fb-hide-parent>' +
1807        '<span class="hd-asset__label">' + esc(a.label) + '</span>' +
1808        '</div>'
1809      );
1810    }).join('') + '</div>';
1811  }
1812
1813  function tokensPanelHtml(assets) {
1814    var list = Array.isArray(assets.tokens) ? assets.tokens : [];
1815    if (!list.length) return '';
1816    return panelHtml('Tokens & Items (' + list.length + ')', 'gift', assetTileGridHtml(list), 'hd-panel--gold');
1817  }
1818
1819  function diceSymbolsAssetsPanelHtml(assets) {
1820    var list = Array.isArray(assets.diceSymbols) ? assets.diceSymbols : [];
1821    if (!list.length) return '';
1822    return panelHtml('Dice Symbols (' + list.length + ')', 'zap', assetTileGridHtml(list), 'hd-panel--cyan');
1823  }
1824
1825  function companionImagesPanelHtml(assets) {
1826    var list = Array.isArray(assets.companions) ? assets.companions : [];
1827    if (!list.length) return '';
1828    return panelHtml('Companion Images (' + list.length + ')', 'users', assetTileGridHtml(list), 'hd-panel--green');
1829  }
1830
1831  // Order matches desktop's own `refs` local (built inside `AssetsTab`,
1832  // Characters.tsx) exactly, which is also the order the brief lists.
1833  var REFERENCE_FIELDS = [
1834    ['boardPanel', 'Hero Board'],
1835    ['portrait', 'Portrait'],
1836    ['portraitLeaflet', 'Leaflet Art'],
1837    ['cpDial', 'CP Dial'],
1838    ['diceReference', 'Dice Reference'],
1839    ['cardsSheet', 'Cards Sheet'],
1840    ['healthDial', 'Health Dial'],
1841    ['heroCard', 'Hero Card'],
1842    ['slamThrone', 'Slam Throne'],
1843    ['storageLeaflet', 'Storage Leaflet'],
1844    ['adventuresHero', 'Adventures Standee'],
1845  ];
1846
1847  // A non-null value here means the server already found the file on disk
1848  // (readCharacterAssets() enumerates real files) — no .jpg/.png fallback
1849  // dance needed, unlike paths this file builds by hand elsewhere.
1850  function referenceImagesPanelHtml(hero, assets) {
1851    var entries = [];
1852    for (var i = 0; i < REFERENCE_FIELDS.length; i++) {
1853      var key = REFERENCE_FIELDS[i][0];
1854      var label = REFERENCE_FIELDS[i][1];
1855      if (assets[key]) entries.push({ url: assets[key], label: label });
1856    }
1857    if (!entries.length) return '';
1858    var body = '<div class="hd-refgrid">' + entries.map(function (e) {
1859      return (
1860        '<div class="hd-ref">' +
1861        '<p class="hd-ref__label">' + esc(e.label) + '</p>' +
1862        '<img src="' + esc(e.url) + '" alt="' + esc(hero.name) + ' ' + esc(e.label) + '" loading="lazy" ' +
1863        // Mirrors desktop's own reference-image fallback (`AssetsTab`'s
1864        // Reference Images section, Characters.tsx), which also hides
1865        // `parentElement` as its last resort — otherwise the label
1866        // paragraph above is left floating alone.
1867        'data-fb-hide-parent>' +
1868        '</div>'
1869      );
1870    }).join('') + '</div>';
1871    return panelHtml('Reference Images', 'image', body);
1872  }
1873
1874  // The six labelled entries desktop lists (`AssetsTab`'s Shared Components
1875  // section, Characters.tsx — an inline array literal with no symbol name of
1876  // its own; approximate location only), sourced from setShared()'s
1877  // components object — omitted entirely when shared data hasn't loaded,
1878  // same rule as General Cards above.
1879  var SHARED_COMPONENT_FIELDS = [
1880    ['cardBack', 'Card Back'],
1881    ['heroCardBack', 'Hero Card Back'],
1882    ['slamThroneBack', 'Slam Throne Back'],
1883    ['storageLeafletBack', 'Storage Leaflet Back'],
1884    ['damageTypeChart', 'Damage Type Chart'],
1885    ['turnOrder', 'Turn Order'],
1886  ];
1887
1888  function sharedComponentsPanelHtml() {
1889    if (!sharedData || !sharedData.components) return '';
1890    var comps = sharedData.components;
1891    var entries = [];
1892    for (var i = 0; i < SHARED_COMPONENT_FIELDS.length; i++) {
1893      var key = SHARED_COMPONENT_FIELDS[i][0];
1894      var label = SHARED_COMPONENT_FIELDS[i][1];
1895      if (comps[key]) entries.push({ url: comps[key], label: label });
1896    }
1897    if (!entries.length) return '';
1898    return panelHtml('Shared Components', 'copy', assetTileGridHtml(entries));
1899  }
1900
1901  function assetsTabHtml(hero) {
1902    var assets = hero.assets || {};
1903    var sections =
1904      diceFacesPanelHtml(hero) +
1905      tokensPanelHtml(assets) +
1906      diceSymbolsAssetsPanelHtml(assets) +
1907      companionImagesPanelHtml(assets) +
1908      referenceImagesPanelHtml(hero, assets) +
1909      sharedComponentsPanelHtml();
1910
1911    if (!sections) {
1912      return (
1913        '<div class="lb-status lb-empty"><p class="lb-empty-title">No asset images available</p>' +
1914        '<p class="lb-empty-sub">' + esc(hero.name) + '’s art assets aren’t in the pack yet.</p></div>'
1915      );
1916    }
1917    return '<div class="hd-stack">' + sections + '</div>';
1918  }
1919
1920  // ── Matchups tab (C5d) ───────────────────────────────────────────────────
1921  // Reference: apps/desktop/src/routes/Characters.tsx's `MatchupsTab`. Both
1922  // fields it reads are already on the merged hero object codex.js holds in
1923  // memory (encyclopedia-data.ts's mergeCharacter()) — no fetch, nothing
1924  // async, same as every other tab in this file.
1925  //
1926  // Empty-state gating is DELIBERATELY NOT a port of desktop's. Desktop's
1927  // top block guards only on `matchups && (matchups.strong || matchups.weak
1928  // || matchups.general)` — MatchupsTab's own JSX shows its "not yet
1929  // available" box whenever that's falsy, even when `archetypeMatchups` has
1930  // real entries, so desktop can render that box directly above a populated
1931  // Archetype Matchups panel. matchupsTabHtml() below gates the empty state
1932  // on BOTH matchups and archetypeEntries being empty instead — see the
1933  // stage report.
1934  //
1935  // Archetype colours reuse existing design tokens only (this surface is
1936  // tokens-only — see CLAUDE.md's palette section), mapped to the nearest
1937  // equivalent of desktop's raw Tailwind `ARCHETYPE_COLORS` values, the same
1938  // way C5b's ability-kind pills already mapped ultimate/offensive/
1939  // defensive/passive:
1940  //   All-In     -> --red        (direct match to desktop's red-500)
1941  //   Slow Burn  -> --magenta    (nearest violet-family token — --purple in
1942  //                               tokens.css is #0AE3F1, a documented
1943  //                               DUPLICATE of --cyan, not an actual purple;
1944  //                               using it here would silently render cyan,
1945  //                               one step from the --blue-link chosen for
1946  //                               Iron Dial below, so the two chips would
1947  //                               read as barely distinguishable)
1948  //   Full Board -> --green      (direct match to desktop's green-500)
1949  //   Buh Bye    -> --orange     (direct match to desktop's orange-500)
1950  //   Iron Dial  -> --blue-link  (nearest blue token — the same substitution
1951  //                               C5b's hd-pill--defensive already made)
1952  // Archetype names are DATA, not a fixed list (hero.archetype_matchups is
1953  // iterated, never hardcoded as a five-name lookup order), so an
1954  // unrecognised name must degrade gracefully rather than crash or vanish —
1955  // mirrors desktop's own `ARCHETYPE_COLORS[archetype] ?? ''` fallback in
1956  // spirit. Desktop's `?? ''` yields an unstyled (but still real) pill;
1957  // since `.hd-pill` on this surface has no bare/unstyled variant,
1958  // hd-pill--archetype-unknown supplies a neutral muted border/text chip
1959  // instead (hero-detail.css) so the fallback still renders as a real chip.
1960  var ARCHETYPE_PILL_CLASSES = {
1961    'All-In': 'hd-pill--archetype-allin',
1962    'Slow Burn': 'hd-pill--archetype-slowburn',
1963    'Full Board': 'hd-pill--archetype-fullboard',
1964    'Buh Bye': 'hd-pill--archetype-buhbye',
1965    'Iron Dial': 'hd-pill--archetype-irondial',
1966  };
1967
1968  function archetypePillClass(name) {
1969    // hasOwnProperty, not a bare lookup: archetype names are data-driven, and
1970    // a bare ARCHETYPE_PILL_CLASSES[name] returns a truthy INHERITED member for
1971    // 'toString' / 'constructor' / '__proto__', which would emit that
1972    // stringified built-in as the class list and silently skip the documented
1973    // unknown-name fallback. Same guard archetypeMatchupEntries() uses below.
1974    return Object.prototype.hasOwnProperty.call(ARCHETYPE_PILL_CLASSES, name)
1975      ? ARCHETYPE_PILL_CLASSES[name]
1976      : 'hd-pill--archetype-unknown';
1977  }
1978
1979  // hero.archetype_matchups is an object keyed by archetype NAME (not a
1980  // fixed list — iterated via for-in, same pattern codex.js's setState()
1981  // uses for an arbitrary-keys object). Entries with an empty/falsy analysis
1982  // value are dropped here so every caller already has "real data or
1983  // nothing" applied.
1984  function archetypeMatchupEntries(map) {
1985    var out = [];
1986    if (!map || typeof map !== 'object') return out;
1987    for (var key in map) {
1988      if (!Object.prototype.hasOwnProperty.call(map, key)) continue;
1989      if (map[key]) out.push({ name: key, analysis: map[key] });
1990    }
1991    return out;
1992  }
1993
1994  function matchupPanelHtml(title, iconName, text, extraClass) {
1995    return panelHtml(title, iconName, '<p class="hd-text">' + esc(text) + '</p>', extraClass);
1996  }
1997
1998  // Two-up row when both Strong/Weak exist; a single full-width panel when
1999  // only one does — never a grid with an empty second cell.
2000  function strongWeakRowHtml(matchups) {
2001    var strongHtml = matchups.strong
2002      ? matchupPanelHtml('Strong Against', 'swords', matchups.strong, 'hd-panel--green')
2003      : '';
2004    // hd-panel--danger already exists (Strategy's "How to Counter") for the
2005    // negative case — reused rather than inventing a second red.
2006    var weakHtml = matchups.weak
2007      // 'flame', not 'shield': shield already means Status Effects in this
2008      // file and reads as defence, i.e. the opposite of a weakness. Desktop
2009      // renders no icon here, so there is no parity constraint to honour.
2010      ? matchupPanelHtml('Weak Against', 'flame', matchups.weak, 'hd-panel--danger')
2011      : '';
2012    if (strongHtml && weakHtml) {
2013      return '<div class="hd-matchup-row">' + strongHtml + weakHtml + '</div>';
2014    }
2015    return strongHtml + weakHtml;
2016  }
2017
2018  function generalAnalysisPanelHtml(matchups) {
2019    if (!matchups.general) return '';
2020    return matchupPanelHtml('General Analysis', 'scroll', matchups.general, '');
2021  }
2022
2023  function archetypeMatchupsPanelHtml(entries) {
2024    if (!entries.length) return '';
2025    var rows = entries.map(function (e) {
2026      return (
2027        '<div class="hd-minicard">' +
2028        '<span class="hd-pill ' + archetypePillClass(e.name) + '">vs ' + esc(e.name) + '</span>' +
2029        '<p class="hd-text hd-text--sm">' + esc(e.analysis) + '</p>' +
2030        '</div>'
2031      );
2032    }).join('');
2033    return panelHtml('Archetype Matchups', 'target', '<div class="hd-stack">' + rows + '</div>');
2034  }
2035
2036  function matchupsTabHtml(hero) {
2037    var matchups = (hero.matchups && typeof hero.matchups === 'object') ? hero.matchups : {};
2038    var archetypeEntries = archetypeMatchupEntries(hero.archetype_matchups);
2039    var hasMatchups = !!(matchups.strong || matchups.weak || matchups.general);
2040    if (!hasMatchups && !archetypeEntries.length) {
2041      return '<div class="tdtt-panel hd-panel--center">Matchup data not yet available for ' + esc(hero.name) + '.</div>';
2042    }
2043    var sections =
2044      strongWeakRowHtml(matchups) +
2045      generalAnalysisPanelHtml(matchups) +
2046      archetypeMatchupsPanelHtml(archetypeEntries);
2047    return '<div class="hd-stack">' + sections + '</div>';
2048  }
2049
2050  // ── Fallback for an unrecognised tab id ──────────────────────────────────
2051  function comingSoonHtml(tab) {
2052    return (
2053      '<div class="lb-status lb-empty"><p class="lb-empty-title">' + esc(tab.label) + ' coming soon</p>' +
2054      '<p class="lb-empty-sub">This tab ships in a later stage of the hero detail build.</p></div>'
2055    );
2056  }
2057
2058  var TAB_BUILDERS = {
2059    overview: overviewTabHtml,
2060    rulesheets: rulesheetsTabHtml,
2061    abilities: abilitiesTabHtml,
2062    statusEffects: statusEffectsTabHtml,
2063    cards: cardsTabHtml,
2064    strategy: strategyTabHtml,
2065    matchups: matchupsTabHtml,
2066    assets: assetsTabHtml,
2067  };
2068
2069  function tabById(id) {
2070    for (var i = 0; i < TABS.length; i++) {
2071      if (TABS[i].id === id) return TABS[i];
2072    }
2073    return TABS[0];
2074  }
2075
2076  function tabStripHtml() {
2077    return (
2078      '<div class="tdtt-tabs" role="tablist">' +
2079      TABS.map(function (t) {
2080        var on = t.id === activeTab;
2081        return (
2082          '<button type="button" class="tdtt-tabs__tab' + (on ? ' on' : '') + '" role="tab" ' +
2083          'aria-selected="' + (on ? 'true' : 'false') + '" data-hero-tab="' + esc(t.id) + '">' +
2084          esc(t.label) + '</button>'
2085        );
2086      }).join('') +
2087      '</div>'
2088    );
2089  }
2090
2091  function panelBodyHtml(hero) {
2092    var builder = TAB_BUILDERS[activeTab];
2093    return builder ? builder(hero) : comingSoonHtml(tabById(activeTab));
2094  }
2095
2096  // Full body HTML for the panel — codex.js's heroPanelSectionHtml() (and
2097  // its lazy shared-data/rankings re-renders) call this directly and drop
2098  // the result straight into `#cx-hero-panel-body`. Re-callable any number
2099  // of times (tab switch, a lazy fetch resolving) — it always reflects the
2100  // current `activeTab`, never accumulates state of its own.
2101  function bodyHtml(hero) {
2102    return (
2103      '<div class="hd">' + tabStripHtml() +
2104      '<div class="hd-tabpanel" role="tabpanel">' + panelBodyHtml(hero) + '</div>' +
2105      '</div>'
2106    );
2107  }
2108
2109  // Called by codex.js every time a DIFFERENT hero is opened — always
2110  // resets to Overview rather than trying to remember a tab across a hero
2111  // change. (The carousel's own Prev/Next arrows only move browsing
2112  // position, not the open hero — see codex.js's stepRosterCarousel(); they
2113  // never call this.)
2114  function open() {
2115    activeTab = 'overview';
2116    generalCardsExpanded = false;
2117    cardsFilter = 'all';
2118    // Task 9: an optimistic override belongs to the hero it was made for —
2119    // never carry it into a freshly-opened DIFFERENT hero's buttons.
2120    // tierUnverified is deliberately NOT reset here — see its own comment.
2121    tierVoteState = { slug: null, tier: null, inFlight: false };
2122  }
2123
2124  // codex.js's setShared() entry point (C5c) — pushes the lazily-fetched
2125  // /api/encyclopedia/shared payload into module state. Anything other than
2126  // a real object is treated as "not loaded" (the shared-backed sections all
2127  // guard on `sharedData` being truthy), so a malformed call can't half-render.
2128  function setShared(data) {
2129    sharedData = (data && typeof data === 'object') ? data : null;
2130  }
2131
2132  // codex.js reads this after setShared() resolves to decide whether the
2133  // open panel body actually needs re-rendering — only Cards and Assets
2134  // read shared data, so a resolve while any other tab is active is a no-op
2135  // for the DOM (setShared() has already updated module state either way, so
2136  // switching TO Cards/Assets afterwards picks it up on the next bodyHtml()
2137  // call regardless).
2138  function activeTabUsesShared() {
2139    return activeTab === 'cards' || activeTab === 'assets';
2140  }
2141
2142  // C6: codex.js's loadRankingsOnce() entry point — pushes the lazily-
2143  // fetched combined community-stats payload into module state. Mirrors
2144  // setShared() exactly: anything other than a real object is treated as
2145  // "not loaded" (communityStatsHtml() guards on `rankingsData` being
2146  // truthy), so a malformed call can't half-render.
2147  function setRankings(data) {
2148    rankingsData = (data && typeof data === 'object') ? data : null;
2149  }
2150
2151  // codex.js reads this after setRankings() resolves to decide whether the
2152  // open panel body actually needs re-rendering — only Overview reads
2153  // rankings data, so a resolve while any other tab is active is a no-op for
2154  // the DOM (setRankings() has already updated module state either way, so
2155  // switching back to Overview afterwards picks it up on the next
2156  // bodyHtml() call regardless).
2157  function activeTabUsesRankings() {
2158    return activeTab === 'overview';
2159  }
2160
2161  // Wires the tab strip inside `scope` (the panel's `#cx-hero-panel-body`
2162  // element, already populated with this module's own bodyHtml()). Clicking
2163  // a tab updates the internal `activeTab` and calls `onTabChange()` —
2164  // codex.js's callback re-renders just that body element and re-wires it
2165  // (see codex.js's wireHeroDetailBody()); this module never touches the
2166  // panel chrome (name/close button), only its own content.
2167  function wire(scope, hero, onTabChange) {
2168    if (!scope) return;
2169    var btns = scope.querySelectorAll('[data-hero-tab]');
2170    for (var i = 0; i < btns.length; i++) {
2171      btns[i].addEventListener('click', function (ev) {
2172        var id = ev.currentTarget.getAttribute('data-hero-tab');
2173        if (id === activeTab) return;
2174        activeTab = id;
2175        onTabChange();
2176      });
2177    }
2178    // Task 9 (dt-hero-tiers batch) — the Your Rating panel's five S/A/B/C/D
2179    // buttons. Delegates the actual vote()/clear() decision + optimistic
2180    // re-render entirely to handleTierVoteClick() above; this loop only
2181    // wires the DOM.
2182    var tierBtns = scope.querySelectorAll('[data-hero-tier]');
2183    for (var t = 0; t < tierBtns.length; t++) {
2184      tierBtns[t].addEventListener('click', function (ev) {
2185        handleTierVoteClick(ev.currentTarget.getAttribute('data-hero-tier'), hero, onTabChange);
2186      });
2187    }
2188    // Cards tab's General Cards Show/Hide toggle (C5c) — keyed off
2189    // data-hero-toggle rather than a second tab-strip-shaped mechanism,
2190    // since this toggles a section WITHIN the Cards tab, not between tabs.
2191    // Reuses the same onTabChange callback: it already does exactly
2192    // "re-render this body element and re-wire it," which is also correct
2193    // here.
2194    var toggles = scope.querySelectorAll('[data-hero-toggle]');
2195    for (var j = 0; j < toggles.length; j++) {
2196      toggles[j].addEventListener('click', function (ev) {
2197        var key = ev.currentTarget.getAttribute('data-hero-toggle');
2198        // onTabChange() is a full re-render — only worth doing when a
2199        // recognised key actually changed module state, not for every click
2200        // on anything carrying this attribute.
2201        if (key === 'general-cards') {
2202          generalCardsExpanded = !generalCardsExpanded;
2203          onTabChange();
2204        } else if (key && key.indexOf('cards-filter-') === 0) {
2205          // Card Pool filter chips (Task 9) — 'cards-filter-all' /
2206          // '-hero' / '-general' / '-upgrades'.
2207          var filter = key.slice('cards-filter-'.length);
2208          if (filter !== cardsFilter) {
2209            cardsFilter = filter;
2210            onTabChange();
2211          }
2212        }
2213      });
2214    }
2215    // Bug C fix (2026-09-17) — card tile click-to-enlarge (General/Hero/
2216    // Upgrade/Info Cards, cardTileOpenTag() above). Reuses the shared
2217    // `.tdtt-overlay` via window.TDTT.overlay.open() rather than a new modal
2218    // — the same primitive codex.js's openTcgCardOverlay() already opens for
2219    // the TCG card detail. Escape-close, backdrop-click-close and
2220    // focus-restore-to-trigger all come from that shared module for free;
2221    // this only has to build the (title, image) body.
2222    var zoomTiles = scope.querySelectorAll('[data-zoom-src]');
2223    for (var k = 0; k < zoomTiles.length; k++) {
2224      zoomTiles[k].addEventListener('click', function (ev) {
2225        var src = safeImageUrl(ev.currentTarget.getAttribute('data-zoom-src'));
2226        var title = ev.currentTarget.getAttribute('data-zoom-title') || '';
2227        if (!src || typeof window.TDTT === 'undefined' || !window.TDTT.overlay) return;
2228        window.TDTT.overlay.open({
2229          title: title,
2230          bodyHtml: (
2231            '<div style="display:flex;justify-content:center;">' +
2232            '<img src="' + esc(src) + '" alt="' + esc(title) + '" ' +
2233            'style="max-width:100%;max-height:74vh;object-fit:contain;border-radius:10px;">' +
2234            '</div>'
2235          ),
2236        });
2237      });
2238    }
2239
2240    // Ability tiles (Abilities tab): open the enlarged view with the text.
2241    var abilityTiles = scope.querySelectorAll('[data-hero-ability]');
2242    for (var a = 0; a < abilityTiles.length; a++) {
2243      abilityTiles[a].addEventListener('click', function (ev) {
2244        if (typeof window.TDTT === 'undefined' || !window.TDTT.overlay) return;
2245        var items = abilityEntriesOf(hero);
2246        var item = items[Number(ev.currentTarget.getAttribute('data-hero-ability'))];
2247        if (!item) return;
2248        openAbilityOverlay(hero, item);
2249      });
2250    }
2251    // Status Effects tab: a rules link goes to the rules page at that section
2252    // (the anchor is re-validated here, never trusted from the attribute); a
2253    // leaflet link switches to the Rulesheets tab and opens that sheet.
2254    var ruleLinks = scope.querySelectorAll('[data-hero-rule]');
2255    for (var r = 0; r < ruleLinks.length; r++) {
2256      ruleLinks[r].addEventListener('click', function (ev) {
2257        if (ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.button === 1) return;
2258        var anchor = ev.currentTarget.getAttribute('data-hero-rule');
2259        if (!/^[a-z0-9-]+$/.test(anchor || '')) return;
2260        if (!window.TDTTRouter || typeof window.TDTTRouter.go !== 'function') return;
2261        ev.preventDefault();
2262        window.TDTTRouter.go('rules', '?game=DICE_THRONE&section=' + anchor);
2263      });
2264    }
2265    var leafletLinks = scope.querySelectorAll('[data-hero-leaflet]');
2266    for (var l = 0; l < leafletLinks.length; l++) {
2267      leafletLinks[l].addEventListener('click', function (ev) {
2268        var n = Number(ev.currentTarget.getAttribute('data-hero-leaflet'));
2269        activeTab = 'rulesheets';
2270        onTabChange();
2271        var sheets = hero.assets && Array.isArray(hero.assets.rulesheets) ? hero.assets.rulesheets : [];
2272        var src = n >= 0 && n === Math.floor(n) && n < sheets.length ? safeImageUrl(sheets[n]) : '';
2273        if (!src || typeof window.TDTT === 'undefined' || !window.TDTT.overlay) return;
2274        window.TDTT.overlay.open({
2275          title: hero.name + ' rulesheet ' + (n + 1),
2276          bodyHtml: (
2277            '<div style="display:flex;justify-content:center;">' +
2278            '<img src="' + esc(src) + '" alt="' + esc(hero.name) + ' rulesheet ' + (n + 1) + '" ' +
2279            'style="max-width:100%;max-height:74vh;object-fit:contain;border-radius:10px;">' +
2280            '</div>'
2281          ),
2282        });
2283      });
2284    }
2285  }
2286
2287
2288  window.TDTT = window.TDTT || {};
2289  window.TDTT.heroDetail = {
2290    abilityDetailModel: twin.abilityDetailModel,
2291    splitHeroCards: twin.splitHeroCards,
2292    statusEffectsModel: twin.statusEffectsModel,
2293    TABS: TABS,
2294    open: open,
2295    bodyHtml: bodyHtml,
2296    wire: wire,
2297    setShared: setShared,
2298    activeTabUsesShared: activeTabUsesShared,
2299    setRankings: setRankings,
2300    activeTabUsesRankings: activeTabUsesRankings,
2301    tierPanelHtml: tierPanelHtml,
2302  };
2303})();

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.