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 // 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. // --tray-cell-size on #id_tray so SCSS grid tracks pick it up.
// Portrait: divide height / 8. Landscape: divide width / 8. // 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 // 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. // 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() { function _computeCellSize() {
if (!_tray) return; if (!_tray) return;
var size; var size;
var bevel = _bevelPx();
if (_isLandscape()) { if (_isLandscape()) {
size = Math.floor(_tray.clientWidth / 8); size = Math.floor((_tray.clientWidth - 2 * bevel) / 8);
} else { } else {
var wasHidden = (_tray.style.display === 'none' || !_tray.style.display); var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
if (wasHidden) { if (wasHidden) {
_tray.style.visibility = 'hidden'; _tray.style.visibility = 'hidden';
_tray.style.display = 'grid'; _tray.style.display = 'grid';
} }
size = Math.floor(_tray.clientHeight / 8); size = Math.floor((_tray.clientHeight - 2 * bevel) / 8);
if (wasHidden) { if (wasHidden) {
_tray.style.display = 'none'; _tray.style.display = 'none';
_tray.style.visibility = ''; _tray.style.visibility = '';
@@ -252,6 +266,7 @@ var Tray = (function () {
if (!firstCell) { if (onComplete) onComplete(); return; } if (!firstCell) { if (onComplete) onComplete(); return; }
firstCell.classList.add('tray-role-card'); firstCell.classList.add('tray-role-card');
firstCell.tabIndex = 0; // enable :focus persistence for the hover-tilt
firstCell.dataset.role = roleCode; firstCell.dataset.role = roleCode;
firstCell.textContent = ''; firstCell.textContent = '';
if (_roleIconsUrl) { if (_roleIconsUrl) {
@@ -342,6 +357,16 @@ var Tray = (function () {
_roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null; _roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
if (!_btn) return; 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()) { if (_isLandscape()) {
// Show tray before measuring so offsetHeight includes it. // Show tray before measuring so offsetHeight includes it.
if (_tray) _tray.style.display = 'grid'; if (_tray) _tray.style.display = 'grid';
@@ -494,6 +519,7 @@ var Tray = (function () {
if (_grid) { if (_grid) {
_grid.querySelectorAll('.tray-cell').forEach(function (el) { _grid.querySelectorAll('.tray-cell').forEach(function (el) {
el.classList.remove('tray-role-card', 'arc-in'); el.classList.remove('tray-role-card', 'arc-in');
el.removeAttribute('tabindex');
el.textContent = ''; el.textContent = '';
delete el.dataset.role; delete el.dataset.role;
}); });

View File

@@ -287,6 +287,13 @@ def _role_select_context(room, user):
"starter_roles": starter_roles, "starter_roles": starter_roles,
"assigned_seats": assigned_seats, "assigned_seats": assigned_seats,
"my_tray_role": _my_role, "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": ( "my_tray_scrawl_static_path": (
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg" f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
if _my_role else None if _my_role else None

View File

@@ -0,0 +1,163 @@
"""
Component FT — tray tooltip (Phase 1: role-card img).
Hovering the tray's role-card <img> populates #id_tooltip_portal with the
role's display name as the title and a description, matching the existing
Game-Kit tooltip portal pattern (apps.tooltips). Mousing well off the card
clears the portal.
Phase 2 (sig-card tooltip mirroring #id_fan_fyi_panel) lives in a separate
test class and is not yet covered here.
"""
from selenium.webdriver.common.action_chains import ActionChains
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.lyric.models import User
def _equip_earthman_deck(user):
deck, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman", "card_count": 106, "is_default": True},
)
user.equipped_deck = deck
user.save(update_fields=["equipped_deck"])
def _fill_room_via_orm(room, emails):
for i, email in enumerate(emails, start=1):
gamer, _ = User.objects.get_or_create(email=email)
slot = room.gate_slots.get(slot_number=i)
slot.gamer = gamer
slot.status = GateSlot.FILLED
slot.save()
room.gate_status = Room.OPEN
room.save()
class TrayRoleCardTooltipTest(FunctionalTest):
EMAILS = [
"tt1@test.io", "tt2@test.io", "tt3@test.io",
"tt4@test.io", "tt5@test.io", "tt6@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_role(self, role="PC"):
"""Room in ROLE_SELECT with founder seated and assigned `role`."""
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
_equip_earthman_deck(founder)
room = Room.objects.create(name="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"):
TableSeat.objects.create(
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
role=(role if slot.slot_number == 1 else None),
)
return room
def _open_tray(self):
# Tray is closed off-screen by default. Open it AND wait for the slide
# transition (left: 736px → 0px, ~0.35s) to land before hovering;
# otherwise getBoundingClientRect returns the closed-state rect that
# ActionChains sees as off-viewport.
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,
"tray wrap should slide to ~0",
)
)
def _hover(self, element):
ActionChains(self.browser).move_to_element(element).perform()
def _move_far_away(self):
# Synthesize a mousemove event well outside the trigger+portal union.
# ActionChains origins require a visible element; firing the event
# directly via JS sidesteps that and exercises the same listener path.
self.browser.execute_script(
"document.dispatchEvent(new MouseEvent('mousemove',"
"{bubbles:true, clientX:9999, clientY:9999}));"
)
# ------------------------------------------------------------------ #
# T1 — Hover role img → portal shows title (role display name) #
# + description ("[Placeholder description]"). #
# ------------------------------------------------------------------ #
def test_hover_role_img_populates_tooltip_portal(self):
room = self._make_room_with_role(role="PC")
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
# The role card is rendered server-side as the first tray cell.
role_img = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card img"
)
)
# Portal exists at page root, hidden initially.
portal = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_tooltip_portal")
)
self.assertFalse(
portal.is_displayed(),
"tooltip portal should start hidden before any hover",
)
# Hover the role art → portal becomes visible with title + description.
self._hover(role_img)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
title = portal.find_element(By.CSS_SELECTOR, ".tt-title").text
self.assertEqual(title.strip(), "Player") # PC → "Player"
description = portal.find_element(By.CSS_SELECTOR, ".tt-description").text
self.assertEqual(description.strip(), "[Placeholder description]")
# ------------------------------------------------------------------ #
# T2 — Mousing well off the card clears the portal. #
# ------------------------------------------------------------------ #
def test_mouseleave_clears_tooltip_portal(self):
room = self._make_room_with_role(role="EC")
self.create_pre_authenticated_session(self.EMAILS[0])
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
self._open_tray()
role_img = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card img"
)
)
portal = self.browser.find_element(By.ID, "id_tooltip_portal")
self._hover(role_img)
self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
# Title reflects the EC mapping.
self.assertEqual(
portal.find_element(By.CSS_SELECTOR, ".tt-title").text.strip(),
"Economist",
)
self._move_far_away()
self.wait_for(lambda: self.assertFalse(portal.is_displayed()))

View File

@@ -21,6 +21,7 @@
<script src="Spec.js"></script> <script src="Spec.js"></script>
<script src="RoleSelectSpec.js"></script> <script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script> <script src="TraySpec.js"></script>
<script src="TrayTooltipSpec.js"></script>
<script src="SigSelectSpec.js"></script> <script src="SigSelectSpec.js"></script>
<script src="SeaDealSpec.js"></script> <script src="SeaDealSpec.js"></script>
<script src="FanStageSpec.js"></script> <script src="FanStageSpec.js"></script>
@@ -34,6 +35,7 @@
<script src="/static/apps/epic/stage-card.js"></script> <script src="/static/apps/epic/stage-card.js"></script>
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script> <script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/tray-tooltip.js"></script>
<script src="/static/apps/epic/sig-select.js"></script> <script src="/static/apps/epic/sig-select.js"></script>
<script src="/static/apps/epic/sea.js"></script> <script src="/static/apps/epic/sea.js"></script>
<script src="/static/apps/gameboard/game-kit.js"></script> <script src="/static/apps/gameboard/game-kit.js"></script>

View File

@@ -470,6 +470,11 @@ describe("Tray", () => {
expect(firstCell.dataset.role).toBe("NC"); expect(firstCell.dataset.role).toBe("NC");
}); });
it("sets tabIndex=0 on the placed cell so :focus persists the hover-tilt", () => {
Tray.placeCard("PC", null);
expect(firstCell.tabIndex).toBe(0);
});
it("grid cell count stays at 8", () => { it("grid cell count stays at 8", () => {
Tray.placeCard("PC", null); Tray.placeCard("PC", null);
expect(grid.children.length).toBe(8); expect(grid.children.length).toBe(8);
@@ -518,5 +523,57 @@ describe("Tray", () => {
expect(firstCell.classList.contains("tray-role-card")).toBe(false); expect(firstCell.classList.contains("tray-role-card")).toBe(false);
expect(firstCell.dataset.role).toBeUndefined(); expect(firstCell.dataset.role).toBeUndefined();
}); });
it("reset() also clears tabindex from the placed cell", () => {
Tray.placeCard("PC", null);
Tray.reset();
expect(firstCell.hasAttribute("tabindex")).toBe(false);
});
});
// ---------------------------------------------------------------------- //
// init() — focusable tray cards //
// ---------------------------------------------------------------------- //
//
// .tray-sig-card is rendered server-side by room.html when the seat has a
// significator; .tray-role-card may be too if the seat already has a role.
// init() must mark these cells tabbable so the SCSS :focus rule persists
// the hover-tilt animation after the user clicks the card.
describe("init() — focusable tray cards", () => {
let grid;
beforeEach(() => {
grid = document.createElement("div");
grid.id = "id_tray_grid";
document.body.appendChild(grid);
});
afterEach(() => grid.remove());
function _addCell(extraClass) {
const cell = document.createElement("div");
cell.className = "tray-cell" + (extraClass ? " " + extraClass : "");
grid.appendChild(cell);
return cell;
}
it("sets tabIndex=0 on a template-rendered .tray-sig-card", () => {
const sigCell = _addCell("tray-sig-card");
Tray.init();
expect(sigCell.tabIndex).toBe(0);
});
it("sets tabIndex=0 on a template-rendered .tray-role-card", () => {
const roleCell = _addCell("tray-role-card");
Tray.init();
expect(roleCell.tabIndex).toBe(0);
});
it("does NOT set tabindex on bare .tray-cell elements", () => {
const empty = _addCell();
Tray.init();
expect(empty.hasAttribute("tabindex")).toBe(false);
});
}); });
}); });

View File

@@ -0,0 +1,174 @@
// ── TrayTooltipSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for TrayTooltip — apps.tooltips portal-population on hover of a
// tray-cell child element. Phase 1 covers .tray-role-card > img only; Phase 2
// (sig-card with PRV/NXT pager) is a separate sprint.
//
// Public API under test:
// TrayTooltip.init() — binds document hover/move listeners
// TrayTooltip.reset() — detaches listeners + hides portal (afterEach)
//
// DOM contract assumed by the module:
// #id_tooltip_portal — fixed portal at page root, hidden by default
// #id_tray_grid — tray's grid container
// .tray-role-card — cell carrying the role art
// > img — hover trigger
// > .tt — server-rendered tooltip content (.tt-title, .tt-description, …)
//
// Behaviour:
// * Hovering the img copies its sibling .tt's innerHTML into the portal,
// marks the portal .active, and shows it (display: block).
// * Pointer leaving the union of [trigger, portal] rects clears the portal.
// * Position is clamped to the viewport: portal.left ≥ halfPortalW + 8;
// portal.left ≤ viewportW halfPortalW 8.
//
// ─────────────────────────────────────────────────────────────────────────────
describe("TrayTooltip", () => {
let portal, grid, cell, img, tt;
beforeEach(() => {
portal = document.createElement("div");
portal.id = "id_tooltip_portal";
portal.style.display = "none";
document.body.appendChild(portal);
grid = document.createElement("div");
grid.id = "id_tray_grid";
cell = document.createElement("div");
cell.className = "tray-cell tray-role-card";
cell.dataset.role = "PC";
img = document.createElement("img");
img.alt = "PC";
tt = document.createElement("div");
tt.className = "tt";
tt.style.display = "none";
tt.innerHTML =
'<h4 class="tt-title">Player</h4>' +
'<p class="tt-description">[Placeholder description]</p>';
cell.appendChild(img);
cell.appendChild(tt);
grid.appendChild(cell);
document.body.appendChild(grid);
// Force a known geometry so clamp math has something to clamp against.
// (jsdom-style env: getBoundingClientRect() returns zeroes by default;
// override via stubs.)
spyOn(img, "getBoundingClientRect").and.returnValue({
left: 100, right: 148, top: 200, bottom: 248, width: 48, height: 48,
});
TrayTooltip.init();
});
afterEach(() => {
TrayTooltip.reset();
portal.remove();
grid.remove();
});
// ---------------------------------------------------------------------- //
// Hover → portal becomes active //
// ---------------------------------------------------------------------- //
describe("on mouseenter of role-card img", () => {
it("copies .tt innerHTML into the portal", () => {
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.innerHTML).toContain('class="tt-title"');
expect(portal.innerHTML).toContain("Player");
expect(portal.innerHTML).toContain("[Placeholder description]");
});
it("marks the portal .active and shows it", () => {
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.classList.contains("active")).toBe(true);
expect(portal.style.display).not.toBe("none");
});
it("does nothing if there is no sibling .tt", () => {
tt.remove();
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.classList.contains("active")).toBe(false);
expect(portal.style.display).toBe("none");
});
});
// ---------------------------------------------------------------------- //
// Mouseleave (extended) → portal clears //
// ---------------------------------------------------------------------- //
describe("on pointer leaving the trigger+portal union", () => {
beforeEach(() => {
// Pin the portal to a known location after activation so the
// mousemove union test is deterministic.
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
spyOn(portal, "getBoundingClientRect").and.returnValue({
left: 80, right: 280, top: 120, bottom: 200, width: 200, height: 80,
});
});
it("clears the portal when the pointer is well outside both rects", () => {
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 500, clientY: 500,
}));
expect(portal.classList.contains("active")).toBe(false);
expect(portal.style.display).toBe("none");
});
it("keeps the portal active while the pointer is inside the trigger", () => {
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 120, clientY: 220,
}));
expect(portal.classList.contains("active")).toBe(true);
});
it("keeps the portal active while the pointer is inside the portal", () => {
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 180, clientY: 160,
}));
expect(portal.classList.contains("active")).toBe(true);
});
});
// ---------------------------------------------------------------------- //
// Clamping — portal left stays inside the viewport //
// ---------------------------------------------------------------------- //
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.
img.getBoundingClientRect.and.returnValue({
left: 0, right: 48, top: 200, bottom: 248, width: 48, height: 48,
});
// Stub portal width so halfW is predictable AFTER innerHTML is set.
const origDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype, "offsetWidth"
);
Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
const left = parseFloat(portal.style.left);
expect(left).toBeGreaterThanOrEqual(108); // halfW(100) + 8
if (origDescriptor) Object.defineProperty(HTMLElement.prototype, "offsetWidth", origDescriptor);
});
it("clamps to the left when the trigger is near the right edge", () => {
const vw = window.innerWidth;
img.getBoundingClientRect.and.returnValue({
left: vw - 10, right: vw, top: 200, bottom: 248, width: 10, height: 48,
});
Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
const left = parseFloat(portal.style.left);
expect(left).toBeLessThanOrEqual(vw - 100 - 8); // viewport halfW 8
});
});
});

View File

@@ -23,6 +23,7 @@ $handle-rect-w: 10000px;
$handle-rect-h: 72px; $handle-rect-h: 72px;
$handle-exposed: 48px; $handle-exposed: 48px;
$handle-r: 1rem; $handle-r: 1rem;
$tray-bevel: 0.3rem; // inner bevel ring; grid must sit inside this
#id_tray_wrap.role-select-phase { #id_tray_wrap.role-select-phase {
#id_tray_handle { visibility: hidden; pointer-events: none; } #id_tray_handle { visibility: hidden; pointer-events: none; }
@@ -128,9 +129,18 @@ $handle-r: 1rem;
} }
.tray-role-card { .tray-role-card {
padding: 0; padding: 0.5rem; // breathing room around role art (post-bleed-trim)
overflow: hidden; overflow: hidden;
background: transparent; background: transparent;
// Dotted borders on .tray-cell would otherwise shrink the content box
// and push a `width/height: 100%` img off-centre. Flex centring +
// sizing the img to the full grid track keeps it visually centred
// while preserving the dotted grid markings.
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:focus { outline: none; }
img { img {
display: block; display: block;
@@ -138,7 +148,14 @@ $handle-r: 1rem;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
transform: scale(1.4); // crop SVG's internal margins transition: rotate 0.25s ease;
}
// Hover/touch tilts the scrawl 7° counter-clockwise; :focus persists the
// tilt after a click (cell receives tabindex="0" from tray.js).
&:hover > img,
&:focus > img {
rotate: -7deg;
} }
// Cell stays static; only the scrawl image fades in. // Cell stays static; only the scrawl image fades in.
@@ -156,9 +173,19 @@ $handle-r: 1rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer;
&:focus { outline: none; }
.sig-stage-card.sea-sig-card { .sig-stage-card.sea-sig-card {
--sig-card-w: calc(var(--tray-cell-size, 48px) * 5 / 8); --sig-card-w: calc(var(--tray-cell-size, 48px) * 5 / 8);
// `rotate` is independent of `transform`, so the existing -5° baseline
// (set on .sig-stage-card.sea-sig-card via transform) is preserved and
// this rotates 7° clockwise on top of it.
transition: rotate 0.25s ease;
}
&:hover > .sig-stage-card,
&:focus > .sig-stage-card {
rotate: 7deg;
} }
} }
@@ -180,6 +207,7 @@ $handle-r: 1rem;
} }
#id_tray { #id_tray {
--tray-bevel: #{$tray-bevel}; // exposed to JS via getComputedStyle for cell-size math
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start margin-left: 0.5rem; // small gap so tray appears slightly off-screen on drag start
@@ -190,9 +218,10 @@ $handle-r: 1rem;
border-left:2.5rem solid rgba(var(--quaUser), 1); border-left:2.5rem solid rgba(var(--quaUser), 1);
border-top: 2.5rem solid rgba(var(--quaUser), 1); border-top: 2.5rem solid rgba(var(--quaUser), 1);
border-bottom: 2.5rem solid rgba(var(--quaUser), 1); border-bottom: 2.5rem solid rgba(var(--quaUser), 1);
padding: $tray-bevel; // inset grid inside the bevel ring on every felt-facing side
box-shadow: box-shadow:
-0.25rem 0 0.5rem rgba(0, 0, 0, 0.55), -0.25rem 0 0.5rem rgba(0, 0, 0, 0.55),
inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring at wall edge inset 0 0 0 $tray-bevel rgba(var(--quiUser), 0.45), // prominent bevel ring at wall edge
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth
inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quiUser), 0.5), // left wall depth (hue) inset 0.6rem 0 1.5rem -0.5rem rgba(var(--quiUser), 0.5), // left wall depth (hue)
inset 0 0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // top wall depth inset 0 0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // top wall depth
@@ -217,6 +246,20 @@ $handle-r: 1rem;
border-right: 2px dotted rgba(var(--priUser), 0.35); border-right: 2px dotted rgba(var(--priUser), 0.35);
border-bottom: 2px dotted rgba(var(--priUser), 0.35); border-bottom: 2px dotted rgba(var(--priUser), 0.35);
position: relative; position: relative;
// Whatever a cell holds (role-card img, sig stage card, future Celtic Cross
// / natus wheel / dice) gets a soft drop shadow to lift it off the felt.
// Applied to the child rather than the cell itself so the dotted grid
// borders stay shadow-free.
> * {
box-shadow: 1px 1px 5px rgba(0, 0, 0, 1);
}
// Img children (SVG/PNG with transparent regions): use filter drop-shadow
// instead so the shadow traces the rendered silhouette, not the SVG viewport.
> img {
box-shadow: none;
filter: drop-shadow(1px 1px 2px rgba(0, 0, 0, 1));
}
} }
// ─── Tray: landscape reorientation ───────────────────────────────────────── // ─── Tray: landscape reorientation ─────────────────────────────────────────
@@ -285,7 +328,7 @@ $handle-r: 1rem;
box-shadow: box-shadow:
0 0.25rem 0.5rem rgba(0, 0, 0, 0.55), 0 0.25rem 0.5rem rgba(0, 0, 0, 0.55),
inset 0 0 0 0.3rem rgba(var(--quiUser), 0.45), // prominent bevel ring inset 0 0 0 $tray-bevel rgba(var(--quiUser), 0.45), // prominent bevel ring
inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth inset 0 -0.6rem 1.5rem -0.5rem rgba(0, 0, 0, 1), // bottom wall depth
inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // bottom wall depth (hue) inset 0 -0.6rem 1.5rem -0.5rem rgba(var(--quaUser), 0.5), // bottom wall depth (hue)
inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth inset 0.6rem 0 1.5rem -0.5rem rgba(0, 0, 0, 1), // left wall depth
@@ -308,9 +351,11 @@ $handle-r: 1rem;
grid-auto-rows: var(--tray-cell-size, 48px); grid-auto-rows: var(--tray-cell-size, 48px);
// Anchor grid to the handle-side (bottom) of the tray so the first row // Anchor grid to the handle-side (bottom) of the tray so the first row
// is visible when partially open; additional rows grow upward. // is visible when partially open; additional rows grow upward.
// Inset by --tray-bevel so the grid sits inside the bevel ring rather
// than under it (matches the portrait padding inset of the same width).
position: absolute; position: absolute;
bottom: 0; bottom: var(--tray-bevel, 0.3rem);
left: 0; left: var(--tray-bevel, 0.3rem);
} }
// In landscape the first row sits at the bottom; border-top divides it from // In landscape the first row sits at the bottom; border-top divides it from

View File

@@ -21,6 +21,7 @@
<script src="Spec.js"></script> <script src="Spec.js"></script>
<script src="RoleSelectSpec.js"></script> <script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script> <script src="TraySpec.js"></script>
<script src="TrayTooltipSpec.js"></script>
<script src="SigSelectSpec.js"></script> <script src="SigSelectSpec.js"></script>
<script src="SeaDealSpec.js"></script> <script src="SeaDealSpec.js"></script>
<script src="FanStageSpec.js"></script> <script src="FanStageSpec.js"></script>
@@ -34,6 +35,7 @@
<script src="/static/apps/epic/stage-card.js"></script> <script src="/static/apps/epic/stage-card.js"></script>
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script> <script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/tray-tooltip.js"></script>
<script src="/static/apps/epic/sig-select.js"></script> <script src="/static/apps/epic/sig-select.js"></script>
<script src="/static/apps/epic/sea.js"></script> <script src="/static/apps/epic/sea.js"></script>
<script src="/static/apps/gameboard/game-kit.js"></script> <script src="/static/apps/gameboard/game-kit.js"></script>

View File

@@ -470,6 +470,11 @@ describe("Tray", () => {
expect(firstCell.dataset.role).toBe("NC"); expect(firstCell.dataset.role).toBe("NC");
}); });
it("sets tabIndex=0 on the placed cell so :focus persists the hover-tilt", () => {
Tray.placeCard("PC", null);
expect(firstCell.tabIndex).toBe(0);
});
it("grid cell count stays at 8", () => { it("grid cell count stays at 8", () => {
Tray.placeCard("PC", null); Tray.placeCard("PC", null);
expect(grid.children.length).toBe(8); expect(grid.children.length).toBe(8);
@@ -518,5 +523,57 @@ describe("Tray", () => {
expect(firstCell.classList.contains("tray-role-card")).toBe(false); expect(firstCell.classList.contains("tray-role-card")).toBe(false);
expect(firstCell.dataset.role).toBeUndefined(); expect(firstCell.dataset.role).toBeUndefined();
}); });
it("reset() also clears tabindex from the placed cell", () => {
Tray.placeCard("PC", null);
Tray.reset();
expect(firstCell.hasAttribute("tabindex")).toBe(false);
});
});
// ---------------------------------------------------------------------- //
// init() — focusable tray cards //
// ---------------------------------------------------------------------- //
//
// .tray-sig-card is rendered server-side by room.html when the seat has a
// significator; .tray-role-card may be too if the seat already has a role.
// init() must mark these cells tabbable so the SCSS :focus rule persists
// the hover-tilt animation after the user clicks the card.
describe("init() — focusable tray cards", () => {
let grid;
beforeEach(() => {
grid = document.createElement("div");
grid.id = "id_tray_grid";
document.body.appendChild(grid);
});
afterEach(() => grid.remove());
function _addCell(extraClass) {
const cell = document.createElement("div");
cell.className = "tray-cell" + (extraClass ? " " + extraClass : "");
grid.appendChild(cell);
return cell;
}
it("sets tabIndex=0 on a template-rendered .tray-sig-card", () => {
const sigCell = _addCell("tray-sig-card");
Tray.init();
expect(sigCell.tabIndex).toBe(0);
});
it("sets tabIndex=0 on a template-rendered .tray-role-card", () => {
const roleCell = _addCell("tray-role-card");
Tray.init();
expect(roleCell.tabIndex).toBe(0);
});
it("does NOT set tabindex on bare .tray-cell elements", () => {
const empty = _addCell();
Tray.init();
expect(empty.hasAttribute("tabindex")).toBe(false);
});
}); });
}); });

View File

@@ -0,0 +1,174 @@
// ── TrayTooltipSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for TrayTooltip — apps.tooltips portal-population on hover of a
// tray-cell child element. Phase 1 covers .tray-role-card > img only; Phase 2
// (sig-card with PRV/NXT pager) is a separate sprint.
//
// Public API under test:
// TrayTooltip.init() — binds document hover/move listeners
// TrayTooltip.reset() — detaches listeners + hides portal (afterEach)
//
// DOM contract assumed by the module:
// #id_tooltip_portal — fixed portal at page root, hidden by default
// #id_tray_grid — tray's grid container
// .tray-role-card — cell carrying the role art
// > img — hover trigger
// > .tt — server-rendered tooltip content (.tt-title, .tt-description, …)
//
// Behaviour:
// * Hovering the img copies its sibling .tt's innerHTML into the portal,
// marks the portal .active, and shows it (display: block).
// * Pointer leaving the union of [trigger, portal] rects clears the portal.
// * Position is clamped to the viewport: portal.left ≥ halfPortalW + 8;
// portal.left ≤ viewportW halfPortalW 8.
//
// ─────────────────────────────────────────────────────────────────────────────
describe("TrayTooltip", () => {
let portal, grid, cell, img, tt;
beforeEach(() => {
portal = document.createElement("div");
portal.id = "id_tooltip_portal";
portal.style.display = "none";
document.body.appendChild(portal);
grid = document.createElement("div");
grid.id = "id_tray_grid";
cell = document.createElement("div");
cell.className = "tray-cell tray-role-card";
cell.dataset.role = "PC";
img = document.createElement("img");
img.alt = "PC";
tt = document.createElement("div");
tt.className = "tt";
tt.style.display = "none";
tt.innerHTML =
'<h4 class="tt-title">Player</h4>' +
'<p class="tt-description">[Placeholder description]</p>';
cell.appendChild(img);
cell.appendChild(tt);
grid.appendChild(cell);
document.body.appendChild(grid);
// Force a known geometry so clamp math has something to clamp against.
// (jsdom-style env: getBoundingClientRect() returns zeroes by default;
// override via stubs.)
spyOn(img, "getBoundingClientRect").and.returnValue({
left: 100, right: 148, top: 200, bottom: 248, width: 48, height: 48,
});
TrayTooltip.init();
});
afterEach(() => {
TrayTooltip.reset();
portal.remove();
grid.remove();
});
// ---------------------------------------------------------------------- //
// Hover → portal becomes active //
// ---------------------------------------------------------------------- //
describe("on mouseenter of role-card img", () => {
it("copies .tt innerHTML into the portal", () => {
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.innerHTML).toContain('class="tt-title"');
expect(portal.innerHTML).toContain("Player");
expect(portal.innerHTML).toContain("[Placeholder description]");
});
it("marks the portal .active and shows it", () => {
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.classList.contains("active")).toBe(true);
expect(portal.style.display).not.toBe("none");
});
it("does nothing if there is no sibling .tt", () => {
tt.remove();
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(portal.classList.contains("active")).toBe(false);
expect(portal.style.display).toBe("none");
});
});
// ---------------------------------------------------------------------- //
// Mouseleave (extended) → portal clears //
// ---------------------------------------------------------------------- //
describe("on pointer leaving the trigger+portal union", () => {
beforeEach(() => {
// Pin the portal to a known location after activation so the
// mousemove union test is deterministic.
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
spyOn(portal, "getBoundingClientRect").and.returnValue({
left: 80, right: 280, top: 120, bottom: 200, width: 200, height: 80,
});
});
it("clears the portal when the pointer is well outside both rects", () => {
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 500, clientY: 500,
}));
expect(portal.classList.contains("active")).toBe(false);
expect(portal.style.display).toBe("none");
});
it("keeps the portal active while the pointer is inside the trigger", () => {
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 120, clientY: 220,
}));
expect(portal.classList.contains("active")).toBe(true);
});
it("keeps the portal active while the pointer is inside the portal", () => {
document.dispatchEvent(new MouseEvent("mousemove", {
bubbles: true, clientX: 180, clientY: 160,
}));
expect(portal.classList.contains("active")).toBe(true);
});
});
// ---------------------------------------------------------------------- //
// Clamping — portal left stays inside the viewport //
// ---------------------------------------------------------------------- //
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.
img.getBoundingClientRect.and.returnValue({
left: 0, right: 48, top: 200, bottom: 248, width: 48, height: 48,
});
// Stub portal width so halfW is predictable AFTER innerHTML is set.
const origDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype, "offsetWidth"
);
Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
const left = parseFloat(portal.style.left);
expect(left).toBeGreaterThanOrEqual(108); // halfW(100) + 8
if (origDescriptor) Object.defineProperty(HTMLElement.prototype, "offsetWidth", origDescriptor);
});
it("clamps to the left when the trigger is near the right edge", () => {
const vw = window.innerWidth;
img.getBoundingClientRect.and.returnValue({
left: vw - 10, right: vw, top: 200, bottom: 248, width: 10, height: 48,
});
Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });
img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
const left = parseFloat(portal.style.left);
expect(left).toBeLessThanOrEqual(vw - 100 - 8); // viewport halfW 8
});
});
});

View File

@@ -1,5 +1,5 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% load static %} {% load static tooltip_tags %}
{% block title_text %}Gameboard{% endblock title_text %} {% block title_text %}Gameboard{% endblock title_text %}
{% block header_text %}<span>Game</span>room{% endblock header_text %} {% block header_text %}<span>Game</span>room{% endblock header_text %}
@@ -101,9 +101,15 @@
<i class="fa-solid fa-dice-d20"></i> <i class="fa-solid fa-dice-d20"></i>
</button> </button>
</div> </div>
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}"></div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><div class="sig-stage-card sea-sig-card" aria-label="{{ my_tray_sig.name }}"><span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}</div></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div> <div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}">{% tooltip my_tray_role_tooltip %}</div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><div class="sig-stage-card sea-sig-card" aria-label="{{ my_tray_sig.name }}"><span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}</div></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
</div> </div>
{% endif %} {% 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). #}
{% if room.table_status %}
<div id="id_tooltip_portal" class="tt" style="display:none;"></div>
{% endif %}
{% include "apps/gameboard/_partials/_room_gear.html" %} {% include "apps/gameboard/_partials/_room_gear.html" %}
</div> </div>
{% endblock content %} {% endblock content %}
@@ -117,4 +123,5 @@
<script src="{% static 'apps/epic/sig-select.js' %}"></script> <script src="{% static 'apps/epic/sig-select.js' %}"></script>
<script src="{% static 'apps/epic/sea.js' %}"></script> <script src="{% static 'apps/epic/sea.js' %}"></script>
<script src="{% static 'apps/epic/tray.js' %}"></script> <script src="{% static 'apps/epic/tray.js' %}"></script>
<script src="{% static 'apps/epic/tray-tooltip.js' %}"></script>
{% endblock scripts %} {% endblock scripts %}