diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py index 8818ed1..175c622 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_visit.py +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -257,6 +257,61 @@ class MySeaVisitEmptyHandLabelsTest(TestCase): f'data-position="{caption.lower()}">{caption}', 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 diff --git a/src/templates/apps/gameboard/_partials/_my_sea_gear.html b/src/templates/apps/gameboard/_partials/_my_sea_gear.html index 4111288..d883977 100644 --- a/src/templates/apps/gameboard/_partials/_my_sea_gear.html +++ b/src/templates/apps/gameboard/_partials/_my_sea_gear.html @@ -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). #}
{% 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" %} @@ -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) { diff --git a/src/templates/core/_partials/_navbar.html b/src/templates/core/_partials/_navbar.html index 0331df8..c801f5b 100644 --- a/src/templates/core/_partials/_navbar.html +++ b/src/templates/core/_partials/_navbar.html @@ -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. #} diff --git a/src/templates/core/base.html b/src/templates/core/base.html index c6787f3..5366adc 100644 --- a/src/templates/core/base.html +++ b/src/templates/core/base.html @@ -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) {