bud page sea-btn cascade: live-invite window + accept-on-GET + glow handoff; my-buds tooltip clamp + row hover/lock — TDD

Fixes the Bill Bud invite cascade so the sea sub-btn actually lights + leads
to the bud's my_sea, and gives the My Buds row tooltip viewport clamping +
hover/lock styling.

SeaInvite.invitee_access_open (gameboard/models.py): new invitee-facing
access window — a non-terminal invite (PENDING/ACCEPTED) within 24h of being
proffered OR within 24h of the invitee's last gate token deposit. Re-arms on
each deposit; DECLINED/LEFT/EXPIRED stay shut. Distinct from is_expired
(which only models the PENDING lapse). 8 UTs.

bud_page (billboard/views.py): sea_btn_active / sea_first_draw_pending now key
on invitee_access_open across PENDING + ACCEPTED, not PENDING-only. Old design
darkened the btn the instant the user accepted, so they could never reach the
bud's sea from here post-accept — that was the red .fa-ban the user saw. ITs
updated: accepted-within-window now lights; added stale-accepted-dark +
recent-deposit-relights cases.

my_sea_visit (gameboard/views.py): accept-on-GET — a still-pending, non-expired
invite from the owner to the visitor is accepted implicitly on arrival (the
sea-btn cascade + @mailman post-attribution anchor both land here, so the click
IS the acceptance). Previously PENDING → 403, so the cascade dead-ended. ITs:
pending-invitee now auto-accepts (200); expired-pending still 403s; stranger
still 403s.

bud.html: burger → sea_btn glow-handoff machine (the my_sea.html cascade minus
the spread-modal stage) so the glow rides the affordance chain to the click
target; active sea click clears glow, preserves .active, navigates.

my-buds-tooltip.js: clamp the position:fixed #id_tooltip_portal to the viewport
on row-lock — same 1rem-inset shape as game-kit.js / sky-wheel.js / wallet.js
(measure after .active, clamp left, prefer above / flip below). Reset on clear.

_billboard.scss: .bud-entry hover + .row-locked highlight (rows aren't
.row-3col so the existing rule missed them) — fill --secUser, flip the
--terUser handle to --quiUser, trailing title to readable --priUser.

520 billboard+gameboard ITs/UTs green; affected sea-btn-cascade FT green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-29 11:36:25 -04:00
parent d87f26003b
commit f5ee83be0a
9 changed files with 268 additions and 25 deletions

View File

@@ -28,7 +28,40 @@
_lockedRow.classList.remove('row-locked');
_lockedRow = null;
}
if (_portal) _portal.classList.remove('active');
if (_portal) {
_portal.classList.remove('active');
// Reset positional props so the next show measures fresh.
_portal.style.top = '';
_portal.style.bottom = '';
_portal.style.left = '';
}
}
// Clamp the position:fixed portal to the viewport — same 1rem-inset
// shape as game-kit.js / sky-wheel.js / wallet.js. Called AFTER .active
// makes the portal display:block so offsetWidth/Height are real: clamp
// the left edge into [rem, viewport-ttW-rem], then prefer ABOVE the row
// (flip BELOW when the tooltip is too tall to fit above).
function _positionPortal(row) {
if (!_portal) return;
var rect = row.getBoundingClientRect();
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
var ttW = _portal.offsetWidth;
var ttH = _portal.offsetHeight;
var minLeft = rem;
var maxLeft = window.innerWidth - ttW - rem;
var clampedLeft = Math.max(minLeft, Math.min(rect.left, maxLeft));
_portal.style.left = clampedLeft + 'px';
var spaceAbove = rect.top - rem;
if (ttH <= spaceAbove) {
_portal.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
_portal.style.top = '';
} else {
_portal.style.top = (rect.bottom + 8) + 'px';
_portal.style.bottom = '';
}
}
function _findSlot(name) {
@@ -75,7 +108,10 @@
row.classList.add('row-locked');
_lockedRow = row;
_populatePortal(row);
if (_portal) _portal.classList.add('active');
if (_portal) {
_portal.classList.add('active');
_positionPortal(row);
}
}
return;
}

View File

@@ -1251,10 +1251,13 @@ class BudPageAutoAddOnFirstVisitTest(TestCase):
class BudPagePendingInviteCascadeTest(TestCase):
"""`sea_btn_active` + `sea_first_draw_pending` fire iff a non-expired
PENDING SeaInvite exists from this bud (owner) to the viewer (invitee).
Reuses the same template flags `_burger.html` already reads on my_sea
+ room — no new template plumbing on bud.html."""
"""`sea_btn_active` + `sea_first_draw_pending` fire iff a *live* SeaInvite
exists from this bud (owner) to the viewer (invitee) — non-terminal
(PENDING or ACCEPTED) AND inside its 24h-from-proffer window OR within 24h
of the viewer's last gate token deposit (user-spec 2026-05-29, via
`SeaInvite.invitee_access_open`). Reuses the same template flags
`_burger.html` already reads on my_sea + room — no new template plumbing
on bud.html."""
def setUp(self):
from apps.gameboard.models import SeaInvite
@@ -1286,7 +1289,10 @@ class BudPagePendingInviteCascadeTest(TestCase):
self.assertTrue(response.context["sea_btn_active"])
self.assertTrue(response.context["sea_first_draw_pending"])
def test_accepted_invite_does_not_cascade(self):
def test_accepted_invite_within_window_lights_cascade(self):
# New spec: an ACCEPTED invite still inside its 24h window keeps the
# cascade lit (the old design went dark the instant it accepted, so
# the user could never reach the bud's sea from here post-accept).
self.SeaInvite.objects.create(
owner=self.alice,
invitee=self.user,
@@ -1296,8 +1302,9 @@ class BudPagePendingInviteCascadeTest(TestCase):
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertIsNone(response.context["pending_invite"])
self.assertFalse(response.context["sea_btn_active"])
self.assertIsNotNone(response.context["pending_invite"])
self.assertTrue(response.context["sea_btn_active"])
self.assertTrue(response.context["sea_first_draw_pending"])
def test_expired_pending_invite_does_not_cascade(self):
inv = self.SeaInvite.objects.create(
@@ -1315,6 +1322,41 @@ class BudPagePendingInviteCascadeTest(TestCase):
self.assertIsNone(response.context["pending_invite"])
self.assertFalse(response.context["sea_btn_active"])
def test_stale_accepted_without_deposit_does_not_cascade(self):
inv = self.SeaInvite.objects.create(
owner=self.alice,
invitee=self.user,
invitee_email=self.user.email,
status=self.SeaInvite.ACCEPTED,
)
self.SeaInvite.objects.filter(pk=inv.pk).update(
created_at=timezone.now() - timezone.timedelta(hours=48),
)
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertIsNone(response.context["pending_invite"])
self.assertFalse(response.context["sea_btn_active"])
def test_recent_deposit_relights_cascade_past_invite_window(self):
# Invite proffered 3 days ago but a gate token deposit 5h ago re-arms
# the 24h window — the "OR 24h since last token deposit" clause.
inv = self.SeaInvite.objects.create(
owner=self.alice,
invitee=self.user,
invitee_email=self.user.email,
status=self.SeaInvite.ACCEPTED,
token_deposited_at=timezone.now() - timezone.timedelta(hours=5),
)
self.SeaInvite.objects.filter(pk=inv.pk).update(
created_at=timezone.now() - timezone.timedelta(hours=72),
)
response = self.client.get(
reverse("billboard:bud_page", args=[self.alice.id])
)
self.assertIsNotNone(response.context["pending_invite"])
self.assertTrue(response.context["sea_btn_active"])
def test_invite_for_other_invitee_ignored(self):
# Pending invite from alice → some other user is irrelevant to ME.
other = User.objects.create(email="other@inv.io", username="other")

View File

@@ -603,29 +603,37 @@ def bud_page(request, bud_id):
Cascade context (`sea_btn_active` + `sea_first_draw_pending`) reuses
the same template variables `_burger.html` already reads on my_sea +
room — server-side conditional renders `glow-handoff` on the burger
+ `.active` on the sea sub-btn. The flags fire iff a non-expired
PENDING SeaInvite exists from this bud to the viewer."""
+ `.active` on the sea sub-btn. The flags fire iff a *live* SeaInvite
exists from this bud to the viewer — non-terminal (PENDING or ACCEPTED)
AND inside its 24h-from-proffer window OR within 24h of the viewer's
last gate token deposit (user-spec 2026-05-29, `invitee_access_open`).
Accepting the invite no longer darkens the btn; the cascade now stays
lit across the whole window so the user can reach the bud's sea
(`my_sea_visit` accepts a still-pending invite on GET)."""
from django.shortcuts import get_object_or_404
from apps.gameboard.models import SeaInvite
bud = get_object_or_404(User, id=bud_id)
if bud != request.user and not request.user.buds.filter(id=bud.id).exists():
request.user.buds.add(bud)
bn = BudshipNote.objects.filter(user=request.user, bud=bud).first()
pending = (
live = (
SeaInvite.objects
.filter(owner=bud, invitee=request.user, status=SeaInvite.PENDING)
.filter(
owner=bud, invitee=request.user,
status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED],
)
.order_by("-created_at")
.first()
)
if pending is not None and pending.is_expired:
pending = None
if live is not None and not live.invitee_access_open:
live = None
return render(request, "apps/billboard/bud.html", {
"bud": bud,
"shoptalk_text": bn.shoptalk if bn else "",
"milestone_dt": bn.edited_at if bn else None,
"pending_invite": pending,
"sea_btn_active": pending is not None,
"sea_first_draw_pending": pending is not None,
"pending_invite": live,
"sea_btn_active": live is not None,
"sea_first_draw_pending": live is not None,
"page_class": "page-billbud",
})

View File

@@ -419,3 +419,26 @@ class SeaInvite(models.Model):
and self.token_deposited_at is not None
and self.left_at is None
)
@property
def invitee_access_open(self):
"""True iff the invitee's bud-page sea sub-btn should be lit AND the
owner's spectator my-sea is reachable — a non-terminal invite within
24h of being proffered OR within 24h of the invitee's last token
deposit at the owner's visitor gate (user-spec 2026-05-29).
The deposit clause re-arms the window: as long as the invitee keeps
depositing at the table they keep the door open, independent of the
original 24h invite window. DECLINED / LEFT / EXPIRED are terminal —
the door stays shut. Distinct from `is_expired` (which only models the
PENDING lapse): this is the invitee-facing access window and spans
both PENDING + ACCEPTED."""
if self.status not in (self.PENDING, self.ACCEPTED):
return False
now = timezone.now()
if now < self.expires_at:
return True
if self.token_deposited_at is not None:
window = timezone.timedelta(hours=SEA_INVITE_EXPIRE_HOURS)
return now < self.token_deposited_at + window
return False

View File

@@ -43,10 +43,29 @@ class MySeaVisitGuardTest(TestCase):
self.client.force_login(stranger)
self.assertEqual(self.client.get(self.url).status_code, 403)
def test_pending_invitee_is_forbidden(self):
def test_pending_invitee_is_auto_accepted_on_visit(self):
# Accept-on-GET (user-spec 2026-05-29): following the bud-page sea-btn
# cascade — or the @mailman post-attribution anchor — to the spectator
# table implicitly accepts a still-pending invite, so the page renders
# instead of 403ing the legitimately-invited user.
self.invite.status = SeaInvite.PENDING
self.invite.accepted_at = None
self.invite.save()
self.client.force_login(self.bud)
self.assertEqual(self.client.get(self.url).status_code, 200)
self.invite.refresh_from_db()
self.assertEqual(self.invite.status, SeaInvite.ACCEPTED)
self.assertIsNotNone(self.invite.accepted_at)
def test_expired_pending_invitee_is_forbidden(self):
# A lapsed PENDING invite (no deposit, past 24h) is NOT auto-accepted.
self.invite.status = SeaInvite.PENDING
self.invite.accepted_at = None
self.invite.save()
SeaInvite.objects.filter(pk=self.invite.pk).update(
created_at=timezone.now() - timedelta(hours=48),
)
self.client.force_login(self.bud)
self.assertEqual(self.client.get(self.url).status_code, 403)
def test_left_invitee_is_forbidden(self):

View File

@@ -97,3 +97,68 @@ class SeaInviteStatusMachineTest(SimpleTestCase):
left_at=timezone.now(),
)
self.assertFalse(inv.is_present)
# ── invitee_access_open ──────────────────────────────────────────────
# The invitee-facing access window: lights the bud-page sea sub-btn AND
# keeps the owner's spectator my-sea reachable. Open for a non-terminal
# invite within 24h of being proffered OR within 24h of the invitee's
# last gate token deposit (user-spec 2026-05-29).
def test_access_open_for_fresh_pending(self):
inv = self._invite(created_at=timezone.now() - timedelta(hours=1))
self.assertTrue(inv.invitee_access_open)
def test_access_open_for_accepted_within_invite_window(self):
# The key fix: an ACCEPTED invite still inside its 24h window keeps
# the sea btn lit (the old design went dark the moment it accepted).
inv = self._invite(
status=SeaInvite.ACCEPTED,
created_at=timezone.now() - timedelta(hours=5),
)
self.assertTrue(inv.invitee_access_open)
def test_access_closed_for_stale_pending_without_deposit(self):
inv = self._invite(created_at=timezone.now() - timedelta(hours=25))
self.assertFalse(inv.invitee_access_open)
def test_access_closed_for_stale_accepted_without_deposit(self):
inv = self._invite(
status=SeaInvite.ACCEPTED,
created_at=timezone.now() - timedelta(hours=25),
)
self.assertFalse(inv.invitee_access_open)
def test_access_open_within_24h_of_last_deposit_past_invite_window(self):
# Invite proffered 3 days ago, but the invitee deposited a token at
# the owner's gate 5h ago — the deposit clause re-arms the window.
inv = self._invite(
status=SeaInvite.ACCEPTED,
created_at=timezone.now() - timedelta(hours=72),
token_deposited_at=timezone.now() - timedelta(hours=5),
)
self.assertTrue(inv.invitee_access_open)
def test_access_closed_when_both_windows_lapsed(self):
inv = self._invite(
status=SeaInvite.ACCEPTED,
created_at=timezone.now() - timedelta(hours=72),
token_deposited_at=timezone.now() - timedelta(hours=25),
)
self.assertFalse(inv.invitee_access_open)
def test_access_closed_for_declined(self):
inv = self._invite(
status=SeaInvite.DECLINED,
created_at=timezone.now() - timedelta(hours=1),
)
self.assertFalse(inv.invitee_access_open)
def test_access_closed_for_left_even_with_recent_deposit(self):
# A BYE is a deliberate exit — the door stays shut even though the
# token deposit is recent.
inv = self._invite(
status=SeaInvite.LEFT,
created_at=timezone.now() - timedelta(hours=1),
token_deposited_at=timezone.now() - timedelta(hours=1),
left_at=timezone.now(),
)
self.assertFalse(inv.invitee_access_open)

View File

@@ -896,11 +896,26 @@ def my_sea_visit(request, owner_id):
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."""
hand — `sea_btn_active` forced False.
Accept-on-GET (user-spec 2026-05-29): a still-pending, non-expired invite
from `owner` to this user is accepted implicitly on arrival — the bud-page
sea-btn cascade (+ the @mailman post-attribution anchor) both land here,
and the click IS the acceptance. A stranger with no invite still 403s."""
from apps.lyric.models import User
from .models import SeaInvite
owner = get_object_or_404(User, id=owner_id)
if owner == request.user:
return redirect("my_sea")
pending = (
SeaInvite.objects
.filter(owner=owner, invitee=request.user, status=SeaInvite.PENDING)
.order_by("-created_at").first()
)
if pending is not None and not pending.is_expired:
pending.status = SeaInvite.ACCEPTED
pending.accepted_at = timezone.now()
pending.save(update_fields=["status", "accepted_at"])
invite = _accepted_visit_invite(owner, request.user)
if invite is None:
return HttpResponseForbidden()

View File

@@ -110,6 +110,29 @@
}
}
// My Buds rows are `.bud-entry` (`@<handle> the <Title>`), NOT `.row-3col`,
// so the row-3col hover/lock highlight above doesn't reach them. Mirror it
// here: hover (mouse) + `.row-locked` (click-lock set by my-buds-tooltip.js)
// fill the row --secUser and flip the --terUser handle to --quiUser, with the
// trailing title brought to the readable --priUser back-of-card colour. The
// (0,3,1) handle rule out-specifies the generic `.applet-list-entry a:hover`
// glow above, so the lit row reads --quiUser even under the cursor.
.applet-list-entry.bud-entry {
border-radius: 0.25rem;
transition: background-color 0.12s ease, color 0.12s ease;
&:hover,
&.row-locked {
background-color: rgba(var(--secUser), 1);
.bud-name a {
color: rgba(var(--quiUser), 1);
text-shadow: none;
}
.bud-row-title { color: rgba(var(--priUser), 1); }
}
}
.applet-list-buffer {
flex-shrink: 0;
height: 0.5rem;

View File

@@ -69,14 +69,26 @@
{% if pending_invite %}
<script>
(function () {
var burger = document.getElementById('id_burger_btn');
var sea = document.getElementById('id_sea_btn');
if (!sea) return;
// Active sub-btn navigation handler — fires BEFORE burger-btn.js's
// delegated fan handler (target-phase before bubble-up) so the click
// routes to the bud's spectator my-sea page. Burger then closes the
// fan as usual.
if (!burger || !sea) return;
// Glow-handoff machine — the bud-page counterpart of my_sea.html's
// burger → sea_btn cascade, minus the spread-modal stage (the active
// sea sub-btn navigates away instead of opening a modal). The burger
// carries .glow-handoff server-side (sea_first_draw_pending); opening
// it hands the glow off to the sea sub-btn so the nudge rides the
// affordance chain to the click target.
burger.addEventListener('click', function () {
if (!burger.classList.contains('glow-handoff')) return;
burger.classList.remove('glow-handoff');
sea.classList.add('glow-handoff');
});
// Active sea sub-btn click → the bud's spectator my-sea (which accepts
// the invite on GET). Clears the glow but PRESERVES .active. Fires in
// the target phase, BEFORE burger-btn.js's delegated fan-close.
sea.addEventListener('click', function () {
if (!sea.classList.contains('active')) return;
sea.classList.remove('glow-handoff');
window.location.href = '/gameboard/my-sea/visit/{{ bud.id }}/';
});
}());