Compare commits

...

3 Commits

13 changed files with 519 additions and 21 deletions

View File

@@ -9,6 +9,15 @@ const initialize = (inputSelector) => {
};
};
const bindPaletteWheel = () => {
document.querySelectorAll('.palette-scroll').forEach(el => {
el.addEventListener('wheel', (e) => {
e.preventDefault();
el.scrollLeft += e.deltaY;
}, { passive: false });
});
};
const bindPaletteForms = () => {
document.querySelectorAll('form[action*="set_palette"]').forEach(form => {
form.addEventListener("submit", async (e) => {

View File

@@ -0,0 +1,82 @@
"""
Data migration: rename Earthman court cards at positions 11 and 12.
Old naming (from 0010): Jack (11) / Cavalier (12)
New naming: Maid (11) / Jack (12)
Must rename 11 → Maid first so the "jack-of-*-em" slugs are free
before the 12s claim them.
"""
from django.db import migrations
SUITS = ["Wands", "Cups", "Swords", "Coins"]
def rename_court_cards(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Step 1: Jack (11) → Maid — frees up jack-of-*-em slugs
for suit in SUITS:
suit_slug = suit.lower()
TarotCard.objects.filter(
deck_variant=earthman, number=11, slug=f"jack-of-{suit_slug}-em"
).update(
name=f"Maid of {suit}",
slug=f"maid-of-{suit_slug}-em",
)
# Step 2: Cavalier (12) → Jack — takes the now-free jack-of-*-em slugs
for suit in SUITS:
suit_slug = suit.lower()
TarotCard.objects.filter(
deck_variant=earthman, number=12, slug=f"cavalier-of-{suit_slug}-em"
).update(
name=f"Jack of {suit}",
slug=f"jack-of-{suit_slug}-em",
)
def reverse_court_cards(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Step 1: Jack (12) → Cavalier — frees up jack-of-*-em slugs
for suit in SUITS:
suit_slug = suit.lower()
TarotCard.objects.filter(
deck_variant=earthman, number=12, slug=f"jack-of-{suit_slug}-em"
).update(
name=f"Cavalier of {suit}",
slug=f"cavalier-of-{suit_slug}-em",
)
# Step 2: Maid (11) → Jack
for suit in SUITS:
suit_slug = suit.lower()
TarotCard.objects.filter(
deck_variant=earthman, number=11, slug=f"maid-of-{suit_slug}-em"
).update(
name=f"Jack of {suit}",
slug=f"jack-of-{suit_slug}-em",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0010_seed_deck_variants_and_earthman"),
]
operations = [
migrations.RunPython(rename_court_cards, reverse_code=reverse_court_cards),
]

View File

@@ -0,0 +1,162 @@
"""
Data migration:
1. Rename grouped Earthman major arcana to use group-relative ordinals
(e.g. "Virtue VI: Controlled Folly""Implicit Virtue 1: Controlled Folly").
2. Spell out Earthman minor arcana pip names 210
(e.g. "2 of Wands""Two of Wands").
Corner ranks (Roman numerals of absolute card number) are a property on the model
and are unchanged — this only affects the stored name / slug fields.
"""
from django.db import migrations
# ── Major arcana: (new_name, new_slug) keyed by card number ─────────────────
MAJOR_RENAMES = {
# Implicit Virtues (cards 69)
6: ("Implicit Virtue 1: Controlled Folly", "implicit-virtue-1-controlled-folly"),
7: ("Implicit Virtue 2: Not-Doing", "implicit-virtue-2-not-doing"),
8: ("Implicit Virtue 3: Losing Self-Importance", "implicit-virtue-3-losing-self-importance"),
9: ("Implicit Virtue 4: Erasing Personal History", "implicit-virtue-4-erasing-personal-history"),
# Explicit Virtues (cards 1820)
18: ("Explicit Virtue 1: Stalking", "explicit-virtue-1-stalking"),
19: ("Explicit Virtue 2: Intent", "explicit-virtue-2-intent"),
20: ("Explicit Virtue 3: Dreaming", "explicit-virtue-3-dreaming"),
# Classical Elements (cards 2124)
21: ("Classical Element 1: Fire", "classical-element-1-fire"),
22: ("Classical Element 2: Earth", "classical-element-2-earth"),
23: ("Classical Element 3: Air", "classical-element-3-air"),
24: ("Classical Element 4: Water", "classical-element-4-water"),
# Zodiac (cards 2536)
25: ("Zodiac 1: Aries", "zodiac-1-aries"),
26: ("Zodiac 2: Taurus", "zodiac-2-taurus"),
27: ("Zodiac 3: Gemini", "zodiac-3-gemini"),
28: ("Zodiac 4: Cancer", "zodiac-4-cancer"),
29: ("Zodiac 5: Leo", "zodiac-5-leo"),
30: ("Zodiac 6: Virgo", "zodiac-6-virgo"),
31: ("Zodiac 7: Libra", "zodiac-7-libra"),
32: ("Zodiac 8: Scorpio", "zodiac-8-scorpio"),
33: ("Zodiac 9: Sagittarius", "zodiac-9-sagittarius"),
34: ("Zodiac 10: Capricorn", "zodiac-10-capricorn"),
35: ("Zodiac 11: Aquarius", "zodiac-11-aquarius"),
36: ("Zodiac 12: Pisces", "zodiac-12-pisces"),
# Absolute Elements (cards 3738)
37: ("Absolute Element 1: Time", "absolute-element-1-time"),
38: ("Absolute Element 2: Space", "absolute-element-2-space"),
# Wanderers (cards 3949)
39: ("Wanderer 1: The Polestar", "wanderer-1-polestar"),
40: ("Wanderer 2: The Antichthon", "wanderer-2-antichthon"),
41: ("Wanderer 3: The Corestar", "wanderer-3-corestar"),
42: ("Wanderer 4: Mercury", "wanderer-4-mercury"),
43: ("Wanderer 5: Venus", "wanderer-5-venus"),
44: ("Wanderer 6: Mars", "wanderer-6-mars"),
45: ("Wanderer 7: Jupiter", "wanderer-7-jupiter"),
46: ("Wanderer 8: Saturn", "wanderer-8-saturn"),
47: ("Wanderer 9: Uranus", "wanderer-9-uranus"),
48: ("Wanderer 10: Neptune", "wanderer-10-neptune"),
49: ("Wanderer 11: The King & Queen of Hades", "wanderer-11-king-queen-hades"),
}
# Original (name, slug) pairs for reversal
MAJOR_ORIGINALS = {
6: ("Virtue VI: Controlled Folly", "virtue-vi-controlled-folly"),
7: ("Virtue VII: Not-Doing", "virtue-vii-not-doing"),
8: ("Virtue VIII: Losing Self-Importance", "virtue-viii-losing-self-importance"),
9: ("Virtue IX: Erasing Personal History", "virtue-ix-erasing-personal-history"),
18: ("Virtue XVIII: Stalking", "virtue-xviii-stalking"),
19: ("Virtue XIX: Intent", "virtue-xix-intent"),
20: ("Virtue XX: Dreaming", "virtue-xx-dreaming"),
21: ("Element XXI: Fire", "element-xxi-fire"),
22: ("Element XXII: Earth", "element-xxii-earth"),
23: ("Element XXIII: Air", "element-xxiii-air"),
24: ("Element XXIV: Water", "element-xxiv-water"),
25: ("Zodiac XXV: Aries", "zodiac-xxv-aries"),
26: ("Zodiac XXVI: Taurus", "zodiac-xxvi-taurus"),
27: ("Zodiac XXVII: Gemini", "zodiac-xxvii-gemini"),
28: ("Zodiac XXVIII: Cancer", "zodiac-xxviii-cancer"),
29: ("Zodiac XXIX: Leo", "zodiac-xxix-leo"),
30: ("Zodiac XXX: Virgo", "zodiac-xxx-virgo"),
31: ("Zodiac XXXI: Libra", "zodiac-xxxi-libra"),
32: ("Zodiac XXXII: Scorpio", "zodiac-xxxii-scorpio"),
33: ("Zodiac XXXIII: Sagittarius", "zodiac-xxxiii-sagittarius"),
34: ("Zodiac XXXIV: Capricorn", "zodiac-xxxiv-capricorn"),
35: ("Zodiac XXXV: Aquarius", "zodiac-xxxv-aquarius"),
36: ("Zodiac XXXVI: Pisces", "zodiac-xxxvi-pisces"),
37: ("Element XXXVII: Time", "element-xxxvii-time"),
38: ("Element XXXVIII: Space", "element-xxxviii-space"),
39: ("Wanderer XXXIX: The Polestar", "wanderer-xxxix-polestar"),
40: ("Wanderer XL: The Antichthon", "wanderer-xl-antichthon"),
41: ("Wanderer XLI: The Corestar", "wanderer-xli-corestar"),
42: ("Wanderer XLII: Mercury", "wanderer-xlii-mercury"),
43: ("Wanderer XLIII: Venus", "wanderer-xliii-venus"),
44: ("Wanderer XLIV: Mars", "wanderer-xliv-mars"),
45: ("Wanderer XLV: Jupiter", "wanderer-xlv-jupiter"),
46: ("Wanderer XLVI: Saturn", "wanderer-xlvi-saturn"),
47: ("Wanderer XLVII: Uranus", "wanderer-xlvii-uranus"),
48: ("Wanderer XLVIII: Neptune", "wanderer-xlviii-neptune"),
49: ("Wanderer XLIX: The King & Queen of Hades", "wanderer-xlix-king-queen-hades"),
}
# Pip number → spelled-out word (slugs already use the word form, only name changes)
PIP_SPELLINGS = {
2: "Two", 3: "Three", 4: "Four", 5: "Five",
6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten",
}
SUITS = ["WANDS", "CUPS", "SWORDS", "COINS"]
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# 1. Rename grouped major arcana to group-relative ordinals
for number, (new_name, new_slug) in MAJOR_RENAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
# 2. Spell out pip names 210
for number, word in PIP_SPELLINGS.items():
for suit in SUITS:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", suit=suit, number=number
).update(name=f"{word} of {suit.capitalize()}")
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# 1. Restore original major arcana names
for number, (old_name, old_slug) in MAJOR_ORIGINALS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
# 2. Restore numeric pip names (slugs unchanged)
for number, _word in PIP_SPELLINGS.items():
for suit in SUITS:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", suit=suit, number=number
).update(name=f"{number} of {suit.capitalize()}")
class Migration(migrations.Migration):
dependencies = [
("epic", "0011_rename_earthman_court_cards"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,55 @@
"""
Data migration: rename Earthman 4th-suit cards from COINS → PENTACLES.
Updates:
- suit field: "COINS""PENTACLES"
- name: "X of Coins""X of Pentacles"
- slug: "x-of-coins-em""x-of-pentacles-em"
"""
from django.db import migrations
def coins_to_pentacles(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
cards = TarotCard.objects.filter(deck_variant=earthman, suit="COINS")
for card in cards:
card.suit = "PENTACLES"
card.name = card.name.replace(" of Coins", " of Pentacles")
card.slug = card.slug.replace("-of-coins-em", "-of-pentacles-em")
card.save(update_fields=["suit", "name", "slug"])
def pentacles_to_coins(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Only reverse cards that came from Earthman (identified by -em slug suffix)
cards = TarotCard.objects.filter(
deck_variant=earthman, suit="PENTACLES", slug__endswith="-em"
)
for card in cards:
card.suit = "COINS"
card.name = card.name.replace(" of Pentacles", " of Coins")
card.slug = card.slug.replace("-of-pentacles-em", "-of-coins-em")
card.save(update_fields=["suit", "name", "slug"])
class Migration(migrations.Migration):
dependencies = [
("epic", "0012_rename_earthman_major_groups_and_pip_spellings"),
]
operations = [
migrations.RunPython(coins_to_pentacles, reverse_code=pentacles_to_coins),
]

View File

@@ -0,0 +1,65 @@
"""
Data migration: rename the five Pope cards to use Arabic group-relative ordinals,
matching the convention set for other grouped major arcana.
"Pope I: President""Pope 1: President"
"Pope II: Tsar""Pope 2: Tsar"
etc.
"""
from django.db import migrations
POPE_RENAMES = {
1: ("Pope 1: President", "pope-1-president"),
2: ("Pope 2: Tsar", "pope-2-tsar"),
3: ("Pope 3: Chairman", "pope-3-chairman"),
4: ("Pope 4: Emperor", "pope-4-emperor"),
5: ("Pope 5: Chancellor", "pope-5-chancellor"),
}
POPE_ORIGINALS = {
1: ("Pope I: President", "pope-i-president"),
2: ("Pope II: Tsar", "pope-ii-tsar"),
3: ("Pope III: Chairman", "pope-iii-chairman"),
4: ("Pope IV: Emperor", "pope-iv-emperor"),
5: ("Pope V: Chancellor", "pope-v-chancellor"),
}
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (new_name, new_slug) in POPE_RENAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (old_name, old_slug) in POPE_ORIGINALS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
class Migration(migrations.Migration):
dependencies = [
("epic", "0013_earthman_coins_to_pentacles"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,42 @@
"""
Data migration: rename Earthman card 22 from "Classical Element 2: Earth"
to "Classical Element 2: Stone" (Stone = Ossum, the Earthman name for Earth).
"""
from django.db import migrations
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=22
).update(name="Classical Element 2: Stone", slug="classical-element-2-stone")
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=22
).update(name="Classical Element 2: Earth", slug="classical-element-2-earth")
class Migration(migrations.Migration):
dependencies = [
("epic", "0014_rename_earthman_popes_arabic_ordinals"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -233,6 +233,52 @@ class TarotCard(models.Model):
ordering = ["deck_variant", "arcana", "suit", "number"]
unique_together = [("deck_variant", "slug")]
@staticmethod
def _to_roman(n):
if n == 0:
return '0'
val = [50, 40, 10, 9, 5, 4, 1]
syms = ['L','XL','X','IX','V','IV','I']
result = ''
for v, s in zip(val, syms):
while n >= v:
result += s
n -= v
return result
@property
def corner_rank(self):
if self.arcana == self.MAJOR:
return self._to_roman(self.number)
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
return court.get(self.number, str(self.number))
@property
def name_group(self):
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""
if ': ' in self.name:
return self.name.split(': ', 1)[0] + ':'
return ''
@property
def name_title(self):
"""Returns the title after 'Group N: ', or the full name if no colon."""
if ': ' in self.name:
return self.name.split(': ', 1)[1]
return self.name
@property
def suit_icon(self):
if self.arcana == self.MAJOR:
return ''
return {
self.WANDS: 'fa-wand-sparkles',
self.CUPS: 'fa-trophy',
self.SWORDS: 'fa-gun',
self.COINS: 'fa-star',
self.PENTACLES: 'fa-star',
}.get(self.suit, '')
def __str__(self):
return self.name

View File

@@ -84,9 +84,10 @@ function initGameKitPage() {
updateFan();
}
// Click on the dialog background (outside .tarot-fan-wrap) closes the modal
// Click on the dark backdrop (the dialog or fan-wrap itself, not on any card child) closes
var fanWrap = dialog.querySelector('.tarot-fan-wrap');
dialog.addEventListener('click', function(e) {
if (!e.target.closest('.tarot-fan-wrap')) closeFan();
if (e.target === dialog || e.target === fanWrap) closeFan();
});
// Arrow key navigation
@@ -95,6 +96,16 @@ function initGameKitPage() {
if (e.key === 'ArrowLeft') navigate(-1);
});
// Mousewheel navigation — throttled so each detent advances one card
var lastWheel = 0;
dialog.addEventListener('wheel', function(e) {
e.preventDefault();
var now = Date.now();
if (now - lastWheel < 150) return;
lastWheel = now;
navigate(e.deltaY > 0 ? 1 : -1);
}, { passive: false });
prevBtn.addEventListener('click', function() { navigate(-1); });
nextBtn.addEventListener('click', function() { navigate(1); });

View File

@@ -128,7 +128,11 @@ def tarot_fan(request, deck_id):
deck = get_object_or_404(DeckVariant, pk=deck_id)
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
return HttpResponse(status=403)
cards = list(TarotCard.objects.filter(deck_variant=deck).order_by("arcana", "number"))
_suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "COINS": 4}
cards = sorted(
TarotCard.objects.filter(deck_variant=deck),
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
)
return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
"deck": deck,
"cards": cards,

View File

@@ -453,18 +453,15 @@ class GameKitPageTest(FunctionalTest):
# Test 12 — clicking outside the modal closes it #
# ------------------------------------------------------------------ #
def test_clicking_outside_fan_closes_modal(self):
def test_pressing_escape_closes_fan_modal(self):
from selenium.webdriver.common.keys import Keys
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
).click()
dialog = self.browser.find_element(By.ID, "id_tarot_fan_dialog")
self.wait_for(lambda: self.assertTrue(dialog.is_displayed()))
# Dispatch a click directly on the dialog element (simulates clicking the dark backdrop)
self.browser.execute_script(
"document.getElementById('id_tarot_fan_dialog')"
".dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))"
)
dialog.send_keys(Keys.ESCAPE)
self.wait_for(lambda: self.assertFalse(dialog.is_displayed()))
# ------------------------------------------------------------------ #
@@ -484,11 +481,9 @@ class GameKitPageTest(FunctionalTest):
saved_index = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index")
)
# Close
self.browser.execute_script(
"document.getElementById('id_tarot_fan_dialog')"
".dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}))"
)
# Close via ESC
from selenium.webdriver.common.keys import Keys
self.browser.find_element(By.ID, "id_tarot_fan_dialog").send_keys(Keys.ESCAPE)
self.wait_for(
lambda: self.assertFalse(
self.browser.find_element(By.ID, "id_tarot_fan_dialog").is_displayed()

View File

@@ -254,6 +254,26 @@
}
}
.fan-card-corner {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
line-height: 1;
color: rgba(var(--secUser), 0.75);
&--tl { top: 0.4rem; left: 0.4rem; }
&--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); }
.fan-corner-rank {
font-size: 1.5rem;
font-weight: bold;
padding: 0.18rem 0;
}
i { font-size: 1.5rem; }
}
.fan-card-face {
padding: 1.25rem;
text-align: center;
@@ -261,8 +281,9 @@
flex-direction: column;
gap: 0.5rem;
.fan-card-number { font-size: 0.65rem; opacity: 0.5; }
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; }
.fan-card-number { font-size: 0.65rem; opacity: 0.5; }
.fan-card-name-group { font-size: 0.65rem; opacity: 0.5; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; }
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; }
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.6; }
.fan-card-correspondence { font-size: 0.6rem; opacity: 0.45; font-style: italic; }
}

View File

@@ -4,5 +4,6 @@
window.onload = () => {
initialize("#id_text");
bindPaletteForms();
bindPaletteWheel();
};
</script>

View File

@@ -1,15 +1,20 @@
{% for card in cards %}
<div class="fan-card" data-index="{{ forloop.counter0 }}">
<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">
<p class="fan-card-number">{{ card.number }}</p>
<h3 class="fan-card-name">{{ card.name }}</h3>
{% if card.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
<h3 class="fan-card-name">{{ card.name_title }}</h3>
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
{% if card.correspondence %}
<p class="fan-card-correspondence">{{ card.correspondence }}</p>
{% endif %}
{% if card.suit %}
<p class="fan-card-suit">{{ card.suit }}</p>
{% endif %}
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
</div>
</div>
{% endfor %}