A.3 my_sign.html image-rendering — first visible surface — TDD. Sprint A.3 of [[project-image-based-deck-face-rendering]]. When the user's equipped deck has has_card_images=True (Minchiate Fiorentine 1860-1890 today), the saved-sig stage card on /billboard/my-sign/ renders as an <img> over the irregular-shape transparent PNG with a contour-following arcana-colored stroke — not the text fan-card scaffold. First of 6 surfaces in the image-rendering rollout (my_sea + both billboard applets + room + game_kit follow in A.5+). New TarotCard.image_url property (consumes A.2's image_filename + DeckVariant.has_card_images + django.templatetags.static.static() to produce a full static-asset URL) — empty string when has_card_images=False so legacy text-only decks (Earthman, RWS) pass through transparently. my_sign.html picker grid .sig-card elements gain data-image-url + data-arcana-key attrs (the latter for stroke-color CSS selection); the .sig-stage-card scaffold gains a hidden <img class="sig-stage-card-img"> slot that JS swaps visible when image-mode is active. stage-card.js extends fromDataset to read image_url + arcana_key; new _setImageMode(stageCard, card) toggles the .sig-stage-card--image marker class + sets data-arcana-key on the stage card + populates the img src/alt; called from populateCard so all existing sig-stage flows pick up image rendering automatically (text-mode decks still pass through since image_url is empty). SCSS: new .sig-stage-card.sig-stage-card--image rule hides the .fan-card-corner + .fan-card-face text scaffold, strips the rectangular border/padding, and applies a 4-cardinal-direction filter: drop-shadow() stack to the <img> so the stroke FOLLOWS the alpha contour of the PNG instead of tracing a rectangular bounding box (per user spec 2026-05-25 PM clarification — early draft used a rectangular border which doesn't match the irregular-card aesthetic). Stroke color is driven by a CSS custom prop --img-stroke-color defaulting to rgba(var(--quiUser), 1) (cream — minor + middle arcana); [data-arcana-key="MAJOR"] override flips it to rgba(var(--terUser), 1) (gold) per Q2 lock. mobile-safe — filter on raster images works cross-browser (the [[feedback-mobile-svg-glow]] dead-end was specifically SVG glow, not raster drop-shadows). New _seed_minchiate_image_fixtures() helper in functional_tests/sig_page.py re-seeds the minimal Minchiate fixture (DeckVariant + Il Matto + Papa Uno) needed for image FTs after TransactionTestCase's flush wipes migration data — mirrors the existing _seed_earthman_sig_pile pattern per [[feedback-transactiontestcase-flush]]. New MySignImageRenderingTest.test_saved_sig_renders_as_img_for_image_deck FT seeds Minchiate + creates a superuser test gamer (superuser auto-gets super-nomad + super-schizo Notes via the User post_save signal, which _filter_major_unlocks then lets through to expose Il Matto in the picker grid — otherwise Minchiate's sig pool is empty since it has no MIDDLE arcana cards), equips Minchiate, saves Il Matto as sig, visits /billboard/my-sign/, asserts the stage card displays + contains an <img> w. src ending in the v2-convention filename minchiate-fiorentine-1860-1890-trumps-00-il-matto.png + carries .sig-stage-card--image marker class. Out of scope for this commit (deferred to A.3 follow-up polish + A.5+): the full stat-block restructure (top-left rank+suit chip Q♥ inline w. EMANATION/REVERSAL header; title in arcana-color font; keyword reposition; FYI panel re-anchor — per the locked Q3 spec) — image card-face ships now w. the existing stat-block layout to land the visible-win first. Tests: 1 new FT green; 15/15 my_sign FT class green (no regression on the 14 existing tests); 1289/1289 IT+UT total green (68s, unchanged from A.2 since no new ITs in this commit — FT covers the wiring end-to-end). Sprint A backend foundation (A.0+A.1+A.2) + first visible surface (A.3) all landed; 5 surfaces remain (A.5-A.8 + A.4's card-deck icon)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-25 00:04:18 -04:00
parent 91df482dd8
commit 5e78e6b832
6 changed files with 218 additions and 2 deletions

View File

@@ -511,6 +511,19 @@ class TarotCard(models.Model):
return "" return ""
return self.deck_variant.suit_display(self.suit) 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 @property
def cautions_json(self): def cautions_json(self):
import json import json

View File

@@ -47,6 +47,14 @@ var StageCard = (function () {
// Word(s) inside any title slot to wrap in <em> at render time // Word(s) inside any title slot to wrap in <em> at render time
// (e.g. "Stalking" for trumps 19-21). Blank for most cards. // (e.g. "Stalking" for trumps 19-21). Blank for most cards.
italic_word: el.dataset.italicWord || '', italic_word: el.dataset.italicWord || '',
// Sprint A.3 — image-rendering mode. When `image_url` is non-empty,
// the stage card renders an <img> 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 ''; return '';
} }
// Toggle image-mode on the stage card. When `card.image_url` is non-empty,
// show the <img.sig-stage-card-img> 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 <img>
// 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 // Paint the stage-card's upright + reversal faces from a normalized card
// object + the active polarity ('levity' | 'gravity'). Reversal-qualifier // object + the active polarity ('levity' | 'gravity'). Reversal-qualifier
// falls back to the current polarity's qualifier when blank (6F behavior). // falls back to the current polarity's qualifier when blank (6F behavior).
function populateCard(stageCard, card, polarity) { function populateCard(stageCard, card, polarity) {
if (!stageCard) return; if (!stageCard) return;
_setImageMode(stageCard, card);
var isLevity = polarity === 'levity'; var isLevity = polarity === 'levity';
var qualifier = isLevity ? (card.levity_qualifier || '') : (card.gravity_qualifier || ''); var qualifier = isLevity ? (card.levity_qualifier || '') : (card.gravity_qualifier || '');
var isMajor = _isMajor(card); var isMajor = _isMajor(card);

View File

@@ -53,6 +53,37 @@ def _seed_earthman_sig_pile():
return earthman 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 (18601890)",
"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): def _assign_sig(user, card=None, reversed_flag=False):
"""Assign `user.significator` (and optionally `significator_reversed`) """Assign `user.significator` (and optionally `significator_reversed`)
directly, bypassing the picker UI. Returns the assigned card. directly, bypassing the picker UI. Returns the assigned card.

View File

@@ -10,9 +10,9 @@ is branded "Sign" / "Game Sign".
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest 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.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 from apps.lyric.models import User
@@ -533,3 +533,83 @@ class MySignClearTest(FunctionalTest):
self.gamer.refresh_from_db() self.gamer.refresh_from_db()
self.assertIsNone(self.gamer.significator) self.assertIsNone(self.gamer.significator)
self.assertFalse(self.gamer.significator_reversed) 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 <img>
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 <img> 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",
)
# <img> 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 <img>.
self.assertIn(
"sig-stage-card--image", stage_card.get_attribute("class"),
"Stage card should carry .sig-stage-card--image class in image mode",
)

View File

@@ -618,6 +618,52 @@ html:has(.sig-backdrop) {
.sig-qualifier-above, .sig-qualifier-above,
.sig-qualifier-below { opacity: 0.25; } .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 <img.sig-stage-card-img> 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 <img> 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). // Stat block — same dimensions as the preview card (width × 5:8 aspect).

View File

@@ -30,6 +30,11 @@
<div class="sig-stage my-sign-stage"> <div class="sig-stage my-sign-stage">
<div class="sig-stage-card" style="display:none" <div class="sig-stage-card" style="display:none"
{% if current_significator %}data-card-id="{{ current_significator.id }}"{% endif %}> {% if current_significator %}data-card-id="{{ current_significator.id }}"{% endif %}>
{# Image-mode slot — populated by stage-card.js when the focused #}
{# card's data-image-url is non-empty (image-equipped deck per #}
{# DeckVariant.has_card_images). Hidden by default; CSS shows it #}
{# when .sig-stage-card carries .sig-stage-card--image. #}
<img class="sig-stage-card-img" alt="" style="display:none">
<div class="fan-card-corner fan-card-corner--tl"> <div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span> <span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i> <i class="fa-solid stage-suit-icon" style="display:none"></i>
@@ -131,6 +136,8 @@
data-name-group="{{ card.name_group }}" data-name-group="{{ card.name_group }}"
data-name-title="{{ card.name_title }}" data-name-title="{{ card.name_title }}"
data-arcana="{{ card.get_arcana_display }}" data-arcana="{{ card.get_arcana_display }}"
data-arcana-key="{{ card.arcana }}"
data-image-url="{{ card.image_url }}"
data-correspondence="{{ card.correspondence|default:'' }}" data-correspondence="{{ card.correspondence|default:'' }}"
data-keywords-upright="{{ card.keywords_upright|join:',' }}" data-keywords-upright="{{ card.keywords_upright|join:',' }}"
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}" data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"