A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit _tarot_fan.html carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually.
(1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing.
(2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics).
(3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use.
(4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`.
(5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title.
Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -770,6 +770,24 @@ class GameKitViewTest(TestCase):
|
||||
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']")
|
||||
self.assertEqual(inp.get("type"), "checkbox")
|
||||
|
||||
def test_fan_stage_block_renders_rank_suit_chip_per_face(self):
|
||||
"""Sprint A.7.5 — `#id_fan_stage_block` (the carousel modal's stat
|
||||
block) gains the same `.stat-face-header` w. rank+suit chip + the
|
||||
`.stat-face-title` + `.stat-face-arcana` slots that the my_sign /
|
||||
sea_stage stat blocks have. Previously only the keyword list was
|
||||
present (text-mode decks carried text on the card face); for image-
|
||||
mode the stat block is the sole home for textual metadata."""
|
||||
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
||||
face = self.parsed.cssselect(f"#id_fan_stage_block .{face_cls}")
|
||||
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
||||
[header] = face[0].cssselect(".stat-face-header")
|
||||
[_chip] = header.cssselect(".stat-face-chip")
|
||||
[_rank] = header.cssselect(".stat-chip-rank")
|
||||
[_icon] = header.cssselect("i.stat-chip-icon")
|
||||
[_label] = header.cssselect(".stat-face-label")
|
||||
[_title] = face[0].cssselect(".stat-face-title")
|
||||
[_arcana] = face[0].cssselect(".stat-face-arcana")
|
||||
|
||||
|
||||
class ToggleGameKitSectionsViewTest(TestCase):
|
||||
def setUp(self):
|
||||
@@ -877,6 +895,75 @@ class TarotFanViewTest(TestCase):
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_text_mode_deck_keeps_text_scaffold(self):
|
||||
"""Sprint A.7.5 — Earthman (has_card_images=False) carousel cards keep
|
||||
the existing `.fan-card-corner` + `.fan-card-face` text scaffold and
|
||||
lack the `.fan-card--image` marker. Pins the text-mode branch as the
|
||||
before-state so the image-mode branch below isn't a regression risk."""
|
||||
from apps.epic.models import TarotCard
|
||||
# Cap at 5 cards to keep the test focused — the deck has 106 cards.
|
||||
TarotCard.objects.filter(deck_variant=self.earthman).exclude(
|
||||
pk__in=TarotCard.objects.filter(deck_variant=self.earthman)[:5]
|
||||
).delete()
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
cards = parsed.cssselect(".fan-card")
|
||||
self.assertGreater(len(cards), 0)
|
||||
for card in cards:
|
||||
self.assertNotIn("fan-card--image", card.get("class", ""))
|
||||
self.assertEqual(len(card.cssselect("img.sig-stage-card-img")), 0)
|
||||
self.assertGreater(len(card.cssselect(".fan-card-corner")), 0)
|
||||
self.assertGreater(len(card.cssselect(".fan-card-face")), 0)
|
||||
|
||||
def test_image_mode_deck_renders_img_per_card_and_drops_text_scaffold(self):
|
||||
"""Sprint A.7.5 — Minchiate (has_card_images=True + non-polarized) cards
|
||||
carry `.fan-card--image` + an `<img.sig-stage-card-img>` per card +
|
||||
a `<img.sig-stage-card-back-img>` per card (since non-polarized; FLIP
|
||||
flips to back). Text scaffold (corners + face) absent server-side."""
|
||||
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(reverse("tarot_fan", kwargs={"deck_id": minchiate.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
cards = parsed.cssselect(".fan-card")
|
||||
# Spot-check the first card; deck has 97 cards.
|
||||
self.assertGreater(len(cards), 0)
|
||||
first = cards[0]
|
||||
self.assertIn("fan-card--image", first.get("class", ""))
|
||||
self.assertEqual(first.get("data-arcana-key"), "MAJOR") # Minchiate trump #0 = Il Matto
|
||||
[img] = first.cssselect("img.sig-stage-card-img")
|
||||
self.assertIn("minchiate-fiorentine-1860-1890", img.get("src", ""))
|
||||
[back_img] = first.cssselect("img.sig-stage-card-back-img")
|
||||
self.assertIn(
|
||||
"minchiate-fiorentine-1860-1890-back.png", back_img.get("src", "")
|
||||
)
|
||||
# Text scaffold absent across the WHOLE response — none of the cards
|
||||
# in image-mode should render corners/face.
|
||||
self.assertEqual(
|
||||
len(parsed.cssselect(".fan-card-corner")), 0,
|
||||
"image-mode cards must not render the text scaffold",
|
||||
)
|
||||
self.assertEqual(len(parsed.cssselect(".fan-card-face")), 0)
|
||||
|
||||
def test_image_mode_polarized_deck_omits_back_img(self):
|
||||
"""Polarized image-equipped deck (none today, but the gate is
|
||||
defensive): FLIP retains its polarity-cycle meaning and no back-img
|
||||
renders. Earthman flipped to has_card_images=True simulates the
|
||||
future state where Earthman art lands."""
|
||||
self.earthman.has_card_images = True
|
||||
self.earthman.save(update_fields=["has_card_images"])
|
||||
response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
cards = parsed.cssselect(".fan-card")
|
||||
self.assertGreater(len(cards), 0)
|
||||
for card in cards:
|
||||
self.assertIn("fan-card--image", card.get("class", ""))
|
||||
self.assertEqual(
|
||||
len(card.cssselect("img.sig-stage-card-back-img")), 0,
|
||||
"Polarized deck must not render the back-image element",
|
||||
)
|
||||
|
||||
|
||||
class MySeaViewTest(TestCase):
|
||||
"""Sprint 3 of the My Sea roadmap — standalone page is a shell only.
|
||||
@@ -905,6 +992,26 @@ class MySeaViewTest(TestCase):
|
||||
self.assertIn("page-gameboard", response.content.decode())
|
||||
self.assertIn("page-my-sea", response.content.decode())
|
||||
|
||||
def test_sea_stage_stat_block_renders_rank_suit_chip_per_face(self):
|
||||
"""Sprint A.7.5 — `_sea_stage.html` modal scaffold (included from
|
||||
my_sea-picker-phase + the gameroom sea overlay) carries the new
|
||||
`.stat-face-header` wrapper w. the rank+suit chip inline w. the
|
||||
EMANATION/REVERSAL label. Both upright + reversed faces have their
|
||||
own chip; stage-card.js populateStatExtras fills both identically
|
||||
on each card focus. Rendered standalone via render_to_string since
|
||||
the partial's parent views are phase-gated."""
|
||||
from django.template.loader import render_to_string
|
||||
html = render_to_string("apps/gameboard/_partials/_sea_stage.html")
|
||||
parsed = lxml.html.fromstring(html)
|
||||
for face_cls in ("stat-face--upright", "stat-face--reversed"):
|
||||
face = parsed.cssselect(f".sea-stat-block .{face_cls}")
|
||||
self.assertEqual(len(face), 1, f"expected one {face_cls}")
|
||||
[header] = face[0].cssselect(".stat-face-header")
|
||||
[chip] = header.cssselect(".stat-face-chip")
|
||||
[_rank] = chip.cssselect(".stat-chip-rank")
|
||||
[_icon] = chip.cssselect("i.stat-chip-icon")
|
||||
[_label] = header.cssselect(".stat-face-label")
|
||||
|
||||
|
||||
class MySeaDrawSeaLandingViewTest(TestCase):
|
||||
"""Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins
|
||||
|
||||
Reference in New Issue
Block a user