my-sea spectator Phase B: seat-2C occupancy + visitor token gate + one-shot seated glow + gear BYE — TDD

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/<uuid:owner_id>/ (+ /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 <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-27 13:35:00 -04:00
parent fb8563eed2
commit d0c39b51b6
15 changed files with 740 additions and 9 deletions

View File

@@ -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);
});
});
}());

View File

@@ -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)

View File

@@ -25,6 +25,13 @@ urlpatterns = [
name='my_sea_invite_accept'), name='my_sea_invite_accept'),
path('my-sea/invite/decline/<int:invite_id>', views.my_sea_invite_decline, path('my-sea/invite/decline/<int:invite_id>', views.my_sea_invite_decline,
name='my_sea_invite_decline'), name='my_sea_invite_decline'),
path('my-sea/visit/<uuid:owner_id>/', views.my_sea_visit, name='my_sea_visit'),
path('my-sea/visit/<uuid:owner_id>/gate/', views.my_sea_visit_gate,
name='my_sea_visit_gate'),
path('my-sea/visit/<uuid:owner_id>/insert', views.my_sea_visit_insert_token,
name='my_sea_visit_insert_token'),
path('my-sea/visit/<uuid:owner_id>/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, path('my-sea/brief/free-draw/dismiss', views.my_sea_dismiss_free_draw_brief,
name='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, path('my-sea/brief/paid-draw/dismiss', views.my_sea_dismiss_paid_draw_brief,

View File

@@ -865,6 +865,131 @@ def my_sea_invite_decline(request, invite_id):
return _redirect_to_invite_log(invite) 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): def _my_sea_deck_data(user, exclude_id=None):
"""Build the shuffled deck (levity + gravity halves) for the my-sea """Build the shuffled deck (levity + gravity halves) for the my-sea
picker's card-draw mechanic. Card payload shape is whatever picker's card-draw mechanic. Card payload shape is whatever

View File

@@ -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/ (→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/
mirrors the gate hint in its empty-state slot. mirrors the gate hint in its empty-state slot.
""" """
from django.utils import timezone
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
@@ -1891,6 +1892,61 @@ class MySeaInviteAcceptanceLogTest(FunctionalTest):
self.assertEqual(bye.text.upper(), "BYE") 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): class MySeaGearBtnTest(FunctionalTest):
"""Sprint 6 iter 6c — `.gear-btn` on every my-sea page state """Sprint 6 iter 6c — `.gear-btn` on every my-sea page state
(landing / picker / gatekeeper). Opens a NVM-only menu (DEL/BYE (landing / picker / gatekeeper). Opens a NVM-only menu (DEL/BYE

View File

@@ -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();
});
});

View File

@@ -31,6 +31,7 @@
<script src="RowLockSpec.js"></script> <script src="RowLockSpec.js"></script>
<script src="WalletShopSpec.js"></script> <script src="WalletShopSpec.js"></script>
<script src="BurgerSpec.js"></script> <script src="BurgerSpec.js"></script>
<script src="MySeaSeatsSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script> <script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
@@ -45,6 +46,7 @@
<script src="/static/apps/epic/sea.js"></script> <script src="/static/apps/epic/sea.js"></script>
<script src="/static/apps/epic/burger-btn.js"></script> <script src="/static/apps/epic/burger-btn.js"></script>
<script src="/static/apps/gameboard/game-kit.js"></script> <script src="/static/apps/gameboard/game-kit.js"></script>
<script src="/static/apps/gameboard/my-sea-seats.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script> <script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/sky-wheel.js"></script> <script src="/static/apps/gameboard/sky-wheel.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->

View File

@@ -551,6 +551,13 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
50% { opacity: 0.3; } 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 { .table-seat {
position: absolute; position: absolute;
display: grid; display: grid;
@@ -616,6 +623,23 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
filter: none; 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 { .seat-portrait {
width: 36px; width: 36px;
height: 36px; height: 36px;

View File

@@ -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();
});
});

View File

@@ -31,6 +31,7 @@
<script src="RowLockSpec.js"></script> <script src="RowLockSpec.js"></script>
<script src="WalletShopSpec.js"></script> <script src="WalletShopSpec.js"></script>
<script src="BurgerSpec.js"></script> <script src="BurgerSpec.js"></script>
<script src="MySeaSeatsSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/applets/row-lock.js"></script> <script src="/static/apps/applets/row-lock.js"></script>
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
@@ -45,6 +46,7 @@
<script src="/static/apps/epic/sea.js"></script> <script src="/static/apps/epic/sea.js"></script>
<script src="/static/apps/epic/burger-btn.js"></script> <script src="/static/apps/epic/burger-btn.js"></script>
<script src="/static/apps/gameboard/game-kit.js"></script> <script src="/static/apps/gameboard/game-kit.js"></script>
<script src="/static/apps/gameboard/my-sea-seats.js"></script>
<script src="/static/apps/gameboard/d3.min.js"></script> <script src="/static/apps/gameboard/d3.min.js"></script>
<script src="/static/apps/gameboard/sky-wheel.js"></script> <script src="/static/apps/gameboard/sky-wheel.js"></script>
<!-- Jasmine env config (optional) --> <!-- Jasmine env config (optional) -->

View File

@@ -11,5 +11,14 @@
{% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %} {% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %}
<div id="id_my_sea_menu" style="display:none"> <div id="id_my_sea_menu" style="display:none">
<button type="button" class="btn btn-cancel" onclick="location.href='{{ nvm_url }}'">NVM</button> <button type="button" class="btn btn-cancel" onclick="location.href='{{ nvm_url }}'">NVM</button>
{# Spectator-only BYE (Phase B) — drops presence (status=LEFT, frees seat #}
{# 2C, kills voice). Rendered below NVM only when the caller passes a #}
{# `leave_url`; the owner's pages never do, so their menu is unchanged. #}
{% if leave_url %}
<form method="POST" action="{{ leave_url }}" style="display:contents">
{% csrf_token %}
<button type="submit" id="id_my_sea_bye_btn" class="btn btn-abandon">BYE</button>
</form>
{% endif %}
</div> </div>
{% include "apps/applets/_partials/_gear.html" with menu_id="id_my_sea_menu" %} {% include "apps/applets/_partials/_gear.html" with menu_id="id_my_sea_menu" %}

View File

@@ -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). #}
<div class="my-sea-scroll my-sea-visit-scroll">
{% for slot in my_sea_slots %}
{% if slot.card %}
<div class="my-sea-slot-wrap">
<div class="my-sea-slot my-sea-slot--filled my-sea-slot--{{ slot.polarity }}{% if slot.reversed %} my-sea-slot--reversed{% endif %}{% if slot.card.deck_variant.has_card_images %} my-sea-slot--image{% endif %}"
data-position="{{ slot.position }}"
data-card-id="{{ slot.card.id }}"
data-arcana-key="{{ slot.card.arcana }}">
{% if slot.card.deck_variant.has_card_images %}
<img class="sig-stage-card-img" src="{{ slot.card.image_url }}" alt="{{ slot.card.name }}">
{% else %}
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
</div>
<div class="fan-card-face">
{% if slot.face.qualifier_first %}
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
<p class="fan-card-name">{{ slot.face.title }}</p>
{% else %}
<p class="fan-card-name">{{ slot.face.title }}</p>
<p class="fan-card-qualifier">{{ slot.face.qualifier }}</p>
{% endif %}
<p class="fan-card-arcana">{{ slot.card.get_arcana_display }}</p>
</div>
<div class="fan-card-corner fan-card-corner--br">
<span class="fan-corner-rank">{{ slot.card.corner_rank }}</span>
{% if slot.card.suit_icon %}<i class="fa-solid {{ slot.card.suit_icon }}"></i>{% endif %}
</div>
{% endif %}
</div>
<span class="my-sea-slot-label">{{ slot.label }}</span>
</div>
{% else %}
<div class="my-sea-slot-wrap">
<div class="my-sea-slot my-sea-slot--empty" data-position="{{ slot.position }}"></div>
<span class="my-sea-slot-label my-sea-slot-label--empty">{{ slot.label }}</span>
</div>
{% endif %}
{% endfor %}
</div>

View File

@@ -94,7 +94,7 @@
{# semantics clean. `.position-status-icon` + #} {# semantics clean. `.position-status-icon` + #}
{# `.fa-ban` are unchanged — already role- #} {# `.fa-ban` are unchanged — already role- #}
{# agnostic in _room.scss. #} {# agnostic in _room.scss. #}
<div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}"> <div class="table-seat{% if n == '1' and hand_non_empty %} seated{% endif %}" data-slot="{{ n }}"{% if n == '1' and hand_non_empty %} data-seat-token="owner-{{ request.user.id }}-{{ active_draw.id }}"{% endif %}>
<i class="fa-solid fa-chair"></i> <i class="fa-solid fa-chair"></i>
<span class="seat-position-label">{{ n }}C</span> <span class="seat-position-label">{{ n }}C</span>
<i class="position-status-icon fa-solid {% if n == '1' and hand_non_empty %}fa-circle-check{% else %}fa-ban{% endif %}"></i> <i class="position-status-icon fa-solid {% if n == '1' and hand_non_empty %}fa-circle-check{% else %}fa-ban{% endif %}"></i>
@@ -964,6 +964,10 @@
</script> </script>
<script src="{% static 'apps/epic/room.js' %}"></script> <script src="{% static 'apps/epic/room.js' %}"></script>
{# 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. #}
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
<script> <script>
(function () { (function () {
var page = document.querySelector('.my-sea-page'); var page = document.querySelector('.my-sea-page');
@@ -994,6 +998,11 @@
statusIcon.classList.remove('fa-ban'); statusIcon.classList.remove('fa-ban');
statusIcon.classList.add('fa-circle-check'); statusIcon.classList.add('fa-circle-check');
} }
// First-draw moment → play the one-shot seated flare
// (same module the spectator page + load-time handler
// use). On a later reload the server-rendered token
// gates a repeat via localStorage.
if (window.playSeatGlow) window.playSeatGlow(seat1);
} }
setTimeout(function () { setTimeout(function () {
page.setAttribute('data-phase', 'picker'); page.setAttribute('data-phase', 'picker');

View File

@@ -28,7 +28,10 @@
{# their email prefix). #} {# their email prefix). #}
<div class="gate-title-panel"> <div class="gate-title-panel">
<header class="gate-header"> <header class="gate-header">
<h1>{{ user|at_handle }}'s Sea</h1> {# Spectator gate titles the OWNER's Sea (the visitor is #}
{# depositing into someone else's table); owner gate #}
{# titles their own. #}
<h1>{% if spectator %}{{ owner|at_handle }}{% else %}{{ user|at_handle }}{% endif %}'s Sea</h1>
</header> </header>
</div> </div>
@@ -40,7 +43,7 @@
<div class="gate-main-panel"> <div class="gate-main-panel">
<div class="token-slot{% if not deposit_reserved %} active{% else %} pending{% endif %}"> <div class="token-slot{% if not deposit_reserved %} active{% else %} pending{% endif %}">
{% if not deposit_reserved %} {% if not deposit_reserved %}
<form method="POST" action="{% url 'my_sea_insert_token' %}" style="display:contents"> <form method="POST" action="{% if spectator %}{% url 'my_sea_visit_insert_token' visit_owner_id %}{% else %}{% url 'my_sea_insert_token' %}{% endif %}" style="display:contents">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="token-rails" aria-label="Insert token to play"> <button type="submit" class="token-rails" aria-label="Insert token to play">
<span class="rail"></span> <span class="rail"></span>
@@ -87,12 +90,19 @@
{# + gear-btn NVM-only menu. Both render outside .gate-modal so the #} {# + gear-btn NVM-only menu. Both render outside .gate-modal so the #}
{# bud-btn lives at viewport fixed position (per `_bud.scss`) and #} {# bud-btn lives at viewport fixed position (per `_bud.scss`) and #}
{# the gear-btn sits atop `#id_kit_btn` in the bottom-right corner. #} {# the gear-btn sits atop `#id_kit_btn` in the bottom-right corner. #}
{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %} {# Bud invite is owner-only — a spectator doesn't invite others into #}
{# Gatekeeper NVM nav-backs to the my-sea TABLE HEX (landing) so the #} {# someone else's Sea. #}
{# user lands one step back from the gatekeeper rather than ejecting #} {% if not spectator %}{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}{% endif %}
{# all the way to /gameboard/. #} {# NVM nav-backs to the my-sea TABLE HEX one step back. For a spectator #}
{# that's the owner's visit landing + a BYE drops presence entirely. #}
{% if spectator %}
{% url 'my_sea_visit' visit_owner_id as nvm_url %}
{% url 'my_sea_visit_leave' visit_owner_id as leave_url %}
{% include "apps/gameboard/_partials/_my_sea_gear.html" with nvm_url=nvm_url leave_url=leave_url %}
{% else %}
{% url 'my_sea' as nvm_url %} {% url 'my_sea' as nvm_url %}
{% include "apps/gameboard/_partials/_my_sea_gear.html" %} {% include "apps/gameboard/_partials/_my_sea_gear.html" %}
{% endif %}
{% include "apps/gameboard/_partials/_burger.html" %} {% include "apps/gameboard/_partials/_burger.html" %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -0,0 +1,106 @@
{% extends "core/base.html" %}
{% load static %}
{% load lyric_extras %}
{% block title_text %}Game Sea{% endblock title_text %}
{% block header_text %}<span>Game</span><span>Sea</span>{% endblock header_text %}
{% block content %}
{# Phase B spectator surface — an ACCEPTED invitee watches {{ owner|at_handle }}'s #}
{# Sea read-only. The owner's my_sea.html is left untouched; this is a #}
{# dedicated, simpler template: the table hex (seat 1C = owner, 2C = this #}
{# visitor) + the owner's draw rendered read-only (no FLIP / DEL / AUTO #}
{# DRAW). GATE VIEW opens the visitor gate; once a token is deposited the #}
{# center btn becomes VIEW DRAW (toggles the read-only draw into view). #}
<div class="my-sea-page my-sea-visit-page"
data-phase="landing"
data-spectator="true"
data-polarity="{% if significator_reversed %}levity{% else %}gravity{% endif %}">
<div class="my-sea-landing my-sea-visit-landing">
<div class="room-shell">
<div id="id_game_table" class="room-table">
<div class="room-table-scene">
<div class="table-hex-border">
<div class="table-hex">
<div class="table-center">
{% if seat2_present %}
{# Visitor is present — VIEW DRAW reveals the owner's #}
{# read-only draw (client-side toggle below). #}
<button id="id_my_sea_view_draw_btn" type="button"
class="btn btn-primary">VIEW<br>DRAW</button>
{% else %}
{# Not yet present — GATE VIEW → visitor token gate. #}
<button id="id_my_sea_gate_view_btn" type="button"
class="btn btn-primary"
data-gate-url="{% url 'my_sea_visit_gate' owner.id %}">GATE<br>VIEW</button>
{% endif %}
</div>
</div>
</div>
{% for n in "123456" %}
{% if n == '1' and seat1_present %}
<div class="table-seat seated" data-slot="1"
data-seat-token="owner-{{ owner.id }}-{{ owner_draw_id }}">
<i class="fa-solid fa-chair"></i>
<span class="seat-position-label">1C</span>
<i class="position-status-icon fa-solid fa-circle-check"></i>
</div>
{% elif n == '2' and seat2_present %}
<div class="table-seat seated" data-slot="2"
data-seat-token="visit-{{ sea_invite.id }}">
<i class="fa-solid fa-chair"></i>
<span class="seat-position-label">2C</span>
<i class="position-status-icon fa-solid fa-circle-check"></i>
</div>
{% else %}
<div class="table-seat" data-slot="{{ n }}">
<i class="fa-solid fa-chair"></i>
<span class="seat-position-label">{{ n }}C</span>
<i class="position-status-icon fa-solid fa-ban"></i>
</div>
{% endif %}
{% endfor %}
</div>
</div>
</div>
</div>
{% if seat2_present %}
{# Owner's draw, read-only. Hidden until VIEW DRAW toggles it in. #}
<div id="id_my_sea_visit_draw" class="my-sea-visit-draw" style="display:none">
{% include "apps/gameboard/_partials/_my_sea_readonly_draw.html" %}
</div>
{% endif %}
{# Gear menu — NVM (back to /gameboard/) + BYE (drop presence, free 2C). #}
{% url 'my_sea_visit_leave' owner.id as leave_url %}
{% include "apps/gameboard/_partials/_my_sea_gear.html" with leave_url=leave_url %}
</div>
{% endblock content %}
{% block scripts %}
<script src="{% static 'apps/gameboard/my-sea-seats.js' %}"></script>
<script>
(function () {
// VIEW DRAW toggles the read-only draw against the table hex.
var viewBtn = document.getElementById('id_my_sea_view_draw_btn');
var draw = document.getElementById('id_my_sea_visit_draw');
var landing = document.querySelector('.my-sea-visit-landing');
if (viewBtn && draw) {
viewBtn.addEventListener('click', function () {
var showing = draw.style.display !== 'none';
draw.style.display = showing ? 'none' : '';
if (landing) landing.style.display = showing ? '' : 'none';
});
}
// GATE VIEW → visitor gate.
var gateBtn = document.getElementById('id_my_sea_gate_view_btn');
if (gateBtn) {
gateBtn.addEventListener('click', function () {
window.location.href = gateBtn.dataset.gateUrl || '#';
});
}
}());
</script>
{% endblock scripts %}