diff --git a/src/apps/dashboard/tests/integrated/test_views.py b/src/apps/dashboard/tests/integrated/test_views.py index ed4fe80..f6e1583 100644 --- a/src/apps/dashboard/tests/integrated/test_views.py +++ b/src/apps/dashboard/tests/integrated/test_views.py @@ -502,6 +502,38 @@ class KitBagViewTest(TestCase): self.assertEqual(response.status_code, 302) 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 `` + 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): def setUp(self): self.user = User.objects.create(email="disco@test.io") diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 12fccde..937317a 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -259,6 +259,20 @@ class DeckVariant(models.Model): has_card_images = models.BooleanField(default=True) 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): """Map canonical SUIT enum → family-authentic filename slug. e.g. ('italian', 'BRANDS') → 'batons'.""" diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 7ad443f..ffbd7b3 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -459,6 +459,41 @@ class GameboardViewTest(TestCase): "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 + referencing the deck's -back.png + inline style `fill: + url(#deck-back-)` 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 ") + 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 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 ") + self.assertNotIn("fill: url(#deck-back-", html, + "Non-image deck rects must use the SCSS default fill") + class GameboardDeckInUseTest(TestCase): """Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat.""" diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index dcbfe66..e8d9334 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -79,54 +79,70 @@ body.page-gameboard { .kit-item { font-size: 1.5rem; } .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 referencing the deck's -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 { display: flex; flex-direction: column; diff --git a/src/templates/apps/gameboard/_partials/_deck_stack_icon.html b/src/templates/apps/gameboard/_partials/_deck_stack_icon.html index fd1eab1..3e2f80d 100644 --- a/src/templates/apps/gameboard/_partials/_deck_stack_icon.html +++ b/src/templates/apps/gameboard/_partials/_deck_stack_icon.html @@ -1,20 +1,46 @@ {# 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 #} -{# .token.deck-variant; future: room.html pile + deck-bag). #} +{# wherever a deck is represented by an icon (game_kit applet's #} +{# .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 #} -{# whole stack 5° CW + drives the fan-out on hover/active/focus of the #} -{# parent .token (or whatever wraps it). The card-back design here is #} -{# placeholder (solid --priUser fill + currentColor stroke). Detailed art #} -{# (Earthman planet-impact illustration, future custom decks) drops in later #} -{# per [[project-card-deck-icon]]. #} +{# whole stack 5° CW + drives the fan-out on hover/active/focus. When `deck` #} +{# is image-equipped (has_card_images=True), the rect fills are overridden #} +{# via inline `style` to use an SVG referencing the deck's actual #} +{# card-back PNG (per [[reference-card-image-naming-convention]] back slot, #} +{# `-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. #} diff --git a/src/templates/core/_partials/_kit_bag_panel.html b/src/templates/core/_partials/_kit_bag_panel.html index 80f4f2e..6bba5bf 100644 --- a/src/templates/core/_partials/_kit_bag_panel.html +++ b/src/templates/core/_partials/_kit_bag_panel.html @@ -3,17 +3,17 @@
{% if equipped_deck %}
- + {% include "apps/gameboard/_partials/_deck_stack_icon.html" with deck=equipped_deck %}

{{ equipped_deck.name }}{% if equipped_deck.is_default %} (Default){% endif %}

-

{{ equipped_deck.card_count }}-card Tarot deck

+

{{ equipped_deck.card_count }}-card Tarot deck{% if equipped_deck.is_polarized %} (×2){% endif %}

{% if equipped_deck.description %}

{{ equipped_deck.description }}

{% endif %}

Stock version (0 substitutions)

{% else %}
- + {% include "apps/gameboard/_partials/_deck_stack_icon.html" %}
{% endif %}