diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py
index 3ad8814..e29d826 100644
--- a/src/apps/epic/models.py
+++ b/src/apps/epic/models.py
@@ -817,7 +817,7 @@ def _court_cards(deck_variant, suits):
arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR],
suit__in=suits,
number__in=[11, 12, 13, 14],
- ))
+ ).select_related("deck_variant"))
def _major_cards(deck_variant):
@@ -826,7 +826,7 @@ def _major_cards(deck_variant):
return []
return list(TarotCard.objects.filter(
deck_variant=deck_variant, arcana=TarotCard.MAJOR, number__in=[0, 1],
- ))
+ ).select_related("deck_variant"))
def _polarity_sig_cards(room, polarity):
diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js
index 24a2aa8..70f72a8 100644
--- a/src/apps/epic/static/apps/epic/stage-card.js
+++ b/src/apps/epic/static/apps/epic/stage-card.js
@@ -55,6 +55,10 @@ var StageCard = (function () {
// border-color CSS var (--terUser for major, --quiUser for the rest).
image_url: el.dataset.imageUrl || '',
arcana_key: el.dataset.arcanaKey || '',
+ // Per-card FLIP back (dubbodeck) — the focused card's OWN deck back.
+ // Empty for backless (text-only / polarized) decks → _setImageMode
+ // hides the FLIP affordance for that card in a mixed pile.
+ back_image_url: el.dataset.backImageUrl || '',
};
}
@@ -135,6 +139,24 @@ var StageCard = (function () {
img.removeAttribute('src');
}
}
+ // Per-card FLIP back (dubbodeck): point the stage back-img at the FOCUSED
+ // card's own deck back + show/hide the FLIP affordance for cards whose
+ // deck has no back (a mixed pile can hold backless cards next to image
+ // ones). Guarded on the back-img element existing — only rendered when the
+ // pile has backable cards (`sig_pile_has_backs`), so my_sign's single
+ // server-rendered back (no per-card data) is untouched.
+ var backImg = stageCard.querySelector('.sig-stage-card-back-img');
+ if (backImg) {
+ var flipBtn = stageCard.querySelector('.sig-flip-btn');
+ if (card.back_image_url) {
+ backImg.src = card.back_image_url;
+ if (flipBtn) flipBtn.style.display = '';
+ } else {
+ // Backless focused card — drop any flipped state + hide FLIP.
+ stageCard.classList.remove('is-flipped-to-back');
+ if (flipBtn) flipBtn.style.display = 'none';
+ }
+ }
}
// Paint the stage-card's upright + reversal faces from a normalized card
diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py
index 8fc1cbc..5cb20fd 100644
--- a/src/apps/epic/tests/integrated/test_views.py
+++ b/src/apps/epic/tests/integrated/test_views.py
@@ -1798,6 +1798,41 @@ class SigSelectUnifiedStageTest(TestCase):
content = self.client.get(self.url).content.decode()
self.assertIn('data-image-url=""', content)
+ # ── Per-card FLIP-to-back (dubbodeck) ───────────────────────────────────
+ def test_backable_deck_renders_per_card_back_url_and_flip(self):
+ # Non-polarized image deck → each sig card carries its OWN deck back +
+ # the stage renders the FLIP affordance (sig_pile_has_backs True), so a
+ # mixed pile can FLIP each card to its own deck's back via stage-card.js.
+ self.earthman.has_card_images = True
+ self.earthman.is_polarized = False
+ self.earthman.save(update_fields=["has_card_images", "is_polarized"])
+ resp = self.client.get(self.url)
+ content = resp.content.decode()
+ self.assertIn('data-back-image-url="/static', content)
+ self.assertTrue(resp.context["sig_pile_has_backs"])
+ self.assertIn("sig-stage-card-back-img", content)
+ self.assertIn("sig-flip-btn", content)
+
+ def test_polarized_deck_omits_per_card_back_and_flip(self):
+ # Polarized decks don't get a FLIP-to-back (polarity IS their flip) →
+ # empty per-card back + no stage back-img/FLIP element.
+ self.earthman.has_card_images = True
+ self.earthman.is_polarized = True
+ self.earthman.save(update_fields=["has_card_images", "is_polarized"])
+ resp = self.client.get(self.url)
+ content = resp.content.decode()
+ self.assertIn('data-back-image-url=""', content)
+ self.assertFalse(resp.context["sig_pile_has_backs"])
+ self.assertNotIn("sig-stage-card-back-img", content)
+
+ def test_textonly_deck_omits_per_card_back(self):
+ # Glyph-only deck → empty per-card back + no FLIP element.
+ self.earthman.has_card_images = False
+ self.earthman.save(update_fields=["has_card_images"])
+ resp = self.client.get(self.url)
+ self.assertIn('data-back-image-url=""', resp.content.decode())
+ self.assertFalse(resp.context["sig_pile_has_backs"])
+
# ── Workstream C — felt replaces hex-pane content (my_sea-style) ────────
def test_sig_stage_on_felt_in_hex_pane_no_dark_backdrop(self):
content = self.client.get(self.url).content.decode()
diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py
index 5650220..b25eea6 100644
--- a/src/apps/epic/views.py
+++ b/src/apps/epic/views.py
@@ -648,6 +648,17 @@ def _role_select_context(room, user, seat_param=None):
ctx["sig_cards"] = gravity_sig_cards(room, user)
else:
ctx["sig_cards"] = []
+ # Per-card FLIP-to-back (dubbodeck): the stage back-img + FLIP affordance
+ # render when ANY card in the (possibly mixed-deck) pile has a
+ # non-polarized image-deck back; stage-card.js then points the back-img at
+ # the FOCUSED card's OWN deck. Was gated on the viewer's seat deck only —
+ # wrong once a pile mixes decks (an RWS card in a PC-earthman viewer's pile
+ # could never FLIP). select_related on the assembly keeps this N+1-free.
+ ctx["sig_pile_has_backs"] = any(
+ c.deck_variant_id and c.deck_variant.has_card_images
+ and not c.deck_variant.is_polarized
+ for c in ctx["sig_cards"]
+ )
if room.table_status == Room.SKY_SELECT:
# CARTE seat-switch: the sky/sea state is per-SEAT (Character.seat), so key
diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss
index b5cc5b9..3e31876 100644
--- a/src/static_src/scss/rootvars.scss
+++ b/src/static_src/scss/rootvars.scss
@@ -17,26 +17,26 @@
/* Precious Metal Hues */
// nickel
--priNi: 141, 142, 140;
- --secNi: 118, 120, 118;
- --terNi: 93, 95, 94;
- --terNi: 0, 0, 0;
- --quaNi: 0, 0, 0;
- --quiNi: 0, 0, 0;
- --sixNi: 0, 0, 0;
+ --secNi: 108, 120, 108;
+ --terNi: 80, 108, 80;
+ --quaNi: 63, 95, 64;
+ --quiNi: 43, 75, 44;
+ --sixNi: 23, 56, 26;
+
// palladium
- --priPd: 188, 193, 165;
- --secPd: 155, 160, 138;
- --terPd: 124, 129, 111;
- --quaPd: 0, 0, 0;
- --quiPd: 0, 0, 0;
- --sixPd: 0, 0, 0;
+ --priPd: 218, 223, 181;
+ --secPd: 188, 193, 165;
+ --terPd: 155, 160, 138;
+ --quaPd: 124, 129, 111;
+ --quiPd: 97, 99, 76;
+ --sixPd: 72, 75, 0;
// platinum
--priPt: 229, 228, 226;
- --secPt: 189, 190, 189;
- --terPt: 152, 153, 153;
- --quaPt: 0, 0, 0;
- --quiPt: 0, 0, 0;
- --sixPt: 0, 0, 0;
+ --secPt: 191, 190, 188;
+ --terPt: 152, 151, 149;
+ --quaPt: 122, 121, 119;
+ --quiPt: 92, 91, 89;
+ --sixPt: 62, 61, 59;
// titanium
--priTi: 38, 57, 69;
--secTi: 57, 79, 94;
@@ -350,16 +350,18 @@
--secBlt: 137, 107, 32;
// white
--terBlt: 255, 255, 255;
- // --quaBlt: ;
+ // red
+ --quaBlt: 178, 12, 18;
// black
--quiBlt: 0, 0, 0;
--sixBlt: 162, 170, 173;
// purple
--sepBlt: 50, 30, 95;
+ // red again
--octBlt: 157, 34, 53;
// orange
- --ninBlt: 221, 73, 38;
- --decBlt: 181, 57, 30;
+ --ninBlt: 200, 63, 28;
+ --decBlt: 121, 39, 22;
// Felt values
--undUser: var(--priFor);
@@ -452,11 +454,11 @@
.palette-baltimore {
--priUser: var(--sepBlt);
--secUser: var(--sixBlt);
- --terUser: var(--ninBlt);
+ --terUser: var(--quaBlt);
--quaUser: var(--priBlt);
--quiUser: var(--secBlt);
--sixUser: var(--ninBlt);
- --sepUser: var(--quiBlt);
+ --sepUser: var(--quaBlt);
--octUser: var(--decBlt);
--ninUser: var(--terBlt);
--decUser: var(--quiBlt);
@@ -466,12 +468,12 @@
.palette-maryland {
--priUser: var(--quiBlt);
--secUser: var(--sixBlt);
- --terUser: var(--secBlt);
- --quaUser: var(--priYl);
- --quiUser: var(--octBlt);
- --sixUser: var(--quiBlt);
- --sepUser: var(--quiBlt);
- --octUser: var(--quiBlt);
+ --terUser: var(--priYl);
+ --quaUser: var(--priBlt);
+ --quiUser: var(--quaBlt);
+ --sixUser: var(--octBlt);
+ --sepUser: var(--sepBlt);
+ --octUser: var(--decBlt);
--ninUser: var(--priRd);
--decUser: var(--quiBlt);
}
@@ -483,9 +485,9 @@
--terUser: var(--sixAg); /* 240,240,240 — bright white accent */
--quaUser: var(--sixAg); /* 240,240,240 — active/interactive */
--quiUser: var(--secAg); /* 133,133,133 — secondary action */
- --sixUser: var(--quaAg); /* 100,100,100 — subtle mid */
- --sepUser: var(--priPt); /* 60,60,60 — deep subtle */
- --octUser: var(--quiPt); /* 189,190,189 — links (cooler silver) */
+ --sixUser: var(--secFe); /* 100,100,100 — subtle mid */
+ --sepUser: var(--quiPd); /* 60,60,60 — deep subtle */
+ --octUser: var(--priFe); /* 189,190,189 — links (cooler silver) */
--ninUser: var(--sixAg); /* 240,240,240 — glow highlight */
--decUser: var(--terAg); /* 100,100,100 — mid tone */
}
diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
index c2b679a..6cae219 100644
--- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
+++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
@@ -29,14 +29,16 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
{# once the dubbodeck assembly lands (each card keeps its deck). #}
{# Non-polarized image decks (RWS / Minchiate): a FLIP .btn-reveal #}
- {# turns the preview to the deck's card-back (my_sign parity). The #}
+ {# turns the preview to the FOCUSED card's deck card-back. The #}
{# back-img defaults display:none via SCSS; .is-flipped-to-back #}
{# (toggled by sig-select.js) reveals it. The shared #}
{# .my-sign-flip-btn rules already anchor/hide/counter-position on #}
- {# any .sig-stage-card. Gated on the SEAT deck for now — per-card #}
- {# backs arrive with the dubbodeck assembly (mixed-deck piles). #}
- {% if user_seat.deck_variant.has_card_images and not user_seat.deck_variant.is_polarized %}
-
+ {# any .sig-stage-card. Rendered when ANY pile card is backable #}
+ {# (`sig_pile_has_backs`); stage-card.js `_setImageMode` repoints #}
+ {# the src per focused card + hides FLIP for backless cards — so a #}
+ {# mixed dubbodeck pile FLIPs each card to its OWN deck's back. #}
+ {% if sig_pile_has_backs %}
+
{% endif %}