cards 48–49 polarity-split titles; sea-stage mobile breakpoints; @comment fix — TDD

- migration 0015 fills card 49 levity_reversal=The Vibrational Mould of Man, gravity_reversal=The All-Bestowing Eagle (card 48 already seeded in 0004)
- _tarot_fan.html: 4 new data-* attrs (data-levity-emanation / data-gravity-emanation / data-levity-reversal / data-gravity-reversal); upright + reversal slots render full polarity-split title in name slot when set, qualifier slots blank
- StageCard.fromDataset: parse the 4 new attrs; populateCard: emanationOverride / reversalOverride per polarity bypasses the standard name+qualifier rendering
- model: emanation_for / reversal_for fall back to name_title (group prefix stripped) instead of full self.name; reversal_for uses self.reversal_qualifier (was leftover self.reversal post-rename)
- sea-stage-content: --sig-card-w lifted from inline style to SCSS w. portrait ≤480px / landscape ≤500h breakpoints both stepping to 130px (mirrors fan modal triggers); default 180px
- _tarot_fan.html: rewrite multi-line {# #} that rendered as page text into {% comment %}{% endcomment %}

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-30 21:51:23 -04:00
parent 2f039559e6
commit 270e48ab2c
6 changed files with 147 additions and 28 deletions

View File

@@ -0,0 +1,60 @@
"""Populate card 49's polarity-split reversal titles.
The Earthman deck's last two cards (4849) carry distinct titles per polarity
(stored in `levity_emanation` / `gravity_emanation` / `levity_reversal` /
`gravity_reversal`) rather than a shared title + qualifier.
Card 48 had its full set seeded in migration 0004:
levity: Father Sky → reversal: The Storm
gravity: Mother Sea → reversal: The Flood
Card 49 had only emanations seeded; this migration fills the reversals:
levity: The Effulgent Mould of Man → reversal: The Vibrational Mould of Man
gravity: The Devouring Eagle → reversal: The All-Bestowing Eagle
The "qualifier" (Effulgent / Vibrational / Devouring / All-Bestowing) is baked
into the title between "The" and the title-proper rather than rendered as a
separate qualifier slot — the per-polarity title strings are stored verbatim.
"""
from django.db import migrations
def populate_card49_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="The Vibrational Mould of Man",
gravity_reversal="The All-Bestowing Eagle",
)
def clear_card49_reversals(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="",
gravity_reversal="",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0014_rename_reversal_to_reversal_qualifier"),
]
operations = [
migrations.RunPython(populate_card49_reversals, reverse_code=clear_card49_reversals),
]

View File

@@ -291,21 +291,22 @@ class TarotCard(models.Model):
def emanation_for(self, polarity):
"""Return the upright title for a given polarity ('levity' or 'gravity').
Falls back to name for cards without a polarity split."""
Falls back to name_title (group prefix stripped) for cards without a
polarity split."""
if polarity == 'levity' and self.levity_emanation:
return self.levity_emanation
if polarity == 'gravity' and self.gravity_emanation:
return self.gravity_emanation
return self.name
return self.name_title
def reversal_for(self, polarity):
"""Return the reversed title for a given polarity.
Falls back to reversal (blank = same as emanation_for)."""
Falls back to reversal_qualifier (blank = same as emanation_for)."""
if polarity == 'levity' and self.levity_reversal:
return self.levity_reversal
if polarity == 'gravity' and self.gravity_reversal:
return self.gravity_reversal
return self.reversal or self.emanation_for(polarity)
return self.reversal_qualifier or self.emanation_for(polarity)
@property
def name_group(self):

View File

@@ -31,6 +31,13 @@ var StageCard = (function () {
levity_qualifier: el.dataset.levityQualifier || '',
gravity_qualifier: el.dataset.gravityQualifier || '',
reversal_qualifier: el.dataset.reversalQualifier || '',
// Polarity-split title overrides — non-blank for cards 48-49 only,
// where each polarity (and within each polarity, each axis state)
// has a fully distinct title rather than a shared name + qualifier.
levity_emanation: el.dataset.levityEmanation || '',
gravity_emanation: el.dataset.gravityEmanation || '',
levity_reversal: el.dataset.levityReversal || '',
gravity_reversal: el.dataset.gravityReversal || '',
};
}
@@ -47,11 +54,17 @@ var StageCard = (function () {
// falls back to the current polarity's qualifier when blank (6F behavior).
function populateCard(stageCard, card, polarity) {
if (!stageCard) return;
var isLevity = polarity === 'levity';
var isLevity = polarity === 'levity';
var qualifier = isLevity ? (card.levity_qualifier || '') : (card.gravity_qualifier || '');
var isMajor = _isMajor(card);
var title = card.name_title || '';
var isMajor = _isMajor(card);
var title = card.name_title || '';
var reversalQualifier = card.reversal_qualifier || '';
// Polarity-split overrides (cards 48-49). When set, the full title
// string already incorporates whatever qualifier the card carries
// ("The Effulgent Mould of Man" etc.) — render as a single line and
// leave the upright qualifier slots empty.
var emanationOverride = isLevity ? (card.levity_emanation || '') : (card.gravity_emanation || '');
var reversalOverride = isLevity ? (card.levity_reversal || '') : (card.gravity_reversal || '');
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) {
el.textContent = card.corner_rank || '';
@@ -66,27 +79,38 @@ var StageCard = (function () {
});
var nameGroupEl = stageCard.querySelector('.fan-card-name-group');
if (nameGroupEl) nameGroupEl.textContent = card.name_group || '';
if (nameGroupEl) nameGroupEl.textContent = emanationOverride ? '' : (card.name_group || '');
var arcanaEl = stageCard.querySelector('.fan-card-arcana');
if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana';
var nameEl = stageCard.querySelector('.fan-card-name');
if (nameEl) nameEl.textContent = isMajor ? title + ',' : title;
var qAbove = stageCard.querySelector('.sig-qualifier-above');
if (qAbove) qAbove.textContent = isMajor ? '' : qualifier;
var qBelow = stageCard.querySelector('.sig-qualifier-below');
if (qBelow) qBelow.textContent = isMajor ? qualifier : '';
// Reversal face — three cases:
// Major: title (with comma) in qualifier slot, qualifier in name slot
if (emanationOverride) {
// Cards 48-49 — single-line title, no qualifier slots.
if (nameEl) nameEl.textContent = emanationOverride;
if (qAbove) qAbove.textContent = '';
if (qBelow) qBelow.textContent = '';
} else {
if (nameEl) nameEl.textContent = isMajor ? title + ',' : title;
if (qAbove) qAbove.textContent = isMajor ? '' : qualifier;
if (qBelow) qBelow.textContent = isMajor ? qualifier : '';
}
// Reversal face — four cases:
// Polarity-split: full reversal title in qualifier slot (top-after-spin), name slot empty
// Major: title (with comma) in qualifier slot, qualifier in name slot
// Non-major + reversal_qual: reversal_qualifier in qualifier slot, title in name slot
// Non-major no reversal_qual: fall back to current polarity's qualifier
var rQual = stageCard.querySelector('.fan-card-reversal-qualifier');
var rName = stageCard.querySelector('.fan-card-reversal-name');
if (rQual && rName) {
if (isMajor) {
if (reversalOverride) {
rQual.textContent = reversalOverride;
rName.textContent = '';
} else if (isMajor) {
rQual.textContent = title + ',';
rName.textContent = qualifier;
} else if (reversalQualifier) {

View File

@@ -424,15 +424,17 @@
line-height: 1;
background: none;
border: none;
color: rgba(var(--secUser), 0.6);
text-shadow: 0 0 1px rgba(0, 0, 0, 1);
color: rgba(var(--terUser), 0.6);
cursor: pointer;
padding: 1rem;
transition: color 0.15s;
pointer-events: auto;
outline: none;
box-shadow: none;
&:hover { color: rgba(var(--secUser), 1); }
&:hover { color: rgba(var(--ninUser), 1); }
// Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav
&:focus:not(:focus-visible) { outline: none; box-shadow: none; }
&--prev { left: 1rem; }
&--next { right: 1rem; }
}
@@ -1392,6 +1394,12 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
}
.sea-stage-content {
// Card width drives the stage-card AND the stat block (which reuses
// --sig-card-w via the shared stat-block-shared mixin's calc rules).
// Override per breakpoint below to keep the stage from blowing up on small
// screens.
--sig-card-w: 180px;
position: relative;
z-index: 1;
display: flex;
@@ -1401,6 +1409,15 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
padding: 1.5rem;
}
// Sea-stage mobile breakpoints — mirror the fan modal's portrait/landscape
// trigger thresholds so behavior across staging surfaces stays consistent.
@media (orientation: portrait) and (max-width: 480px) {
.sea-stage-content { --sig-card-w: 130px; }
}
@media (orientation: landscape) and (max-height: 500px) {
.sea-stage-content { --sig-card-w: 130px; }
}
// Stage card — size matches sig-select stage (--sig-card-w driven by inline style)
.sea-stage-card {
flex-shrink: 0;

View File

@@ -134,7 +134,7 @@
{# ── Sea stage — big card viewer ─────────────────────────────────────────── #}
<div class="sea-stage" id="id_sea_stage" style="display:none">
<div class="sea-stage-backdrop"></div>
<div class="sea-stage-content" style="--sig-card-w:180px">
<div class="sea-stage-content">
<div class="sig-stage-card sea-stage-card">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank"></span>

View File

@@ -13,25 +13,42 @@
data-operations="{{ card.operations_json }}"
data-levity-qualifier="{{ card.levity_qualifier }}"
data-gravity-qualifier="{{ card.gravity_qualifier }}"
data-reversal-qualifier="{{ card.reversal_qualifier }}">
data-reversal-qualifier="{{ card.reversal_qualifier }}"
data-levity-emanation="{{ card.levity_emanation }}"
data-gravity-emanation="{{ card.gravity_emanation }}"
data-levity-reversal="{{ card.levity_reversal }}"
data-gravity-reversal="{{ card.gravity_reversal }}">
<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.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
{% if card.arcana != "MAJOR" and card.levity_qualifier %}
<p class="sig-qualifier-above">{{ card.levity_qualifier }}</p>
{% endif %}
<h3 class="fan-card-name">{{ card.name_title }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}</h3>
{% if card.arcana == "MAJOR" and card.levity_qualifier %}
<p class="sig-qualifier-below">{{ card.levity_qualifier }}</p>
{% if card.levity_emanation %}
{# Polarity-split title (cards 48-49); no qualifier slots — qualifier is baked into the title between "The" and the proper noun #}
<h3 class="fan-card-name">{{ card.levity_emanation }}</h3>
{% else %}
{% if card.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
{% if card.arcana != "MAJOR" and card.levity_qualifier %}
<p class="sig-qualifier-above">{{ card.levity_qualifier }}</p>
{% endif %}
<h3 class="fan-card-name">{{ card.name_title }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}</h3>
{% if card.arcana == "MAJOR" and card.levity_qualifier %}
<p class="sig-qualifier-below">{{ card.levity_qualifier }}</p>
{% endif %}
{% endif %}
</div>
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
<div class="fan-card-face-reversal">
{% if card.arcana == "MAJOR" %}
{% comment %}
DOM order: reversal-name first, reversal-qualifier second.
After SPIN's 180° rotation DOM-second appears visually on top.
{% endcomment %}
{% if card.levity_reversal %}
{# Polarity-split reversal title — single line, qualifier slot empty. Title goes in the qualifier slot so it visually lands on top after spin. #}
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier">{{ card.levity_reversal }}</p>
{% elif card.arcana == "MAJOR" %}
<p class="fan-card-reversal-name">{{ card.levity_qualifier|default:card.gravity_qualifier }}</p>
<p class="fan-card-reversal-qualifier">{{ card.name_title }}{% if card.levity_qualifier %},{% endif %}</p>
{% else %}