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:
@@ -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 `<i>` 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()))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)' : '';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -74,7 +74,17 @@
|
||||
{# only the polarity axis (FLIP), never the orientation axis #}
|
||||
{# (SPIN), so always render the upright/emanation face. #}
|
||||
<div class="my-sign-applet-stat-block" data-arcana-key="{{ card.arcana }}">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
{# 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). #}
|
||||
<div class="stat-face-header">
|
||||
<span class="stat-face-chip">
|
||||
<span class="stat-chip-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }} stat-chip-icon"></i>{% endif %}
|
||||
</span>
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
</div>
|
||||
<p class="stat-face-title">{{ card.name }}</p>
|
||||
<p class="stat-face-arcana">{{ card.get_arcana_display }}</p>
|
||||
<ul class="stat-keywords">
|
||||
|
||||
@@ -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`.#}
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<div class="stat-face-header">
|
||||
<span class="stat-face-chip">
|
||||
<span class="stat-chip-rank"></span>
|
||||
<i class="fa-solid stat-chip-icon" style="display:none"></i>
|
||||
</span>
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
</div>
|
||||
<p class="stat-face-title"></p>
|
||||
<p class="stat-face-arcana"></p>
|
||||
<ul class="stat-keywords"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<div class="stat-face-header">
|
||||
<span class="stat-face-chip">
|
||||
<span class="stat-chip-rank"></span>
|
||||
<i class="fa-solid stat-chip-icon" style="display:none"></i>
|
||||
</span>
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
</div>
|
||||
<p class="stat-face-title"></p>
|
||||
<p class="stat-face-arcana"></p>
|
||||
<ul class="stat-keywords"></ul>
|
||||
|
||||
@@ -45,14 +45,30 @@
|
||||
<div class="sig-stat-block sea-stat-block">
|
||||
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
|
||||
<button class="btn btn-info fyi-btn" type="button">FYI</button>
|
||||
{# 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.#}
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<div class="stat-face-header">
|
||||
<span class="stat-face-chip">
|
||||
<span class="stat-chip-rank"></span>
|
||||
<i class="fa-solid stat-chip-icon" style="display:none"></i>
|
||||
</span>
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
</div>
|
||||
<p class="stat-face-title"></p>
|
||||
<p class="stat-face-arcana"></p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<div class="stat-face-header">
|
||||
<span class="stat-face-chip">
|
||||
<span class="stat-chip-rank"></span>
|
||||
<i class="fa-solid stat-chip-icon" style="display:none"></i>
|
||||
</span>
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
</div>
|
||||
<p class="stat-face-title"></p>
|
||||
<p class="stat-face-arcana"></p>
|
||||
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{% load tarot_filters %}
|
||||
{% for card in cards %}
|
||||
<div class="fan-card"
|
||||
<div class="fan-card{% if card.deck_variant.has_card_images %} fan-card--image{% endif %}"
|
||||
data-index="{{ forloop.counter0 }}"
|
||||
data-suit-icon="{{ card.suit_icon }}"
|
||||
data-corner-rank="{{ card.corner_rank }}"
|
||||
data-name-group="{{ card.name_group }}"
|
||||
data-name-title="{{ card.name_title }}"
|
||||
data-arcana="{{ card.get_arcana_display }}"
|
||||
data-arcana-key="{{ card.arcana }}"
|
||||
data-correspondence="{{ card.correspondence|default:'' }}"
|
||||
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
|
||||
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
|
||||
@@ -20,53 +21,74 @@
|
||||
data-gravity-emanation="{{ card.gravity_emanation }}"
|
||||
data-levity-reversal="{{ card.levity_reversal }}"
|
||||
data-gravity-reversal="{{ card.gravity_reversal }}"
|
||||
data-italic-word="{{ card.italic_word }}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
<div class="fan-card-face-upright">
|
||||
{% 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 #}
|
||||
<p class="fan-card-name {{ card.title_squeeze_class }}">{{ card.gravity_emanation|italicize:card.italic_word }}</p>
|
||||
{% else %}
|
||||
{% if card.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
|
||||
{% if card.arcana != "MAJOR" and card.gravity_qualifier %}
|
||||
<p class="sig-qualifier-above">{{ card.gravity_qualifier }}</p>
|
||||
{% endif %}
|
||||
<p class="fan-card-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.gravity_qualifier %},{% endif %}</p>
|
||||
{% if card.arcana == "MAJOR" and card.gravity_qualifier %}
|
||||
<p class="sig-qualifier-below">{{ card.gravity_qualifier }}</p>
|
||||
{% 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. #}
|
||||
<img class="sig-stage-card-img" src="{{ card.image_url }}" alt="{{ card.name_title }}">
|
||||
{% if not card.deck_variant.is_polarized %}
|
||||
<img class="sig-stage-card-back-img" alt=""
|
||||
src="{{ card.deck_variant.back_image_url }}">
|
||||
{% 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. #}
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
|
||||
<div class="fan-card-face-reversal">
|
||||
{% 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. #}
|
||||
<p class="fan-card-reversal-qualifier"></p>
|
||||
<p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.gravity_reversal|italicize:card.italic_word }}</p>
|
||||
{% elif card.arcana == "MAJOR" %}
|
||||
<p class="fan-card-reversal-qualifier">{{ card.gravity_qualifier|default:card.levity_qualifier }}</p>
|
||||
<p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}{% if card.gravity_qualifier %},{% endif %}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}</p>
|
||||
<p class="fan-card-reversal-qualifier">{{ card.reversal_qualifier|default:card.gravity_qualifier }}</p>
|
||||
{% endif %}
|
||||
<div class="fan-card-face">
|
||||
<div class="fan-card-face-upright">
|
||||
{% 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 #}
|
||||
<p class="fan-card-name {{ card.title_squeeze_class }}">{{ card.gravity_emanation|italicize:card.italic_word }}</p>
|
||||
{% else %}
|
||||
{% if card.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
|
||||
{% if card.arcana != "MAJOR" and card.gravity_qualifier %}
|
||||
<p class="sig-qualifier-above">{{ card.gravity_qualifier }}</p>
|
||||
{% endif %}
|
||||
<p class="fan-card-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.gravity_qualifier %},{% endif %}</p>
|
||||
{% if card.arcana == "MAJOR" and card.gravity_qualifier %}
|
||||
<p class="sig-qualifier-below">{{ card.gravity_qualifier }}</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
|
||||
<div class="fan-card-face-reversal">
|
||||
{% 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. #}
|
||||
<p class="fan-card-reversal-qualifier"></p>
|
||||
<p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.gravity_reversal|italicize:card.italic_word }}</p>
|
||||
{% elif card.arcana == "MAJOR" %}
|
||||
<p class="fan-card-reversal-qualifier">{{ card.gravity_qualifier|default:card.levity_qualifier }}</p>
|
||||
<p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}{% if card.gravity_qualifier %},{% endif %}</p>
|
||||
{% else %}
|
||||
<p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}</p>
|
||||
<p class="fan-card-reversal-qualifier">{{ card.reversal_qualifier|default:card.gravity_qualifier }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -21,12 +21,35 @@
|
||||
<div class="fan-stage-block sig-stat-block" id="id_fan_stage_block">
|
||||
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
|
||||
<button class="btn btn-info fyi-btn" type="button">FYI</button>
|
||||
{# 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. #}
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
<div class="stat-face-header">
|
||||
<span class="stat-face-chip">
|
||||
<span class="stat-chip-rank"></span>
|
||||
<i class="fa-solid stat-chip-icon" style="display:none"></i>
|
||||
</span>
|
||||
<p class="stat-face-label">Emanation</p>
|
||||
</div>
|
||||
<p class="stat-face-title"></p>
|
||||
<p class="stat-face-arcana"></p>
|
||||
<ul class="stat-keywords" id="id_fan_stat_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
<div class="stat-face-header">
|
||||
<span class="stat-face-chip">
|
||||
<span class="stat-chip-rank"></span>
|
||||
<i class="fa-solid stat-chip-icon" style="display:none"></i>
|
||||
</span>
|
||||
<p class="stat-face-label">Reversal</p>
|
||||
</div>
|
||||
<p class="stat-face-title"></p>
|
||||
<p class="stat-face-arcana"></p>
|
||||
<ul class="stat-keywords" id="id_fan_stat_reversed"></ul>
|
||||
</div>
|
||||
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_fan_fyi_panel" panel_extra_attrs='style="display:none"' %}
|
||||
|
||||
Reference in New Issue
Block a user