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,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()))