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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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:',' }}"
|
||||
|
||||
Reference in New Issue
Block a user