tray cards: shadow, hover-tilt w. focus persistence, role-card tooltip — TDD

- _tray.scss: drop-shadow on cell child elements (img → filter:drop-shadow so the silhouette is the shadow caster, div → box-shadow); 7° hover-tilt on .tray-role-card > img (-7°) and .tray-sig-card > .sig-stage-card (+7° via the standalone `rotate` property so the existing -5° baseline transform composes); :focus persists the tilt after click; cursor: pointer
- tray.js: set tabIndex=0 on placeCard's role cell + on template-rendered .tray-role-card / .tray-sig-card cells at init() so :focus latches the hover state; clear tabindex in reset() for Jasmine afterEach
- TraySpec: 4 new specs covering placeCard tabindex, reset cleanup, init-time tabindex on template-rendered sig & role cards, no-tabindex on bare cells
- New tray-tooltip.js (#id_tooltip_portal) — Phase 1 of the apps.tooltips integration: hovering .tray-role-card > img copies its sibling .tt's innerHTML into the page-root portal, anchors above/below the trigger, & clamps to the viewport horizontally; mousemove outside the union of [trigger, portal] rects clears the portal (Game-Kit pattern, no btns)
- room.html: #id_tooltip_portal mounted at room-page root (outside tray's overflow:hidden); .tt block rendered inline inside .tray-role-card via {% tooltip %} templatetag w. title=role display name & description="[Placeholder description]"
- epic/views.py: my_tray_role_tooltip context dict ({title, description}) keyed off the seated role
- TrayTooltipSpec: 8 specs covering portal population, .active class, sibling-.tt fallback, viewport-edge clamp left/right, and union-rect mouseleave
- 2 FTs in test_component_tray_tooltip.py: hover role img → portal title=Player + description=Placeholder; mouseleave → portal clears

Phase 2 (sig-card tooltip mirroring #id_fan_fyi_panel via a DRY refactor) deferred per plan.

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 18:40:10 -04:00
parent 75fcc5b34d
commit 08243d109d
12 changed files with 849 additions and 10 deletions

View File

@@ -0,0 +1,125 @@
// ── 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.
//
// 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.
//
// ─────────────────────────────────────────────────────────────────────────────
var TrayTooltip = (function () {
var _portal = null;
var _activeTrig = null;
var _onDocMove = null;
function _inRect(x, y, r) {
return x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
function _hide() {
if (!_portal) return;
_portal.classList.remove("active");
_portal.style.display = "none";
_portal.innerHTML = "";
_activeTrig = null;
}
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.
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";
// 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) {
_portal.style.top = Math.round(triggerRect.bottom) + "px";
_portal.style.transform = "translate(-50%, 0.5rem)";
} else {
_portal.style.top = Math.round(triggerRect.top) + "px";
_portal.style.transform = "translate(-50%, calc(-100% - 0.5rem))";
}
}
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) {
img.addEventListener("mouseenter", function () {
if (_activeTrig === img) return;
_show(img);
});
});
}
function init() {
_portal = document.getElementById("id_tooltip_portal");
if (!_portal) return;
_portal.style.display = "none";
_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;
}
_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),
};
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();
_onDocMove = null;
_observer = null;
_hide();
_portal = null;
}
return { init: init, reset: reset };
})();
if (typeof document !== "undefined") {
document.addEventListener("DOMContentLoaded", function () {
TrayTooltip.init();
});
}

View File

@@ -53,20 +53,34 @@ var Tray = (function () {
// Compute the square cell size from the tray's interior dimension and set
// --tray-cell-size on #id_tray so SCSS grid tracks pick it up.
// Portrait: divide height / 8. Landscape: divide width / 8.
// The tray's bevel ring (--tray-bevel, an inset box-shadow) eats into the
// felt area but is invisible to clientHeight/clientWidth — subtract it
// explicitly on both axes so cells stay square inside the bevel.
// In portrait the tray may be display:none; we show it with visibility:hidden
// briefly so clientHeight returns a real value, then restore display:none.
function _bevelPx() {
if (!_tray) return 0;
var raw = getComputedStyle(_tray).getPropertyValue('--tray-bevel').trim();
if (!raw) return 0;
if (raw.endsWith('rem')) {
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return parseFloat(raw) * rem;
}
return parseFloat(raw) || 0;
}
function _computeCellSize() {
if (!_tray) return;
var size;
var bevel = _bevelPx();
if (_isLandscape()) {
size = Math.floor(_tray.clientWidth / 8);
size = Math.floor((_tray.clientWidth - 2 * bevel) / 8);
} else {
var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
if (wasHidden) {
_tray.style.visibility = 'hidden';
_tray.style.display = 'grid';
}
size = Math.floor(_tray.clientHeight / 8);
size = Math.floor((_tray.clientHeight - 2 * bevel) / 8);
if (wasHidden) {
_tray.style.display = 'none';
_tray.style.visibility = '';
@@ -252,6 +266,7 @@ var Tray = (function () {
if (!firstCell) { if (onComplete) onComplete(); return; }
firstCell.classList.add('tray-role-card');
firstCell.tabIndex = 0; // enable :focus persistence for the hover-tilt
firstCell.dataset.role = roleCode;
firstCell.textContent = '';
if (_roleIconsUrl) {
@@ -342,6 +357,16 @@ var Tray = (function () {
_roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
if (!_btn) return;
// Make occupied tray cells focusable so :focus persists the hover-tilt
// after a click. Template-rendered .tray-sig-card / .tray-role-card
// cells need this at init; cells assigned later (e.g. placeCard) set
// tabIndex inline at assignment time.
if (_grid) {
_grid.querySelectorAll('.tray-role-card, .tray-sig-card').forEach(function (cell) {
cell.tabIndex = 0;
});
}
if (_isLandscape()) {
// Show tray before measuring so offsetHeight includes it.
if (_tray) _tray.style.display = 'grid';
@@ -494,6 +519,7 @@ var Tray = (function () {
if (_grid) {
_grid.querySelectorAll('.tray-cell').forEach(function (el) {
el.classList.remove('tray-role-card', 'arc-in');
el.removeAttribute('tabindex');
el.textContent = '';
delete el.dataset.role;
});

View File

@@ -287,6 +287,13 @@ def _role_select_context(room, user):
"starter_roles": starter_roles,
"assigned_seats": assigned_seats,
"my_tray_role": _my_role,
"my_tray_role_tooltip": (
{
"title": _ROLE_SCRAWL_NAMES.get(_my_role, ""),
"description": "[Placeholder description]",
}
if _my_role else None
),
"my_tray_scrawl_static_path": (
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
if _my_role else None