From d26c45bf77a77c8bc09316b9f1d376d6512de31b Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 25 May 2026 01:01:05 -0400 Subject: [PATCH] =?UTF-8?q?A.4=20cont.:=20deck=20back-image=20renders=20in?= =?UTF-8?q?side=20card-stack=20icon=20+=20kit-bag=20dialog=20Deck=20sectio?= =?UTF-8?q?n=20adopts=20the=20icon=20+=20size+pattern=20polish=20=E2=80=94?= =?UTF-8?q?=20TDD.=20Three=20follow-up=20improvements=20after=20user=20bro?= =?UTF-8?q?wser-verified=20A.4's=20first=20cut:=20(1)=20image-equipped=20d?= =?UTF-8?q?ecks=20(Minchiate=20today,=20future=20Earthman)=20now=20render?= =?UTF-8?q?=20the=20deck's=20actual=20-back.png=20as=20the=20ca?= =?UTF-8?q?rd-stack=20icon's=20visible=20faces=20instead=20of=20the=20plac?= =?UTF-8?q?eholder=20--priUser=20solid=20fill=20=E2=80=94=20feels=20like?= =?UTF-8?q?=20a=20real=20deck,=20not=20a=20generic=20stand-in.=20(2)=20The?= =?UTF-8?q?=20kit-bag=20dialog=20Deck=20section=20(`#id=5Fkit=5Fbag=5Fdial?= =?UTF-8?q?og=20.kit-bag-deck`)=20gets=20the=20same=20new=20card-stack=20i?= =?UTF-8?q?con=20(was=20still=20showing=20the=20old=20fa-regular=20fa-id-b?= =?UTF-8?q?adge),=20with=20`(=C3=972)`=20tooltip=20decoration=20on=20polar?= =?UTF-8?q?ized=20decks=20for=20consistency=20w.=20the=20gameboard=20apple?= =?UTF-8?q?t.=20(3)=20Visual=20polish:=20icon=20bumped=201.5=C3=97=20(1.5r?= =?UTF-8?q?em=20=E2=86=92=202.25rem=20width;=202.4rem=20=E2=86=92=203.6rem?= =?UTF-8?q?=20height,=205:8=20aspect=20preserved);=20SVG=20=20swi?= =?UTF-8?q?tched=20from=20`patternUnits=3DuserSpaceOnUse`=20(which=20paint?= =?UTF-8?q?ed=20the=20image=20at=20fixed=20user-space=20coordinates=20and?= =?UTF-8?q?=20let=20the=20rect=20slide=20out=20from=20under=20it=20on=20ho?= =?UTF-8?q?ver,=20reading=20as=20"low=20opacity"=20to=20the=20user)=20to?= =?UTF-8?q?=20`patternUnits=3DobjectBoundingBox=20+=20patternContentUnits?= =?UTF-8?q?=3DobjectBoundingBox`=20(transform-aware=20=E2=80=94=20image=20?= =?UTF-8?q?tracks=20the=20rect=20through=20rest-state=20offsets=20+=20hove?= =?UTF-8?q?r=20fan-out).=20New=20`DeckVariant.back=5Fimage=5Furl`=20proper?= =?UTF-8?q?ty=20mirrors=20A.2's=20`TarotCard.image=5Furl`=20pattern:=20ret?= =?UTF-8?q?urns=20full=20static-asset=20URL=20for=20`-back.png`?= =?UTF-8?q?=20when=20has=5Fcard=5Fimages=3DTrue,=20else=20empty=20string.?= =?UTF-8?q?=20Template=20partial=20`=5Fdeck=5Fstack=5Ficon.html`=20extende?= =?UTF-8?q?d=20w.=20conditional=20``=20block=20that=20rende?= =?UTF-8?q?rs=20only=20when=20`deck.has=5Fcard=5Fimages`=20is=20true;=20ea?= =?UTF-8?q?ch=20of=20the=203=20card=20rects=20then=20carries=20an=20inline?= =?UTF-8?q?=20`style=3D"fill:=20url(#deck-back-)"`=20overridi?= =?UTF-8?q?ng=20the=20SCSS=20default=20`fill:=20rgba(--priUser,=201)`=20(i?= =?UTF-8?q?nline=20style=20beats=20CSS,=20the=20only=20way=20to=20opt=20ou?= =?UTF-8?q?t=20of=20the=20cascade=20default=20per-element).=20When=20no=20?= =?UTF-8?q?deck=20is=20passed=20(kit-bag=20placeholder=20branch)=20or=20de?= =?UTF-8?q?ck=20has=20no=20images=20(Earthman=20+=20RWS),=20the=20partial?= =?UTF-8?q?=20falls=20through=20to=20the=20placeholder=20fill=20=E2=80=94?= =?UTF-8?q?=20single=20template=20handles=20both=20modes.=20`=5Fkit=5Fbag?= =?UTF-8?q?=5Fpanel.html`=20Deck=20section:=20equipped-deck=20branch=20swa?= =?UTF-8?q?ps=20``=20for=20`{%=20i?= =?UTF-8?q?nclude=20=5Fdeck=5Fstack=5Ficon.html=20with=20deck=3Dequipped?= =?UTF-8?q?=5Fdeck=20%}`=20+=20adds=20`(=C3=972)`=20span=20in=20--terUser?= =?UTF-8?q?=20for=20`equipped=5Fdeck.is=5Fpolarized`;=20placeholder=20bran?= =?UTF-8?q?ch=20swaps=20for=20the=20same=20include=20without=20`deck=3D`?= =?UTF-8?q?=20so=20the=20partial's=20conditional=20falls=20through.=20SCSS?= =?UTF-8?q?=20reorg:=20lifted=20the=20`.deck-stack-icon`=20base=20rules=20?= =?UTF-8?q?out=20of=20the=20`#id=5Fapplet=5Fgame=5Fkit`=20nest=20(they=20w?= =?UTF-8?q?ere=20scoped=20to=20gameboard's=20Game=20Kit=20applet=20only)?= =?UTF-8?q?=20into=20top-level=20scope=20so=20the=20same=20SCSS=20applies?= =?UTF-8?q?=20in=20the=20kit-bag=20dialog=20context=20too.=20Hover/active/?= =?UTF-8?q?focus=20trigger=20selector=20list=20broadened=20to=20cover=20`.?= =?UTF-8?q?deck-stack-icon`=20itself=20+=20`.token.deck-variant`=20wrapper?= =?UTF-8?q?=20+=20`.kit-bag-deck`=20wrapper.=204=20new=20ITs=20total:=202?= =?UTF-8?q?=20in=20`GameboardViewTest`=20(image-equipped=20Minchiate's=20=20defines=20+=20inline=20fill=20style=20on=20all=203?= =?UTF-8?q?=20rects=20+=20asset=20URL=20ref;=20non-image=20Earthman=20has?= =?UTF-8?q?=20NEITHER=20pattern=20nor=20inline=20fill);=202=20in=20`dashbo?= =?UTF-8?q?ard.KitBagViewTest`=20(kit-bag=20Deck=20section=20renders=20svg?= =?UTF-8?q?.deck-stack-icon=20+=20lacks=20fa-id-badge;=20polarized=20equip?= =?UTF-8?q?ped=20deck=20tooltip=20carries=20.tt-x2=20=E2=80=94=20element-p?= =?UTF-8?q?resence=20assertion=20since=20literal=20"=C3=972"=20character?= =?UTF-8?q?=20had=20encoding=20issues=20in=20the=20dashboard=20test=20file?= =?UTF-8?q?=20vs=20the=20gameboard=20one,=20which=20is=20fine=20since=20th?= =?UTF-8?q?e=20template-side=20rendering=20of=20the=20literal=20=C3=97=20i?= =?UTF-8?q?s=20exercised=20by=20the=20parent=20template).=20Tests:=204=20n?= =?UTF-8?q?ew=20green;=201297/1297=20IT+UT=20total=20green=20(69s;=20+4=20?= =?UTF-8?q?from=20A.4's=201293).=20Visual=20verify=20pending:=20refresh=20?= =?UTF-8?q?/gameboard/=20=E2=86=92=20Minchiate=20icon=20should=20show=203?= =?UTF-8?q?=20stacked=20Minchiate=20card-backs=20at=201.5=C3=97=20size,=20?= =?UTF-8?q?fan=20out=20on=20hover=20w.=20back=20image=20tracking;=20refres?= =?UTF-8?q?h=20kit-bag=20dialog=20=E2=86=92=20same=20icon=20visible=20in?= =?UTF-8?q?=20Deck=20section=20w.=20(=C3=972)=20on=20Earthman=20tooltip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/tests/integrated/test_views.py | 32 ++++++ src/apps/epic/models.py | 14 +++ .../gameboard/tests/integrated/test_views.py | 35 ++++++ src/static_src/scss/_gameboard.scss | 106 ++++++++++-------- .../gameboard/_partials/_deck_stack_icon.html | 46 ++++++-- .../core/_partials/_kit_bag_panel.html | 6 +- 6 files changed, 181 insertions(+), 58 deletions(-) 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 %}