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

@@ -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)