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:
Disco DeDisco
2026-05-30 02:15:23 -04:00
parent 571d5a84ae
commit 7e39740f9c
5 changed files with 144 additions and 22 deletions

View File

@@ -257,6 +257,61 @@ class MySeaVisitEmptyHandLabelsTest(TestCase):
f'data-position="{caption.lower()}">{caption}</span>', html) 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): class MySeaVisitOwnerSeatedTest(TestCase):
"""Phase 2 — the owner shows seated in 1C on the spectator hex whenever """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 she's committed to a draw cycle (drawn OR paid), not only once a card

View File

@@ -31,7 +31,7 @@ window.mySeaGuardedNav = window.mySeaGuardedNav || function (e, url) {
window.location.href = url; window.location.href = url;
}, },
null, // dismiss = stay null, // dismiss = stay
{ yesLabel: 'NVM' } { yesLabel: 'OK' } // confirm = OK (user-spec 2026-05-30)
); );
return; 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 #} {# column in portrait / row in landscape (the menu itself can't be flexed #}
{# since applets.js force-sets display:block on open). #} {# since applets.js force-sets display:block on open). #}
<div class="menu-btns"> <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 #} {# Spectator-only BYE (Phase B) — drops presence (status=LEFT, frees seat #}
{# 2C, kills voice). Rendered below NVM only when the caller passes a #} {# 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. #} {# `leave_url`; the owner's pages never do, so their menu is unchanged. #}

View File

@@ -73,9 +73,11 @@
</div> </div>
{% endif %} {% 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 %} {% 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). #} {# Burger fan — carries the voice sub-btn (active while voice_active). #}
{% include "apps/gameboard/_partials/_burger.html" %} {% include "apps/gameboard/_partials/_burger.html" %}
</div> </div>
@@ -323,14 +325,42 @@
var viewBtn = document.getElementById('id_my_sea_view_draw_btn'); var viewBtn = document.getElementById('id_my_sea_view_draw_btn');
var draw = document.getElementById('id_my_sea_visit_draw'); var draw = document.getElementById('id_my_sea_visit_draw');
var landing = document.querySelector('.my-sea-visit-landing'); 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) { if (viewBtn && draw) {
viewBtn.addEventListener('click', function () { viewBtn.addEventListener('click', function () {
var showing = draw.style.display !== 'none'; _setDraw(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');
}); });
} }
// 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. // GATE VIEW → visitor gate.
var gateBtn = document.getElementById('id_my_sea_gate_view_btn'); var gateBtn = document.getElementById('id_my_sea_gate_view_btn');
if (gateBtn) { if (gateBtn) {

View File

@@ -33,11 +33,15 @@
{# the `> #id_navbar_gate_view_btn` SCSS pin (top-center in #} {# the `> #id_navbar_gate_view_btn` SCSS pin (top-center in #}
{# landscape) matches; inline onclick handles navigation — #} {# landscape) matches; inline onclick handles navigation — #}
{# no confirm guard since GATE VIEW is non-destructive nav. #} {# 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 <button
id="id_navbar_gate_view_btn" id="id_navbar_gate_view_btn"
class="btn btn-primary" class="btn btn-primary"
type="button" 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 GATE<br>VIEW
</button> </button>

View File

@@ -159,19 +159,19 @@
var portal = null; var portal = null;
var _cb = null; var _cb = null;
var _onDismiss = 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) { function _position() {
if (!portal) return; if (!portal || !_anchor || !portal.classList.contains('active')) return;
options = options || {}; var rect = _anchor.getBoundingClientRect();
_cb = callback; // Anchor detached / hidden (e.g. its menu closed) → keep last pos.
_onDismiss = onDismiss || null; if (rect.width === 0 && rect.height === 0) return;
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();
var pw = portal.offsetWidth; var pw = portal.offsetWidth;
var rawLeft = rect.left + rect.width / 2; var rawLeft = rect.left + rect.width / 2;
var cleft = Math.max(pw / 2 + 8, Math.min(rawLeft, window.innerWidth - pw / 2 - 8)); 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). // 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). // invertY: upper half → above (for modal grids where tooltip should fly away from centre).
var showBelow = (cardCenterY < window.innerHeight / 2); var showBelow = (cardCenterY < window.innerHeight / 2);
if (options.invertY) showBelow = !showBelow; if (_opts && _opts.invertY) showBelow = !showBelow;
if (showBelow) { if (showBelow) {
portal.style.top = Math.round(rect.bottom) + 'px'; portal.style.top = Math.round(rect.bottom) + 'px';
portal.style.transform = 'translate(-50%, 0.5rem)'; 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() { function dismiss() {
if (!portal) return; if (!portal) return;
var od = _onDismiss; var od = _onDismiss;
@@ -197,6 +213,8 @@
portal.querySelector('.guard-yes').textContent = 'OK'; portal.querySelector('.guard-yes').textContent = 'OK';
_cb = null; _cb = null;
_onDismiss = null; _onDismiss = null;
_anchor = null;
_opts = null;
if (od) od(); if (od) od();
} }
@@ -206,6 +224,8 @@
portal.querySelector('.guard-yes').textContent = 'OK'; portal.querySelector('.guard-yes').textContent = 'OK';
_cb = null; _cb = null;
_onDismiss = null; _onDismiss = null;
_anchor = null;
_opts = null;
if (cb) cb(); if (cb) cb();
} }
@@ -217,6 +237,16 @@
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') dismiss(); 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 // Outside-click to dismiss — capture phase + stopPropagation
// prevents the click from cascading to backdrop listeners (e.g. closeFan) // prevents the click from cascading to backdrop listeners (e.g. closeFan)
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {