diff --git a/src/apps/epic/static/apps/epic/tray-tooltip.js b/src/apps/epic/static/apps/epic/tray-tooltip.js
new file mode 100644
index 0000000..d20ba74
--- /dev/null
+++ b/src/apps/epic/static/apps/epic/tray-tooltip.js
@@ -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();
+ });
+}
diff --git a/src/apps/epic/static/apps/epic/tray.js b/src/apps/epic/static/apps/epic/tray.js
index 3cce5c0..6db925b 100644
--- a/src/apps/epic/static/apps/epic/tray.js
+++ b/src/apps/epic/static/apps/epic/tray.js
@@ -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;
});
diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py
index 4602d6d..5f53fc2 100644
--- a/src/apps/epic/views.py
+++ b/src/apps/epic/views.py
@@ -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
diff --git a/src/functional_tests/test_component_tray_tooltip.py b/src/functional_tests/test_component_tray_tooltip.py
new file mode 100644
index 0000000..7370310
--- /dev/null
+++ b/src/functional_tests/test_component_tray_tooltip.py
@@ -0,0 +1,163 @@
+"""
+Component FT — tray tooltip (Phase 1: role-card img).
+
+Hovering the tray's role-card 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()))
diff --git a/src/static/tests/SpecRunner.html b/src/static/tests/SpecRunner.html
index 027f783..5f38d99 100644
--- a/src/static/tests/SpecRunner.html
+++ b/src/static/tests/SpecRunner.html
@@ -21,6 +21,7 @@
+
@@ -34,6 +35,7 @@
+
diff --git a/src/static/tests/TraySpec.js b/src/static/tests/TraySpec.js
index 2148403..1e876e1 100644
--- a/src/static/tests/TraySpec.js
+++ b/src/static/tests/TraySpec.js
@@ -470,6 +470,11 @@ describe("Tray", () => {
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", () => {
Tray.placeCard("PC", null);
expect(grid.children.length).toBe(8);
@@ -518,5 +523,57 @@ describe("Tray", () => {
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
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);
+ });
});
});
diff --git a/src/static/tests/TrayTooltipSpec.js b/src/static/tests/TrayTooltipSpec.js
new file mode 100644
index 0000000..27cd471
--- /dev/null
+++ b/src/static/tests/TrayTooltipSpec.js
@@ -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 =
+ '
[Placeholder description]
'; + + 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 + }); + }); +}); diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss index 30faabc..e5a595f 100644 --- a/src/static_src/scss/_tray.scss +++ b/src/static_src/scss/_tray.scss @@ -23,6 +23,7 @@ $handle-rect-w: 10000px; $handle-rect-h: 72px; $handle-exposed: 48px; $handle-r: 1rem; +$tray-bevel: 0.3rem; // inner bevel ring; grid must sit inside this #id_tray_wrap.role-select-phase { #id_tray_handle { visibility: hidden; pointer-events: none; } @@ -128,9 +129,18 @@ $handle-r: 1rem; } .tray-role-card { - padding: 0; + padding: 0.5rem; // breathing room around role art (post-bleed-trim) overflow: hidden; 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 { display: block; @@ -138,7 +148,14 @@ $handle-r: 1rem; height: 100%; object-fit: cover; 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. @@ -156,9 +173,19 @@ $handle-r: 1rem; display: flex; align-items: center; justify-content: center; + cursor: pointer; + &:focus { outline: none; } .sig-stage-card.sea-sig-card { --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 { + --tray-bevel: #{$tray-bevel}; // exposed to JS via getComputedStyle for cell-size math flex: 1; min-width: 0; 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-top: 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: -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(var(--quiUser), 0.5), // left wall depth (hue) 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-bottom: 2px dotted rgba(var(--priUser), 0.35); 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 ───────────────────────────────────────── @@ -285,7 +328,7 @@ $handle-r: 1rem; box-shadow: 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(var(--quaUser), 0.5), // bottom wall depth (hue) 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); // Anchor grid to the handle-side (bottom) of the tray so the first row // 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; - bottom: 0; - left: 0; + bottom: var(--tray-bevel, 0.3rem); + left: var(--tray-bevel, 0.3rem); } // In landscape the first row sits at the bottom; border-top divides it from diff --git a/src/static_src/tests/SpecRunner.html b/src/static_src/tests/SpecRunner.html index 027f783..5f38d99 100644 --- a/src/static_src/tests/SpecRunner.html +++ b/src/static_src/tests/SpecRunner.html @@ -21,6 +21,7 @@ + @@ -34,6 +35,7 @@ + diff --git a/src/static_src/tests/TraySpec.js b/src/static_src/tests/TraySpec.js index 2148403..1e876e1 100644 --- a/src/static_src/tests/TraySpec.js +++ b/src/static_src/tests/TraySpec.js @@ -470,6 +470,11 @@ describe("Tray", () => { 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", () => { Tray.placeCard("PC", null); expect(grid.children.length).toBe(8); @@ -518,5 +523,57 @@ describe("Tray", () => { expect(firstCell.classList.contains("tray-role-card")).toBe(false); 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); + }); }); }); diff --git a/src/static_src/tests/TrayTooltipSpec.js b/src/static_src/tests/TrayTooltipSpec.js new file mode 100644 index 0000000..27cd471 --- /dev/null +++ b/src/static_src/tests/TrayTooltipSpec.js @@ -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 = + '[Placeholder description]
'; + + 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 + }); + }); +}); diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 27ef08a..1f1c3ae 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -1,5 +1,5 @@ {% extends "core/base.html" %} -{% load static %} +{% load static tooltip_tags %} {% block title_text %}Gameboard{% endblock title_text %} {% block header_text %}Gameroom{% endblock header_text %} @@ -101,9 +101,15 @@ - + {% 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 %} + + {% endif %} {% include "apps/gameboard/_partials/_room_gear.html" %} {% endblock content %} @@ -117,4 +123,5 @@ + {% endblock scripts %}