From b29bcf5c381f6a77ed0d41a4083ae3fbd31f9407 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 3 May 2026 21:07:33 -0400 Subject: [PATCH] =?UTF-8?q?tray=20sig-card=20tooltip:=20portal=20w.=20PRV|?= =?UTF-8?q?NXT=20pager=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- .../epic/static/apps/epic/tray-tooltip.js | 173 +++++++++---- .../test_component_tray_tooltip.py | 228 +++++++++++++++++- src/static/tests/TrayTooltipSpec.js | 133 ++++++++++ src/static_src/tests/TrayTooltipSpec.js | 133 ++++++++++ .../gameboard/_partials/_sea_overlay.html | 11 +- .../gameboard/_partials/_sig_fyi_panel.html | 22 ++ .../_partials/_sig_select_overlay.html | 11 +- src/templates/apps/gameboard/game_kit.html | 11 +- src/templates/apps/gameboard/room.html | 10 +- 9 files changed, 654 insertions(+), 78 deletions(-) create mode 100644 src/templates/apps/gameboard/_partials/_sig_fyi_panel.html 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 %} +
    +
    +

    +

    +
    +

    + +
    + + diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html index ed9b260..d2a2c0f 100644 --- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html @@ -52,16 +52,7 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_

    Reversal

      -
      -
      -

      -

      -
      -

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