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:
Disco DeDisco
2026-06-01 12:13:09 -04:00
parent 19471662ff
commit 30246cc94a
10 changed files with 509 additions and 17 deletions

View File

@@ -0,0 +1,160 @@
// ── position-tooltip.js ───────────────────────────────────────────────────────
//
// Hover-triggered rich tooltips on the gate-position circles (16), 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();
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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%;
}
}
}

View File

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

View File

@@ -1,9 +1,22 @@
{% load lyric_extras %}
{# Gate-position circles (16). 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 %}

View File

@@ -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 %}

View File

@@ -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 #}