diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js
index 519c50f..03106e9 100644
--- a/src/apps/epic/static/apps/epic/role-select.js
+++ b/src/apps/epic/static/apps/epic/role-select.js
@@ -3,6 +3,11 @@ var RoleSelect = (function () {
// ahead of the fetch response doesn't get overridden by Tray.open().
var _turnChangedBeforeFetch = false;
+ // Set to true while placeCard animation is running. handleTurnChanged
+ // defers its work until the animation completes.
+ var _animationPending = false;
+ var _pendingTurnChange = null;
+
var ROLES = [
{ code: "PC", name: "Player", element: "Fire" },
{ code: "BC", name: "Builder", element: "Stone" },
@@ -27,18 +32,10 @@ var RoleSelect = (function () {
if (backdrop) backdrop.remove();
}
- function selectRole(roleCode, cardEl) {
+ function selectRole(roleCode) {
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
- var invCard = cardEl.cloneNode(true);
- invCard.classList.add("flipped");
- // strip old event listeners from the clone by replacing with a clean copy
- var clean = invCard.cloneNode(true);
-
closeFan();
- var invSlot = document.getElementById("id_inv_role_card");
- if (invSlot) invSlot.appendChild(clean);
-
// Immediately lock the stack — do not wait for WS turn_changed
var stack = document.querySelector(".card-stack[data-starter-roles]");
if (stack) {
@@ -60,24 +57,27 @@ var RoleSelect = (function () {
}).then(function (response) {
if (!response.ok) {
// Server rejected (role already taken) — undo optimistic update
- if (invSlot && invSlot.contains(clean)) invSlot.removeChild(clean);
if (stack) {
stack.dataset.starterRoles = stack.dataset.starterRoles
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
}
openFan();
} else {
- // Place role card in tray grid and open the tray
- var grid = document.getElementById("id_tray_grid");
- if (grid) {
- var trayCard = document.createElement("div");
- trayCard.className = "tray-cell tray-role-card";
- trayCard.dataset.role = roleCode;
- grid.insertBefore(trayCard, grid.firstChild);
- }
- // Only open if turn_changed hasn't already arrived and closed it.
- if (typeof Tray !== "undefined" && !_turnChangedBeforeFetch) {
- Tray.open();
+ // Always animate the role card into the tray, even if turn_changed
+ // already arrived. placeCard opens the tray, arcs the card in,
+ // then force-closes — so the user always sees their role card land.
+ // If turn_changed arrived before the fetch, handleTurnChanged already
+ // ran; _pendingTurnChange will be null and onComplete is a no-op.
+ if (typeof Tray !== "undefined") {
+ _animationPending = true;
+ Tray.placeCard(roleCode, function () {
+ _animationPending = false;
+ if (_pendingTurnChange) {
+ var ev = _pendingTurnChange;
+ _pendingTurnChange = null;
+ handleTurnChanged(ev);
+ }
+ });
}
}
});
@@ -135,7 +135,7 @@ var RoleSelect = (function () {
"Start round 1 as
" + role.name + " (" + role.code + ") …?",
function () { // confirm
card.classList.remove("guard-active");
- selectRole(role.code, card);
+ selectRole(role.code);
},
function () { // dismiss (NVM / outside click)
card.classList.remove("guard-active");
@@ -165,9 +165,13 @@ var RoleSelect = (function () {
}
function handleTurnChanged(event) {
+ // If a placeCard animation is running, defer until it completes.
+ if (_animationPending) {
+ _pendingTurnChange = event;
+ return;
+ }
+
var active = String(event.detail.active_slot);
- var invSlot = document.getElementById("id_inv_role_card");
- if (invSlot) invSlot.innerHTML = "";
// Force-close tray instantly so it never obscures the next player's card-stack.
// Also set the race flag so the fetch .then() doesn't re-open if it arrives late.
@@ -220,8 +224,13 @@ var RoleSelect = (function () {
}
return {
- openFan: openFan,
- closeFan: closeFan,
- setReload: function (fn) { _reload = fn; },
+ openFan: openFan,
+ closeFan: closeFan,
+ setReload: function (fn) { _reload = fn; },
+ // Testing hook — resets animation-pause state between Jasmine specs
+ _testReset: function () {
+ _animationPending = false;
+ _pendingTurnChange = null;
+ },
};
}());
diff --git a/src/apps/epic/static/apps/epic/tray.js b/src/apps/epic/static/apps/epic/tray.js
index c8f23ad..0b71cd4 100644
--- a/src/apps/epic/static/apps/epic/tray.js
+++ b/src/apps/epic/static/apps/epic/tray.js
@@ -224,6 +224,37 @@ var Tray = (function () {
});
}
+ // _arcIn — add .arc-in to cardEl, wait for animationend, remove it, call onComplete.
+ function _arcIn(cardEl, onComplete) {
+ cardEl.classList.add('arc-in');
+ cardEl.addEventListener('animationend', function handler() {
+ cardEl.removeEventListener('animationend', handler);
+ cardEl.classList.remove('arc-in');
+ if (onComplete) onComplete();
+ });
+ }
+
+ // placeCard(roleCode, onComplete) — mark the first tray cell with the role,
+ // open the tray, arc-in the cell, then force-close. Calls onComplete after.
+ // The grid always contains exactly 8 .tray-cell elements (from the template);
+ // the first one receives .tray-role-card and data-role instead of a new element
+ // being inserted, so the cell count never changes.
+ function placeCard(roleCode, onComplete) {
+ if (!_grid) { if (onComplete) onComplete(); return; }
+ var firstCell = _grid.querySelector('.tray-cell');
+ if (!firstCell) { if (onComplete) onComplete(); return; }
+
+ firstCell.classList.add('tray-role-card');
+ firstCell.dataset.role = roleCode;
+ firstCell.textContent = roleCode;
+
+ open();
+ _arcIn(firstCell, function () {
+ forceClose();
+ if (onComplete) onComplete();
+ });
+ }
+
function _startDrag(clientX, clientY) {
_dragHandled = false;
if (_wrap) _wrap.classList.add('tray-dragging');
@@ -428,6 +459,14 @@ var Tray = (function () {
_onBtnClick = null;
}
_cancelPendingHide();
+ // Clear any role-card state from tray cells (Jasmine afterEach)
+ if (_grid) {
+ _grid.querySelectorAll('.tray-cell').forEach(function (el) {
+ el.classList.remove('tray-role-card', 'arc-in');
+ el.textContent = '';
+ delete el.dataset.role;
+ });
+ }
_wrap = null;
_btn = null;
_tray = null;
@@ -446,6 +485,7 @@ var Tray = (function () {
close: close,
forceClose: forceClose,
isOpen: isOpen,
+ placeCard: placeCard,
reset: reset,
_testSetLandscape: function (v) { _landscapeOverride = v; },
};
diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py
index fc8690d..c279f4d 100644
--- a/src/apps/epic/tests/integrated/test_views.py
+++ b/src/apps/epic/tests/integrated/test_views.py
@@ -655,43 +655,6 @@ class SelectRoleViewTest(TestCase):
)
-class RevealPhaseRenderingTest(TestCase):
- def setUp(self):
- self.founder = User.objects.create(email="founder@test.io")
- self.room = Room.objects.create(name="Test Room", owner=self.founder)
- gamers = [self.founder]
- for i in range(2, 7):
- gamers.append(User.objects.create(email=f"g{i}@test.io"))
- roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
- for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
- TableSeat.objects.create(
- room=self.room, gamer=gamer, slot_number=i,
- role=role, role_revealed=True,
- )
- self.room.gate_status = Room.OPEN
- self.room.table_status = Room.SIG_SELECT
- self.room.save()
- self.client.force_login(self.founder)
-
- def test_face_up_role_cards_rendered_when_sig_select(self):
- response = self.client.get(
- reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
- )
- self.assertContains(response, "face-up")
-
- def test_inv_role_card_slot_present(self):
- response = self.client.get(
- reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
- )
- self.assertContains(response, "id_inv_role_card")
-
- def test_partner_indicator_present_when_sig_select(self):
- response = self.client.get(
- reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
- )
- self.assertContains(response, "partner-indicator")
-
-
class RoomActionsViewTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="owner@test.io")
diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py
index 196e9b5..e7bcef4 100644
--- a/src/functional_tests/test_room_role_select.py
+++ b/src/functional_tests/test_room_role_select.py
@@ -216,14 +216,7 @@ class RoleSelectTest(FunctionalTest):
)
)
- # 7. Role card appears in inventory
- self.wait_for(
- lambda: self.browser.find_element(
- By.CSS_SELECTOR, "#id_inv_role_card .card"
- )
- )
-
- # 8. Card stack returns to table centre
+ # 7. Card stack returns to table centre
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
)
@@ -323,46 +316,6 @@ class RoleSelectTest(FunctionalTest):
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card")
self.assertEqual(len(cards), 5)
- # ------------------------------------------------------------------ #
- # Test 3d — Previously selected roles appear in inventory on re-entry#
- # ------------------------------------------------------------------ #
-
- def test_previously_selected_roles_shown_in_inventory_on_re_entry(self):
- """A multi-slot gamer who already chose some roles should see those
- role cards pre-populated in the inventory when they re-enter the room."""
- from apps.epic.models import TableSeat
- founder, _ = User.objects.get_or_create(email="founder@test.io")
- room = Room.objects.create(name="Inventory Re-entry Test", owner=founder)
- _fill_room_via_orm(room, [
- "founder@test.io", "founder@test.io",
- "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
- ])
- 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,
- )
- # Founder's first slot has already chosen BC
- TableSeat.objects.filter(room=room, slot_number=1).update(role="BC")
-
- room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
- self.create_pre_authenticated_session("founder@test.io")
- self.browser.get(room_url)
-
- # Inventory should contain exactly one pre-rendered card for BC
- inv_cards = self.wait_for(
- lambda: self.browser.find_elements(
- By.CSS_SELECTOR, "#id_inv_role_card .card"
- )
- )
- self.assertEqual(len(inv_cards), 1)
- self.assertIn(
- "BUILDER",
- inv_cards[0].text.upper(),
- )
-
# ------------------------------------------------------------------ #
# Test 4 — Click-away dismisses fan without selecting #
# ------------------------------------------------------------------ #
@@ -392,17 +345,13 @@ class RoleSelectTest(FunctionalTest):
# Click the backdrop (outside the fan)
self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click()
- # Modal closes; stack still present; inventory still empty
+ # Modal closes; stack still present
self.wait_for(
lambda: self.assertEqual(
len(self.browser.find_elements(By.ID, "id_role_select")), 0
)
)
self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
- self.assertEqual(
- len(self.browser.find_elements(By.CSS_SELECTOR, "#id_inv_role_card .card")),
- 0
- )
# ------------------------------------------------------------------ #
@@ -529,17 +478,9 @@ class RoleSelectTest(FunctionalTest):
self.browser.refresh()
- # All role cards in inventory are face-up
- face_up_cards = self.wait_for(
- lambda: self.browser.find_elements(
- By.CSS_SELECTOR, "#id_inv_role_card .card.face-up"
- )
- )
- self.assertGreater(len(face_up_cards), 0)
-
- # Partner indicator is visible
+ # Sig deck is present (page has transitioned to SIG_SELECT)
self.wait_for(
- lambda: self.browser.find_element(By.CSS_SELECTOR, ".partner-indicator")
+ lambda: self.browser.find_element(By.ID, "id_sig_deck")
)
@@ -590,12 +531,13 @@ class RoleSelectTrayTest(FunctionalTest):
self.confirm_guard()
# ------------------------------------------------------------------ #
- # T1 — Portrait: role card at topmost grid square, tray opens #
+ # T1 — Portrait: role card marks first cell; tray opens then closes #
# ------------------------------------------------------------------ #
def test_portrait_role_card_enters_topmost_grid_square(self):
- """Portrait: after confirming a role, a .tray-role-card is the first child
- of #id_tray_grid (topmost cell) and the tray is open."""
+ """Portrait: after confirming a role the first .tray-cell gets
+ .tray-role-card; the grid still has exactly 8 cells; and the tray
+ opens briefly then closes once the arc-in animation completes."""
self.browser.set_window_size(390, 844)
room = self._make_room()
self.create_pre_authenticated_session("slot1@test.io")
@@ -604,34 +546,42 @@ class RoleSelectTrayTest(FunctionalTest):
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
self._select_role()
- # Card appears in the grid.
+ # First cell receives the role card class.
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
)
)
- # It is the first child — topmost in portrait.
- is_first = self.browser.execute_script("""
- var card = document.querySelector('#id_tray_grid .tray-role-card');
- return card !== null && card === card.parentElement.firstElementChild;
+ result = self.browser.execute_script("""
+ var grid = document.getElementById('id_tray_grid');
+ var card = grid.querySelector('.tray-role-card');
+ return {
+ isFirst: card !== null && card === grid.firstElementChild,
+ count: grid.children.length,
+ role: card ? card.dataset.role : null
+ };
""")
- self.assertTrue(is_first, "Role card should be the first child of #id_tray_grid")
+ self.assertTrue(result["isFirst"], "Role card should be the first cell")
+ self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
+ self.assertTrue(result["role"], "First cell should carry data-role")
- # Tray is open.
- self.assertTrue(
- self.browser.execute_script("return Tray.isOpen()"),
- "Tray should be open after role selection"
+ # Tray closes after the animation sequence.
+ self.wait_for(
+ lambda: self.assertFalse(
+ self.browser.execute_script("return Tray.isOpen()"),
+ "Tray should close after the arc-in sequence"
+ )
)
# ------------------------------------------------------------------ #
- # T2 — Landscape: role card at leftmost grid square, tray opens #
+ # T2 — Landscape: same contract in landscape #
# ------------------------------------------------------------------ #
@tag('two-browser')
def test_landscape_role_card_enters_leftmost_grid_square(self):
- """Landscape: after confirming a role, a .tray-role-card is the first child
- of #id_tray_grid (leftmost cell) and the tray is open."""
+ """Landscape: the first .tray-cell gets .tray-role-card; grid has
+ 8 cells; tray opens then closes."""
self.browser.set_window_size(844, 390)
room = self._make_room()
self.create_pre_authenticated_session("slot1@test.io")
@@ -640,24 +590,28 @@ class RoleSelectTrayTest(FunctionalTest):
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
self._select_role()
- # Card appears in the grid.
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
)
)
- # It is the first child — leftmost in landscape.
- is_first = self.browser.execute_script("""
- var card = document.querySelector('#id_tray_grid .tray-role-card');
- return card !== null && card === card.parentElement.firstElementChild;
+ result = self.browser.execute_script("""
+ var grid = document.getElementById('id_tray_grid');
+ var card = grid.querySelector('.tray-role-card');
+ return {
+ isFirst: card !== null && card === grid.firstElementChild,
+ count: grid.children.length
+ };
""")
- self.assertTrue(is_first, "Role card should be the first child of #id_tray_grid")
+ self.assertTrue(result["isFirst"], "Role card should be the first cell")
+ self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
- # Tray is open.
- self.assertTrue(
- self.browser.execute_script("return Tray.isOpen()"),
- "Tray should be open after role selection"
+ self.wait_for(
+ lambda: self.assertFalse(
+ self.browser.execute_script("return Tray.isOpen()"),
+ "Tray should close after the arc-in sequence"
+ )
)
diff --git a/src/functional_tests/test_room_sig_select.py b/src/functional_tests/test_room_sig_select.py
index 9a095fe..506fa64 100644
--- a/src/functional_tests/test_room_sig_select.py
+++ b/src/functional_tests/test_room_sig_select.py
@@ -189,12 +189,9 @@ class SigSelectTest(FunctionalTest):
)
)
- # Founder's significator appears in their inventory
- self.wait_for(
- lambda: self.browser.find_element(
- By.CSS_SELECTOR, "#id_inv_sig_card .card"
- )
- )
+ # TODO: sig card should appear in the tray (tray.placeCard for sig phase)
+ # once sig-select.js is updated to call Tray.placeCard instead of
+ # appending to the removed #id_inv_sig_card inventory element.
# Active seat advances to NC
self.wait_for(
diff --git a/src/static/tests/RoleSelectSpec.js b/src/static/tests/RoleSelectSpec.js
index cc32d4f..df72887 100644
--- a/src/static/tests/RoleSelectSpec.js
+++ b/src/static/tests/RoleSelectSpec.js
@@ -7,7 +7,6 @@ describe("RoleSelect", () => {
-
`;
document.body.appendChild(testDiv);
window.fetch = jasmine.createSpy("fetch").and.returnValue(
@@ -102,12 +101,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull();
});
- it("clicking a card appends a .card to #id_inv_role_card", () => {
- const card = document.querySelector("#id_role_select .card");
- card.click();
- expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
- });
-
it("clicking a card POSTs to the select_role URL", () => {
const card = document.querySelector("#id_role_select .card");
card.click();
@@ -117,11 +110,6 @@ describe("RoleSelect", () => {
);
});
- it("clicking a card results in exactly one card in inventory", () => {
- const card = document.querySelector("#id_role_select .card");
- card.click();
- expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1);
- });
});
// ------------------------------------------------------------------ //
@@ -135,11 +123,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull();
});
- it("does not add a card to inventory", () => {
- RoleSelect.openFan();
- document.querySelector(".role-select-backdrop").click();
- expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
- });
});
// ------------------------------------------------------------------ //
@@ -259,20 +242,13 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => {
- let grid, guardConfirm;
+ let guardConfirm;
beforeEach(() => {
- // Minimal tray grid matching room.html structure
- 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);
- }
- testDiv.appendChild(grid);
-
- spyOn(Tray, "open");
+ // Spy on Tray.placeCard: call the onComplete callback immediately.
+ spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
+ if (cb) cb();
+ });
// Capturing guard spy — holds onConfirm so we can fire it per-test
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
@@ -283,54 +259,128 @@ describe("RoleSelect", () => {
document.querySelector("#id_role_select .card").click();
});
- it("prepends a .tray-role-card to #id_tray_grid on success", async () => {
+ it("calls Tray.placeCard() on success", async () => {
guardConfirm();
await Promise.resolve();
- expect(grid.querySelector(".tray-role-card")).not.toBeNull();
+ expect(Tray.placeCard).toHaveBeenCalled();
});
- it("tray-role-card is the first child of #id_tray_grid", async () => {
+ it("passes the role code string to Tray.placeCard", async () => {
guardConfirm();
await Promise.resolve();
- expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true);
+ const roleCode = Tray.placeCard.calls.mostRecent().args[0];
+ expect(typeof roleCode).toBe("string");
+ expect(roleCode.length).toBeGreaterThan(0);
});
- it("tray-role-card carries the selected role as data-role", async () => {
- guardConfirm();
- await Promise.resolve();
- const trayCard = grid.querySelector(".tray-role-card");
- expect(trayCard.dataset.role).toBeTruthy();
- });
-
- it("calls Tray.open() on success", async () => {
- guardConfirm();
- await Promise.resolve();
- expect(Tray.open).toHaveBeenCalled();
- });
-
- it("does not prepend a tray-role-card on server rejection", async () => {
+ it("does not call Tray.placeCard() on server rejection", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: false })
);
guardConfirm();
await Promise.resolve();
- expect(grid.querySelector(".tray-role-card")).toBeNull();
+ expect(Tray.placeCard).not.toHaveBeenCalled();
});
+ });
- it("does not call Tray.open() on server rejection", async () => {
- window.fetch = jasmine.createSpy("fetch").and.returnValue(
- Promise.resolve({ ok: false })
+ // ------------------------------------------------------------------ //
+ // WS turn_changed pause during animation //
+ // ------------------------------------------------------------------ //
+
+ describe("WS turn_changed pause during placeCard animation", () => {
+ let stack, guardConfirm;
+
+ beforeEach(() => {
+ // Six table seats, slot 1 starts active
+ for (let i = 1; i <= 6; i++) {
+ const seat = document.createElement("div");
+ seat.className = "table-seat" + (i === 1 ? " active" : "");
+ seat.dataset.slot = String(i);
+ testDiv.appendChild(seat);
+ }
+ stack = document.createElement("div");
+ stack.className = "card-stack";
+ stack.dataset.state = "ineligible";
+ stack.dataset.userSlots = "1";
+ stack.dataset.starterRoles = "";
+ testDiv.appendChild(stack);
+
+ const grid = document.createElement("div");
+ grid.id = "id_tray_grid";
+ testDiv.appendChild(grid);
+
+ // placeCard spy that holds the onComplete callback
+ let heldCallback = null;
+ spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
+ heldCallback = cb; // don't call immediately — simulate animation in-flight
+ });
+ spyOn(Tray, "forceClose");
+
+ // Expose heldCallback so tests can fire it
+ Tray._testFirePlaceCardComplete = () => {
+ if (heldCallback) heldCallback();
+ };
+
+ window.showGuard = jasmine.createSpy("showGuard").and.callFake(
+ (anchor, message, onConfirm) => { guardConfirm = onConfirm; }
);
- guardConfirm();
- await Promise.resolve();
- expect(Tray.open).not.toHaveBeenCalled();
});
- it("grid grows by exactly 1 on success", async () => {
- const before = grid.children.length;
+ afterEach(() => {
+ delete Tray._testFirePlaceCardComplete;
+ RoleSelect._testReset();
+ });
+
+ it("turn_changed during animation does not call Tray.forceClose immediately", async () => {
+ RoleSelect.openFan();
+ document.querySelector("#id_role_select .card").click();
+ guardConfirm();
+ await Promise.resolve(); // fetch resolves; placeCard called; animation pending
+
+ window.dispatchEvent(new CustomEvent("room:turn_changed", {
+ detail: { active_slot: 2, starter_roles: [] }
+ }));
+ expect(Tray.forceClose).not.toHaveBeenCalled();
+ });
+
+ it("turn_changed during animation does not immediately move the active seat", async () => {
+ RoleSelect.openFan();
+ document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve();
- expect(grid.children.length).toBe(before + 1);
+
+ window.dispatchEvent(new CustomEvent("room:turn_changed", {
+ detail: { active_slot: 2, starter_roles: [] }
+ }));
+ const activeSeat = testDiv.querySelector(".table-seat.active");
+ expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1
+ });
+
+ it("deferred turn_changed is processed when animation completes", async () => {
+ RoleSelect.openFan();
+ document.querySelector("#id_role_select .card").click();
+ guardConfirm();
+ await Promise.resolve();
+
+ window.dispatchEvent(new CustomEvent("room:turn_changed", {
+ detail: { active_slot: 2, starter_roles: [] }
+ }));
+
+ // Fire onComplete — deferred turn_changed should now run
+ Tray._testFirePlaceCardComplete();
+
+ const activeSeat = testDiv.querySelector(".table-seat.active");
+ expect(activeSeat && activeSeat.dataset.slot).toBe("2");
+ });
+
+ it("turn_changed after animation completes is processed immediately", () => {
+ // No animation in flight — turn_changed should run right away
+ window.dispatchEvent(new CustomEvent("room:turn_changed", {
+ detail: { active_slot: 2, starter_roles: [] }
+ }));
+ expect(Tray.forceClose).toHaveBeenCalled();
+ const activeSeat = testDiv.querySelector(".table-seat.active");
+ expect(activeSeat && activeSeat.dataset.slot).toBe("2");
});
});
@@ -433,9 +483,6 @@ describe("RoleSelect", () => {
);
});
- it("appends a .card to #id_inv_role_card", () => {
- expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
- });
});
describe("dismissing the guard (NVM or outside click)", () => {
@@ -463,10 +510,6 @@ describe("RoleSelect", () => {
expect(window.fetch).not.toHaveBeenCalled();
});
- it("does not add a card to inventory", () => {
- expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
- });
-
it("restores normal mouseleave behaviour on the card", () => {
card.dispatchEvent(new MouseEvent("mouseenter"));
card.dispatchEvent(new MouseEvent("mouseleave"));
diff --git a/src/static/tests/TraySpec.js b/src/static/tests/TraySpec.js
index 264275f..818249a 100644
--- a/src/static/tests/TraySpec.js
+++ b/src/static/tests/TraySpec.js
@@ -422,4 +422,97 @@ describe("Tray", () => {
expect(Tray.isOpen()).toBe(false);
});
});
+
+ // ---------------------------------------------------------------------- //
+ // placeCard() //
+ // ---------------------------------------------------------------------- //
+ //
+ // 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).
+ // 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");
+ });
+
+ 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);
+ });
+
+ it("adds .arc-in to the first cell", () => {
+ Tray.placeCard("PC", null);
+ expect(firstCell.classList.contains("arc-in")).toBe(true);
+ });
+
+ it("removes .arc-in and force-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(Tray.isOpen()).toBe(false);
+ });
+
+ it("calls onComplete after the tray closes", () => {
+ let called = false;
+ Tray.placeCard("PC", () => { called = true; });
+ firstCell.dispatchEvent(new Event("animationend"));
+ 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();
+ });
+ });
});
diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss
index 1fd753c..c118f73 100644
--- a/src/static_src/scss/_room.scss
+++ b/src/static_src/scss/_room.scss
@@ -419,17 +419,6 @@ $seat-r-y: round($seat-r * 0.5); // 65px
justify-content: center;
}
-.room-inventory {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 1rem;
- min-height: 0;
- overflow-y: auto;
- scrollbar-width: thin;
- scrollbar-color: rgba(var(--terUser), 0.3) transparent;
-}
-
.table-seat {
position: absolute;
display: flex;
@@ -617,56 +606,6 @@ $card-h: 120px;
}
}
-// ─── Inventory role card hand ───────────────────────────────────────────────
-//
-// Cards are stacked vertically: only a $strip-height peek of each card below
-// the first is visible by default, showing the role name at the top of the
-// card face. Hovering any card slides it right to pop it clear of the stack.
-
-$inv-card-w: 100px;
-$inv-card-h: 150px;
-$inv-strip: 30px; // visible height of each stacked card after the first
-
-#id_inv_role_card {
- display: flex;
- flex-direction: column;
-
- .card {
- width: $inv-card-w;
- height: $inv-card-h;
- position: relative;
- z-index: 1;
- flex-shrink: 0;
- transition: transform 0.2s ease;
-
- // Every card after the first overlaps the one above it
- & + .card {
- margin-top: -($inv-card-h - $inv-strip);
- }
-
- // Role name pinned to the top of the face so it reads in the strip
- .card-front {
- justify-content: flex-start;
- padding-top: 0.4rem;
- }
-
- // Pop the hovered card to the right, above siblings
- &:hover {
- transform: translateX(1.5rem);
- z-index: 10;
- }
- }
-}
-
-// ─── Partner indicator ─────────────────────────────────────────────────────
-
-.partner-indicator {
- margin-top: 0.5rem;
- font-size: 0.75rem;
- opacity: 0.6;
- text-align: center;
-}
-
// Landscape mobile — aggressively scale down to fit short viewport
@media (orientation: landscape) and (max-width: 1440px) {
.gate-modal {
diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss
index 389dbbb..0cfe05b 100644
--- a/src/static_src/scss/_tray.scss
+++ b/src/static_src/scss/_tray.scss
@@ -117,6 +117,27 @@ $handle-r: 1rem;
&::before { border-color: rgba(var(--quaUser), 1); }
}
+// ─── Role card: arc-in animation (portrait) ─────────────────────────────────
+@keyframes tray-role-arc-in {
+ from { opacity: 0; transform: scale(0.3) translate(-40%, -40%); }
+ to { opacity: 1; transform: scale(1) translate(0, 0); }
+}
+
+.tray-role-card {
+ background: rgba(var(--quaUser), 0.25);
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+ padding: 0.2em;
+ font-size: 0.65rem;
+ color: rgba(var(--quaUser), 1);
+ font-weight: 600;
+
+ &.arc-in {
+ animation: tray-role-arc-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
+ }
+}
+
@keyframes tray-wobble {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
@@ -276,6 +297,16 @@ $handle-r: 1rem;
border-bottom: none;
}
+ // Role card arc-in for landscape
+ @keyframes tray-role-arc-in-landscape {
+ from { opacity: 0; transform: scale(0.3) translate(-40%, 40%); }
+ to { opacity: 1; transform: scale(1) translate(0, 0); }
+ }
+
+ .tray-role-card.arc-in {
+ animation: tray-role-arc-in-landscape 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
+ }
+
@keyframes tray-wobble-landscape {
0%, 100% { transform: translateY(0); }
20% { transform: translateY(-8px); }
diff --git a/src/static_src/tests/RoleSelectSpec.js b/src/static_src/tests/RoleSelectSpec.js
index cc32d4f..df72887 100644
--- a/src/static_src/tests/RoleSelectSpec.js
+++ b/src/static_src/tests/RoleSelectSpec.js
@@ -7,7 +7,6 @@ describe("RoleSelect", () => {
-
`;
document.body.appendChild(testDiv);
window.fetch = jasmine.createSpy("fetch").and.returnValue(
@@ -102,12 +101,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull();
});
- it("clicking a card appends a .card to #id_inv_role_card", () => {
- const card = document.querySelector("#id_role_select .card");
- card.click();
- expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
- });
-
it("clicking a card POSTs to the select_role URL", () => {
const card = document.querySelector("#id_role_select .card");
card.click();
@@ -117,11 +110,6 @@ describe("RoleSelect", () => {
);
});
- it("clicking a card results in exactly one card in inventory", () => {
- const card = document.querySelector("#id_role_select .card");
- card.click();
- expect(document.querySelectorAll("#id_inv_role_card .card").length).toBe(1);
- });
});
// ------------------------------------------------------------------ //
@@ -135,11 +123,6 @@ describe("RoleSelect", () => {
expect(document.getElementById("id_role_select")).toBeNull();
});
- it("does not add a card to inventory", () => {
- RoleSelect.openFan();
- document.querySelector(".role-select-backdrop").click();
- expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
- });
});
// ------------------------------------------------------------------ //
@@ -259,20 +242,13 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => {
- let grid, guardConfirm;
+ let guardConfirm;
beforeEach(() => {
- // Minimal tray grid matching room.html structure
- 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);
- }
- testDiv.appendChild(grid);
-
- spyOn(Tray, "open");
+ // Spy on Tray.placeCard: call the onComplete callback immediately.
+ spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
+ if (cb) cb();
+ });
// Capturing guard spy — holds onConfirm so we can fire it per-test
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
@@ -283,54 +259,128 @@ describe("RoleSelect", () => {
document.querySelector("#id_role_select .card").click();
});
- it("prepends a .tray-role-card to #id_tray_grid on success", async () => {
+ it("calls Tray.placeCard() on success", async () => {
guardConfirm();
await Promise.resolve();
- expect(grid.querySelector(".tray-role-card")).not.toBeNull();
+ expect(Tray.placeCard).toHaveBeenCalled();
});
- it("tray-role-card is the first child of #id_tray_grid", async () => {
+ it("passes the role code string to Tray.placeCard", async () => {
guardConfirm();
await Promise.resolve();
- expect(grid.firstElementChild.classList.contains("tray-role-card")).toBe(true);
+ const roleCode = Tray.placeCard.calls.mostRecent().args[0];
+ expect(typeof roleCode).toBe("string");
+ expect(roleCode.length).toBeGreaterThan(0);
});
- it("tray-role-card carries the selected role as data-role", async () => {
- guardConfirm();
- await Promise.resolve();
- const trayCard = grid.querySelector(".tray-role-card");
- expect(trayCard.dataset.role).toBeTruthy();
- });
-
- it("calls Tray.open() on success", async () => {
- guardConfirm();
- await Promise.resolve();
- expect(Tray.open).toHaveBeenCalled();
- });
-
- it("does not prepend a tray-role-card on server rejection", async () => {
+ it("does not call Tray.placeCard() on server rejection", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: false })
);
guardConfirm();
await Promise.resolve();
- expect(grid.querySelector(".tray-role-card")).toBeNull();
+ expect(Tray.placeCard).not.toHaveBeenCalled();
});
+ });
- it("does not call Tray.open() on server rejection", async () => {
- window.fetch = jasmine.createSpy("fetch").and.returnValue(
- Promise.resolve({ ok: false })
+ // ------------------------------------------------------------------ //
+ // WS turn_changed pause during animation //
+ // ------------------------------------------------------------------ //
+
+ describe("WS turn_changed pause during placeCard animation", () => {
+ let stack, guardConfirm;
+
+ beforeEach(() => {
+ // Six table seats, slot 1 starts active
+ for (let i = 1; i <= 6; i++) {
+ const seat = document.createElement("div");
+ seat.className = "table-seat" + (i === 1 ? " active" : "");
+ seat.dataset.slot = String(i);
+ testDiv.appendChild(seat);
+ }
+ stack = document.createElement("div");
+ stack.className = "card-stack";
+ stack.dataset.state = "ineligible";
+ stack.dataset.userSlots = "1";
+ stack.dataset.starterRoles = "";
+ testDiv.appendChild(stack);
+
+ const grid = document.createElement("div");
+ grid.id = "id_tray_grid";
+ testDiv.appendChild(grid);
+
+ // placeCard spy that holds the onComplete callback
+ let heldCallback = null;
+ spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
+ heldCallback = cb; // don't call immediately — simulate animation in-flight
+ });
+ spyOn(Tray, "forceClose");
+
+ // Expose heldCallback so tests can fire it
+ Tray._testFirePlaceCardComplete = () => {
+ if (heldCallback) heldCallback();
+ };
+
+ window.showGuard = jasmine.createSpy("showGuard").and.callFake(
+ (anchor, message, onConfirm) => { guardConfirm = onConfirm; }
);
- guardConfirm();
- await Promise.resolve();
- expect(Tray.open).not.toHaveBeenCalled();
});
- it("grid grows by exactly 1 on success", async () => {
- const before = grid.children.length;
+ afterEach(() => {
+ delete Tray._testFirePlaceCardComplete;
+ RoleSelect._testReset();
+ });
+
+ it("turn_changed during animation does not call Tray.forceClose immediately", async () => {
+ RoleSelect.openFan();
+ document.querySelector("#id_role_select .card").click();
+ guardConfirm();
+ await Promise.resolve(); // fetch resolves; placeCard called; animation pending
+
+ window.dispatchEvent(new CustomEvent("room:turn_changed", {
+ detail: { active_slot: 2, starter_roles: [] }
+ }));
+ expect(Tray.forceClose).not.toHaveBeenCalled();
+ });
+
+ it("turn_changed during animation does not immediately move the active seat", async () => {
+ RoleSelect.openFan();
+ document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve();
- expect(grid.children.length).toBe(before + 1);
+
+ window.dispatchEvent(new CustomEvent("room:turn_changed", {
+ detail: { active_slot: 2, starter_roles: [] }
+ }));
+ const activeSeat = testDiv.querySelector(".table-seat.active");
+ expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1
+ });
+
+ it("deferred turn_changed is processed when animation completes", async () => {
+ RoleSelect.openFan();
+ document.querySelector("#id_role_select .card").click();
+ guardConfirm();
+ await Promise.resolve();
+
+ window.dispatchEvent(new CustomEvent("room:turn_changed", {
+ detail: { active_slot: 2, starter_roles: [] }
+ }));
+
+ // Fire onComplete — deferred turn_changed should now run
+ Tray._testFirePlaceCardComplete();
+
+ const activeSeat = testDiv.querySelector(".table-seat.active");
+ expect(activeSeat && activeSeat.dataset.slot).toBe("2");
+ });
+
+ it("turn_changed after animation completes is processed immediately", () => {
+ // No animation in flight — turn_changed should run right away
+ window.dispatchEvent(new CustomEvent("room:turn_changed", {
+ detail: { active_slot: 2, starter_roles: [] }
+ }));
+ expect(Tray.forceClose).toHaveBeenCalled();
+ const activeSeat = testDiv.querySelector(".table-seat.active");
+ expect(activeSeat && activeSeat.dataset.slot).toBe("2");
});
});
@@ -433,9 +483,6 @@ describe("RoleSelect", () => {
);
});
- it("appends a .card to #id_inv_role_card", () => {
- expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull();
- });
});
describe("dismissing the guard (NVM or outside click)", () => {
@@ -463,10 +510,6 @@ describe("RoleSelect", () => {
expect(window.fetch).not.toHaveBeenCalled();
});
- it("does not add a card to inventory", () => {
- expect(document.querySelector("#id_inv_role_card .card")).toBeNull();
- });
-
it("restores normal mouseleave behaviour on the card", () => {
card.dispatchEvent(new MouseEvent("mouseenter"));
card.dispatchEvent(new MouseEvent("mouseleave"));
diff --git a/src/static_src/tests/TraySpec.js b/src/static_src/tests/TraySpec.js
index 264275f..818249a 100644
--- a/src/static_src/tests/TraySpec.js
+++ b/src/static_src/tests/TraySpec.js
@@ -422,4 +422,97 @@ describe("Tray", () => {
expect(Tray.isOpen()).toBe(false);
});
});
+
+ // ---------------------------------------------------------------------- //
+ // placeCard() //
+ // ---------------------------------------------------------------------- //
+ //
+ // 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).
+ // 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");
+ });
+
+ 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);
+ });
+
+ it("adds .arc-in to the first cell", () => {
+ Tray.placeCard("PC", null);
+ expect(firstCell.classList.contains("arc-in")).toBe(true);
+ });
+
+ it("removes .arc-in and force-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(Tray.isOpen()).toBe(false);
+ });
+
+ it("calls onComplete after the tray closes", () => {
+ let called = false;
+ Tray.placeCard("PC", () => { called = true; });
+ firstCell.dispatchEvent(new Event("animationend"));
+ 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();
+ });
+ });
});
diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html
index 7592e5c..e3769ee 100644
--- a/src/templates/apps/gameboard/room.html
+++ b/src/templates/apps/gameboard/room.html
@@ -47,32 +47,6 @@
{% endfor %}
{% endif %}
-
-
-
- {% if room.table_status == "ROLE_SELECT" %}
- {% for seat in assigned_seats %}
-
-
?
-
-
{{ seat.get_role_display }}
-
-
- {% endfor %}
- {% elif room.table_status == "SIG_SELECT" and user_seat %}
-
-
-
{{ user_seat.get_role_display }}
-
-
- {% if partner_seat %}
-
- Partner: {{ partner_seat.get_role_display }}
-
- {% endif %}
- {% endif %}
-
-
{% if room.table_status == "SIG_SELECT" and sig_cards %}