A.6 + A.7 billboard My Sign applet + gameboard My Sea applet image-rendering + applet-level FLIP-to-back — TDD. Sprints A.6 + A.7 of [[project-image-based-deck-face-rendering]]: rolls image-mode out to the two card-rendering applets (My Sign on /billboard/, My Sea on /gameboard/). Both reuse the shared .sig-stage-card.sig-stage-card--image SCSS contract via a comma-list selector extension covering the parallel container classes (.my-sign-applet-card.my-sign-applet-card--image + .my-sea-slot.my-sea-slot--image) — single source of truth for the contour-stroke drop-shadow chain + tray-card silhouette black depth shadow + .is-flipped-to-back visibility toggle + the --img-stroke-color arcana-keyed CSS prop. Templates branch server-side on card.deck_variant.has_card_images: image-mode renders <img class="sig-stage-card-img" src="{{ card.image_url }}"> w. the marker class + data-arcana-key attr; text mode keeps the existing fan-card-corner + fan-card-face scaffold unchanged. SCSS import-order quirk: _card-deck.scss imports BEFORE both _billboard.scss (which nests .my-sign-applet-card inside .my-sign-applet-body for container queries) and _gameboard.scss (which nests .my-sea-slot--filled.--gravity/--levity inside #id_applet_my_sea w. specificity 1,2,0). The shared top-level image-mode rule at 0,2,0 loses on bg/border/padding to those nested base rules, so each app's stylesheet gets a parallel &.--image { background: transparent; border: 0; padding: 0 } override inside its own nest. The filter-chain rules on .sig-stage-card-img (descendant selector inside the shared rule) DO win since the apps don't restyle that class — only the outer container needs the parallel override. Sprint A.6 bonus: applet-level FLIP btn for non-polarized image-equipped decks (Minchiate today). Mirrors the my_sign.html main page A.5-polish-2 FLIP-to-back contract — .my-sign-applet-flip-btn nested inside the .--image card so absolute positioning anchors to the card bounds; inline <script> IIFE (gated inside the sig-present {% with card %} scope to keep card in lexical reach + prevent the JS selector string leaking into the no-sig DOM where assertNotContains "my-sign-applet-card" ITs catch it) attaches a click handler that runs the same rotateY 0→90→0 animation, toggles .is-flipped-to-back at the halfway point, and clears data-flipping at end; SCSS .my-sign-applet-card[data-flipping] .my-sign-applet-flip-btn { opacity: 0; pointer-events: none } hides the btn mid-spin. Critical scope bug caught + fixed during browser verify: initial draft had the script BLOCK + its {% if card.deck_variant.has_card_images %} gate placed AFTER the {% endwith %} closing tag — card was out of scope at the {% if %} evaluation, Django treats undefined vars as empty string, the gate evaluated falsy, and the script NEVER rendered (the FLIP btn rendered fine since it was inside the with block, but no JS handler → click did nothing but the CSS depress animation). Fix: move {% endwith %} to AFTER the script gate so card is still in scope. 7 new ITs total: 2 in BillboardAppletMySignTest (image-equipped Minchiate renders --image class + img + correct asset URL + lacks text scaffold; Earthman keeps the text scaffold + lacks --image); 3 in BillboardMySignViewTest (data-deck-polarized attr present; back-img element renders for non-polarized image deck; polarized deck omits it); 1 in GameboardViewTest (image-equipped Minchiate slot renders --image + img + lacks text scaffold); plus regression coverage on the no-sig empty-state assertion that originally caught the script-scope bug (assertNotContains validates the script doesn't leak in the no-sig case). Tests: 6 new ITs green; 1306/1306 IT+UT total green (72s; +6 from bdf6a25's 1303 — minus 3 dups since some ITs were counted across both A.6 + A.5-polish-2 runs). Visual verify by user 2026-05-25 PM: stage card image renders cleanly; FLIP cycles to back image + back via animation; FLIP btn hides during 500ms spin; placeholder dim styling correctly distinguishes no-deck state
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,37 +14,59 @@
|
||||
{# `_billboard.scss`. `significator_reversed` is the POLARITY #}
|
||||
{# axis (True ↔ levity), so the saved sig is always upright #}
|
||||
{# in its polarity — no `.stage-card--reversed` rotation. #}
|
||||
<div class="my-sign-applet-card my-sign-applet-card--{% if request.user.significator_reversed %}levity{% else %}gravity{% endif %}"
|
||||
data-card-id="{{ card.id }}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
{# `request.user.sig_face` is the rendering payload from #}
|
||||
{# `TarotCard.applet_face()` — mirrors `populateCard` in #}
|
||||
{# `stage-card.js:135-144`: #}
|
||||
{# • Polarity-split (cards 48-49, trumps 19-21): #}
|
||||
{# single-line title, qualifier blank. #}
|
||||
{# • Major + qualifier: title carries a trailing #}
|
||||
{# comma + qualifier renders BELOW. #}
|
||||
{# • Non-Major (middle court, Schizo / Nomad w. no #}
|
||||
{# qualifier): qualifier renders ABOVE the title. #}
|
||||
{% with face=request.user.sig_face %}
|
||||
{% if face.qualifier_first %}
|
||||
<p class="fan-card-qualifier">{{ face.qualifier }}</p>
|
||||
<p class="fan-card-name">{{ face.title }}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-name">{{ face.title }}</p>
|
||||
<p class="fan-card-qualifier">{{ face.qualifier }}</p>
|
||||
<div class="my-sign-applet-card my-sign-applet-card--{% if request.user.significator_reversed %}levity{% else %}gravity{% endif %}{% if card.deck_variant.has_card_images %} my-sign-applet-card--image{% endif %}"
|
||||
data-card-id="{{ card.id }}"
|
||||
data-arcana-key="{{ card.arcana }}">
|
||||
{% if card.deck_variant.has_card_images %}
|
||||
{# Sprint A.6 — image-mode render mirrors my_sign.html's #}
|
||||
{# .sig-stage-card--image treatment. Shares the SCSS rule #}
|
||||
{# (comma-list selector) so the contour stroke + tray-card #}
|
||||
{# silhouette black depth shadow + arcana stroke-color #}
|
||||
{# come for free. #}
|
||||
<img class="sig-stage-card-img" src="{{ card.image_url }}" alt="{{ card.name }}">
|
||||
{% if not card.deck_variant.is_polarized %}
|
||||
{# Non-polarized image deck: FLIP btn shows the deck back #}
|
||||
{# image (same behavior as my_sign.html main page). Both #}
|
||||
{# the back-img + flip-btn nest INSIDE the card so the #}
|
||||
{# absolute-positioned FLIP btn anchors to the card's #}
|
||||
{# bounds (card is position: relative in --image mode). #}
|
||||
<img class="sig-stage-card-back-img" alt=""
|
||||
src="{{ card.deck_variant.back_image_url }}">
|
||||
<button class="btn btn-reveal my-sign-applet-flip-btn"
|
||||
type="button"
|
||||
aria-label="Flip to deck back">FLIP</button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
{# `request.user.sig_face` is the rendering payload from #}
|
||||
{# `TarotCard.applet_face()` — mirrors `populateCard` in #}
|
||||
{# `stage-card.js:135-144`: #}
|
||||
{# • Polarity-split (cards 48-49, trumps 19-21): #}
|
||||
{# single-line title, qualifier blank. #}
|
||||
{# • Major + qualifier: title carries a trailing #}
|
||||
{# comma + qualifier renders BELOW. #}
|
||||
{# • Non-Major (middle court, Schizo / Nomad w. no #}
|
||||
{# qualifier): qualifier renders ABOVE the title. #}
|
||||
{% with face=request.user.sig_face %}
|
||||
{% if face.qualifier_first %}
|
||||
<p class="fan-card-qualifier">{{ face.qualifier }}</p>
|
||||
<p class="fan-card-name">{{ face.title }}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-name">{{ face.title }}</p>
|
||||
<p class="fan-card-qualifier">{{ face.qualifier }}</p>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{# Stat block — same shape as my_sign.html's `.sig-stat-block` #}
|
||||
{# (Emanation face label + keyword list) but no SPIN/FYI btns #}
|
||||
@@ -59,6 +81,45 @@
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{# Sprint A.6 — applet FLIP btn handler. Mirrors my_sign.html's #}
|
||||
{# `_flipToBackAnimated()` shape (rotateY 0→90→0 over 500ms, class #}
|
||||
{# toggle at halfway, `data-flipping` attr for SCSS to hide the #}
|
||||
{# btn). Self-contained inline script — no shared module needed #}
|
||||
{# since the applet is the only consumer outside the main page #}
|
||||
{# (which has its own copy). Script wrapped inside the sig-present #}
|
||||
{# branch AND inside `{% with card %}` scope so `card` references #}
|
||||
{# resolve + the JS selector strings don't leak into the no-sig #}
|
||||
{# DOM (which would trip substring-matching tests). #}
|
||||
{% if card.deck_variant.has_card_images and not card.deck_variant.is_polarized %}
|
||||
<script>
|
||||
(function () {
|
||||
var applet = document.getElementById('id_applet_my_sign');
|
||||
if (!applet) return;
|
||||
var c = applet.querySelector('.my-sign-applet-card--image');
|
||||
var b = applet.querySelector('.my-sign-applet-flip-btn');
|
||||
if (!c || !b) return;
|
||||
b.addEventListener('click', function () {
|
||||
if (c.dataset.flipping) return;
|
||||
c.dataset.flipping = '1';
|
||||
var rest = 'rotateY(0deg)';
|
||||
var mid = 'rotateY(90deg)';
|
||||
c.animate([
|
||||
{ transform: rest },
|
||||
{ transform: mid, offset: 0.5 },
|
||||
{ transform: rest },
|
||||
], { duration: 500, easing: 'ease' });
|
||||
setTimeout(function () {
|
||||
c.classList.toggle('is-flipped-to-back');
|
||||
b.classList.toggle(
|
||||
'is-reversed',
|
||||
c.classList.contains('is-flipped-to-back')
|
||||
);
|
||||
}, 250);
|
||||
setTimeout(function () { delete c.dataset.flipping; }, 500);
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<p class="my-sign-applet-empty">No sign chosen yet.</p>
|
||||
|
||||
@@ -27,38 +27,48 @@
|
||||
{# the wrap so it sits BELOW the slot box (user-spec #}
|
||||
{# 2026-05-23: same position as the my_sea.html picker's #}
|
||||
{# `.sea-pos-label`). #}
|
||||
<div class="my-sea-slot my-sea-slot--filled my-sea-slot--{{ slot.polarity }}{% if slot.reversed %} my-sea-slot--reversed{% endif %}"
|
||||
<div class="my-sea-slot my-sea-slot--filled my-sea-slot--{{ slot.polarity }}{% if slot.reversed %} my-sea-slot--reversed{% endif %}{% if slot.card.deck_variant.has_card_images %} my-sea-slot--image{% endif %}"
|
||||
data-position="{{ slot.position }}"
|
||||
data-card-id="{{ slot.card.id }}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
{# `slot.face` is the rendering payload from `TarotCard. #}
|
||||
{# applet_face()` — mirrors `populateCard` in #}
|
||||
{# `stage-card.js`: #}
|
||||
{# • Polarity-split (cards 19-21, 48-49): single-line #}
|
||||
{# title, qualifier blank. #}
|
||||
{# • Pattern B Major (2-5, 10-15, 22-35, 41): swapped #}
|
||||
{# reversal name + polarity qualifier carried. #}
|
||||
{# • Pattern B' Major (16-18): swapped reversal name, #}
|
||||
{# no qualifier on reversal. #}
|
||||
{# • Non-Major: qualifier ABOVE the title. #}
|
||||
{# Empty `.fan-card-qualifier` is hidden by `:empty` CSS. #}
|
||||
{% if slot.face.qualifier_first %}
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
{% endif %}
|
||||
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
data-card-id="{{ slot.card.id }}"
|
||||
data-arcana-key="{{ slot.card.arcana }}">
|
||||
{% if slot.card.deck_variant.has_card_images %}
|
||||
{# Sprint A.7 — image-mode slot render. Shares the #}
|
||||
{# `.sig-stage-card-img` SCSS rule via the #}
|
||||
{# `.my-sea-slot--image` comma-list addition in #}
|
||||
{# `_card-deck.scss`. Contour stroke + depth shadow #}
|
||||
{# scale w. the slot's smaller dimensions. #}
|
||||
<img class="sig-stage-card-img" src="{{ slot.card.image_url }}" alt="{{ slot.card.name }}">
|
||||
{% else %}
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
{# `slot.face` is the rendering payload from `TarotCard. #}
|
||||
{# applet_face()` — mirrors `populateCard` in #}
|
||||
{# `stage-card.js`: #}
|
||||
{# • Polarity-split (cards 19-21, 48-49): single-line #}
|
||||
{# title, qualifier blank. #}
|
||||
{# • Pattern B Major (2-5, 10-15, 22-35, 41): swapped #}
|
||||
{# reversal name + polarity qualifier carried. #}
|
||||
{# • Pattern B' Major (16-18): swapped reversal name, #}
|
||||
{# no qualifier on reversal. #}
|
||||
{# • Non-Major: qualifier ABOVE the title. #}
|
||||
{# Empty `.fan-card-qualifier` is hidden by `:empty` CSS. #}
|
||||
{% if slot.face.qualifier_first %}
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-name">{{ slot.face.title }}</p>
|
||||
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
|
||||
{% endif %}
|
||||
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
|
||||
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="my-sea-slot-label">{{ slot.label }}</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user