diff --git a/src/apps/epic/migrations/0014_rename_reversal_to_reversal_qualifier.py b/src/apps/epic/migrations/0014_rename_reversal_to_reversal_qualifier.py new file mode 100644 index 0000000..2def261 --- /dev/null +++ b/src/apps/epic/migrations/0014_rename_reversal_to_reversal_qualifier.py @@ -0,0 +1,22 @@ +"""Rename TarotCard.reversal → TarotCard.reversal_qualifier. + +Symmetric naming with levity_qualifier / gravity_qualifier; disambiguates the +qualifier-text field from the reversal *axis* state and the keywords_reversed +list. Pure column rename — no data movement. +""" +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0013_fix_nomad_icon"), + ] + + operations = [ + migrations.RenameField( + model_name="tarotcard", + old_name="reversal", + new_name="reversal_qualifier", + ), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index c1711e8..d87be2d 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -250,7 +250,7 @@ class TarotCard(models.Model): slug = models.SlugField(max_length=120) correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent group = models.CharField(max_length=100, blank=True) # Earthman major grouping - reversal = models.CharField(max_length=200, blank=True, default='') # reversed-state title; blank = same as name + reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # reversal-axis qualifier (e.g. "Nervous"); polarity-shared; blank = falls back to current polarity's qualifier levity_qualifier = models.CharField(max_length=100, blank=True, default='') gravity_qualifier = models.CharField(max_length=100, blank=True, default='') levity_emanation = models.CharField(max_length=200, blank=True, default='') # polarity-split upright (cards 48-49) diff --git a/src/apps/epic/static/apps/epic/combobox.js b/src/apps/epic/static/apps/epic/combobox.js new file mode 100644 index 0000000..d4acd12 --- /dev/null +++ b/src/apps/epic/static/apps/epic/combobox.js @@ -0,0 +1,123 @@ +// Generic CSS-stylable replacement for +// +// +// On selection the hidden input's value is updated and a 'change' event fires +// on it, so existing consumers (`document.getElementById('X').value`) keep working. +var Combobox = (function () { + 'use strict'; + + function init(combo) { + if (!combo || combo.dataset.comboboxInit) return; + combo.dataset.comboboxInit = '1'; + + var hiddenId = combo.dataset.comboboxTarget; + var hidden = hiddenId && document.getElementById(hiddenId); + var current = combo.querySelector('.sea-select-current'); + var list = combo.querySelector('.sea-select-list'); + var options = list ? Array.from(list.querySelectorAll('[role="option"]')) : []; + var focusIdx = options.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; }); + if (focusIdx < 0) focusIdx = 0; + + function isOpen() { return combo.getAttribute('aria-expanded') === 'true'; } + function open() { + combo.setAttribute('aria-expanded', 'true'); + // Re-focus the currently-selected option each time we open + focusIdx = options.findIndex(function (o) { return o.getAttribute('aria-selected') === 'true'; }); + if (focusIdx < 0) focusIdx = 0; + highlight(); + } + function close() { combo.setAttribute('aria-expanded', 'false'); } + function toggle() { isOpen() ? close() : open(); } + + function highlight() { + options.forEach(function (o, i) { + o.classList.toggle('sea-select-option--focus', i === focusIdx); + }); + } + + function select(i) { + if (i < 0 || i >= options.length) return; + options.forEach(function (o, idx) { + o.setAttribute('aria-selected', idx === i ? 'true' : 'false'); + }); + var picked = options[i]; + if (current) current.textContent = picked.textContent.trim(); + if (hidden && hidden.value !== picked.dataset.value) { + hidden.value = picked.dataset.value; + hidden.dispatchEvent(new Event('change', { bubbles: true })); + } + close(); + } + + combo.addEventListener('click', function (e) { + var opt = e.target.closest('[role="option"]'); + if (opt) { + var i = options.indexOf(opt); + if (i >= 0) select(i); + return; + } + toggle(); + }); + + combo.addEventListener('keydown', function (e) { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + if (!isOpen()) { open(); } + else { focusIdx = Math.min(options.length - 1, focusIdx + 1); highlight(); } + break; + case 'ArrowUp': + e.preventDefault(); + if (!isOpen()) { open(); } + else { focusIdx = Math.max(0, focusIdx - 1); highlight(); } + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (isOpen()) select(focusIdx); + else open(); + break; + case 'Escape': + if (isOpen()) { e.preventDefault(); close(); } + break; + case 'Home': + if (isOpen()) { e.preventDefault(); focusIdx = 0; highlight(); } + break; + case 'End': + if (isOpen()) { e.preventDefault(); focusIdx = options.length - 1; highlight(); } + break; + } + }); + + // Click outside the combo closes the dropdown + document.addEventListener('click', function (e) { + if (!combo.contains(e.target)) close(); + }); + } + + function initAll(root) { + (root || document).querySelectorAll('[data-combobox]').forEach(init); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { initAll(); }); + } else { + initAll(); + } + + return { init: init, initAll: initAll }; +}()); diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js index 3865b5d..ec23845 100644 --- a/src/apps/epic/static/apps/epic/sea.js +++ b/src/apps/epic/static/apps/epic/sea.js @@ -13,70 +13,17 @@ var SeaDeal = (function () { var _infoOpen = false; var _spinOrigLabel, _fyiOrigLabel; - // ── Keyword list ────────────────────────────────────────────────────────── - - function _populateList(listEl, items) { - if (!listEl) return; - listEl.innerHTML = (items || []).map(function (k) { - return '
  • ' + k + '
  • '; - }).join(''); - } - - // ── Stage card population ───────────────────────────────────────────────── + // ── Stage card population (delegates to shared StageCard module) ────────── function _populate(card, isLevity) { - var qualifier = isLevity - ? (card.levity_qualifier || '') - : (card.gravity_qualifier || ''); - var isMajor = card.arcana === 'MAJOR'; - var title = card.name_title || card.name || ''; - - // Corners - stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { - el.textContent = card.corner_rank || ''; + var polarity = isLevity ? 'levity' : 'gravity'; + StageCard.populateCard(stageCard, card, polarity); + StageCard.populateKeywords(statBlock, card.keywords_upright, card.keywords_reversed, { + uprightSel: '#id_sea_stat_upright', + reversedSel: '#id_sea_stat_reversed', }); - stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) { - if (card.suit_icon) { - el.className = 'fa-solid ' + card.suit_icon + ' stage-suit-icon'; - el.style.display = ''; - } else { - el.style.display = 'none'; - } - }); - - // Upright face - var nameGroupEl = stageCard.querySelector('.fan-card-name-group'); - if (nameGroupEl) nameGroupEl.textContent = card.name_group || ''; - var arcanaEl = stageCard.querySelector('.fan-card-arcana'); - if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana'; - stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title; - stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier; - stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : ''; - - // Reversal face (same slot-swap logic as sig-select) - var reversal = card.reversal || ''; - if (isMajor) { - stageCard.querySelector('.fan-card-reversal-qualifier').textContent = title + ','; - stageCard.querySelector('.fan-card-reversal-name').textContent = qualifier; - } else if (reversal) { - stageCard.querySelector('.fan-card-reversal-qualifier').textContent = reversal; - stageCard.querySelector('.fan-card-reversal-name').textContent = title; - } else { - stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier; - stageCard.querySelector('.fan-card-reversal-name').textContent = title; - } - - // Keywords - _populateList(overlay.querySelector('#id_sea_stat_upright'), card.keywords_upright); - _populateList(overlay.querySelector('#id_sea_stat_reversed'), card.keywords_reversed); - - // FYI data (energies + operations) - _infoData = (card.energies || []).map(function (e) { - return { type: e.type, effect: e.effect, category: 'energies' }; - }).concat((card.operations || []).map(function (o) { - return { type: o.type, effect: o.effect, category: 'operations' }; - })); - _infoIdx = 0; + _infoData = StageCard.buildInfoData(card); + _infoIdx = 0; // Reset SPIN stageCard.classList.remove('stage-card--reversed'); @@ -87,23 +34,7 @@ var SeaDeal = (function () { // ── FYI info panel ──────────────────────────────────────────────────────── function _renderInfo() { - if (!fyiPanel) return; - if (_infoData.length === 0) { - fyiTitle.textContent = 'Energy'; - fyiTitle.className = 'sig-info-title sig-info-title--energies'; - if (fyiType) fyiType.textContent = ''; - if (fyiEffect) fyiEffect.innerHTML = 'No interactions defined.'; - fyiIndex.textContent = ''; - return; - } - var entry = _infoData[_infoIdx]; - var isEnergies = entry.category === 'energies'; - fyiTitle.textContent = isEnergies ? 'Energy' : 'Operation'; - fyiTitle.className = 'sig-info-title sig-info-title--' + entry.category; - if (fyiType) fyiType.textContent = entry.type || ''; - if (fyiEffect) fyiEffect.innerHTML = entry.effect || ''; - fyiIndex.textContent = _infoData.length > 1 - ? (_infoIdx + 1) + ' / ' + _infoData.length : ''; + StageCard.renderFyi(fyiPanel, _infoData, _infoIdx); } function _openInfo() { @@ -112,7 +43,7 @@ var SeaDeal = (function () { if (fyiPanel) fyiPanel.style.display = 'flex'; if (spinBtn) { spinBtn.classList.add('btn-disabled'); spinBtn.textContent = '×'; } if (fyiBtn) { fyiBtn.classList.add('btn-disabled'); fyiBtn.textContent = '×'; } - stage.classList.add('sea-info-open'); + statBlock.classList.add('fyi-open'); } function _closeInfo() { @@ -120,7 +51,7 @@ var SeaDeal = (function () { if (fyiPanel) fyiPanel.style.display = 'none'; if (spinBtn) { spinBtn.classList.remove('btn-disabled'); spinBtn.textContent = _spinOrigLabel || 'SPIN'; } if (fyiBtn) { fyiBtn.classList.remove('btn-disabled'); fyiBtn.textContent = _fyiOrigLabel || 'FYI'; } - if (stage) stage.classList.remove('sea-info-open'); + if (statBlock) statBlock.classList.remove('fyi-open'); } // ── Slot fill ───────────────────────────────────────────────────────────── @@ -187,8 +118,8 @@ var SeaDeal = (function () { _userPolarity = overlay.dataset.seaUserPolarity || 'levity'; - spinBtn = statBlock.querySelector('.sea-spin-btn'); - fyiBtn = statBlock.querySelector('.sea-fyi-btn'); + spinBtn = statBlock.querySelector('.spin-btn'); + fyiBtn = statBlock.querySelector('.fyi-btn'); bdrop = stage.querySelector('.sea-stage-backdrop'); fyiPanel = overlay.querySelector('#id_sea_fyi_panel'); @@ -196,8 +127,8 @@ var SeaDeal = (function () { fyiType = fyiPanel && fyiPanel.querySelector('.sig-info-type'); fyiEffect = fyiPanel && fyiPanel.querySelector('.sig-info-effect'); fyiIndex = fyiPanel && fyiPanel.querySelector('.sig-info-index'); - fyiPrev = statBlock.querySelector('.sea-fyi-prev'); - fyiNext = statBlock.querySelector('.sea-fyi-next'); + fyiPrev = statBlock.querySelector('.fyi-prev'); + fyiNext = statBlock.querySelector('.fyi-next'); _spinOrigLabel = spinBtn ? spinBtn.textContent : 'SPIN'; _fyiOrigLabel = fyiBtn ? fyiBtn.textContent : 'FYI'; @@ -225,7 +156,7 @@ var SeaDeal = (function () { // Clicking the FYI panel itself dismisses it (same as sig-select caution) if (fyiPanel) { fyiPanel.addEventListener('click', function (e) { - if (!e.target.closest('.sea-fyi-prev') && !e.target.closest('.sea-fyi-next')) { + if (!e.target.closest('.fyi-prev') && !e.target.closest('.fyi-next')) { _closeInfo(); } }); diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index 5408dcb..b8105b3 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -36,63 +36,32 @@ var SigSelect = (function () { // ── Stage ────────────────────────────────────────────────────────────── - function _populateKeywordList(listEl, csv) { - var keywords = csv ? csv.split(',').filter(Boolean) : []; - listEl.innerHTML = keywords.map(function (k) { - return '
  • ' + k.trim() + '
  • '; - }).join(''); - } + // _populateKeywordList removed — sig now delegates to StageCard.populateKeywords // ── Caution tooltip ─────────────────────────────────────────────────── function _renderCaution() { - if (_cautionData.length === 0) { - cautionTitle.textContent = 'Energy'; - cautionTitle.className = 'sig-info-title sig-info-title--energies'; - if (cautionTypeEl) cautionTypeEl.textContent = ''; - cautionEffect.innerHTML = 'No interactions defined.'; - cautionPrev.disabled = true; - cautionNext.disabled = true; - cautionIndexEl.textContent = ''; - return; - } - var entry = _cautionData[_cautionIdx]; - var isEnergies = entry.category === 'energies'; - cautionTitle.textContent = isEnergies ? 'Energy' : 'Operation'; - cautionTitle.className = 'sig-info-title sig-info-title--' + entry.category; - if (cautionTypeEl) cautionTypeEl.textContent = entry.type || ''; - cautionEffect.innerHTML = entry.effect || ''; - cautionPrev.disabled = (_cautionData.length <= 1); - cautionNext.disabled = (_cautionData.length <= 1); - cautionIndexEl.textContent = _cautionData.length > 1 - ? (_cautionIdx + 1) + ' / ' + _cautionData.length - : ''; + StageCard.renderFyi(stage.querySelector('.sig-info'), _cautionData, _cautionIdx); + // Sig disables PRV/NXT when there's only one entry (sea/fan don't bother). + if (cautionPrev) cautionPrev.disabled = (_cautionData.length <= 1); + if (cautionNext) cautionNext.disabled = (_cautionData.length <= 1); } function _openCaution() { if (!_focusedCardEl) return; - try { - var energies = JSON.parse(_focusedCardEl.dataset.energies || '[]'); - var operations = JSON.parse(_focusedCardEl.dataset.operations || '[]'); - _cautionData = energies.map(function (e) { - return { type: e.type, effect: e.effect, category: 'energies' }; - }).concat(operations.map(function (o) { - return { type: o.type, effect: o.effect, category: 'operations' }; - })); - } catch (e) { - _cautionData = []; - } + var card = StageCard.fromDataset(_focusedCardEl); + _cautionData = StageCard.buildInfoData(card); _cautionIdx = 0; _renderCaution(); _flipBtn.classList.add('btn-disabled'); _cautionBtn.classList.add('btn-disabled'); _flipBtn.textContent = '\u00D7'; _cautionBtn.textContent = '\u00D7'; - stage.classList.add('sig-info-open'); + statBlock.classList.add('fyi-open'); } function _closeCaution() { - stage.classList.remove('sig-info-open'); + if (statBlock) statBlock.classList.remove('fyi-open'); if (_flipBtn) { _flipBtn.classList.remove('btn-disabled'); _cautionBtn.classList.remove('btn-disabled'); @@ -112,65 +81,19 @@ var SigSelect = (function () { } _focusedCardEl = cardEl; - var rank = cardEl.dataset.cornerRank || ''; - var icon = cardEl.dataset.suitIcon || ''; - var group = cardEl.dataset.nameGroup || ''; - var title = cardEl.dataset.nameTitle || ''; - var arcana= cardEl.dataset.arcana || ''; - var corr = cardEl.dataset.correspondence || ''; + var card = StageCard.fromDataset(cardEl); + StageCard.populateCard(stageCard, card, userPolarity); + // Sig hides the correspondence in the stage card (shown in game-kit only) + var corrEl = stageCard.querySelector('.fan-card-correspondence'); + if (corrEl) corrEl.textContent = ''; - stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; }); - stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) { - if (icon) { - el.className = 'fa-solid ' + icon + ' stage-suit-icon'; - el.style.display = ''; - } else { - el.style.display = 'none'; - } - }); - stageCard.querySelector('.fan-card-name-group').textContent = group; - stageCard.querySelector('.fan-card-arcana').textContent = arcana; - stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only - - var qualifier = userPolarity === 'levity' - ? (cardEl.dataset.levityQualifier || '') - : (cardEl.dataset.gravityQualifier || ''); - var isMajor = arcana.toLowerCase().indexOf('major') !== -1; - // Major arcana: qualifier sits below the title — append comma so it reads as a subtitle. - stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title; - stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier; - stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : ''; - - // Reversed face. - // - Major arcana: polarity qualifier + reversal concept name - // - Non-major w. reversal: suit qualifier word replaces polarity qualifier; - // card name (title) stays the same — two separate lines - // - Non-major w/o reversal: fall back to mirroring the polarity qualifier - var reversal = cardEl.dataset.reversal || ''; - if (isMajor) { - // Slots are swapped vs. non-major: spin reverses DOM order visually, - // so qualifier-slot (DOM-second) appears first and name-slot (DOM-first) appears second. - stageCard.querySelector('.fan-card-reversal-qualifier').textContent = title + ','; - stageCard.querySelector('.fan-card-reversal-name').textContent = qualifier; - } else if (reversal) { - stageCard.querySelector('.fan-card-reversal-qualifier').textContent = reversal; - stageCard.querySelector('.fan-card-reversal-name').textContent = title; - } else { - stageCard.querySelector('.fan-card-reversal-qualifier').textContent = qualifier; - stageCard.querySelector('.fan-card-reversal-name').textContent = title; - } - - // Populate stat block keyword faces and reset to upright + // Reset SPIN state, then populate keyword lists statBlock.classList.remove('is-reversed'); stageCard.classList.remove('stage-card--reversed'); - _populateKeywordList( - statBlock.querySelector('#id_stat_keywords_upright'), - cardEl.dataset.keywordsUpright - ); - _populateKeywordList( - statBlock.querySelector('#id_stat_keywords_reversed'), - cardEl.dataset.keywordsReversed - ); + StageCard.populateKeywords(statBlock, card.keywords_upright, card.keywords_reversed, { + uprightSel: '#id_stat_keywords_upright', + reversedSel: '#id_stat_keywords_reversed', + }); stageCard.style.display = ''; stage.classList.add('sig-stage--active'); @@ -639,8 +562,8 @@ var SigSelect = (function () { stageCard = stage.querySelector('.sig-stage-card'); statBlock = stage.querySelector('.sig-stat-block'); - _flipBtn = statBlock.querySelector('.sig-flip-btn'); - _cautionBtn = statBlock.querySelector('.sig-info-btn'); + _flipBtn = statBlock.querySelector('.spin-btn'); + _cautionBtn = statBlock.querySelector('.fyi-btn'); _flipOrigLabel = _flipBtn.textContent; _cautionOrigLabel = _cautionBtn.textContent; @@ -654,8 +577,8 @@ var SigSelect = (function () { cautionEffect = cautionEl.querySelector('.sig-info-effect'); cautionTitle = cautionEl.querySelector('.sig-info-title'); cautionTypeEl = cautionEl.querySelector('.sig-info-type'); - cautionPrev = statBlock.querySelector('.sig-info-prev'); - cautionNext = statBlock.querySelector('.sig-info-next'); + cautionPrev = statBlock.querySelector('.fyi-prev'); + cautionNext = statBlock.querySelector('.fyi-next'); cautionIndexEl = cautionEl.querySelector('.sig-info-index'); // Clicking the tooltip (not nav buttons) dismisses it @@ -665,7 +588,7 @@ var SigSelect = (function () { _cautionBtn.addEventListener('click', function () { if (_cautionBtn.classList.contains('btn-disabled')) return; - stage.classList.contains('sig-info-open') ? _closeCaution() : _openCaution(); + statBlock.classList.contains('fyi-open') ? _closeCaution() : _openCaution(); }); cautionPrev.addEventListener('click', function () { _cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length; diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js new file mode 100644 index 0000000..0d3a143 --- /dev/null +++ b/src/apps/epic/static/apps/epic/stage-card.js @@ -0,0 +1,158 @@ +// Shared stage-card helpers used by Sig Select, Sea Select, and the Game Kit fan. +// +// Each call site handles its own DOM lookup, click wiring, and polarity decisions; +// this module owns the duplicated logic for normalizing a card source, painting +// the upright/reversal faces, populating the stat-block keyword lists, and +// rendering the FYI panel entries. +var StageCard = (function () { + 'use strict'; + + function _parseCSV(str) { + return (str || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean); + } + function _parseJSON(str) { + try { return JSON.parse(str || '[]'); } catch (e) { return []; } + } + + // Normalize a `.sig-card` / `.fan-card` element's data-* attrs into the same + // shape that sea.js receives via fetch (so all call sites can share populateCard). + function fromDataset(el) { + return { + corner_rank: el.dataset.cornerRank || '', + suit_icon: el.dataset.suitIcon || '', + name_group: el.dataset.nameGroup || '', + name_title: el.dataset.nameTitle || '', + arcana: el.dataset.arcana || '', + correspondence: el.dataset.correspondence || '', + keywords_upright: _parseCSV(el.dataset.keywordsUpright), + keywords_reversed: _parseCSV(el.dataset.keywordsReversed), + energies: _parseJSON(el.dataset.energies), + operations: _parseJSON(el.dataset.operations), + levity_qualifier: el.dataset.levityQualifier || '', + gravity_qualifier: el.dataset.gravityQualifier || '', + reversal_qualifier: el.dataset.reversalQualifier || '', + }; + } + + // Decide whether a card object represents Major Arcana — sig sources from + // `data-arcana` (Django's `get_arcana_display`, e.g. "Major Arcana"), sea + // from card.arcana (model code, e.g. "MAJOR"). Accept both. + function _isMajor(card) { + var a = (card.arcana || '').toUpperCase(); + return a === 'MAJOR' || a === 'MAJOR ARCANA'; + } + + // Paint the stage-card's upright + reversal faces from a normalized card + // object + the active polarity ('levity' | 'gravity'). Reversal-qualifier + // falls back to the current polarity's qualifier when blank (6F behavior). + function populateCard(stageCard, card, polarity) { + if (!stageCard) return; + var isLevity = polarity === 'levity'; + var qualifier = isLevity ? (card.levity_qualifier || '') : (card.gravity_qualifier || ''); + var isMajor = _isMajor(card); + var title = card.name_title || ''; + var reversalQualifier = card.reversal_qualifier || ''; + + stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { + el.textContent = card.corner_rank || ''; + }); + stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) { + if (card.suit_icon) { + el.className = 'fa-solid ' + card.suit_icon + ' stage-suit-icon'; + el.style.display = ''; + } else { + el.style.display = 'none'; + } + }); + + var nameGroupEl = stageCard.querySelector('.fan-card-name-group'); + if (nameGroupEl) nameGroupEl.textContent = card.name_group || ''; + + var arcanaEl = stageCard.querySelector('.fan-card-arcana'); + if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana'; + + var nameEl = stageCard.querySelector('.fan-card-name'); + if (nameEl) nameEl.textContent = isMajor ? title + ',' : title; + + var qAbove = stageCard.querySelector('.sig-qualifier-above'); + if (qAbove) qAbove.textContent = isMajor ? '' : qualifier; + var qBelow = stageCard.querySelector('.sig-qualifier-below'); + if (qBelow) qBelow.textContent = isMajor ? qualifier : ''; + + // Reversal face — three cases: + // Major: title (with comma) in qualifier slot, qualifier in name slot + // Non-major + reversal_qual: reversal_qualifier in qualifier slot, title in name slot + // Non-major no reversal_qual: fall back to current polarity's qualifier + var rQual = stageCard.querySelector('.fan-card-reversal-qualifier'); + var rName = stageCard.querySelector('.fan-card-reversal-name'); + if (rQual && rName) { + if (isMajor) { + rQual.textContent = title + ','; + rName.textContent = qualifier; + } else if (reversalQualifier) { + rQual.textContent = reversalQualifier; + rName.textContent = title; + } else { + rQual.textContent = qualifier; + rName.textContent = title; + } + } + } + + // Fill the upright + reversed keyword