diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py index 35880b3..98d176b 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_visit.py +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -147,6 +147,51 @@ class MySeaVisitContextTest(TestCase): self.assertIn('data-card-id="1"', content) # owner's drawn card slot self.assertNotIn("my-sea-scroll", content) + def test_present_renders_owner_deck_stack_top_left_flip_disabled(self): + """The deck stack mirrors the OWNER's deck (everyone plays the owner's + deck), pinned top-left (--visit) with FLIP always disabled (read-only + spectator can't deal). A polarized owner deck → a dubbodeck.""" + from apps.epic.models import DeckVariant + deck = DeckVariant.objects.filter(is_polarized=True).first() + self.assertIsNotNone(deck, "expected a polarized (dubbo) deck seeded") + self.owner.equipped_deck = deck # the OWNER's deck, not the viewer's + self.owner.save(update_fields=["equipped_deck"]) + self.invite.token_deposited_at = timezone.now() + self.invite.voice_until = timezone.now() + timedelta(hours=24) + self.invite.save() + html = self.client.get(self.url).content.decode() + self.assertIn("my-sea-stacks-wrap--visit", html) + # Dubbodeck → two named stacks. + self.assertIn("sea-deck-stack--gravity", html) + self.assertIn("sea-deck-stack--levity", html) + # FLIP is the disabled (×) state; never an enabled FLIP for a visitor. + self.assertIn("sea-stack-ok btn-disabled", html) + self.assertNotIn( + 'class="btn btn-reveal sea-stack-ok" type="button">FLIP', html) + + def test_stack_keys_on_owner_deck_not_viewer_deck(self): + # Viewer has no deck, owner has one → the stack still renders (it's the + # owner's deck in play). + from apps.epic.models import DeckVariant + self.bud.equipped_deck = None + self.bud.save(update_fields=["equipped_deck"]) + self.owner.equipped_deck = DeckVariant.objects.first() + self.owner.save(update_fields=["equipped_deck"]) + self.invite.token_deposited_at = timezone.now() + self.invite.voice_until = timezone.now() + timedelta(hours=24) + self.invite.save() + html = self.client.get(self.url).content.decode() + self.assertIn("my-sea-stacks-wrap--visit", html) + + def test_no_stack_when_owner_has_no_deck(self): + self.owner.equipped_deck = None + self.owner.save(update_fields=["equipped_deck"]) + self.invite.token_deposited_at = timezone.now() + self.invite.voice_until = timezone.now() + timedelta(hours=24) + self.invite.save() + html = self.client.get(self.url).content.decode() + self.assertNotIn("my-sea-stacks-wrap--visit", html) + def test_not_present_does_not_render_cross_or_deck_json(self): # Before deposit (gate view) there's no draw surface to seed. content = self.client.get(self.url).content.decode() diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index d293d15..7cdc7ec 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -795,6 +795,40 @@ body.page-gameboard { pointer-events: auto; } +// Spectator (bud-sea) deck stack — the VISITOR's own deck, pinned TOP-LEFT +// (across the table from the owner, who deals bottom-right). FLIP renders +// disabled server-side (read-only). For a dubbodeck the Gravity/Levity +// `.sea-stack-name` flips ABOVE the face + upside-down, signalling that +// someone across the table is dealing. (user-spec 2026-05-29) +.my-sea-stacks-wrap.my-sea-stacks-wrap--visit { + top: 1rem; + left: 1rem; + bottom: auto; + right: auto; + + // Name above the face (DOM order is face → name) + rotated 180°. + .sea-deck-stack { flex-direction: column-reverse; } + .sea-stack-name { + transform: scaleY(1.2) rotate(180deg); + transform-origin: center; + } + + // Read-only FLIP (disabled ×) — hidden until its stack is hovered/focused, + // then eases in: same reveal shape as the shared `flip-btn-base` mixin + // (opacity 0→1 over 0.3s ease), inlined here because _gameboard.scss is + // imported before _card-deck.scss (where the mixin lives). Stays non- + // interactive — `.btn-disabled` keeps pointer-events:none. The base + // `.sea-stack-ok` positioning is untouched (we only add the fade). + .sea-stack-ok { + opacity: 0; + transition: opacity 0.3s ease; + } + .sea-deck-stack:hover .sea-stack-ok, + .sea-deck-stack:focus-within .sea-stack-ok { + opacity: 1; + } +} + // ── Iter 4b: Brief banner + DEL guard portal ───────────────────────────────── // Both reuse shared chrome: the Brief is `.note-banner` from note.js // (portaled atop h2 w. Gaussian glass); the DEL guard is `#id_guard_portal` diff --git a/src/templates/apps/gameboard/_partials/_my_sea_deck_stack.html b/src/templates/apps/gameboard/_partials/_my_sea_deck_stack.html new file mode 100644 index 0000000..fc16db0 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_my_sea_deck_stack.html @@ -0,0 +1,36 @@ +{# Deck stack — mono (single DECK) or dubbo (DECKS: Gravity + Levity), per #} +{# `deck.is_polarized`. Shared by the owner picker (my_sea.html) AND the #} +{# read-only visitor cross (_my_sea_visit_cross.html). `deck` = the #} +{# DeckVariant to render; `flip_disabled` forces the FLIP btn to its #} +{# disabled (×) state — owner: hand_complete; visitor: always (a read-only #} +{# spectator can't deal). Markup is byte-identical to the owner's prior #} +{# inline stack so existing assertions hold. #} +{% if deck.is_polarized %} +
+ DECKS +
+
+ +
+ Gravity +
+
+
+ +
+ Levity +
+
+{% else %} +
+ DECK +
+
+ {% if deck.has_card_images %} + + {% endif %} + +
+
+
+{% endif %} diff --git a/src/templates/apps/gameboard/_partials/_my_sea_visit_cross.html b/src/templates/apps/gameboard/_partials/_my_sea_visit_cross.html index d75f2c8..70ab070 100644 --- a/src/templates/apps/gameboard/_partials/_my_sea_visit_cross.html +++ b/src/templates/apps/gameboard/_partials/_my_sea_visit_cross.html @@ -49,5 +49,15 @@ + {# Deck stack — the OWNER's deck (everyone at @owner's sea plays the #} + {# owner's deck), mirrored to the TOP-LEFT for the visitor sitting across #} + {# from the owner, who deals bottom-right. FLIP is always disabled #} + {# (read-only); a dubbodeck's Gravity/Levity name flips above + upside- #} + {# down via `--visit` to signal someone across the table is dealing. #} + {% if owner.equipped_deck %} +
+ {% include "apps/gameboard/_partials/_my_sea_deck_stack.html" with deck=owner.equipped_deck flip_disabled=True %} +
+ {% endif %} {% include "apps/gameboard/_partials/_sea_stage.html" %} diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 0d55864..d57c279 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -186,35 +186,7 @@ {# collapses to a single unnamed stack (DECKS → DECK). #} {# FLIP btn picks up `.btn-disabled` once hand_complete. #}
- {% if request.user.equipped_deck.is_polarized %} -
- DECKS -
-
- -
- Gravity -
-
-
- -
- Levity -
-
- {% else %} -
- DECK -
-
- {% if request.user.equipped_deck.has_card_images %} - - {% endif %} - -
-
-
- {% endif %} + {% include "apps/gameboard/_partials/_my_sea_deck_stack.html" with deck=request.user.equipped_deck flip_disabled=hand_complete %}
{# Spread modal — opens on #id_sea_btn click (burger fan). #}