2026-03-29 23:39:03 -04:00
|
|
|
// ── TraySpec.js ───────────────────────────────────────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Unit specs for tray.js — the per-seat, per-room slide-out panel anchored
|
|
|
|
|
// to the right edge of the viewport.
|
|
|
|
|
//
|
|
|
|
|
// DOM contract assumed by the module:
|
|
|
|
|
// #id_tray_wrap — outermost container; JS sets style.left for positioning
|
|
|
|
|
// #id_tray_btn — the drawer-handle button
|
|
|
|
|
// #id_tray — the tray panel (hidden by default)
|
|
|
|
|
//
|
|
|
|
|
// Public API under test:
|
|
|
|
|
// Tray.init() — compute bounds, apply vertical bounds, attach listeners
|
|
|
|
|
// Tray.open() — reveal tray, animate wrap to minLeft
|
|
|
|
|
// Tray.close() — hide tray, animate wrap to maxLeft
|
|
|
|
|
// Tray.isOpen() — state predicate
|
|
|
|
|
// Tray.reset() — restore initial state (for afterEach)
|
|
|
|
|
//
|
|
|
|
|
// Drag model: tray follows pointer in real-time; position persists on release.
|
|
|
|
|
// Any leftward drag opens the tray.
|
|
|
|
|
// Drag > 10px suppresses the subsequent click event.
|
|
|
|
|
//
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
describe("Tray", () => {
|
|
|
|
|
let btn, tray, wrap;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
wrap = document.createElement("div");
|
|
|
|
|
wrap.id = "id_tray_wrap";
|
|
|
|
|
|
|
|
|
|
btn = document.createElement("button");
|
|
|
|
|
btn.id = "id_tray_btn";
|
|
|
|
|
|
|
|
|
|
tray = document.createElement("div");
|
|
|
|
|
tray.id = "id_tray";
|
|
|
|
|
tray.style.display = "none";
|
|
|
|
|
|
|
|
|
|
wrap.appendChild(btn);
|
|
|
|
|
document.body.appendChild(wrap);
|
|
|
|
|
document.body.appendChild(tray);
|
|
|
|
|
|
|
|
|
|
Tray._testSetLandscape(false); // force portrait regardless of window size
|
|
|
|
|
Tray.init();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
Tray.reset();
|
|
|
|
|
wrap.remove();
|
|
|
|
|
tray.remove();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// open() //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("open()", () => {
|
|
|
|
|
it("makes #id_tray visible", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
expect(tray.style.display).not.toBe("none");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds .open to #id_tray_btn", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
expect(btn.classList.contains("open")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sets wrap left to minLeft (0)", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
expect(wrap.style.left).toBe("0px");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calling open() twice does not duplicate .open", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
Tray.open();
|
|
|
|
|
const openCount = btn.className.split(" ").filter(c => c === "open").length;
|
|
|
|
|
expect(openCount).toBe(1);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// close() //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("close()", () => {
|
|
|
|
|
beforeEach(() => Tray.open());
|
|
|
|
|
|
|
|
|
|
it("hides #id_tray after slide + snap both complete", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
|
|
|
|
wrap.dispatchEvent(new Event("animationend"));
|
|
|
|
|
expect(tray.style.display).toBe("none");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds .snap to wrap after slide transition completes", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
|
|
|
|
expect(wrap.classList.contains("snap")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .snap from wrap once animationend fires", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
|
|
|
|
wrap.dispatchEvent(new Event("animationend"));
|
|
|
|
|
expect(wrap.classList.contains("snap")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .open from #id_tray_btn", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
expect(btn.classList.contains("open")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sets wrap left to maxLeft", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not throw if already closed", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
expect(() => Tray.close()).not.toThrow();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// isOpen() //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("isOpen()", () => {
|
|
|
|
|
it("returns false by default", () => {
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns true after open()", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns false after close()", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
Tray.close();
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// Click when closed — wobble wrap, do not open //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("clicking btn when closed", () => {
|
|
|
|
|
it("adds .wobble to wrap", () => {
|
|
|
|
|
btn.click();
|
|
|
|
|
expect(wrap.classList.contains("wobble")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not open the tray", () => {
|
|
|
|
|
btn.click();
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .wobble once animationend fires on wrap", () => {
|
|
|
|
|
btn.click();
|
|
|
|
|
wrap.dispatchEvent(new Event("animationend"));
|
|
|
|
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// Click when open — close, no wobble //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("clicking btn when open", () => {
|
|
|
|
|
beforeEach(() => Tray.open());
|
|
|
|
|
|
|
|
|
|
it("closes the tray", () => {
|
|
|
|
|
btn.click();
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not add .wobble", () => {
|
|
|
|
|
btn.click();
|
|
|
|
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// Drag interaction — continuous positioning //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("drag interaction", () => {
|
|
|
|
|
function simulateDrag(deltaX) {
|
|
|
|
|
const startX = 800;
|
|
|
|
|
btn.dispatchEvent(new PointerEvent("pointerdown", { clientX: startX, bubbles: true }));
|
|
|
|
|
btn.dispatchEvent(new PointerEvent("pointermove", { clientX: startX + deltaX, bubbles: true }));
|
|
|
|
|
btn.dispatchEvent(new PointerEvent("pointerup", { clientX: startX + deltaX, bubbles: true }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("dragging left opens the tray", () => {
|
|
|
|
|
simulateDrag(-60);
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("any leftward drag opens the tray", () => {
|
|
|
|
|
simulateDrag(-20);
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("dragging right does not open the tray", () => {
|
|
|
|
|
simulateDrag(100);
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drag > 10px suppresses the subsequent click", () => {
|
|
|
|
|
simulateDrag(-60);
|
|
|
|
|
btn.click(); // should be swallowed — tray stays open
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not add .wobble during drag", () => {
|
|
|
|
|
simulateDrag(-60);
|
|
|
|
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// Landscape mode — Y-axis drag, top-positioned wrap //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("landscape mode", () => {
|
|
|
|
|
// Re-init in landscape after the portrait init from outer beforeEach.
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
Tray.reset();
|
|
|
|
|
Tray._testSetLandscape(true);
|
|
|
|
|
Tray.init();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function simulateDragY(deltaY) {
|
|
|
|
|
const startY = 50;
|
|
|
|
|
btn.dispatchEvent(new PointerEvent("pointerdown", { clientY: startY, clientX: 0, bubbles: true }));
|
|
|
|
|
btn.dispatchEvent(new PointerEvent("pointermove", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
|
|
|
|
|
btn.dispatchEvent(new PointerEvent("pointerup", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── open() in landscape ─────────────────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
describe("open()", () => {
|
|
|
|
|
it("makes #id_tray visible", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
expect(tray.style.display).not.toBe("none");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds .open to #id_tray_btn", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
expect(btn.classList.contains("open")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("positions wrap via style.top, not style.left", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
expect(wrap.style.top).not.toBe("");
|
|
|
|
|
expect(wrap.style.left).toBe("");
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── close() in landscape ────────────────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
describe("close()", () => {
|
|
|
|
|
beforeEach(() => Tray.open());
|
|
|
|
|
|
|
|
|
|
it("closes the tray (display not toggled in landscape)", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .open from #id_tray_btn", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
expect(btn.classList.contains("open")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("closed top is less than open top (wrap slides up to close)", () => {
|
|
|
|
|
const openTop = parseInt(wrap.style.top, 10);
|
|
|
|
|
Tray.close();
|
|
|
|
|
const closedTop = parseInt(wrap.style.top, 10);
|
|
|
|
|
expect(closedTop).toBeLessThan(openTop);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds .snap to wrap after top transition completes", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
|
|
|
|
|
expect(wrap.classList.contains("snap")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .snap from wrap once animationend fires", () => {
|
|
|
|
|
Tray.close();
|
|
|
|
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
|
|
|
|
|
wrap.dispatchEvent(new Event("animationend"));
|
|
|
|
|
expect(wrap.classList.contains("snap")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── drag — Y axis ──────────────────────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
describe("drag interaction", () => {
|
|
|
|
|
it("dragging down opens the tray", () => {
|
|
|
|
|
simulateDragY(100);
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("dragging up does not open the tray", () => {
|
|
|
|
|
simulateDragY(-100);
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("drag > 10px downward suppresses subsequent click", () => {
|
|
|
|
|
simulateDragY(100);
|
|
|
|
|
btn.click(); // should be swallowed — tray stays open
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not set style.left (Y axis only)", () => {
|
|
|
|
|
simulateDragY(100);
|
|
|
|
|
expect(wrap.style.left).toBe("");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not add .wobble during drag", () => {
|
|
|
|
|
simulateDragY(100);
|
|
|
|
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── click when closed — wobble, no open ───────────────────────── //
|
|
|
|
|
|
|
|
|
|
describe("clicking btn when closed", () => {
|
|
|
|
|
it("adds .wobble to wrap", () => {
|
|
|
|
|
btn.click();
|
|
|
|
|
expect(wrap.classList.contains("wobble")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not open the tray", () => {
|
|
|
|
|
btn.click();
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── click when open — close ────────────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
describe("clicking btn when open", () => {
|
|
|
|
|
beforeEach(() => Tray.open());
|
|
|
|
|
|
|
|
|
|
it("closes the tray", () => {
|
|
|
|
|
btn.click();
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── init positions wrap at closed (top) ────────────────────────── //
|
|
|
|
|
|
|
|
|
|
it("init sets wrap to closed position (top < 0 or = maxTop)", () => {
|
|
|
|
|
// After landscape init with no real elements, _maxTop = -(wrapH_fallback - handleH_fallback)
|
|
|
|
|
// which will be negative. Wrap starts off-screen above.
|
|
|
|
|
const top = parseInt(wrap.style.top, 10);
|
|
|
|
|
expect(top).toBeLessThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── resize closes landscape tray ─────────────────────────────── //
|
|
|
|
|
|
|
|
|
|
describe("resize closes the tray", () => {
|
|
|
|
|
it("closes when landscape tray is open", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .open from btn on resize", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(btn.classList.contains("open")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("resets wrap to closed top position on resize", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(parseInt(wrap.style.top, 10)).toBeLessThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not re-open a closed tray on resize", () => {
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// window resize — portrait //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("window resize (portrait)", () => {
|
|
|
|
|
it("closes the tray when open", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .open from btn on resize", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(btn.classList.contains("open")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("hides the tray panel on resize", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(tray.style.display).toBe("none");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("resets wrap to closed left position on resize", () => {
|
|
|
|
|
Tray.open();
|
|
|
|
|
expect(wrap.style.left).toBe("0px");
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("does not re-open a closed tray on resize", () => {
|
|
|
|
|
window.dispatchEvent(new Event("resize"));
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-30 16:42:23 -04:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// placeCard() //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
//
|
|
|
|
|
// placeCard(roleCode, onComplete):
|
|
|
|
|
// 1. Marks the first .tray-cell with .tray-role-card + data-role.
|
|
|
|
|
// 2. Opens the tray.
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
// 3. Fade-in animates the cell (.fade-in class, animationend fires).
|
2026-03-30 16:42:23 -04:00
|
|
|
// 4. forceClose() — tray closes instantly.
|
|
|
|
|
// 5. Calls onComplete.
|
|
|
|
|
//
|
|
|
|
|
// The grid always has exactly 8 .tray-cell elements (from the template);
|
|
|
|
|
// no new elements are inserted.
|
|
|
|
|
//
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
|
|
|
|
|
describe("placeCard()", () => {
|
|
|
|
|
let grid, firstCell;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
grid = document.createElement("div");
|
|
|
|
|
grid.id = "id_tray_grid";
|
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
|
|
|
const cell = document.createElement("div");
|
|
|
|
|
cell.className = "tray-cell";
|
|
|
|
|
grid.appendChild(cell);
|
|
|
|
|
}
|
|
|
|
|
document.body.appendChild(grid);
|
|
|
|
|
// Re-init so _grid is set (reset() in outer afterEach clears it)
|
|
|
|
|
Tray.init();
|
|
|
|
|
firstCell = grid.querySelector(".tray-cell");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
grid.remove();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds .tray-role-card to the first .tray-cell", () => {
|
|
|
|
|
Tray.placeCard("PC", null);
|
|
|
|
|
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sets data-role on the first cell", () => {
|
|
|
|
|
Tray.placeCard("NC", null);
|
|
|
|
|
expect(firstCell.dataset.role).toBe("NC");
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-05-03 18:40:10 -04:00
|
|
|
it("sets tabIndex=0 on the placed cell so :focus persists the hover-tilt", () => {
|
|
|
|
|
Tray.placeCard("PC", null);
|
|
|
|
|
expect(firstCell.tabIndex).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-30 16:42:23 -04:00
|
|
|
it("grid cell count stays at 8", () => {
|
|
|
|
|
Tray.placeCard("PC", null);
|
|
|
|
|
expect(grid.children.length).toBe(8);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("opens the tray", () => {
|
|
|
|
|
Tray.placeCard("PC", null);
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
it("adds .fade-in to the first cell", () => {
|
2026-03-30 16:42:23 -04:00
|
|
|
Tray.placeCard("PC", null);
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
expect(firstCell.classList.contains("fade-in")).toBe(true);
|
2026-03-30 16:42:23 -04:00
|
|
|
});
|
|
|
|
|
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
it("removes .fade-in and closes after animationend", () => {
|
2026-03-30 16:42:23 -04:00
|
|
|
Tray.placeCard("PC", null);
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
firstCell.dispatchEvent(new Event("animationend"));
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
expect(firstCell.classList.contains("fade-in")).toBe(false);
|
2026-03-30 16:42:23 -04:00
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calls onComplete after the tray closes", () => {
|
|
|
|
|
let called = false;
|
|
|
|
|
Tray.placeCard("PC", () => { called = true; });
|
|
|
|
|
firstCell.dispatchEvent(new Event("animationend"));
|
2026-04-04 14:54:54 -04:00
|
|
|
// Simulate the close transition completing (portrait: 'left' property)
|
|
|
|
|
const te = new Event("transitionend");
|
|
|
|
|
te.propertyName = "left";
|
|
|
|
|
wrap.dispatchEvent(te);
|
2026-03-30 16:42:23 -04:00
|
|
|
expect(called).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("landscape: same behaviour — first cell gets role card", () => {
|
|
|
|
|
Tray._testSetLandscape(true);
|
|
|
|
|
Tray.init();
|
|
|
|
|
Tray.placeCard("EC", null);
|
|
|
|
|
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
|
|
|
|
|
expect(firstCell.dataset.role).toBe("EC");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reset() removes .tray-role-card and data-role from cells", () => {
|
|
|
|
|
Tray.placeCard("PC", null);
|
|
|
|
|
Tray.reset();
|
|
|
|
|
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
|
|
|
|
|
expect(firstCell.dataset.role).toBeUndefined();
|
|
|
|
|
});
|
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>
2026-05-03 18:40:10 -04:00
|
|
|
|
|
|
|
|
it("reset() also clears tabindex from the placed cell", () => {
|
|
|
|
|
Tray.placeCard("PC", null);
|
|
|
|
|
Tray.reset();
|
|
|
|
|
expect(firstCell.hasAttribute("tabindex")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
tray: Tray.placeSig analogue of placeCard for SIG SELECT exit; rename arc-in → fade-in — TDD
After all 3 gamers in a polarity room confirm TAKE SIG and the 12s countdown
expires, sig-select.js's room:polarity_room_done handler now plays the same
tray-open / fade-in / tray-close sequence the role-select uses, then
dismisses the sig overlay & shows the waiting msg ("Gravity settling…" /
"Levity appraising…") on Tray.placeSig's completion callback. Visual order:
sig stage → tray slides in → sig fades into the second tray cell → tray
slides out → table hex w. waiting msg. Cross-polarity events (other room
finishing while we're still in our overlay) are no-op as before.
- tray.js: new Tray.placeSig(sourceEl, onComplete). Mutates the SECOND
.tray-cell in place (sig slot), copies aria-label / data-energies /
data-operations / corner-rank + suit-icon markup from the source
.sig-stage-card, then runs the shared open → fade-in → close sequence.
Extracted _runFadeInSequence helper so placeCard + placeSig share the
same animation glue. reset() now also clears .tray-sig-card from cells.
- _tray.scss: .tray-sig-card.fade-in > .sig-stage-card animates via the
existing tray-role-fade-in keyframes.
- sig-select.js polarity_room_done handler: Tray.placeSig(stageCard,
_settle); _settle runs the existing _dismissSigOverlay + _showWaitingMsg.
Falls back to immediate dismiss when Tray is undefined (test environments
without the tray).
- arc-in → fade-in rename across tray.js, role-select.js, _tray.scss
(incl. @keyframes tray-role-arc-in → tray-role-fade-in), TraySpec.js
spec descriptions + assertions, & test_room_role_select.py docstrings.
The original "arc-in" name suggested a curved-path animation; the actual
behaviour is a 1s opacity fade, so fade-in is the accurate label.
- TraySpec: 10 new placeSig specs mirroring placeCard (second-cell mutation,
data + markup copy, tabIndex, fade-in class, animationend-triggered close,
onComplete callback, landscape parity, reset cleanup).
- SigSelectSpec: 3 new specs (Tray.placeSig called w. stageCard on own
polarity; not called on other polarity; overlay dismiss deferred to the
Tray.placeSig completion callback).
344 specs / 4 pending green; RoleSelectTrayTest FT still green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 21:58:44 -04:00
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// placeSig() //
|
|
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
//
|
|
|
|
|
// placeSig(sourceEl, onComplete) — analogue of placeCard, applied to the
|
|
|
|
|
// SECOND tray cell (the sig slot). sourceEl is the user's selected sig
|
|
|
|
|
// .sig-stage-card (during sig-select), whose data-* attrs and inner
|
|
|
|
|
// markup are copied into the tray's .sig-stage-card.sea-sig-card.
|
|
|
|
|
//
|
|
|
|
|
// Sequence: open → fade-in → animationend → close → onComplete.
|
|
|
|
|
|
|
|
|
|
describe("placeSig()", () => {
|
|
|
|
|
let grid, firstCell, secondCell, sourceEl;
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
grid = document.createElement("div");
|
|
|
|
|
grid.id = "id_tray_grid";
|
|
|
|
|
for (let i = 0; i < 8; i++) {
|
|
|
|
|
const cell = document.createElement("div");
|
|
|
|
|
cell.className = "tray-cell";
|
|
|
|
|
grid.appendChild(cell);
|
|
|
|
|
}
|
|
|
|
|
document.body.appendChild(grid);
|
|
|
|
|
Tray.init();
|
|
|
|
|
firstCell = grid.children[0];
|
|
|
|
|
secondCell = grid.children[1];
|
|
|
|
|
|
|
|
|
|
// Source: the user's selected sig stage card during sig-select.
|
|
|
|
|
// Carries the same data-* shape that StageCard.fromDataset reads
|
|
|
|
|
// and the same rank+icon child markup the tray template renders.
|
|
|
|
|
sourceEl = document.createElement("div");
|
|
|
|
|
sourceEl.className = "sig-stage-card";
|
|
|
|
|
sourceEl.setAttribute("aria-label", "The Tester");
|
|
|
|
|
sourceEl.dataset.energies = '[{"type":"TESTLIBIDO","effect":"e1"}]';
|
|
|
|
|
sourceEl.dataset.operations = '[{"type":"TESTOP","effect":"o1"}]';
|
|
|
|
|
sourceEl.innerHTML =
|
|
|
|
|
'<span class="fan-corner-rank">XCVIII</span>' +
|
|
|
|
|
'<i class="fa-solid fa-flask"></i>';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
afterEach(() => grid.remove());
|
|
|
|
|
|
|
|
|
|
it("adds .tray-sig-card to the SECOND .tray-cell", () => {
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
expect(secondCell.classList.contains("tray-sig-card")).toBe(true);
|
|
|
|
|
expect(firstCell.classList.contains("tray-sig-card")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("sets tabIndex=0 on the placed cell", () => {
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
expect(secondCell.tabIndex).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("renders the .sig-stage-card.sea-sig-card child w. copied data + markup", () => {
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
const stage = secondCell.querySelector(".sig-stage-card.sea-sig-card");
|
|
|
|
|
expect(stage).not.toBe(null);
|
|
|
|
|
expect(stage.getAttribute("aria-label")).toBe("The Tester");
|
|
|
|
|
expect(stage.dataset.energies).toBe(sourceEl.dataset.energies);
|
|
|
|
|
expect(stage.dataset.operations).toBe(sourceEl.dataset.operations);
|
|
|
|
|
expect(stage.querySelector(".fan-corner-rank").textContent).toBe("XCVIII");
|
|
|
|
|
expect(stage.querySelector("i.fa-solid")).not.toBe(null);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("grid cell count stays at 8", () => {
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
expect(grid.children.length).toBe(8);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("opens the tray", () => {
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
expect(Tray.isOpen()).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("adds .fade-in to the placed cell", () => {
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
expect(secondCell.classList.contains("fade-in")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("removes .fade-in and closes after animationend", () => {
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
secondCell.dispatchEvent(new Event("animationend"));
|
|
|
|
|
expect(secondCell.classList.contains("fade-in")).toBe(false);
|
|
|
|
|
expect(Tray.isOpen()).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("calls onComplete after the tray closes", () => {
|
|
|
|
|
let called = false;
|
|
|
|
|
Tray.placeSig(sourceEl, () => { called = true; });
|
|
|
|
|
secondCell.dispatchEvent(new Event("animationend"));
|
|
|
|
|
const te = new Event("transitionend");
|
|
|
|
|
te.propertyName = "left";
|
|
|
|
|
wrap.dispatchEvent(te);
|
|
|
|
|
expect(called).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("landscape: same behaviour — second cell gets sig card", () => {
|
|
|
|
|
Tray._testSetLandscape(true);
|
|
|
|
|
Tray.init();
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
expect(secondCell.classList.contains("tray-sig-card")).toBe(true);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("reset() removes .tray-sig-card from cells", () => {
|
|
|
|
|
Tray.placeSig(sourceEl, null);
|
|
|
|
|
Tray.reset();
|
|
|
|
|
expect(secondCell.classList.contains("tray-sig-card")).toBe(false);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
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>
2026-05-03 18:40:10 -04:00
|
|
|
// ---------------------------------------------------------------------- //
|
|
|
|
|
// 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);
|
|
|
|
|
});
|
2026-03-30 16:42:23 -04:00
|
|
|
});
|
2026-03-29 23:39:03 -04:00
|
|
|
});
|