tarot card icons + ranks; sig fallback for pre-sync Characters; DECKS label sizing — TDD

- migration 0011: The Nomad (0) → fa-hat-cowboy; The Schizo (1) → fa-hat-wizard
- corner_rank: non-MAJOR pip card 1 → 'A' (Ace); court unchanged (M/J/Q/K); TDD
- 17 unit model tests for corner_rank + suit_icon
- _role_select_context: my_tray_sig falls back to seat.significator when
  confirmed_char.significator is None (Characters created before natus_save sync)
- _card-deck.scss: DECKS label bigger (1rem, 0.32em letter-spacing) to fill
  stack height; sea-stack-name: opacity 0.6, scaleY(1.5), margin-top -0.4rem
  partially under face; sea-stack-face z-index:1

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-29 00:20:55 -04:00
parent 6d75b9541f
commit 2af59b3a7f
6 changed files with 147 additions and 7 deletions

View File

@@ -0,0 +1,43 @@
"""Assign individual icons to The Nomad (0) and The Schizo (1).
All other Major Arcana already have fa-hand-dots from migration 0010.
"""
from django.db import migrations
ICONS = {0: 'fa-hat-cowboy', 1: 'fa-hat-wizard'}
def assign_icons(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, icon in ICONS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(icon=icon)
def clear_icons(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(ICONS.keys())
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0010_major_arcana_hand_dots_icon"),
]
operations = [
migrations.RunPython(assign_icons, reverse_code=clear_icons),
]

View File

@@ -285,7 +285,9 @@ class TarotCard(models.Model):
if self.arcana == self.MAJOR: if self.arcana == self.MAJOR:
return self._to_roman(self.number) return self._to_roman(self.number)
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'} court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
return court.get(self.number, str(self.number)) if self.number in court:
return court[self.number]
return 'A' if self.number == 1 else str(self.number)
def emanation_for(self, polarity): def emanation_for(self, polarity):
"""Return the upright title for a given polarity ('levity' or 'gravity'). """Return the upright title for a given polarity ('levity' or 'gravity').

View File

@@ -1855,6 +1855,16 @@ class PickSeaRenderingTest(TestCase):
self.assertIn("user_polarity", response.context) self.assertIn("user_polarity", response.context)
self.assertEqual(response.context["user_polarity"], "levity") # PC is levity self.assertEqual(response.context["user_polarity"], "levity") # PC is levity
def test_my_tray_sig_falls_back_to_seat_when_char_sig_is_none(self):
"""Characters created before the sig-sync fix have significator=None; fall back to seat."""
Character.objects.create(
seat=self.pc_seat,
significator=None,
confirmed_at=timezone.now(),
)
response = self.client.get(self.url)
self.assertEqual(response.context["my_tray_sig"], self.sig_card)
def test_my_tray_sig_comes_from_character_significator_when_confirmed(self): def test_my_tray_sig_comes_from_character_significator_when_confirmed(self):
"""When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat).""" """When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat)."""
char = Character.objects.create( char = Character.objects.create(

View File

@@ -0,0 +1,72 @@
from django.test import SimpleTestCase
from apps.epic.models import TarotCard
def _card(arcana, number, suit='', icon=''):
c = TarotCard()
c.arcana = arcana
c.number = number
c.suit = suit
c.icon = icon
return c
class TarotCardCornerRankTest(SimpleTestCase):
"""TarotCard.corner_rank — alphanumeric display labels."""
def test_major_arcana_0_gives_0(self):
self.assertEqual(_card('MAJOR', 0).corner_rank, '0')
def test_major_arcana_1_gives_roman_I(self):
self.assertEqual(_card('MAJOR', 1).corner_rank, 'I')
def test_major_arcana_2_gives_roman_II(self):
self.assertEqual(_card('MAJOR', 2).corner_rank, 'II')
def test_non_major_pip_1_gives_A(self):
"""Ace — pip card number 1 should show 'A', not '1'."""
self.assertEqual(_card('MIDDLE', 1, 'BRANDS').corner_rank, 'A')
def test_non_major_pip_2_gives_2(self):
self.assertEqual(_card('MIDDLE', 2, 'BRANDS').corner_rank, '2')
def test_non_major_pip_10_gives_10(self):
self.assertEqual(_card('MIDDLE', 10, 'CROWNS').corner_rank, '10')
def test_court_maid_gives_M(self):
self.assertEqual(_card('MIDDLE', 11, 'GRAILS').corner_rank, 'M')
def test_court_jack_gives_J(self):
self.assertEqual(_card('MIDDLE', 12, 'BLADES').corner_rank, 'J')
def test_court_queen_gives_Q(self):
self.assertEqual(_card('MIDDLE', 13, 'BRANDS').corner_rank, 'Q')
def test_court_king_gives_K(self):
self.assertEqual(_card('MIDDLE', 14, 'CROWNS').corner_rank, 'K')
class TarotCardSuitIconTest(SimpleTestCase):
"""TarotCard.suit_icon — icon class resolution."""
def test_major_with_icon_returns_icon(self):
self.assertEqual(_card('MAJOR', 0, icon='fa-hat-cowboy').suit_icon, 'fa-hat-cowboy')
def test_major_without_icon_returns_empty(self):
self.assertEqual(_card('MAJOR', 5).suit_icon, '')
def test_brands_returns_wand_sparkles(self):
self.assertEqual(_card('MIDDLE', 11, 'BRANDS').suit_icon, 'fa-wand-sparkles')
def test_grails_returns_trophy(self):
self.assertEqual(_card('MIDDLE', 11, 'GRAILS').suit_icon, 'fa-trophy')
def test_blades_returns_gun(self):
self.assertEqual(_card('MIDDLE', 11, 'BLADES').suit_icon, 'fa-gun')
def test_crowns_returns_crown(self):
self.assertEqual(_card('MIDDLE', 11, 'CROWNS').suit_icon, 'fa-crown')
def test_icon_override_takes_priority_over_suit(self):
self.assertEqual(_card('MIDDLE', 11, 'CROWNS', icon='fa-star').suit_icon, 'fa-star')

View File

@@ -371,7 +371,8 @@ def _role_select_context(room, user):
ctx["sky_confirmed"] = sky_confirmed ctx["sky_confirmed"] = sky_confirmed
ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else '' ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
if sky_confirmed: if sky_confirmed:
ctx["my_tray_sig"] = confirmed_char.significator # Fall back to seat.significator for Characters created before the sync was added
ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator
return ctx return ctx

View File

@@ -940,11 +940,14 @@ $sea-card-h: 6.5rem;
writing-mode: vertical-rl; writing-mode: vertical-rl;
transform: rotate(180deg); transform: rotate(180deg);
text-transform: uppercase; text-transform: uppercase;
font-size: 0.6rem; // Fill the full card height ($sea-card-h: 6.5rem) with 5 letters
letter-spacing: 0.15em; font-size: 1rem;
opacity: 0.45; letter-spacing: 0.32em;
font-weight: 700;
opacity: 0.5;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
align-self: center;
} }
.sea-deck-stack { .sea-deck-stack {
@@ -966,6 +969,7 @@ $sea-card-h: 6.5rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: box-shadow 0.15s; transition: box-shadow 0.15s;
z-index: 1; // sits above the name label
} }
.sea-stack-ok { .sea-stack-ok {
@@ -976,11 +980,19 @@ $sea-card-h: 6.5rem;
z-index: 5; z-index: 5;
} }
.sea-deck-stack { gap: 0; } // remove gap so name slides under the face
.sea-stack-name { .sea-stack-name {
font-size: 0.6rem; font-size: 0.65rem;
letter-spacing: 0.06em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
font-weight: 600; font-weight: 600;
opacity: 0.6;
// Pull top of label partially under the stack face
// margin-top: -0.1rem;
transform: scaleY(1.2);
transform-origin: top center;
z-index: 0;
} }
.sea-deck-stack--gravity .sea-stack-name { color: rgba(var(--quaUser), 1); } .sea-deck-stack--gravity .sea-stack-name { color: rgba(var(--quaUser), 1); }
.sea-deck-stack--levity .sea-stack-name { color: rgba(var(--terUser), 1); } .sea-deck-stack--levity .sea-stack-name { color: rgba(var(--terUser), 1); }