castanedan virtues + card 49 tweak; italic_word for trumps 19–21; sig/sea propagation — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

- migration 0016: card 49 gravity_reversal All-Bestowing → Bestowing
- migration 0017: implicit virtues (trumps 6–9) Sublimating/Sedimentary qualifiers + shared reversals (Indulged Folly / Indulgent Doing / Self-Indulgence / Indulging Personal History); explicit virtues (trumps 19–21) full-string emanation/reversal overrides (The Hunter's/Sleeper's/Quarry's etc.); canonicalize trump 7 name "Not Doing" → "Not-Doing"
- migrations 0018+0019: TarotCard.italic_word field; populated for trumps 19–21 (Stalking / Dreaming / Intent)
- _tarot_fan.html: data-italic-word + |italicize:card.italic_word filter applied to all rendered title slots
- new templatetags/tarot_filters.py: italicize(text, word) — escape-safe <em> wrapping
- StageCard JS: parse data-italic-word; new _escape / _italicize / _setTitle helpers wrap matching word in <em> via innerHTML when present (textContent otherwise)
- views.py _card_dict: include polarity-split overrides + italic_word so Sea Select stage gets them via fetch JSON
- _sig_select_overlay.html: emit the five new data-* attrs on sig-card markup so Sig Select stage picks them up via StageCard.fromDataset

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 23:36:35 -04:00
parent 270e48ab2c
commit da57106d7a
11 changed files with 309 additions and 15 deletions

View File

@@ -0,0 +1,37 @@
"""Tweak card 49 gravity_reversal: 'All-Bestowing Eagle''Bestowing Eagle'."""
from django.db import migrations
def forward(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(gravity_reversal="The Bestowing Eagle")
def reverse(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(gravity_reversal="The All-Bestowing Eagle")
class Migration(migrations.Migration):
dependencies = [
("epic", "0015_card49_polarity_reversal_titles"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,114 @@
"""Populate the seven Castanedan Virtues — trumps 69 (Implicit) + 1921 (Explicit).
Implicit Virtues (69): emanation qualifier differs by polarity (Sublimating /
Sedimentary), name is shared. Reversal is a single full string shared across
both polarities (the agency word — Controlled / Not / Losing / Erasing —
flips to Indulged / Indulgent / Self-Indulgence / Indulging). We fill the
standard `levity_qualifier` / `gravity_qualifier` slots so the major-arcana
upright renders "Controlled Folly,\nSublimating" via the existing template
branch; we fill BOTH `levity_reversal` + `gravity_reversal` with the same
string so a FLIP'd reversal still picks up the override (an empty side falls
through to the default major-arcana rendering).
Explicit Virtues (1921): emanation is shared across polarities (e.g. "The
Hunter's Stalking" — no qualifier + stem decomposition), reversal differs by
polarity. All four polarity-split title fields filled.
Also canonicalizes trump 7's name from "Not Doing" to "Not-Doing" per the spec
doc (slug "not-doing" already correct).
"""
from django.db import migrations
IMPLICIT = [
# (number, levity_qualifier, gravity_qualifier, reversal_title)
(6, "Sublimating", "Sedimentary", "Indulged Folly"),
(7, "Sublimating", "Sedimentary", "Indulgent Doing"),
(8, "Sublimating", "Sedimentary", "Self-Indulgence"),
(9, "Sublimating", "Sedimentary", "Indulging Personal History"),
]
EXPLICIT = [
# (number, levity_emanation, gravity_emanation, levity_reversal, gravity_reversal)
(19, "The Hunter's Stalking", "The Hunter's Stalking", "The Sleeper's Stalking", "The Quarry's Stalking"),
(20, "The Dreamer's Dreaming", "The Dreamer's Dreaming", "The Sleeper's Dreaming", "The Dreamed's Dreaming"),
(21, "The Warrior's Intent", "The Warrior's Intent", "The Sleeper's Intent", "The Predator's Intent"),
]
def forward(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
# Trump 7 name canonicalization
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not-Doing")
for number, lvty, grav, rev in IMPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier=lvty,
gravity_qualifier=grav,
levity_reversal=rev,
gravity_reversal=rev,
)
for number, le, ge, lr, gr in EXPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation=le,
gravity_emanation=ge,
levity_reversal=lr,
gravity_reversal=gr,
)
def reverse(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=7,
).update(name="Not Doing")
for number, _lvty, _grav, _rev in IMPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier="",
gravity_qualifier="",
levity_reversal="",
gravity_reversal="",
)
for number, _le, _ge, _lr, _gr in EXPLICIT:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation="",
gravity_emanation="",
levity_reversal="",
gravity_reversal="",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0016_card49_bestowing_eagle"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-01 03:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0017_castanedan_virtues'),
]
operations = [
migrations.AddField(
model_name='tarotcard',
name='italic_word',
field=models.CharField(blank=True, default='', max_length=50),
),
]

View File

@@ -0,0 +1,51 @@
"""Set TarotCard.italic_word for trumps 19-21 (Stalking / Dreaming / Intent).
Each of these three Castanedan virtues has its title key-word italicized
across every emanation/reversal slot ("The Hunter's *Stalking*", "The
Sleeper's *Stalking*", etc.). Storing the word in a single field lets the
renderer wrap it in <em> at display time without HTML in the data.
"""
from django.db import migrations
WORDS = {
19: "Stalking",
20: "Dreaming",
21: "Intent",
}
def forward(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
for number, word in WORDS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(italic_word=word)
def reverse(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__in=list(WORDS),
).update(italic_word="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0018_add_italic_word"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -257,6 +257,7 @@ class TarotCard(models.Model):
gravity_emanation = models.CharField(max_length=200, blank=True, default='')
levity_reversal = models.CharField(max_length=200, blank=True, default='') # polarity-split reversal (card 48)
gravity_reversal = models.CharField(max_length=200, blank=True, default='')
italic_word = models.CharField(max_length=50, blank=True, default='') # word(s) inside any title slot to wrap in <em> at render time (e.g. "Stalking" for trumps 19-21)
energies = models.JSONField(default=list) # list of {type, effect} dicts — Energy interactions
operations = models.JSONField(default=list) # list of {type, effect} dicts — Operation interactions
keywords_upright = models.JSONField(default=list)

View File

@@ -38,9 +38,44 @@ var StageCard = (function () {
gravity_emanation: el.dataset.gravityEmanation || '',
levity_reversal: el.dataset.levityReversal || '',
gravity_reversal: el.dataset.gravityReversal || '',
// Word(s) inside any title slot to wrap in <em> at render time
// (e.g. "Stalking" for trumps 19-21). Blank for most cards.
italic_word: el.dataset.italicWord || '',
};
}
function _escape(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Wrap every occurrence of `word` in `text` with <em>...</em>, returning
// an HTML-safe string. Both inputs are escaped before splicing.
function _italicize(text, word) {
if (!text) return '';
var safeText = _escape(text);
if (!word) return safeText;
var safeWord = _escape(word);
if (safeText.indexOf(safeWord) === -1) return safeText;
return safeText.split(safeWord).join('<em>' + safeWord + '</em>');
}
// Set element text — uses innerHTML if the card has an italic_word that
// appears in the string, otherwise textContent. Caller passes the full
// card so we can pick up `italic_word` without re-passing it everywhere.
function _setTitle(el, text, card) {
if (!el) return;
if (card.italic_word && text && text.indexOf(card.italic_word) !== -1) {
el.innerHTML = _italicize(text, card.italic_word);
} else {
el.textContent = text || '';
}
}
// Decide whether a card object represents Major Arcana — sig sources from
// `data-arcana` (Django's `get_arcana_display`, e.g. "Major Arcana"), sea
// from card.arcana (model code, e.g. "MAJOR"). Accept both.
@@ -89,12 +124,12 @@ var StageCard = (function () {
var qBelow = stageCard.querySelector('.sig-qualifier-below');
if (emanationOverride) {
// Cards 48-49 — single-line title, no qualifier slots.
if (nameEl) nameEl.textContent = emanationOverride;
// Cards 48-49 + trumps 19-21 — single-line title, no qualifier slots.
_setTitle(nameEl, emanationOverride, card);
if (qAbove) qAbove.textContent = '';
if (qBelow) qBelow.textContent = '';
} else {
if (nameEl) nameEl.textContent = isMajor ? title + ',' : title;
_setTitle(nameEl, isMajor ? title + ',' : title, card);
if (qAbove) qAbove.textContent = isMajor ? '' : qualifier;
if (qBelow) qBelow.textContent = isMajor ? qualifier : '';
}
@@ -108,17 +143,17 @@ var StageCard = (function () {
var rName = stageCard.querySelector('.fan-card-reversal-name');
if (rQual && rName) {
if (reversalOverride) {
rQual.textContent = reversalOverride;
_setTitle(rQual, reversalOverride, card);
rName.textContent = '';
} else if (isMajor) {
rQual.textContent = title + ',';
_setTitle(rQual, title + ',', card);
rName.textContent = qualifier;
} else if (reversalQualifier) {
rQual.textContent = reversalQualifier;
rName.textContent = title;
_setTitle(rName, title, card);
} else {
rQual.textContent = qualifier;
rName.textContent = title;
_setTitle(rName, title, card);
}
}
}

View File

View File

@@ -0,0 +1,24 @@
"""Template filters for tarot card rendering."""
from django import template
from django.utils.html import escape
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter(name='italicize')
def italicize(text, word):
"""Wrap every occurrence of `word` in `text` with <em>...</em>.
Both `text` and `word` are escape()d before splicing the <em> in, so the
output is safe to mark `mark_safe` regardless of input.
"""
if not text or not word:
return text
text = str(text)
word = str(word)
if word not in text:
return text
safe_text = escape(text)
safe_word = escape(word)
return mark_safe(safe_text.replace(safe_word, '<em>{}</em>'.format(safe_word)))

View File

@@ -1146,6 +1146,13 @@ def sea_deck(request, room_id):
'levity_qualifier': c.levity_qualifier,
'gravity_qualifier': c.gravity_qualifier,
'reversal_qualifier': c.reversal_qualifier,
# Polarity-split full-title overrides (cards 48-49 + trumps 19-21)
'levity_emanation': c.levity_emanation,
'gravity_emanation': c.gravity_emanation,
'levity_reversal': c.levity_reversal,
'gravity_reversal': c.gravity_reversal,
# Word inside any title slot to wrap in <em> at render time
'italic_word': c.italic_word,
'keywords_upright': c.keywords_upright,
'keywords_reversed': c.keywords_reversed,
'energies': c.energies,

View File

@@ -81,7 +81,12 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
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 }}"
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 %}

View File

@@ -1,3 +1,4 @@
{% load tarot_filters %}
{% for card in cards %}
<div class="fan-card"
data-index="{{ forloop.counter0 }}"
@@ -17,7 +18,8 @@
data-levity-emanation="{{ card.levity_emanation }}"
data-gravity-emanation="{{ card.gravity_emanation }}"
data-levity-reversal="{{ card.levity_reversal }}"
data-gravity-reversal="{{ card.gravity_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 %}
@@ -25,14 +27,14 @@
<div class="fan-card-face">
<div class="fan-card-face-upright">
{% 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>
{# Polarity-split title (cards 48-49 + trumps 19-21); no qualifier slots — qualifier is baked into the title between "The" and the proper noun #}
<h3 class="fan-card-name">{{ card.levity_emanation|italicize:card.italic_word }}</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>
<h3 class="fan-card-name">{{ card.name_title|italicize:card.italic_word }}{% 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 %}
@@ -47,12 +49,12 @@
{% 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>
<p class="fan-card-reversal-qualifier">{{ card.levity_reversal|italicize:card.italic_word }}</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>
<p class="fan-card-reversal-qualifier">{{ card.name_title|italicize:card.italic_word }}{% if card.levity_qualifier %},{% endif %}</p>
{% else %}
<p class="fan-card-reversal-name">{{ card.name_title }}</p>
<p class="fan-card-reversal-name">{{ card.name_title|italicize:card.italic_word }}</p>
<p class="fan-card-reversal-qualifier">{{ card.reversal_qualifier|default:card.gravity_qualifier }}</p>
{% endif %}
</div>