position-circle tooltips: adversarial-review fixes — drop email leak, hide-on-hover-transition, surface #tokens, room_gate tooltip-only, N+1 hoist + specificity hardening — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

Follow-up to the position-circle tooltips sprint, addressing confirmed findings from a multi-agent adversarial review of the diff:

- Email leak (privacy): the hidden .slot-gamer span rendered the raw login email into DOM source on every filled circle — widened to room_gate this sprint. Now renders {{ gamer|at_handle }}; new IT asserts no occupant email anywhere in the page source.
- Stale hover state (position-tooltip.js): moving circle→circle accumulated .tt-pos-* classes on the portal (prior set never stripped), and circle→empty left the prior tooltip stranded. Now _hide() before _show() on every transition.
- Dead #tokens plumbing: data-tt-tokens was computed + rendered but never displayed. Surfaced as a .tt-tokens line in the portal.
- room_gate gather forms: the merged _gate_context let a CARTE owner drop/release gate slots from the renewal gate-view. Zeroed carte_next/nvm/is_last_slot so it's tooltip-only; new IT asserts no drop/release forms.
- N+1: hoisted the per-CARTE-slot token lookup into one carte_claims map; added select_related(significator) on seats + select_related(gamer) on gate_slots.
- SIG_SELECT seat override now gated on an EXPLICIT ?seat (no-param falls back to the canonical PC seat, not the lowest gate slot, so every SIG_SELECT surface agrees).
- Dropped dead is_self/is_bud dict keys (kept the locals + is_me_also).
- room-gate pointer-events override doubled to .room-gate-page.room-gate-page → (0,4,1), no longer a source-order tie with the (0,3,1) suppressor.

Tests: 11 position-tooltip FTs green (no skips); +2 ITs (no-email-in-source, room_gate-tooltip-only); full suite 1604 green. Deferred (noted in memory): in-UI seat switcher during SIG_SELECT, NVM-between-seats 409, gate_status/sea_partial enrichment split.

[[project-position-circle-tooltips]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-01 14:10:00 -04:00
parent 58280c63f5
commit 5a39746853
7 changed files with 61 additions and 15 deletions

View File

@@ -84,6 +84,8 @@ var PositionTooltip = (function () {
_set(".tt-title", title);
_set(".tt-description", circle.dataset.ttDescription);
_set(".tt-shoptalk", circle.dataset.ttShoptalk);
var tokens = circle.dataset.ttTokens;
_set(".tt-tokens", tokens ? tokens + (tokens === "1" ? " token" : " tokens") : "");
_set(".tt-expiry", _fmtExpiry(circle.dataset.ttExpiry));
var sign = _portal.querySelector(".tt-sign");
@@ -112,6 +114,11 @@ var PositionTooltip = (function () {
circle._posTooltipBound = true;
circle.addEventListener("mouseenter", function () {
if (_activeTrig === circle) return;
// Clear any prior tooltip BEFORE showing the next — otherwise
// an A→B move accumulates B's .tt-pos-* classes onto the portal
// (A's never get stripped), and an A→empty move leaves A's
// tooltip stranded (since _show early-returns for empty slots).
_hide();
_show(circle);
});
});

View File

@@ -702,6 +702,14 @@ class PositionTooltipRenderTest(TestCase):
slot2 = _circle_tag(self._gate_content(), 2)
self.assertIn(f'data-tt-sign-rank="{sig.corner_rank}"', slot2)
def test_no_occupant_email_anywhere_in_page_source(self):
# The position circles render @handle (at_handle), never the raw login
# email — not in the tooltip payload AND not in the hidden .slot-gamer
# span. Assert on the FULL response, not just the circle's opening tag.
content = self._gate_content()
self.assertNotIn(self.gamers[1].email, content) # g2@test.io
self.assertNotIn(self.viewer.email, content) # disco@test.io
class PositionTooltipCarteRenderTest(TestCase):
"""CARTE-solo render contract: a single gamer owns all six slots — their
@@ -762,6 +770,16 @@ class PositionTooltipCarteRenderTest(TestCase):
self.assertIn('data-active-slot="1"', content)
self.assertIn('data-state="eligible"', content)
def test_room_gate_renders_circles_but_no_carte_action_forms(self):
# The renewal gate-view shows the circles (for their hover tooltips)
# but is NOT a gather surface — no CARTE drop/release forms.
gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
content = self.client.get(gate_url).content.decode()
self.assertIn("position-strip", content)
self.assertEqual(content.count('class="gate-slot'), 6)
self.assertNotIn("slot-release-btn", content)
self.assertNotIn("drop-token-btn", content)
class PickRolesViewTest(TestCase):
def setUp(self):

View File

@@ -215,7 +215,10 @@ def _gate_positions(room, user=None, current_slot=None):
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
# of which role each gamer chose — so use count, not role matching.
assigned_count = room.table_seats.exclude(role__isnull=True).count()
seats_by_slot = {s.slot_number: s for s in room.table_seats.all()}
seats_by_slot = {
s.slot_number: s
for s in room.table_seats.select_related("significator").all()
}
authed = getattr(user, "is_authenticated", False)
bud_ids = set(user.buds.values_list("id", flat=True)) if authed else set()
shoptalk_map = {}
@@ -225,8 +228,15 @@ def _gate_positions(room, user=None, current_slot=None):
bn.bud_id: bn.shoptalk
for bn in BudshipNote.objects.filter(user=user)
}
# One CARTE-token-per-gamer map, hoisted out of the loop — a CARTE gamer
# owns ONE token (carrying slots_claimed) but claims many slots, so the
# per-slot lookup was up to 6 identical queries.
carte_claims = {
t.user_id: t.slots_claimed
for t in Token.objects.filter(token_type=Token.CARTE, current_room=room)
}
positions = []
for slot in room.gate_slots.order_by("slot_number"):
for slot in room.gate_slots.select_related("gamer").order_by("slot_number"):
gamer = slot.gamer
seat = seats_by_slot.get(slot.slot_number)
is_self = authed and gamer is not None and gamer.id == user.id
@@ -244,20 +254,16 @@ def _gate_positions(room, user=None, current_slot=None):
else:
state_class = "tt-pos-gamer"
# Deposited-token count — CARTE claims many slots on one token.
tokens = 1
if gamer is not None and slot.debited_token_type == Token.CARTE:
carte = gamer.tokens.filter(
token_type=Token.CARTE, current_room=room
).first()
tokens = carte.slots_claimed if carte else 1
tokens = carte_claims.get(gamer.id, 1)
else:
tokens = 1
sig = seat.significator if seat else None
positions.append({
"slot": slot,
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
"role_assigned": slot.slot_number <= assigned_count,
"state_class": state_class,
"is_self": is_self,
"is_bud": is_bud,
"is_me_also": is_me_also,
"shoptalk": shoptalk_map.get(gamer.id, "") if is_bud else "",
"tokens": tokens,
@@ -514,9 +520,11 @@ def _role_select_context(room, user, seat_param=None):
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
# CARTE seat-switch (?seat=N): a multi-seat owner picks a sig per seat,
# so the overlay must reflect the SELECTED seat (its role/polarity/deck),
# not the canonical PC seat. `current_slot` is the ?seat-resolved owned
# slot (falls back to canonical for a one-seat gamer, who never switches).
if user.is_authenticated and current_slot is not None:
# not the canonical PC seat. Gate on an EXPLICIT ?seat — with no param,
# `current_slot` is merely the lowest owned GATE slot, which need not be
# the canonical PC seat (roles aren't slot-ordered); keep the canonical
# seat in that case so every SIG_SELECT surface (incl. my_tray_sig) agrees.
if seat_param and user.is_authenticated and current_slot is not None:
_seat_override = room.table_seats.filter(
gamer=user, slot_number=current_slot
).first()
@@ -665,6 +673,13 @@ def room_gate(request, room_id):
"cost_current": user_slot.cost_current if user_slot else True,
"deposited_count": room.gate_slots.filter(status=GateSlot.FILLED).count(),
"page_class": "page-gameboard page-room page-room-gate",
# The renewal gate-view shows the circles for their rich hover tooltips
# ONLY — it is not a gather surface. Zero the CARTE drop/release form
# triggers so _table_positions renders no OK/NVM/PICK ROLES buttons here
# (renewal happens via the modal's own token rails).
"carte_next_slot_number": None,
"carte_nvm_slot_number": None,
"is_last_slot": False,
})
return render(request, "apps/gameboard/room_gate.html", ctx)

View File

@@ -228,8 +228,11 @@ body.page-gameboard {
.tt-title,
.tt-description,
.tt-shoptalk,
.tt-tokens,
.tt-expiry { display: block; }
.tt-tokens { font-size: 0.75rem; opacity: 0.65; }
// Seat significator stack — pinned top-right, modeled on the tray sig
// card (.fan-corner-rank + fa suit icon) and the .tt-price corner pin.
.tt-sign {

View File

@@ -361,8 +361,10 @@ html:has(.gate-backdrop) .position-strip .gate-slot form,
html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: auto; }
// The room-gate renewal modal renders its OWN .gate-backdrop, but its
// position circles are hover-only (tooltips) and must stay live — re-enable
// them (equal specificity to the suppressor above, wins on source order).
html .room-gate-page .position-strip .gate-slot { pointer-events: auto; }
// them. The doubled `.room-gate-page` makes this (0,4,1) so it UNAMBIGUOUSLY
// out-specifies the (0,3,1) suppressor above — not a fragile source-order tie
// that a future SCSS reorder could silently flip. [[feedback-scss-import-order-specificity]]
html .room-gate-page.room-gate-page .position-strip .gate-slot { pointer-events: auto; }
.position-strip {
position: absolute;

View File

@@ -10,5 +10,6 @@
<span class="tt-title"></span>
<span class="tt-description"></span>
<span class="tt-shoptalk"></span>
<span class="tt-tokens"></span>
<span class="tt-expiry"></span>
</div>

View File

@@ -11,7 +11,7 @@
<div class="gate-slot{% if pos.slot.status == 'EMPTY' %} empty{% elif pos.slot.status == 'FILLED' %} filled{% elif pos.slot.status == 'RESERVED' %} reserved{% endif %}{% if pos.role_assigned %} role-assigned{% endif %}{% if pos.state_class %} {{ pos.state_class }}{% endif %}"
data-slot="{{ pos.slot.slot_number }}"{% if pos.slot.gamer %} data-user-id="{{ pos.slot.gamer.id }}" data-tt-title="{{ pos.slot.gamer|at_handle }}" data-tt-description="{{ pos.slot.gamer.active_title_display }}" data-tt-shoptalk="{{ pos.shoptalk }}" data-tt-tokens="{{ pos.tokens }}"{% if pos.expiry %} data-tt-expiry="{{ pos.expiry|date:'c' }}"{% endif %}{% if pos.sign_rank %} data-tt-sign-rank="{{ pos.sign_rank }}" data-tt-sign-suit="{{ pos.sign_suit_icon }}"{% endif %}{% endif %}>
<span class="slot-number">{{ pos.slot.slot_number }}</span>
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %}</span>
<span class="slot-gamer">{% if pos.slot.gamer %}{{ pos.slot.gamer|at_handle }}{% else %}empty{% endif %}</span>
{% if pos.is_me_also %}
{# CARTE: the viewer's own seat they aren't currently acting #}
{# as — a switch href loads that seat's view (?seat=N). #}