tray sig-card tooltip: portal w. PRV|NXT pager — TDD

Phase 2 of the apps.tooltips integration on the tray. Hovering
.tray-sig-card > .sig-stage-card opens #id_tooltip_portal w. an FYI panel
that mirrors #id_fan_fyi_panel (Energy / Operation entries cycled via
PRV|NXT), but w.o. the stage block, w.o. Reversal entries, & w.o. the fan
stage's click-to-dismiss handler — the panel-body click is reserved for
future drag-and-drop on .tray-sig-card:active.

- _partials/_sig_fyi_panel.html — new partial, the .sig-info + PRV|NXT
  block extracted out of game_kit.html, _sig_select_overlay.html, &
  _sea_overlay.html. {% include %}d back from those 3 callers; pure
  copy-paste extraction (no behavioural change to fan stage, sig select,
  or sea select).
- room.html: .tray-sig-card > .sig-stage-card gains data-energies +
  data-operations (the only attrs StageCard.buildInfoData reads), keyed
  off my_tray_sig.energies_json / .operations_json (existing TarotCard
  properties).
- tray-tooltip.js: new sig branch — _showSig() builds the panel inline,
  paints via StageCard.renderFyi, & wires PRV|NXT cycle handlers; the
  mousemove union now covers the .fyi-prev / .fyi-next btn rects (the
  btns hang past the portal's left & right edges) so mouse-over them
  keeps the panel alive. Click stopPropagation on the btns prevents the
  panel-body click from reaching anything else.
- TrayTooltipSpec: 6 new sig-branch specs (panel structure; first energy
  entry rendered; PRV|NXT cycling; body click no-dismiss; pointer over
  btn rects keeps panel alive; pointer outside full union clears).
- test_component_tray_tooltip.py: 4 sig FTs (hover populates portal w.
  Energy/TESTLIBIDO/effect/1-of-2; PRV|NXT cycle; body click does NOT
  dismiss; mouseleave clears).

FT helper note — the sig FT's _hover dispatches a synthetic mouseenter
via JS rather than ActionChains.move_to_element, because the role-card
& sig-card cells sit side-by-side in the tray grid: the pointer's
animated path crosses the role-card on its way to the sig-card &
opens the role tooltip mid-flight, which then occludes the sig stage
by the time the move lands. Direct dispatch lands the event on the
intended trigger w.o. the cross-cell drag-by.

313 epic ITs + 335 Jasmine specs (incl. 6 new) + 6 tray-tooltip FTs all
green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-03 21:07:33 -04:00
parent 08243d109d
commit b29bcf5c38
9 changed files with 654 additions and 78 deletions

View File

@@ -1,20 +1,32 @@
// ── tray-tooltip.js ───────────────────────────────────────────────────────────
//
// Phase 1: hover the tray's .tray-role-card > img → copy its sibling .tt's
// innerHTML into #id_tooltip_portal and clamp the portal inside the viewport.
// Mouseleave the union of [trigger, portal] rects → hide the portal.
// Hover-triggered tooltips on the tray's two card cells, both portalled to
// #id_tooltip_portal so they escape the tray's overflow:hidden clip.
//
// Mirrors the Game-Kit token pattern (see gameboard.js:initGameKitTooltips)
// but scoped to the tray and without DON/DOFF buttons or a mini portal.
// Phase 2 (sig card with PRV/NXT pager) will extend this module via a separate
// trigger selector once the FYI panel is extracted from #id_fan_fyi_panel.
// Phase 1 — .tray-role-card > img:
// Copies its sibling .tt's innerHTML into the portal. No btns.
// Phase 2 — .tray-sig-card > .sig-stage-card:
// Builds an FYI panel via StageCard.buildInfoData / renderFyi from the
// stage card's data-energies + data-operations attrs. PRV/NXT btns cycle
// through entries. NO click-to-dismiss (departure from the fan stage's
// #id_fan_fyi_panel — the panel-body click is reserved for future
// drag-and-drop on .tray-sig-card:active).
//
// Mouseleave: a document-level mousemove listener hides the portal when the
// pointer leaves the union of [trigger, portal] rects (Game-Kit pattern).
// For the sig branch, the union additionally covers .fyi-prev / .fyi-next
// since their absolute positioning makes them spill past the portal's edges.
//
// ─────────────────────────────────────────────────────────────────────────────
var TrayTooltip = (function () {
var _portal = null;
var _activeTrig = null;
var _activeKind = null; // "role" | "sig"
var _infoData = [];
var _infoIdx = 0;
var _onDocMove = null;
var _observer = null;
function _inRect(x, y, r) {
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
@@ -26,33 +38,21 @@ var TrayTooltip = (function () {
_portal.style.display = "none";
_portal.innerHTML = "";
_activeTrig = null;
_activeKind = null;
_infoData = [];
_infoIdx = 0;
}
function _show(triggerEl) {
var tt = triggerEl.parentElement && triggerEl.parentElement.querySelector(".tt");
if (!tt || !_portal) return;
_portal.innerHTML = tt.innerHTML;
_portal.classList.add("active");
_portal.style.display = "block";
_activeTrig = triggerEl;
// Position: centre horizontally on the trigger, then clamp inside the
// viewport so the portal never crosses an edge. 8px breathing room
// matches the Game-Kit clamp.
function _position(triggerEl) {
var triggerRect = triggerEl.getBoundingClientRect();
var halfW = _portal.offsetWidth / 2;
var rawLeft = triggerRect.left + triggerRect.width / 2;
var minLeft = halfW + 8;
var maxLeft = window.innerWidth - halfW - 8;
var left = Math.max(minLeft, Math.min(rawLeft, maxLeft));
_portal.style.left = Math.round(left) + "px";
_portal.style.left = Math.round(Math.max(minLeft, Math.min(rawLeft, maxLeft))) + "px";
// Anchor vertically: above the trigger when it sits in the lower half,
// below otherwise. translate(-50%, …) keeps the centre column aligned.
var triggerCY = triggerRect.top + triggerRect.height / 2;
var showBelow = triggerCY < window.innerHeight / 2;
if (showBelow) {
if (triggerCY < window.innerHeight / 2) {
_portal.style.top = Math.round(triggerRect.bottom) + "px";
_portal.style.transform = "translate(-50%, 0.5rem)";
} else {
@@ -61,16 +61,109 @@ var TrayTooltip = (function () {
}
}
function _showRole(triggerEl) {
var tt = triggerEl.parentElement && triggerEl.parentElement.querySelector(".tt");
if (!tt || !_portal) return;
_portal.innerHTML = tt.innerHTML;
_portal.classList.add("active");
_portal.style.display = "block";
_activeTrig = triggerEl;
_activeKind = "role";
_position(triggerEl);
}
// Build the FYI panel HTML used by the sig branch — same DOM shape as
// _partials/_sig_fyi_panel.html so SCSS rules and StageCard.renderFyi work
// unchanged. Kept here (not loaded from the partial) so the portal remains
// a single page-level element with no per-trigger duplication.
var _SIG_PANEL_HTML = ''
+ '<div class="sig-info">'
+ '<div class="sig-info-header">'
+ '<h4 class="sig-info-title"></h4>'
+ '<p class="sig-info-type"></p>'
+ '</div>'
+ '<p class="sig-info-effect"></p>'
+ '<span class="sig-info-index"></span>'
+ '</div>'
+ '<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>'
+ '<button class="btn btn-nav-right fyi-next" type="button">NXT</button>';
function _showSig(triggerEl) {
if (!_portal) return;
var card = (typeof StageCard !== "undefined")
? StageCard.fromDataset(triggerEl)
: { energies: [], operations: [] };
_infoData = (typeof StageCard !== "undefined")
? StageCard.buildInfoData(card)
: [];
_infoIdx = 0;
_portal.innerHTML = _SIG_PANEL_HTML;
var infoPanel = _portal.querySelector(".sig-info");
if (typeof StageCard !== "undefined") {
StageCard.renderFyi(infoPanel, _infoData, _infoIdx);
}
_portal.classList.add("active");
_portal.style.display = "block";
_activeTrig = triggerEl;
_activeKind = "sig";
_portal.querySelector(".fyi-prev").addEventListener("click", function (e) {
e.stopPropagation();
if (!_infoData.length) return;
_infoIdx = (_infoIdx - 1 + _infoData.length) % _infoData.length;
StageCard.renderFyi(infoPanel, _infoData, _infoIdx);
});
_portal.querySelector(".fyi-next").addEventListener("click", function (e) {
e.stopPropagation();
if (!_infoData.length) return;
_infoIdx = (_infoIdx + 1) % _infoData.length;
StageCard.renderFyi(infoPanel, _infoData, _infoIdx);
});
_position(triggerEl);
}
function _bindTriggers() {
// Server-rendered .tray-role-card > img (Phase 1).
// Phase 2 will additionally bind .tray-sig-card > .sig-stage-card.
var roleImgs = document.querySelectorAll("#id_tray_grid .tray-role-card > img");
roleImgs.forEach(function (img) {
if (img._trayTooltipBound) return;
img._trayTooltipBound = true;
img.addEventListener("mouseenter", function () {
if (_activeTrig === img) return;
_show(img);
_showRole(img);
});
});
var sigStages = document.querySelectorAll("#id_tray_grid .tray-sig-card > .sig-stage-card");
sigStages.forEach(function (stage) {
if (stage._trayTooltipBound) return;
stage._trayTooltipBound = true;
stage.addEventListener("mouseenter", function () {
if (_activeTrig === stage) return;
_showSig(stage);
});
});
}
function _activeUnion() {
if (!_activeTrig || !_portal) return null;
var rects = [
_activeTrig.getBoundingClientRect(),
_portal.getBoundingClientRect(),
];
if (_activeKind === "sig") {
var prv = _portal.querySelector(".fyi-prev");
var nxt = _portal.querySelector(".fyi-next");
if (prv) rects.push(prv.getBoundingClientRect());
if (nxt) rects.push(nxt.getBoundingClientRect());
}
return {
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; })),
};
}
function init() {
@@ -80,32 +173,21 @@ var TrayTooltip = (function () {
_bindTriggers();
// Observe later-added role cards (placeCard inserts the class+img
// dynamically when the active gamer confirms a role pick).
var grid = document.getElementById("id_tray_grid");
if (grid && typeof MutationObserver !== "undefined") {
var observer = new MutationObserver(_bindTriggers);
observer.observe(grid, { childList: true, subtree: true });
_observer = observer;
_observer = new MutationObserver(_bindTriggers);
_observer.observe(grid, { childList: true, subtree: true });
}
_onDocMove = function (e) {
if (!_portal.classList.contains("active") || !_activeTrig) return;
var trigRect = _activeTrig.getBoundingClientRect();
var portalRect = _portal.getBoundingClientRect();
var union = {
left: Math.min(trigRect.left, portalRect.left),
top: Math.min(trigRect.top, portalRect.top),
right: Math.max(trigRect.right, portalRect.right),
bottom: Math.max(trigRect.bottom, portalRect.bottom),
};
var union = _activeUnion();
if (!union) return;
if (!_inRect(e.clientX, e.clientY, union)) _hide();
};
document.addEventListener("mousemove", _onDocMove);
}
var _observer = null;
function reset() {
if (_onDocMove) document.removeEventListener("mousemove", _onDocMove);
if (_observer) _observer.disconnect();
@@ -113,6 +195,11 @@ var TrayTooltip = (function () {
_observer = null;
_hide();
_portal = null;
// Drop the bind sentinel so a re-init re-binds the same DOM nodes
// (Jasmine afterEach pattern).
document.querySelectorAll("[data-tray-tt-bound]").forEach(function (el) {
delete el._trayTooltipBound;
});
}
return { init: init, reset: reset };

View File

@@ -14,7 +14,7 @@ from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.applets.models import Applet
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat, TarotCard
from apps.lyric.models import User
@@ -161,3 +161,229 @@ class TrayRoleCardTooltipTest(FunctionalTest):
self._move_far_away()
self.wait_for(lambda: self.assertFalse(portal.is_displayed()))
class TraySigCardTooltipTest(FunctionalTest):
"""Phase 2 — sig-card tooltip mirrors #id_fan_fyi_panel functionally:
Energy/Operation entries cycled via PRV/NXT btns, "1/N" pager, no stage
block, no Reversal entries. Extended-mouseleave (union incl. PRV/NXT btn
rects) replaces the fan-stage version's click-to-dismiss handler.
"""
EMAILS = [
"ts1@test.io", "ts2@test.io", "ts3@test.io",
"ts4@test.io", "ts5@test.io", "ts6@test.io",
]
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
Applet.objects.get_or_create(
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
)
Applet.objects.get_or_create(
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
)
def _make_room_with_sig(self):
"""Room w. founder seated, role assigned, & a TarotCard set as sig.
The seeded card carries 1 energy + 1 operation = 2 FYI entries so the
PRV/NXT pager has something to cycle through.
"""
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
deck, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
)
founder.equipped_deck = deck
founder.save(update_fields=["equipped_deck"])
# Stable test fixture — slug + arcana + suit unique per (deck_variant, slug).
# corner_rank / suit_icon are computed properties on TarotCard;
# only the underlying fields go in create().
sig_card = TarotCard.objects.create(
deck_variant=deck,
name="The Tester",
arcana="MAJOR",
number=98,
slug="the-tester-sig-tt",
icon="fa-flask",
energies=[
{"type": "TESTLIBIDO", "effect": "First energy effect."},
],
operations=[
{"type": "TESTOP", "effect": "First operation effect."},
],
)
room = Room.objects.create(name="Sig Tooltip Room", owner=founder)
_fill_room_via_orm(room, self.EMAILS)
room.table_status = Room.ROLE_SELECT
room.save()
for slot in room.gate_slots.order_by("slot_number"):
seat = TableSeat.objects.create(
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
role=("PC" if slot.slot_number == 1 else None),
)
if slot.slot_number == 1:
seat.significator = sig_card
seat.save(update_fields=["significator"])
return room, sig_card
def _open_tray(self):
self.browser.execute_script("Tray.open();")
self.wait_for(
lambda: self.assertLess(
self.browser.execute_script(
"return document.getElementById('id_tray_wrap').getBoundingClientRect().left"
),
40,
)
)
def _hover(self, element):
# Synthetic mouseenter — Selenium's ActionChains animates the pointer
# from the previous position to the destination. With role-card and
# sig-card cells side-by-side in the tray grid, an ActionChains hover
# on the sig stage crosses the role-card's hit area on the way and
# opens the role tooltip mid-flight, which then occludes the sig stage
# by the time the pointer lands. Direct dispatch avoids this and still
# exercises the same listener path (mouseenter → _showSig).
self.browser.execute_script(
"arguments[0].dispatchEvent(new MouseEvent('mouseenter',"
"{bubbles:false, cancelable:false}));",
element,
)
def _move_far_away(self):
self.browser.execute_script(
"document.dispatchEvent(new MouseEvent('mousemove',"
"{bubbles:true, clientX:9999, clientY:9999}));"
)
# ------------------------------------------------------------------ #
# T1 — Hover sig card → portal shows first energy entry #
# ------------------------------------------------------------------ #
def test_hover_sig_card_populates_fyi_portal(self):
room, _card = self._make_room_with_sig()
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
sig_stage = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self.assertFalse(portal.is_displayed())
self._hover(sig_stage)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
# Portal renders the same .sig-info structure as the fan FYI panel.
title = portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip()
ttype = portal.find_element(By.CSS_SELECTOR, ".sig-info-type").text.strip()
effect = portal.find_element(By.CSS_SELECTOR, ".sig-info-effect").text.strip()
index = portal.find_element(By.CSS_SELECTOR, ".sig-info-index").text.strip()
self.assertEqual(title, "Energy")
self.assertEqual(ttype, "TESTLIBIDO")
self.assertEqual(effect, "First energy effect.")
self.assertEqual(index, "1 / 2") # 1 energy + 1 operation = 2 entries
# PRV + NXT buttons are present.
portal.find_element(By.CSS_SELECTOR, ".fyi-prev")
portal.find_element(By.CSS_SELECTOR, ".fyi-next")
# ------------------------------------------------------------------ #
# T2 — NXT cycles to operation entry; PRV cycles back #
# ------------------------------------------------------------------ #
def test_prv_nxt_cycle_through_entries(self):
room, _card = self._make_room_with_sig()
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
sig_stage = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self._hover(sig_stage)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
nxt = portal.find_element(By.CSS_SELECTOR, ".fyi-next")
nxt.click()
self.wait_for(
lambda: self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip(),
"Operation",
)
)
self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".sig-info-type").text.strip(),
"TESTOP",
)
self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".sig-info-index").text.strip(),
"2 / 2",
)
prv = portal.find_element(By.CSS_SELECTOR, ".fyi-prev")
prv.click()
self.wait_for(
lambda: self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".sig-info-title").text.strip(),
"Energy",
)
)
# ------------------------------------------------------------------ #
# T3 — Clicking the panel body does NOT dismiss (departure from fan). #
# ------------------------------------------------------------------ #
def test_panel_body_click_does_not_dismiss(self):
room, _card = self._make_room_with_sig()
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
sig_stage = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self._hover(sig_stage)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
# Click on the panel body (not on PRV / NXT) → portal stays active.
portal.find_element(By.CSS_SELECTOR, ".sig-info-effect").click()
self.assertTrue(portal.is_displayed())
# ------------------------------------------------------------------ #
# T4 — Mouseleave (outside trigger + portal + btn rects) clears. #
# ------------------------------------------------------------------ #
def test_mouseleave_outside_union_clears_portal(self):
room, _card = self._make_room_with_sig()
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
sig_stage = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-sig-card .sig-stage-card"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self._hover(sig_stage)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
self._move_far_away()
self.wait_for(lambda: self.assertFalse(portal.is_displayed()))

View File

@@ -138,6 +138,139 @@ describe("TrayTooltip", () => {
// Clamping — portal left stays inside the viewport //
// ---------------------------------------------------------------------- //
// ---------------------------------------------------------------------- //
// Phase 2 — sig-card branch //
// ---------------------------------------------------------------------- //
//
// Hovering .tray-sig-card > .sig-stage-card populates the SAME portal with
// a .sig-info FYI panel (Energy/Operation entries cycled via PRV/NXT btns).
// No click-to-dismiss; mouseleave union covers the overhanging btn rects.
describe("sig-card branch", () => {
let sigCell, sigStage;
beforeEach((done) => {
sigCell = document.createElement("div");
sigCell.className = "tray-cell tray-sig-card";
sigStage = document.createElement("div");
sigStage.className = "sig-stage-card sea-sig-card";
// Two FYI entries — 1 energy + 1 operation — so PRV/NXT have
// something to cycle through.
sigStage.dataset.energies = JSON.stringify([
{ type: "TESTLIBIDO", effect: "First energy effect." },
]);
sigStage.dataset.operations = JSON.stringify([
{ type: "TESTOP", effect: "First operation effect." },
]);
sigCell.appendChild(sigStage);
grid.appendChild(sigCell);
spyOn(sigStage, "getBoundingClientRect").and.returnValue({
left: 100, right: 160, top: 300, bottom: 396,
width: 60, height: 96,
});
// MutationObserver fires asynchronously — yield once so the new
// sig cell is bound before the spec body runs.
setTimeout(done, 0);
});
it("populates the portal with a .sig-info panel + PRV/NXT btns", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.querySelector(".sig-info-title")).not.toBe(null);
expect(portal.querySelector(".sig-info-type")).not.toBe(null);
expect(portal.querySelector(".sig-info-effect")).not.toBe(null);
expect(portal.querySelector(".sig-info-index")).not.toBe(null);
expect(portal.querySelector(".fyi-prev")).not.toBe(null);
expect(portal.querySelector(".fyi-next")).not.toBe(null);
});
it("renders the first energy entry on hover", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTLIBIDO");
expect(portal.querySelector(".sig-info-effect").textContent).toBe("First energy effect.");
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
});
it("NXT advances to the operation entry; PRV cycles back", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
portal.querySelector(".fyi-next").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.querySelector(".sig-info-title").textContent).toBe("Operation");
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTOP");
expect(portal.querySelector(".sig-info-index").textContent).toBe("2 / 2");
portal.querySelector(".fyi-prev").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
});
it("clicking the panel body does NOT dismiss (departure from fan stage)", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
portal.querySelector(".sig-info-effect").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.classList.contains("active")).toBe(true);
expect(portal.style.display).not.toBe("none");
});
it("keeps portal active when pointer is over a fyi-prev / fyi-next btn rect", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
// Stub the portal + btn rects so the union test is deterministic.
spyOn(portal, "getBoundingClientRect").and.returnValue({
left: 80, right: 280, top: 200, bottom: 320,
width: 200, height: 120,
});
const prv = portal.querySelector(".fyi-prev");
const nxt = portal.querySelector(".fyi-next");
spyOn(prv, "getBoundingClientRect").and.returnValue({
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
});
spyOn(nxt, "getBoundingClientRect").and.returnValue({
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
});
// Pointer over the PRV btn (left:6092) — outside the portal's left
// edge (80) but inside the btn rect — should keep the portal alive.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 70, clientY: 256,
}));
expect(portal.classList.contains("active")).toBe(true);
// Pointer over the NXT btn (right:268300, past the portal right
// edge 280) — likewise stays alive.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 290, clientY: 256,
}));
expect(portal.classList.contains("active")).toBe(true);
});
it("clears portal when pointer leaves trigger + portal + btn rect union", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
spyOn(portal, "getBoundingClientRect").and.returnValue({
left: 80, right: 280, top: 200, bottom: 320,
width: 200, height: 120,
});
const prv = portal.querySelector(".fyi-prev");
const nxt = portal.querySelector(".fyi-next");
spyOn(prv, "getBoundingClientRect").and.returnValue({
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
});
spyOn(nxt, "getBoundingClientRect").and.returnValue({
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
});
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 9999, clientY: 9999,
}));
expect(portal.classList.contains("active")).toBe(false);
expect(portal.style.display).toBe("none");
});
});
describe("position clamping", () => {
it("clamps to the right when the trigger is near the left edge", () => {
// Trigger near x=0 should push portal centre rightward to halfW + 8.

View File

@@ -138,6 +138,139 @@ describe("TrayTooltip", () => {
// Clamping — portal left stays inside the viewport //
// ---------------------------------------------------------------------- //
// ---------------------------------------------------------------------- //
// Phase 2 — sig-card branch //
// ---------------------------------------------------------------------- //
//
// Hovering .tray-sig-card > .sig-stage-card populates the SAME portal with
// a .sig-info FYI panel (Energy/Operation entries cycled via PRV/NXT btns).
// No click-to-dismiss; mouseleave union covers the overhanging btn rects.
describe("sig-card branch", () => {
let sigCell, sigStage;
beforeEach((done) => {
sigCell = document.createElement("div");
sigCell.className = "tray-cell tray-sig-card";
sigStage = document.createElement("div");
sigStage.className = "sig-stage-card sea-sig-card";
// Two FYI entries — 1 energy + 1 operation — so PRV/NXT have
// something to cycle through.
sigStage.dataset.energies = JSON.stringify([
{ type: "TESTLIBIDO", effect: "First energy effect." },
]);
sigStage.dataset.operations = JSON.stringify([
{ type: "TESTOP", effect: "First operation effect." },
]);
sigCell.appendChild(sigStage);
grid.appendChild(sigCell);
spyOn(sigStage, "getBoundingClientRect").and.returnValue({
left: 100, right: 160, top: 300, bottom: 396,
width: 60, height: 96,
});
// MutationObserver fires asynchronously — yield once so the new
// sig cell is bound before the spec body runs.
setTimeout(done, 0);
});
it("populates the portal with a .sig-info panel + PRV/NXT btns", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.querySelector(".sig-info-title")).not.toBe(null);
expect(portal.querySelector(".sig-info-type")).not.toBe(null);
expect(portal.querySelector(".sig-info-effect")).not.toBe(null);
expect(portal.querySelector(".sig-info-index")).not.toBe(null);
expect(portal.querySelector(".fyi-prev")).not.toBe(null);
expect(portal.querySelector(".fyi-next")).not.toBe(null);
});
it("renders the first energy entry on hover", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTLIBIDO");
expect(portal.querySelector(".sig-info-effect").textContent).toBe("First energy effect.");
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
});
it("NXT advances to the operation entry; PRV cycles back", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
portal.querySelector(".fyi-next").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.querySelector(".sig-info-title").textContent).toBe("Operation");
expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTOP");
expect(portal.querySelector(".sig-info-index").textContent).toBe("2 / 2");
portal.querySelector(".fyi-prev").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
});
it("clicking the panel body does NOT dismiss (departure from fan stage)", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
portal.querySelector(".sig-info-effect").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(portal.classList.contains("active")).toBe(true);
expect(portal.style.display).not.toBe("none");
});
it("keeps portal active when pointer is over a fyi-prev / fyi-next btn rect", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
// Stub the portal + btn rects so the union test is deterministic.
spyOn(portal, "getBoundingClientRect").and.returnValue({
left: 80, right: 280, top: 200, bottom: 320,
width: 200, height: 120,
});
const prv = portal.querySelector(".fyi-prev");
const nxt = portal.querySelector(".fyi-next");
spyOn(prv, "getBoundingClientRect").and.returnValue({
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
});
spyOn(nxt, "getBoundingClientRect").and.returnValue({
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
});
// Pointer over the PRV btn (left:6092) — outside the portal's left
// edge (80) but inside the btn rect — should keep the portal alive.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 70, clientY: 256,
}));
expect(portal.classList.contains("active")).toBe(true);
// Pointer over the NXT btn (right:268300, past the portal right
// edge 280) — likewise stays alive.
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 290, clientY: 256,
}));
expect(portal.classList.contains("active")).toBe(true);
});
it("clears portal when pointer leaves trigger + portal + btn rect union", () => {
sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
spyOn(portal, "getBoundingClientRect").and.returnValue({
left: 80, right: 280, top: 200, bottom: 320,
width: 200, height: 120,
});
const prv = portal.querySelector(".fyi-prev");
const nxt = portal.querySelector(".fyi-next");
spyOn(prv, "getBoundingClientRect").and.returnValue({
left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
});
spyOn(nxt, "getBoundingClientRect").and.returnValue({
left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
});
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 9999, clientY: 9999,
}));
expect(portal.classList.contains("active")).toBe(false);
expect(portal.style.display).toBe("none");
});
});
describe("position clamping", () => {
it("clamps to the right when the trigger is near the left edge", () => {
// Trigger near x=0 should push portal centre rightward to halfW + 8.

View File

@@ -176,16 +176,7 @@
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_sea_stat_reversed"></ul>
</div>
<div class="sig-info" id="id_sea_fyi_panel" style="display:none">
<div class="sig-info-header">
<h4 class="sig-info-title"></h4>
<p class="sig-info-type"></p>
</div>
<p class="sig-info-effect"></p>
<span class="sig-info-index"></span>
</div>
<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>
<button class="btn btn-nav-right fyi-next" type="button">NXT</button>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
</div>
</div>
</div>

View File

@@ -0,0 +1,22 @@
{% comment %}
Shared FYI panel — Energy / Operation entries painted by StageCard.renderFyi
in JS. PRV / NXT btns hang past the panel's left/right edges by -1rem each
(see _card-deck.scss). Used by:
- the fan stage (game_kit.html, paired with #id_fan_stage_block)
- the sig select overlay (_sig_select_overlay.html)
- the sea select overlay (_sea_overlay.html)
- the tray sig-card tooltip (rendered into #id_tooltip_portal at hover time)
`panel_id` is optional; when blank the wrapper is unidentified (used by the
tray, which fills #id_tooltip_portal instead).
{% endcomment %}
<div class="sig-info"{% if panel_id %} id="{{ panel_id }}"{% endif %}{% if panel_extra_attrs %} {{ panel_extra_attrs }}{% endif %}>
<div class="sig-info-header">
<h4 class="sig-info-title"></h4>
<p class="sig-info-type"></p>
</div>
<p class="sig-info-effect"></p>
<span class="sig-info-index"></span>
</div>
<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>
<button class="btn btn-nav-right fyi-next" type="button">NXT</button>

View File

@@ -52,16 +52,7 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div>
<div class="sig-info" id="id_sig_tooltip">
<div class="sig-info-header">
<h4 class="sig-info-title"></h4>
<p class="sig-info-type"></p>
</div>
<p class="sig-info-effect"></p>
<span class="sig-info-index"></span>
</div>
<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>
<button class="btn btn-nav-right fyi-next" type="button">NXT</button>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sig_tooltip" %}
</div>
</div>

View File

@@ -29,16 +29,7 @@
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_fan_stat_reversed"></ul>
</div>
<div class="sig-info" id="id_fan_fyi_panel" style="display:none">
<div class="sig-info-header">
<h4 class="sig-info-title"></h4>
<p class="sig-info-type"></p>
</div>
<p class="sig-info-effect"></p>
<span class="sig-info-index"></span>
</div>
<button class="btn btn-nav-left fyi-prev" type="button">PRV</button>
<button class="btn btn-nav-right fyi-next" type="button">NXT</button>
{% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_fan_fyi_panel" panel_extra_attrs='style="display:none"' %}
</div>
<button id="id_fan_next" class="fan-nav fan-nav--next" aria-label="Next card">&#8250;</button>
</div>

View File

@@ -101,12 +101,14 @@
<i class="fa-solid fa-dice-d20"></i>
</button>
</div>
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}">{% tooltip my_tray_role_tooltip %}</div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><div class="sig-stage-card sea-sig-card" aria-label="{{ my_tray_sig.name }}"><span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}</div></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}">{% tooltip my_tray_role_tooltip %}</div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><div class="sig-stage-card sea-sig-card" aria-label="{{ my_tray_sig.name }}" data-energies="{{ my_tray_sig.energies_json }}" data-operations="{{ my_tray_sig.operations_json }}"><span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}</div></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
</div>
{% endif %}
{# Tray-tooltip portal — sibling of the tray so it sits at room-page root,
{% comment %}
Tray-tooltip portal — sibling of the tray so it sits at room-page root,
not inside the tray's overflow:hidden / mask-image clip. JS populates
innerHTML on hover of .tray-role-card > img (and Phase 2: sig card). #}
innerHTML on hover of .tray-role-card > img (and Phase 2: sig card).
{% endcomment %}
{% if room.table_status %}
<div id="id_tooltip_portal" class="tt" style="display:none;"></div>
{% endif %}