diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js
index df81ca1..5a68a49 100644
--- a/src/apps/epic/static/apps/epic/sig-select.js
+++ b/src/apps/epic/static/apps/epic/sig-select.js
@@ -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');
diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py
index 212029a..eb9b142 100644
--- a/src/apps/epic/tests/integrated/test_views.py
+++ b/src/apps/epic/tests/integrated/test_views.py
@@ -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
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."""
diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss
index b314af8..4a8f610 100644
--- a/src/static_src/scss/_card-deck.scss
+++ b/src/static_src/scss/_card-deck.scss
@@ -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
diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
index 82555a9..6c27e5c 100644
--- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
+++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
@@ -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 %}
-
+ {# Image-mode slot — stage-card.js _setImageMode shows this #}
+ {# + 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). #}
+
+ {# 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 %}
+
+
+ {% endif %}
{{ card.corner_rank }}
{% if card.suit_icon %}{% endif %}
diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html
index d232dd9..58ec997 100644
--- a/src/templates/apps/gameboard/room.html
+++ b/src/templates/apps/gameboard/room.html
@@ -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. #}
-
+ {# `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. #}
+
{# SCAN SIGS advances the whole table past role-select — gated on #}
@@ -109,6 +113,13 @@
{# /.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 %}