From 7bd8e3256ab6e89558677b58f39850bb6697d6c5 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 29 May 2026 20:48:31 -0400 Subject: [PATCH] =?UTF-8?q?my-sea=20spectator:=20render=20owner's=20draw?= =?UTF-8?q?=20as=20the=20identical=20interactive=20cross=20stage;=20owner?= =?UTF-8?q?=20seated=20in=201C=20when=20paid=20OR=20drawn=20=E2=80=94=20TD?= =?UTF-8?q?D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 + 2 of the my-sea spectator/voice batch (user-spec 2026-05-29). ── Phase 1: spectator VIEW DRAW parity ── The visitor's VIEW DRAW rendered _my_sea_readonly_draw.html — a flat `.my-sea-scroll` strip that, out of its applet context, blew a single card up to fill the viewport. It now renders the SAME `.my-sea-cross` picker + `_sea_stage` modal the owner sees, populated from the owner's draw, read-only but fully interactive (click card → magnified stage, hover, SPIN, FYI). No FLIP / DEL / AUTO DRAW / deck-stacks / spread combobox — the visitor watches. - `_saved_by_position(saved_hand)` extracted as a shared helper (owner picker + spectator render build the IDENTICAL cross); my_sea refactored onto it. - my_sea_visit context gains `saved_by_position`, `label_by_position`, `default_spread`, and the OWNER's `sea_deck_data` (so sea.js resolves each clicked slot's full card face for the stage). - new `_my_sea_visit_cross.html` mirrors the owner cross + includes `_sea_stage` under `#id_sea_overlay`; my_sea_visit.html embeds the owner deck JSON + loads stage-card.js + sea.js + a trimmed seed IIFE (reconstructs SeaDeal's `_seaHand` from the filled slots so each card is clickable into the stage). - deletes the obsolete `_my_sea_readonly_draw.html`. ── Phase 2: owner 1C seating ── The owner is "seated" in 1C whenever committed to a draw cycle — paid for one (deposit reserved / paid-through credit) OR partially/completely drawn — not only once a card lands. Previously a paid-but-undrawn owner (the PAID DRAW landing) and the visitor's view of her showed the semi-opaque `.fa-ban` default. Seat 1C now carries persistent `.seated` + `.fa-circle-check` (sync on refresh; the one-shot flare just settles into it). - my_sea: new `seat1_seated = hand_non_empty or show_paid_draw`; my_sea.html seat 1C keys on it (class + data-seat-token + status icon). - my_sea_visit: `seat1_present = owner drawn OR owner paid` so the visitor sees the owner seated on the spectator hex under the same conditions. - seat flare bumped 1.5s → 2s (my-sea-seats.js GLOW_MS + _room.scss keyframe). Tests: +2 spectator-cross ITs, +1 spectator-cross FT (Phase 1); +4 owner-seat ITs, +2 visitor both-seated/owner-seated ITs, +1 owner-seating FT (Phase 2). 286 gameboard ITs/UTs green. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) --- .../static/apps/gameboard/my-sea-seats.js | 5 +- .../tests/integrated/test_sea_visit.py | 74 +++++++++++++++ .../gameboard/tests/integrated/test_views.py | 55 +++++++++++ src/apps/gameboard/views.py | 91 ++++++++++++++----- src/functional_tests/test_game_my_sea.py | 90 ++++++++++++++++++ src/static_src/scss/_card-deck.scss | 4 +- src/static_src/scss/_room.scss | 7 +- .../_partials/_my_sea_readonly_draw.html | 45 --------- .../_partials/_my_sea_visit_cross.html | 53 +++++++++++ src/templates/apps/gameboard/my_sea.html | 4 +- .../apps/gameboard/my_sea_visit.html | 54 ++++++++++- 11 files changed, 402 insertions(+), 80 deletions(-) delete mode 100644 src/templates/apps/gameboard/_partials/_my_sea_readonly_draw.html create mode 100644 src/templates/apps/gameboard/_partials/_my_sea_visit_cross.html diff --git a/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js b/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js index b8072a6..1fa6b9f 100644 --- a/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js +++ b/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js @@ -11,7 +11,10 @@ (function () { 'use strict'; var STORE_PREFIX = 'mysea-seat-seen:'; - var GLOW_MS = 1500; + // 2s flare (user-spec 2026-05-29) — matches the `my-sea-seat-flare` + // keyframe duration in _room.scss so the class is removed exactly as the + // chair settles into its steady `.seated` --secUser look. + var GLOW_MS = 2000; function alreadySeen(token) { try { diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py index 9552576..8d82dc2 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_visit.py +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -123,6 +123,80 @@ class MySeaVisitContextTest(TestCase): self.assertTrue(ctx["seat2_present"]) self.assertIn("VIEW", self.client.get(self.url).content.decode()) + def test_present_renders_owner_draw_as_interactive_cross_stage(self): + """Phase 1 (2026-05-29): the visitor's VIEW DRAW renders the IDENTICAL + `.my-sea-cross` picker + `_sea_stage` the owner sees (click→stage, + hover) off the owner's draw — NOT the old flat `.my-sea-scroll` strip + that blew one card up to fill the viewport.""" + self.invite.token_deposited_at = timezone.now() + self.invite.voice_until = timezone.now() + timedelta(hours=24) + self.invite.save() + resp = self.client.get(self.url) + ctx = resp.context + # Cross-stage parity context is present + sourced from the owner. + self.assertEqual(ctx["default_spread"], "situation-action-outcome") + self.assertIn("lay", ctx["saved_by_position"]) + self.assertEqual(ctx["saved_by_position"]["lay"]["card_id"], 1) + self.assertIn("lay", ctx["label_by_position"]) + content = resp.content.decode() + # The interactive cross + stage modal render; the old scroll is gone. + self.assertIn('id="id_sea_overlay"', content) + self.assertIn("sea-cross", content) + self.assertIn('id="id_sea_stage"', content) + self.assertIn('id="id_my_sea_deck"', content) # deck JSON for stage + self.assertIn('data-card-id="1"', content) # owner's drawn card slot + self.assertNotIn("my-sea-scroll", content) + + 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() + self.assertNotIn('id="id_sea_overlay"', content) + self.assertNotIn('id="id_my_sea_deck"', content) + + def test_both_seats_render_seated_simultaneously(self): + # Phase 2 (2026-05-29): with the owner drawn (1C) AND the visitor + # deposited (2C), BOTH chairs render `.seated` + `.fa-circle-check` + # at once — neither reverts to the semi-opaque .fa-ban default. + self.invite.token_deposited_at = timezone.now() + self.invite.voice_until = timezone.now() + timedelta(hours=24) + self.invite.save() + ctx = self.client.get(self.url).context + self.assertTrue(ctx["seat1_present"]) # owner drawn + self.assertTrue(ctx["seat2_present"]) # visitor present + html = self.client.get(self.url).content.decode() + self.assertIn('class="table-seat seated" data-slot="1"', html) + self.assertIn('class="table-seat seated" data-slot="2"', html) + self.assertEqual( + html.count('class="position-status-icon fa-solid fa-circle-check"'), 2) + + +class MySeaVisitOwnerSeatedTest(TestCase): + """Phase 2 — the owner shows seated in 1C on the spectator hex whenever + she's committed to a draw cycle (drawn OR paid), not only once a card + lands. Previously a paid-but-undrawn owner read as the empty .fa-ban + default on the visitor's view.""" + + def setUp(self): + self.owner = _owner_with_sig() + self.bud = User.objects.create(email="bud@test.io", username="budster") + self.invite = SeaInvite.objects.create( + owner=self.owner, invitee=self.bud, invitee_email=self.bud.email, + status=SeaInvite.ACCEPTED, accepted_at=timezone.now(), + ) + self.client.force_login(self.bud) + self.url = reverse("my_sea_visit", args=[self.owner.id]) + + def test_owner_seated_when_paid_with_empty_hand(self): + MySeaDraw.objects.create( + user=self.owner, spread="situation-action-outcome", + significator_id=1, hand=[], deposit_token_id=99, + deposit_reserved_at=timezone.now(), + ) + self.assertTrue(self.client.get(self.url).context["seat1_present"]) + + def test_owner_not_seated_without_draw_or_payment(self): + self.assertFalse(self.client.get(self.url).context["seat1_present"]) + class MySeaVisitInsertTokenTest(TestCase): def setUp(self): diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 043f805..1a0c4d6 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1346,6 +1346,61 @@ class MySeaDrawSeaLandingViewTest(TestCase): self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6) self.assertEqual(html.count('class="seat-position-label"'), 6) + # ── Owner 1C seating (Phase 2, 2026-05-29) ─────────────────────────── + # The owner is "seated" in 1C whenever she's committed to a draw cycle — + # paid for one OR partially/completely drawn (w/o DEL). Previously only a + # non-empty hand seated 1C, so a paid-but-undrawn owner (the PAID DRAW + # landing) showed the semi-opaque .fa-ban default. Seat 1C now carries + # `.seated` + `.fa-circle-check` so it persists on refresh (sync). + _SEATED_1C = 'class="table-seat seated" data-slot="1"' + _STATUS_CHECK = 'class="position-status-icon fa-solid fa-circle-check"' + _STATUS_BAN = 'class="position-status-icon fa-solid fa-ban"' + + def _make_draw(self, hand=None, deposit=False, paid_through=False): + from apps.gameboard.models import MySeaDraw + return MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + hand=hand or [], + significator_id=self.user.significator_id, + deposit_token_id=99 if deposit else None, + deposit_reserved_at=timezone.now() if deposit else None, + paid_through_at=timezone.now() if paid_through else None, + ) + + def test_seat_1c_seated_when_paid_draw_pending_with_empty_hand(self): + # Paid (deposit reserved) but no card drawn yet → PAID DRAW landing. + self._make_draw(hand=[], deposit=True) + html = self.client.get(reverse("my_sea")).content.decode() + self.assertIn(self._SEATED_1C, html) + self.assertIn("PAID", html) + self.assertEqual(html.count(self._STATUS_CHECK), 1) # only 1C + self.assertEqual(html.count(self._STATUS_BAN), 5) # 2C–6C + + def test_seat_1c_seated_when_paid_through_credit_with_empty_hand(self): + self._make_draw(hand=[], paid_through=True) + html = self.client.get(reverse("my_sea")).content.decode() + self.assertIn(self._SEATED_1C, html) + self.assertEqual(html.count(self._STATUS_CHECK), 1) + + def test_seat_1c_seated_mid_draw_on_forced_landing(self): + self._make_draw(hand=[ + {"position": "lay", "card_id": 1, "reversed": False, + "polarity": "gravity"}, + ]) + html = self.client.get(reverse("my_sea") + "?phase=landing").content.decode() + self.assertIn(self._SEATED_1C, html) + self.assertEqual(html.count(self._STATUS_CHECK), 1) + + def test_seat_1c_not_seated_at_gate_view_after_del(self): + # Cooldown row, hand cleared (DEL'd), no deposit/paid credit → GATE + # VIEW. The owner has unseated herself; 1C falls back to .fa-ban. + self._make_draw(hand=[]) + html = self.client.get(reverse("my_sea")).content.decode() + self.assertNotIn(self._SEATED_1C, html) + self.assertIn("GATE", html) + self.assertEqual(html.count(self._STATUS_CHECK), 0) + self.assertEqual(html.count(self._STATUS_BAN), 6) + def test_landing_not_rendered_when_user_has_no_sig(self): # Sprint 4b gate still wins precedence — FREE DRAW must not render # when significator is None. diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index ae69516..11e80ee 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -194,6 +194,35 @@ def toggle_game_kit_sections(request): return redirect("game_kit") +def _saved_by_position(saved_hand): + """Build the per-position saved-card lookup the picker cross renders from + (`_my_sea_slot.html`). Keyed by position slug ("lay", "cover", ...); each + value carries the pre-resolved card fields so the template never hits the + DB per slot. Shared by the owner picker (`my_sea`) AND the read-only + spectator render (`my_sea_visit`) so both draw the IDENTICAL cross.""" + saved = {} + if not saved_hand: + return saved + from apps.epic.models import TarotCard + ids = [e["card_id"] for e in saved_hand] + cards_by_id = {c.id: c for c in TarotCard.objects.filter(id__in=ids)} + for entry in saved_hand: + c = cards_by_id.get(entry["card_id"]) + saved[entry["position"]] = { + "card_id": entry["card_id"], + "reversed": entry.get("reversed", False), + "polarity": entry.get("polarity", "gravity"), + "corner_rank": c.corner_rank if c else "", + "suit_icon": c.suit_icon if c else "", + "has_card_images": (c.deck_variant.has_card_images + if c and c.deck_variant else False), + "image_url": c.image_url if c else "", + "arcana": c.arcana if c else "", + "name": c.name if c else "", + } + return saved + + @login_required(login_url="/") def my_sea(request): """Shell view for the My Sea standalone page. @@ -295,29 +324,9 @@ def my_sea(request): # entry=saved_by_position.lay %}` block. The card fields (corner_rank, # suit_icon) come pre-resolved so the template doesn't need to do a # DB lookup per slot. - saved_by_position = {} - if saved_hand: - from apps.epic.models import TarotCard - ids = [e["card_id"] for e in saved_hand] - cards_by_id = {c.id: c for c in TarotCard.objects.filter(id__in=ids)} - for entry in saved_hand: - c = cards_by_id.get(entry["card_id"]) - saved_by_position[entry["position"]] = { - "card_id": entry["card_id"], - "reversed": entry.get("reversed", False), - "polarity": entry.get("polarity", "gravity"), - "corner_rank": c.corner_rank if c else "", - "suit_icon": c.suit_icon if c else "", - # Sprint A.7-polish: extra fields for image-mode slot render - # in `_my_sea_slot.html`. Empty strings when the card's deck - # has no images (legacy text-only); template branches on - # `has_card_images` to pick render mode. - "has_card_images": (c.deck_variant.has_card_images - if c and c.deck_variant else False), - "image_url": c.image_url if c else "", - "arcana": c.arcana if c else "", - "name": c.name if c else "", - } + # Per-position lookup for `_my_sea_slot.html` — see `_saved_by_position` + # (shared with the read-only spectator render so both draw the same cross). + saved_by_position = _saved_by_position(saved_hand) # @taxman Brief payloads w. NVM-persistence (user-spec 2026-05-26). The # FREE DRAW Brief surfaces ONLY when an active draw exists AND the user @@ -371,6 +380,12 @@ def my_sea(request): "saved_by_position": saved_by_position, "next_free_draw_at": next_free_draw_at, "hand_complete": hand_complete, + # Owner seated in 1C whenever committed to a draw cycle — paid for one + # (deposit reserved OR paid-through credit, via show_paid_draw) OR has + # cards down (hand_non_empty). Drives the persistent `.seated` + + # `.fa-circle-check` on the landing's 1C chair (user-spec 2026-05-29); + # a DEL'd row at GATE VIEW (empty hand, no paid credit) reads unseated. + "seat1_seated": hand_non_empty or show_paid_draw, "show_picker": show_picker, "show_cont_draw": show_cont_draw, # Sub-btn .active flag for the burger fan — Sea sub-btn lights up @@ -871,21 +886,47 @@ def my_sea_visit(request, owner_id): owner_draw = active_draw_for(owner) sig_card, sig_reversed = _resolve_sig(owner, owner_draw) owner_hand_non_empty = owner_draw is not None and bool(owner_draw.hand) + # Owner seated in 1C (as the visitor sees it) whenever she's committed to a + # draw cycle — drawn OR paid (deposit reserved / paid-through credit). Keeps + # the owner's 1C chair persistently `.seated` on the spectator hex even + # before a card lands (user-spec 2026-05-29); mirrors my_sea's seat1_seated. + owner_paid = owner_draw is not None and ( + owner_draw.deposit_token_id is not None + or owner_draw.paid_through_at is not None + ) + owner_seated = owner_hand_non_empty or owner_paid + # Read-only spectator render parity (Phase 1, 2026-05-29): the visitor's + # VIEW DRAW renders the SAME `.my-sea-cross` picker + `_sea_stage` the + # owner sees, populated from the owner's draw. `saved_by_position` fills + # the slots; `label_by_position` captions them per the owner's spread; + # `sea_deck_data` is the OWNER's deck so sea.js can resolve each clicked + # slot's full card face for the magnified stage. + owner_slots = latest_draw_slots(owner) return render(request, "apps/gameboard/my_sea_visit.html", { "spectator": True, "is_owner": False, "read_only": True, "owner": owner, "sea_invite": invite, - "seat1_present": owner_hand_non_empty, + "seat1_present": owner_seated, "seat2_present": invite.is_present, "owner_draw_id": owner_draw.id if owner_draw is not None else "", "voice_active": invite.voice_active, "voice_room_id": f"mysea-{owner.id}", "significator": sig_card, "significator_reversed": sig_reversed, - "my_sea_slots": latest_draw_slots(owner), + "my_sea_slots": owner_slots, "owner_hand_non_empty": owner_hand_non_empty, + # Read-only cross-stage parity payload. + "default_spread": (owner_draw.spread if owner_draw is not None + else "situation-action-outcome"), + "saved_by_position": _saved_by_position( + owner_draw.hand if owner_draw is not None else []), + "label_by_position": {s["position"]: s["label"] for s in owner_slots}, + "sea_deck_data": ( + _my_sea_deck_data(owner, exclude_id=sig_card.id if sig_card else None) + if sig_card is not None else {"levity": [], "gravity": []} + ), # Owner-only controls forced off on the spectator surface. "sea_btn_active": False, "sea_first_draw_pending": False, diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index c0edc30..3a8929a 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -2053,3 +2053,93 @@ class MySeaGearBtnTest(FunctionalTest): self.browser.current_url, r"/gameboard/my-sea/$" ) ) + + +class MySeaOwnerSeatingTest(FunctionalTest): + """Phase 2 (2026-05-29) — the owner's 1C chair stays persistently seated + (`.seated` + green `.fa-circle-check`, NOT the semi-opaque `.fa-ban` + default) whenever she's committed to a draw cycle, including a PAID DRAW + landing before any card lands. Previously only a non-empty hand seated 1C, + so a paid-but-undrawn owner showed the empty-seat default.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.email = "seated@test.io" + self.gamer = User.objects.create(email=self.email) + _assign_sig(self.gamer) + + def _paid_empty_draw(self): + from apps.gameboard.models import MySeaDraw + return MySeaDraw.objects.create( + user=self.gamer, spread="situation-action-outcome", + significator_id=self.gamer.significator_id, hand=[], + deposit_token_id=99, deposit_reserved_at=timezone.now(), + ) + + def test_paid_draw_landing_seats_owner_in_1c_persistently(self): + self._paid_empty_draw() + self.create_pre_authenticated_session(self.email) + self.browser.get(self.live_server_url + "/gameboard/my-sea/") + seat1 = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".table-seat[data-slot='1']" + )) + # Seated (not the empty default) + the green check — and STAYS so + # after the 2s flare settles (the steady state is server-rendered). + self.assertIn("seated", (seat1.get_attribute("class") or "").split()) + self.assertTrue(seat1.find_elements( + By.CSS_SELECTOR, ".position-status-icon.fa-circle-check" + )) + self.assertFalse(seat1.find_elements( + By.CSS_SELECTOR, ".position-status-icon.fa-ban" + )) + + +class MySeaVisitSpectatorCrossTest(FunctionalTest): + """Phase 1 (2026-05-29) — the visitor's VIEW DRAW renders the owner's draw + as the SAME interactive cross stage the owner sees (`.my-sea-cross` + + `_sea_stage`), NOT the old flat scroll that blew a single card up to fill + the viewport.""" + + def setUp(self): + super().setUp() + _seed_earthman_sig_pile() + _seed_gameboard_applets() + self.owner = User.objects.create(email="owner@test.io", username="owner") + _assign_sig(self.owner) + self.visitor_email = "viz@test.io" + self.visitor = User.objects.create( + email=self.visitor_email, username="viz") + from apps.epic.models import TarotCard + from apps.gameboard.models import MySeaDraw, SeaInvite + card = TarotCard.objects.exclude(id=self.owner.significator_id).first() + MySeaDraw.objects.create( + user=self.owner, spread="situation-action-outcome", + significator_id=self.owner.significator_id, + hand=[{"position": "lay", "card_id": card.id, "reversed": False, + "polarity": "gravity"}], + ) + SeaInvite.objects.create( + owner=self.owner, invitee=self.visitor, + invitee_email=self.visitor_email, + status=SeaInvite.ACCEPTED, accepted_at=timezone.now(), + token_deposited_at=timezone.now(), + voice_until=timezone.now() + timedelta(hours=24), + ) + + def test_view_draw_reveals_interactive_cross_with_filled_slot(self): + self.create_pre_authenticated_session(self.visitor_email) + self.browser.get( + self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/") + view_btn = self.wait_for(lambda: self.browser.find_element( + By.ID, "id_my_sea_view_draw_btn")) + self.browser.execute_script("arguments[0].click()", view_btn) + cross = self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".my-sea-cross")) + self.assertTrue(cross.is_displayed()) + self.assertTrue(self.browser.find_elements( + By.CSS_SELECTOR, ".sea-card-slot--filled")) + self.assertTrue(self.browser.find_elements(By.ID, "id_sea_stage")) + self.assertFalse(self.browser.find_elements( + By.CSS_SELECTOR, ".my-sea-scroll")) diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index f730bc6..204b77d 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -410,7 +410,7 @@ color: rgba(var(--secUser), 1); .stat-face-label { color: rgba(var(--secUser), 1); } .stat-keywords li { - color: rgba(var(--quiUser), 1); + color: rgba(var(--secUser), 1); border-bottom-color: rgba(var(--terUser), 0.18); } } @@ -2283,7 +2283,7 @@ $_sea-title-els: '.fan-card-name, .sig-qualifier-above, .sig-qualifier-below, .f border-color: rgba(var(--terUser), 0.15); .stat-face-label { color: rgba(var(--secUser), 1); } .stat-keywords li { - color: rgba(var(--quiUser), 1); + color: rgba(var(--secUser), 1); border-bottom-color: rgba(var(--terUser), 0.18); } } diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 3572fb1..1186566 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -631,13 +631,14 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut color: rgba(var(--secUser), 1); filter: none; } - // One-shot "just seated" flare (~1.5s) played the FIRST time a viewer + // One-shot "just seated" flare (2s) played the FIRST time a viewer // sees the occupancy (my-sea-seats.js adds/removes `.seat-just-seated`, // localStorage-gated). Chair flares --terUser + a --ninUser glow, then // eases back into the steady --secUser .seated look above (user-spec - // 2026-05-27). Mirrors the room's .active → .role-confirmed handoff. + // 2026-05-29, bumped from 1.5s). Mirrors the room's .active → + // .role-confirmed handoff. &.seat-just-seated .fa-chair { - animation: my-sea-seat-flare 1.5s ease forwards; + animation: my-sea-seat-flare 2s ease forwards; } .seat-portrait { diff --git a/src/templates/apps/gameboard/_partials/_my_sea_readonly_draw.html b/src/templates/apps/gameboard/_partials/_my_sea_readonly_draw.html deleted file mode 100644 index 0c24279..0000000 --- a/src/templates/apps/gameboard/_partials/_my_sea_readonly_draw.html +++ /dev/null @@ -1,45 +0,0 @@ -{# Read-only render of the owner's draw for a my-sea spectator (Phase B of #} -{# [[my-sea-invite-voice-blueprint]]). Mirrors `_applet-my-sea.html`'s slot #} -{# markup off the same `latest_draw_slots` payload (`my_sea_slots`), but #} -{# standalone + with NO interactive affordances (FLIP / DEL / AUTO DRAW). #} -
-{% for slot in my_sea_slots %} - {% if slot.card %} -
-
- {% if slot.card.deck_variant.has_card_images %} - {{ slot.card.name }} - {% else %} -
- {{ slot.card.corner_rank }} - {% if slot.card.suit_icon %}{% endif %} -
-
- {% if slot.face.qualifier_first %} -

{{ slot.face.qualifier }}

-

{{ slot.face.title }}

- {% else %} -

{{ slot.face.title }}

-

{{ slot.face.qualifier }}

- {% endif %} -

{{ slot.card.get_arcana_display }}

-
-
- {{ slot.card.corner_rank }} - {% if slot.card.suit_icon %}{% endif %} -
- {% endif %} -
- {{ slot.label }} -
- {% else %} -
-
- {{ slot.label }} -
- {% endif %} -{% endfor %} -
diff --git a/src/templates/apps/gameboard/_partials/_my_sea_visit_cross.html b/src/templates/apps/gameboard/_partials/_my_sea_visit_cross.html new file mode 100644 index 0000000..d75f2c8 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_my_sea_visit_cross.html @@ -0,0 +1,53 @@ +{# Read-only spectator render of the owner's draw (Phase 1, 2026-05-29). #} +{# Reuses the owner's `.sea-cross.my-sea-cross` picker DOM (sig center + 6 #} +{# position cells via _my_sea_slot.html) + the shared `_sea_stage.html` #} +{# modal, bound by sea.js for click→stage + SPIN + FYI + hover — so the #} +{# visitor sees IDENTICALLY what the owner sees on her own my_sea, just #} +{# without the FLIP / DEL / AUTO DRAW / deck-stacks / spread combobox #} +{# (the visitor only watches). `#id_sea_overlay` is what sea.js binds to; #} +{# `--locked` mirrors the owner's hand-complete picker so no draw #} +{# affordances surface even if a stray handler fires. #} +
+
+
+
+ {{ label_by_position.crown }} + {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="crown" saved=saved_by_position.crown crossing=False %} +
+
+ {{ label_by_position.leave }} + {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="leave" saved=saved_by_position.leave crossing=False %} +
+
+
+ {% if significator.deck_variant.has_card_images %} + {{ significator.name }} + {% else %} + {{ significator.corner_rank }} + {% if significator.suit_icon %}{% endif %} + {% endif %} +
+
+ {{ label_by_position.cover }} + {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="cover" saved=saved_by_position.cover crossing=False %} +
+
+ {{ label_by_position.cross }} + {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="cross" saved=saved_by_position.cross crossing=True %} +
+
+
+ {{ label_by_position.loom }} + {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="loom" saved=saved_by_position.loom crossing=False %} +
+
+ {{ label_by_position.lay }} + {% include "apps/gameboard/_partials/_my_sea_slot.html" with position="lay" saved=saved_by_position.lay crossing=False %} +
+
+
+ {% 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 8ca611e..344c298 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -94,10 +94,10 @@ {# semantics clean. `.position-status-icon` + #} {# `.fa-ban` are unchanged — already role- #} {# agnostic in _room.scss. #} -
+
{{ n }}C - +
{% endfor %}
diff --git a/src/templates/apps/gameboard/my_sea_visit.html b/src/templates/apps/gameboard/my_sea_visit.html index 34931d2..2fc58fd 100644 --- a/src/templates/apps/gameboard/my_sea_visit.html +++ b/src/templates/apps/gameboard/my_sea_visit.html @@ -67,9 +67,12 @@ {% if seat2_present %} - {# Owner's draw, read-only. Hidden until VIEW DRAW toggles it in. #} + {# Owner's draw, read-only. Hidden until VIEW DRAW toggles it in. Renders #} + {# the SAME interactive cross stage the owner sees (click→stage, hover, #} + {# SPIN, FYI) off the owner's draw payload — see _my_sea_visit_cross.html. #} {% endif %} @@ -84,6 +87,53 @@ {% block scripts %} + {% if seat2_present %} + {# Read-only cross stage — StageCard + SeaDeal bind to #id_sea_overlay #} + {# (inside #id_my_sea_visit_draw) for click→stage + SPIN + FYI + hover. #} + {# A trimmed seed IIFE (no picker/FLIP/DEL machinery) reconstructs #} + {# SeaDeal's `_seaHand` from the server-rendered filled slots so each #} + {# card is clickable into the magnified stage — same logic as my_sea's #} + {# saved-hand restore, just without the draw affordances. #} + + + + {% endif %}