position circles: rich .tt-pos-* hover tooltips on the gate-view + table circles — @handle/title/seat-sig/shoptalk/#tokens/expiry portal + CARTE me-also ?seat switch href — TDD
Workstream A of the position-circle tooltips sprint (green; B/C ride @skip-ped).
The numbered gate-position circles (1-6) gain rich hover tooltips mirroring the My Buds bud tooltip on every surface — and now render on room_gate.html (the GATE VIEW), which showed no circles before (the headline gap).
- _gate_positions(room, user, current_slot): per-circle .tt-pos-* state class (empty / gamer / gamer+bud / me-current / me-also) + data-tt-* payload (@handle via at_handle NOT email, title, seat significator rank/suit, bud shoptalk, deposited #tokens [CARTE slots_claimed else 1], seat-clock cost_current_until expiry). _viewer_current_slot resolves the viewer's acting seat (?seat override or canonical) to split me-current vs me-also.
- room_gate view merges _gate_context so _table_positions renders there; room_view threads ?seat into _role_select_context.
- _table_positions.html: .tt-pos-* appended AFTER role-assigned (keeps the 'gate-slot filled role-assigned' substring + class-before-data-slot regex intact for RoleSelectRenderingTest), data-tt-* attrs, me-also ?seat switch anchor.
- #id_position_tooltip_portal (page-root, position:fixed) + position-tooltip.js (hover/clamp/union-hide modeled on tray-tooltip.js); .tt-sign rank+suit stack; .tt-pos-* circle accents; room-gate pointer-events re-enable.
Tests: 7 PositionTooltipTest + 2 CarteSeatSwitchTest (tokens, me-also href) FTs green; 8 fast render-level ITs (PositionTooltip{,Carte}RenderTest); full suite 1598 green.
[[project-position-circle-tooltips]]
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
160
src/apps/epic/static/apps/epic/position-tooltip.js
Normal file
160
src/apps/epic/static/apps/epic/position-tooltip.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// ── position-tooltip.js ───────────────────────────────────────────────────────
|
||||
//
|
||||
// Hover-triggered rich tooltips on the gate-position circles (1–6), portalled
|
||||
// to #id_position_tooltip_portal (page-root, position:fixed) so they escape
|
||||
// the tray's overflow / any mask-image clip. Mirrors tray-tooltip.js's
|
||||
// hover + viewport-clamp + document-mousemove union-hide shape, but reads its
|
||||
// content from each circle's data-tt-* attrs (filled server-side) rather than
|
||||
// a sibling .tt.
|
||||
//
|
||||
// On .gate-slot mouseenter:
|
||||
// • skip empty circles (no data-tt-title)
|
||||
// • copy the circle's .tt-pos-* state class onto the portal (themes per
|
||||
// occupant kind: empty / gamer / bud / me-current / me-also)
|
||||
// • fill .tt-title (@handle) / .tt-description (title) / .tt-shoptalk /
|
||||
// .tt-expiry (seat clock) + the top-right .tt-sign stack (seat
|
||||
// significator rank + suit icon; hidden when the seat has no sig yet)
|
||||
//
|
||||
// The @handle/title/etc. are computed server-side (at_handle, NOT email) so
|
||||
// no email leaks into the portal.
|
||||
|
||||
var PositionTooltip = (function () {
|
||||
var _portal = null;
|
||||
var _activeTrig = null;
|
||||
var _posClasses = []; // .tt-pos-* classes copied onto the portal
|
||||
var _onDocMove = null;
|
||||
|
||||
function _inRect(x, y, r) {
|
||||
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
|
||||
}
|
||||
|
||||
function _set(sel, val) {
|
||||
var el = _portal.querySelector(sel);
|
||||
if (el) el.textContent = val || "";
|
||||
}
|
||||
|
||||
function _hide() {
|
||||
if (!_portal) return;
|
||||
_portal.classList.remove("active");
|
||||
if (_posClasses.length) {
|
||||
_portal.classList.remove.apply(_portal.classList, _posClasses);
|
||||
_posClasses = [];
|
||||
}
|
||||
_portal.style.display = "none";
|
||||
_activeTrig = null;
|
||||
}
|
||||
|
||||
function _fmtExpiry(iso) {
|
||||
if (!iso) return "";
|
||||
var d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return "seat held to " + d.toLocaleDateString();
|
||||
}
|
||||
|
||||
// Clamp the fixed portal to the viewport, centred under (or over) the
|
||||
// trigger — same shape as tray-tooltip.js _position. The circles sit at
|
||||
// the top of the page so the tooltip renders BELOW them.
|
||||
function _position(triggerEl) {
|
||||
var rect = triggerEl.getBoundingClientRect();
|
||||
var halfW = _portal.offsetWidth / 2;
|
||||
var rawLeft = rect.left + rect.width / 2;
|
||||
var minLeft = halfW + 8;
|
||||
var maxLeft = window.innerWidth - halfW - 8;
|
||||
_portal.style.left = Math.round(Math.max(minLeft, Math.min(rawLeft, maxLeft))) + "px";
|
||||
|
||||
var cy = rect.top + rect.height / 2;
|
||||
if (cy < window.innerHeight / 2) {
|
||||
_portal.style.top = Math.round(rect.bottom) + "px";
|
||||
_portal.style.transform = "translate(-50%, 0.5rem)";
|
||||
} else {
|
||||
_portal.style.top = Math.round(rect.top) + "px";
|
||||
_portal.style.transform = "translate(-50%, calc(-100% - 0.5rem))";
|
||||
}
|
||||
}
|
||||
|
||||
function _show(circle) {
|
||||
if (!_portal) return;
|
||||
var title = circle.dataset.ttTitle;
|
||||
if (!title) return; // empty slot — no occupant tooltip
|
||||
|
||||
var classes = circle.className.match(/tt-pos-[\w-]+/g) || [];
|
||||
_posClasses = classes;
|
||||
if (classes.length) _portal.classList.add.apply(_portal.classList, classes);
|
||||
|
||||
_set(".tt-title", title);
|
||||
_set(".tt-description", circle.dataset.ttDescription);
|
||||
_set(".tt-shoptalk", circle.dataset.ttShoptalk);
|
||||
_set(".tt-expiry", _fmtExpiry(circle.dataset.ttExpiry));
|
||||
|
||||
var sign = _portal.querySelector(".tt-sign");
|
||||
if (sign) {
|
||||
var rank = circle.dataset.ttSignRank;
|
||||
if (rank) {
|
||||
var rankEl = sign.querySelector(".fan-corner-rank");
|
||||
if (rankEl) rankEl.textContent = rank;
|
||||
var icon = sign.querySelector("i");
|
||||
if (icon) icon.className = "fa-solid tt-sign-suit " + (circle.dataset.ttSignSuit || "");
|
||||
sign.style.display = "";
|
||||
} else {
|
||||
sign.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
_portal.classList.add("active");
|
||||
_portal.style.display = "block";
|
||||
_activeTrig = circle;
|
||||
_position(circle);
|
||||
}
|
||||
|
||||
function _bind() {
|
||||
document.querySelectorAll(".position-strip .gate-slot").forEach(function (circle) {
|
||||
if (circle._posTooltipBound) return;
|
||||
circle._posTooltipBound = true;
|
||||
circle.addEventListener("mouseenter", function () {
|
||||
if (_activeTrig === circle) return;
|
||||
_show(circle);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
_portal = document.getElementById("id_position_tooltip_portal");
|
||||
if (!_portal) return;
|
||||
_portal.style.display = "none";
|
||||
_bind();
|
||||
|
||||
_onDocMove = function (e) {
|
||||
if (!_portal.classList.contains("active") || !_activeTrig) return;
|
||||
var rects = [
|
||||
_activeTrig.getBoundingClientRect(),
|
||||
_portal.getBoundingClientRect(),
|
||||
];
|
||||
var union = {
|
||||
left: Math.min.apply(null, rects.map(function (r) { return r.left; })),
|
||||
top: Math.min.apply(null, rects.map(function (r) { return r.top; })),
|
||||
right: Math.max.apply(null, rects.map(function (r) { return r.right; })),
|
||||
bottom: Math.max.apply(null, rects.map(function (r) { return r.bottom; })),
|
||||
};
|
||||
if (!_inRect(e.clientX, e.clientY, union)) _hide();
|
||||
};
|
||||
document.addEventListener("mousemove", _onDocMove);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (_onDocMove) document.removeEventListener("mousemove", _onDocMove);
|
||||
_onDocMove = null;
|
||||
_hide();
|
||||
_portal = null;
|
||||
document.querySelectorAll(".position-strip .gate-slot").forEach(function (c) {
|
||||
delete c._posTooltipBound;
|
||||
});
|
||||
}
|
||||
|
||||
return { init: init, reset: reset };
|
||||
})();
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
PositionTooltip.init();
|
||||
});
|
||||
}
|
||||
@@ -598,6 +598,154 @@ class RoleSelectRenderingTest(TestCase):
|
||||
self.assertNotIn("fa-circle-check", nc_seat_chunk)
|
||||
|
||||
|
||||
def _circle_start(content, slot_number):
|
||||
"""Index of the gate-slot circle's opening `<div` for the given slot.
|
||||
Scopes to `.gate-slot` (room.html also renders `.table-seat` data-slot=N
|
||||
elements first, so a bare data-slot search would hit the seat, not the
|
||||
circle)."""
|
||||
needle = f'data-slot="{slot_number}"'
|
||||
pos = 0
|
||||
while True:
|
||||
idx = content.index('<div class="gate-slot', pos)
|
||||
end = content.index(">", idx)
|
||||
if needle in content[idx:end]:
|
||||
return idx
|
||||
pos = end
|
||||
|
||||
|
||||
def _circle_tag(content, slot_number):
|
||||
"""Return the opening `<div ...>` tag of the gate-slot circle for the
|
||||
given slot — class + every data-tt-* attr live on this one tag."""
|
||||
idx = _circle_start(content, slot_number)
|
||||
return content[idx:content.index(">", idx)]
|
||||
|
||||
|
||||
class PositionTooltipRenderTest(TestCase):
|
||||
"""Render-level coverage for the rich position-circle tooltip payload
|
||||
(sprint 2026-06-02) — the fast IT counterpart to the Selenium
|
||||
PositionTooltipTest in functional_tests/test_game_room_position_tooltips.py.
|
||||
Exercised on the GATE VIEW (room_gate), which rendered no circles before
|
||||
this sprint."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import DeckVariant
|
||||
self.viewer = User.objects.create(email="disco@test.io", username="disco")
|
||||
self.deck, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||||
)
|
||||
self.viewer.equipped_deck = self.deck
|
||||
self.viewer.save(update_fields=["equipped_deck"])
|
||||
self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
|
||||
self.gamers = [self.viewer]
|
||||
for i in range(2, 7):
|
||||
self.gamers.append(
|
||||
User.objects.create(email=f"g{i}@test.io", username=f"g{i}")
|
||||
)
|
||||
for i, gamer in enumerate(self.gamers, start=1):
|
||||
slot = self.room.gate_slots.get(slot_number=i)
|
||||
slot.gamer = gamer
|
||||
slot.status = GateSlot.FILLED
|
||||
slot.filled_at = timezone.now()
|
||||
slot.debited_token_type = Token.FREE
|
||||
slot.save()
|
||||
self.room.gate_status = Room.OPEN
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
self.gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
|
||||
self.client.force_login(self.viewer)
|
||||
|
||||
def _gate_content(self):
|
||||
return self.client.get(self.gate_url).content.decode()
|
||||
|
||||
def test_gate_view_renders_six_position_circles(self):
|
||||
content = self._gate_content()
|
||||
self.assertContains(self.client.get(self.gate_url), "position-strip")
|
||||
self.assertEqual(content.count('class="gate-slot'), 6)
|
||||
|
||||
def test_own_slot_is_me_current_others_are_gamer(self):
|
||||
content = self._gate_content()
|
||||
self.assertIn("tt-pos-me-current", _circle_tag(content, 1))
|
||||
slot2 = _circle_tag(content, 2)
|
||||
self.assertIn("tt-pos-gamer", slot2)
|
||||
self.assertNotIn("tt-pos-me", slot2)
|
||||
|
||||
def test_other_gamer_handle_in_title_not_email(self):
|
||||
slot2 = _circle_tag(self._gate_content(), 2)
|
||||
self.assertIn('data-tt-title="@g2"', slot2)
|
||||
# No email field in the tooltip payload (user-spec).
|
||||
self.assertNotIn("data-tt-email", slot2)
|
||||
self.assertIn("data-tt-description", slot2)
|
||||
|
||||
def test_bud_occupant_carries_bud_class_and_shoptalk(self):
|
||||
from apps.billboard.models import BudshipNote
|
||||
amigo = self.gamers[1]
|
||||
self.viewer.buds.add(amigo)
|
||||
BudshipNote.objects.create(user=self.viewer, bud=amigo, shoptalk="met at the deli")
|
||||
slot2 = _circle_tag(self._gate_content(), 2)
|
||||
self.assertIn("tt-pos-bud", slot2)
|
||||
self.assertIn('data-tt-shoptalk="met at the deli"', slot2)
|
||||
|
||||
def test_deposit_count_and_expiry_present(self):
|
||||
slot2 = _circle_tag(self._gate_content(), 2)
|
||||
self.assertIn('data-tt-tokens="1"', slot2)
|
||||
self.assertIn("data-tt-expiry=", slot2)
|
||||
|
||||
def test_seat_significator_rank_rides_the_circle(self):
|
||||
sig = TarotCard.objects.create(
|
||||
deck_variant=self.deck, slug="queen-of-brands-em",
|
||||
arcana="MIDDLE", suit="BRANDS", number=13, name="Queen of Brands",
|
||||
)
|
||||
TableSeat.objects.create(
|
||||
room=self.room, gamer=self.gamers[1], slot_number=2, significator=sig,
|
||||
)
|
||||
slot2 = _circle_tag(self._gate_content(), 2)
|
||||
self.assertIn(f'data-tt-sign-rank="{sig.corner_rank}"', slot2)
|
||||
|
||||
|
||||
class PositionTooltipCarteRenderTest(TestCase):
|
||||
"""CARTE-solo render contract: a single gamer owns all six slots — their
|
||||
non-current circles read tt-pos-me-also + carry a ?seat=N switch href, and
|
||||
the deposited count reflects the CARTE token's slots_claimed."""
|
||||
|
||||
def setUp(self):
|
||||
from apps.epic.models import DeckVariant
|
||||
self.viewer = User.objects.create(email="disco@test.io", username="disco")
|
||||
self.deck, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
|
||||
)
|
||||
self.viewer.equipped_deck = self.deck
|
||||
self.viewer.save(update_fields=["equipped_deck"])
|
||||
self.room = Room.objects.create(name="Carte Room", owner=self.viewer)
|
||||
self.room.gate_slots.update(
|
||||
gamer=self.viewer, status=GateSlot.FILLED,
|
||||
filled_at=timezone.now(), debited_token_type=Token.CARTE,
|
||||
)
|
||||
Token.objects.create(
|
||||
user=self.viewer, token_type=Token.CARTE,
|
||||
current_room=self.room, slots_claimed=6,
|
||||
)
|
||||
self.room.gate_status = Room.OPEN
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
self.client.force_login(self.viewer)
|
||||
self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||
|
||||
def test_tokens_reflects_carte_slots_claimed(self):
|
||||
content = self.client.get(self.room_url).content.decode()
|
||||
self.assertIn('data-tt-tokens="6"', _circle_tag(content, 1))
|
||||
|
||||
def test_own_other_seat_is_me_also_with_switch_href(self):
|
||||
content = self.client.get(self.room_url).content.decode()
|
||||
slot4 = _circle_tag(content, 4)
|
||||
self.assertIn("tt-pos-me-also", slot4)
|
||||
# The switch anchor lands just after the opening tag.
|
||||
idx = _circle_start(content, 4)
|
||||
chunk = content[idx:idx + 800]
|
||||
self.assertIn("seat=4", chunk)
|
||||
|
||||
|
||||
class PickRolesViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.founder = User.objects.create(email="founder@test.io")
|
||||
|
||||
@@ -178,19 +178,94 @@ _ROLE_SCRAWL_NAMES = {
|
||||
}
|
||||
|
||||
|
||||
def _gate_positions(room):
|
||||
"""Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
|
||||
def _viewer_current_slot(room, user, seat_param=None):
|
||||
"""The slot the viewer is currently "acting as": a ?seat=N override when
|
||||
they own that slot, else their lowest-numbered owned slot (the canonical
|
||||
seat). None for anon / non-seated viewers. Splits the viewer's own
|
||||
position circles into me-current (this slot) vs me-also (their other
|
||||
CARTE-claimed slots)."""
|
||||
if not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
owned = sorted(
|
||||
room.gate_slots.filter(gamer=user).values_list("slot_number", flat=True)
|
||||
)
|
||||
if not owned:
|
||||
return None
|
||||
if seat_param:
|
||||
try:
|
||||
n = int(seat_param)
|
||||
except (TypeError, ValueError):
|
||||
n = None
|
||||
if n in owned:
|
||||
return n
|
||||
return owned[0]
|
||||
|
||||
|
||||
def _gate_positions(room, user=None, current_slot=None):
|
||||
"""Return list of per-circle dicts for _table_positions.html.
|
||||
|
||||
Carries the legacy keys (slot, role_label, role_assigned) PLUS the rich
|
||||
tooltip payload (sprint 2026-06-02): a `.tt-pos-*` state class classifying
|
||||
the occupant relative to `user`, the deposited-token count, the seat-clock
|
||||
expiry, the seat significator rank/suit, and bud shoptalk. `@handle` +
|
||||
title are read off `pos.slot.gamer` in the template (at_handle /
|
||||
active_title_display). `current_slot` is the viewer's acting seat
|
||||
(`_viewer_current_slot`) — it splits the viewer's own circles me-current
|
||||
vs me-also."""
|
||||
# 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()
|
||||
return [
|
||||
{
|
||||
seats_by_slot = {s.slot_number: s for s in room.table_seats.all()}
|
||||
authed = getattr(user, "is_authenticated", False)
|
||||
bud_ids = set(user.buds.values_list("id", flat=True)) if authed else set()
|
||||
shoptalk_map = {}
|
||||
if authed:
|
||||
from apps.billboard.models import BudshipNote
|
||||
shoptalk_map = {
|
||||
bn.bud_id: bn.shoptalk
|
||||
for bn in BudshipNote.objects.filter(user=user)
|
||||
}
|
||||
positions = []
|
||||
for slot in room.gate_slots.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
|
||||
is_bud = (
|
||||
authed and gamer is not None and not is_self
|
||||
and gamer.id in bud_ids
|
||||
)
|
||||
is_me_also = is_self and slot.slot_number != current_slot
|
||||
if gamer is None:
|
||||
state_class = "tt-pos-empty"
|
||||
elif is_self:
|
||||
state_class = "tt-pos-me-also" if is_me_also else "tt-pos-me-current"
|
||||
elif is_bud:
|
||||
state_class = "tt-pos-gamer tt-pos-bud"
|
||||
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
|
||||
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,
|
||||
}
|
||||
for slot in room.gate_slots.order_by("slot_number")
|
||||
]
|
||||
"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,
|
||||
"expiry": slot.cost_current_until,
|
||||
"sign_rank": sig.corner_rank if sig else "",
|
||||
"sign_suit_icon": sig.suit_icon if sig else "",
|
||||
})
|
||||
return positions
|
||||
|
||||
|
||||
def _expire_reserved_slots(room):
|
||||
@@ -250,7 +325,7 @@ def _expire_lapsed_seats(room):
|
||||
room.save(update_fields=["gate_status"])
|
||||
|
||||
|
||||
def _gate_context(room, user):
|
||||
def _gate_context(room, user, seat_param=None):
|
||||
_expire_reserved_slots(room)
|
||||
slots = room.gate_slots.order_by("slot_number")
|
||||
pending_slot = slots.filter(status=GateSlot.RESERVED).first()
|
||||
@@ -306,12 +381,15 @@ def _gate_context(room, user):
|
||||
"carte_slots_claimed": carte_slots_claimed,
|
||||
"carte_nvm_slot_number": carte_nvm_slot_number,
|
||||
"carte_next_slot_number": carte_next_slot_number,
|
||||
"gate_positions": _gate_positions(room),
|
||||
"gate_positions": _gate_positions(
|
||||
room, user, _viewer_current_slot(room, user, seat_param)
|
||||
),
|
||||
"starter_roles": [],
|
||||
}
|
||||
|
||||
|
||||
def _role_select_context(room, user):
|
||||
def _role_select_context(room, user, seat_param=None):
|
||||
current_slot = _viewer_current_slot(room, user, seat_param)
|
||||
user_seat = None
|
||||
active_seat = None
|
||||
unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number")
|
||||
@@ -391,7 +469,7 @@ def _role_select_context(room, user):
|
||||
.values_list("slot_number", flat=True)
|
||||
) if user.is_authenticated else [],
|
||||
"active_slot": active_slot,
|
||||
"gate_positions": _gate_positions(room),
|
||||
"gate_positions": _gate_positions(room, user, current_slot),
|
||||
"slots": room.gate_slots.order_by("slot_number"),
|
||||
}
|
||||
# Viewer's seat token-cost state — drives the center-hex GATE VIEW
|
||||
@@ -511,7 +589,7 @@ def gatekeeper(request, room_id):
|
||||
def room_view(request, room_id):
|
||||
room = Room.objects.get(id=room_id)
|
||||
_expire_lapsed_seats(room)
|
||||
ctx = _role_select_context(room, request.user)
|
||||
ctx = _role_select_context(room, request.user, request.GET.get("seat"))
|
||||
ctx["room"] = room
|
||||
# `page-room` drives the navbar GATE VIEW swap (mirrors my-sea's
|
||||
# `page-my-sea`) so the table page reaches the renewal gate-view instead
|
||||
@@ -541,12 +619,19 @@ def room_gate(request, room_id):
|
||||
user_slot = room.gate_slots.filter(
|
||||
gamer=request.user, status=GateSlot.FILLED
|
||||
).first()
|
||||
return render(request, "apps/gameboard/room_gate.html", {
|
||||
# Merge the gatekeeper gate-context so the position circles
|
||||
# (_table_positions.html) render here too — it supplies gate_positions
|
||||
# (now rich w. the .tt-pos-* tooltip payload) + every carte_* key the
|
||||
# partial's slot conditionals read. The renewal modal's own keys override
|
||||
# below.
|
||||
ctx = _gate_context(room, request.user, request.GET.get("seat"))
|
||||
ctx.update({
|
||||
"room": room,
|
||||
"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",
|
||||
})
|
||||
return render(request, "apps/gameboard/room_gate.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
|
||||
@@ -55,7 +55,6 @@ def _gate_view_url(self, room):
|
||||
return self.live_server_url + f"/gameboard/room/{room.id}/gate/view/"
|
||||
|
||||
|
||||
@skip(_RED)
|
||||
class PositionTooltipTest(FunctionalTest):
|
||||
"""Tooltip CONTENT + state classes on the gate-position circles, exercised
|
||||
on the new GATE VIEW gate-view (the clearest red surface — it renders no
|
||||
@@ -72,6 +71,13 @@ class PositionTooltipTest(FunctionalTest):
|
||||
["disco@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io"],
|
||||
)
|
||||
# Every occupant needs a stable @handle (not email-derived) so the
|
||||
# tooltip title reads "@amigo", never "amigo@test.io" — the spec
|
||||
# forbids leaking the email into the title.
|
||||
for g in self.gamers:
|
||||
if not g.username:
|
||||
g.username = g.email.split("@")[0]
|
||||
g.save(update_fields=["username"])
|
||||
# Stamp filled_at so cost_current_until (the .tt-expiry) is real.
|
||||
self.room.gate_slots.filter(status=GateSlot.FILLED).update(
|
||||
filled_at=timezone.now(), debited_token_type=Token.FREE,
|
||||
@@ -165,7 +171,6 @@ class PositionTooltipTest(FunctionalTest):
|
||||
self.assertNotIn("@test.io", portal.text)
|
||||
|
||||
|
||||
@skip(_RED)
|
||||
class CarteSeatSwitchTest(FunctionalTest):
|
||||
"""A CARTE gamer occupies multiple positions. Their non-current owned
|
||||
circles read `.tt-pos-me-also` and carry a `?seat=<n>` switch href that
|
||||
@@ -221,6 +226,7 @@ class CarteSeatSwitchTest(FunctionalTest):
|
||||
)
|
||||
self.assertEqual(circle.get_attribute("data-tt-tokens"), "6")
|
||||
|
||||
@skip(_RED + " [workstream B — ?seat card-stack consumption]")
|
||||
def test_switching_seat_loads_that_seats_role_view(self):
|
||||
# Clicking the me-also seat-4 circle loads ?seat=4 and the card-stack
|
||||
# reflects seat 4 (a non-active seat → ineligible / .fa-ban atop deck).
|
||||
@@ -240,6 +246,7 @@ class CarteSeatSwitchTest(FunctionalTest):
|
||||
self.assertEqual(stack.get_attribute("data-active-slot"), "4")
|
||||
stack.find_element(By.CSS_SELECTOR, ".fa-ban")
|
||||
|
||||
@skip(_RED + " [workstream C — solo-group sig persistence]")
|
||||
def test_carte_saves_a_significator_per_seat(self):
|
||||
# Sig Select: the viewer saves a sig on PC (seat 1), switches to seat 2,
|
||||
# and saves a DIFFERENT sig there — proving per-seat (not per-gamer) sig.
|
||||
|
||||
@@ -215,6 +215,40 @@ body.page-gameboard {
|
||||
&.active { display: block; }
|
||||
}
|
||||
|
||||
// Position-circle tooltip portal — page-root, position:fixed. Distinct id
|
||||
// from #id_tooltip_portal (the tray's) so the two hover systems don't share
|
||||
// state. position-tooltip.js centres + clamps it under the hovered circle.
|
||||
#id_position_tooltip_portal {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
padding: 0.75rem 1.5rem;
|
||||
|
||||
@extend %tt-token-fields;
|
||||
|
||||
.tt-title,
|
||||
.tt-description,
|
||||
.tt-shoptalk,
|
||||
.tt-expiry { display: block; }
|
||||
|
||||
// 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 {
|
||||
position: absolute;
|
||||
top: 0.4rem;
|
||||
right: 0.6rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
font-size: 1rem;
|
||||
color: rgba(var(--terUser), 1);
|
||||
|
||||
.fan-corner-rank { font-weight: 700; }
|
||||
i { font-size: 0.85em; }
|
||||
}
|
||||
|
||||
&.active { display: block; }
|
||||
}
|
||||
|
||||
#id_mini_tooltip_portal {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
|
||||
@@ -359,6 +359,10 @@ html:has(.role-select-backdrop) .position-strip .gate-slot { pointer-events: non
|
||||
// Re-enable clicks on confirm/reject/drop-token forms inside slots
|
||||
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; }
|
||||
|
||||
.position-strip {
|
||||
position: absolute;
|
||||
@@ -438,6 +442,22 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
|
||||
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Occupant-relative accents (sprint 2026-06-02). Additive over the
|
||||
// .filled/.reserved fill above — border tint only, appended last so
|
||||
// a single-class modifier wins on source order.
|
||||
&.tt-pos-me-current { border-color: rgba(var(--ninUser), 1); }
|
||||
&.tt-pos-me-also { border-color: rgba(var(--ninUser), 0.6); cursor: pointer; }
|
||||
&.tt-pos-bud { border-color: rgba(var(--secUser), 1); }
|
||||
|
||||
// CARTE seat-switch — a full-circle anchor on the viewer's own
|
||||
// non-current seats; ?seat=N loads that seat's view. Sits below any
|
||||
// confirm/release form (later in DOM) so the NVM button still wins.
|
||||
.pos-seat-switch {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{# Position-circle tooltip portal — page-root, position:fixed (escapes any #}
|
||||
{# tray overflow / mask-image clip per the portal gotcha). Distinct id from #}
|
||||
{# the tray's #id_tooltip_portal so the two hover systems don't collide. #}
|
||||
{# position-tooltip.js fills it on .gate-slot mouseenter from the circle's #}
|
||||
{# data-tt-* attrs + copies the circle's .tt-pos-* class on so it themes per #}
|
||||
{# occupant kind. The .tt-sign stack (seat significator: rank + suit) pins #}
|
||||
{# top-right, modeled on the tray sig card. #}
|
||||
<div id="id_position_tooltip_portal" class="tt">
|
||||
<span class="tt-sign" style="display:none"><span class="fan-corner-rank"></span><i class="fa-solid tt-sign-suit"></i></span>
|
||||
<span class="tt-title"></span>
|
||||
<span class="tt-description"></span>
|
||||
<span class="tt-shoptalk"></span>
|
||||
<span class="tt-expiry"></span>
|
||||
</div>
|
||||
@@ -1,9 +1,22 @@
|
||||
{% load lyric_extras %}
|
||||
{# Gate-position circles (1–6). Each carries a `.tt-pos-*` state class + #}
|
||||
{# `data-tt-*` payload (sprint 2026-06-02) that position-tooltip.js reads on #}
|
||||
{# hover to fill #id_position_tooltip_portal — @handle / title / seat-sig / #}
|
||||
{# bud shoptalk / #tokens / seat-clock expiry. NB: the `.tt-pos-*` class is #}
|
||||
{# appended AFTER `role-assigned` so the `gate-slot filled role-assigned` #}
|
||||
{# substring (RoleSelectRenderingTest) stays intact, and `class` stays first #}
|
||||
{# (before data-slot) for the class-attr regex IT. #}
|
||||
<div class="position-strip">
|
||||
{% for pos in gate_positions %}
|
||||
<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 %}"
|
||||
data-slot="{{ pos.slot.slot_number }}"{% if pos.slot.gamer %} data-user-id="{{ pos.slot.gamer.id }}"{% endif %}>
|
||||
<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>
|
||||
{% 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). #}
|
||||
<a class="pos-seat-switch" href="{% url 'epic:room' room.id %}?seat={{ pos.slot.slot_number }}" aria-label="Switch to seat {{ pos.slot.slot_number }}"></a>
|
||||
{% endif %}
|
||||
{% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}
|
||||
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -150,6 +150,9 @@
|
||||
{% if room.table_status %}
|
||||
<div id="id_tooltip_portal" class="tt" style="display:none;"></div>
|
||||
{% endif %}
|
||||
{# Position-circle tooltip portal — rendered whenever the circles can #}
|
||||
{# (gatekeeper + role-select; the SIG_SELECT phase hides the strip). #}
|
||||
{% include "apps/gameboard/_partials/_position_tooltip.html" %}
|
||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||
{% include "apps/gameboard/_partials/_burger.html" %}
|
||||
</div>
|
||||
@@ -168,5 +171,6 @@
|
||||
<script src="{% static 'apps/epic/sea.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/tray.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/position-tooltip.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||
{% endblock scripts %}
|
||||
|
||||
@@ -80,6 +80,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Position circles + their hover tooltips — the gate-view rendered no #}
|
||||
{# circles before this sprint (the headline gap). Reuses the shared #}
|
||||
{# _table_positions partial fed by the merged _gate_context. #}
|
||||
{% include "apps/gameboard/_partials/_table_positions.html" %}
|
||||
{% include "apps/gameboard/_partials/_position_tooltip.html" %}
|
||||
|
||||
{# NVM nav-backs one step to the table hex (not out to /gameboard/). #}
|
||||
{% url 'epic:room' room.id as nvm_url %}
|
||||
{% include "apps/gameboard/_partials/_room_gear.html" with nvm_url=nvm_url %}
|
||||
@@ -90,6 +96,7 @@
|
||||
{% block scripts %}
|
||||
<script src="{% static 'apps/dashboard/note.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/burger-btn.js' %}"></script>
|
||||
<script src="{% static 'apps/epic/position-tooltip.js' %}"></script>
|
||||
<script>
|
||||
{# Status-dots animation — identical loop to _gatekeeper.html so the #}
|
||||
{# "Token(s) Deposited . . . ." / "Please Deposit Token . . . ." status #}
|
||||
|
||||
Reference in New Issue
Block a user