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

@@ -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

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