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 };