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>
This commit is contained in:
Disco DeDisco
2026-05-03 21:58:44 -04:00
parent 9b93b9d31b
commit 480cb4aed6
9 changed files with 431 additions and 27 deletions

View File

@@ -809,4 +809,63 @@ describe("SigSelect", () => {
expect(takeSigBtn.classList.contains("btn-cancel")).toBe(false);
});
});
// ── polarity_room_done → tray sequence ─────────────────────────────────── //
//
// After all 3 gamers in the user's polarity confirm TAKE SIG and the
// 12s countdown expires, the server fires room:polarity_room_done. The
// sig-select handler should: (1) play the tray sequence — Tray.placeSig
// with the user's selected stage card; (2) on Tray.placeSig's completion
// callback, dismiss the overlay and show the waiting message. Tray runs
// FIRST, while the overlay is still up, so the slide is visually anchored
// to the sig stage's exit.
//
// Cross-polarity events (the OTHER room finishing while we're still
// selecting) must NOT trigger the sequence.
describe("polarity_room_done → tray sequence", () => {
beforeEach(() => {
// .table-center is appended to by _showWaitingMsg in the dismiss
// path; provide one so dismissal doesn't error.
const center = document.createElement("div");
center.className = "table-center";
document.body.appendChild(center);
makeFixture({ polarity: "levity", userRole: "PC" });
spyOn(Tray, "placeSig");
});
afterEach(() => {
document.querySelectorAll(".table-center, #id_hex_waiting_msg")
.forEach((el) => el.remove());
});
it("calls Tray.placeSig with the stage card when own polarity finishes", () => {
window.dispatchEvent(new CustomEvent("room:polarity_room_done", {
detail: { polarity: "levity" },
}));
expect(Tray.placeSig).toHaveBeenCalled();
const arg = Tray.placeSig.calls.mostRecent().args[0];
expect(arg).toBe(stageCard);
});
it("does NOT call Tray.placeSig when the OTHER polarity finishes", () => {
window.dispatchEvent(new CustomEvent("room:polarity_room_done", {
detail: { polarity: "gravity" },
}));
expect(Tray.placeSig).not.toHaveBeenCalled();
});
it("dismisses the overlay only AFTER Tray.placeSig's callback fires", () => {
window.dispatchEvent(new CustomEvent("room:polarity_room_done", {
detail: { polarity: "levity" },
}));
// Overlay still mounted — dismissal deferred to tray callback.
expect(document.querySelector(".sig-overlay")).not.toBe(null);
const cb = Tray.placeSig.calls.mostRecent().args[1];
cb();
expect(document.querySelector(".sig-overlay")).toBe(null);
expect(document.getElementById("id_hex_waiting_msg")).not.toBe(null);
});
});
});

View File

@@ -430,7 +430,7 @@ describe("Tray", () => {
// placeCard(roleCode, onComplete):
// 1. Marks the first .tray-cell with .tray-role-card + data-role.
// 2. Opens the tray.
// 3. Arc-in animates the cell (.arc-in class, animationend fires).
// 3. Fade-in animates the cell (.fade-in class, animationend fires).
// 4. forceClose() — tray closes instantly.
// 5. Calls onComplete.
//
@@ -485,16 +485,16 @@ describe("Tray", () => {
expect(Tray.isOpen()).toBe(true);
});
it("adds .arc-in to the first cell", () => {
it("adds .fade-in to the first cell", () => {
Tray.placeCard("PC", null);
expect(firstCell.classList.contains("arc-in")).toBe(true);
expect(firstCell.classList.contains("fade-in")).toBe(true);
});
it("removes .arc-in and closes after animationend", () => {
it("removes .fade-in and closes after animationend", () => {
Tray.placeCard("PC", null);
expect(Tray.isOpen()).toBe(true);
firstCell.dispatchEvent(new Event("animationend"));
expect(firstCell.classList.contains("arc-in")).toBe(false);
expect(firstCell.classList.contains("fade-in")).toBe(false);
expect(Tray.isOpen()).toBe(false);
});
@@ -531,6 +531,116 @@ describe("Tray", () => {
});
});
// ---------------------------------------------------------------------- //
// 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);
});
});
// ---------------------------------------------------------------------- //
// init() — focusable tray cards //
// ---------------------------------------------------------------------- //