From d0c39b51b6b2934039a3ce4c49baa4cf0c2d08aa Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 27 May 2026 13:35:00 -0400 Subject: [PATCH] =?UTF-8?q?my-sea=20spectator=20Phase=20B:=20seat-2C=20occ?= =?UTF-8?q?upancy=20+=20visitor=20token=20gate=20+=20one-shot=20seated=20g?= =?UTF-8?q?low=20+=20gear=20BYE=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B of the my-sea invite → spectator → voice blueprint. An ACCEPTED invitee can watch the owner's my-sea read-only, deposit a token to occupy seat 2C (opening a 24h voice window for Phase C), and BYE out. Owner's my_sea.html is left structurally intact — the spectator gets a dedicated, simpler my_sea_visit.html; the read-only draw reuses the existing `latest_draw_slots` payload (no picker surgery). - B1: my_sea_visit(owner_id) spectator view — 403 unless an ACCEPTED SeaInvite(owner, request.user); owner bounced to their own my_sea. Context forces owner-only controls off (sea_btn_active=False, read_only=True); renders the table hex (1C owner / 2C visitor) + owner draw read-only. - B2: visitor gate — my_sea_visit_gate reuses my_sea_gate.html w. a spectator branch (titles the OWNER's Sea, INSERT posts to the visitor endpoint, bud-panel suppressed, gear NVM→visit + BYE). Single-step my_sea_visit_insert_token selects+debits the visitor's token (same priority chain) and records token_deposited_at + a 24h voice_until on the SeaInvite → seat 2C present. Center btn flips GATE VIEW → VIEW DRAW. - B3: spectator gear BYE — my_sea_visit_leave sets status=LEFT, left_at, clears voice_until (frees 2C, ends voice), redirects /gameboard/. _my_sea_gear.html gains a `leave_url`-gated BYE below NVM (owner pages pass no leave_url, so unchanged). - B-seat: one-shot "seated" glow per user-spec 2026-05-27 — new shared apps/gameboard/my-sea-seats.js: on first view (localStorage-gated by a per-occupancy data-seat-token) an occupied seat flares --terUser + --ninUser glow ~1.5s then settles to full-opacity --secUser (.fa-ban already swapped to .fa-circle-check). _room.scss adds .seated / .seat-just-seated + the my-sea-seat-flare keyframes (mirrors the room's .active→.role-confirmed handoff). Wired on BOTH the spectator page (load) and the owner page (load + on the FREE DRAW seat-1 transition). MySeaSeatsSpec.js Jasmine spec covers the gating + timed class removal. - B5: MySeaSpectatorFlowTest FT — accept → visit → GATE VIEW → deposit → VIEW DRAW + seat 2C seated. URLs: my-sea/visit// (+ /gate/, /insert, /leave). 470 IT/UT green; spectator FT + full Jasmine suite green. Phase C (WebRTC mesh voice + coturn droplet) next — the 24h voice_until window set here drives it. Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- .../static/apps/gameboard/my-sea-seats.js | 53 ++++++ .../tests/integrated/test_sea_visit.py | 175 ++++++++++++++++++ src/apps/gameboard/urls.py | 7 + src/apps/gameboard/views.py | 125 +++++++++++++ src/functional_tests/test_game_my_sea.py | 56 ++++++ src/static/tests/MySeaSeatsSpec.js | 54 ++++++ src/static/tests/SpecRunner.html | 2 + src/static_src/scss/_room.scss | 24 +++ src/static_src/tests/MySeaSeatsSpec.js | 54 ++++++ src/static_src/tests/SpecRunner.html | 2 + .../gameboard/_partials/_my_sea_gear.html | 9 + .../_partials/_my_sea_readonly_draw.html | 45 +++++ src/templates/apps/gameboard/my_sea.html | 11 +- src/templates/apps/gameboard/my_sea_gate.html | 26 ++- .../apps/gameboard/my_sea_visit.html | 106 +++++++++++ 15 files changed, 740 insertions(+), 9 deletions(-) create mode 100644 src/apps/gameboard/static/apps/gameboard/my-sea-seats.js create mode 100644 src/apps/gameboard/tests/integrated/test_sea_visit.py create mode 100644 src/static/tests/MySeaSeatsSpec.js create mode 100644 src/static_src/tests/MySeaSeatsSpec.js create mode 100644 src/templates/apps/gameboard/_partials/_my_sea_readonly_draw.html create mode 100644 src/templates/apps/gameboard/my_sea_visit.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 new file mode 100644 index 0000000..b8072a6 --- /dev/null +++ b/src/apps/gameboard/static/apps/gameboard/my-sea-seats.js @@ -0,0 +1,53 @@ +// my-sea-seats.js — one-shot "seated" animation for the my-sea table hex. +// +// When a seat is occupied (owner draws → seat 1C; visitor deposits a token → +// seat 2C), the FIRST time a given viewer sees that occupancy the seat plays +// a one-shot glow: chair fills --terUser + glows --ninUsers for ~1.5s, then +// settles back to --secUser at FULL opacity (the steady `.seated` look), with +// `.fa-ban` already swapped for `.fa-circle-check` server-side. "First time" +// is tracked per-browser in localStorage, keyed on a stable per-occupancy +// token (`data-seat-token`) so a fresh draw / fresh invite re-animates but a +// reload does not. (Spec: user 2026-05-27.) +(function () { + 'use strict'; + var STORE_PREFIX = 'mysea-seat-seen:'; + var GLOW_MS = 1500; + + function alreadySeen(token) { + try { + return window.localStorage.getItem(STORE_PREFIX + token) === '1'; + } catch (e) { + return false; // private mode / storage disabled → always animate + } + } + + function markSeen(token) { + try { window.localStorage.setItem(STORE_PREFIX + token, '1'); } + catch (e) { /* no-op */ } + } + + // Play the one-shot glow on a `.table-seat` element unless this viewer has + // already seen this exact occupancy. Safe to call on a seat with no token + // (always animates, never persists). + function playSeatGlow(seat) { + if (!seat) return; + var token = seat.getAttribute('data-seat-token'); + if (token && alreadySeen(token)) return; + seat.classList.add('seat-just-seated'); + window.setTimeout(function () { + seat.classList.remove('seat-just-seated'); + }, GLOW_MS); + if (token) markSeen(token); + } + + // Exposed so the owner's FREE DRAW flow can fire the same glow the moment + // seat 1C transitions to `.seated` client-side (my_sea.html). + window.playSeatGlow = playSeatGlow; + + document.addEventListener('DOMContentLoaded', function () { + var seats = document.querySelectorAll('.table-seat.seated[data-seat-token]'); + Array.prototype.forEach.call(seats, function (seat) { + playSeatGlow(seat); + }); + }); +}()); diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py new file mode 100644 index 0000000..acb6f9c --- /dev/null +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -0,0 +1,175 @@ +"""ITs for the my-sea spectator (invitee) views — Phase B of +[[my-sea-invite-voice-blueprint]]. + +An ACCEPTED invitee can VISIT the owner's my-sea as a read-only spectator, +deposit a token at a visitor gate to occupy seat 2C (+ open a 24h voice +window), and BYE out (freeing the seat). All access is gated on an ACCEPTED +SeaInvite for (owner, request.user). +""" + +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.gameboard.models import MySeaDraw, SeaInvite +from apps.lyric.models import Token, User + + +def _owner_with_sig(email="owner@test.io", username="discoman"): + owner = User.objects.create(email=email, username=username) + # A sig id is required to build a MySeaDraw; any int is fine for the + # spectator read-only path (no card lookup asserted here). + return owner + + +class MySeaVisitGuardTest(TestCase): + 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.url = reverse("my_sea_visit", args=[self.owner.id]) + + def test_accepted_invitee_can_visit(self): + self.client.force_login(self.bud) + self.assertEqual(self.client.get(self.url).status_code, 200) + + def test_non_invitee_is_forbidden(self): + stranger = User.objects.create(email="x@test.io", username="x") + self.client.force_login(stranger) + self.assertEqual(self.client.get(self.url).status_code, 403) + + def test_pending_invitee_is_forbidden(self): + self.invite.status = SeaInvite.PENDING + self.invite.save() + self.client.force_login(self.bud) + self.assertEqual(self.client.get(self.url).status_code, 403) + + def test_left_invitee_is_forbidden(self): + self.invite.status = SeaInvite.LEFT + self.invite.left_at = timezone.now() + self.invite.save() + self.client.force_login(self.bud) + self.assertEqual(self.client.get(self.url).status_code, 403) + + def test_owner_visiting_own_sea_redirects_to_my_sea(self): + self.client.force_login(self.owner) + resp = self.client.get(self.url) + self.assertRedirects(resp, reverse("my_sea"), fetch_redirect_response=False) + + +class MySeaVisitContextTest(TestCase): + 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(), + ) + # Owner has drawn a hand → seat 1C is occupied + cards exist to view. + MySeaDraw.objects.create( + user=self.owner, spread="situation-action-outcome", + significator_id=1, hand=[ + {"position": "lay", "card_id": 1, "reversed": False, + "polarity": "gravity"}, + ], + ) + self.client.force_login(self.bud) + self.url = reverse("my_sea_visit", args=[self.owner.id]) + + def test_spectator_context_flags(self): + ctx = self.client.get(self.url).context + self.assertTrue(ctx["spectator"]) + self.assertFalse(ctx["is_owner"]) + self.assertEqual(ctx["owner"], self.owner) + self.assertEqual(ctx["sea_invite"], self.invite) + # Owner controls forced off on the spectator surface. + self.assertFalse(ctx["sea_btn_active"]) + + def test_not_present_shows_gate_view(self): + content = self.client.get(self.url).content.decode() + self.assertIn("GATE", content) + self.assertIn(reverse("my_sea_visit_gate", args=[self.owner.id]), content) + + def test_present_shows_view_draw_and_seat2(self): + 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["sea_invite"].is_present) + self.assertTrue(ctx["seat2_present"]) + self.assertIn("VIEW", self.client.get(self.url).content.decode()) + + +class MySeaVisitInsertTokenTest(TestCase): + 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.url = reverse("my_sea_visit_insert_token", args=[self.owner.id]) + + def test_deposit_marks_present_and_opens_voice_window(self): + self.client.force_login(self.bud) + resp = self.client.post(self.url) + self.invite.refresh_from_db() + self.assertIsNotNone(self.invite.token_deposited_at) + self.assertIsNotNone(self.invite.voice_until) + self.assertTrue(self.invite.voice_active) + self.assertTrue(self.invite.is_present) + self.assertRedirects( + resp, reverse("my_sea_visit", args=[self.owner.id]), + fetch_redirect_response=False, + ) + + def test_non_invitee_cannot_deposit(self): + stranger = User.objects.create(email="x@test.io", username="x") + self.client.force_login(stranger) + self.assertEqual(self.client.post(self.url).status_code, 403) + + def test_no_token_does_not_mark_present(self): + # Strip the visitor of every usable token. + self.bud.equipped_trinket = None + self.bud.save(update_fields=["equipped_trinket"]) + Token.objects.filter(user=self.bud).delete() + self.client.force_login(self.bud) + self.client.post(self.url) + self.invite.refresh_from_db() + self.assertIsNone(self.invite.token_deposited_at) + self.assertFalse(self.invite.is_present) + + +class MySeaVisitLeaveTest(TestCase): + 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(), + token_deposited_at=timezone.now(), + voice_until=timezone.now() + timedelta(hours=24), + ) + self.url = reverse("my_sea_visit_leave", args=[self.owner.id]) + + def test_leave_sets_left_and_kills_voice(self): + self.client.force_login(self.bud) + resp = self.client.post(self.url) + self.invite.refresh_from_db() + self.assertEqual(self.invite.status, SeaInvite.LEFT) + self.assertIsNotNone(self.invite.left_at) + self.assertIsNone(self.invite.voice_until) + self.assertFalse(self.invite.is_present) + self.assertRedirects( + resp, reverse("gameboard"), fetch_redirect_response=False, + ) + + def test_non_invitee_cannot_leave(self): + stranger = User.objects.create(email="x@test.io", username="x") + self.client.force_login(stranger) + self.assertEqual(self.client.post(self.url).status_code, 403) diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index 43b09b1..55d5602 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -25,6 +25,13 @@ urlpatterns = [ name='my_sea_invite_accept'), path('my-sea/invite/decline/', views.my_sea_invite_decline, name='my_sea_invite_decline'), + path('my-sea/visit//', views.my_sea_visit, name='my_sea_visit'), + path('my-sea/visit//gate/', views.my_sea_visit_gate, + name='my_sea_visit_gate'), + path('my-sea/visit//insert', views.my_sea_visit_insert_token, + name='my_sea_visit_insert_token'), + path('my-sea/visit//leave', views.my_sea_visit_leave, + name='my_sea_visit_leave'), path('my-sea/brief/free-draw/dismiss', views.my_sea_dismiss_free_draw_brief, name='my_sea_dismiss_free_draw_brief'), path('my-sea/brief/paid-draw/dismiss', views.my_sea_dismiss_paid_draw_brief, diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 8635311..136edb4 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -865,6 +865,131 @@ def my_sea_invite_decline(request, invite_id): return _redirect_to_invite_log(invite) +# ── Phase B — my-sea spectator (invitee) surfaces ─────────────────────────── +def _accepted_visit_invite(owner, user): + """The requester's ACCEPTED invite to spectate `owner`'s my-sea, or None. + The single access gate for every spectator surface — a token deposit + keeps the row ACCEPTED (presence derives from `token_deposited_at`); a + BYE flips it to LEFT, which closes the gate.""" + from .models import SeaInvite + return SeaInvite.objects.filter( + owner=owner, invitee=user, status=SeaInvite.ACCEPTED, + ).order_by("-created_at").first() + + +@login_required(login_url="/") +def my_sea_visit(request, owner_id): + """Spectator view — an ACCEPTED invitee watches the owner's my-sea read- + only (Phase B of [[my-sea-invite-voice-blueprint]]). 403 unless an + ACCEPTED SeaInvite(owner, request.user) exists; the owner is bounced to + their own my_sea. Renders the table hex (seat 1C = owner-drawn, seat 2C = + this visitor once present) + the owner's draw read-only via + `latest_draw_slots`. No AUTO DRAW / DEL / FLIP-to-deposit on the owner's + hand — `sea_btn_active` forced False.""" + from apps.lyric.models import User + owner = get_object_or_404(User, id=owner_id) + if owner == request.user: + return redirect("my_sea") + invite = _accepted_visit_invite(owner, request.user) + if invite is None: + return HttpResponseForbidden() + 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) + 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, + "seat2_present": invite.is_present, + "owner_draw_id": owner_draw.id if owner_draw is not None else "", + "voice_active": invite.voice_active, + "significator": sig_card, + "significator_reversed": sig_reversed, + "my_sea_slots": latest_draw_slots(owner), + "owner_hand_non_empty": owner_hand_non_empty, + # Owner-only controls forced off on the spectator surface. + "sea_btn_active": False, + "sea_first_draw_pending": False, + "page_class": "page-gameboard page-my-sea page-my-sea-visit", + }) + + +@login_required(login_url="/") +def my_sea_visit_gate(request, owner_id): + """Visitor gate — single-step token deposit to occupy seat 2C + open the + voice window. Reuses my_sea_gate.html with spectator=True (INSERT TOKEN + only; no refund / PAID-DRAW two-step).""" + from apps.lyric.models import User + owner = get_object_or_404(User, id=owner_id) + invite = _accepted_visit_invite(owner, request.user) + if invite is None: + return HttpResponseForbidden() + sig_card, sig_reversed = _resolve_sig(owner, active_draw_for(owner)) + return render(request, "apps/gameboard/my_sea_gate.html", { + "spectator": True, + "owner": owner, + "sea_invite": invite, + "visit_owner_id": owner.id, + "user_has_sig": sig_card is not None, + "significator": sig_card, + "significator_reversed": sig_reversed, + # Visitor gate is single-step — no reserve/commit, so the refund + + # PAID DRAW blocks (gated on `deposit_reserved`) never render. + "deposit_reserved": False, + "hand_non_empty": False, + "page_class": "page-gameboard page-my-sea page-my-sea-gate page-my-sea-visit-gate", + }) + + +@login_required(login_url="/") +@require_POST +def my_sea_visit_insert_token(request, owner_id): + """Single-step visitor token deposit. Selects + debits the visitor's next- + priority token (same priority chain as the owner gate), then records + `token_deposited_at` + a 24h `voice_until` on the SeaInvite (NOT on the + owner's MySeaDraw), marking seat 2C present + opening the voice window.""" + from datetime import timedelta + from apps.lyric.models import User + owner = get_object_or_404(User, id=owner_id) + invite = _accepted_visit_invite(owner, request.user) + if invite is None: + return HttpResponseForbidden() + if invite.token_deposited_at is None: + token = _select_my_sea_token(request.user) + if token is not None: + debit_my_sea_token(request.user, token) + now = timezone.now() + invite.token_deposited_at = now + invite.voice_until = now + timedelta(hours=24) + invite.save(update_fields=["token_deposited_at", "voice_until"]) + return redirect("my_sea_visit", owner_id=owner.id) + + +@login_required(login_url="/") +@require_POST +def my_sea_visit_leave(request, owner_id): + """Spectator BYE — drop presence: status=LEFT, left_at=now, voice killed + (frees seat 2C + ends the voice window). Matches the requester's latest + non-DECLINED invite for this owner.""" + from apps.lyric.models import User + from .models import SeaInvite + owner = get_object_or_404(User, id=owner_id) + invite = (SeaInvite.objects + .filter(owner=owner, invitee=request.user) + .exclude(status=SeaInvite.DECLINED) + .order_by("-created_at").first()) + if invite is None: + return HttpResponseForbidden() + invite.status = SeaInvite.LEFT + invite.left_at = timezone.now() + invite.voice_until = None + invite.save(update_fields=["status", "left_at", "voice_until"]) + return redirect("gameboard") + + def _my_sea_deck_data(user, exclude_id=None): """Build the shuffled deck (levity + gravity halves) for the my-sea picker's card-draw mechanic. Card payload shape is whatever diff --git a/src/functional_tests/test_game_my_sea.py b/src/functional_tests/test_game_my_sea.py index 172e4f0..9979887 100644 --- a/src/functional_tests/test_game_my_sea.py +++ b/src/functional_tests/test_game_my_sea.py @@ -6,6 +6,7 @@ Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM (→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/ mirrors the gate hint in its empty-state slot. """ +from django.utils import timezone from selenium.webdriver.common.by import By from .base import FunctionalTest @@ -1891,6 +1892,61 @@ class MySeaInviteAcceptanceLogTest(FunctionalTest): self.assertEqual(bye.text.upper(), "BYE") +class MySeaSpectatorFlowTest(FunctionalTest): + """Phase B of [[my-sea-invite-voice-blueprint]] — an ACCEPTED invitee + visits the owner's my-sea, deposits a token at the visitor gate, and + thereby occupies seat 2C (the center btn flips GATE VIEW → VIEW DRAW).""" + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) # portrait — center-hex clicks + _seed_gameboard_applets() + from apps.gameboard.models import MySeaDraw, SeaInvite + self.owner = User.objects.create(email="owner@test.io", username="discoman") + # Owner has drawn → seat 1C is occupied + there's a draw to view. + MySeaDraw.objects.create( + user=self.owner, spread="situation-action-outcome", + significator_id=1, + hand=[{"position": "lay", "card_id": 1, "reversed": False, + "polarity": "gravity"}], + ) + self.email = "bud@test.io" + self.bud = User.objects.create(email=self.email, username="budster") + self.invite = SeaInvite.objects.create( + owner=self.owner, invitee=self.bud, invitee_email=self.email, + status=SeaInvite.ACCEPTED, accepted_at=timezone.now(), + ) + + def test_spectator_deposits_token_to_occupy_seat_2c(self): + from django.urls import reverse + self.create_pre_authenticated_session(self.email) + self.browser.get( + self.live_server_url + reverse("my_sea_visit", args=[self.owner.id]) + ) + # Before deposit: GATE VIEW shown, seat 2C not yet seated. + gate_btn = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_my_sea_gate_view_btn") + ) + seat2 = self.browser.find_element( + By.CSS_SELECTOR, ".table-seat[data-slot='2']" + ) + self.assertNotIn("seated", seat2.get_attribute("class")) + # GATE VIEW → visitor gate → INSERT TOKEN (single-step deposit). + gate_btn.click() + insert = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-rails") + ) + insert.click() + # Back on the visit page: VIEW DRAW now shown, seat 2C seated. + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_my_sea_view_draw_btn") + ) + seat2 = self.browser.find_element( + By.CSS_SELECTOR, ".table-seat[data-slot='2']" + ) + self.assertIn("seated", seat2.get_attribute("class")) + + class MySeaGearBtnTest(FunctionalTest): """Sprint 6 iter 6c — `.gear-btn` on every my-sea page state (landing / picker / gatekeeper). Opens a NVM-only menu (DEL/BYE diff --git a/src/static/tests/MySeaSeatsSpec.js b/src/static/tests/MySeaSeatsSpec.js new file mode 100644 index 0000000..7e91a3c --- /dev/null +++ b/src/static/tests/MySeaSeatsSpec.js @@ -0,0 +1,54 @@ +// Jasmine spec for my-sea-seats.js — the one-shot "seated" glow module +// (Phase B of the my-sea invite/voice sprint). Verifies the localStorage- +// gated first-view behaviour + the timed flare-class removal. +describe('my-sea-seats one-shot seated glow', function () { + var seat; + + beforeEach(function () { + window.localStorage.clear(); + seat = document.createElement('div'); + seat.className = 'table-seat seated'; + document.body.appendChild(seat); + }); + + afterEach(function () { + if (seat && seat.parentNode) seat.parentNode.removeChild(seat); + window.localStorage.clear(); + }); + + it('exposes playSeatGlow globally', function () { + expect(typeof window.playSeatGlow).toBe('function'); + }); + + it('adds the seat-just-seated flare class', function () { + window.playSeatGlow(seat); + expect(seat.classList.contains('seat-just-seated')).toBe(true); + }); + + it('marks a tokened seat seen in localStorage', function () { + seat.setAttribute('data-seat-token', 'visit-42'); + window.playSeatGlow(seat); + expect(window.localStorage.getItem('mysea-seat-seen:visit-42')).toBe('1'); + }); + + it('does not replay the flare for an already-seen token', function () { + seat.setAttribute('data-seat-token', 'visit-42'); + window.localStorage.setItem('mysea-seat-seen:visit-42', '1'); + window.playSeatGlow(seat); + expect(seat.classList.contains('seat-just-seated')).toBe(false); + }); + + it('always animates a tokenless seat (no persistence)', function () { + window.playSeatGlow(seat); + expect(seat.classList.contains('seat-just-seated')).toBe(true); + }); + + it('removes the flare class after the ~1.5s glow window', function () { + jasmine.clock().install(); + window.playSeatGlow(seat); + expect(seat.classList.contains('seat-just-seated')).toBe(true); + jasmine.clock().tick(1600); + expect(seat.classList.contains('seat-just-seated')).toBe(false); + jasmine.clock().uninstall(); + }); +}); diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html index 8ea2547..8f8cc97 100644 --- a/src/static/tests/SpecRunner.html +++ b/src/static/tests/SpecRunner.html @@ -31,6 +31,7 @@ + @@ -45,6 +46,7 @@ + diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 68bf197..3572fb1 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -551,6 +551,13 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut 50% { opacity: 0.3; } } +// my-sea seat one-shot "just seated" flare — see `.table-seat.seat-just-seated`. +@keyframes my-sea-seat-flare { + 0% { color: rgba(var(--terUser), 1); filter: drop-shadow(0 0 6px rgba(var(--ninUser), 1)); } + 70% { color: rgba(var(--terUser), 1); filter: drop-shadow(0 0 6px rgba(var(--ninUser), 0.85)); } + 100% { color: rgba(var(--secUser), 1); filter: none; } +} + .table-seat { position: absolute; display: grid; @@ -616,6 +623,23 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut filter: none; } + // ── my-sea "seated" occupancy (seat 1C owner, 2C visitor) ────────────── + // Steady state once a seat is taken: chair settles to full-opacity + // --secUser (mirrors .role-confirmed); the status icon is already + // .fa-circle-check (green) from the server / JS swap. + &.seated .fa-chair { + color: rgba(var(--secUser), 1); + filter: none; + } + // One-shot "just seated" flare (~1.5s) 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. + &.seat-just-seated .fa-chair { + animation: my-sea-seat-flare 1.5s ease forwards; + } + .seat-portrait { width: 36px; height: 36px; diff --git a/src/static_src/tests/MySeaSeatsSpec.js b/src/static_src/tests/MySeaSeatsSpec.js new file mode 100644 index 0000000..7e91a3c --- /dev/null +++ b/src/static_src/tests/MySeaSeatsSpec.js @@ -0,0 +1,54 @@ +// Jasmine spec for my-sea-seats.js — the one-shot "seated" glow module +// (Phase B of the my-sea invite/voice sprint). Verifies the localStorage- +// gated first-view behaviour + the timed flare-class removal. +describe('my-sea-seats one-shot seated glow', function () { + var seat; + + beforeEach(function () { + window.localStorage.clear(); + seat = document.createElement('div'); + seat.className = 'table-seat seated'; + document.body.appendChild(seat); + }); + + afterEach(function () { + if (seat && seat.parentNode) seat.parentNode.removeChild(seat); + window.localStorage.clear(); + }); + + it('exposes playSeatGlow globally', function () { + expect(typeof window.playSeatGlow).toBe('function'); + }); + + it('adds the seat-just-seated flare class', function () { + window.playSeatGlow(seat); + expect(seat.classList.contains('seat-just-seated')).toBe(true); + }); + + it('marks a tokened seat seen in localStorage', function () { + seat.setAttribute('data-seat-token', 'visit-42'); + window.playSeatGlow(seat); + expect(window.localStorage.getItem('mysea-seat-seen:visit-42')).toBe('1'); + }); + + it('does not replay the flare for an already-seen token', function () { + seat.setAttribute('data-seat-token', 'visit-42'); + window.localStorage.setItem('mysea-seat-seen:visit-42', '1'); + window.playSeatGlow(seat); + expect(seat.classList.contains('seat-just-seated')).toBe(false); + }); + + it('always animates a tokenless seat (no persistence)', function () { + window.playSeatGlow(seat); + expect(seat.classList.contains('seat-just-seated')).toBe(true); + }); + + it('removes the flare class after the ~1.5s glow window', function () { + jasmine.clock().install(); + window.playSeatGlow(seat); + expect(seat.classList.contains('seat-just-seated')).toBe(true); + jasmine.clock().tick(1600); + expect(seat.classList.contains('seat-just-seated')).toBe(false); + jasmine.clock().uninstall(); + }); +}); diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index 8ea2547..8f8cc97 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -31,6 +31,7 @@ + @@ -45,6 +46,7 @@ + diff --git a/src/templates/apps/gameboard/_partials/_my_sea_gear.html b/src/templates/apps/gameboard/_partials/_my_sea_gear.html index 5dd59cf..9d6e86d 100644 --- a/src/templates/apps/gameboard/_partials/_my_sea_gear.html +++ b/src/templates/apps/gameboard/_partials/_my_sea_gear.html @@ -11,5 +11,14 @@ {% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %} {% include "apps/applets/_partials/_gear.html" with menu_id="id_my_sea_menu" %} diff --git a/src/templates/apps/gameboard/_partials/_my_sea_readonly_draw.html b/src/templates/apps/gameboard/_partials/_my_sea_readonly_draw.html new file mode 100644 index 0000000..0c24279 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_my_sea_readonly_draw.html @@ -0,0 +1,45 @@ +{# 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/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 73b87c3..8ca611e 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -94,7 +94,7 @@ {# semantics clean. `.position-status-icon` + #} {# `.fa-ban` are unchanged — already role- #} {# agnostic in _room.scss. #} -
+
{{ n }}C @@ -964,6 +964,10 @@ + {# Shared one-shot "seated" glow module — also drives seat 2C on #} + {# the spectator page. Exposes window.playSeatGlow + auto-plays on #} + {# load for any server-rendered .seated[data-seat-token] seat. #} + + +{% endblock scripts %}