dubbodeck per-card FLIP back: the sig stage FLIPs each card to its OWN deck's back, not the seat's — finishes the cross-deck story — TDD

Pairs with the dubbodeck assembly: a mixed pile can now hold cards from up to three decks per polarity, so the single server-rendered back (the viewer's seat deck) was wrong for any cross-deck card. Now per-card: each .sig-card thumbnail carries data-back-image-url (its OWN deck back, only for non-polarized image decks; empty otherwise); stage-card.js fromDataset reads back_image_url + _setImageMode repoints the stage .sig-stage-card-back-img at the FOCUSED card's back and shows/hides the FLIP affordance per card (a backless card in the pile hides FLIP + drops any flipped state).

- _sig_select_overlay.html: the stage back-img + FLIP now render on sig_pile_has_backs (ANY pile card is a non-polarized image deck) instead of the viewer's-seat-deck gate, so an RWS grails/blades card in a PC-earthman viewer's pile can FLIP. Initial src dropped (JS sets it on first focus). epic/views.py computes the flag where sig_cards is set; _court_cards/_major_cards gained select_related(deck_variant) to keep the per-card deck access N+1-free.

TDD: 3 ITs in SigSelectUnifiedStageTest (non-polarized image deck -> per-card /static back url + sig_pile_has_backs + stage back-img/FLIP rendered; polarized deck -> empty back + no FLIP element; text-only deck -> empty back). 562 epic view+model ITs green. The JS repoint/hide is the stage-Jasmine debt (deferred) — manually verified.

- bundled (parallel work): rootvars.scss ongoing palette tuning.

[[project-deck-segment-model]] [[project-image-based-deck-face-rendering]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-08 20:32:47 -04:00
parent 034639d335
commit 4aee5016c1
6 changed files with 114 additions and 38 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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 */
}

View File

@@ -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). #}
<img class="sig-stage-card-img" alt="" style="display:none">
{# 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 %}
<img class="sig-stage-card-back-img" alt="" src="{{ user_seat.deck_variant.back_image_url }}">
{# 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 %}
<img class="sig-stage-card-back-img" alt="">
<button class="btn btn-reveal my-sign-flip-btn sig-flip-btn" type="button">FLIP</button>
{% endif %}
<div class="fan-card-corner fan-card-corner--tl">
@@ -91,6 +93,10 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
data-arcana="{{ card.get_arcana_display }}"
data-arcana-key="{{ card.arcana }}"
data-image-url="{{ card.image_url }}"
{# Per-card FLIP back (dubbodeck): the card's OWN deck back, only #}
{# for non-polarized image decks (others empty stage-card.js #}
{# hides the FLIP affordance for that card). #}
data-back-image-url="{% if card.deck_variant.has_card_images and not card.deck_variant.is_polarized %}{{ card.deck_variant.back_image_url }}{% endif %}"
data-correspondence="{{ card.correspondence|default:'' }}"
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"