From 9b93b9d31bf7020939c38dc7f675f5b6e94f84c0 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 3 May 2026 21:18:09 -0400 Subject: [PATCH] =?UTF-8?q?tray=20tooltips:=20tilt=20persists=20while=20po?= =?UTF-8?q?rtal=20is=20open;=20PRV|NXT=20pinned=20to=20corners=20=E2=80=94?= =?UTF-8?q?=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TrayTooltip adds .tt-active to the .tray-role-card / .tray-sig-card cell while its tooltip is open & removes it on _hide. The hover-tilt selectors gain .tt-active alongside :hover, :focus so the card stays tilted while the user is hovering the portal itself rather than the cell. - #id_tooltip_portal: .fyi-prev / .fyi-next pinned to the bottom corners w. 1rem outside the panel (bottom: -1rem; left/right: -1rem) — same anchor the @stat-block-shared mixin uses for fan / sig / sea, restated here since the portal isn't covered by that mixin. - 2 new TrayTooltipSpec specs (.tt-active added on hover, removed on _hide; for both role & sig branches). Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.7 (1M context) --- .../epic/static/apps/epic/tray-tooltip.js | 13 ++++++ src/static/tests/TrayTooltipSpec.js | 42 +++++++++++++++++++ src/static_src/scss/_gameboard.scss | 16 +++++++ src/static_src/scss/_tray.scss | 10 +++-- src/static_src/tests/TrayTooltipSpec.js | 42 +++++++++++++++++++ 5 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/apps/epic/static/apps/epic/tray-tooltip.js b/src/apps/epic/static/apps/epic/tray-tooltip.js index 34468a6..e48f51c 100644 --- a/src/apps/epic/static/apps/epic/tray-tooltip.js +++ b/src/apps/epic/static/apps/epic/tray-tooltip.js @@ -22,6 +22,7 @@ var TrayTooltip = (function () { var _portal = null; var _activeTrig = null; + var _activeCell = null; // .tray-role-card / .tray-sig-card holding the tilt class var _activeKind = null; // "role" | "sig" var _infoData = []; var _infoIdx = 0; @@ -37,12 +38,20 @@ var TrayTooltip = (function () { _portal.classList.remove("active"); _portal.style.display = "none"; _portal.innerHTML = ""; + if (_activeCell) _activeCell.classList.remove("tt-active"); + _activeCell = null; _activeTrig = null; _activeKind = null; _infoData = []; _infoIdx = 0; } + // Find the .tray-role-card / .tray-sig-card ancestor of a trigger so we + // can keep its hover-tilt locked while the portal is open. + function _cellOf(triggerEl) { + return triggerEl.closest && triggerEl.closest(".tray-role-card, .tray-sig-card"); + } + function _position(triggerEl) { var triggerRect = triggerEl.getBoundingClientRect(); var halfW = _portal.offsetWidth / 2; @@ -69,6 +78,8 @@ var TrayTooltip = (function () { _portal.style.display = "block"; _activeTrig = triggerEl; _activeKind = "role"; + _activeCell = _cellOf(triggerEl); + if (_activeCell) _activeCell.classList.add("tt-active"); _position(triggerEl); } @@ -107,6 +118,8 @@ var TrayTooltip = (function () { _portal.style.display = "block"; _activeTrig = triggerEl; _activeKind = "sig"; + _activeCell = _cellOf(triggerEl); + if (_activeCell) _activeCell.classList.add("tt-active"); _portal.querySelector(".fyi-prev").addEventListener("click", function (e) { e.stopPropagation(); diff --git a/src/static/tests/TrayTooltipSpec.js b/src/static/tests/TrayTooltipSpec.js index e9eced0..82ded36 100644 --- a/src/static/tests/TrayTooltipSpec.js +++ b/src/static/tests/TrayTooltipSpec.js @@ -138,6 +138,35 @@ describe("TrayTooltip", () => { // Clamping — portal left stays inside the viewport // // ---------------------------------------------------------------------- // + // ---------------------------------------------------------------------- // + // .tt-active on the cell — tilt persistence // + // ---------------------------------------------------------------------- // + // + // The role-card / sig-card hover-tilt is keyed off :hover + :focus on the + // cell. While the portal is open the pointer is typically OFF the cell + // (hovering the portal itself), so :hover drops; if the cell never + // received focus, :focus is also absent and the tilt reverts even though + // the tooltip is still active. Solution: TrayTooltip adds .tt-active to + // the cell while its tooltip is open and removes it on _hide. SCSS + // includes .tt-active in the tilt selector list. + + describe(".tt-active class on the cell", () => { + it("is added to the role-card cell on mouseenter and removed on hide", () => { + img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(cell.classList.contains("tt-active")).toBe(true); + + // outer beforeEach already stubs img.getBoundingClientRect; just + // pin the portal so the union test is deterministic. + spyOn(portal, "getBoundingClientRect").and.returnValue({ + left: 0, right: 10, top: 0, bottom: 10, width: 10, height: 10, + }); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, clientX: 9999, clientY: 9999, + })); + expect(cell.classList.contains("tt-active")).toBe(false); + }); + }); + // ---------------------------------------------------------------------- // // Phase 2 — sig-card branch // // ---------------------------------------------------------------------- // @@ -249,6 +278,19 @@ describe("TrayTooltip", () => { expect(portal.classList.contains("active")).toBe(true); }); + it("adds .tt-active to the sig cell on hover and removes it on _hide", () => { + sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(sigCell.classList.contains("tt-active")).toBe(true); + + spyOn(portal, "getBoundingClientRect").and.returnValue({ + left: 0, right: 10, top: 0, bottom: 10, width: 10, height: 10, + }); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, clientX: 9999, clientY: 9999, + })); + expect(sigCell.classList.contains("tt-active")).toBe(false); + }); + it("clears portal when pointer leaves trigger + portal + btn rect union", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); spyOn(portal, "getBoundingClientRect").and.returnValue({ diff --git a/src/static_src/scss/_gameboard.scss b/src/static_src/scss/_gameboard.scss index 122dd0a..b3b5609 100644 --- a/src/static_src/scss/_gameboard.scss +++ b/src/static_src/scss/_gameboard.scss @@ -138,6 +138,22 @@ body.page-gameboard { .btn { margin: 0; } } + // Tray sig-card tooltip (Phase 2) — PRV / NXT btns pinned to the bottom + // corners of the portal, 1rem outside the panel so the btn centres land + // exactly on the corners. The shared @stat-block-shared mixin in + // _card-deck.scss already does this for fan / sig / sea contexts; the + // portal isn't covered by that mixin so we re-state the rules here. + .fyi-prev, + .fyi-next { + display: inline-flex; + position: absolute; + bottom: -1rem; + margin: 0; + z-index: 70; + } + .fyi-prev { left: -1rem; } + .fyi-next { right: -1rem; } + &.active { display: block; } } diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss index e5a595f..8f8709e 100644 --- a/src/static_src/scss/_tray.scss +++ b/src/static_src/scss/_tray.scss @@ -152,9 +152,12 @@ $tray-bevel: 0.3rem; // inner bevel ring; grid must sit inside this } // Hover/touch tilts the scrawl 7° counter-clockwise; :focus persists the - // tilt after a click (cell receives tabindex="0" from tray.js). + // tilt after a click (cell receives tabindex="0" from tray.js); .tt-active + // (set by TrayTooltip while the portal is open for this cell) keeps the + // tilt while the user is hovering the portal itself rather than the cell. &:hover > img, - &:focus > img { + &:focus > img, + &.tt-active > img { rotate: -7deg; } @@ -184,7 +187,8 @@ $tray-bevel: 0.3rem; // inner bevel ring; grid must sit inside this transition: rotate 0.25s ease; } &:hover > .sig-stage-card, - &:focus > .sig-stage-card { + &:focus > .sig-stage-card, + &.tt-active > .sig-stage-card { rotate: 7deg; } } diff --git a/src/static_src/tests/TrayTooltipSpec.js b/src/static_src/tests/TrayTooltipSpec.js index e9eced0..82ded36 100644 --- a/src/static_src/tests/TrayTooltipSpec.js +++ b/src/static_src/tests/TrayTooltipSpec.js @@ -138,6 +138,35 @@ describe("TrayTooltip", () => { // Clamping — portal left stays inside the viewport // // ---------------------------------------------------------------------- // + // ---------------------------------------------------------------------- // + // .tt-active on the cell — tilt persistence // + // ---------------------------------------------------------------------- // + // + // The role-card / sig-card hover-tilt is keyed off :hover + :focus on the + // cell. While the portal is open the pointer is typically OFF the cell + // (hovering the portal itself), so :hover drops; if the cell never + // received focus, :focus is also absent and the tilt reverts even though + // the tooltip is still active. Solution: TrayTooltip adds .tt-active to + // the cell while its tooltip is open and removes it on _hide. SCSS + // includes .tt-active in the tilt selector list. + + describe(".tt-active class on the cell", () => { + it("is added to the role-card cell on mouseenter and removed on hide", () => { + img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(cell.classList.contains("tt-active")).toBe(true); + + // outer beforeEach already stubs img.getBoundingClientRect; just + // pin the portal so the union test is deterministic. + spyOn(portal, "getBoundingClientRect").and.returnValue({ + left: 0, right: 10, top: 0, bottom: 10, width: 10, height: 10, + }); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, clientX: 9999, clientY: 9999, + })); + expect(cell.classList.contains("tt-active")).toBe(false); + }); + }); + // ---------------------------------------------------------------------- // // Phase 2 — sig-card branch // // ---------------------------------------------------------------------- // @@ -249,6 +278,19 @@ describe("TrayTooltip", () => { expect(portal.classList.contains("active")).toBe(true); }); + it("adds .tt-active to the sig cell on hover and removes it on _hide", () => { + sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(sigCell.classList.contains("tt-active")).toBe(true); + + spyOn(portal, "getBoundingClientRect").and.returnValue({ + left: 0, right: 10, top: 0, bottom: 10, width: 10, height: 10, + }); + document.dispatchEvent(new MouseEvent("mousemove", { + bubbles: true, clientX: 9999, clientY: 9999, + })); + expect(sigCell.classList.contains("tt-active")).toBe(false); + }); + it("clears portal when pointer leaves trigger + portal + btn rect union", () => { sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); spyOn(portal, "getBoundingClientRect").and.returnValue({