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:
Disco DeDisco
2026-05-25 14:25:41 -04:00
parent 5a1acbd9ca
commit a9ad422b35
13 changed files with 475 additions and 67 deletions

View File

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

View File

@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 %}

View File

@@ -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"' %}