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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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&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 — ' + 1618 esc(counts.generalByType.main) + ' Main Phase<span class="hd-tier__sep">·</span>' + 1619 esc(counts.generalByType.instant) + ' Instant<span class="hd-tier__sep">·</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§ion=' + 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.