From a9ad422b3599f711c0690c772e2980a43837eccf Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 25 May 2026 14:25:41 -0400 Subject: [PATCH] =?UTF-8?q?A.7.5=20Game=20Kit=20carousel=20image-mode=20+?= =?UTF-8?q?=20universal=20stat-block=20top-left=20chip=20+=20EMANATION/REV?= =?UTF-8?q?ERSAL=20--secUser=20convention=20=E2=80=94=20TDD.=20Mid-session?= =?UTF-8?q?=202026-05-25=20PM=20(Sprint=20A.7.5=20of=20[[project-image-bas?= =?UTF-8?q?ed-deck-face-rendering]]=20=E2=80=94=20slotted=20between=20A.7?= =?UTF-8?q?=20polish=20+=20tomorrow's=20A.8=20room.html).=20Three=20thread?= =?UTF-8?q?s=20bundled:=20(1)=20Game=20Kit=20`=5Ftarot=5Ffan.html`=20carou?= =?UTF-8?q?sel=20modal=20gets=20the=20image-mode=20branch=20+=20per-card?= =?UTF-8?q?=20FLIP-to-back=20for=20non-polarized=20image-equipped=20decks?= =?UTF-8?q?=20(Minchiate=20today;=20brings=20the=20carousel=20into=20parit?= =?UTF-8?q?y=20w.=20the=20other=205=20image-mode=20surfaces=20shipped=20in?= =?UTF-8?q?=20A.3-A.7);=20(2)=20the=20A.3=20Q3-spec=20top-left=20rank+suit?= =?UTF-8?q?=20chip=20lands=20across=20all=204=20stat-block=20surfaces=20(m?= =?UTF-8?q?y=5Fsign=20main=20/=20=5Fapplet-my-sign=20/=20=5Fsea=5Fstage=20?= =?UTF-8?q?modal=20/=20new=20game=5Fkit=20fan=20stage),=20retrofitting=20w?= =?UTF-8?q?ork=20that=20A.3=20explicitly=20deferred=20per=20the=20"Lower-p?= =?UTF-8?q?riority=20follow-ups"=20list=20in=20the=20project=20memory;=20(?= =?UTF-8?q?3)=20chip=20+=20EMANATION/REVERSAL=20label=20adopt=20--secUser?= =?UTF-8?q?=20as=20the=20new=20universal=20color=20convention=20so=20the?= =?UTF-8?q?=20title=20(--quaUser/--terUser=20per=20arcana)=20stays=20the?= =?UTF-8?q?=20focal=20text=20+=20the=20chip-and-label=20header=20recedes?= =?UTF-8?q?=20visually.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `` + (for non-polarized decks) a sibling `` 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) --- .../billboard/tests/integrated/test_views.py | 45 +++++++ src/apps/epic/models.py | 8 +- src/apps/epic/static/apps/epic/stage-card.js | 17 +++ src/apps/epic/tests/unit/test_models.py | 6 +- .../static/apps/gameboard/game-kit.js | 44 +++++++ .../gameboard/tests/integrated/test_views.py | 107 ++++++++++++++++ src/static_src/scss/_billboard.scss | 39 +++++- src/static_src/scss/_card-deck.scss | 80 ++++++++++-- .../billboard/_partials/_applet-my-sign.html | 12 +- src/templates/apps/billboard/my_sign.html | 21 +++- .../apps/gameboard/_partials/_sea_stage.html | 20 ++- .../apps/gameboard/_partials/_tarot_fan.html | 116 +++++++++++------- src/templates/apps/gameboard/game_kit.html | 27 +++- 13 files changed, 475 insertions(+), 67 deletions(-) diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py index 1a63643..858d2ad 100644 --- a/src/apps/billboard/tests/integrated/test_views.py +++ b/src/apps/billboard/tests/integrated/test_views.py @@ -906,6 +906,26 @@ class MySignViewTest(TestCase): self.user.refresh_from_db() self.assertIsNone(self.user.significator_id) + def test_stat_block_renders_rank_suit_chip_per_face(self): + """Sprint A.7.5 — `.stat-face-header` wraps the new top-left rank+suit + chip inline w. the EMANATION/REVERSAL label per [[project-image-based- + deck-face-rendering]]'s A.3 Q3 spec. Empty by default (JS-populated by + stage-card.js populateStatExtras on focus); both upright + reversed + faces carry their own chip slot so post-SPIN the chip stays visible.""" + import lxml.html + response = self.client.get(reverse("billboard:my_sign")) + parsed = lxml.html.fromstring(response.content) + for face_cls in ("stat-face--upright", "stat-face--reversed"): + face = parsed.cssselect(f".sig-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") + # The label still lives inside the header (now flex-laid-out + # inline w. the chip rather than as a bare child of the face). + [_label] = header.cssselect(".stat-face-label") + def test_save_sign_get_redirects_back_to_picker(self): response = self.client.get(reverse("billboard:save_sign")) self.assertRedirects(response, reverse("billboard:my_sign")) @@ -1093,3 +1113,28 @@ class BillboardAppletMySignTest(TestCase): len(card_el.cssselect(".fan-card-corner")), 0, "Non-image deck keeps the text scaffold", ) + + def test_applet_stat_block_renders_server_side_chip(self): + """Sprint A.7.5 — applet is read-only so the rank+suit chip is server- + rendered (not JS-populated as on stage / sea_stage / fan stage). Chip + carries the card's corner_rank + suit_icon FA class inline w. the + EMANATION label inside `.stat-face-header`.""" + from apps.epic.models import personal_sig_cards + target = personal_sig_cards(self.user)[0] + self.user.significator = target + self.user.save(update_fields=["significator"]) + import lxml.html + response = self.client.get("/billboard/") + parsed = lxml.html.fromstring(response.content) + [block] = parsed.cssselect(".my-sign-applet-stat-block") + [header] = block.cssselect(".stat-face-header") + [chip] = header.cssselect(".stat-face-chip") + [rank] = chip.cssselect(".stat-chip-rank") + # Court middle cards have single-letter corner ranks (M/J/Q/K) per + # TarotCard.corner_rank — pin presence, not the exact value (which + # depends on which middle court personal_sig_cards returns first). + self.assertTrue(rank.text and rank.text.strip()) + # Middle court has a suit, so the suit-icon `` is present + carries + # the canonical FA class for the suit (fa-wand-sparkles for BRANDS etc). + [icon] = chip.cssselect("i.stat-chip-icon") + self.assertTrue(any(cls.startswith("fa-") for cls in (icon.get("class") or "").split())) diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 937317a..f791c9c 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -488,7 +488,13 @@ class TarotCard(models.Model): if self.icon: return self.icon if self.arcana == self.MAJOR: - return '' + # Sprint A.7.5 — trumps default to fa-hand-dots so the chip (and + # any text-mode corner) always has a symbol below the rank. Per- + # card overrides still win via the `self.icon` branch above (the + # Earthman seed sets `icon="fa-hand-dots"` explicitly for trumps + # 2+, which was the only place this fallback used to live; trumps + # 0/1 + every Minchiate trump now pick it up for free). + return 'fa-hand-dots' return { self.BRANDS: 'fa-wand-sparkles', self.CROWNS: 'fa-crown', diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js index 823cf44..625db0e 100644 --- a/src/apps/epic/static/apps/epic/stage-card.js +++ b/src/apps/epic/static/apps/epic/stage-card.js @@ -273,6 +273,12 @@ var StageCard = (function () { // For text-mode decks the same info is on the card face too — duplicates, // not regressive. For image-mode decks the stat block is the only home // for textual metadata. Caller-side opt-out by passing opts.skipExtras=true. + // + // Sprint A.7.5 — also fills the top-left rank+suit chip (`.stat-chip-rank` + // + `.stat-chip-icon`) on each stat-face header. Chip data reuses the same + // `corner_rank` + `suit_icon` fields populated on the card's corners; for + // image-mode decks where the card has no corners, the chip is the only + // place the rank+suit appears. function populateStatExtras(statBlock, card, opts) { if (!statBlock) return; opts = opts || {}; @@ -287,6 +293,17 @@ var StageCard = (function () { statBlock.querySelectorAll('.stat-face-arcana').forEach(function (el) { el.textContent = arcana; }); + statBlock.querySelectorAll('.stat-chip-rank').forEach(function (el) { + el.textContent = card.corner_rank || ''; + }); + statBlock.querySelectorAll('.stat-chip-icon').forEach(function (el) { + if (card.suit_icon) { + el.className = 'fa-solid ' + card.suit_icon + ' stat-chip-icon'; + el.style.display = ''; + } else { + el.style.display = 'none'; + } + }); // Surface arcana on the stat-block parent so SCSS `[data-arcana-key]` // selectors can color-key the title (--quiUser for minor/middle, // --terUser for major) — mirrors the same hook used on the card. diff --git a/src/apps/epic/tests/unit/test_models.py b/src/apps/epic/tests/unit/test_models.py index ff7d20b..ce339d3 100644 --- a/src/apps/epic/tests/unit/test_models.py +++ b/src/apps/epic/tests/unit/test_models.py @@ -55,8 +55,10 @@ class TarotCardSuitIconTest(SimpleTestCase): def test_major_with_icon_returns_icon(self): self.assertEqual(_card('MAJOR', 0, icon='fa-hat-cowboy-side').suit_icon, 'fa-hat-cowboy-side') - def test_major_without_icon_returns_empty(self): - self.assertEqual(_card('MAJOR', 5).suit_icon, '') + def test_major_without_icon_defaults_to_hand_dots(self): + # Sprint A.7.5 — trump fallback for the stat-block chip + text-mode + # corners. Per-card `icon` still wins (covered by the test above). + self.assertEqual(_card('MAJOR', 5).suit_icon, 'fa-hand-dots') def test_brands_returns_wand_sparkles(self): self.assertEqual(_card('MIDDLE', 11, 'BRANDS').suit_icon, 'fa-wand-sparkles') diff --git a/src/apps/gameboard/static/apps/gameboard/game-kit.js b/src/apps/gameboard/static/apps/gameboard/game-kit.js index 3eff346..88c7dcf 100644 --- a/src/apps/gameboard/static/apps/gameboard/game-kit.js +++ b/src/apps/gameboard/static/apps/gameboard/game-kit.js @@ -120,6 +120,11 @@ var GameKit = (function () { uprightSel: '#id_fan_stat_upright', reversedSel: '#id_fan_stat_reversed', }); + // Sprint A.7.5 — fill the title + arcana + top-left rank+suit chip + // fields per the new image-mode stat-block shape. The carousel stage + // block previously had only the keyword list; for image-mode decks + // the stat block is the only home for textual metadata. + StageCard.populateStatExtras(stageBlock, card); _infoData = StageCard.buildInfoData(card); _infoIdx = 0; @@ -127,6 +132,10 @@ var GameKit = (function () { // so a previously-reversed card returns to upright when it leaves focus. stageBlock.classList.remove('is-reversed'); cards.forEach(function (c) { c.classList.remove('stage-card--reversed'); }); + // Sprint A.7.5 — also clear FLIP-to-back state on card change so a + // back-flipped non-polarized card returns to its front when it leaves + // focus (mirrors the SPIN reset semantics). + cards.forEach(function (c) { c.classList.remove('is-flipped-to-back'); }); _closeFyi(); } @@ -138,6 +147,16 @@ var GameKit = (function () { var active = cards[currentIndex]; if (!active) return; if (active.dataset.flipping) return; // mid-flip + + // Sprint A.7.5 — non-polarized image-equipped decks (Minchiate today) + // render a `.sig-stage-card-back-img` child server-side per the my_sign + // pattern. Presence of that child = FLIP toggles back-image instead of + // polarity cycle (which has no meaning for non-polarized decks). + if (active.querySelector('.sig-stage-card-back-img')) { + _flipToBack(active); + return; + } + active.dataset.flipping = '1'; // Build the resting transform (carousel offset 0 + optional SPIN rotate(180)) @@ -169,6 +188,25 @@ var GameKit = (function () { setTimeout(function () { delete active.dataset.flipping; }, 500); } + // Sprint A.7.5 — FLIP-to-back animation for non-polarized image-equipped + // decks. Mirrors my_sign.html / _applet-my-sign.html FLIP-to-back: rotateY + // 0→90→0 over 500ms, toggle `.is-flipped-to-back` at the halfway point, + // clear `data-flipping` at end. SCSS hides/shows the front + back img via + // the same `.is-flipped-to-back` class used by the my_sign surface. + function _flipToBack(active) { + active.dataset.flipping = '1'; + var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : ''; + var rest = 'translateX(0px) rotateY(0deg) scale(1)' + spin; + var mid = 'translateX(0px) rotateY(0deg) scale(1)' + spin + ' rotateY(90deg)'; + active.animate([ + { transform: rest }, + { transform: mid, offset: 0.5 }, + { transform: rest }, + ], { duration: 500, easing: 'ease' }); + setTimeout(function () { active.classList.toggle('is-flipped-to-back'); }, 250); + setTimeout(function () { delete active.dataset.flipping; }, 500); + } + // ── FYI panel ───────────────────────────────────────────────────────────── function _renderFyi() { @@ -351,6 +389,12 @@ var GameKit = (function () { stageBlock.classList.toggle('is-reversed'); var active = cards[currentIndex]; if (!active) return; + // Sprint A.7.5 — image-mode cards don't rotate 180° on SPIN. + // The artwork is the canonical card; spinning it would invert + // the image visually w. no equivalent text-content swap (the + // text scaffold isn't rendered in image-mode). Stat block still + // flips via `.is-reversed` above to swap EMANATION ↔ REVERSAL. + if (active.classList.contains('fan-card--image')) return; active.classList.toggle('stage-card--reversed'); var t = cardTransform(0); var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : ''; diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index b88f353..6f99100 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -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 `` per card + + a `` 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 diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss index bab80f4..b53717a 100644 --- a/src/static_src/scss/_billboard.scss +++ b/src/static_src/scss/_billboard.scss @@ -624,12 +624,44 @@ body.page-billposts { text-transform: uppercase; letter-spacing: 0.09em; opacity: 0.7; - color: rgba(var(--terUser), 1); + // Sprint A.7.5 user-spec 2026-05-25 PM — applet sets the convention: + // EMANATION/REVERSAL label is --secUser so it recedes against the + // title (--quaUser/--terUser per arcana). Shared mixin + the other + // 3 stat-block surfaces (sig-stat-block, sea-stat-block, fan-stage- + // block) updated to match. + color: rgba(var(--secUser), 1); margin: 0 0 calc(var(--applet-card-w) * 0.06); text-decoration: underline; text-underline-offset: 0.15em; } + // Sprint A.7.5 — same shape as the shared mixin's `.stat-face-header` + // + `.stat-face-chip` but sized off the applet's `--applet-card-w` + // container-query var rather than `--sig-card-w`. Chip mirrors the + // `.fan-card-corner` pattern (vertically stacked rank + suit-icon, + // --secUser, no chrome) per user-spec 2026-05-25 PM. + .stat-face-header { + display: flex; + align-items: flex-start; + gap: calc(var(--applet-card-w) * 0.06); + margin: 0 0 calc(var(--applet-card-w) * 0.06); + + .stat-face-label { margin: 0; } + } + .stat-face-chip { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: calc(var(--applet-card-w) * 0.012); + line-height: 1; + color: rgba(var(--secUser), 1); + font-weight: bold; + + .stat-chip-rank { font-size: calc(var(--applet-card-w) * 0.105); } + i { font-size: calc(var(--applet-card-w) * 0.105); align-self: flex-start; } + .stat-chip-rank:empty { display: none; } + } + // Sprint A.7-polish-3 — title + arcana in applet stat-block per // user spec 2026-05-25 PM. Title color keys off the parent's // `data-arcana-key` (rendered server-side from `card.arcana`). @@ -678,7 +710,10 @@ body.page-billposts { .my-sign-applet-body[data-polarity="gravity"] .my-sign-applet-stat-block { background: rgba(var(--priUser), 1); border-color: rgba(var(--secUser), 0.15); - .stat-face-label { color: rgba(var(--quiUser), 1); } + // Sprint A.7.5 — label override removed; bg under gravity is still + // --priUser (just full alpha vs. the default 0.8), so the shared + // --secUser label is readable in both polarities + matches the new + // applet convention end-to-end. .stat-keywords li { color: rgba(var(--secUser), 1); border-bottom-color: rgba(var(--secUser), 0.18); diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 83fae56..d54c737 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -71,7 +71,12 @@ text-transform: uppercase; letter-spacing: 0.09em; opacity: 0.7; - color: rgba(var(--terUser), 1); + // Sprint A.7.5 user-spec 2026-05-25 PM — label color flipped from + // --terUser to --secUser so EMANATION/REVERSAL recedes visually and + // lets the title stay the focal text. Gravity-polarity overrides + // below still flip to --quiUser since the gravity stat-block bg is + // --secUser (a --secUser label would be invisible). + color: rgba(var(--secUser), 1); margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); // Sprint A.7-polish-3 — underline per user spec 2026-05-25 PM // (the original A.3 Q3 lock referred to underlined Emanation / @@ -82,6 +87,48 @@ text-underline-offset: 0.15em; } + // Sprint A.7.5 — `.stat-face-header` is the flex wrapper holding the + // new top-left rank+suit chip inline w. the EMANATION/REVERSAL label. + // Per [[project-image-based-deck-face-rendering]]'s A.3 Q3 spec the + // chip is the chosen home for rank+suit on image-mode decks (where + // the card itself has no corners). On text-mode decks the chip is a + // benign duplicate of the corner rank+suit — won't bite until we + // delete the text-mode rendering entirely. User-spec 2026-05-25 PM: + // mirror the `.fan-card-corner` pattern (vertically stacked rank + + // icon, --secUser, no bg/border) rather than the original bordered- + // pill draft. The label sits inline-right of the chip, top-aligned + // (chip is 2 rows tall, label is 1 — "vaguely inline" per spec). + .stat-face-header { + display: flex; + align-items: flex-start; + gap: calc(var(--sig-card-w, 120px) * 0.05); + margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); + + .stat-face-label { margin: 0; } // header owns the bottom margin now + } + + .stat-face-chip { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: calc(var(--sig-card-w, 120px) * 0.012); + line-height: 1; + color: rgba(var(--secUser), 1); + font-weight: bold; + + .stat-chip-rank { + font-size: calc(var(--sig-card-w, 120px) * 0.092); + } + i { + font-size: calc(var(--sig-card-w, 120px) * 0.092); + align-self: flex-start; + } + + // Empty rank (JS-populated surfaces before first paint) — collapse + // the rank line so the chip doesn't leave a stray empty row. + .stat-chip-rank:empty { display: none; } + } + // Sprint A.7-polish-3 — title + arcana fields per locked Q3 spec. // Title color keys off the stat-block's `data-arcana-key` attr (set by // stage-card.js populateStatExtras OR server-side in the applet partial): @@ -311,6 +358,11 @@ border-color: rgba(var(--priUser), 0.15); color: rgba(var(--priUser), 1); .stat-face-label { color: rgba(var(--quiUser), 1); } + // Sprint A.7.5 — chip uses --secUser by default; under gravity the + // stat-block bg IS --secUser, so the chip would be invisible. Flip + // to --priUser to stay on the opposite-polarity side per the + // [[feedback-card-polarity-convention]]. + .stat-face-chip { color: rgba(var(--priUser), 1); } .stat-keywords li { color: rgba(var(--priUser), 1); border-bottom-color: rgba(var(--priUser), 0.18); @@ -320,7 +372,11 @@ background: rgba(var(--priUser), 1); border-color: rgba(var(--terUser), 0.15); color: rgba(var(--secUser), 1); - .stat-face-label { color: rgba(var(--terUser), 1); } + // Sprint A.7.5 — label drops to --secUser to match the new applet + // convention. Was --terUser; --secUser still has comfortable contrast + // against the --priUser bg + lets the title (--quaUser/--terUser per + // arcana) stay the focal text. + .stat-face-label { color: rgba(var(--secUser), 1); } .stat-keywords li { color: rgba(var(--quiUser), 1); border-bottom-color: rgba(var(--terUser), 0.18); @@ -705,7 +761,8 @@ html:has(.sig-backdrop) { .sig-stage-card.sig-stage-card--image, .my-sign-applet-card.my-sign-applet-card--image, .my-sea-slot.my-sea-slot--image, -.sea-card-slot.sea-card-slot--image { +.sea-card-slot.sea-card-slot--image, +.fan-card.fan-card--image { --img-stroke-color: rgba(var(--quiUser), 1); background: transparent; border: 0; @@ -1176,10 +1233,10 @@ html:has(.sig-backdrop) { .sig-stage-card .fan-card-reversal-qualifier, .sig-stage-card .sig-qualifier-above, .sig-stage-card .sig-qualifier-below { color: rgba(var(--quiUser), 1); } - // Stat-face label: levity stat-block bg is --priUser (opposite of levity card's - // --secUser bg), so the label takes the gravity-card text color (--terUser) to - // stay legible against the dark stat-block. - .sig-stat-block .stat-face-label { color: rgba(var(--terUser), 1); } + // Stat-face label: levity stat-block bg is --priUser. Per A.7.5 user-spec + // 2026-05-25 PM the label uses --secUser (was --terUser) so EMANATION / + // REVERSAL recedes against the title — same convention as the applet. + .sig-stat-block .stat-face-label { color: rgba(var(--secUser), 1); } // Upright + reversal title glow — levity. Drop-shadow is WHITE here (was 0,0,0 // at 0.55) because the inverted-frame levity card uses a light --secUser bg, // so a dark drop shadow reads as harsh smudge under the --quiUser title text. @@ -1218,6 +1275,9 @@ html:has(.sig-backdrop) { // --priUser bg), so the label takes the levity-card text color (--quiUser) to // stay legible against the lighter stat-block. .sig-stat-block .stat-face-label { color: rgba(var(--quiUser), 1); } + // Sprint A.7.5 — chip flips to --priUser under gravity (default --secUser + // would be invisible on the --secUser stat-block bg). + .sig-stat-block .stat-face-chip { color: rgba(var(--priUser), 1); } // Upright + reversal title glow — gravity .sig-stage-card .fan-card-name, .sig-stage-card .sig-qualifier-above, @@ -2089,6 +2149,8 @@ $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .f background: rgba(var(--secUser), 0.85); border-color: rgba(var(--priUser), 0.15); .stat-face-label { color: rgba(var(--quiUser), 1); } + // Sprint A.7.5 — chip flips to --priUser under gravity (bg is --secUser). + .stat-face-chip { color: rgba(var(--priUser), 1); } .stat-keywords li { color: rgba(var(--priUser), 1); border-bottom-color: rgba(var(--priUser), 0.18); @@ -2097,7 +2159,9 @@ $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .f .sea-stage--levity .sea-stat-block { background: rgba(var(--priUser), 0.85); border-color: rgba(var(--terUser), 0.15); - .stat-face-label { color: rgba(var(--terUser), 1); } + // Sprint A.7.5 — label flipped to --secUser (was --terUser) to match + // the new applet convention. --secUser still contrasts well w. --priUser bg. + .stat-face-label { color: rgba(var(--secUser), 1); } .stat-keywords li { color: rgba(var(--quiUser), 1); border-bottom-color: rgba(var(--terUser), 0.18); diff --git a/src/templates/apps/billboard/_partials/_applet-my-sign.html b/src/templates/apps/billboard/_partials/_applet-my-sign.html index 7dc8d6f..4a5fed0 100644 --- a/src/templates/apps/billboard/_partials/_applet-my-sign.html +++ b/src/templates/apps/billboard/_partials/_applet-my-sign.html @@ -74,7 +74,17 @@ {# only the polarity axis (FLIP), never the orientation axis #} {# (SPIN), so always render the upright/emanation face. #}
-

Emanation

+ {# Sprint A.7.5 — `.stat-face-header` wraps the rank+suit chip #} + {# inline w. EMANATION per [[project-image-based-deck-face- #} + {# rendering]]'s A.3 Q3 spec. Server-rendered (read-only #} + {# applet — no JS populate path). #} +
+ + {{ card.corner_rank }} + {% if card.suit_icon %}{% endif %} + +

Emanation

+

{{ card.name }}

{{ card.get_arcana_display }}

    diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html index c99f4cb..556047a 100644 --- a/src/templates/apps/billboard/my_sign.html +++ b/src/templates/apps/billboard/my_sign.html @@ -83,14 +83,31 @@ {# `.stat-face-title` + `.stat-face-arcana` empty by default, #} {# populated by stage-card.js `populateStatBlock` from the card #} {# data flow on focus / save. #} + {# Sprint A.7.5 — `.stat-face-header` wraps the new top-left #} + {# rank+suit chip inline w. the underlined EMANATION/REVERSAL #} + {# label per [[project-image-based-deck-face-rendering]]'s A.3 #} + {# Q3 spec. Chip elements empty by default; stage-card.js's #} + {# populateStatExtras fills `.stat-chip-rank` + `.stat-chip-icon`.#}
    -

    Emanation

    +
    + + + + +

    Emanation

    +

      -

      Reversal

      +
      + + + + +

      Reversal

      +

        diff --git a/src/templates/apps/gameboard/_partials/_sea_stage.html b/src/templates/apps/gameboard/_partials/_sea_stage.html index 0f6fb68..aa35547 100644 --- a/src/templates/apps/gameboard/_partials/_sea_stage.html +++ b/src/templates/apps/gameboard/_partials/_sea_stage.html @@ -45,14 +45,30 @@
        + {# Sprint A.7.5 — `.stat-face-header` wraps the rank+suit chip #} + {# inline w. EMANATION/REVERSAL per [[project-image-based-deck- #} + {# face-rendering]]'s A.3 Q3 spec. Chip empty by default; stage- #} + {# card.js populateStatExtras fills both faces' chips identically.#}
        -

        Emanation

        +
        + + + + +

        Emanation

        +

          -

          Reversal

          +
          + + + + +

          Reversal

          +

            diff --git a/src/templates/apps/gameboard/_partials/_tarot_fan.html b/src/templates/apps/gameboard/_partials/_tarot_fan.html index 8a5ee57..472bca9 100644 --- a/src/templates/apps/gameboard/_partials/_tarot_fan.html +++ b/src/templates/apps/gameboard/_partials/_tarot_fan.html @@ -1,12 +1,13 @@ {% load tarot_filters %} {% for card in cards %} -
            -
            - {{ card.corner_rank }} - {% if card.suit_icon %}{% endif %} -
            -
            -
            - {% if card.gravity_emanation %} - {# Polarity-split title (cards 48-49 + trumps 19-21); no qualifier slots — qualifier is baked into the title between "The" and the proper noun #} -

            {{ card.gravity_emanation|italicize:card.italic_word }}

            - {% else %} - {% if card.name_group %}

            {{ card.name_group }}

            {% endif %} - {% if card.arcana != "MAJOR" and card.gravity_qualifier %} -

            {{ card.gravity_qualifier }}

            - {% endif %} -

            {{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.gravity_qualifier %},{% endif %}

            - {% if card.arcana == "MAJOR" and card.gravity_qualifier %} -

            {{ card.gravity_qualifier }}

            - {% endif %} - {% endif %} + data-italic-word="{{ card.italic_word }}" + data-image-url="{{ card.image_url|default:'' }}"> + {% if card.deck_variant.has_card_images %} + {# Sprint A.7.5 — image-mode card face. The image IS the card; the #} + {# adjacent stat block (in #id_fan_stage_block) is the sole home for #} + {# textual metadata (chip, EMANATION/REVERSAL header, title, arcana, #} + {# keywords). For non-polarized image-equipped decks the FLIP btn #} + {# flips this card to its back-image (mirrors my_sign.html's A.5- #} + {# polish-2 pattern). The back-img defaults to display:none via CSS; #} + {# `.fan-card.is-flipped-to-back` toggles visibility. #} + {{ card.name_title }} + {% if not card.deck_variant.is_polarized %} + + {% endif %} + {% else %} + {# Text-mode (Earthman + RWS today): existing corner + face scaffold #} + {# unchanged from pre-A.7.5. Will be removed once both decks have #} + {# images (user's plan: scrape RWS art today; Earthman public-domain #} + {# paintings to follow). "Shabby cardstock" non-equippable Earthman #} + {# variant will retain this text rendering as a legacy preservation. #} +
            + {{ card.corner_rank }} + {% if card.suit_icon %}{% endif %}
            -

            {{ card.get_arcana_display }}

            -
            - {% comment %} - Class names always match semantic content: qualifier text in - .fan-card-reversal-qualifier, title text in .fan-card-reversal-name. - DOM order is per-arcana, controlling visual layout after the 180° - SPIN rotation (DOM-second appears visually on top): - Major / polarity-split — title on top → name class is DOM-second - Non-major — qualifier on top → qualifier class is DOM-second - {% endcomment %} - {% if card.gravity_reversal %} - {# Polarity-split: single-line title in the name slot, qualifier slot empty. #} -

            -

            {{ card.gravity_reversal|italicize:card.italic_word }}

            - {% elif card.arcana == "MAJOR" %} -

            {{ card.gravity_qualifier|default:card.levity_qualifier }}

            -

            {{ card.name_title|italicize:card.italic_word }}{% if card.gravity_qualifier %},{% endif %}

            - {% else %} -

            {{ card.name_title|italicize:card.italic_word }}

            -

            {{ card.reversal_qualifier|default:card.gravity_qualifier }}

            - {% endif %} +
            +
            + {% if card.gravity_emanation %} + {# Polarity-split title (cards 48-49 + trumps 19-21); no qualifier slots — qualifier is baked into the title between "The" and the proper noun #} +

            {{ card.gravity_emanation|italicize:card.italic_word }}

            + {% else %} + {% if card.name_group %}

            {{ card.name_group }}

            {% endif %} + {% if card.arcana != "MAJOR" and card.gravity_qualifier %} +

            {{ card.gravity_qualifier }}

            + {% endif %} +

            {{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.gravity_qualifier %},{% endif %}

            + {% if card.arcana == "MAJOR" and card.gravity_qualifier %} +

            {{ card.gravity_qualifier }}

            + {% endif %} + {% endif %} +
            +

            {{ card.get_arcana_display }}

            +
            + {% comment %} + Class names always match semantic content: qualifier text in + .fan-card-reversal-qualifier, title text in .fan-card-reversal-name. + DOM order is per-arcana, controlling visual layout after the 180° + SPIN rotation (DOM-second appears visually on top): + Major / polarity-split — title on top → name class is DOM-second + Non-major — qualifier on top → qualifier class is DOM-second + {% endcomment %} + {% if card.gravity_reversal %} + {# Polarity-split: single-line title in the name slot, qualifier slot empty. #} +

            +

            {{ card.gravity_reversal|italicize:card.italic_word }}

            + {% elif card.arcana == "MAJOR" %} +

            {{ card.gravity_qualifier|default:card.levity_qualifier }}

            +

            {{ card.name_title|italicize:card.italic_word }}{% if card.gravity_qualifier %},{% endif %}

            + {% else %} +

            {{ card.name_title|italicize:card.italic_word }}

            +

            {{ card.reversal_qualifier|default:card.gravity_qualifier }}

            + {% endif %} +
            -
            -
            - {{ card.corner_rank }} - {% if card.suit_icon %}{% endif %} -
            +
            + {{ card.corner_rank }} + {% if card.suit_icon %}{% endif %} +
            + {% endif %}
            {% endfor %} diff --git a/src/templates/apps/gameboard/game_kit.html b/src/templates/apps/gameboard/game_kit.html index c0d5fad..426e0d4 100644 --- a/src/templates/apps/gameboard/game_kit.html +++ b/src/templates/apps/gameboard/game_kit.html @@ -21,12 +21,35 @@
            + {# Sprint A.7.5 — match the my_sign / sea_stage stat-block shape: #} + {# `.stat-face-header` w. chip + EMANATION/REVERSAL label, then #} + {# `.stat-face-title` + `.stat-face-arcana` (the carousel stat #} + {# block previously had only the keyword list; for image-mode #} + {# decks the stat block is the only home for textual metadata). #} + {# All four — chip rank, chip icon, title, arcana — populated by #} + {# stage-card.js's populateStatExtras on each card focus change. #}
            -

            Emanation

            +
            + + + + +

            Emanation

            +
            +

            +

              -

              Reversal

              +
              + + + + +

              Reversal

              +
              +

              +

                {% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_fan_fyi_panel" panel_extra_attrs='style="display:none"' %}