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

@@ -11,5 +11,14 @@
{% if not nvm_url %}{% url 'gameboard' as nvm_url %}{% endif %}
<div id="id_my_sea_menu" style="display:none">
<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>
{% 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` + #}
{# `.fa-ban` are unchanged — already role- #}
{# 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>
<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>
@@ -964,6 +964,10 @@
</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>
(function () {
var page = document.querySelector('.my-sea-page');
@@ -994,6 +998,11 @@
statusIcon.classList.remove('fa-ban');
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 () {
page.setAttribute('data-phase', 'picker');

View File

@@ -28,7 +28,10 @@
{# their email prefix). #}
<div class="gate-title-panel">
<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>
</div>
@@ -40,7 +43,7 @@
<div class="gate-main-panel">
<div class="token-slot{% if not deposit_reserved %} active{% else %} pending{% endif %}">
{% 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 %}
<button type="submit" class="token-rails" aria-label="Insert token to play">
<span class="rail"></span>
@@ -87,12 +90,19 @@
{# + gear-btn NVM-only menu. Both render outside .gate-modal so the #}
{# bud-btn lives at viewport fixed position (per `_bud.scss`) and #}
{# the gear-btn sits atop `#id_kit_btn` in the bottom-right corner. #}
{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}
{# Gatekeeper NVM nav-backs to the my-sea TABLE HEX (landing) so the #}
{# user lands one step back from the gatekeeper rather than ejecting #}
{# all the way to /gameboard/. #}
{% url 'my_sea' as nvm_url %}
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
{# Bud invite is owner-only — a spectator doesn't invite others into #}
{# someone else's Sea. #}
{% if not spectator %}{% include "apps/gameboard/_partials/_my_sea_bud_panel.html" %}{% endif %}
{# 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 %}
{% include "apps/gameboard/_partials/_my_sea_gear.html" %}
{% endif %}
{% include "apps/gameboard/_partials/_burger.html" %}
</div>
{% 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 %}