diff --git a/src/apps/billboard/tests/integrated/test_views.py b/src/apps/billboard/tests/integrated/test_views.py
index 858d2ad..02bec10 100644
--- a/src/apps/billboard/tests/integrated/test_views.py
+++ b/src/apps/billboard/tests/integrated/test_views.py
@@ -919,12 +919,12 @@ class MySignViewTest(TestCase):
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")
+ # Polish-4 — header is a 2-row vertical stack: rank on row 1
+ # (direct child), icon+label inside `.stat-chip-tag` on row 2.
+ [_rank] = header.cssselect(".stat-chip-rank")
+ [_tag] = header.cssselect(".stat-chip-tag")
+ [_icon] = _tag.cssselect("i.stat-chip-icon")
+ [_label] = _tag.cssselect(".stat-face-label")
def test_save_sign_get_redirects_back_to_picker(self):
response = self.client.get(reverse("billboard:save_sign"))
@@ -1128,13 +1128,15 @@ class BillboardAppletMySignTest(TestCase):
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")
+ # Polish-4 — rank is a direct child of header (own row); icon lives
+ # inside `.stat-chip-tag` (row-2 inline w. the EMANATION label).
+ [rank] = header.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())
+ [tag] = header.cssselect(".stat-chip-tag")
+ [icon] = tag.cssselect("i.stat-chip-icon")
# Middle court has a suit, so the suit-icon `` 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()))
diff --git a/src/apps/gameboard/static/apps/gameboard/game-kit.js b/src/apps/gameboard/static/apps/gameboard/game-kit.js
index 88c7dcf..d415198 100644
--- a/src/apps/gameboard/static/apps/gameboard/game-kit.js
+++ b/src/apps/gameboard/static/apps/gameboard/game-kit.js
@@ -389,12 +389,13 @@ 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;
+ // Sprint A.7.5-polish-4 — image-mode + text-mode share the
+ // same SPIN path: toggle `.stage-card--reversed` + reset
+ // inline transform. The existing `transition: transform
+ // 0.18s ease-out` on `.fan-card` (set in updateFan) handles
+ // the visual rotation — no Element.animate layered on top,
+ // which had caused a double/triple-flip when the JS anim
+ // overlay + the CSS transition both fired.
active.classList.toggle('stage-card--reversed');
var t = cardTransform(0);
var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : '';
diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py
index 6f99100..5301907 100644
--- a/src/apps/gameboard/tests/integrated/test_views.py
+++ b/src/apps/gameboard/tests/integrated/test_views.py
@@ -781,10 +781,13 @@ class GameKitViewTest(TestCase):
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")
+ # Polish-4 — rank is a direct child of the header (own row);
+ # icon + label live in `.stat-chip-tag` (row-2 inline). No
+ # `.stat-face-chip` wrapper.
[_rank] = header.cssselect(".stat-chip-rank")
- [_icon] = header.cssselect("i.stat-chip-icon")
- [_label] = header.cssselect(".stat-face-label")
+ [_tag] = header.cssselect(".stat-chip-tag")
+ [_icon] = _tag.cssselect("i.stat-chip-icon")
+ [_label] = _tag.cssselect(".stat-face-label")
[_title] = face[0].cssselect(".stat-face-title")
[_arcana] = face[0].cssselect(".stat-face-arcana")
@@ -1007,10 +1010,10 @@ class MySeaViewTest(TestCase):
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")
+ [_rank] = header.cssselect(".stat-chip-rank")
+ [_tag] = header.cssselect(".stat-chip-tag")
+ [_icon] = _tag.cssselect("i.stat-chip-icon")
+ [_label] = _tag.cssselect(".stat-face-label")
class MySeaDrawSeaLandingViewTest(TestCase):
diff --git a/src/static_src/scss/_billboard.scss b/src/static_src/scss/_billboard.scss
index 55789ea..d4e181b 100644
--- a/src/static_src/scss/_billboard.scss
+++ b/src/static_src/scss/_billboard.scss
@@ -631,36 +631,44 @@ body.page-billposts {
// 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;
+ margin: 0;
+ // text-decoration dropped in polish-4 — the `.stat-face-header`
+ // border-bottom (below) now underscores the whole header unit.
}
- // 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.
+ // Sprint A.7.5-polish-4 — header is a 2-row vertical stack: rank on
+ // row 1 (room for long Roman numerals), icon + EMANATION/REVERSAL on
+ // row 2 inline. Border-bottom underscores both rows as one header
+ // unit, separating it from the title block below. Sized off the
+ // applet's `--applet-card-w` container-query var rather than `--sig-
+ // card-w`. Mirrors the shared mixin shape; deliberate parallel since
+ // applet sizing is independent.
.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);
+ align-items: flex-start;
+ gap: calc(var(--applet-card-w) * 0.015);
+ margin: 0 0 calc(var(--applet-card-w) * 0.06);
+ padding-bottom: calc(var(--applet-card-w) * 0.035);
+ border-bottom: 0.05rem solid rgba(var(--secUser), 0.4);
+ }
+ .stat-chip-rank {
+ font-size: calc(var(--applet-card-w) * 0.12);
+ font-weight: bold;
line-height: 1;
color: rgba(var(--secUser), 1);
- font-weight: bold;
+ &:empty { display: none; }
+ }
+ .stat-chip-tag {
+ display: inline-flex;
+ align-items: baseline;
+ gap: calc(var(--applet-card-w) * 0.04);
+ line-height: 1;
- .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; }
+ i {
+ font-size: calc(var(--applet-card-w) * 0.085);
+ color: rgba(var(--secUser), 1);
+ }
}
// Sprint A.7-polish-3 — title + arcana in applet stat-block per
diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss
index 968b415..8ef0614 100644
--- a/src/static_src/scss/_card-deck.scss
+++ b/src/static_src/scss/_card-deck.scss
@@ -53,7 +53,13 @@
.stat-face {
display: none;
- padding: calc(var(--sig-card-w, 120px) * 0.37)
+ // Sprint A.7.5-polish-4 — top-pinned content per user-spec 2026-05-25 PM
+ // ("pin the number/alphanumeric at the top and the rest of the content
+ // cascades down from it, instead of pinning the arcana type in the center
+ // and stacking the rest of the content atop it"). Was top: 0.37 of card-w
+ // (visually-centered the arcana mid-stat-block); now uniform 0.1 so the
+ // chip header sits at the actual top edge + cascade flows down.
+ padding: calc(var(--sig-card-w, 120px) * 0.1)
calc(var(--sig-card-w, 120px) * 0.1)
calc(var(--sig-card-w, 120px) * 0.08);
}
@@ -77,72 +83,65 @@
// 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 /
- // Reversal headers in the image-mode stat block; same spec re-
- // applied universally here so non-image-mode stat blocks get the
- // same visual treatment).
- text-decoration: underline;
- text-underline-offset: 0.15em;
+ margin: 0;
+ // text-decoration: underline dropped in polish-4 — the new
+ // `.stat-face-header` border-bottom underscores the whole header
+ // (chip + icon + label) as a single visual unit, separating it
+ // from the title block below.
}
- // 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).
+ // Sprint A.7.5-polish-4 — header is now a two-row vertical stack so
+ // long Roman numerals (e.g. XXVIII) get their own line w. room to
+ // breathe; row-2 holds the suit-icon + EMANATION/REVERSAL label
+ // inline (the icon is always 1 char so it never overflows). The
+ // border-bottom underscores both rows as one header unit, replacing
+ // the prior per-label text-decoration: underline.
.stat-face-header {
display: flex;
+ flex-direction: column;
align-items: flex-start;
- gap: calc(var(--sig-card-w, 120px) * 0.05);
+ gap: calc(var(--sig-card-w, 120px) * 0.02);
margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07);
-
- .stat-face-label { margin: 0; } // header owns the bottom margin now
+ padding-bottom: calc(var(--sig-card-w, 120px) * 0.04);
+ border-bottom: 0.05rem solid rgba(var(--secUser), 0.4);
}
- .stat-face-chip {
- display: inline-flex;
- flex-direction: column;
- align-items: center;
- gap: calc(var(--sig-card-w, 120px) * 0.012);
+ .stat-chip-rank {
+ font-size: calc(var(--sig-card-w, 120px) * 0.105);
+ font-weight: bold;
line-height: 1;
color: rgba(var(--secUser), 1);
- font-weight: bold;
+ &:empty { display: none; }
+ }
+
+ .stat-chip-tag {
+ display: inline-flex;
+ align-items: baseline;
+ gap: calc(var(--sig-card-w, 120px) * 0.04);
+ line-height: 1;
- .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;
+ font-size: calc(var(--sig-card-w, 120px) * 0.083);
+ color: rgba(var(--secUser), 1);
}
-
- // 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):
// - MAJOR → --terUser (gold)
- // - MINOR / MIDDLE → --quiUser (cream)
- // Matches the contour-stroke color on the image-mode card so card +
- // title read as a coordinated pair.
+ // - MINOR / MIDDLE → --quaUser (bright yellow-gold, user-spec 2026-
+ // 05-25 PM "only the My Sign applet has --quaUser as a font color;
+ // the rest are --quiUser. Let's change the latter to match the
+ // former" — applet's `.stat-face-title` was already --quaUser;
+ // shared mixin now matches so all 4 stat-block surfaces unify).
.stat-face-title {
font-size: calc(var(--sig-card-w, 120px) * 0.105);
font-weight: 700;
line-height: 1.15;
margin: 0 0 calc(var(--sig-card-w, 120px) * 0.03);
text-wrap: balance;
- color: rgba(var(--quiUser), 1);
+ color: rgba(var(--quaUser), 1);
}
[data-arcana-key="MAJOR"] .stat-face-title {
color: rgba(var(--terUser), 1);
@@ -1301,6 +1300,14 @@ html:has(.sig-backdrop) {
background: rgba(var(--secUser), 1);
border-color: rgba(var(--priUser), 0.6);
color: rgba(var(--priUser), 1); // currentColor propagates to .fan-corner-rank + i
+
+ // Sprint A.7.5-polish-4 — same image-mode override as the base rule
+ // above. Without this the 0,3,0 levity rule's --secUser bg would
+ // re-clothe the sea-sig-card under levity even in image mode.
+ &.sig-stage-card--image {
+ background: transparent;
+ border: 0;
+ }
}
// ─── Sig select: landscape overrides ─────────────────────────────────────────
@@ -1697,6 +1704,19 @@ $sea-card-h: 6.5rem;
padding: 0.25rem;
overflow: hidden;
+ // Sprint A.7.5-polish-4 — image-mode override. `.sig-stage-card.sea-sig-
+ // card` (0,2,0) matches the shared `.sig-stage-card.sig-stage-card--image`
+ // comma-list rule's specificity but source-loses to it — so we re-state
+ // the transparency here AT 0,3,0 (parent + 2 sibling classes). Mirrors
+ // the my_sign-applet pattern in `_billboard.scss`. Filter chain on
+ // `.sig-stage-card-img` is still inherited from the shared rule (the
+ // image-mode rule's img descendant selector doesn't lose anywhere).
+ &.sig-stage-card--image {
+ background: transparent;
+ border: 0;
+ padding: 0;
+ }
+
.fan-card-face {
flex: 1;
display: flex;
diff --git a/src/templates/apps/billboard/_partials/_applet-my-sign.html b/src/templates/apps/billboard/_partials/_applet-my-sign.html
index 4a5fed0..928c900 100644
--- a/src/templates/apps/billboard/_partials/_applet-my-sign.html
+++ b/src/templates/apps/billboard/_partials/_applet-my-sign.html
@@ -79,11 +79,11 @@
{# rendering]]'s A.3 Q3 spec. Server-rendered (read-only #}
{# applet — no JS populate path). #}
Emanation Emanation
{{ card.name }}
{{ card.get_arcana_display }}
diff --git a/src/templates/apps/billboard/my_sign.html b/src/templates/apps/billboard/my_sign.html index 556047a..158742e 100644 --- a/src/templates/apps/billboard/my_sign.html +++ b/src/templates/apps/billboard/my_sign.html @@ -90,11 +90,11 @@ {# populateStatExtras fills `.stat-chip-rank` + `.stat-chip-icon`.#}Emanation
+Emanation
+Reversal
+Reversal
+Emanation
+Emanation
+Reversal
+Reversal
+Emanation
+Emanation
+Reversal
+Reversal
+