diff --git a/src/apps/epic/static/apps/epic/position-tooltip.js b/src/apps/epic/static/apps/epic/position-tooltip.js new file mode 100644 index 0000000..7bbdb61 --- /dev/null +++ b/src/apps/epic/static/apps/epic/position-tooltip.js @@ -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(); + }); +} diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index 12e8386..6296b8b 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -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 `", idx) + if needle in content[idx:end]: + return idx + pos = end + + +def _circle_tag(content, slot_number): + """Return the opening `
` 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") diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index c312601..4c428ea 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -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 diff --git a/src/functional_tests/test_game_room_position_tooltips.py b/src/functional_tests/test_game_room_position_tooltips.py index 4e99040..d81d7f1 100644 --- a/src/functional_tests/test_game_room_position_tooltips.py +++ b/src/functional_tests/test_game_room_position_tooltips.py @@ -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=` 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. diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 9418d0a..f9c67a5 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -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; diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index a72b5f0..32813b8 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -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%; + } } } diff --git a/src/templates/apps/gameboard/_partials/_position_tooltip.html b/src/templates/apps/gameboard/_partials/_position_tooltip.html new file mode 100644 index 0000000..24f7144 --- /dev/null +++ b/src/templates/apps/gameboard/_partials/_position_tooltip.html @@ -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. #} +
+ + + + + +
diff --git a/src/templates/apps/gameboard/_partials/_table_positions.html b/src/templates/apps/gameboard/_partials/_table_positions.html index 988e843..c67d6df 100644 --- a/src/templates/apps/gameboard/_partials/_table_positions.html +++ b/src/templates/apps/gameboard/_partials/_table_positions.html @@ -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. #}
{% for pos in gate_positions %} -
+
{{ pos.slot.slot_number }} {% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %} + {% 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). #} + + {% endif %} {% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %}
{% csrf_token %} diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index e1a71af..27e0cce 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -150,6 +150,9 @@ {% if room.table_status %} {% 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" %}
@@ -168,5 +171,6 @@ + {% endblock scripts %} diff --git a/src/templates/apps/gameboard/room_gate.html b/src/templates/apps/gameboard/room_gate.html index bd82cc8..7640a6b 100644 --- a/src/templates/apps/gameboard/room_gate.html +++ b/src/templates/apps/gameboard/room_gate.html @@ -80,6 +80,12 @@
+ {# 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 %} +