my_sea_visit nav: phase-aware NVM (hex→bud page, draw→hex) + navbar GATE VIEW → visit gate + guard reposition on resize — TDD
Spectator guard-portal + NVM/GATE VIEW routing (user-spec 2026-05-30): - The leave-the-sea guard now confirms with OK (was NVM); `_my_sea_gear.html` gains an `nvm_handler` param so a caller can swap the default leave-nav. - my_sea_visit NVM is phase-aware (`mySeaVisitNvm`): on the DRAW/spread phase it flips back to the table hex (client toggle, stays in-ecosphere → no voice guard, mirroring the owner's picker→landing); on the table hex it LEAVES to the bud's page (`billboard:bud_page`) behind the shared voice-disconnect guard. - The navbar GATE VIEW opens THIS owner's visitor gatekeeper on my_sea_visit (+ its gate page, whose page_class also carries `page-my-sea-visit`), not the viewer's own sea gate; owner pages are unchanged. - showGuard now RE-POSITIONS the open guard on resize/orientationchange (rAF-throttled) so it follows its anchor instead of stranding at its show-time coords — a cross-cutting fix (every gear-menu guard) for the portal landing off-screen after an orientation flip relocated the gear menu. Coverage: MySeaVisitNavTest (navbar→visit gate, gear NVM→mySeaVisitNvm, bud URL, OK label) + MySeaOwnerNavbarGateUnaffectedTest (owner gate untouched). Verified live in Firefox: picker NVM returns to the hex; the guard followed its anchor from 882px→54px on a simulated orientation flip, staying in-viewport. Code architected by Disco DeDisco <discodedisco@outlook.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -257,6 +257,61 @@ class MySeaVisitEmptyHandLabelsTest(TestCase):
|
||||
f'data-position="{caption.lower()}">{caption}</span>', html)
|
||||
|
||||
|
||||
class MySeaVisitNavTest(TestCase):
|
||||
"""NVM + GATE VIEW routing on the spectator surface (user-spec 2026-05-30):
|
||||
the navbar GATE VIEW opens THIS owner's visitor gate (not the viewer's own);
|
||||
the gear NVM is phase-aware (mySeaVisitNvm) — table hex → the bud's page,
|
||||
draw phase → back to the hex; the leave-the-sea guard confirms with OK."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = _owner_with_sig()
|
||||
self.bud = User.objects.create(email="bud@test.io", username="budster")
|
||||
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.client.force_login(self.bud)
|
||||
self.html = self.client.get(
|
||||
reverse("my_sea_visit", args=[self.owner.id])).content.decode()
|
||||
|
||||
def test_navbar_gate_view_targets_the_visit_gate_not_own(self):
|
||||
visit_gate = reverse("my_sea_visit_gate", args=[self.owner.id])
|
||||
self.assertIn(f"window.location.href='{visit_gate}'", self.html)
|
||||
# The viewer's OWN sea gate must NOT be the navbar target here.
|
||||
self.assertNotIn(
|
||||
f"window.location.href='{reverse('my_sea_gate')}'", self.html)
|
||||
|
||||
def test_gear_nvm_uses_the_phase_aware_visit_handler(self):
|
||||
self.assertIn('onclick="mySeaVisitNvm(event)"', self.html)
|
||||
|
||||
def test_visit_nvm_leaves_to_the_bud_page_from_the_hex(self):
|
||||
bud_url = reverse("billboard:bud_page", args=[self.owner.id])
|
||||
self.assertIn(f"BUD_URL = '{bud_url}'", self.html)
|
||||
|
||||
def test_leave_guard_confirms_with_OK(self):
|
||||
self.assertIn("yesLabel: 'OK'", self.html)
|
||||
self.assertNotIn("yesLabel: 'NVM'", self.html)
|
||||
|
||||
|
||||
class MySeaOwnerNavbarGateUnaffectedTest(TestCase):
|
||||
"""Regression — the OWNER's own my_sea navbar GATE VIEW still routes to the
|
||||
user's own gate (the visit-gate branch must not leak onto owner pages)."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import personal_sig_cards
|
||||
self.user = User.objects.create(email="owner@test.io", username="discoself")
|
||||
self.user.significator = personal_sig_cards(self.user)[0]
|
||||
self.user.save(update_fields=["significator"])
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_owner_navbar_gate_targets_own_gate(self):
|
||||
html = self.client.get(reverse("my_sea")).content.decode()
|
||||
self.assertIn(
|
||||
f"window.location.href='{reverse('my_sea_gate')}'", html)
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -31,7 +31,7 @@ window.mySeaGuardedNav = window.mySeaGuardedNav || function (e, url) {
|
||||
window.location.href = url;
|
||||
},
|
||||
null, // dismiss = stay
|
||||
{ yesLabel: 'NVM' }
|
||||
{ yesLabel: 'OK' } // confirm = OK (user-spec 2026-05-30)
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -43,7 +43,10 @@ window.mySeaGuardedNav = window.mySeaGuardedNav || function (e, url) {
|
||||
{# column in portrait / row in landscape (the menu itself can't be flexed #}
|
||||
{# since applets.js force-sets display:block on open). #}
|
||||
<div class="menu-btns">
|
||||
<button type="button" class="btn btn-cancel" onclick="mySeaGuardedNav(event, '{{ nvm_url }}')">NVM</button>
|
||||
{# `nvm_handler` lets a caller swap the default leave-the-sea nav for a #}
|
||||
{# phase-aware handler (the visitor's my_sea_visit routes NVM to the bud #}
|
||||
{# page from the table hex, but back to the hex from the draw phase). #}
|
||||
<button type="button" class="btn btn-cancel" onclick="{% if nvm_handler %}{{ nvm_handler }}{% else %}mySeaGuardedNav(event, '{{ nvm_url }}'){% endif %}">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. #}
|
||||
|
||||
@@ -73,9 +73,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Gear menu — NVM (back to /gameboard/) + BYE (drop presence, free 2C). #}
|
||||
{# Gear menu — NVM is phase-aware (mySeaVisitNvm): table hex → the bud's #}
|
||||
{# page (voice-disconnect guard); draw phase → back to the hex (client #}
|
||||
{# toggle, stays in-ecosphere, no guard). BYE drops presence + frees 2C. #}
|
||||
{% url 'my_sea_visit_leave' owner.id as leave_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" with leave_url=leave_url %}
|
||||
{% include "apps/gameboard/_partials/_my_sea_gear.html" with leave_url=leave_url nvm_handler="mySeaVisitNvm(event)" %}
|
||||
{# Burger fan — carries the voice sub-btn (active while voice_active). #}
|
||||
{% include "apps/gameboard/_partials/_burger.html" %}
|
||||
</div>
|
||||
@@ -323,14 +325,42 @@
|
||||
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');
|
||||
function _setDraw(show) {
|
||||
if (!draw) return;
|
||||
draw.style.display = show ? '' : 'none';
|
||||
if (landing) landing.style.display = show ? 'none' : '';
|
||||
if (page) page.setAttribute('data-phase', show ? 'picker' : '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';
|
||||
if (page) page.setAttribute('data-phase', showing ? 'landing' : 'picker');
|
||||
_setDraw(draw.style.display === 'none');
|
||||
});
|
||||
}
|
||||
|
||||
// Phase-aware NVM (the gear menu's NVM calls this). On the DRAW/spread
|
||||
// phase NVM just flips back to the table hex — it stays inside the
|
||||
// my_sea_visit ecosphere (voice untouched), so NO guard, like the owner's
|
||||
// picker→landing NVM. On the table hex NVM LEAVES to the bud's page, with
|
||||
// the shared voice-disconnect guard (mySeaGuardedNav). (user-spec
|
||||
// 2026-05-30.)
|
||||
var BUD_URL = '{% url 'billboard:bud_page' owner.id %}';
|
||||
function _closeGearMenu() {
|
||||
var menu = document.getElementById('id_my_sea_menu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
var gear = document.querySelector('.gear-btn[data-menu-target="id_my_sea_menu"]');
|
||||
if (gear) gear.classList.remove('active');
|
||||
}
|
||||
window.mySeaVisitNvm = function (e) {
|
||||
if (page && page.getAttribute('data-phase') === 'picker') {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
_setDraw(false); // back to the table hex
|
||||
_closeGearMenu();
|
||||
return;
|
||||
}
|
||||
// Table hex → leave to the bud's page (guarded if voice is live).
|
||||
if (window.mySeaGuardedNav) window.mySeaGuardedNav(e, BUD_URL);
|
||||
else window.location.href = BUD_URL;
|
||||
};
|
||||
// GATE VIEW → visitor gate.
|
||||
var gateBtn = document.getElementById('id_my_sea_gate_view_btn');
|
||||
if (gateBtn) {
|
||||
|
||||
@@ -33,11 +33,15 @@
|
||||
{# the `> #id_navbar_gate_view_btn` SCSS pin (top-center in #}
|
||||
{# landscape) matches; inline onclick handles navigation — #}
|
||||
{# no confirm guard since GATE VIEW is non-destructive nav. #}
|
||||
{# On a my_sea_VISIT page (incl. its gate, whose page_class also #}
|
||||
{# carries `page-my-sea-visit`), GATE VIEW must open THIS owner's #}
|
||||
{# visitor gatekeeper — not the viewer's own sea gate. `owner` is #}
|
||||
{# in context on those pages. #}
|
||||
<button
|
||||
id="id_navbar_gate_view_btn"
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
onclick="window.location.href='{% url 'my_sea_gate' %}'"
|
||||
onclick="window.location.href='{% if 'page-my-sea-visit' in page_class %}{% url 'my_sea_visit_gate' owner.id %}{% else %}{% url 'my_sea_gate' %}{% endif %}'"
|
||||
>
|
||||
GATE<br>VIEW
|
||||
</button>
|
||||
|
||||
@@ -159,19 +159,19 @@
|
||||
var portal = null;
|
||||
var _cb = null;
|
||||
var _onDismiss = null;
|
||||
// Live anchor + options for the OPEN guard, so it can RE-position when
|
||||
// the viewport changes (rotate / resize). Without this the portal was
|
||||
// pinned at its show-time coords — after an orientation flip the gear
|
||||
// menu (its anchor) relocates (e.g. bottom→top) and the portal was
|
||||
// stranded off-screen (user-reported 2026-05-30).
|
||||
var _anchor = null;
|
||||
var _opts = null;
|
||||
|
||||
function show(anchor, message, callback, onDismiss, options) {
|
||||
if (!portal) return;
|
||||
options = options || {};
|
||||
_cb = callback;
|
||||
_onDismiss = onDismiss || null;
|
||||
portal.querySelector('.guard-message').innerHTML = message;
|
||||
// Optional override for the YES-button label (e.g., "DEL" for
|
||||
// a destructive-named action). Resets to "OK" inside dismiss/
|
||||
// doConfirm so the next show() starts from the default.
|
||||
portal.querySelector('.guard-yes').textContent = options.yesLabel || 'OK';
|
||||
portal.classList.add('active');
|
||||
var rect = anchor.getBoundingClientRect();
|
||||
function _position() {
|
||||
if (!portal || !_anchor || !portal.classList.contains('active')) return;
|
||||
var rect = _anchor.getBoundingClientRect();
|
||||
// Anchor detached / hidden (e.g. its menu closed) → keep last pos.
|
||||
if (rect.width === 0 && rect.height === 0) return;
|
||||
var pw = portal.offsetWidth;
|
||||
var rawLeft = rect.left + rect.width / 2;
|
||||
var cleft = Math.max(pw / 2 + 8, Math.min(rawLeft, window.innerWidth - pw / 2 - 8));
|
||||
@@ -180,7 +180,7 @@
|
||||
// Default: upper half → below (avoids viewport top edge for navbar/fixed buttons).
|
||||
// invertY: upper half → above (for modal grids where tooltip should fly away from centre).
|
||||
var showBelow = (cardCenterY < window.innerHeight / 2);
|
||||
if (options.invertY) showBelow = !showBelow;
|
||||
if (_opts && _opts.invertY) showBelow = !showBelow;
|
||||
if (showBelow) {
|
||||
portal.style.top = Math.round(rect.bottom) + 'px';
|
||||
portal.style.transform = 'translate(-50%, 0.5rem)';
|
||||
@@ -190,6 +190,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
function show(anchor, message, callback, onDismiss, options) {
|
||||
if (!portal) return;
|
||||
options = options || {};
|
||||
_cb = callback;
|
||||
_onDismiss = onDismiss || null;
|
||||
_anchor = anchor;
|
||||
_opts = options;
|
||||
portal.querySelector('.guard-message').innerHTML = message;
|
||||
// Optional override for the YES-button label (e.g., "DEL" for
|
||||
// a destructive-named action). Resets to "OK" inside dismiss/
|
||||
// doConfirm so the next show() starts from the default.
|
||||
portal.querySelector('.guard-yes').textContent = options.yesLabel || 'OK';
|
||||
portal.classList.add('active');
|
||||
_position();
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (!portal) return;
|
||||
var od = _onDismiss;
|
||||
@@ -197,6 +213,8 @@
|
||||
portal.querySelector('.guard-yes').textContent = 'OK';
|
||||
_cb = null;
|
||||
_onDismiss = null;
|
||||
_anchor = null;
|
||||
_opts = null;
|
||||
if (od) od();
|
||||
}
|
||||
|
||||
@@ -206,6 +224,8 @@
|
||||
portal.querySelector('.guard-yes').textContent = 'OK';
|
||||
_cb = null;
|
||||
_onDismiss = null;
|
||||
_anchor = null;
|
||||
_opts = null;
|
||||
if (cb) cb();
|
||||
}
|
||||
|
||||
@@ -217,6 +237,16 @@
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') dismiss();
|
||||
});
|
||||
// Re-position the open guard when the viewport changes (rotate /
|
||||
// resize) so it keeps following its anchor instead of stranding
|
||||
// at its show-time coords. rAF-throttled to coalesce resize bursts.
|
||||
var _raf = 0;
|
||||
function _onResize() {
|
||||
if (_raf) return;
|
||||
_raf = requestAnimationFrame(function () { _raf = 0; _position(); });
|
||||
}
|
||||
window.addEventListener('resize', _onResize);
|
||||
window.addEventListener('orientationchange', _onResize);
|
||||
// Outside-click to dismiss — capture phase + stopPropagation
|
||||
// prevents the click from cascading to backdrop listeners (e.g. closeFan)
|
||||
document.addEventListener('click', function (e) {
|
||||
|
||||
Reference in New Issue
Block a user