From 520fdf786241cfd4b0a6204cc257c861c775fecd Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 7 Apr 2026 00:22:04 -0400 Subject: [PATCH] Sig select: caution tooltip, FLIP/FYI stat block, keyword display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TarotCard.cautions JSONField + cautions_json property; migrations 0027–0029 seed The Schizo (number=1) with 4 rival-interaction cautions (Roman-numeral card refs: I. The Pervert / II. The Occultist, etc.) - Sig-select overlay: FLIP (stat-block toggle) + FYI (caution tooltip) buttons; nav PRV/NXT portaled outside tooltip at bottom corners (z-70); caution tooltip covers stat block (inset:0, z-60, Gaussian blur); tooltip click dismisses; FLIP/FYI fully dead while btn-disabled; nav wraps circularly (4/4 → 1/4, 1/4 → 4/4) - SCSS: btn-disabled specificity fix (!important); btn-nav-left/right classes; sig-caution-* layout; stat-face keyword lists - Jasmine suite expanded: stat block + FLIP (5 specs), caution tooltip (16 specs) including wrap-around and disabled-button behaviour - IT tests: TarotCardCautionsTest (5), SigSelectRenderingTest (8) - Role-card SVG icons added to static/ Co-Authored-By: Claude Sonnet 4.6 --- ...0026_earthman_suit_renames_and_keywords.py | 154 +++++++++++ .../migrations/0027_tarotcard_cautions.py | 65 +++++ .../migrations/0028_alter_tarotcard_suit.py | 18 ++ .../migrations/0029_fix_schizo_cautions.py | 61 +++++ src/apps/epic/models.py | 31 ++- .../cards-roles/starter-role-Alchemist.svg | 105 ++++++++ .../icons/cards-roles/starter-role-Blank.svg | 62 +++++ .../cards-roles/starter-role-Builder.svg | 100 +++++++ .../cards-roles/starter-role-Economist.svg | 63 +++++ .../cards-roles/starter-role-Narrator.svg | 96 +++++++ .../icons/cards-roles/starter-role-Player.svg | 77 ++++++ .../cards-roles/starter-role-Shepherd.svg | 76 ++++++ src/apps/epic/static/apps/epic/sig-select.js | 107 +++++++- src/apps/epic/static/apps/epic/tray.js | 16 +- src/apps/epic/tests/integrated/test_models.py | 61 ++++- src/apps/epic/tests/integrated/test_views.py | 36 ++- src/apps/epic/views.py | 11 + src/static/tests/SigSelectSpec.js | 251 +++++++++++++++++- src/static_src/scss/_button-pad.scss | 146 +++++++++- src/static_src/scss/_room.scss | 131 +++++++++ src/static_src/scss/_tray.scss | 41 ++- src/static_src/tests/SigSelectSpec.js | 251 +++++++++++++++++- .../_partials/_sig_select_overlay.html | 29 +- src/templates/apps/gameboard/room.html | 2 +- 24 files changed, 1936 insertions(+), 54 deletions(-) create mode 100644 src/apps/epic/migrations/0026_earthman_suit_renames_and_keywords.py create mode 100644 src/apps/epic/migrations/0027_tarotcard_cautions.py create mode 100644 src/apps/epic/migrations/0028_alter_tarotcard_suit.py create mode 100644 src/apps/epic/migrations/0029_fix_schizo_cautions.py create mode 100644 src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg create mode 100644 src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg create mode 100644 src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg create mode 100644 src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg create mode 100644 src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg create mode 100644 src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg create mode 100644 src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg diff --git a/src/apps/epic/migrations/0026_earthman_suit_renames_and_keywords.py b/src/apps/epic/migrations/0026_earthman_suit_renames_and_keywords.py new file mode 100644 index 0000000..80f6f8e --- /dev/null +++ b/src/apps/epic/migrations/0026_earthman_suit_renames_and_keywords.py @@ -0,0 +1,154 @@ +""" +Data migration — Earthman deck: + 1. Rename three suit codes (and card names) for Earthman cards: + WANDS → BRANDS (Wands → Brands) + CUPS → GRAILS (Cups → Grails) + SWORDS → BLADES (Swords → Blades) + CROWNS stays CROWNS. + 2. Copy keywords_upright / keywords_reversed from the Fiorentine Minchiate + deck to corresponding Earthman cards: + • Major: explicit number-to-number map based on card correspondences. + • Minor/Middle: same number, suit mapped (BRANDS→WANDS, GRAILS→CUPS, + BLADES→SWORDS, CROWNS→PENTACLES). Cards with no Fiorentine counterpart + stay with empty keyword lists. +""" +from django.db import migrations + +# ── 1. Suit rename map ──────────────────────────────────────────────────────── + +SUIT_RENAMES = { + "WANDS": "BRANDS", + "CUPS": "GRAILS", + "SWORDS": "BLADES", +} + +# ── 2. Major arcana: Earthman number → Fiorentine number ───────────────────── +# Cards without a Fiorentine counterpart are omitted (keywords stay empty). + +MAJOR_KEYWORD_MAP = { + 0: 0, # The Schiz → The Fool + 1: 1, # Pope I (President) → The Magician + 2: 2, # Pope II (Tsar) → The High Priestess + 3: 3, # Pope III (Chairman) → The Empress + 4: 4, # Pope IV (Emperor) → The Emperor + 5: 5, # Pope V (Chancellor) → The Hierophant + 6: 8, # Virtue VI (Controlled Folly) → Strength + 7: 11, # Virtue VII (Not-Doing) → Justice + 8: 14, # Virtue VIII (Losing Self-Importance) → Temperance + # 9: Prudence — no Fiorentine equivalent + 10: 10, # Wheel of Fortune → Wheel of Fortune + 11: 7, # The Junkboat → The Chariot + 12: 12, # The Junkman → The Hanged Man + 13: 13, # Death → Death + 14: 15, # The Traitor → The Devil + 15: 16, # Disco Inferno → The Tower + # 16: Torre Terrestre (Purgatory) — no equivalent + # 17: Fantasia Celestia (Paradise) — no equivalent + 18: 6, # Virtue XVIII (Stalking) → The Lovers + # 19: Virtue XIX (Intent / Hope) — no equivalent + # 20: Virtue XX (Dreaming / Faith)— no equivalent + # 21–38: Classical Elements + Zodiac — no equivalents + 39: 17, # Wanderer XXXIX (Polestar) → The Star + 40: 18, # Wanderer XL (Antichthon) → The Moon + 41: 19, # Wanderer XLI (Corestar) → The Sun + # 42–49: Planets + The Binary — no equivalents + 50: 20, # The Eagle → Judgement + 51: 21, # Divine Calculus → The World +} + +# ── 3. Minor suit map: Earthman (post-rename) → Fiorentine ─────────────────── + +MINOR_SUIT_MAP = { + "BRANDS": "WANDS", + "GRAILS": "CUPS", + "BLADES": "SWORDS", + "CROWNS": "PENTACLES", +} + + +def forward(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + + try: + earthman = DeckVariant.objects.get(slug="earthman") + fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + except DeckVariant.DoesNotExist: + return # decks not seeded — nothing to do + + # ── Step 1: rename Earthman suit codes + card names ─────────────────────── + for old_suit, new_suit in SUIT_RENAMES.items(): + old_display = old_suit.capitalize() # e.g. "Wands" + new_display = new_suit.capitalize() # e.g. "Brands" + cards = TarotCard.objects.filter(deck_variant=earthman, suit=old_suit) + for card in cards: + card.name = card.name.replace(f" of {old_display}", f" of {new_display}") + card.suit = new_suit + card.save() + + # ── Step 2: copy major arcana keywords ─────────────────────────────────── + fio_major = { + card.number: card + for card in TarotCard.objects.filter(deck_variant=fiorentine, arcana="MAJOR") + } + for em_num, fio_num in MAJOR_KEYWORD_MAP.items(): + fio_card = fio_major.get(fio_num) + if not fio_card: + continue + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=em_num + ).update( + keywords_upright=fio_card.keywords_upright, + keywords_reversed=fio_card.keywords_reversed, + ) + + # ── Step 3: copy minor/middle arcana keywords ───────────────────────────── + for em_suit, fio_suit in MINOR_SUIT_MAP.items(): + fio_by_number = { + card.number: card + for card in TarotCard.objects.filter(deck_variant=fiorentine, suit=fio_suit) + } + for em_card in TarotCard.objects.filter(deck_variant=earthman, suit=em_suit): + fio_card = fio_by_number.get(em_card.number) + if fio_card: + em_card.keywords_upright = fio_card.keywords_upright + em_card.keywords_reversed = fio_card.keywords_reversed + em_card.save() + + +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 + + # Reverse suit renames + reverse_renames = {new: old for old, new in SUIT_RENAMES.items()} + for new_suit, old_suit in reverse_renames.items(): + new_display = new_suit.capitalize() + old_display = old_suit.capitalize() + cards = TarotCard.objects.filter(deck_variant=earthman, suit=new_suit) + for card in cards: + card.name = card.name.replace(f" of {new_display}", f" of {old_display}") + card.suit = old_suit + card.save() + + # Clear all Earthman keywords + TarotCard.objects.filter(deck_variant=earthman).update( + keywords_upright=[], + keywords_reversed=[], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0025_earthman_middle_arcana_and_major_icons"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/migrations/0027_tarotcard_cautions.py b/src/apps/epic/migrations/0027_tarotcard_cautions.py new file mode 100644 index 0000000..9fc9a05 --- /dev/null +++ b/src/apps/epic/migrations/0027_tarotcard_cautions.py @@ -0,0 +1,65 @@ +""" +Schema + data migration: + 1. Add `cautions` JSONField (list, default=[]) to TarotCard. + 2. Seed The Schizo (Earthman MAJOR #1) with 4 rival-interaction cautions. + All other cards default to [] — the UI shows a placeholder when empty. +""" +from django.db import migrations, models + +SCHIZO_CAUTIONS = [ + 'This card will reverse into The Pervert when it' + ' comes under dominion of The Occultist, which in turn' + ' reverses into Pestilence.', + + 'This card will reverse into The Paranoiac when it' + ' comes under dominion of The Despot, which in turn' + ' reverses into War.', + + 'This card will reverse into The Neurotic when it' + ' comes under dominion of The Capitalist, which in turn' + ' reverses into Famine.', + + 'This card will reverse into The Suicidal when it' + ' comes under dominion of The Fascist, which in turn' + ' reverses into Death.', +] + + +def seed_schizo_cautions(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=1 + ).update(cautions=SCHIZO_CAUTIONS) + + +def clear_schizo_cautions(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=1 + ).update(cautions=[]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0026_earthman_suit_renames_and_keywords"), + ] + + operations = [ + migrations.AddField( + model_name="tarotcard", + name="cautions", + field=models.JSONField(default=list), + ), + migrations.RunPython(seed_schizo_cautions, reverse_code=clear_schizo_cautions), + ] diff --git a/src/apps/epic/migrations/0028_alter_tarotcard_suit.py b/src/apps/epic/migrations/0028_alter_tarotcard_suit.py new file mode 100644 index 0000000..84832ff --- /dev/null +++ b/src/apps/epic/migrations/0028_alter_tarotcard_suit.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-04-07 03:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0027_tarotcard_cautions'), + ] + + operations = [ + migrations.AlterField( + model_name='tarotcard', + name='suit', + field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns'), ('BRANDS', 'Brands'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True), + ), + ] diff --git a/src/apps/epic/migrations/0029_fix_schizo_cautions.py b/src/apps/epic/migrations/0029_fix_schizo_cautions.py new file mode 100644 index 0000000..48f0dea --- /dev/null +++ b/src/apps/epic/migrations/0029_fix_schizo_cautions.py @@ -0,0 +1,61 @@ +""" +Data fix: clear Schizo cautions from The Nomad (number=0) if present, +and ensure they land on The Schizo (number=1). +""" +from django.db import migrations + +SCHIZO_CAUTIONS = [ + 'This card will reverse into I. The Pervert when it' + ' comes under dominion of II. The Occultist, which in turn' + ' reverses into II. Pestilence.', + + 'This card will reverse into I. The Paranoiac when it' + ' comes under dominion of III. The Despot, which in turn' + ' reverses into III. War.', + + 'This card will reverse into I. The Neurotic when it' + ' comes under dominion of IV. The Capitalist, which in turn' + ' reverses into IV. Famine.', + + 'This card will reverse into I. The Suicidal when it' + ' comes under dominion of V. The Fascist, which in turn' + ' reverses into V. Death.', +] + + +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=0 + ).update(cautions=[]) + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=1 + ).update(cautions=SCHIZO_CAUTIONS) + + +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=1 + ).update(cautions=[]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0028_alter_tarotcard_suit"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 743f551..41c0f34 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -216,13 +216,19 @@ class TarotCard(models.Model): CUPS = "CUPS" SWORDS = "SWORDS" PENTACLES = "PENTACLES" # Fiorentine 4th suit - CROWNS = "CROWNS" # Earthman 4th suit (renamed from Pentacles) + CROWNS = "CROWNS" # Earthman 4th suit + BRANDS = "BRANDS" # Earthman Wands + GRAILS = "GRAILS" # Earthman Cups + BLADES = "BLADES" # Earthman Swords SUIT_CHOICES = [ (WANDS, "Wands"), (CUPS, "Cups"), (SWORDS, "Swords"), (PENTACLES, "Pentacles"), (CROWNS, "Crowns"), + (BRANDS, "Brands"), + (GRAILS, "Grails"), + (BLADES, "Blades"), ] deck_variant = models.ForeignKey( @@ -239,6 +245,7 @@ class TarotCard(models.Model): group = models.CharField(max_length=100, blank=True) # Earthman major grouping keywords_upright = models.JSONField(default=list) keywords_reversed = models.JSONField(default=list) + cautions = models.JSONField(default=list) class Meta: ordering = ["deck_variant", "arcana", "suit", "number"] @@ -290,8 +297,16 @@ class TarotCard(models.Model): self.SWORDS: 'fa-gun', self.PENTACLES: 'fa-star', self.CROWNS: 'fa-crown', + self.BRANDS: 'fa-wand-sparkles', + self.GRAILS: 'fa-trophy', + self.BLADES: 'fa-gun', }.get(self.suit, '') + @property + def cautions_json(self): + import json + return json.dumps(self.cautions) + def __str__(self): return self.name @@ -369,9 +384,9 @@ class SigReservation(models.Model): def sig_deck_cards(room): """Return 36 TarotCard objects forming the Significator deck (18 unique × 2). - PC/BC pair → WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique - SC/AC pair → SWORDS + CUPS Middle Arcana court cards (11–14): 8 unique - NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique + PC/BC pair → BRANDS/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique + SC/AC pair → BLADES/SWORDS + GRAILS/CUPS Middle Arcana court cards (11–14): 8 unique + NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique Total: 18 unique × 2 (levity + gravity piles) = 36 cards. """ deck_variant = room.owner.equipped_deck @@ -380,13 +395,13 @@ def sig_deck_cards(room): wands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.WANDS, TarotCard.CROWNS], + suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) swords_cups = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.SWORDS, TarotCard.CUPS], + suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS], number__in=[11, 12, 13, 14], )) major = list(TarotCard.objects.filter( @@ -406,13 +421,13 @@ def _sig_unique_cards(room): wands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.WANDS, TarotCard.CROWNS], + suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) swords_cups = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.SWORDS, TarotCard.CUPS], + suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS], number__in=[11, 12, 13, 14], )) major = list(TarotCard.objects.filter( diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg new file mode 100644 index 0000000..c970f51 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg new file mode 100644 index 0000000..563e5a0 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg new file mode 100644 index 0000000..67c4564 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg new file mode 100644 index 0000000..514d7bd --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg new file mode 100644 index 0000000..6bb50a0 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg new file mode 100644 index 0000000..cc52014 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg new file mode 100644 index 0000000..98c360c --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index 320c76d..b743a95 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -5,9 +5,14 @@ var SigSelect = (function () { gravity: ['BC', 'EC', 'AC'], }; - var overlay, deckGrid, stage, stageCard; + var overlay, deckGrid, stage, stageCard, statBlock; + var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl; + var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel; var reserveUrl, userRole, userPolarity; + var _cautionData = []; + var _cautionIdx = 0; + var _focusedCardEl = null; // card currently shown in stage var _reservedCardId = null; // card with active reservation var _stageFrozen = false; // true after OK — stage locks on reserved card @@ -20,8 +25,60 @@ 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(''); + } + + // ── Caution tooltip ─────────────────────────────────────────────────── + + function _renderCaution() { + if (_cautionData.length === 0) { + cautionEffect.innerHTML = 'Rival interactions pending.'; + cautionPrev.disabled = true; + cautionNext.disabled = true; + cautionIndexEl.textContent = ''; + return; + } + cautionEffect.innerHTML = _cautionData[_cautionIdx]; + cautionPrev.disabled = (_cautionData.length <= 1); + cautionNext.disabled = (_cautionData.length <= 1); + cautionIndexEl.textContent = _cautionData.length > 1 + ? (_cautionIdx + 1) + ' / ' + _cautionData.length + : ''; + } + + function _openCaution() { + if (!_focusedCardEl) return; + try { + _cautionData = JSON.parse(_focusedCardEl.dataset.cautions || '[]'); + } catch (e) { + _cautionData = []; + } + _cautionIdx = 0; + _renderCaution(); + _flipBtn.classList.add('btn-disabled'); + _cautionBtn.classList.add('btn-disabled'); + _flipBtn.textContent = '\u00D7'; + _cautionBtn.textContent = '\u00D7'; + stage.classList.add('sig-caution-open'); + } + + function _closeCaution() { + stage.classList.remove('sig-caution-open'); + if (_flipBtn) { + _flipBtn.classList.remove('btn-disabled'); + _cautionBtn.classList.remove('btn-disabled'); + _flipBtn.textContent = _flipOrigLabel; + _cautionBtn.textContent = _cautionOrigLabel; + } + } + function updateStage(cardEl) { if (_stageFrozen) return; + _closeCaution(); if (!cardEl) { stageCard.style.display = 'none'; stage.classList.remove('sig-stage--active'); @@ -51,6 +108,17 @@ var SigSelect = (function () { stageCard.querySelector('.fan-card-arcana').textContent = arcana; stageCard.querySelector('.fan-card-correspondence').textContent = corr; + // Populate stat block keyword faces and reset to upright + statBlock.classList.remove('is-reversed'); + _populateKeywordList( + statBlock.querySelector('#id_stat_keywords_upright'), + cardEl.dataset.keywordsUpright + ); + _populateKeywordList( + statBlock.querySelector('#id_stat_keywords_reversed'), + cardEl.dataset.keywordsReversed + ); + stageCard.style.display = ''; stage.classList.add('sig-stage--active'); } @@ -188,6 +256,41 @@ var SigSelect = (function () { deckGrid = overlay.querySelector('.sig-deck-grid'); stage = overlay.querySelector('.sig-stage'); stageCard = stage.querySelector('.sig-stage-card'); + statBlock = stage.querySelector('.sig-stat-block'); + + _flipBtn = statBlock.querySelector('.sig-flip-btn'); + _cautionBtn = statBlock.querySelector('.sig-caution-btn'); + _flipOrigLabel = _flipBtn.textContent; + _cautionOrigLabel = _cautionBtn.textContent; + + _flipBtn.addEventListener('click', function () { + if (_flipBtn.classList.contains('btn-disabled')) return; + statBlock.classList.toggle('is-reversed'); + }); + + cautionEl = stage.querySelector('.sig-caution-tooltip'); + cautionEffect = cautionEl.querySelector('.sig-caution-effect'); + cautionPrev = statBlock.querySelector('.sig-caution-prev'); + cautionNext = statBlock.querySelector('.sig-caution-next'); + cautionIndexEl = cautionEl.querySelector('.sig-caution-index'); + + // Clicking the tooltip (not nav buttons) dismisses it + cautionEl.addEventListener('click', function () { + _closeCaution(); + }); + + _cautionBtn.addEventListener('click', function () { + if (_cautionBtn.classList.contains('btn-disabled')) return; + stage.classList.contains('sig-caution-open') ? _closeCaution() : _openCaution(); + }); + cautionPrev.addEventListener('click', function () { + _cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length; + _renderCaution(); + }); + cautionNext.addEventListener('click', function () { + _cautionIdx = (_cautionIdx + 1) % _cautionData.length; + _renderCaution(); + }); reserveUrl = overlay.dataset.reserveUrl; userRole = overlay.dataset.userRole; @@ -264,6 +367,8 @@ var SigSelect = (function () { _reservedCardId = null; _stageFrozen = false; _requestInFlight = false; + _cautionData = []; + _cautionIdx = 0; init(); }, _setFrozen: function (v) { _stageFrozen = v; }, diff --git a/src/apps/epic/static/apps/epic/tray.js b/src/apps/epic/static/apps/epic/tray.js index 3aa9439..3cce5c0 100644 --- a/src/apps/epic/static/apps/epic/tray.js +++ b/src/apps/epic/static/apps/epic/tray.js @@ -14,6 +14,13 @@ var Tray = (function () { var _tray = null; var _grid = null; + // Role code → scrawl SVG name mapping for tray card display. + var _ROLE_SCRAWL = { + PC: 'Player', NC: 'Narrator', EC: 'Economist', + SC: 'Shepherd', AC: 'Alchemist', BC: 'Builder' + }; + var _roleIconsUrl = null; + // Portrait bounds (X axis) var _minLeft = 0; var _maxLeft = 0; @@ -246,7 +253,13 @@ var Tray = (function () { firstCell.classList.add('tray-role-card'); firstCell.dataset.role = roleCode; - firstCell.textContent = roleCode; + firstCell.textContent = ''; + if (_roleIconsUrl) { + var img = document.createElement('img'); + img.src = _roleIconsUrl + 'starter-role-' + (_ROLE_SCRAWL[roleCode] || 'Blank') + '.svg'; + img.alt = roleCode; + firstCell.appendChild(img); + } open(); _arcIn(firstCell, function () { @@ -326,6 +339,7 @@ var Tray = (function () { _btn = document.getElementById('id_tray_btn'); _tray = document.getElementById('id_tray'); _grid = document.getElementById('id_tray_grid'); + _roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null; if (!_btn) return; if (_isLandscape()) { diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index b3bf67a..39584b7 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -269,16 +269,16 @@ class SigDeckCompositionTest(TestCase): cards = sig_deck_cards(self.room) self.assertEqual(len(cards), 36) - def test_sc_ac_contribute_court_cards_of_swords_and_cups(self): + def test_sc_ac_contribute_court_cards_of_blades_and_grails(self): cards = sig_deck_cards(self.room) - sc_ac = [c for c in cards if c.suit in ("SWORDS", "CUPS")] + sc_ac = [c for c in cards if c.suit in ("BLADES", "GRAILS")] # M/J/Q/K × 2 suits × 2 roles = 16 self.assertEqual(len(sc_ac), 16) self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac)) - def test_pc_bc_contribute_court_cards_of_wands_and_crowns(self): + def test_pc_bc_contribute_court_cards_of_brands_and_crowns(self): cards = sig_deck_cards(self.room) - pc_bc = [c for c in cards if c.suit in ("WANDS", "CROWNS")] + pc_bc = [c for c in cards if c.suit in ("BRANDS", "CROWNS")] self.assertEqual(len(pc_bc), 16) self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc)) @@ -342,7 +342,7 @@ class SigCardFieldTest(TestCase): defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) self.card = TarotCard.objects.get( - deck_variant=earthman, arcana="MIDDLE", suit="WANDS", number=11, + deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11, ) owner = User.objects.create(email="owner@test.io") room = Room.objects.create(name="Field Test", owner=owner) @@ -479,3 +479,54 @@ class SigCardHelperTest(TestCase): self.owner.save() self.assertEqual(levity_sig_cards(self.room), []) self.assertEqual(gravity_sig_cards(self.room), []) + + +class TarotCardCautionsTest(TestCase): + """TarotCard.cautions JSONField — field existence and Schizo seed data.""" + + def setUp(self): + self.earthman = DeckVariant.objects.get(slug="earthman") + + def test_cautions_field_saves_and_retrieves_list(self): + card = TarotCard.objects.create( + deck_variant=self.earthman, + arcana="MINOR", + suit="CROWNS", + number=99, + name="Test Card", + slug="test-card-cautions", + cautions=["First caution.", "Second caution."], + ) + card.refresh_from_db() + self.assertEqual(card.cautions, ["First caution.", "Second caution."]) + + def test_cautions_defaults_to_empty_list(self): + card = TarotCard.objects.create( + deck_variant=self.earthman, + arcana="MINOR", + suit="CROWNS", + number=98, + name="Default Cautions Card", + slug="default-cautions-card", + ) + self.assertEqual(card.cautions, []) + + def test_schizo_has_4_cautions(self): + schizo = TarotCard.objects.get( + deck_variant=self.earthman, arcana="MAJOR", number=1 + ) + self.assertEqual(len(schizo.cautions), 4) + + def test_schizo_caution_references_the_pervert(self): + schizo = TarotCard.objects.get( + deck_variant=self.earthman, arcana="MAJOR", number=1 + ) + self.assertIn("The Pervert", schizo.cautions[0]) + + def test_schizo_cautions_use_reverse_language(self): + schizo = TarotCard.objects.get( + deck_variant=self.earthman, arcana="MAJOR", number=1 + ) + for caution in schizo.cautions: + self.assertIn("reverse", caution) + self.assertNotIn("transform", caution) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 28caa71..16798f2 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -926,7 +926,7 @@ def _full_sig_setUp(test_case, role_order=None): room.table_status = Room.SIG_SELECT room.save() card_in_deck = TarotCard.objects.get( - deck_variant=earthman, arcana="MIDDLE", suit="WANDS", number=11 + deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11 ) test_case.client.force_login(founder) return room, gamers, earthman, card_in_deck @@ -963,6 +963,32 @@ class SigSelectRenderingTest(TestCase): response = self.client.get(self.url) self.assertNotContains(response, "id_sig_deck") + def test_sig_cards_render_keyword_data_attributes(self): + response = self.client.get(self.url) + content = response.content.decode() + self.assertIn("data-keywords-upright=", content) + self.assertIn("data-keywords-reversed=", content) + + def test_sig_stat_block_structure_rendered(self): + response = self.client.get(self.url) + self.assertContains(response, "sig-stat-block") + self.assertContains(response, "sig-flip-btn") + self.assertContains(response, "stat-face--upright") + self.assertContains(response, "stat-face--reversed") + + def test_sig_cards_render_cautions_data_attribute(self): + response = self.client.get(self.url) + self.assertContains(response, "data-cautions=") + + def test_sig_caution_tooltip_structure_rendered(self): + response = self.client.get(self.url) + self.assertContains(response, "sig-caution-tooltip") + self.assertContains(response, "sig-caution-btn") + self.assertContains(response, "sig-caution-effect") + self.assertContains(response, "sig-caution-index") + self.assertContains(response, "sig-caution-prev") + self.assertContains(response, "sig-caution-next") + class SelectSigCardViewTest(TestCase): """select_sig view — records choice, enforces turn order, rejects bad input.""" @@ -1000,8 +1026,8 @@ class SelectSigCardViewTest(TestCase): def test_select_sig_card_not_in_deck_returns_400(self): # Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1) other = TarotCard.objects.create( - deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=5, - name="Five of Wands Test", slug="five-of-wands-test", + deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5, + name="Five of Brands Test", slug="five-of-brands-test", keywords_upright=[], keywords_reversed=[], ) response = self._post(card_id=other.id) @@ -1188,7 +1214,7 @@ class SigReserveViewTest(TestCase): def test_reserve_different_card_while_holding_returns_409(self): """Cannot OK a different card while holding one — must NVM first.""" card_b = TarotCard.objects.filter( - deck_variant=self.earthman, arcana="MIDDLE", suit="WANDS", number=12 + deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12 ).first() self._reserve() # PC grabs card A → 200 response = self._reserve(card_id=card_b.id) # tries card B → 409 @@ -1210,7 +1236,7 @@ class SigReserveViewTest(TestCase): def test_reserve_blocked_then_unblocked_after_release(self): """After NVM, a new card can be OK'd.""" card_b = TarotCard.objects.filter( - deck_variant=self.earthman, arcana="MIDDLE", suit="WANDS", number=12 + deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12 ).first() self._reserve() # hold card A self._reserve(action="release") # NVM diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 1f618d3..f9f1bc5 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -92,6 +92,11 @@ def _notify_sig_reserved(room_id, card_id, role, reserved): SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} +_ROLE_SCRAWL_NAMES = { + "PC": "Player", "NC": "Narrator", "EC": "Economist", + "SC": "Shepherd", "AC": "Alchemist", "BC": "Builder", +} + def _gate_positions(room): """Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html.""" @@ -216,10 +221,16 @@ def _role_select_context(room, user): if user.is_authenticated else [] ) active_slot = active_seat.slot_number if active_seat else None + _my_role = assigned_seats[0].role if assigned_seats else None ctx = { "card_stack_state": card_stack_state, "starter_roles": starter_roles, "assigned_seats": assigned_seats, + "my_tray_role": _my_role, + "my_tray_scrawl_static_path": ( + f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg" + if _my_role else None + ), "user_seat": user_seat, "user_slots": list( room.table_seats.filter(gamer=user, role__isnull=True) diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index 1b149f0..3acb254 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -1,7 +1,7 @@ describe("SigSelect", () => { - let testDiv, stageCard, card; + let testDiv, stageCard, card, statBlock; - function makeFixture({ reservations = '{}' } = {}) { + function makeFixture({ reservations = '{}', cardCautions = '[]' } = {}) { testDiv = document.createElement("div"); testDiv.innerHTML = `
    {

    +
    + + +
    +

    Upright

    +
      +
      +
      +

      Reversed

      +
        +
        + + +
        +
        +

        Caution!

        + Rival Interaction +
        +

        [Shoptalk forthcoming]

        +

        + +
        +
        { data-name-group="Pentacles" data-name-title="King of Pentacles" data-arcana="Minor Arcana" - data-correspondence=""> + data-correspondence="" + data-keywords-upright="action,impulsiveness,ambition" + data-keywords-reversed="no direction,disregard for consequences" + data-cautions="${cardCautions.replace(/"/g, '"')}">
        K
        @@ -48,6 +74,7 @@ describe("SigSelect", () => { `; document.body.appendChild(testDiv); stageCard = testDiv.querySelector(".sig-stage-card"); + statBlock = testDiv.querySelector(".sig-stat-block"); card = testDiv.querySelector(".sig-card"); window.fetch = jasmine.createSpy("fetch").and.returnValue( Promise.resolve({ ok: true }) @@ -252,4 +279,222 @@ describe("SigSelect", () => { expect(card.classList.contains("sig-focused")).toBe(true); }); }); + + // ── Caution tooltip (!!) ──────────────────────────────────────────── // + + describe("caution tooltip", () => { + var cautionTooltip, cautionEffect, cautionPrev, cautionNext, cautionBtn; + + beforeEach(() => { + makeFixture(); + cautionTooltip = testDiv.querySelector(".sig-caution-tooltip"); + cautionEffect = testDiv.querySelector(".sig-caution-effect"); + cautionPrev = testDiv.querySelector(".sig-caution-prev"); + cautionNext = testDiv.querySelector(".sig-caution-next"); + cautionBtn = testDiv.querySelector(".sig-caution-btn"); + }); + + function hover() { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + } + + function openCaution() { + hover(); + cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + + it("!! click adds .sig-caution-open to the stage", () => { + openCaution(); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); + }); + + it("second !! click removes .sig-caution-open (toggle)", () => { + openCaution(); + cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); + }); + + it("shows placeholder text when cautions list is empty", () => { + card.dataset.cautions = "[]"; + openCaution(); + expect(cautionEffect.innerHTML).toContain("pending"); + }); + + it("renders first caution effect HTML including .card-ref spans", () => { + card.dataset.cautions = JSON.stringify(['First Card effect.']); + openCaution(); + expect(cautionEffect.querySelector(".card-ref")).not.toBeNull(); + expect(cautionEffect.querySelector(".card-ref").textContent).toBe("Card"); + }); + + it("with 1 caution both nav arrows are disabled", () => { + card.dataset.cautions = JSON.stringify(["Single caution."]); + openCaution(); + expect(cautionPrev.disabled).toBe(true); + expect(cautionNext.disabled).toBe(true); + }); + + it("with multiple cautions both nav arrows are always enabled", () => { + card.dataset.cautions = JSON.stringify(["C1", "C2", "C3", "C4"]); + openCaution(); + expect(cautionPrev.disabled).toBe(false); + expect(cautionNext.disabled).toBe(false); + }); + + it("next click advances to second caution", () => { + card.dataset.cautions = JSON.stringify(["First", "Second"]); + openCaution(); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cautionEffect.innerHTML).toContain("Second"); + }); + + it("next wraps from last caution back to first", () => { + card.dataset.cautions = JSON.stringify(["First", "Last"]); + openCaution(); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cautionEffect.innerHTML).toContain("First"); + }); + + it("prev click goes back to first caution", () => { + card.dataset.cautions = JSON.stringify(["First", "Second"]); + openCaution(); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cautionEffect.innerHTML).toContain("First"); + }); + + it("prev wraps from first caution to last", () => { + card.dataset.cautions = JSON.stringify(["First", "Middle", "Last"]); + openCaution(); + cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cautionEffect.innerHTML).toContain("Last"); + }); + + it("index label shows n / total when multiple cautions", () => { + card.dataset.cautions = JSON.stringify(["C1", "C2", "C3"]); + openCaution(); + expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("1 / 3"); + }); + + it("index label is empty when only 1 caution", () => { + card.dataset.cautions = JSON.stringify(["Only one."]); + openCaution(); + expect(testDiv.querySelector(".sig-caution-index").textContent).toBe(""); + }); + + it("card mouseleave closes the caution", () => { + openCaution(); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); + }); + + it("opening again resets to first caution", () => { + card.dataset.cautions = JSON.stringify(["First", "Second"]); + openCaution(); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + // Close and reopen + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + openCaution(); + expect(cautionEffect.innerHTML).toContain("First"); + }); + + it("opening caution adds .btn-disabled and swaps labels to ×", () => { + openCaution(); + var flipBtn = testDiv.querySelector(".sig-flip-btn"); + expect(flipBtn.classList.contains("btn-disabled")).toBe(true); + expect(cautionBtn.classList.contains("btn-disabled")).toBe(true); + expect(flipBtn.textContent).toBe("\u00D7"); + expect(cautionBtn.textContent).toBe("\u00D7"); + }); + + it("closing caution removes .btn-disabled and restores original labels", () => { + var flipBtn = testDiv.querySelector(".sig-flip-btn"); + var origFlip = flipBtn.textContent; + var origCaution = cautionBtn.textContent; + openCaution(); + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + expect(flipBtn.classList.contains("btn-disabled")).toBe(false); + expect(cautionBtn.classList.contains("btn-disabled")).toBe(false); + expect(flipBtn.textContent).toBe(origFlip); + expect(cautionBtn.textContent).toBe(origCaution); + }); + + it("clicking the tooltip closes caution", () => { + openCaution(); + cautionEffect.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); + }); + + it("FLIP click when caution open (btn-disabled) does nothing", () => { + openCaution(); + var flipBtn = testDiv.querySelector(".sig-flip-btn"); + flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + }); + + // ── Stat block: keyword population and FLIP toggle ────────────────── // + + describe("stat block and FLIP", () => { + beforeEach(() => makeFixture()); + + it("populates upright keywords when a card is hovered", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + var items = statBlock.querySelectorAll("#id_stat_keywords_upright li"); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe("action"); + expect(items[1].textContent).toBe("impulsiveness"); + expect(items[2].textContent).toBe("ambition"); + }); + + it("populates reversed keywords when a card is hovered", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + var items = statBlock.querySelectorAll("#id_stat_keywords_reversed li"); + expect(items.length).toBe(2); + expect(items[0].textContent).toBe("no direction"); + expect(items[1].textContent).toBe("disregard for consequences"); + }); + + it("FLIP click adds .is-reversed to the stat block", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + var flipBtn = statBlock.querySelector(".sig-flip-btn"); + flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(true); + }); + + it("second FLIP click removes .is-reversed", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + var flipBtn = statBlock.querySelector(".sig-flip-btn"); + flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + + it("hovering a new card resets .is-reversed", () => { + // Add a second card to the grid so we can hover it + var secondCard = card.cloneNode(true); + secondCard.dataset.cardId = "99"; + testDiv.querySelector(".sig-deck-grid").appendChild(secondCard); + + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + statBlock.querySelector(".sig-flip-btn").dispatchEvent( + new MouseEvent("click", { bubbles: true }) + ); + expect(statBlock.classList.contains("is-reversed")).toBe(true); + + secondCard.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + + it("card with no keywords yields empty lists", () => { + card.dataset.keywordsUpright = ""; + card.dataset.keywordsReversed = ""; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.querySelectorAll("#id_stat_keywords_upright li").length).toBe(0); + expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0); + }); + }); }); diff --git a/src/static_src/scss/_button-pad.scss b/src/static_src/scss/_button-pad.scss index 34cdb3d..5a864d7 100644 --- a/src/static_src/scss/_button-pad.scss +++ b/src/static_src/scss/_button-pad.scss @@ -290,9 +290,9 @@ cursor: default !important; font-size: 1.2rem; padding-bottom: 0.1rem; - color: rgba(var(--secUser), 0.25); - background-color: rgba(var(--priUser), 1); - border-color: rgba(var(--secUser), 0.25); + color: rgba(var(--secUser), 0.25) !important; + background-color: rgba(var(--priUser), 1) !important; + border-color: rgba(var(--secUser), 0.25) !important; box-shadow: 0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5), 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), @@ -322,4 +322,144 @@ ; } } + + &.btn-nav-left { + color: rgba(var(--priFs), 1); + border-color: rgba(var(--priFs), 1); + background-color: rgba(var(--terFs), 1); + box-shadow: + 0.1rem 0.1rem 0.12rem rgba(var(--terFs), 0.25), + 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), + 0.25rem 0.25rem 0.25rem rgba(var(--terFs), 0.12) + ; + + &:hover { + text-shadow: + 0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25), + 0 0 1rem rgba(var(--priFs), 1) + ; + box-shadow: + 0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priFs), 0.12) + ; + } + + &:active { + border: 0.18rem solid rgba(var(--priFs), 1); + text-shadow: + -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25), + 0 0 0.12rem rgba(var(--priFs), 1) + ; + box-shadow: + -0.1rem -0.1rem 0.12rem rgba(var(--terFs), 0.25), + -0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priFs), 0.12) + ; + } + } + + &.btn-nav-right { + color: rgba(var(--priLm), 1); + border-color: rgba(var(--priLm), 1); + background-color: rgba(var(--terLm), 1); + box-shadow: + 0.1rem 0.1rem 0.12rem rgba(var(--terLm), 0.25), + 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), + 0.25rem 0.25rem 0.25rem rgba(var(--terLm), 0.12) + ; + + &:hover { + text-shadow: + 0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25), + 0 0 1rem rgba(var(--priLm), 1) + ; + box-shadow: + 0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priLm), 0.12) + ; + } + + &:active { + border: 0.18rem solid rgba(var(--priLm), 1); + text-shadow: + -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25), + 0 0 0.12rem rgba(var(--priLm), 1) + ; + box-shadow: + -0.1rem -0.1rem 0.12rem rgba(var(--terLm), 0.25), + -0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priLm), 0.12) + ; + } + } + + &.btn-reverse { + color: rgba(var(--priCy), 1); + border-color: rgba(var(--priCy), 1); + background-color: rgba(var(--terCy), 1); + box-shadow: + 0.1rem 0.1rem 0.12rem rgba(var(--terCy), 0.25), + 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), + 0.25rem 0.25rem 0.25rem rgba(var(--terCy), 0.12) + ; + + &:hover { + text-shadow: + 0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25), + 0 0 1rem rgba(var(--priCy), 1) + ; + box-shadow: + 0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priCy), 0.12) + ; + } + + &:active { + border: 0.18rem solid rgba(var(--priCy), 1); + text-shadow: + -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25), + 0 0 0.12rem rgba(var(--priCy), 1) + ; + box-shadow: + -0.1rem -0.1rem 0.12rem rgba(var(--terCy), 0.25), + -0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priCy), 0.12) + ; + } + } + + &.btn-tip { + color: rgba(var(--priLm), 1); + border-color: rgba(var(--priLm), 1); + background-color: rgba(var(--terLm), 1); + box-shadow: + 0.1rem 0.1rem 0.12rem rgba(var(--terLm), 0.25), + 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), + 0.25rem 0.25rem 0.25rem rgba(var(--terLm), 0.12) + ; + + &:hover { + text-shadow: + 0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25), + 0 0 1rem rgba(var(--priLm), 1) + ; + box-shadow: + 0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priLm), 0.12) + ; + } + + &:active { + border: 0.18rem solid rgba(var(--priLm), 1); + text-shadow: + -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25), + 0 0 0.12rem rgba(var(--priLm), 1) + ; + box-shadow: + -0.1rem -0.1rem 0.12rem rgba(var(--terLm), 0.25), + -0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25), + 0 0 0.5rem rgba(var(--priLm), 0.12) + ; + } + } } diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 837b2bc..075361e 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -880,6 +880,7 @@ html:has(.sig-backdrop) { .sig-stage { flex: 1; min-height: 0; + position: relative; display: flex; flex-direction: row; align-items: flex-end; @@ -955,9 +956,139 @@ html:has(.sig-backdrop) { border-radius: 0.4rem; border: 0.1rem solid rgba(var(--terUser), 0.15); display: none; + position: relative; + + + .sig-flip-btn { + position: absolute; + top: -1rem; + right: -1rem; + margin: 0; + z-index: 50; + } + + .sig-caution-btn { + position: absolute; + top: 1.25rem; + right: -1rem; + margin: 0; + z-index: 50; + } + + // Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons. + .sig-caution-tooltip { + display: none; + position: absolute; + inset: 0; + z-index: 60; + background-color: rgba(var(--tooltip-bg), 0.6); + backdrop-filter: blur(6px); + border-radius: 0.4rem; + border: 0.1rem solid rgba(var(--priYl), 0.35); + padding: 0.75rem; + flex-direction: column; + gap: 0.4rem; + overflow-y: auto; + } + + .sig-caution-header { + display: flex; + flex-direction: column; + gap: 0.1rem; + } + + .sig-caution-title { + font-size: calc(var(--sig-card-w, 120px) * 0.093); + font-weight: 700; + margin: 0; + color: rgba(var(--priYl), 1); + } + + .sig-caution-type { + font-size: calc(var(--sig-card-w, 120px) * 0.058); + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; + } + + .sig-caution-shoptalk { + font-size: calc(var(--sig-card-w, 120px) * 0.063); + opacity: 0.55; + margin: 0; + font-style: italic; + } + + .sig-caution-effect { + flex: 1; + font-size: calc(var(--sig-card-w, 120px) * 0.075); + margin: 0; + line-height: 1.55; + + .card-ref { + color: rgba(var(--terUser), 1); + font-weight: 600; + } + } + + .sig-caution-index { + font-size: calc(var(--sig-card-w, 120px) * 0.063); + opacity: 0.55; + } + + // Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70) + .sig-caution-prev, + .sig-caution-next { + display: none; + position: absolute; + bottom: -1rem; + margin: 0; + z-index: 70; + } + .sig-caution-prev { left: -1rem; } + .sig-caution-next { right: -1rem; } + + .stat-face { + display: none; + padding: calc(var(--sig-card-w, 120px) * 0.37) calc(var(--sig-card-w, 120px) * 0.1) calc(var(--sig-card-w, 120px) * 0.08); + + &--upright { display: block; } + } + + &.is-reversed { + .stat-face--upright { display: none; } + .stat-face--reversed { display: block; } + } + + .stat-face-label { + font-size: calc(var(--sig-card-w, 120px) * 0.063); + text-transform: uppercase; + letter-spacing: 0.09em; + opacity: 0.4; + margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); + } + + .stat-keywords { + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: calc(var(--sig-card-w, 120px) * 0.083); + padding: calc(var(--sig-card-w, 120px) * 0.042) 0; + opacity: 0.85; + border-bottom: 0.05rem solid rgba(var(--terUser), 0.12); + + &:last-child { border-bottom: none; } + } + } } &.sig-stage--frozen .sig-stat-block { display: block; } + &.sig-caution-open .sig-stat-block { + .sig-caution-tooltip { display: flex; } + .sig-caution-prev, .sig-caution-next { display: inline-flex; } + } } // ─── Mini card grid ─────────────────────────────────────────────────────────── diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss index c75c8f8..a1a14c7 100644 --- a/src/static_src/scss/_tray.scss +++ b/src/static_src/scss/_tray.scss @@ -121,24 +121,29 @@ $handle-r: 1rem; &::before { border-color: rgba(var(--quaUser), 1); } } -// ─── Role card: arc-in animation (portrait) ───────────────────────────────── +// ─── Role card: scrawl fade-in ─────────────────────────────────────────────── @keyframes tray-role-arc-in { - from { opacity: 0; transform: scale(0.3) translate(-40%, -40%); } - to { opacity: 1; transform: scale(1) translate(0, 0); } + from { opacity: 0; } + to { opacity: 1; } } .tray-role-card { - background: rgba(var(--quaUser), 0.25); - display: flex; - align-items: flex-start; - justify-content: flex-start; - padding: 0.2em; - font-size: 0.65rem; - color: rgba(var(--quaUser), 1); - font-weight: 600; + padding: 0; + overflow: hidden; + background: transparent; - &.arc-in { - animation: tray-role-arc-in 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards; + img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + transform: scale(1.4); // crop SVG's internal margins + } + + // Cell stays static; only the scrawl image fades in. + &.arc-in img { + animation: tray-role-arc-in 1s ease forwards; } } @@ -301,15 +306,7 @@ $handle-r: 1rem; border-bottom: none; } - // Role card arc-in for landscape - @keyframes tray-role-arc-in-landscape { - from { opacity: 0; transform: scale(0.3) translate(-40%, 40%); } - to { opacity: 1; transform: scale(1) translate(0, 0); } - } - - .tray-role-card.arc-in { - animation: tray-role-arc-in-landscape 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards; - } + // Role card: same fade-in in landscape — no override needed. @keyframes tray-wobble-landscape { 0%, 100% { transform: translateY(0); } diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index 1b149f0..3acb254 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -1,7 +1,7 @@ describe("SigSelect", () => { - let testDiv, stageCard, card; + let testDiv, stageCard, card, statBlock; - function makeFixture({ reservations = '{}' } = {}) { + function makeFixture({ reservations = '{}', cardCautions = '[]' } = {}) { testDiv = document.createElement("div"); testDiv.innerHTML = `
        {

        +
        + + +
        +

        Upright

        +
          +
          +
          +

          Reversed

          +
            +
            + + +
            +
            +

            Caution!

            + Rival Interaction +
            +

            [Shoptalk forthcoming]

            +

            + +
            +
            { data-name-group="Pentacles" data-name-title="King of Pentacles" data-arcana="Minor Arcana" - data-correspondence=""> + data-correspondence="" + data-keywords-upright="action,impulsiveness,ambition" + data-keywords-reversed="no direction,disregard for consequences" + data-cautions="${cardCautions.replace(/"/g, '"')}">
            K
            @@ -48,6 +74,7 @@ describe("SigSelect", () => { `; document.body.appendChild(testDiv); stageCard = testDiv.querySelector(".sig-stage-card"); + statBlock = testDiv.querySelector(".sig-stat-block"); card = testDiv.querySelector(".sig-card"); window.fetch = jasmine.createSpy("fetch").and.returnValue( Promise.resolve({ ok: true }) @@ -252,4 +279,222 @@ describe("SigSelect", () => { expect(card.classList.contains("sig-focused")).toBe(true); }); }); + + // ── Caution tooltip (!!) ──────────────────────────────────────────── // + + describe("caution tooltip", () => { + var cautionTooltip, cautionEffect, cautionPrev, cautionNext, cautionBtn; + + beforeEach(() => { + makeFixture(); + cautionTooltip = testDiv.querySelector(".sig-caution-tooltip"); + cautionEffect = testDiv.querySelector(".sig-caution-effect"); + cautionPrev = testDiv.querySelector(".sig-caution-prev"); + cautionNext = testDiv.querySelector(".sig-caution-next"); + cautionBtn = testDiv.querySelector(".sig-caution-btn"); + }); + + function hover() { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + } + + function openCaution() { + hover(); + cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + + it("!! click adds .sig-caution-open to the stage", () => { + openCaution(); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); + }); + + it("second !! click removes .sig-caution-open (toggle)", () => { + openCaution(); + cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); + }); + + it("shows placeholder text when cautions list is empty", () => { + card.dataset.cautions = "[]"; + openCaution(); + expect(cautionEffect.innerHTML).toContain("pending"); + }); + + it("renders first caution effect HTML including .card-ref spans", () => { + card.dataset.cautions = JSON.stringify(['First Card effect.']); + openCaution(); + expect(cautionEffect.querySelector(".card-ref")).not.toBeNull(); + expect(cautionEffect.querySelector(".card-ref").textContent).toBe("Card"); + }); + + it("with 1 caution both nav arrows are disabled", () => { + card.dataset.cautions = JSON.stringify(["Single caution."]); + openCaution(); + expect(cautionPrev.disabled).toBe(true); + expect(cautionNext.disabled).toBe(true); + }); + + it("with multiple cautions both nav arrows are always enabled", () => { + card.dataset.cautions = JSON.stringify(["C1", "C2", "C3", "C4"]); + openCaution(); + expect(cautionPrev.disabled).toBe(false); + expect(cautionNext.disabled).toBe(false); + }); + + it("next click advances to second caution", () => { + card.dataset.cautions = JSON.stringify(["First", "Second"]); + openCaution(); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cautionEffect.innerHTML).toContain("Second"); + }); + + it("next wraps from last caution back to first", () => { + card.dataset.cautions = JSON.stringify(["First", "Last"]); + openCaution(); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cautionEffect.innerHTML).toContain("First"); + }); + + it("prev click goes back to first caution", () => { + card.dataset.cautions = JSON.stringify(["First", "Second"]); + openCaution(); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cautionEffect.innerHTML).toContain("First"); + }); + + it("prev wraps from first caution to last", () => { + card.dataset.cautions = JSON.stringify(["First", "Middle", "Last"]); + openCaution(); + cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(cautionEffect.innerHTML).toContain("Last"); + }); + + it("index label shows n / total when multiple cautions", () => { + card.dataset.cautions = JSON.stringify(["C1", "C2", "C3"]); + openCaution(); + expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("1 / 3"); + }); + + it("index label is empty when only 1 caution", () => { + card.dataset.cautions = JSON.stringify(["Only one."]); + openCaution(); + expect(testDiv.querySelector(".sig-caution-index").textContent).toBe(""); + }); + + it("card mouseleave closes the caution", () => { + openCaution(); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); + }); + + it("opening again resets to first caution", () => { + card.dataset.cautions = JSON.stringify(["First", "Second"]); + openCaution(); + cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true })); + // Close and reopen + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + openCaution(); + expect(cautionEffect.innerHTML).toContain("First"); + }); + + it("opening caution adds .btn-disabled and swaps labels to ×", () => { + openCaution(); + var flipBtn = testDiv.querySelector(".sig-flip-btn"); + expect(flipBtn.classList.contains("btn-disabled")).toBe(true); + expect(cautionBtn.classList.contains("btn-disabled")).toBe(true); + expect(flipBtn.textContent).toBe("\u00D7"); + expect(cautionBtn.textContent).toBe("\u00D7"); + }); + + it("closing caution removes .btn-disabled and restores original labels", () => { + var flipBtn = testDiv.querySelector(".sig-flip-btn"); + var origFlip = flipBtn.textContent; + var origCaution = cautionBtn.textContent; + openCaution(); + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + expect(flipBtn.classList.contains("btn-disabled")).toBe(false); + expect(cautionBtn.classList.contains("btn-disabled")).toBe(false); + expect(flipBtn.textContent).toBe(origFlip); + expect(cautionBtn.textContent).toBe(origCaution); + }); + + it("clicking the tooltip closes caution", () => { + openCaution(); + cautionEffect.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false); + }); + + it("FLIP click when caution open (btn-disabled) does nothing", () => { + openCaution(); + var flipBtn = testDiv.querySelector(".sig-flip-btn"); + flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + }); + + // ── Stat block: keyword population and FLIP toggle ────────────────── // + + describe("stat block and FLIP", () => { + beforeEach(() => makeFixture()); + + it("populates upright keywords when a card is hovered", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + var items = statBlock.querySelectorAll("#id_stat_keywords_upright li"); + expect(items.length).toBe(3); + expect(items[0].textContent).toBe("action"); + expect(items[1].textContent).toBe("impulsiveness"); + expect(items[2].textContent).toBe("ambition"); + }); + + it("populates reversed keywords when a card is hovered", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + var items = statBlock.querySelectorAll("#id_stat_keywords_reversed li"); + expect(items.length).toBe(2); + expect(items[0].textContent).toBe("no direction"); + expect(items[1].textContent).toBe("disregard for consequences"); + }); + + it("FLIP click adds .is-reversed to the stat block", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + var flipBtn = statBlock.querySelector(".sig-flip-btn"); + flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(true); + }); + + it("second FLIP click removes .is-reversed", () => { + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + var flipBtn = statBlock.querySelector(".sig-flip-btn"); + flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + + it("hovering a new card resets .is-reversed", () => { + // Add a second card to the grid so we can hover it + var secondCard = card.cloneNode(true); + secondCard.dataset.cardId = "99"; + testDiv.querySelector(".sig-deck-grid").appendChild(secondCard); + + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + statBlock.querySelector(".sig-flip-btn").dispatchEvent( + new MouseEvent("click", { bubbles: true }) + ); + expect(statBlock.classList.contains("is-reversed")).toBe(true); + + secondCard.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.classList.contains("is-reversed")).toBe(false); + }); + + it("card with no keywords yields empty lists", () => { + card.dataset.keywordsUpright = ""; + card.dataset.keywordsReversed = ""; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.querySelectorAll("#id_stat_keywords_upright li").length).toBe(0); + expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0); + }); + }); }); diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html index 84a273e..d76613c 100644 --- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html @@ -30,7 +30,29 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
            -
            +
            + + +
            +

            Upright

            +
              +
              +
              +

              Reversed

              +
                +
                +
                +
                +

                Caution!

                +

                Rival Interaction

                +
                +

                [Shoptalk forthcoming]

                +

                + +
                + + +
                @@ -42,7 +64,10 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_ data-name-group="{{ card.name_group }}" data-name-title="{{ card.name_title }}" data-arcana="{{ card.get_arcana_display }}" - data-correspondence="{{ card.correspondence|default:'' }}"> + data-correspondence="{{ card.correspondence|default:'' }}" + data-keywords-upright="{{ card.keywords_upright|join:',' }}" + data-keywords-reversed="{{ card.keywords_reversed|join:',' }}" + data-cautions="{{ card.cautions_json }}">
                {{ card.corner_rank }} {% if card.suit_icon %}{% endif %} diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 16a5205..3555046 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -73,7 +73,7 @@
                - +
                {% endif %} {% include "apps/gameboard/_partials/_room_gear.html" %}