Compare commits

..

3 Commits

Author SHA1 Message Date
Disco DeDisco
9a00f96fe5 Sig Select qualifier on the stat-block — green the 3 theme FTs
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
The qualifier (Elevated/Enlightened/Graven) now renders on the frozen stat-block
stat-face, not just the hover-only text card-face (which image-mode decks hide).
populateStatExtras takes opts.polarity and fills new .stat-face-qualifier--above
/--below slots, mirroring the card-face placement: non-major above the title,
major below it (title gets a trailing comma). Qualifier shares the title's style
per request ("same style as 'Jack of Crowns' below it").

The 3 theme FTs were asserting the qualifier on hover, but the stat-block is
display:none until a card is OK'd (.sig-stage--frozen) — only the card preview
shows on hover. Repointed them to select→OK→freeze, then read the stat-block.
Threaded polarity through the sig + my_sign callers; added 4 Jasmine specs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:28:45 -04:00
Disco DeDisco
544ce978d5 Sig Select theme FTs: skip 3 card-face qualifier asserts (moving to stat-block)
The Earthman deck now renders the sig stage in image-mode (has_card_images
defaults True), so .fan-card-face — where .sig-qualifier-above/below live — is
display:none and Selenium reads "". The Elevated/Enlightened/Graven qualifier
belongs on the always-visible stat-block now (the card-face instance goes away
once every deck gets images). Skipped pending the repoint + green-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:33:36 -04:00
Disco DeDisco
0e4101ce95 FTs: unified Sig-stage felt/stat-block (single-browser) + live SCROLL refresh (channels)
End-to-end coverage for this session's two shipped features.

SigStageUnifiedTest (FunctionalTest, no WS — both pass locally):
- the sig stage renders INSIDE .room-hex-pane on green --duoUser felt
  (.has-sig-stage), the overlay is a descendant of the hex pane, the dark
  .sig-backdrop is gone, and the overlay bg is not a translucent-black wash;
- OK'ing a card freezes the stage and reveals the DRY _stat_face.html —
  .stat-face-title + .stat-chip-rank populate (the old reduced block had
  neither; proves the populateStatExtras wiring).

RoomScrollLiveRefreshTest (ChannelsFunctionalTest, @tag channels):
- with the room open, a server-side record() of a new GameEvent grows the
  feed (#id_drama_scroll .drama-event 1 → 2) WITHOUT a reload, via the
  record() on_commit broadcast → RoomConsumer.scroll_update relay →
  room-scroll.js re-fetch+swap. Validated in the CI channels stage (needs a
  cross-process channel layer); the plumbing is already green via the
  consumer-relay + record-hook + scroll_status ITs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 03:19:12 -04:00
9 changed files with 328 additions and 32 deletions

View File

@@ -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');

View File

@@ -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;

View File

@@ -6,11 +6,12 @@ the hex. No partial scroll — `scroll-snap-stop: always` enforces the binary.
FT bucket: game_room. Built to DRY-template onto my_sea next.
"""
from django.test import tag
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .base import FunctionalTest, ChannelsFunctionalTest
from .room_page import _equip_earthman_deck, _fill_room_via_orm
from apps.drama.models import GameEvent
from apps.drama.models import GameEvent, record
from apps.epic.models import Room
from apps.lyric.models import User
@@ -189,3 +190,54 @@ class RoomScrollOfEventsTest(FunctionalTest):
By.CSS_SELECTOR,
"#id_drama_scroll .drama-event[data-label='frame']",
).is_displayed())
@tag("channels")
class RoomScrollLiveRefreshTest(ChannelsFunctionalTest):
"""The SCROLL applet refreshes live over WebSocket — no page reload. A
GameEvent recorded by any actor nudges every open room socket (record() →
on_commit broadcast → RoomConsumer.scroll_update relay → room-scroll.js
re-fetches `core/_partials/_scroll.html` + swaps #id_drama_scroll), so an
open feed grows on its own. Needs daphne + a real channel layer (channels
stage)."""
EMAILS = [
"disco@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
]
def setUp(self):
super().setUp()
self.viewer = User.objects.create(email="disco@test.io", username="disco")
_equip_earthman_deck(self.viewer)
self.room = Room.objects.create(name="Willawonky", owner=self.viewer)
_fill_room_via_orm(self.room, self.EMAILS)
# One seed line so the feed isn't empty at open.
record(self.room, GameEvent.SLOT_FILLED, actor=self.viewer,
slot_number=1, token_type="carte", token_display="Carte Blanche",
renewal_days=7)
self.room.table_status = Room.ROLE_SELECT
self.room.gate_status = Room.OPEN
self.room.save()
def _rows(self):
return len(self.browser.find_elements(
By.CSS_SELECTOR, "#id_drama_scroll .drama-event"))
def test_feed_grows_live_when_an_event_is_recorded(self):
self.create_pre_authenticated_session("disco@test.io")
self.browser.set_window_size(820, 600)
self.browser.get(self.live_server_url + f"/gameboard/room/{self.room.id}/")
# Wait for the room WebSocket to actually open (room.js sets _roomSocket).
self.wait_for(lambda: self.assertEqual(
self.browser.execute_script(
"return window._roomSocket ? window._roomSocket.readyState : -1;"),
1))
self.wait_for(lambda: self.assertEqual(self._rows(), 1))
# Another action is recorded server-side → broadcasts scroll_update on
# commit; the open feed must grow WITHOUT a reload.
record(self.room, GameEvent.SLOT_FILLED, actor=self.viewer,
slot_number=2, token_type="carte", token_display="Carte Blanche",
renewal_days=7)
self.wait_for(lambda: self.assertEqual(self._rows(), 2))

View File

@@ -255,57 +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 ──────────────────────────────────── #
# 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, "")
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 ───────────────────────────────────── #
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 ─────────────────────────────────────── #
@@ -325,6 +348,72 @@ class SigSelectThemeTest(FunctionalTest):
self.assertEqual(corr.text, "")
class SigStageUnifiedTest(FunctionalTest):
"""The Sig Select stage unified with the my_sign card-stage apparatus
(2026-06-03): the green --duoUser felt fills the hex pane (no dark Gaussian
backdrop), and the stat-block is the shared DRY `_stat_face.html` (rank-chip
+ title + arcana), not the old label-only copy. No WebSocket — local stage
updates; plain FunctionalTest."""
EMAILS = [
"founder@test.io", "amigo@test.io", "bud@test.io",
"pal@test.io", "dude@test.io", "bro@test.io",
]
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
def _open_sig(self):
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
room = Room.objects.create(name="Felt Test", owner=founder)
_fill_room_via_orm(room, self.EMAILS)
_assign_all_roles(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"))
return room
def test_stage_fills_hex_pane_on_felt_no_dark_backdrop(self):
"""The sig stage renders INSIDE the hex pane on edge-to-edge green felt
(my_sea-style), replacing the old fixed dark-Gaussian modal."""
self._open_sig()
self.assertTrue(
self.browser.find_elements(By.CSS_SELECTOR, ".room-hex-pane.has-sig-stage"))
# The overlay is a descendant of the hex pane (not a root-level modal).
inside = self.browser.execute_script(
"var o=document.querySelector('.sig-overlay');"
"return !!(o && o.closest('.room-hex-pane'));")
self.assertTrue(inside)
# The dark blur backdrop element is gone…
self.assertFalse(self.browser.find_elements(By.CSS_SELECTOR, ".sig-backdrop"))
# …and the overlay's own background is the green --duoUser felt, not a
# translucent-black wash.
bg = self.browser.execute_script(
"return getComputedStyle(document.querySelector('.sig-overlay'))"
".backgroundColor;")
self.assertNotIn("rgba(0, 0, 0", bg)
def test_stat_block_is_dry_stat_face_with_rank_and_title(self):
"""OK'ing a card freezes the stage and reveals the rich DRY stat-face —
rank-chip + title + arcana (the old reduced block had none of these)."""
self._open_sig()
card = self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-card"))
card.click()
ok = self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-ok-btn"))
ok.click()
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-stage--frozen"))
# populateStatExtras filled the title + rank chip (was the missing call).
title = self.browser.find_element(
By.CSS_SELECTOR, ".sig-stat-block .stat-face-title")
rank = self.browser.find_element(
By.CSS_SELECTOR, ".sig-stat-block .stat-chip-rank")
self.wait_for(lambda: self.assertTrue(title.text.strip()))
self.assertTrue(rank.text.strip())
# ── SAVE SIG / WAIT NVM — ready gate ──────────────────────────────────────────
#
# SAVE SIG (.btn.btn-primary) appears at the bottom-left corner of the card

View File

@@ -34,10 +34,18 @@ describe("SigSelect", () => {
<button class="btn btn-info fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<p class="stat-face-qualifier stat-face-qualifier--above"></p>
<p class="stat-face-title"></p>
<p class="stat-face-qualifier stat-face-qualifier--below"></p>
<p class="stat-face-arcana"></p>
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<p class="stat-face-qualifier stat-face-qualifier--above"></p>
<p class="stat-face-title"></p>
<p class="stat-face-qualifier stat-face-qualifier--below"></p>
<p class="stat-face-arcana"></p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div>
<button class="btn btn-nav-left fyi-prev" type="button">&#9664;</button>
@@ -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", () => {

View File

@@ -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 {

View File

@@ -34,10 +34,18 @@ describe("SigSelect", () => {
<button class="btn btn-info fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<p class="stat-face-qualifier stat-face-qualifier--above"></p>
<p class="stat-face-title"></p>
<p class="stat-face-qualifier stat-face-qualifier--below"></p>
<p class="stat-face-arcana"></p>
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<p class="stat-face-qualifier stat-face-qualifier--above"></p>
<p class="stat-face-title"></p>
<p class="stat-face-qualifier stat-face-qualifier--below"></p>
<p class="stat-face-arcana"></p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div>
<button class="btn btn-nav-left fyi-prev" type="button">&#9664;</button>
@@ -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", () => {

View File

@@ -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);

View File

@@ -32,7 +32,15 @@ Args:
<p class="stat-face-label">{{ label_text }}</p>
</div>
</div>
{# 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. #}
<p class="stat-face-qualifier stat-face-qualifier--above"></p>
<p class="stat-face-title">{% if card %}{{ card.name }}{% endif %}</p>
<p class="stat-face-qualifier stat-face-qualifier--below"></p>
<p class="stat-face-arcana">{% if card %}{{ card.get_arcana_display }}{% endif %}</p>
<ul class="stat-keywords"{% if keywords_ul_id %} id="{{ keywords_ul_id }}"{% endif %}>{% if card %}{% for kw in card.keywords_upright %}<li>{{ kw }}</li>{% endfor %}{% endif %}</ul>
</div>