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

@@ -21,6 +21,7 @@
<script src="Spec.js"></script>
<script src="RoleSelectSpec.js"></script>
<script src="TraySpec.js"></script>
<script src="TrayTooltipSpec.js"></script>
<script src="SigSelectSpec.js"></script>
<script src="SeaDealSpec.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/role-select.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/sea.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");
});
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);
});
});
});

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
});
});
});