From 260c1c132577c645e771d05d4982491c2e419fa3 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 29 May 2026 23:01:21 -0400 Subject: [PATCH] =?UTF-8?q?my=5Fsea=5Fvisit:=20give=20the=20visitor=20a=20?= =?UTF-8?q?top-left=20deck=20stack=20(owner's=20deck)=20with=20a=20hover-r?= =?UTF-8?q?evealed=20disabled=20FLIP=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the owner's deck stack onto the spectator hex, DRY: - new shared _my_sea_deck_stack.html partial (mono DECK / dubbo DECKS by is_polarized) rendered by BOTH the owner picker (my_sea.html, flip_disabled= hand_complete) AND the visitor cross (flip_disabled=True). Owner markup is byte-identical, so its assertions hold. - the visitor's stack uses the OWNER's deck (everyone at @owner's sea plays the owner's deck — the visitor's own equipped deck is irrelevant), pinned top-left (--visit) across the table from the owner who deals bottom-right. - dubbodeck: the Gravity/Levity name flips above the face + upside-down to signal someone across the table is dealing. - the read-only FLIP (disabled ×) is hidden until its stack is hovered/focused, then eases in (same opacity 0->1 over 0.3s as the shared flip-btn-base reveal; inlined since _gameboard precedes _card-deck in the import order) so a permanent × doesn't clutter the stack. ITs: stack keys on the owner's deck (not the viewer's), dubbo renders 2 named stacks, FLIP is the disabled state, no stack when the owner has no deck. Owner deck-stack IT + FT stay green (identical markup). Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/integrated/test_sea_visit.py | 45 +++++++++++++++++++ src/static_src/scss/_gameboard.scss | 34 ++++++++++++++ .../_partials/_my_sea_deck_stack.html | 36 +++++++++++++++ .../_partials/_my_sea_visit_cross.html | 10 +++++ src/templates/apps/gameboard/my_sea.html | 30 +------------ 5 files changed, 126 insertions(+), 29 deletions(-) create mode 100644 src/templates/apps/gameboard/_partials/_my_sea_deck_stack.html 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). #}