Compare commits

...

2 Commits

Author SHA1 Message Date
Disco DeDisco
da57106d7a castanedan virtues + card 49 tweak; italic_word for trumps 19–21; sig/sea propagation — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- migration 0016: card 49 gravity_reversal All-Bestowing → Bestowing
- migration 0017: implicit virtues (trumps 6–9) Sublimating/Sedimentary qualifiers + shared reversals (Indulged Folly / Indulgent Doing / Self-Indulgence / Indulging Personal History); explicit virtues (trumps 19–21) full-string emanation/reversal overrides (The Hunter's/Sleeper's/Quarry's etc.); canonicalize trump 7 name "Not Doing" → "Not-Doing"
- migrations 0018+0019: TarotCard.italic_word field; populated for trumps 19–21 (Stalking / Dreaming / Intent)
- _tarot_fan.html: data-italic-word + |italicize:card.italic_word filter applied to all rendered title slots
- new templatetags/tarot_filters.py: italicize(text, word) — escape-safe <em> wrapping
- StageCard JS: parse data-italic-word; new _escape / _italicize / _setTitle helpers wrap matching word in <em> via innerHTML when present (textContent otherwise)
- views.py _card_dict: include polarity-split overrides + italic_word so Sea Select stage gets them via fetch JSON
- _sig_select_overlay.html: emit the five new data-* attrs on sig-card markup so Sig Select stage picks them up via StageCard.fromDataset

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 23:36:35 -04:00
Disco DeDisco
270e48ab2c cards 48–49 polarity-split titles; sea-stage mobile breakpoints; @comment fix — TDD
- migration 0015 fills card 49 levity_reversal=The Vibrational Mould of Man, gravity_reversal=The All-Bestowing Eagle (card 48 already seeded in 0004)
- _tarot_fan.html: 4 new data-* attrs (data-levity-emanation / data-gravity-emanation / data-levity-reversal / data-gravity-reversal); upright + reversal slots render full polarity-split title in name slot when set, qualifier slots blank
- StageCard.fromDataset: parse the 4 new attrs; populateCard: emanationOverride / reversalOverride per polarity bypasses the standard name+qualifier rendering
- model: emanation_for / reversal_for fall back to name_title (group prefix stripped) instead of full self.name; reversal_for uses self.reversal_qualifier (was leftover self.reversal post-rename)
- sea-stage-content: --sig-card-w lifted from inline style to SCSS w. portrait ≤480px / landscape ≤500h breakpoints both stepping to 130px (mirrors fan modal triggers); default 180px
- _tarot_fan.html: rewrite multi-line {# #} that rendered as page text into {% comment %}{% endcomment %}

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 21:51:23 -04:00
14 changed files with 447 additions and 34 deletions

View File

@@ -0,0 +1,60 @@
"""Populate card 49's polarity-split reversal titles.
The Earthman deck's last two cards (4849) carry distinct titles per polarity
(stored in `levity_emanation` / `gravity_emanation` / `levity_reversal` /
`gravity_reversal`) rather than a shared title + qualifier.
Card 48 had its full set seeded in migration 0004:
levity: Father Sky → reversal: The Storm
gravity: Mother Sea → reversal: The Flood
Card 49 had only emanations seeded; this migration fills the reversals:
levity: The Effulgent Mould of Man → reversal: The Vibrational Mould of Man
gravity: The Devouring Eagle → reversal: The All-Bestowing Eagle
The "qualifier" (Effulgent / Vibrational / Devouring / All-Bestowing) is baked
into the title between "The" and the title-proper rather than rendered as a
separate qualifier slot — the per-polarity title strings are stored verbatim.
"""
from django.db import migrations
def populate_card49_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="The Vibrational Mould of Man",
gravity_reversal="The All-Bestowing Eagle",
)
def clear_card49_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="",
gravity_reversal="",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0014_rename_reversal_to_reversal_qualifier"),
]
operations = [
migrations.RunPython(populate_card49_reversals, reverse_code=clear_card49_reversals),
]

View File

@@ -0,0 +1,37 @@
"""Tweak card 49 gravity_reversal: 'All-Bestowing Eagle''Bestowing Eagle'."""
from django.db import migrations
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(gravity_reversal="The Bestowing Eagle")
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(gravity_reversal="The All-Bestowing Eagle")
class Migration(migrations.Migration):
dependencies = [
("epic", "0015_card49_polarity_reversal_titles"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,114 @@
"""Populate the seven Castanedan Virtues — trumps 69 (Implicit) + 1921 (Explicit).
Implicit Virtues (69): emanation qualifier differs by polarity (Sublimating /
Sedimentary), name is shared. Reversal is a single full string shared across
both polarities (the agency word — Controlled / Not / Losing / Erasing —
flips to Indulged / Indulgent / Self-Indulgence / Indulging). We fill the
standard `levity_qualifier` / `gravity_qualifier` slots so the major-arcana
upright renders "Controlled Folly,\nSublimating" via the existing template
branch; we fill BOTH `levity_reversal` + `gravity_reversal` with the same
string so a FLIP'd reversal still picks up the override (an empty side falls
through to the default major-arcana rendering).
Explicit Virtues (1921): emanation is shared across polarities (e.g. "The
Hunter's Stalking" — no qualifier + stem decomposition), reversal differs by
polarity. All four polarity-split title fields filled.
Also canonicalizes trump 7's name from "Not Doing" to "Not-Doing" per the spec
doc (slug "not-doing" already correct).
"""
from django.db import migrations
IMPLICIT = [
# (number, levity_qualifier, gravity_qualifier, reversal_title)
(6, "Sublimating", "Sedimentary", "Indulged Folly"),
(7, "Sublimating", "Sedimentary", "Indulgent Doing"),
(8, "Sublimating", "Sedimentary", "Self-Indulgence"),
(9, "Sublimating", "Sedimentary", "Indulging Personal History"),
]
EXPLICIT = [
# (number, levity_emanation, gravity_emanation, levity_reversal, gravity_reversal)
(19, "The Hunter's Stalking", "The Hunter's Stalking", "The Sleeper's Stalking", "The Quarry's Stalking"),
(20, "The Dreamer's Dreaming", "The Dreamer's Dreaming", "The Sleeper's Dreaming", "The Dreamed's Dreaming"),
(21, "The Warrior's Intent", "The Warrior's Intent", "The Sleeper's Intent", "The Predator's Intent"),
]
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Trump 7 name canonicalization
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not-Doing")
for number, lvty, grav, rev in IMPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier=lvty,
gravity_qualifier=grav,
levity_reversal=rev,
gravity_reversal=rev,
)
for number, le, ge, lr, gr in EXPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation=le,
gravity_emanation=ge,
levity_reversal=lr,
gravity_reversal=gr,
)
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not Doing")
for number, _lvty, _grav, _rev in IMPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier="",
gravity_qualifier="",
levity_reversal="",
gravity_reversal="",
)
for number, _le, _ge, _lr, _gr in EXPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation="",
gravity_emanation="",
levity_reversal="",
gravity_reversal="",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0016_card49_bestowing_eagle"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-01 03:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0017_castanedan_virtues'),
]
operations = [
migrations.AddField(
model_name='tarotcard',
name='italic_word',
field=models.CharField(blank=True, default='', max_length=50),
),
]

View File

@@ -0,0 +1,51 @@
"""Set TarotCard.italic_word for trumps 19-21 (Stalking / Dreaming / Intent).
Each of these three Castanedan virtues has its title key-word italicized
across every emanation/reversal slot ("The Hunter's *Stalking*", "The
Sleeper's *Stalking*", etc.). Storing the word in a single field lets the
renderer wrap it in <em> at display time without HTML in the data.
"""
from django.db import migrations
WORDS = {
19: "Stalking",
20: "Dreaming",
21: "Intent",
}
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
for number, word in WORDS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(italic_word=word)
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number__in=list(WORDS),
).update(italic_word="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0018_add_italic_word"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -257,6 +257,7 @@ class TarotCard(models.Model):
gravity_emanation = models.CharField(max_length=200, blank=True, default='')
levity_reversal = models.CharField(max_length=200, blank=True, default='') # polarity-split reversal (card 48)
gravity_reversal = models.CharField(max_length=200, blank=True, default='')
italic_word = models.CharField(max_length=50, blank=True, default='') # word(s) inside any title slot to wrap in <em> at render time (e.g. "Stalking" for trumps 19-21)
energies = models.JSONField(default=list) # list of {type, effect} dicts — Energy interactions
operations = models.JSONField(default=list) # list of {type, effect} dicts — Operation interactions
keywords_upright = models.JSONField(default=list)
@@ -291,21 +292,22 @@ class TarotCard(models.Model):
def emanation_for(self, polarity):
"""Return the upright title for a given polarity ('levity' or 'gravity').
Falls back to name for cards without a polarity split."""
Falls back to name_title (group prefix stripped) for cards without a
polarity split."""
if polarity == 'levity' and self.levity_emanation:
return self.levity_emanation
if polarity == 'gravity' and self.gravity_emanation:
return self.gravity_emanation
return self.name
return self.name_title
def reversal_for(self, polarity):
"""Return the reversed title for a given polarity.
Falls back to reversal (blank = same as emanation_for)."""
Falls back to reversal_qualifier (blank = same as emanation_for)."""
if polarity == 'levity' and self.levity_reversal:
return self.levity_reversal
if polarity == 'gravity' and self.gravity_reversal:
return self.gravity_reversal
return self.reversal or self.emanation_for(polarity)
return self.reversal_qualifier or self.emanation_for(polarity)
@property
def name_group(self):

View File

@@ -31,9 +31,51 @@ var StageCard = (function () {
levity_qualifier: el.dataset.levityQualifier || '',
gravity_qualifier: el.dataset.gravityQualifier || '',
reversal_qualifier: el.dataset.reversalQualifier || '',
// Polarity-split title overrides — non-blank for cards 48-49 only,
// where each polarity (and within each polarity, each axis state)
// has a fully distinct title rather than a shared name + qualifier.
levity_emanation: el.dataset.levityEmanation || '',
gravity_emanation: el.dataset.gravityEmanation || '',
levity_reversal: el.dataset.levityReversal || '',
gravity_reversal: el.dataset.gravityReversal || '',
// Word(s) inside any title slot to wrap in <em> at render time
// (e.g. "Stalking" for trumps 19-21). Blank for most cards.
italic_word: el.dataset.italicWord || '',
};
}
function _escape(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Wrap every occurrence of `word` in `text` with <em>...</em>, returning
// an HTML-safe string. Both inputs are escaped before splicing.
function _italicize(text, word) {
if (!text) return '';
var safeText = _escape(text);
if (!word) return safeText;
var safeWord = _escape(word);
if (safeText.indexOf(safeWord) === -1) return safeText;
return safeText.split(safeWord).join('<em>' + safeWord + '</em>');
}
// Set element text — uses innerHTML if the card has an italic_word that
// appears in the string, otherwise textContent. Caller passes the full
// card so we can pick up `italic_word` without re-passing it everywhere.
function _setTitle(el, text, card) {
if (!el) return;
if (card.italic_word && text && text.indexOf(card.italic_word) !== -1) {
el.innerHTML = _italicize(text, card.italic_word);
} else {
el.textContent = text || '';
}
}
// 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.
@@ -52,6 +94,12 @@ var StageCard = (function () {
var isMajor = _isMajor(card);
var title = card.name_title || '';
var reversalQualifier = card.reversal_qualifier || '';
// Polarity-split overrides (cards 48-49). When set, the full title
// string already incorporates whatever qualifier the card carries
// ("The Effulgent Mould of Man" etc.) — render as a single line and
// leave the upright qualifier slots empty.
var emanationOverride = isLevity ? (card.levity_emanation || '') : (card.gravity_emanation || '');
var reversalOverride = isLevity ? (card.levity_reversal || '') : (card.gravity_reversal || '');
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) {
el.textContent = card.corner_rank || '';
@@ -66,35 +114,46 @@ var StageCard = (function () {
});
var nameGroupEl = stageCard.querySelector('.fan-card-name-group');
if (nameGroupEl) nameGroupEl.textContent = card.name_group || '';
if (nameGroupEl) nameGroupEl.textContent = emanationOverride ? '' : (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:
if (emanationOverride) {
// Cards 48-49 + trumps 19-21 — single-line title, no qualifier slots.
_setTitle(nameEl, emanationOverride, card);
if (qAbove) qAbove.textContent = '';
if (qBelow) qBelow.textContent = '';
} else {
_setTitle(nameEl, isMajor ? title + ',' : title, card);
if (qAbove) qAbove.textContent = isMajor ? '' : qualifier;
if (qBelow) qBelow.textContent = isMajor ? qualifier : '';
}
// Reversal face — four cases:
// Polarity-split: full reversal title in qualifier slot (top-after-spin), name slot empty
// 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 + ',';
if (reversalOverride) {
_setTitle(rQual, reversalOverride, card);
rName.textContent = '';
} else if (isMajor) {
_setTitle(rQual, title + ',', card);
rName.textContent = qualifier;
} else if (reversalQualifier) {
rQual.textContent = reversalQualifier;
rName.textContent = title;
_setTitle(rName, title, card);
} else {
rQual.textContent = qualifier;
rName.textContent = title;
_setTitle(rName, title, card);
}
}
}

View File

View File

@@ -0,0 +1,24 @@
"""Template filters for tarot card rendering."""
from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter(name='italicize')
def italicize(text, word):
"""Wrap every occurrence of `word` in `text` with <em>...</em>.
Both `text` and `word` are escape()d before splicing the <em> in, so the
output is safe to mark `mark_safe` regardless of input.
"""
if not text or not word:
return text
text = str(text)
word = str(word)
if word not in text:
return text
safe_text = escape(text)
safe_word = escape(word)
return mark_safe(safe_text.replace(safe_word, '<em>{}</em>'.format(safe_word)))

View File

@@ -1146,6 +1146,13 @@ def sea_deck(request, room_id):
'levity_qualifier': c.levity_qualifier,
'gravity_qualifier': c.gravity_qualifier,
'reversal_qualifier': c.reversal_qualifier,
# Polarity-split full-title overrides (cards 48-49 + trumps 19-21)
'levity_emanation': c.levity_emanation,
'gravity_emanation': c.gravity_emanation,
'levity_reversal': c.levity_reversal,
'gravity_reversal': c.gravity_reversal,
# Word inside any title slot to wrap in <em> at render time
'italic_word': c.italic_word,
'keywords_upright': c.keywords_upright,
'keywords_reversed': c.keywords_reversed,
'energies': c.energies,

View File

@@ -424,15 +424,17 @@
line-height: 1;
background: none;
border: none;
color: rgba(var(--secUser), 0.6);
text-shadow: 0 0 1px rgba(0, 0, 0, 1);
color: rgba(var(--terUser), 0.6);
cursor: pointer;
padding: 1rem;
transition: color 0.15s;
pointer-events: auto;
outline: none;
box-shadow: none;
&:hover { color: rgba(var(--secUser), 1); }
&:hover { color: rgba(var(--ninUser), 1); }
// Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav
&:focus:not(:focus-visible) { outline: none; box-shadow: none; }
&--prev { left: 1rem; }
&--next { right: 1rem; }
}
@@ -1392,6 +1394,12 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
}
.sea-stage-content {
// Card width drives the stage-card AND the stat block (which reuses
// --sig-card-w via the shared stat-block-shared mixin's calc rules).
// Override per breakpoint below to keep the stage from blowing up on small
// screens.
--sig-card-w: 180px;
position: relative;
z-index: 1;
display: flex;
@@ -1401,6 +1409,15 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
padding: 1.5rem;
}
// Sea-stage mobile breakpoints — mirror the fan modal's portrait/landscape
// trigger thresholds so behavior across staging surfaces stays consistent.
@media (orientation: portrait) and (max-width: 480px) {
.sea-stage-content { --sig-card-w: 130px; }
}
@media (orientation: landscape) and (max-height: 500px) {
.sea-stage-content { --sig-card-w: 130px; }
}
// Stage card — size matches sig-select stage (--sig-card-w driven by inline style)
.sea-stage-card {
flex-shrink: 0;

View File

@@ -134,7 +134,7 @@
{# ── Sea stage — big card viewer ─────────────────────────────────────────── #}
<div class="sea-stage" id="id_sea_stage" style="display:none">
<div class="sea-stage-backdrop"></div>
<div class="sea-stage-content" style="--sig-card-w:180px">
<div class="sea-stage-content">
<div class="sig-stage-card sea-stage-card">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>

View File

@@ -81,7 +81,12 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
data-operations="{{ card.operations_json }}"
data-levity-qualifier="{{ card.levity_qualifier }}"
data-gravity-qualifier="{{ card.gravity_qualifier }}"
data-reversal-qualifier="{{ card.reversal_qualifier }}">
data-reversal-qualifier="{{ card.reversal_qualifier }}"
data-levity-emanation="{{ card.levity_emanation }}"
data-gravity-emanation="{{ card.gravity_emanation }}"
data-levity-reversal="{{ card.levity_reversal }}"
data-gravity-reversal="{{ card.gravity_reversal }}"
data-italic-word="{{ card.italic_word }}">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}

View File

@@ -1,3 +1,4 @@
{% load tarot_filters %}
{% for card in cards %}
<div class="fan-card"
data-index="{{ forloop.counter0 }}"
@@ -13,29 +14,47 @@
data-operations="{{ card.operations_json }}"
data-levity-qualifier="{{ card.levity_qualifier }}"
data-gravity-qualifier="{{ card.gravity_qualifier }}"
data-reversal-qualifier="{{ card.reversal_qualifier }}">
data-reversal-qualifier="{{ card.reversal_qualifier }}"
data-levity-emanation="{{ card.levity_emanation }}"
data-gravity-emanation="{{ card.gravity_emanation }}"
data-levity-reversal="{{ card.levity_reversal }}"
data-gravity-reversal="{{ card.gravity_reversal }}"
data-italic-word="{{ card.italic_word }}">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
</div>
<div class="fan-card-face">
<div class="fan-card-face-upright">
{% if card.levity_emanation %}
{# Polarity-split title (cards 48-49 + trumps 19-21); no qualifier slots — qualifier is baked into the title between "The" and the proper noun #}
<h3 class="fan-card-name">{{ card.levity_emanation|italicize:card.italic_word }}</h3>
{% else %}
{% if card.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
{% if card.arcana != "MAJOR" and card.levity_qualifier %}
<p class="sig-qualifier-above">{{ card.levity_qualifier }}</p>
{% endif %}
<h3 class="fan-card-name">{{ card.name_title }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}</h3>
<h3 class="fan-card-name">{{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}</h3>
{% if card.arcana == "MAJOR" and card.levity_qualifier %}
<p class="sig-qualifier-below">{{ card.levity_qualifier }}</p>
{% endif %}
{% endif %}
</div>
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
<div class="fan-card-face-reversal">
{% if card.arcana == "MAJOR" %}
{% comment %}
DOM order: reversal-name first, reversal-qualifier second.
After SPIN's 180° rotation DOM-second appears visually on top.
{% endcomment %}
{% if card.levity_reversal %}
{# Polarity-split reversal title — single line, qualifier slot empty. Title goes in the qualifier slot so it visually lands on top after spin. #}
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier">{{ card.levity_reversal|italicize:card.italic_word }}</p>
{% elif card.arcana == "MAJOR" %}
<p class="fan-card-reversal-name">{{ card.levity_qualifier|default:card.gravity_qualifier }}</p>
<p class="fan-card-reversal-qualifier">{{ card.name_title }}{% if card.levity_qualifier %},{% endif %}</p>
<p class="fan-card-reversal-qualifier">{{ card.name_title|italicize:card.italic_word }}{% if card.levity_qualifier %},{% endif %}</p>
{% else %}
<p class="fan-card-reversal-name">{{ card.name_title }}</p>
<p class="fan-card-reversal-name">{{ card.name_title|italicize:card.italic_word }}</p>
<p class="fan-card-reversal-qualifier">{{ card.reversal_qualifier|default:card.gravity_qualifier }}</p>
{% endif %}
</div>