A.4 cont.: deck back-image renders inside card-stack icon + kit-bag dialog Deck section adopts the icon + size+pattern polish — TDD. Three follow-up improvements after user browser-verified A.4's first cut: (1) image-equipped decks (Minchiate today, future Earthman) now render the deck's actual <deck-slug>-back.png as the card-stack icon's visible faces instead of the placeholder --priUser solid fill — feels like a real deck, not a generic stand-in. (2) The kit-bag dialog Deck section (#id_kit_bag_dialog .kit-bag-deck) gets the same new card-stack icon (was still showing the old fa-regular fa-id-badge), with (×2) tooltip decoration on polarized decks for consistency w. the gameboard applet. (3) Visual polish: icon bumped 1.5× (1.5rem → 2.25rem width; 2.4rem → 3.6rem height, 5:8 aspect preserved); SVG <pattern> switched from patternUnits=userSpaceOnUse (which painted the image at fixed user-space coordinates and let the rect slide out from under it on hover, reading as "low opacity" to the user) to patternUnits=objectBoundingBox + patternContentUnits=objectBoundingBox (transform-aware — image tracks the rect through rest-state offsets + hover fan-out). New DeckVariant.back_image_url property mirrors A.2's TarotCard.image_url pattern: returns full static-asset URL for <deck-slug>-back.png when has_card_images=True, else empty string. Template partial _deck_stack_icon.html extended w. conditional <defs><pattern> block that renders only when deck.has_card_images is true; each of the 3 card rects then carries an inline style="fill: url(#deck-back-<short_key>)" overriding the SCSS default fill: rgba(--priUser, 1) (inline style beats CSS, the only way to opt out of the cascade default per-element). When no deck is passed (kit-bag placeholder branch) or deck has no images (Earthman + RWS), the partial falls through to the placeholder fill — single template handles both modes. _kit_bag_panel.html Deck section: equipped-deck branch swaps <i class="fa-regular fa-id-badge"> for {% include _deck_stack_icon.html with deck=equipped_deck %} + adds (×2) span in --terUser for equipped_deck.is_polarized; placeholder branch swaps for the same include without deck= so the partial's conditional falls through. SCSS reorg: lifted the .deck-stack-icon base rules out of the #id_applet_game_kit nest (they were scoped to gameboard's Game Kit applet only) into top-level scope so the same SCSS applies in the kit-bag dialog context too. Hover/active/focus trigger selector list broadened to cover .deck-stack-icon itself + .token.deck-variant wrapper + .kit-bag-deck wrapper. 4 new ITs total: 2 in GameboardViewTest (image-equipped Minchiate's <pattern> defines + inline fill style on all 3 rects + asset URL ref; non-image Earthman has NEITHER pattern nor inline fill); 2 in dashboard.KitBagViewTest (kit-bag Deck section renders svg.deck-stack-icon + lacks fa-id-badge; polarized equipped deck tooltip carries .tt-x2 — element-presence assertion since literal "×2" character had encoding issues in the dashboard test file vs the gameboard one, which is fine since the template-side rendering of the literal × is exercised by the parent template). Tests: 4 new green; 1297/1297 IT+UT total green (69s; +4 from A.4's 1293). Visual verify pending: refresh /gameboard/ → Minchiate icon should show 3 stacked Minchiate card-backs at 1.5× size, fan out on hover w. back image tracking; refresh kit-bag dialog → same icon visible in Deck section w. (×2) on Earthman tooltip

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-25 01:01:05 -04:00
parent b9bb73db69
commit d26c45bf77
6 changed files with 181 additions and 58 deletions

View File

@@ -502,6 +502,38 @@ class KitBagViewTest(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"]) self.assertIn("/?next=", response["Location"])
def test_deck_section_renders_card_stack_svg_not_fa_id_badge(self):
"""Sprint A.4 follow-up — kit-bag Deck section uses the new card-deck
SVG icon partial, replacing the prior `<i class="fa-regular fa-id-badge">`
for both the equipped-deck branch and the placeholder branch."""
import lxml.html
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
# Auto-equipped Earthman appears in the Deck section.
deck = parsed.cssselect(".kit-bag-deck")[0]
[_] = deck.cssselect("svg.deck-stack-icon")
self.assertEqual(
len(deck.cssselect("i.fa-id-badge")), 0,
"fa-regular fa-id-badge must be gone from kit-bag-deck",
)
def test_polarized_equipped_deck_tooltip_has_x2_decoration(self):
"""Same (x2) decoration as the gameboard applet — polarized decks
signal segment-doubling in the tooltip card-count line via --terUser
`.tt-x2` span. Element-presence assertion (the literal `×2` content
is exercised by the parent template; this test only locks the conditional
render on `is_polarized`)."""
import lxml.html
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
deck = parsed.cssselect(".kit-bag-deck")[0]
# Earthman is auto-equipped + is_polarized=True per A.0 migration.
self.assertEqual(
len(deck.cssselect(".tt-x2")), 1,
"Polarized equipped deck must render .tt-x2 decoration in kit-bag tooltip",
)
class ToggleDashAppletsViewTest(TestCase): class ToggleDashAppletsViewTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="disco@test.io") self.user = User.objects.create(email="disco@test.io")

View File

@@ -259,6 +259,20 @@ class DeckVariant(models.Model):
has_card_images = models.BooleanField(default=True) has_card_images = models.BooleanField(default=True)
is_polarized = models.BooleanField(default=False) is_polarized = models.BooleanField(default=False)
@property
def back_image_url(self):
"""Full static-asset URL for this deck's card-back image, or empty
string if the deck has no images (legacy text-only mode). Sprint A.4
— consumed by the card-stack icon SVG to render the actual deck back
as the visible card-stack rect-fills instead of the placeholder
`--priUser` solid color."""
if not self.has_card_images:
return ""
from django.templatetags.static import static
return static(
f"apps/epic/images/cards-faces/{self.slug}/{self.slug}-back.png"
)
def suit_slug(self, canonical_suit): def suit_slug(self, canonical_suit):
"""Map canonical SUIT enum → family-authentic filename slug. """Map canonical SUIT enum → family-authentic filename slug.
e.g. ('italian', 'BRANDS') → 'batons'.""" e.g. ('italian', 'BRANDS') → 'batons'."""

View File

@@ -459,6 +459,41 @@ class GameboardViewTest(TestCase):
"Non-polarized RWS deck must not show (×2) in tooltip", "Non-polarized RWS deck must not show (×2) in tooltip",
) )
def test_image_equipped_deck_icon_uses_back_image_pattern(self):
"""Sprint A.4 follow-up — image-equipped decks (Minchiate today,
future Earthman) render the SVG card-stack icon w. an inline <pattern>
referencing the deck's <deck-slug>-back.png + inline style `fill:
url(#deck-back-<short_key>)` on each rect so the actual card-back
renders instead of the placeholder `--priUser` solid fill."""
from apps.epic.models import DeckVariant
minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
self.user.unlocked_decks.add(minchiate)
response = self.client.get("/gameboard/")
import lxml.html
parsed = lxml.html.fromstring(response.content)
deck = parsed.cssselect("#id_game_kit #id_kit_minchiate_deck")[0]
html = lxml.html.tostring(deck, encoding="unicode")
self.assertIn("deck-back-minchiate", html,
"Image-equipped deck's SVG must define a back-image <pattern>")
self.assertIn("minchiate-fiorentine-1860-1890-back.png", html,
"Pattern must reference the deck's back-image asset URL")
self.assertGreaterEqual(
html.count("fill: url(#deck-back-minchiate)"), 3,
"Each of the 3 card rects must override its fill via inline style",
)
def test_nonimage_deck_icon_has_no_back_pattern(self):
"""Earthman (has_card_images=False until its art lands) renders the
placeholder fill — NO <pattern> defs, no inline-style fill override,
rects fall through to the SCSS default `--priUser` solid fill."""
deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0]
import lxml.html
html = lxml.html.tostring(deck, encoding="unicode")
self.assertNotIn("deck-back-earthman", html,
"Non-image deck must not define a back-image <pattern>")
self.assertNotIn("fill: url(#deck-back-", html,
"Non-image deck rects must use the SCSS default fill")
class GameboardDeckInUseTest(TestCase): class GameboardDeckInUseTest(TestCase):
"""Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat.""" """Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat."""

View File

@@ -79,54 +79,70 @@ body.page-gameboard {
.kit-item { font-size: 1.5rem; } .kit-item { font-size: 1.5rem; }
.kit-item { opacity: 0.6; } .kit-item { opacity: 0.6; }
// Sprint A.4 — card-deck stack icon (.deck-stack-icon) replaces the
// fa-regular fa-id-badge for .token.deck-variant. 3 stacked card-back
// rects, 5° CW rest tilt, fan-out on hover/active/focus of the parent
// .token (animation + tooltip portal trigger lockstep on the same
// pseudo-class set). See [[project-card-deck-icon]].
.deck-stack-icon {
display: inline-block;
width: 1.5rem; // match the prior fa-id-badge visual weight
height: 2.4rem; // 5:8 tarot card aspect
color: rgba(var(--terUser), 1); // stroke color via currentColor
overflow: visible; // fan-out exceeds the viewBox bounds
filter: drop-shadow(0.08rem 0.08rem 0.15rem rgba(0, 0, 0, 0.6));
.deck-stack-icon__stack {
transform: rotate(5deg);
transform-origin: 50% 50%;
transform-box: fill-box;
transition: transform 0.25s ease;
}
.deck-stack-icon__card {
fill: rgba(var(--priUser), 1);
stroke: currentColor;
stroke-width: 1;
transform-origin: 50% 50%;
transform-box: fill-box;
transition: transform 0.25s ease;
}
// Rest: tightly stacked w. tiny vertical offsets (suggests stack
// depth without separating the cards visually).
.deck-stack-icon__card--1 { transform: translateY(-0.4px); }
.deck-stack-icon__card--3 { transform: translateY( 0.4px); }
}
// Hover/active/focus on the parent .token fans cards 2 + 3 out from
// under card 1; card 1 stays put. Tooltip portal is wired to the
// same `.token:hover` trigger via JS so the splay + tooltip-appearance
// co-activate.
.token.deck-variant:hover .deck-stack-icon,
.token.deck-variant:active .deck-stack-icon,
.token.deck-variant:focus .deck-stack-icon {
.deck-stack-icon__card--2 { transform: translate(-5px, -2px) rotate(-12deg); }
.deck-stack-icon__card--3 { transform: translate( 5px, -2px) rotate( 12deg); }
}
} }
} }
// Sprint A.4 — card-deck stack icon (.deck-stack-icon) replaces the
// fa-regular fa-id-badge wherever a deck appears in icon form: gameboard's
// .token.deck-variant, kit-bag dialog's .kit-bag-deck, future room.html pile
// + deck-bag. Lifted out of the #id_applet_game_kit nest so the base sizing
// + rest-state SCSS applies in any deck-icon context. 3 stacked card-back
// rects, 5° CW rest tilt; see [[project-card-deck-icon]] for the design rules.
// When the deck has card-images, the rect fills are overridden inline w. an
// SVG <pattern> referencing the deck's <deck-slug>-back.png; otherwise the
// placeholder `fill: rgba(--priUser, 1)` shows through.
.deck-stack-icon {
display: inline-block;
// 2026-05-25 PM user spec: 1.5× the prior fa-id-badge visual weight
// since the icon is no longer constrained to placeholder-icon dimensions
// (now a meaningful first-class deck visualization).
width: 2.25rem; // 1.5rem × 1.5
height: 3.6rem; // 1.5× while preserving 5:8 tarot card aspect
color: rgba(var(--terUser), 1); // stroke color via currentColor
overflow: visible; // fan-out exceeds the viewBox bounds
filter: drop-shadow(0.08rem 0.08rem 0.15rem rgba(0, 0, 0, 0.6));
.deck-stack-icon__stack {
transform: rotate(5deg);
transform-origin: 50% 50%;
transform-box: fill-box;
transition: transform 0.25s ease;
}
.deck-stack-icon__card {
fill: rgba(var(--priUser), 1);
stroke: currentColor;
stroke-width: 1;
transform-origin: 50% 50%;
transform-box: fill-box;
transition: transform 0.25s ease;
}
// Rest: tightly stacked w. tiny vertical offsets (suggests stack
// depth without separating the cards visually).
.deck-stack-icon__card--1 { transform: translateY(-0.4px); }
.deck-stack-icon__card--3 { transform: translateY( 0.4px); }
}
// Sprint A.4 — fan-out trigger lives at the icon level (not nested in
// #id_applet_game_kit) so the same `.deck-stack-icon` works wherever it's
// dropped: gameboard's .token.deck-variant, kit-bag dialog's .kit-bag-deck,
// future room.html pile + deck-bag. Hover/active/focus on the icon itself
// OR any of its known wrappers triggers the splay; cards 2 + 3 fan out from
// under card 1, card 1 stays put. Tooltip portal is wired to the same
// `.token:hover` / `.kit-bag-deck:hover` triggers via JS so the splay +
// tooltip-appearance co-activate.
.deck-stack-icon:hover,
.deck-stack-icon:active,
.token.deck-variant:hover .deck-stack-icon,
.token.deck-variant:active .deck-stack-icon,
.token.deck-variant:focus .deck-stack-icon,
.kit-bag-deck:hover .deck-stack-icon,
.kit-bag-deck:active .deck-stack-icon,
.kit-bag-deck:focus .deck-stack-icon {
.deck-stack-icon__card--2 { transform: translate(-5px, -2px) rotate(-12deg); }
.deck-stack-icon__card--3 { transform: translate( 5px, -2px) rotate( 12deg); }
}
#id_applet_new_game { #id_applet_new_game {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,20 +1,46 @@
{# Sprint A.4 — card-deck stack SVG icon. Replaces fa-regular fa-id-badge #} {# Sprint A.4 — card-deck stack SVG icon. Replaces fa-regular fa-id-badge #}
{# wherever a deck is represented by an icon (currently game_kit applet's #} {# wherever a deck is represented by an icon (game_kit applet's #}
{# .token.deck-variant; future: room.html pile + deck-bag). #} {# .token.deck-variant + kit-bag dialog's .kit-bag-deck; future: room.html #}
{# pile + deck-bag). #}
{# #} {# #}
{# 3 rect-cards stacked tightly at rest; .deck-stack-icon SCSS rotates the #} {# 3 rect-cards stacked tightly at rest; .deck-stack-icon SCSS rotates the #}
{# whole stack 5° CW + drives the fan-out on hover/active/focus of the #} {# whole stack 5° CW + drives the fan-out on hover/active/focus. When `deck` #}
{# parent .token (or whatever wraps it). The card-back design here is #} {# is image-equipped (has_card_images=True), the rect fills are overridden #}
{# placeholder (solid --priUser fill + currentColor stroke). Detailed art #} {# via inline `style` to use an SVG <pattern> referencing the deck's actual #}
{# (Earthman planet-impact illustration, future custom decks) drops in later #} {# card-back PNG (per [[reference-card-image-naming-convention]] back slot, #}
{# per [[project-card-deck-icon]]. #} {# `<deck-slug>-back.png`). When no deck is passed (kit-bag deck placeholder) #}
{# or the deck has no images (Earthman until art lands, RWS), rects fall #}
{# through to the SCSS default `fill: rgba(--priUser, 1)` placeholder. #}
<svg class="deck-stack-icon" viewBox="0 0 32 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"> <svg class="deck-stack-icon" viewBox="0 0 32 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
{% if deck.has_card_images %}
<defs>
{# Both patternUnits + patternContentUnits = objectBoundingBox so the #}
{# image is rendered relative to each card rect's bounding box rather #}
{# than in fixed user-space. This makes the image TRACK the rect's #}
{# transforms (rest-state micro-offsets + hover fan-out) instead of #}
{# staying put while the rect slides out from under it — the bug #}
{# that read as low-opacity to the user 2026-05-25 PM. width=height=1 #}
{# normalizes the pattern to fill the bbox; image x/y/width/height #}
{# = 0/0/1/1 fills the pattern. preserveAspectRatio=xMidYMid slice #}
{# crops the image to the 5:8 card aspect (vs the back PNG's native #}
{# 620×1024 ≈ 5:8.25 — minimal crop). #}
<pattern id="deck-back-{{ deck.short_key }}"
patternUnits="objectBoundingBox" patternContentUnits="objectBoundingBox"
width="1" height="1">
<image href="{{ deck.back_image_url }}" x="0" y="0" width="1" height="1"
preserveAspectRatio="xMidYMid slice" />
</pattern>
</defs>
{% endif %}
<g class="deck-stack-icon__stack"> <g class="deck-stack-icon__stack">
<rect class="deck-stack-icon__card deck-stack-icon__card--3" <rect class="deck-stack-icon__card deck-stack-icon__card--3"
x="6" y="8" width="20" height="32" rx="2.5" /> x="6" y="8" width="20" height="32" rx="2.5"
{% if deck.has_card_images %}style="fill: url(#deck-back-{{ deck.short_key }});"{% endif %} />
<rect class="deck-stack-icon__card deck-stack-icon__card--2" <rect class="deck-stack-icon__card deck-stack-icon__card--2"
x="6" y="8" width="20" height="32" rx="2.5" /> x="6" y="8" width="20" height="32" rx="2.5"
{% if deck.has_card_images %}style="fill: url(#deck-back-{{ deck.short_key }});"{% endif %} />
<rect class="deck-stack-icon__card deck-stack-icon__card--1" <rect class="deck-stack-icon__card deck-stack-icon__card--1"
x="6" y="8" width="20" height="32" rx="2.5" /> x="6" y="8" width="20" height="32" rx="2.5"
{% if deck.has_card_images %}style="fill: url(#deck-back-{{ deck.short_key }});"{% endif %} />
</g> </g>
</svg> </svg>

View File

@@ -3,17 +3,17 @@
<div class="kit-bag-row"> <div class="kit-bag-row">
{% if equipped_deck %} {% if equipped_deck %}
<div class="kit-bag-deck" data-deck-id="{{ equipped_deck.pk }}"> <div class="kit-bag-deck" data-deck-id="{{ equipped_deck.pk }}">
<i class="fa-regular fa-id-badge"></i> {% include "apps/gameboard/_partials/_deck_stack_icon.html" with deck=equipped_deck %}
<div class="tt"> <div class="tt">
<h4 class="tt-title">{{ equipped_deck.name }}{% if equipped_deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4> <h4 class="tt-title">{{ equipped_deck.name }}{% if equipped_deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4>
<p class="tt-description">{{ equipped_deck.card_count }}-card Tarot deck</p> <p class="tt-description">{{ equipped_deck.card_count }}-card Tarot deck{% if equipped_deck.is_polarized %} <span class="tt-x2">(×2)</span>{% endif %}</p>
{% if equipped_deck.description %}<p class="tt-shoptalk"><em>{{ equipped_deck.description }}</em></p>{% endif %} {% if equipped_deck.description %}<p class="tt-shoptalk"><em>{{ equipped_deck.description }}</em></p>{% endif %}
<p class="tt-shoptalk">Stock version <span class="tt-subcounter">(0 substitutions)</span></p> <p class="tt-shoptalk">Stock version <span class="tt-subcounter">(0 substitutions)</span></p>
</div> </div>
</div> </div>
{% else %} {% else %}
<div class="kit-bag-placeholder"> <div class="kit-bag-placeholder">
<i class="fa-regular fa-id-badge"></i> {% include "apps/gameboard/_partials/_deck_stack_icon.html" %}
</div> </div>
{% endif %} {% endif %}
</div> </div>