diff --git a/src/apps/epic/static/apps/epic/tray-tooltip.js b/src/apps/epic/static/apps/epic/tray-tooltip.js
index d20ba74..34468a6 100644
--- a/src/apps/epic/static/apps/epic/tray-tooltip.js
+++ b/src/apps/epic/static/apps/epic/tray-tooltip.js
@@ -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 = ''
+ + '
'
+ + '
'
+ + ''
+ + ''
+ + '
'
+ + ''
+ + ''
+ + '
'
+ + ''
+ + '';
+
+ 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 };
diff --git a/src/functional_tests/test_component_tray_tooltip.py b/src/functional_tests/test_component_tray_tooltip.py
index 7370310..7955a01 100644
--- a/src/functional_tests/test_component_tray_tooltip.py
+++ b/src/functional_tests/test_component_tray_tooltip.py
@@ -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()))
diff --git a/src/static/tests/TrayTooltipSpec.js b/src/static/tests/TrayTooltipSpec.js
index 27cd471..e9eced0 100644
--- a/src/static/tests/TrayTooltipSpec.js
+++ b/src/static/tests/TrayTooltipSpec.js
@@ -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:60–92) — 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:268–300, 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.
diff --git a/src/static_src/tests/TrayTooltipSpec.js b/src/static_src/tests/TrayTooltipSpec.js
index 27cd471..e9eced0 100644
--- a/src/static_src/tests/TrayTooltipSpec.js
+++ b/src/static_src/tests/TrayTooltipSpec.js
@@ -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:60–92) — 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:268–300, 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.
diff --git a/src/templates/apps/gameboard/_partials/_sea_overlay.html b/src/templates/apps/gameboard/_partials/_sea_overlay.html
index 4e53c55..cf7ea6c 100644
--- a/src/templates/apps/gameboard/_partials/_sea_overlay.html
+++ b/src/templates/apps/gameboard/_partials/_sea_overlay.html
@@ -176,16 +176,7 @@
Reversal
-
-
-
-
-
-
-
-
-
-
+ {% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sea_fyi_panel" panel_extra_attrs='style="display:none"' %}
diff --git a/src/templates/apps/gameboard/_partials/_sig_fyi_panel.html b/src/templates/apps/gameboard/_partials/_sig_fyi_panel.html
new file mode 100644
index 0000000..7cad966
--- /dev/null
+++ b/src/templates/apps/gameboard/_partials/_sig_fyi_panel.html
@@ -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 %}
+
-
-
+ {% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_sig_tooltip" %}
diff --git a/src/templates/apps/gameboard/game_kit.html b/src/templates/apps/gameboard/game_kit.html
index 6799c25..10c6f4b 100644
--- a/src/templates/apps/gameboard/game_kit.html
+++ b/src/templates/apps/gameboard/game_kit.html
@@ -29,16 +29,7 @@
Reversal
-
-
-
-
-
-
-
-
-
-
+ {% include "apps/gameboard/_partials/_sig_fyi_panel.html" with panel_id="id_fan_fyi_panel" panel_extra_attrs='style="display:none"' %}
diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html
index 1f1c3ae..5679e58 100644
--- a/src/templates/apps/gameboard/room.html
+++ b/src/templates/apps/gameboard/room.html
@@ -101,12 +101,14 @@
-
{% if my_tray_role %}
{% tooltip my_tray_role_tooltip %}
{% else %}{% endif %}{% if my_tray_sig %}
{{ my_tray_sig.corner_rank }}{% if my_tray_sig.suit_icon %}{% endif %}
{% else %}{% endif %}{% for i in "345678" %}{% endfor %}
+
{% if my_tray_role %}
{% tooltip my_tray_role_tooltip %}
{% else %}{% endif %}{% if my_tray_sig %}
{{ my_tray_sig.corner_rank }}{% if my_tray_sig.suit_icon %}{% endif %}
{% else %}{% endif %}{% for i in "345678" %}{% endfor %}
{% endif %}
- {# 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). #}
+ {% 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).
+ {% endcomment %}
{% if room.table_status %}
{% endif %}