room Sig Select: unify with the my_sign card-stage apparatus (DRY stat-block, per-card stage image, --duoUser felt)

The in-room SIG_SELECT stage diverged from the polished GAME SIGN page:
a fixed dark-Gaussian modal over the hex, a stale label-only stat-block,
and no card imagery. This brings it in line with my_sign / my_sea.

A — Stat-block DRY: _sig_select_overlay.html now renders the shared
core/_partials/_stat_face.html (rank-chip + title + arcana + keywords)
instead of a reduced label-only copy; sig-select.js's updateStage() now
calls StageCard.populateStatExtras (the missing call that left those
fields blank). data-arcana-key added per card for title color-keying.

B — Per-card stage image: the stage card gains a .sig-stage-card-img
slot + data-image-url per thumbnail, so an image-equipped seat deck
(RWS / Minchiate) shows real card art on the preview. Thumbnails stay
glyph-only (rank + suit) at every deck — only the stage shows the image.
Keyed off each card's OWN deck_variant, so it auto-upgrades to mixed art
when the dubbodeck assembly lands. No backend change (cards already
carry a deck_variant via _room_deck_variant).

C — Felt-in-aperture: the stage renders INSIDE .room-hex-pane on edge-to-
edge green --duoUser felt (my_sea-style), replacing the hex content; the
old .sig-backdrop blur is gone. .sig-overlay absolute-fills the pane
(.room-hex-pane.has-sig-stage = positioning context); dismissing it
reveals the hex + waiting message behind. Scroll-down still reaches the
reelhouse carousel (untouched scroll pane).

Polishes:
- Image-mode bg escape: the levity 0,3,0 polarity rule
  (.sig-overlay/.my-sign-page[data-polarity="levity"] .sig-stage-card)
  hard-set a --secUser background that re-clothed image cards behind the
  transparent PNG. Added the &.sig-stage-card--image { background:
  transparent; border:0; overflow:visible } escape (parity w. the base +
  my-sea rules). Latent my_sign bug too. Monodeck-era assumption.
- FLIP .btn-reveal: non-polarized image decks get a FLIP that turns the
  preview to the deck card-back (my_sign parity) — back-img + reused
  .my-sign-flip-btn (shared positioning/hide/counter-position rules
  already cover .sig-stage-card) + a frozen-gated reveal scoped to
  .sig-overlay + sig-select.js _flipToBack (500ms Y-rotate, midpoint
  swap). SPIN now sets data-spinning so the btn hides mid-rotate.
- Reserved thumbs-up / hover cursors portal to a body-root fixed
  container, so they hung over the reelhouse on scroll. sig-select.js now
  toggles .cursors-hidden off the aperture scrollTop: instant hide the
  moment the scroll leaves the hex, 0.5s opacity ease-in on the full
  return. Tray intentionally kept.

TDD: SigSelectUnifiedStageTest (6 ITs) — DRY stat-face present, per-card
data-image-url + data-arcana-key, .sig-stage-card-img slot, image deck
non-empty face URL / text deck empty, has-sig-stage felt + overlay inside
the hex pane. 319 epic test_views ITs green; user-verified live on an RWS
room (no rect, FLIP works, thumb timing). Jasmine for the JS wiring +
the dubbodeck cross-deck assembly (per-seat segment cards, CARTE-solo
both-polarity case, per-card backs) are the tracked follow-on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-03 02:37:13 -04:00
parent 148fcac7af
commit 71c00699a1
5 changed files with 231 additions and 38 deletions

View File

@@ -98,6 +98,11 @@ var SigSelect = (function () {
uprightSel: '#id_stat_keywords_upright',
reversedSel: '#id_stat_keywords_reversed',
});
// Fill the rank-chip + title + arcana of the DRY _stat_face.html block
// (unified w. my_sign 2026-06-03) — previously the sig stat-block was a
// reduced label-only copy, so this call was absent and those fields
// stayed blank.
StageCard.populateStatExtras(statBlock, card);
stageCard.style.display = '';
stage.classList.add('sig-stage--active');
@@ -113,6 +118,32 @@ var SigSelect = (function () {
updateStage(cardEl);
}
// ── FLIP — reveal the deck card-back (non-polarized image decks) ──────
//
// my_sign parity (its `_flipToBackAnimated`): a 500ms Y-axis half-rotation
// that swaps the front face for the deck's card-back at the midpoint. The
// back-img + .sig-flip-btn render only for non-polarized image decks
// (template gate), so this no-ops on Earthman/text decks. SPIN orientation
// (.stage-card--reversed) is preserved through the flip. `data-flipping`
// hides the in-card FLIP btn mid-rotate (shared SCSS rule).
function _flipToBack() {
if (!stageCard || stageCard.dataset.flipping) return;
stageCard.dataset.flipping = '1';
var spin = stageCard.classList.contains('stage-card--reversed')
? ' rotate(180deg)' : '';
var rest = 'translateX(0px) rotateY(0deg) scale(1)' + spin;
var mid = rest + ' rotateY(90deg)';
stageCard.animate([
{ transform: rest },
{ transform: mid, offset: 0.5 },
{ transform: rest },
], { duration: 500, easing: 'ease' });
setTimeout(function () {
stageCard.classList.toggle('is-flipped-to-back');
}, 250);
setTimeout(function () { delete stageCard.dataset.flipping; }, 500);
}
// ── Hover events ──────────────────────────────────────────────────────
function onCardEnter(e) {
@@ -482,8 +513,9 @@ var SigSelect = (function () {
function _dismissSigOverlay() {
_hideCountdown();
_hideTakeSigBtn();
var backdrop = document.querySelector('.sig-backdrop');
if (backdrop) backdrop.remove();
// Removing the felt overlay reveals the hex + waiting message that sit
// behind it in the pane (no separate dark backdrop element anymore —
// the felt lives on .sig-overlay itself since the 2026-06-03 unify).
if (overlay) { overlay.remove(); overlay = null; }
// Remove all floating cursors (hover + thumbs-up) from the portal
Object.keys(_reservedFloats).forEach(function (role) {
@@ -594,9 +626,38 @@ var SigSelect = (function () {
_flipBtn.addEventListener('click', function () {
if (_flipBtn.classList.contains('btn-disabled')) return;
statBlock.classList.toggle('is-reversed');
// data-spinning hides the in-card FLIP btn for the 0.4s CSS rotate
// (shared `.sig-stage-card[data-spinning] .my-sign-flip-btn` rule)
// so it doesn't ride the spin between bottom-left + top-right.
stageCard.dataset.spinning = '1';
stageCard.classList.toggle('stage-card--reversed');
setTimeout(function () { delete stageCard.dataset.spinning; }, 400);
});
// FLIP-to-back (non-polarized image decks only — btn renders just then).
var sigFlipBtn = stageCard.querySelector('.sig-flip-btn');
if (sigFlipBtn) {
sigFlipBtn.addEventListener('click', _flipToBack);
}
// Reserved thumbs-up + hover cursors portal to a body-root fixed
// container, so they'd hang over the reelhouse when the aperture scroll-
// snaps down to it. Drive their visibility straight off the aperture's
// scrollTop: 0 == snapped on the hex (show); any non-zero == the user
// has begun scrolling toward the reelhouse (hide). This vanishes them
// the INSTANT the scroll starts and restores them only once it lands
// fully back on the hex — user-spec timing 2026-06-03.
var _aperture = document.getElementById('id_room_aperture');
if (_aperture) {
_aperture.addEventListener('scroll', function () {
var portal = document.getElementById('id_sig_cursor_portal');
// .cursors-hidden hides INSTANTLY (transition:none) at scroll
// start; removing it on the return eases opacity 0→1 over 0.5s
// via the portal's base transition.
if (portal) portal.classList.toggle('cursors-hidden', _aperture.scrollTop > 0);
}, { passive: true });
}
cautionEl = stage.querySelector('.sig-info');
cautionEffect = cautionEl.querySelector('.sig-info-effect');
cautionTitle = cautionEl.querySelector('.sig-info-title');

View File

@@ -1494,6 +1494,69 @@ class SigSelectRenderingTest(TestCase):
self.assertContains(response, "fyi-next")
class SigSelectUnifiedStageTest(TestCase):
"""Sig Select stage unified with the my_sign card-stage apparatus:
the shared DRY _stat_face.html stat-block (rank-chip + title + arcana,
not just keywords), per-card face-image plumbing, and the green
--duoUser felt that replaces the hex-pane content (my_sea-style) instead
of the old fixed dark-Gaussian modal. Founder is PC (levity), mid-pick."""
def setUp(self):
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
# ── Workstream A — DRY stat-block ───────────────────────────────────────
def test_stat_block_uses_dry_stat_face_partial(self):
# The overlay must render the SHARED _stat_face.html (rank-chip +
# title + arcana), not the old reduced label-only stat-face.
content = self.client.get(self.url).content.decode()
self.assertIn("stat-face-title", content)
self.assertIn("stat-chip-rank", content)
self.assertIn("stat-face-arcana", content)
# Keyword <ul> IDs preserved so sig-select.js's selectors still resolve.
self.assertIn('id="id_stat_keywords_upright"', content)
self.assertIn('id="id_stat_keywords_reversed"', content)
# ── Workstream B — per-card face-image plumbing ─────────────────────────
def test_sig_cards_carry_image_url_and_arcana_key(self):
content = self.client.get(self.url).content.decode()
self.assertIn("data-image-url=", content)
self.assertIn("data-arcana-key=", content)
def test_stage_card_has_image_slot(self):
content = self.client.get(self.url).content.decode()
self.assertIn("sig-stage-card-img", content)
def test_image_deck_renders_nonempty_face_url(self):
# Image-equipped seat deck → each sig card carries a real static path.
self.earthman.has_card_images = True
self.earthman.save(update_fields=["has_card_images"])
content = self.client.get(self.url).content.decode()
self.assertIn('data-image-url="/static', content)
def test_textonly_deck_renders_empty_face_url(self):
# Glyph-only deck (Earthman as seeded) → empty data-image-url, glyph
# fallback. The attribute still renders (plumbing present).
self.earthman.has_card_images = False
self.earthman.save(update_fields=["has_card_images"])
content = self.client.get(self.url).content.decode()
self.assertIn('data-image-url=""', content)
# ── 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()
# Felt modifier on the hex pane; the old dark Gaussian backdrop is gone.
self.assertIn("has-sig-stage", content)
self.assertNotIn("sig-backdrop", content)
# The overlay lives INSIDE the hex pane (before the scroll/views pane).
hex_pos = content.find("room-hex-pane")
overlay_pos = content.find("sig-overlay")
scroll_pos = content.find("room-scroll-pane")
self.assertNotEqual(overlay_pos, -1)
self.assertLess(hex_pos, overlay_pos)
self.assertLess(overlay_pos, scroll_pos)
class SelectSigCardViewTest(TestCase):
"""select_sig view — records choice, enforces turn order, rejects bad input."""

View File

@@ -609,33 +609,33 @@
&--next { right: 1rem; }
}
// ─── Sig Select overlay (SIG_SELECT phase) ────────────────────────────────────
// ─── Sig Select stage (SIG_SELECT phase) ──────────────────────────────────────
//
// Two overlays (levity / gravity) run in parallel, one per polarity group.
// Layout mirrors the gatekeeper: dark Gaussian backdrop + centred modal.
// Inside the modal: upper stage (card preview) + lower mini card grid (no scroll).
// Two stages (levity / gravity) run in parallel, one per polarity group.
// Unified w. my_sign / my_sea 2026-06-03: renders INSIDE .room-hex-pane on
// edge-to-edge green --duoUser felt (no dark Gaussian backdrop), replacing the
// hex content my_sea-style. Inside the modal: upper stage (card preview) +
// lower mini card grid (no scroll).
html:has(.sig-backdrop) {
overflow: hidden;
}
.sig-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(5px);
z-index: 100;
pointer-events: none;
// The hex pane becomes a positioning context only while the felt stage shows,
// so the absolute-filling overlay scopes to the pane (not the viewport) and
// the position-strip's root stacking stays untouched in every other phase.
.room-hex-pane.has-sig-stage {
position: relative;
}
.sig-overlay {
position: fixed;
position: absolute;
inset: 0;
display: flex;
align-items: stretch;
justify-content: center;
z-index: 120;
pointer-events: none;
z-index: 5;
// Edge-to-edge non-Gaussian green felt — an opaque surface that covers the
// hex/seats behind it; sig-select.js's _dismissSigOverlay removes the whole
// overlay (this gamer's sigs done) to reveal the hex + waiting message.
background: rgba(var(--duoUser), 1);
pointer-events: auto;
}
.sig-modal {
@@ -1077,6 +1077,15 @@ body.deck-family-english {
@extend %flip-btn-revealed;
}
// Room sig-select stage: same frozen-gated hover-reveal as my_sign, scoped to
// .sig-overlay (the FLIP btn renders only for non-polarized image seat decks).
// The FLIP appears once a card is OK-locked (.sig-stage--frozen), matching the
// my_sign-main rule above.
.sig-overlay .sig-stage--frozen .sig-stage-card:hover .my-sign-flip-btn,
.sig-overlay .sig-stage--frozen .sig-stage-card:has(.my-sign-flip-btn:hover) .my-sign-flip-btn {
@extend %flip-btn-revealed;
}
// Unified mid-flip-hide across all 4 surfaces. `[data-flipping]="1"` is set on
// the card by each surface's FLIP handler for the 500ms rotation duration;
// `%flip-btn-mid-flip`'s `display: none` makes the btn vanish INSTANTLY (per
@@ -1232,8 +1241,26 @@ body.deck-family-english {
pointer-events: none;
z-index: 200; // above sig-overlay (120), below tray (310)
overflow: visible;
opacity: 1;
transition: opacity 0.25s ease; // eases the cursors back in on scroll-return
}
// sig-select.js toggles this off the aperture scrollTop. `transition: none`
// makes the hide at scroll-start INSTANT (user-spec); removing the class on the
// return falls back to the base 0.5s opacity ease above — fade in, snap out.
#id_sig_cursor_portal.cursors-hidden {
opacity: 0;
transition: none;
}
// The reserved-sig thumbs-up + hover hand-pointers portal to this body-root
// fixed container (escaping the deck-grid clip), so when the table-hex aperture
// scroll-snaps DOWN to the reelhouse they'd otherwise hang over that view at
// their stale coords. sig-select.js toggles their visibility off an
// IntersectionObserver on the scroll pane (threshold 0) — hidden the instant
// the reelhouse begins to enter, restored only once it has fully left. (The
// tray is intentionally NOT hidden — user wants to keep it.)
.sig-cursor-float {
position: absolute;
font-size: 1.5rem;
@@ -1324,6 +1351,16 @@ body.deck-family-english {
.fan-card-name-group{ color: rgba(var(--priUser), 1); }
.fan-card-name { color: rgba(var(--quiUser), 1); }
.fan-card-arcana { color: rgba(var(--priUser), 1); }
// Image-mode parity w. the base (`.sig-stage-card--image` ~L795) + the
// my-sea sea-sig-card override below: this 0,3,0 levity bg/border must
// NOT re-clothe an image card — its transparent shell + contour-stroke
// PNG has to show through (else a solid --secUser rect sits behind the
// card). Was the missing escape that left the rect on RWS/Minchiate.
&.sig-stage-card--image {
background: transparent;
border: 0;
overflow: visible;
}
}
// Polarity title + qualifier text: --quiUser for levity (paired w. gravity's --terUser).
// All five selectors prefixed w. .sig-stage-card to match (or beat) the 0,4,0 specificity

View File

@@ -1,9 +1,11 @@
{% load i18n %}{% comment %}
Sig Select overlay — dark Gaussian modal over the dormant table hex.
Rendered for the current user's polarity group only.
Sig Select stage — unified with the my_sign / my_sea card-stage apparatus
(2026-06-03). Renders INSIDE .room-hex-pane on edge-to-edge green --duoUser
felt (no dark Gaussian backdrop), replacing the hex content my_sea-style;
sig-select.js bootstraps off `.sig-overlay`'s data-* payload. Rendered for
the current user's polarity group only.
Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_json
{% endcomment %}
<div class="sig-backdrop"></div>
<div class="sig-overlay"
data-polarity="{{ user_polarity }}"
data-user-role="{{ user_seat.role }}"
@@ -18,6 +20,24 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
<div class="sig-stage" id="id_sig_stage">
<div class="sig-stage-card" style="display:none">
{# Image-mode slot — stage-card.js _setImageMode shows this <img> #}
{# + adds .sig-stage-card--image when the focused card's #}
{# data-image-url is non-empty (image-equipped seat deck per #}
{# DeckVariant.has_card_images); else the text fan-card-* scaffold #}
{# below takes over. Per-card src — works for mixed-deck piles #}
{# 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 #}
{# 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 }}">
<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">
<span class="fan-corner-rank"></span>
<i class="fa-solid stage-suit-icon" style="display:none"></i>
@@ -48,14 +68,13 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
<div class="sig-stat-block">
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
<button class="btn btn-info fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
</div>
<div class="stat-face stat-face--reversed">
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div>
{# DRY stat-face (rank-chip + title + arcana + keywords) — the #}
{# same shared partial my_sign / sea_stage / fan use. No `card` #}
{# arg → JS-populated by sig-select.js (populateStatExtras + #}
{# populateKeywords). keywords_ul_id keeps the <ul> ids sig- #}
{# select.js's #id_stat_keywords_* selectors target. #}
{% include "core/_partials/_stat_face.html" with face_modifier="upright" label_text="Emanation" keywords_ul_id="id_stat_keywords_upright" %}
{% include "core/_partials/_stat_face.html" with face_modifier="reversed" label_text="Reversal" keywords_ul_id="id_stat_keywords_reversed" %}
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sig_tooltip" %}
</div>
</div>
@@ -69,6 +88,8 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
data-name-group="{{ card.name_group }}"
data-name-title="{{ card.name_title }}"
data-arcana="{{ card.get_arcana_display }}"
data-arcana-key="{{ card.arcana }}"
data-image-url="{{ card.image_url }}"
data-correspondence="{{ card.correspondence|default:'' }}"
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
@@ -83,6 +104,9 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
data-levity-reversal="{{ card.levity_reversal }}"
data-gravity-reversal="{{ card.gravity_reversal }}"
data-italic-word="{{ card.italic_word }}">
{# Thumbnails stay glyph-only (rank + suit symbol) at every deck #}
{# — `data-image-url` above feeds the STAGE preview's image only #}
{# (stage-card.js _setImageMode), never the thumbnail face. #}
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}

View File

@@ -31,7 +31,11 @@
{# the hex content is replaced entirely by the Scroll (no partial scroll #}
{# — `scroll-snap-stop: always`). DRY seam: my_sea reuses _room_scroll. #}
<div id="id_room_aperture" class="room-aperture{% if room.table_status %} is-scrollable{% endif %}">
<div class="room-pane room-hex-pane">
{# `has-sig-stage` (active sig pick) makes the pane a positioning context #}
{# so the green-felt _sig_select_overlay fills it (my_sea-style), covering #}
{# the hex/seats behind. Dismissing the overlay (this gamer's sigs done) #}
{# reveals the hex + waiting message underneath. #}
<div class="room-pane room-hex-pane{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %} has-sig-stage{% endif %}">
<div class="room-shell">
<div id="id_game_table" class="room-table">
{# SCAN SIGS advances the whole table past role-select — gated on #}
@@ -109,6 +113,13 @@
</div>
</div>
</div>{# /.room-shell #}
{# Sig Select stage — green-felt card stage + thumbnail grid, rendered #}
{# INSIDE the hex pane (absolute-fills it on --duoUser felt, my_sea- #}
{# style) for this gamer's polarity group. Suppressed once their polarity #}
{# sigs are assigned (then the hex/waiting-msg shows through). #}
{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
{% endif %}
{# Position circles scroll away WITH the hex (they live inside the hex #}
{# pane, not at room-page root). Neither the aperture nor the pane sets #}
{# z-index/transform, so the strip's z-130 still resolves in the root #}
@@ -134,12 +145,9 @@
{% endif %}
{# Phase overlays are gated on `viewer_cost_current` too: a lapsed gamer #}
{# gets GATE VIEW in the center, so the SIG/SKY/SEA modals (which embed #}
{# their trigger-btn ids in JS) must not render alongside it. #}
{# Sig Select overlay — suppressed once this gamer's polarity sigs are assigned #}
{% if room.table_status == "SIG_SELECT" and user_polarity and not polarity_done and viewer_cost_current %}
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
{% endif %}
{# gets GATE VIEW in the center, so the SKY/SEA modals (which embed #}
{# their trigger-btn ids in JS) must not render alongside it. (The Sig #}
{# Select stage now lives INSIDE the hex pane above — my_sea-style felt.) #}
{# Sky (Pick Sky) overlay — natal chart entry #}
{% if room.table_status == "SKY_SELECT" and not sky_confirmed and viewer_cost_current %}