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:
@@ -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")
|
||||||
|
|||||||
@@ -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'."""
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -79,16 +79,25 @@ 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
|
// 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
|
// fa-regular fa-id-badge wherever a deck appears in icon form: gameboard's
|
||||||
// rects, 5° CW rest tilt, fan-out on hover/active/focus of the parent
|
// .token.deck-variant, kit-bag dialog's .kit-bag-deck, future room.html pile
|
||||||
// .token (animation + tooltip portal trigger lockstep on the same
|
// + deck-bag. Lifted out of the #id_applet_game_kit nest so the base sizing
|
||||||
// pseudo-class set). See [[project-card-deck-icon]].
|
// + 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 {
|
.deck-stack-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 1.5rem; // match the prior fa-id-badge visual weight
|
// 2026-05-25 PM user spec: 1.5× the prior fa-id-badge visual weight
|
||||||
height: 2.4rem; // 5:8 tarot card aspect
|
// 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
|
color: rgba(var(--terUser), 1); // stroke color via currentColor
|
||||||
overflow: visible; // fan-out exceeds the viewBox bounds
|
overflow: visible; // fan-out exceeds the viewBox bounds
|
||||||
filter: drop-shadow(0.08rem 0.08rem 0.15rem rgba(0, 0, 0, 0.6));
|
filter: drop-shadow(0.08rem 0.08rem 0.15rem rgba(0, 0, 0, 0.6));
|
||||||
@@ -114,18 +123,25 @@ body.page-gameboard {
|
|||||||
.deck-stack-icon__card--3 { 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
|
// Sprint A.4 — fan-out trigger lives at the icon level (not nested in
|
||||||
// under card 1; card 1 stays put. Tooltip portal is wired to the
|
// #id_applet_game_kit) so the same `.deck-stack-icon` works wherever it's
|
||||||
// same `.token:hover` trigger via JS so the splay + tooltip-appearance
|
// dropped: gameboard's .token.deck-variant, kit-bag dialog's .kit-bag-deck,
|
||||||
// co-activate.
|
// 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:hover .deck-stack-icon,
|
||||||
.token.deck-variant:active .deck-stack-icon,
|
.token.deck-variant:active .deck-stack-icon,
|
||||||
.token.deck-variant:focus .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--2 { transform: translate(-5px, -2px) rotate(-12deg); }
|
||||||
.deck-stack-icon__card--3 { 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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user