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 = + '

Player

' + + '

[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 = + '

Player

' + + '

[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 %}