diff --git a/src/apps/epic/migrations/0020_self_unimportance.py b/src/apps/epic/migrations/0020_self_unimportance.py
new file mode 100644
index 0000000..1e7b7fc
--- /dev/null
+++ b/src/apps/epic/migrations/0020_self_unimportance.py
@@ -0,0 +1,41 @@
+"""Trump 8 rename: Losing Self-Importance → Self-Unimportance.
+
+The renamed form fits on one fan-card line above the Sublimating/Sedimentary
+qualifier without a scaleX squeeze.
+"""
+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=8,
+ ).update(name="Self-Unimportance", slug="self-unimportance")
+
+
+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=8,
+ ).update(name="Losing Self-Importance", slug="losing-self-importance")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("epic", "0019_explicit_virtues_italic_word"),
+ ]
+
+ operations = [
+ migrations.RunPython(forward, reverse_code=reverse),
+ ]
diff --git a/src/apps/epic/migrations/0021_trump9_nbsp.py b/src/apps/epic/migrations/0021_trump9_nbsp.py
new file mode 100644
index 0000000..14cec28
--- /dev/null
+++ b/src/apps/epic/migrations/0021_trump9_nbsp.py
@@ -0,0 +1,61 @@
+"""Long-title wrap fixes for trumps 8 and 9.
+
+ Trump 8 "Self-Unimportance" → swap the hyphen for U+2011 (non-breaking
+ hyphen) so it stays glued and the title sits on one line above
+ Sublimating / Sedimentary.
+
+ Trump 9 "Erasing Personal History" → insert U+00A0 (non-breaking space)
+ between "Personal" and "History" so the browser keeps them together,
+ forcing "Erasing" alone on line 1 and "Personal History," on line 2.
+"""
+from django.db import migrations
+
+
+# Trump 8
+OLD_8 = "Self-Unimportance"
+NEW_8 = "Self‑Unimportance"
+
+# Trump 9
+OLD_9 = "Erasing Personal History"
+NEW_9 = "Erasing Personal History"
+
+
+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=8,
+ ).update(name=NEW_8)
+ TarotCard.objects.filter(
+ deck_variant=earthman, arcana="MAJOR", number=9,
+ ).update(name=NEW_9)
+
+
+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=8,
+ ).update(name=OLD_8)
+ TarotCard.objects.filter(
+ deck_variant=earthman, arcana="MAJOR", number=9,
+ ).update(name=OLD_9)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("epic", "0020_self_unimportance"),
+ ]
+
+ operations = [
+ migrations.RunPython(forward, reverse_code=reverse),
+ ]
diff --git a/src/apps/epic/migrations/0022_pips_to_minor_arcana.py b/src/apps/epic/migrations/0022_pips_to_minor_arcana.py
new file mode 100644
index 0000000..fc2fe61
--- /dev/null
+++ b/src/apps/epic/migrations/0022_pips_to_minor_arcana.py
@@ -0,0 +1,42 @@
+"""Reclassify Earthman pip cards (number 1-10) from MIDDLE to MINOR arcana.
+
+The 0004 reseed initially lumped pips + court cards under MIDDLE; pips
+should be MINOR arcana, with MIDDLE reserved for the Earthman court
+cards (Maid/Jack/Queen/King at numbers 11-14).
+"""
+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="MIDDLE", number__lte=10,
+ ).update(arcana="MINOR")
+
+
+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="MINOR", number__lte=10,
+ ).update(arcana="MIDDLE")
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("epic", "0021_trump9_nbsp"),
+ ]
+
+ operations = [
+ migrations.RunPython(forward, reverse_code=reverse),
+ ]
diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py
index ab789c5..5537f69 100644
--- a/src/apps/epic/models.py
+++ b/src/apps/epic/models.py
@@ -211,8 +211,8 @@ class DeckVariant(models.Model):
class TarotCard(models.Model):
MAJOR = "MAJOR"
- MINOR = "MINOR"
- MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K)
+ MINOR = "MINOR" # pip cards (numbers 1-10)
+ MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K, numbers 11-14)
ARCANA_CHOICES = [
(MAJOR, "Major Arcana"),
(MINOR, "Minor Arcana"),
@@ -323,6 +323,14 @@ class TarotCard(models.Model):
return self.name.split(': ', 1)[1]
return self.name
+ @property
+ def title_squeeze_class(self):
+ """No-op kept for template compatibility. Title fit is now handled by
+ a smaller base `font-size` on `.fan-card-name`/`.fan-card-reversal-*`
+ plus `text-wrap: balance` (see `_card-deck.scss`) — every long-title
+ card fits naturally without per-card CSS hacks."""
+ return ''
+
@property
def suit_icon(self):
if self.icon:
diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js
index 9f00a0a..4921375 100644
--- a/src/apps/epic/static/apps/epic/sea.js
+++ b/src/apps/epic/static/apps/epic/sea.js
@@ -25,9 +25,12 @@ var SeaDeal = (function () {
_infoData = StageCard.buildInfoData(card);
_infoIdx = 0;
- // Reset SPIN
- stageCard.classList.remove('stage-card--reversed');
- statBlock.classList.remove('is-reversed');
+ // Sync SPIN state to the card's reversal axis — `card.reversed` is set
+ // server-side at deck-fetch time (apps.epic.utils.stack_reversal_probability)
+ // and persisted in `_seaHand`, so re-clicking a deposited slot must
+ // restore that state, not reset to upright.
+ stageCard.classList.toggle('stage-card--reversed', !!card.reversed);
+ statBlock.classList.toggle('is-reversed', !!card.reversed);
_closeInfo();
}
@@ -105,13 +108,6 @@ var SeaDeal = (function () {
_viewingPos = posSelector;
_seaHand[posSelector] = { card: card, isLevity: isLevity };
_populate(card, isLevity);
- // Server pre-rolled the reversal axis at deck-fetch time
- // (apps.epic.utils.stack_reversal_probability). Honor it here so the
- // card lands face-reversed if rolled.
- if (card.reversed) {
- statBlock.classList.add('is-reversed');
- stageCard.classList.add('stage-card--reversed');
- }
_fillSlot(posSelector, card, isLevity);
_showStage(isLevity);
}
diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js
index b113737..a3b5ca8 100644
--- a/src/apps/epic/static/apps/epic/stage-card.js
+++ b/src/apps/epic/static/apps/epic/stage-card.js
@@ -84,6 +84,15 @@ var StageCard = (function () {
return a === 'MAJOR' || a === 'MAJOR ARCANA';
}
+ // Map either form (model code or display) to the rendered label.
+ function _arcanaDisplay(card) {
+ var a = (card.arcana || '').toUpperCase();
+ if (a === 'MAJOR' || a === 'MAJOR ARCANA') return 'Major Arcana';
+ if (a === 'MINOR' || a === 'MINOR ARCANA') return 'Minor Arcana';
+ if (a === 'MIDDLE' || a === 'MIDDLE ARCANA') return 'Middle Arcana';
+ return '';
+ }
+
// Paint the stage-card's upright + reversal faces from a normalized card
// object + the active polarity ('levity' | 'gravity'). Reversal-qualifier
// falls back to the current polarity's qualifier when blank (6F behavior).
@@ -117,7 +126,7 @@ var StageCard = (function () {
if (nameGroupEl) nameGroupEl.textContent = emanationOverride ? '' : (card.name_group || '');
var arcanaEl = stageCard.querySelector('.fan-card-arcana');
- if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana';
+ if (arcanaEl) arcanaEl.textContent = _arcanaDisplay(card);
var nameEl = stageCard.querySelector('.fan-card-name');
var qAbove = stageCard.querySelector('.sig-qualifier-above');
diff --git a/src/apps/gameboard/static/apps/gameboard/gameboard.js b/src/apps/gameboard/static/apps/gameboard/gameboard.js
index 135513c..57d7a84 100644
--- a/src/apps/gameboard/static/apps/gameboard/gameboard.js
+++ b/src/apps/gameboard/static/apps/gameboard/gameboard.js
@@ -33,14 +33,14 @@ function initGameKitTooltips() {
if (portal.classList.contains('active') && activeToken) {
const tokenRect = activeToken.getBoundingClientRect();
const portalRect = portal.getBoundingClientRect();
- // Expand left to cover button overflow outside portal edge
- const expandedPortalRect = {
- left: portalRect.left - 24,
- top: portalRect.top,
- right: portalRect.right,
- bottom: portalRect.bottom,
- };
- const rects = [tokenRect, expandedPortalRect];
+ const rects = [tokenRect, portalRect];
+ // Include the DON/DOFF button group's actual bounding rect so the
+ // portions of those buttons that hang past the portal's left edge
+ // (and above its top edge) stay inside the hover-tolerance region.
+ // Was previously a hardcoded 24px left expansion which didn't
+ // cover top overhang and underestimated wider button labels.
+ const equipBtns = portal.querySelector('.tt-equip-btns');
+ if (equipBtns) rects.push(equipBtns.getBoundingClientRect());
if (miniPortal.classList.contains('active')) rects.push(miniPortal.getBoundingClientRect());
const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top));
@@ -244,7 +244,19 @@ function initGameKitTooltips() {
const tokenRect = token.getBoundingClientRect();
const halfW = portal.offsetWidth / 2;
const rawLeft = tokenRect.left + tokenRect.width / 2;
- const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
+ // Extra left clearance — the DON/DOFF button group is absolute-
+ // positioned with `left: -1rem` inside the portal and spills further
+ // left by its own width. Measure the actual overhang so the clamp
+ // keeps the buttons inside the viewport rather than just the portal.
+ let leftOverhang = 0;
+ const equipBtns = portal.querySelector('.tt-equip-btns');
+ if (equipBtns) {
+ const portalRect = portal.getBoundingClientRect();
+ const btnsRect = equipBtns.getBoundingClientRect();
+ leftOverhang = Math.max(0, portalRect.left - btnsRect.left);
+ }
+ const minLeft = halfW + 8 + leftOverhang;
+ const clampedLeft = Math.max(minLeft, Math.min(rawLeft, window.innerWidth - halfW - 8));
portal.style.left = Math.round(clampedLeft) + 'px';
// Show above when token is in lower viewport half; below when in upper half
diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss
index 7f84935..a999079 100644
--- a/src/static_src/scss/_card-deck.scss
+++ b/src/static_src/scss/_card-deck.scss
@@ -350,21 +350,34 @@
display: flex;
flex-direction: column;
align-items: center;
+ justify-content: center;
gap: calc(var(--fan-card-w) * 0.007);
+ // Ghost-line: reserve at least two title-line-heights of vertical space
+ // on each face so emanation + reversal stay symmetric even when one
+ // side has a single-line title (e.g. trumps 6–9 reversal "Indulged
+ // Folly" vs upright "Losing Self-Importance, / Sublimating").
+ min-height: calc(var(--fan-card-w) * 0.21);
}
// Qualifier shares the name's typography — same line, different content.
// Sizes scale with --fan-card-w so they stay proportional on mobile.
+ // `text-wrap: balance` distributes lines evenly so a borderline-long title
+ // breaks at the natural midpoint instead of greedy first-fit (e.g. trump
+ // 9 wraps as "Erasing / Personal History," instead of "Erasing Personal /
+ // History,"). Base size lowered from 0.1 → 0.087 (~13%) so all the long
+ // titles (trumps 8/9/18/36/41 + Queen of Crowns) fit without per-card
+ // hacks and without asymmetry between upright (h3) and reversal (p).
.sig-qualifier-above,
.sig-qualifier-below,
.fan-card-reversal-qualifier,
.fan-card-reversal-name,
.fan-card-name {
- font-size: calc(var(--fan-card-w) * 0.1);
+ font-size: calc(var(--fan-card-w) * 0.087);
font-weight: bold;
margin: 0;
color: rgba(var(--terUser), 1);
transition: opacity 0.2s;
+ text-wrap: balance;
}
// Reversal-face spans pre-rotated so they read forward once the card spins
@@ -547,12 +560,14 @@ html:has(.sig-backdrop) {
.fan-card-face-upright { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; }
.fan-card-face-reversal { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding-top: 0.1rem; }
.fan-card-name-group { font-size: calc(var(--sig-card-w, 120px) * 0.073); opacity: 0.6; }
- // Upright qualifier + name share sizing/weight/color with their reversed counterparts
+ // Upright qualifier + name share sizing/weight/color with their reversed counterparts.
+ // text-wrap: balance distributes lines evenly so longer titles wrap symmetrically;
+ // base size 0.08 (was 0.093) gives long titles room to fit without per-card hacks.
.sig-qualifier-above,
.sig-qualifier-below,
- .fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
+ .fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 120px) * 0.08); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; text-wrap: balance; }
.fan-card-name,
- .fan-card-reversal-name { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
+ .fan-card-reversal-name { font-size: calc(var(--sig-card-w, 120px) * 0.08); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; text-wrap: balance; }
.fan-card-arcana { font-size: calc(var(--sig-card-w, 120px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
.fan-card-correspondence{ display: none; } // Minchiate equivalence shown in game-kit only
// Reversed face elements — pre-rotated so they read forward after card spins
@@ -1148,8 +1163,9 @@ $sea-card-h: 6.5rem;
}
// .sig-stage-card is normally scoped inside .sig-stage — re-apply the card shell
-// here so it renders correctly outside that context.
-.sea-cross .sig-stage-card {
+// here so it renders correctly outside that context. Class-based selector so it
+// also applies in the tray (.tray-sig-card .sig-stage-card.sea-sig-card).
+.sig-stage-card.sea-sig-card {
flex-shrink: 0;
width: var(--sig-card-w, #{$sea-card-w});
height: auto;
@@ -1498,9 +1514,9 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
.fan-card-name-group { font-size: calc(var(--sig-card-w, 140px) * 0.073); opacity: 0.6; }
.sig-qualifier-above,
.sig-qualifier-below,
- .fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 140px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
+ .fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 140px) * 0.08); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; text-wrap: balance; }
.fan-card-name,
- .fan-card-reversal-name { font-size: calc(var(--sig-card-w, 140px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
+ .fan-card-reversal-name { font-size: calc(var(--sig-card-w, 140px) * 0.08); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; text-wrap: balance; }
.fan-card-arcana { font-size: calc(var(--sig-card-w, 140px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
.fan-card-reversal-qualifier,
.fan-card-reversal-name { transform: rotate(180deg); opacity: 0.25; }
diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss
index 7bc640a..30faabc 100644
--- a/src/static_src/scss/_tray.scss
+++ b/src/static_src/scss/_tray.scss
@@ -147,18 +147,18 @@ $handle-r: 1rem;
}
}
+// Hosts the same compact rank-+-icon Sig card used in the Sea Select center
+// (.sig-stage-card.sea-sig-card). Width is sized so the 5:8-aspect card
+// height ≈ tray cell height.
.tray-sig-card {
- padding: 0;
- overflow: hidden;
+ padding: 0;
background: transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
- img {
- display: block;
- width: 100%;
- height: 100%;
- object-fit: cover;
- object-position: center;
- transform: scale(1.4); // crop SVG's internal margins
+ .sig-stage-card.sea-sig-card {
+ --sig-card-w: calc(var(--tray-cell-size, 48px) * 5 / 8);
}
}
diff --git a/src/templates/apps/gameboard/_partials/_sea_overlay.html b/src/templates/apps/gameboard/_partials/_sea_overlay.html
index 553e32d..4e53c55 100644
--- a/src/templates/apps/gameboard/_partials/_sea_overlay.html
+++ b/src/templates/apps/gameboard/_partials/_sea_overlay.html
@@ -13,7 +13,7 @@
Draw +6 cards to describe your character's influences and seed the game-map. Draw +6 cards to describe your character's influences and seed the map.PICK SEA
-
{{ card.levity_emanation|italicize:card.italic_word }}
{% else %} {% if card.name_group %}{{ card.name_group }}
{% endif %} {% if card.arcana != "MAJOR" and card.levity_qualifier %}{{ card.levity_qualifier }}
{% endif %} -{{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}
{% if card.arcana == "MAJOR" and card.levity_qualifier %}{{ card.levity_qualifier }}
{% endif %} @@ -49,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. #} -{{ card.levity_reversal|italicize:card.italic_word }}
+{{ card.levity_reversal|italicize:card.italic_word }}
{% elif card.arcana == "MAJOR" %}{{ card.levity_qualifier|default:card.gravity_qualifier }}
-{{ card.name_title|italicize:card.italic_word }}{% if card.levity_qualifier %},{% endif %}
+{{ card.name_title|italicize:card.italic_word }}{% if card.levity_qualifier %},{% endif %}
{% else %} -{{ card.name_title|italicize:card.italic_word }}
+{{ card.name_title|italicize:card.italic_word }}
{{ card.reversal_qualifier|default:card.gravity_qualifier }}
{% endif %}