diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index 5a68a49..3224e43 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -102,7 +102,7 @@ var SigSelect = (function () { // (unified w. my_sign 2026-06-03) — previously the sig stat-block was a // reduced label-only copy, so this call was absent and those fields // stayed blank. - StageCard.populateStatExtras(statBlock, card); + StageCard.populateStatExtras(statBlock, card, { polarity: userPolarity }); stageCard.style.display = ''; stage.classList.add('sig-stage--active'); diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js index 625db0e..24a2aa8 100644 --- a/src/apps/epic/static/apps/epic/stage-card.js +++ b/src/apps/epic/static/apps/epic/stage-card.js @@ -287,8 +287,38 @@ var StageCard = (function () { var rawTitle = card.name_title || card.name || ''; var title = rawTitle.split(',')[0].trim(); var arcana = _arcanaDisplay(card); + var isMajor = _isMajor(card); + + // Polarity qualifier (Elevated/Enlightened/Graven) on the stat-block — + // the always-visible home now that image-mode decks hide the text card + // face. Mirrors populateCard's above/below placement: non-major above + // the title, major below it (title carries a trailing comma so the + // qualifier reads as a subtitle). Only filled when a polarity is + // supplied (sig / my_sign / sea); the kit fan omits it. Cards 48-49 + + // trumps 19-21 carry a polarity-split emanation override whose title + // already incorporates the qualifier inline → no separate slot. + var polarity = opts.polarity || ''; + var qualifier = polarity === 'levity' ? (card.levity_qualifier || '') + : polarity === 'gravity' ? (card.gravity_qualifier || '') + : ''; + var emanationOverride = polarity === 'levity' ? (card.levity_emanation || '') + : polarity === 'gravity' ? (card.gravity_emanation || '') + : ''; + if (emanationOverride) { + title = emanationOverride; + qualifier = ''; + } + var qAbove = qualifier && !isMajor ? qualifier : ''; + var qBelow = qualifier && isMajor ? qualifier : ''; + statBlock.querySelectorAll('.stat-face-title').forEach(function (el) { - el.textContent = title; + el.textContent = qBelow ? title + ',' : title; + }); + statBlock.querySelectorAll('.stat-face-qualifier--above').forEach(function (el) { + el.textContent = qAbove; + }); + statBlock.querySelectorAll('.stat-face-qualifier--below').forEach(function (el) { + el.textContent = qBelow; }); statBlock.querySelectorAll('.stat-face-arcana').forEach(function (el) { el.textContent = arcana; diff --git a/src/functional_tests/test_game_room_select_sig.py b/src/functional_tests/test_game_room_select_sig.py index 8c9a95e..ec795be 100644 --- a/src/functional_tests/test_game_room_select_sig.py +++ b/src/functional_tests/test_game_room_select_sig.py @@ -1,5 +1,4 @@ import os -from unittest import skip from django.conf import settings as django_settings from django.test import tag @@ -256,71 +255,80 @@ class SigSelectThemeTest(FunctionalTest): ActionChains(self.browser).move_to_element(card).perform() return card + def _select_card_and_freeze(self, css): + """Click a card → OK → wait for the stage to freeze. The stat-block is + `display:none` until `.sig-stage--frozen` (only the card preview shows + on hover); OK'ing a card reveals it. The qualifier lives on the frozen + stat-block now (the hover-only text card-face is going away as decks + gain images), so assertions need the frozen state.""" + card = self.browser.find_element(By.CSS_SELECTOR, css) + card.click() + ok = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-focused .sig-ok-btn") + ) + ok.click() + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-stage--frozen")) + # ── ST1: Levity (Leavened) qualifier ──────────────────────────────────── # - @skip( - "Qualifier (Elevated/Enlightened/Graven) now belongs on the always-" - "visible stat-block, not the card face: the Earthman deck renders in " - "image-mode (has_card_images defaults True) so the text card-face is " - "display:none. Unskip + repoint to the stat-block next." - ) + # Qualifier asserts target the frozen stat-block (.stat-face--upright), not + # the text card-face: image-mode decks hide .fan-card-face, and the qualifier + # is moving to the always-present stat-block. It mirrors the card-face + # above/below placement — non-major puts the qualifier ABOVE the title, major + # BELOW it. See stage-card.js populateStatExtras + _stat_face.html. + UPRIGHT_QUAL_ABOVE = ".sig-stat-block .stat-face--upright .stat-face-qualifier--above" + UPRIGHT_QUAL_BELOW = ".sig-stat-block .stat-face--upright .stat-face-qualifier--below" + def test_levity_non_major_card_shows_elevated_above(self): - """Hovering a non-major card in the levity overlay shows 'Elevated' in - qualifier-above and nothing in qualifier-below.""" + """Selecting (OK) a non-major card shows 'Elevated' in the frozen + stat-block qualifier-above and nothing in qualifier-below.""" room = self._setup_sig_room() self.create_pre_authenticated_session("founder@test.io") # PC = levity self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) - self._hover_card('.sig-card[data-arcana="Middle Arcana"]') + self._select_card_and_freeze('.sig-card[data-arcana="Middle Arcana"]') - above = self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above") - ) - self.assertEqual(above.text, "Elevated") - below = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below") + self.wait_for(lambda: self.assertEqual( + self.browser.find_element(By.CSS_SELECTOR, self.UPRIGHT_QUAL_ABOVE).text, + "Elevated", + )) + below = self.browser.find_element(By.CSS_SELECTOR, self.UPRIGHT_QUAL_BELOW) self.assertEqual(below.text, "") - @skip( - "Qualifier now belongs on the always-visible stat-block, not the card " - "face (image-mode hides .fan-card-face). Unskip + repoint next." - ) def test_levity_major_card_shows_enlightened_below(self): - """Hovering a major arcana card in the levity overlay shows 'Enlightened' in - qualifier-below and nothing in qualifier-above.""" + """Selecting (OK) a major arcana card shows 'Enlightened' in the frozen + stat-block qualifier-below and nothing in qualifier-above.""" room = self._setup_sig_room() self.create_pre_authenticated_session("founder@test.io") # PC = levity self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) - self._hover_card('.sig-card[data-arcana="Major Arcana"]') + self._select_card_and_freeze('.sig-card[data-arcana="Major Arcana"]') - below = self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below") - ) - self.assertEqual(below.text, "Enlightened") - above = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above") + self.wait_for(lambda: self.assertEqual( + self.browser.find_element(By.CSS_SELECTOR, self.UPRIGHT_QUAL_BELOW).text, + "Enlightened", + )) + above = self.browser.find_element(By.CSS_SELECTOR, self.UPRIGHT_QUAL_ABOVE) self.assertEqual(above.text, "") # ── ST2: Gravity (Graven) qualifier ───────────────────────────────────── # - @skip( - "Qualifier now belongs on the always-visible stat-block, not the card " - "face (image-mode hides .fan-card-face). Unskip + repoint next." - ) def test_gravity_non_major_card_shows_graven_above(self): - """EC (bud) sees the gravity overlay; hovering a non-major card shows 'Graven'.""" + """EC (bud) sees the gravity overlay; selecting (OK) a non-major card + shows 'Graven' in the frozen stat-block qualifier-above.""" room = self._setup_sig_room() self.create_pre_authenticated_session("bud@test.io") # EC = gravity self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) - self._hover_card('.sig-card[data-arcana="Middle Arcana"]') + self._select_card_and_freeze('.sig-card[data-arcana="Middle Arcana"]') - above = self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above") - ) - self.assertEqual(above.text, "Graven") + self.wait_for(lambda: self.assertEqual( + self.browser.find_element(By.CSS_SELECTOR, self.UPRIGHT_QUAL_ABOVE).text, + "Graven", + )) # ── ST3: Correspondence not shown ─────────────────────────────────────── # diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index 330de47..d24cea3 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -34,10 +34,18 @@ describe("SigSelect", () => {

Emanation

+

+

+

+

Reversal

+

+

+

+

@@ -742,6 +750,51 @@ describe("SigSelect", () => { }); }); + // ── Polarity theming — stat-block qualifier ────────────────────────────── // + // + // The qualifier's always-visible home: image-mode decks hide the text card + // face, so the stat-block (.stat-face--upright) carries Elevated/Enlightened/ + // Graven now. Mirrors the card-face above/below placement (non-major above + // the title, major below it w. a trailing-comma title). + + describe("polarity theming — stat-block qualifier", () => { + const UPRIGHT = ".stat-face--upright"; + + it("levity non-major puts qualifier ABOVE the stat-block title, below empty", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); // fixture card = Minor Arcana + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--above").textContent).toBe("Elevated"); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--below").textContent).toBe(""); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-title").textContent).toBe("King of Pentacles"); + }); + + it("levity major puts qualifier BELOW the stat-block title, above empty, title gets comma", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--above").textContent).toBe(""); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--below").textContent).toBe("Elevated"); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-title").textContent).toBe("King of Pentacles,"); + }); + + it("gravity non-major puts 'Graven' ABOVE the stat-block title", () => { + makeFixture({ polarity: 'gravity', userRole: 'BC' }); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--above").textContent).toBe("Graven"); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--below").textContent).toBe(""); + }); + + it("re-hovering a different arcana re-places the stat-block qualifier slot", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); // non-major → above + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); // major → below + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--above").textContent).toBe(""); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--below").textContent).toBe("Elevated"); + }); + }); + // ── WAIT NVM glow pulse ────────────────────────────────────────────────────── // describe("WAIT NVM glow pulse", () => { diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 4a8f610..e233c98 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -176,7 +176,12 @@ // the rest are --quiUser. Let's change the latter to match the // former" — applet's `.stat-face-title` was already --quaUser; // shared mixin now matches so all 4 stat-block surfaces unify). - .stat-face-title { + // Title + polarity qualifier share one style (user-spec 2026-06-03 — + // "'Elevated' needs to be the same style as 'Jack of Crowns' below it"). + // Qualifier is the subtitle hugging the title (above for non-major, below + // for major); both pick up the arcana-keyed color override below. + .stat-face-title, + .stat-face-qualifier { font-size: calc(var(--sig-card-w, 120px) * 0.105); font-weight: 700; line-height: 1.15; @@ -184,7 +189,8 @@ text-wrap: balance; color: rgba(var(--quaUser), 1); } - [data-arcana-key="MAJOR"] .stat-face-title { + [data-arcana-key="MAJOR"] .stat-face-title, + [data-arcana-key="MAJOR"] .stat-face-qualifier { color: rgba(var(--terUser), 1); } @@ -195,10 +201,15 @@ opacity: 0.6; margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); } - // `:empty` rule hides title + arcana when stage-card.js hasn't populated - // them yet (rest state) — prevents zero-height paragraphs from inflating - // the stat block vertical layout. + // Qualifier hugs the title — tighten the gap on the side that touches it + // (below the above-slot, above the below-slot) so the two read as one unit. + .stat-face-qualifier--above { margin-bottom: calc(var(--sig-card-w, 120px) * 0.01); } + .stat-face-qualifier--below { margin-top: calc(var(--sig-card-w, 120px) * -0.01); } + // `:empty` rule hides title + arcana + qualifier slots when stage-card.js + // hasn't populated them yet (rest state) — prevents zero-height paragraphs + // from inflating the stat block vertical layout. .stat-face-title:empty, + .stat-face-qualifier:empty, .stat-face-arcana:empty { display: none; } .stat-keywords { diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index 330de47..d24cea3 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -34,10 +34,18 @@ describe("SigSelect", () => {

Emanation

+

+

+

+

Reversal

+

+

+

+

@@ -742,6 +750,51 @@ describe("SigSelect", () => { }); }); + // ── Polarity theming — stat-block qualifier ────────────────────────────── // + // + // The qualifier's always-visible home: image-mode decks hide the text card + // face, so the stat-block (.stat-face--upright) carries Elevated/Enlightened/ + // Graven now. Mirrors the card-face above/below placement (non-major above + // the title, major below it w. a trailing-comma title). + + describe("polarity theming — stat-block qualifier", () => { + const UPRIGHT = ".stat-face--upright"; + + it("levity non-major puts qualifier ABOVE the stat-block title, below empty", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); // fixture card = Minor Arcana + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--above").textContent).toBe("Elevated"); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--below").textContent).toBe(""); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-title").textContent).toBe("King of Pentacles"); + }); + + it("levity major puts qualifier BELOW the stat-block title, above empty, title gets comma", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--above").textContent).toBe(""); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--below").textContent).toBe("Elevated"); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-title").textContent).toBe("King of Pentacles,"); + }); + + it("gravity non-major puts 'Graven' ABOVE the stat-block title", () => { + makeFixture({ polarity: 'gravity', userRole: 'BC' }); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--above").textContent).toBe("Graven"); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--below").textContent).toBe(""); + }); + + it("re-hovering a different arcana re-places the stat-block qualifier slot", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); // non-major → above + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); // major → below + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--above").textContent).toBe(""); + expect(statBlock.querySelector(UPRIGHT + " .stat-face-qualifier--below").textContent).toBe("Elevated"); + }); + }); + // ── WAIT NVM glow pulse ────────────────────────────────────────────────────── // describe("WAIT NVM glow pulse", () => { diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html index 9ad7bd8..00fd452 100644 --- a/src/templates/apps/billboard/my_sign.html +++ b/src/templates/apps/billboard/my_sign.html @@ -238,7 +238,7 @@ this billboard surface re-brands to "Sign". StageCard.populateCard(stageCard, _currentCard, _polarity()); StageCard.populateKeywords(statBlock, _currentCard.keywords_upright, _currentCard.keywords_reversed); - StageCard.populateStatExtras(statBlock, _currentCard); + StageCard.populateStatExtras(statBlock, _currentCard, { polarity: _polarity() }); _fyiData = StageCard.buildInfoData(_currentCard); _fyiIdx = 0; if (fyiPanel) StageCard.renderFyi(fyiPanel, _fyiData, _fyiIdx); diff --git a/src/templates/core/_partials/_stat_face.html b/src/templates/core/_partials/_stat_face.html index b3686f9..cf1e22d 100644 --- a/src/templates/core/_partials/_stat_face.html +++ b/src/templates/core/_partials/_stat_face.html @@ -32,7 +32,15 @@ Args:

{{ label_text }}

+ {# Polarity qualifier (Elevated/Enlightened/Graven) — mirrors the card-face #} + {# above/below placement: non-major arcana puts it ABOVE the title, major #} + {# puts it BELOW (the title then carries a trailing comma). JS-only #} + {# (stage-card.js populateStatExtras w. opts.polarity); empty server-side #} + {# since the qualifier is polarity-dependent. The card-face copy is going #} + {# away as decks gain images, so the stat-block becomes its sole home. #} +

{% if card %}{{ card.name }}{% endif %}

+

{% if card %}{{ card.get_arcana_display }}{% endif %}