diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 510c2e8..1259e5f 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -511,6 +511,19 @@ class TarotCard(models.Model): return "" return self.deck_variant.suit_display(self.suit) + @property + def image_url(self): + """Full static-asset URL for the card image, or empty string if the + deck has no images (legacy text-only mode). Constructed via Django's + `static` helper so STATIC_URL prefix + manifest-versioning (when + WhiteNoise compressed manifest is active) flow through.""" + if not self.deck_variant.has_card_images: + return "" + from django.templatetags.static import static + return static( + f"apps/epic/images/cards-faces/{self.deck_variant.slug}/{self.image_filename}" + ) + @property def cautions_json(self): import json diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js index c530299..e0052f2 100644 --- a/src/apps/epic/static/apps/epic/stage-card.js +++ b/src/apps/epic/static/apps/epic/stage-card.js @@ -47,6 +47,14 @@ var StageCard = (function () { // Word(s) inside any title slot to wrap in at render time // (e.g. "Stalking" for trumps 19-21). Blank for most cards. italic_word: el.dataset.italicWord || '', + // Sprint A.3 — image-rendering mode. When `image_url` is non-empty, + // the stage card renders an instead of the text fan-card + // scaffold (transparent-bg PNG over arcana-colored border per + // [[project-image-based-deck-face-rendering]]). `arcana_key` is the + // canonical model code (MAJOR/MINOR/MIDDLE) used to pick the + // border-color CSS var (--terUser for major, --quiUser for the rest). + image_url: el.dataset.imageUrl || '', + arcana_key: el.dataset.arcanaKey || '', }; } @@ -99,11 +107,42 @@ var StageCard = (function () { return ''; } + // Toggle image-mode on the stage card. When `card.image_url` is non-empty, + // show the child + add .sig-stage-card--image + // marker class (CSS hides the text fan-card-* children + applies the + // arcana-colored border). When image_url is empty (legacy text-only + // decks: Earthman, RWS pre-images), strip the marker + hide the + // so the text scaffold takes over. Sprint A.3 of + // [[project-image-based-deck-face-rendering]]. + function _setImageMode(stageCard, card) { + if (!stageCard) return; + var img = stageCard.querySelector('.sig-stage-card-img'); + if (card.image_url) { + stageCard.classList.add('sig-stage-card--image'); + if (card.arcana_key) { + stageCard.setAttribute('data-arcana-key', card.arcana_key); + } + if (img) { + img.src = card.image_url; + img.alt = card.name_title || ''; + img.style.display = ''; + } + } else { + stageCard.classList.remove('sig-stage-card--image'); + stageCard.removeAttribute('data-arcana-key'); + if (img) { + img.style.display = 'none'; + img.removeAttribute('src'); + } + } + } + // Paint the stage-card's upright + reversal faces from a normalized card // object + the active polarity ('levity' | 'gravity'). Reversal-qualifier // falls back to the current polarity's qualifier when blank (6F behavior). function populateCard(stageCard, card, polarity) { if (!stageCard) return; + _setImageMode(stageCard, card); var isLevity = polarity === 'levity'; var qualifier = isLevity ? (card.levity_qualifier || '') : (card.gravity_qualifier || ''); var isMajor = _isMajor(card); diff --git a/src/functional_tests/sig_page.py b/src/functional_tests/sig_page.py index 2277056..61dd177 100644 --- a/src/functional_tests/sig_page.py +++ b/src/functional_tests/sig_page.py @@ -53,6 +53,37 @@ def _seed_earthman_sig_pile(): return earthman +def _seed_minchiate_image_fixtures(): + """Re-seed the minimal Minchiate Fiorentine 1860-1890 deck rows that the + image-rendering FTs need (Sprint A.3+ of [[project-image-based-deck-face-rendering]]): + DeckVariant + Il Matto (MAJOR rank 0, unnumbered Fool) + Papa Uno (MAJOR rank 1). + Idempotent — `get_or_create` on deck slug + each card slug. The full 97-card + seed lives in migration 0013; this helper restores enough for image-render + tests after TransactionTestCase's flush wipes migration data.""" + deck, _ = DeckVariant.objects.get_or_create( + slug="minchiate-fiorentine-1860-1890", + defaults={ + "name": "Minchiate Fiorentine (1860–1890)", + "card_count": 97, + "is_default": False, + "family": "italian", + "has_card_images": True, + "is_polarized": False, + }, + ) + for number, name, slug, corr in [ + (0, "Il Matto", "il-matto", "The Fool"), + (1, "Papa Uno", "papa-uno", ""), + ]: + TarotCard.objects.get_or_create( + deck_variant=deck, + slug=slug, + defaults={"arcana": "MAJOR", "suit": None, "number": number, + "name": name, "correspondence": corr}, + ) + return deck + + def _assign_sig(user, card=None, reversed_flag=False): """Assign `user.significator` (and optionally `significator_reversed`) directly, bypassing the picker UI. Returns the assigned card. diff --git a/src/functional_tests/test_bill_my_sign.py b/src/functional_tests/test_bill_my_sign.py index e8fb303..c4ed2df 100644 --- a/src/functional_tests/test_bill_my_sign.py +++ b/src/functional_tests/test_bill_my_sign.py @@ -10,9 +10,9 @@ is branded "Sign" / "Game Sign". from selenium.webdriver.common.by import By from .base import FunctionalTest -from .sig_page import _assign_sig, _seed_earthman_sig_pile +from .sig_page import _assign_sig, _seed_earthman_sig_pile, _seed_minchiate_image_fixtures from apps.applets.models import Applet -from apps.epic.models import personal_sig_cards +from apps.epic.models import TarotCard, personal_sig_cards from apps.lyric.models import User @@ -533,3 +533,83 @@ class MySignClearTest(FunctionalTest): self.gamer.refresh_from_db() self.assertIsNone(self.gamer.significator) self.assertFalse(self.gamer.significator_reversed) + + +class MySignImageRenderingTest(FunctionalTest): + """Sprint A.3 — when the user's equipped deck has card images (Minchiate + Fiorentine 1860-1890 today), the saved-sig stage card renders as an + pointing at the deck's image asset, not the text-only fan-card scaffold. + + First visible-surface FT in the image-rendering rollout per + [[project-image-based-deck-face-rendering]]. Other 5 surfaces (my_sea, + both billboard applets, room, game_kit) follow in A.5+. + """ + + def setUp(self): + super().setUp() + # Earthman is auto-equipped by the User post_save signal — seed its + # pile first so the signal succeeds, then override the equipped deck + # to Minchiate (the image-deck under test). + _seed_earthman_sig_pile() + self.minchiate = _seed_minchiate_image_fixtures() + Applet.objects.get_or_create( + slug="my-sign", + defaults={"name": "My Sign", "context": "billboard", + "default_visible": True, "grid_cols": 4, "grid_rows": 6}, + ) + for slug, name in [ + ("my-scrolls", "My Scrolls"), + ("my-buds", "My Buds"), + ("most-recent-scroll", "Most Recent Scroll"), + ]: + Applet.objects.get_or_create( + slug=slug, defaults={"name": name, "context": "billboard"}, + ) + self.email = "img-sig@test.io" + # Superuser so post_save grants super-nomad + super-schizo Notes → + # `_filter_major_unlocks` lets Il Matto (Major 0) through into the + # picker grid. Without the Notes, Minchiate's sig pool is empty for + # this user (no MIDDLE arcana cards + the 2 Major-0/1 cards filtered). + self.gamer = User.objects.create(email=self.email, is_superuser=True) + self.gamer.unlocked_decks.add(self.minchiate) + self.gamer.equipped_deck = self.minchiate + self.gamer.save(update_fields=["equipped_deck"]) + # Save Il Matto as the user's sig (bypass the picker UI — the FT is + # about render output, not the pick flow). + self.il_matto = TarotCard.objects.get( + deck_variant=self.minchiate, slug="il-matto", + ) + _assign_sig(self.gamer, card=self.il_matto) + + def test_saved_sig_renders_as_img_for_image_deck(self): + """Visit /billboard/my-sign/ with a Minchiate sig saved → the stage + card contains an child whose src points at the deck's image + asset under the v2 naming convention. The text fan-card scaffold is + hidden in image mode.""" + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/billboard/my-sign/") + stage_card = self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".my-sign-stage .sig-stage-card" + ) + ) + self.assertTrue( + stage_card.is_displayed(), + "Stage card should preview the saved sig on landing", + ) + # child renders w. the v2-convention filename for Il Matto. + img = self.wait_for( + lambda: stage_card.find_element(By.TAG_NAME, "img") + ) + src = img.get_attribute("src") or "" + self.assertIn( + "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png", + src, + f"Expected Minchiate Il Matto image src, got: {src}", + ) + # Image mode toggle — the stage card carries a marker class so SCSS + # can hide the fan-card text scaffold + show the . + self.assertIn( + "sig-stage-card--image", stage_card.get_attribute("class"), + "Stage card should carry .sig-stage-card--image class in image mode", + ) diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 4f2bcfc..7ccc484 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -618,6 +618,52 @@ html:has(.sig-backdrop) { .sig-qualifier-above, .sig-qualifier-below { opacity: 0.25; } } + + // Sprint A.3 — image-rendering mode for decks w. DeckVariant.has_card_images=True + // (Minchiate Fiorentine 1860-1890 today; future image-equipped decks + // flip the flag to opt in). When `.sig-stage-card--image` is set by + // stage-card.js _setImageMode, the text scaffold (fan-card-* children) + // hides and an renders inside the same shell. + // Card bg + border go away — the transparent PNG carries its own + // irregular outline; we stack four cardinal-direction drop-shadows on + // the itself to render a stroke-like outline that FOLLOWS the + // alpha contour (per user spec 2026-05-25 PM — NOT a rectangular border + // around the bounding box). Color is arcana-driven: `--quiUser` (cream) + // for minor + middle, `--terUser` (gold) for major per + // [[project-image-based-deck-face-rendering]]'s Q2 lock. + &.sig-stage-card--image { + --img-stroke-color: rgba(var(--quiUser), 1); + background: transparent; + border: 0; + padding: 0; + overflow: visible; + + &[data-arcana-key="MAJOR"] { + --img-stroke-color: rgba(var(--terUser), 1); + } + + .fan-card-corner, + .fan-card-face { + display: none; + } + + .sig-stage-card-img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + // 4 cardinal-direction drop-shadows track the PNG's alpha + // channel → contour-following stroke. 1.5px each → ~3px + // combined apparent stroke. Mobile-safe: opacity-based effects + // per [[feedback-mobile-svg-glow]] (filter on raster images + // works fine across browsers, the dead-end was SVG glow). + filter: + drop-shadow( 1.5px 0 0 var(--img-stroke-color)) + drop-shadow(-1.5px 0 0 var(--img-stroke-color)) + drop-shadow( 0 1.5px 0 var(--img-stroke-color)) + drop-shadow( 0 -1.5px 0 var(--img-stroke-color)); + } + } } // Stat block — same dimensions as the preview card (width × 5:8 aspect). diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html index 37bc528..4787ebf 100644 --- a/src/templates/apps/billboard/my_sign.html +++ b/src/templates/apps/billboard/my_sign.html @@ -30,6 +30,11 @@