From 736b59b5c043fd7ac859e2114c464d168b9d2bb3 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Tue, 31 Mar 2026 00:01:04 -0400 Subject: [PATCH] role-select UX: tray timing delays, seat/circle state polish, 394 ITs green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _animationPending set before fetch (not in .then()) — blocks WS turn advance during in-flight request - _placeCardDelay (3s) + _postTrayDelay (3s) give gamer time to see each step; both zeroed by _testReset() - .role-confirmed class: full-opacity chair after placeCard completes; server-rendered on reload - Slot circles disappear in join order (slot 1 first) via count-based logic, not role-label matching - data-active-slot on card-stack; handleTurnChanged writes it for selectRole() to read - #id_tray_wrap not rendered during gate phase ({% if room.table_status %}) - Tray slide/arc-in slowed to 1s for diagnostics; wobble kept at 0.45s - Obsolete test_roles_revealed_simultaneously FT removed; T8 tray FT uses ROLE_SELECT room - Jasmine macrotask flush pattern: await new Promise(r => setTimeout(r, 0)) after fetch .then() Co-Authored-By: Claude Sonnet 4.6 --- src/apps/epic/static/apps/epic/role-select.js | 116 ++++++-- src/apps/epic/tests/integrated/test_views.py | 114 +++++++- src/apps/epic/views.py | 13 +- src/functional_tests/test_gatekeeper.py | 83 +++--- src/functional_tests/test_room_role_select.py | 124 +++----- src/functional_tests/test_room_tray.py | 16 +- src/static/tests/RoleSelectSpec.js | 184 +++++++++++- src/static_src/scss/_room.scss | 275 +++++++++--------- src/static_src/scss/_tray.scss | 18 +- src/static_src/tests/RoleSelectSpec.js | 185 +++++++++++- .../apps/gameboard/_partials/_gatekeeper.html | 37 --- .../gameboard/_partials/_table_positions.html | 39 ++- src/templates/apps/gameboard/room.html | 29 +- 13 files changed, 833 insertions(+), 400 deletions(-) diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js index b87384f..3033955 100644 --- a/src/apps/epic/static/apps/epic/role-select.js +++ b/src/apps/epic/static/apps/epic/role-select.js @@ -8,6 +8,15 @@ var RoleSelect = (function () { var _animationPending = false; var _pendingTurnChange = null; + // Delay before the tray animation begins (ms). Gives the gamer a moment + // to see their pick confirmed before the tray slides in. Set to 0 by + // _testReset() so Jasmine tests don't need jasmine.clock(). + var _placeCardDelay = 3000; + + // Delay after the tray closes before advancing to the next turn (ms). + // Gives the gamer a moment to see their confirmed seat before the turn moves. + var _postTrayDelay = 3000; + var ROLES = [ { code: "PC", name: "Player", element: "Fire" }, { code: "BC", name: "Builder", element: "Stone" }, @@ -36,6 +45,10 @@ var RoleSelect = (function () { _turnChangedBeforeFetch = false; // fresh selection, reset the race flag closeFan(); + // Show the tray handle — gamer confirmed a pick, tray animation about to run + var trayWrap = document.getElementById("id_tray_wrap"); + if (trayWrap) trayWrap.classList.remove("role-select-phase"); + // Immediately lock the stack — do not wait for WS turn_changed var stack = document.querySelector(".card-stack[data-starter-roles]"); if (stack) { @@ -45,12 +58,24 @@ var RoleSelect = (function () { stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode; } - // Mark position as actively being seated (glow state) - var activePos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]'); + // Mark seat as actively being claimed (glow state) + var activePos = document.querySelector('.table-seat[data-role="' + roleCode + '"]'); if (activePos) activePos.classList.add('active'); + // Immediately fade out the gate-slot circle for the current turn's slot + var activeSlot = stack ? stack.dataset.activeSlot : null; + if (activeSlot) { + var slotCircle = document.querySelector('.gate-slot[data-slot="' + activeSlot + '"]'); + if (slotCircle) slotCircle.classList.add('role-assigned'); + } + var url = getSelectRoleUrl(); if (!url) return; + + // Block handleTurnChanged immediately — WS turn_changed can arrive while + // the fetch is in-flight and must be deferred until our animation completes. + _animationPending = true; + fetch(url, { method: "POST", headers: { @@ -61,34 +86,41 @@ var RoleSelect = (function () { }).then(function (response) { if (!response.ok) { // Server rejected (role already taken) — undo optimistic update + _animationPending = false; if (stack) { stack.dataset.starterRoles = stack.dataset.starterRoles .split(",").filter(function (r) { return r.trim() !== roleCode; }).join(","); } openFan(); } else { - // 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. + // Animate the role card into the tray: open, arc-in, force-close. + // Any turn_changed that arrived while the fetch was in-flight is + // queued in _pendingTurnChange and will run after onComplete. if (typeof Tray !== "undefined") { - _animationPending = true; - Tray.placeCard(roleCode, function () { - _animationPending = false; - // Swap ban → check and clear glow on the seated position - var seatedPos = document.querySelector('.table-position[data-role-label="' + roleCode + '"]'); - if (seatedPos) { - seatedPos.classList.remove('active'); - var ban = seatedPos.querySelector('.fa-ban'); - if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); } - } - if (_pendingTurnChange) { - var ev = _pendingTurnChange; - _pendingTurnChange = null; - handleTurnChanged(ev); - } - }); + setTimeout(function () { + Tray.placeCard(roleCode, function () { + // Swap ban → check, clear glow, mark seat as confirmed + var seatedPos = document.querySelector('.table-seat[data-role="' + roleCode + '"]'); + if (seatedPos) { + seatedPos.classList.remove('active'); + seatedPos.classList.add('role-confirmed'); + var ban = seatedPos.querySelector('.fa-ban'); + if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); } + } + // Hold _animationPending through the post-tray pause so any + // turn_changed WS event that arrives now is still deferred. + setTimeout(function () { + _animationPending = false; + if (_pendingTurnChange) { + var ev = _pendingTurnChange; + _pendingTurnChange = null; + handleTurnChanged(ev); + } + }, _postTrayDelay); + }); + }, _placeCardDelay); + } else { + _animationPending = false; } } }); @@ -189,11 +221,39 @@ var RoleSelect = (function () { _turnChangedBeforeFetch = true; if (typeof Tray !== "undefined") Tray.forceClose(); - // Clear any stale .active glow from position indicators - document.querySelectorAll('.table-position.active').forEach(function (p) { + // Hide tray handle until the next player confirms their pick + var trayWrap = document.getElementById("id_tray_wrap"); + if (trayWrap) trayWrap.classList.add("role-select-phase"); + + // Clear any stale .active glow from hex seats + document.querySelectorAll('.table-seat.active').forEach(function (p) { p.classList.remove('active'); }); + // Sync seat icons from starter_roles so state persists without a reload + if (event.detail.starter_roles) { + var assignedRoles = event.detail.starter_roles; + document.querySelectorAll(".table-seat").forEach(function (seat) { + var role = seat.dataset.role; + if (assignedRoles.indexOf(role) !== -1) { + seat.classList.add("role-confirmed"); + var ban = seat.querySelector(".fa-ban"); + if (ban) { ban.classList.remove("fa-ban"); ban.classList.add("fa-circle-check"); } + } + }); + // Hide slot circles in turn order: slots 1..N done when N roles assigned + var assignedCount = assignedRoles.length; + document.querySelectorAll(".gate-slot[data-slot]").forEach(function (circle) { + if (parseInt(circle.dataset.slot, 10) <= assignedCount) { + circle.classList.add("role-assigned"); + } + }); + } + + // Update active slot on the card stack so selectRole() can read it + var stack = document.querySelector(".card-stack[data-user-slots]"); + if (stack) stack.dataset.activeSlot = active; + var stack = document.querySelector(".card-stack[data-user-slots]"); if (stack) { // Sync starter-roles from server so the fan reflects actual DB state @@ -221,12 +281,10 @@ var RoleSelect = (function () { } } - // Move .active to the newly active seat + // Clear any stale seat glow (JS-only; glow is only during tray animation) document.querySelectorAll(".table-seat.active").forEach(function (s) { s.classList.remove("active"); }); - var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']"); - if (activeSeat) activeSeat.classList.add("active"); } window.addEventListener("room:role_select_start", init); @@ -247,6 +305,8 @@ var RoleSelect = (function () { _testReset: function () { _animationPending = false; _pendingTurnChange = null; + _placeCardDelay = 0; + _postTrayDelay = 0; }, }; }()); diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index a3039ab..7d6dc65 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -402,7 +402,9 @@ class RoleSelectRenderingTest(TestCase): response = self.client.get( self.url ) - self.assertNotContains(response, "fa-ban") + # Seat ban icons carry "position-status-icon"; card-stack ban does not. + # Assert the bare "fa-solid fa-ban" (card-stack form) is absent. + self.assertNotContains(response, 'class="fa-solid fa-ban"') def test_gatekeeper_overlay_absent_when_role_select(self): self.client.force_login(self.founder) @@ -411,6 +413,21 @@ class RoleSelectRenderingTest(TestCase): ) self.assertNotContains(response, "gate-overlay") + def test_tray_wrap_has_role_select_phase_class(self): + # Tray handle hidden until gamer confirms a role pick + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"') + + def test_tray_absent_during_gatekeeper_phase(self): + # Tray must not render before the gamer occupies a seat + room = Room.objects.create(name="Gate Room", owner=self.founder) + self.client.force_login(self.founder) + response = self.client.get( + reverse("epic:gatekeeper", kwargs={"room_id": room.id}) + ) + self.assertNotContains(response, 'id="id_tray_wrap"') + def test_six_table_seats_rendered(self): self.client.force_login(self.founder) response = self.client.get( @@ -418,20 +435,66 @@ class RoleSelectRenderingTest(TestCase): ) self.assertContains(response, "table-seat", count=6) - def test_active_table_seat_has_active_class(self): - self.client.force_login(self.founder) # slot 1 is active - response = self.client.get( - self.url - ) - self.assertContains(response, 'class="table-seat active"') - - def test_inactive_table_seat_lacks_active_class(self): + def test_table_seats_never_active_on_load(self): + # Seat glow is JS-only (during tray animation); never server-rendered self.client.force_login(self.founder) - response = self.client.get( - self.url - ) - # Slots 2–6 are not active, so at least one plain table-seat exists - self.assertContains(response, 'class="table-seat"') + response = self.client.get(self.url) + self.assertNotContains(response, 'class="table-seat active"') + + def test_assigned_seat_renders_role_confirmed_class(self): + # A seat with a role already picked must load as role-confirmed (opaque chair) + self.gamers[0].refresh_from_db() + seat = self.room.table_seats.get(slot_number=1) + seat.role = "PC" + seat.save() + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, 'table-seat role-confirmed') + + def test_unassigned_seat_lacks_role_confirmed_class(self): + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertNotContains(response, 'table-seat role-confirmed') + + def test_assigned_slot_circle_renders_role_assigned_class(self): + # Slot 1 circle hidden because 1 role was assigned (count-based, not role-label-based) + seat = self.room.table_seats.get(slot_number=1) + seat.role = "PC" + seat.save() + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, 'gate-slot filled role-assigned') + + def test_slot_circle_hides_by_count_not_role_label(self): + # Gamer in slot 1 picks NC (not PC) — slot 1 circle must still hide, not slot 2's + seat = self.room.table_seats.get(slot_number=1) + seat.role = "NC" + seat.save() + self.client.force_login(self.founder) + response = self.client.get(self.url) + content = response.content.decode() + import re + # Template renders class before data-slot; capture both orderings + circles = re.findall(r'class="([^"]*gate-slot[^"]*)"[^>]*data-slot="(\d)"', content) + slot1_classes = next((cls for cls, slot in circles if slot == "1"), "") + slot2_classes = next((cls for cls, slot in circles if slot == "2"), "") + self.assertIn("role-assigned", slot1_classes) + self.assertNotIn("role-assigned", slot2_classes) + + def test_unassigned_slot_circle_lacks_role_assigned_class(self): + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertNotContains(response, 'role-assigned') + + def test_position_strip_rendered_during_role_select(self): + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, "position-strip") + + def test_position_strip_has_six_gate_slots(self): + self.client.force_login(self.founder) + response = self.client.get(self.url) + self.assertContains(response, "gate-slot", count=6) def test_card_stack_has_data_user_slots_for_eligible_gamer(self): self.client.force_login(self.founder) # founder is slot 1 only @@ -447,6 +510,29 @@ class RoleSelectRenderingTest(TestCase): ) self.assertContains(response, 'data-user-slots="2"') + def test_assigned_seat_renders_check_icon(self): + seat = self.room.table_seats.get(slot_number=1) + seat.role = "PC" + seat.save() + self.client.force_login(self.founder) + response = self.client.get(self.url) + content = response.content.decode() + # The PC seat should have fa-circle-check, not fa-ban + pc_seat_start = content.index('data-role="PC"') + pc_seat_chunk = content[pc_seat_start:pc_seat_start + 300] + self.assertIn("fa-circle-check", pc_seat_chunk) + self.assertNotIn("fa-ban", pc_seat_chunk) + + def test_unassigned_seat_renders_ban_icon(self): + # slot 2's role is still null + self.client.force_login(self.founder) + response = self.client.get(self.url) + content = response.content.decode() + nc_seat_start = content.index('data-role="NC"') + nc_seat_chunk = content[nc_seat_start:nc_seat_start + 300] + self.assertIn("fa-ban", nc_seat_chunk) + self.assertNotIn("fa-circle-check", nc_seat_chunk) + class PickRolesViewTest(TestCase): def setUp(self): diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 99e142d..d18e8ca 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -75,9 +75,16 @@ SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} def _gate_positions(room): - """Return list of dicts [{slot, role_label}] for _table_positions.html.""" + """Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html.""" + # Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless + # of which role each gamer chose — so use count, not role matching. + assigned_count = room.table_seats.exclude(role__isnull=True).count() return [ - {"slot": slot, "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, "")} + { + "slot": slot, + "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""), + "role_assigned": slot.slot_number <= assigned_count, + } for slot in room.gate_slots.order_by("slot_number") ] @@ -147,6 +154,7 @@ def _gate_context(room, user): "carte_nvm_slot_number": carte_nvm_slot_number, "carte_next_slot_number": carte_next_slot_number, "gate_positions": _gate_positions(room), + "starter_roles": [], } @@ -199,6 +207,7 @@ def _role_select_context(room, user): ) if user.is_authenticated else [], "active_slot": active_slot, "gate_positions": _gate_positions(room), + "slots": room.gate_slots.order_by("slot_number"), } if room.table_status == Room.SIG_SELECT: user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 338c54c..66f9e21 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -607,20 +607,20 @@ class PositionIndicatorsTest(FunctionalTest): ) # ------------------------------------------------------------------ # - # Test P1 — 6 position indicators present while gatekeeper is open # + # Test P1 — 6 position circles present in strip alongside gatekeeper # # ------------------------------------------------------------------ # def test_position_indicators_visible_alongside_gatekeeper(self): self.browser.get(self.gate_url) - # Gatekeeper modal is open self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") ) - # Six .table-position elements are rendered outside the modal - positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position") - self.assertEqual(len(positions), 6) - for pos in positions: - self.assertTrue(pos.is_displayed()) + # Six .gate-slot elements are rendered in .position-strip, outside modal + strip = self.browser.find_element(By.CSS_SELECTOR, ".position-strip") + slots = strip.find_elements(By.CSS_SELECTOR, ".gate-slot") + self.assertEqual(len(slots), 6) + for slot in slots: + self.assertTrue(slot.is_displayed()) # ------------------------------------------------------------------ # # Test P2 — URL drops /gate/ after pick_roles # @@ -631,63 +631,57 @@ class PositionIndicatorsTest(FunctionalTest): "founder@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", ]) - # Simulate pick_roles having fired: room advances to ROLE_SELECT self.room.table_status = Room.ROLE_SELECT self.room.save() - # Navigating to the /gate/ URL should redirect to the plain room URL self.browser.get(self.gate_url) - expected_url = ( - f"{self.live_server_url}/gameboard/room/{self.room.id}/" - ) + expected_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/" self.wait_for( lambda: self.assertEqual(self.browser.current_url, expected_url) ) # ------------------------------------------------------------------ # - # Test P3 — Each position has a chair icon and correct role label # + # Test P3 — Gate-slot circles live outside the modal # # ------------------------------------------------------------------ # - def test_position_shows_chair_icon_and_role_label(self): - SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} + def test_position_circles_outside_gatekeeper_modal(self): + """The numbered position circles must NOT be descendants of .gate-modal — + they live in .position-strip which sits above the backdrop.""" self.browser.get(self.gate_url) self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal") ) - for slot_number, role_label in SLOT_ROLE_LABELS.items(): - pos = self.browser.find_element( - By.CSS_SELECTOR, f".table-position[data-slot='{slot_number}']" - ) - # Chair icon present - self.assertTrue(pos.find_elements(By.CSS_SELECTOR, ".fa-chair")) - # Role label attribute and visible text - self.assertEqual(pos.get_attribute("data-role-label"), role_label) - label_el = pos.find_element(By.CSS_SELECTOR, ".position-role-label") - self.assertEqual(label_el.text.strip(), role_label) + # No .gate-slot inside the modal + modal_slots = self.browser.find_elements( + By.CSS_SELECTOR, ".gate-modal .gate-slot" + ) + self.assertEqual(len(modal_slots), 0) + # All 6 live in .position-strip + strip_slots = self.browser.find_elements( + By.CSS_SELECTOR, ".position-strip .gate-slot" + ) + self.assertEqual(len(strip_slots), 6) # ------------------------------------------------------------------ # - # Test P4 — Unoccupied position shows ban icon # + # Test P4 — Each circle displays its slot number # # ------------------------------------------------------------------ # - def test_unoccupied_position_shows_ban_icon(self): + def test_position_circle_shows_slot_number(self): self.browser.get(self.gate_url) self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip") ) - # All slots are empty — every position should have a ban icon - positions = self.browser.find_elements(By.CSS_SELECTOR, ".table-position") - for pos in positions: - self.assertTrue( - pos.find_elements(By.CSS_SELECTOR, ".fa-ban"), - f"Expected .fa-ban on slot {pos.get_attribute('data-slot')}", + for n in range(1, 7): + slot_el = self.browser.find_element( + By.CSS_SELECTOR, f".position-strip .gate-slot[data-slot='{n}']" ) + self.assertIn(str(n), slot_el.text) # ------------------------------------------------------------------ # - # Test P5 — Occupied position shows check icon after token confirmed # + # Test P5 — Filled slot carries .filled class in strip # # ------------------------------------------------------------------ # - def test_occupied_position_shows_check_icon_after_token_confirmed(self): - # Slot 1 is filled via ORM + def test_filled_slot_shown_in_strip(self): from apps.epic.models import GateSlot slot = self.room.gate_slots.get(slot_number=1) slot.gamer = self.founder @@ -696,10 +690,13 @@ class PositionIndicatorsTest(FunctionalTest): self.browser.get(self.gate_url) self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip") ) - pos1 = self.browser.find_element( - By.CSS_SELECTOR, ".table-position[data-slot='1']" + slot1 = self.browser.find_element( + By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='1']" ) - self.assertTrue(pos1.find_elements(By.CSS_SELECTOR, ".fa-circle-check")) - self.assertFalse(pos1.find_elements(By.CSS_SELECTOR, ".fa-ban")) + self.assertIn("filled", slot1.get_attribute("class")) + slot2 = self.browser.find_element( + By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='2']" + ) + self.assertIn("empty", slot2.get_attribute("class")) diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py index 0141253..e5d0b5d 100644 --- a/src/functional_tests/test_room_role_select.py +++ b/src/functional_tests/test_room_role_select.py @@ -445,12 +445,14 @@ class RoleSelectTest(FunctionalTest): ) # ------------------------------------------------------------------ # - # Test 7 — All roles revealed simultaneously after all gamers select # + # Test 8a — Hex seats carry role labels during role select # # ------------------------------------------------------------------ # - def test_roles_revealed_simultaneously_after_all_select(self): + def test_seats_around_hex_have_role_labels(self): + """During role select the 6 .table-seat elements carry data-role + attributes matching the fixed slot→role mapping (PC at slot 1, etc.).""" founder, _ = User.objects.get_or_create(email="founder@test.io") - room = Room.objects.create(name="Reveal Test", owner=founder) + room = Room.objects.create(name="Seat Label Test", owner=founder) _fill_room_via_orm(room, [ "founder@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", @@ -462,38 +464,24 @@ class RoleSelectTest(FunctionalTest): self.create_pre_authenticated_session("founder@test.io") self.browser.get(room_url) - # Assign all roles via ORM (simulating all gamers having chosen) - from apps.epic.models import TableSeat - roles = ["PC", "BC", "SC", "AC", "NC", "EC"] - for i, slot in enumerate(room.gate_slots.order_by("slot_number")): - TableSeat.objects.create( - room=room, - gamer=slot.gamer, - slot_number=slot.slot_number, - role=roles[i], - role_revealed=True, - ) - room.table_status = Room.SIG_SELECT - room.save() - - self.browser.refresh() - - # Sig deck is present (page has transitioned to SIG_SELECT) + expected = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} self.wait_for( - lambda: self.browser.find_element(By.ID, "id_sig_deck") + lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat") ) - + for slot_number, role_label in expected.items(): + seat = self.browser.find_element( + By.CSS_SELECTOR, f".table-seat[data-slot='{slot_number}']" + ) + self.assertEqual(seat.get_attribute("data-role"), role_label) # ------------------------------------------------------------------ # - # Test 8a — Position glows while role card is being placed # + # Test 8b — Hex seats show .fa-ban when empty # # ------------------------------------------------------------------ # - def test_position_glows_when_role_card_confirmed(self): - """Immediately after confirming a role pick, the matching - .table-position should receive .active (the glow state) before - the tray animation completes.""" + def test_seats_show_ban_icon_when_empty(self): + """All 6 seats carry .fa-ban before any role has been chosen.""" founder, _ = User.objects.get_or_create(email="founder@test.io") - room = Room.objects.create(name="Position Glow Test", owner=founder) + room = Room.objects.create(name="Seat Ban Test", owner=founder) _fill_room_via_orm(room, [ "founder@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", @@ -509,32 +497,26 @@ class RoleSelectTest(FunctionalTest): self.create_pre_authenticated_session("founder@test.io") self.browser.get(room_url) - # Open fan, click first card (PC), confirm guard self.wait_for( - lambda: self.browser.find_element( - By.CSS_SELECTOR, ".card-stack[data-state='eligible']" - ) - ).click() - self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) - self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() - self.confirm_guard() - - # PC position gains .active immediately after confirmation - self.wait_for( - lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-position[data-role-label='PC'].active" - ) + lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat") ) + seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat") + self.assertEqual(len(seats), 6) + for seat in seats: + self.assertTrue( + seat.find_elements(By.CSS_SELECTOR, ".fa-ban"), + f"Expected .fa-ban on seat slot {seat.get_attribute('data-slot')}", + ) # ------------------------------------------------------------------ # - # Test 8b — Position shows check icon after tray sequence ends # + # Test 8c — Hex seat gets .fa-circle-check after role selected # # ------------------------------------------------------------------ # - def test_position_gets_check_when_tray_sequence_ends(self): - """After the tray arc-in animation completes and the tray closes, - the PC .table-position should show .fa-circle-check and no .fa-ban.""" + def test_seat_gets_check_after_role_selected(self): + """After confirming a role pick the corresponding hex seat should + show .fa-circle-check and lose .fa-ban.""" founder, _ = User.objects.get_or_create(email="founder@test.io") - room = Room.objects.create(name="Position Check Test", owner=founder) + room = Room.objects.create(name="Seat Check Test", owner=founder) _fill_room_via_orm(room, [ "founder@test.io", "amigo@test.io", "bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io", @@ -550,7 +532,7 @@ class RoleSelectTest(FunctionalTest): self.create_pre_authenticated_session("founder@test.io") self.browser.get(room_url) - # Open fan, pick PC card, confirm guard + # Open fan, pick first card (PC), confirm guard self.wait_for( lambda: self.browser.find_element( By.CSS_SELECTOR, ".card-stack[data-state='eligible']" @@ -560,23 +542,23 @@ class RoleSelectTest(FunctionalTest): self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() self.confirm_guard() - # Wait for tray animation to complete (tray closes) + # Wait for tray animation to complete self.wait_for( lambda: self.assertFalse( self.browser.execute_script("return Tray.isOpen()"), - "Tray should close after arc-in sequence" + "Tray should close after arc-in sequence", ) ) - # PC position now shows check icon, ban icon gone + # The PC seat (slot 1) now shows check, no ban self.wait_for( lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-circle-check" + By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-circle-check" ) ) self.assertEqual( len(self.browser.find_elements( - By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-ban" + By.CSS_SELECTOR, ".table-seat[data-role='PC'] .fa-ban" )), 0, ) @@ -745,11 +727,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): ) room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/" - # 1. Watcher loads the room — slot 1 is active on initial render + # 1. Watcher (slot 2) loads the room self.create_pre_authenticated_session("watcher@test.io") self.browser.get(room_url) self.wait_for(lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-seat.active[data-slot='1']" + By.CSS_SELECTOR, ".card-stack[data-state='ineligible']" )) # 2. Founder picks a role in second browser @@ -764,16 +746,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() self.confirm_guard(browser=self.browser2) - # 3. Watcher's seat arc moves to slot 2 — no page refresh + # 3. Watcher's turn arrives via WS — card-stack becomes eligible self.wait_for(lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" + By.CSS_SELECTOR, ".card-stack[data-state='eligible']" )) - self.assertEqual( - len(self.browser.find_elements( - By.CSS_SELECTOR, ".table-seat.active[data-slot='1']" - )), - 0, - ) finally: self.browser2.quit() @@ -836,16 +812,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): return card !== null && card === card.parentElement.firstElementChild; """))) - # Turn advances via WS — seat 2 becomes active. - self.wait_for(lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" - )) - - # Tray must be closed: forceClose() fires in handleTurnChanged. - self.assertFalse( + # Turn advances via WS — tray must close (forceClose in handleTurnChanged). + self.wait_for(lambda: self.assertFalse( self.browser.execute_script("return Tray.isOpen()"), "Tray should be closed after turn advances" - ) + )) def test_landscape_tray_closes_on_turn_advance(self): """Landscape: role card at leftmost grid square; tray closes when @@ -869,15 +840,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): return card !== null && card === card.parentElement.firstElementChild; """))) - # Turn advances via WS — seat 2 becomes active. - self.wait_for(lambda: self.browser.find_element( - By.CSS_SELECTOR, ".table-seat.active[data-slot='2']" - )) - - # Tray must be closed. - self.assertFalse( + # Turn advances via WS — tray must close (forceClose in handleTurnChanged). + self.wait_for(lambda: self.assertFalse( self.browser.execute_script("return Tray.isOpen()"), "Tray should be closed after turn advances" - ) + )) diff --git a/src/functional_tests/test_room_tray.py b/src/functional_tests/test_room_tray.py index 20e22bd..3dacaaf 100644 --- a/src/functional_tests/test_room_tray.py +++ b/src/functional_tests/test_room_tray.py @@ -80,6 +80,20 @@ class TrayTest(FunctionalTest): document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true})); """, btn, start_y, end_y) + def _make_role_select_room(self, founder_email="founder@test.io"): + from apps.epic.models import TableSeat + founder, _ = User.objects.get_or_create(email=founder_email) + room = Room.objects.create(name="Tray Test Room", owner=founder) + emails = [founder_email, "nc@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io"] + _fill_room_via_orm(room, emails) + room.table_status = Room.ROLE_SELECT + room.save() + for i, email in enumerate(emails, start=1): + gamer, _ = User.objects.get_or_create(email=email) + TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i) + return room + def _make_sig_select_room(self, founder_email="founder@test.io"): founder, _ = User.objects.get_or_create(email=founder_email) room = Room.objects.create(name="Tray Test Room", owner=founder) @@ -262,7 +276,7 @@ class TrayTest(FunctionalTest): @tag('two-browser') def test_tray_grid_is_1_column_by_8_rows_in_portrait(self): - room = self._make_sig_select_room() + room = self._make_role_select_room() self.create_pre_authenticated_session("founder@test.io") self.browser.get(self._room_url(room)) diff --git a/src/static/tests/RoleSelectSpec.js b/src/static/tests/RoleSelectSpec.js index df72887..1aaf626 100644 --- a/src/static/tests/RoleSelectSpec.js +++ b/src/static/tests/RoleSelectSpec.js @@ -2,6 +2,7 @@ describe("RoleSelect", () => { let testDiv; beforeEach(() => { + RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange testDiv = document.createElement("div"); testDiv.innerHTML = `
{ // ------------------------------------------------------------------ // describe("room:turn_changed event", () => { - let stack; + let stack, trayWrap; beforeEach(() => { // Six table seats, slot 1 starts active @@ -169,6 +170,11 @@ describe("RoleSelect", () => { stack.dataset.userSlots = "1"; stack.dataset.starterRoles = ""; testDiv.appendChild(stack); + + trayWrap = document.createElement("div"); + trayWrap.id = "id_tray_wrap"; + trayWrap.className = "role-select-phase"; + testDiv.appendChild(trayWrap); }); it("calls Tray.forceClose() on turn change", () => { @@ -179,13 +185,19 @@ describe("RoleSelect", () => { expect(Tray.forceClose).toHaveBeenCalled(); }); - it("moves .active to the newly active seat", () => { + it("clears .active from all seats on turn change", () => { window.dispatchEvent(new CustomEvent("room:turn_changed", { detail: { active_slot: 2 } })); - expect( - testDiv.querySelector(".table-seat.active").dataset.slot - ).toBe("2"); + expect(testDiv.querySelector(".table-seat.active")).toBeNull(); + }); + + it("re-adds role-select-phase to tray wrap on turn change", () => { + trayWrap.classList.remove("role-select-phase"); // simulate it was shown + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2 } + })); + expect(trayWrap.classList.contains("role-select-phase")).toBe(true); }); it("removes .active from the previously active seat", () => { @@ -231,6 +243,119 @@ describe("RoleSelect", () => { stack.click(); expect(document.querySelector(".role-select-backdrop")).toBeNull(); }); + + it("updates seat icon to fa-circle-check when role appears in starter_roles", () => { + const seat = document.createElement("div"); + seat.className = "table-seat"; + seat.dataset.role = "PC"; + const ban = document.createElement("i"); + ban.className = "position-status-icon fa-solid fa-ban"; + seat.appendChild(ban); + testDiv.appendChild(seat); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(seat.querySelector(".fa-ban")).toBeNull(); + expect(seat.querySelector(".fa-circle-check")).not.toBeNull(); + }); + + it("adds role-confirmed to seat when role appears in starter_roles", () => { + const seat = document.createElement("div"); + seat.className = "table-seat"; + seat.dataset.role = "PC"; + testDiv.appendChild(seat); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(seat.classList.contains("role-confirmed")).toBe(true); + }); + + it("leaves seat icon as fa-ban when role not in starter_roles", () => { + const seat = document.createElement("div"); + seat.className = "table-seat"; + seat.dataset.role = "NC"; + const ban = document.createElement("i"); + ban.className = "position-status-icon fa-solid fa-ban"; + seat.appendChild(ban); + testDiv.appendChild(seat); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(seat.querySelector(".fa-ban")).not.toBeNull(); + expect(seat.querySelector(".fa-circle-check")).toBeNull(); + }); + + it("adds role-assigned to slot-1 circle when 1 role assigned", () => { + const circle = document.createElement("div"); + circle.className = "gate-slot filled"; + circle.dataset.slot = "1"; + testDiv.appendChild(circle); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(circle.classList.contains("role-assigned")).toBe(true); + }); + + it("leaves slot-2 circle visible when only 1 role assigned", () => { + const circle = document.createElement("div"); + circle.className = "gate-slot filled"; + circle.dataset.slot = "2"; + testDiv.appendChild(circle); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(circle.classList.contains("role-assigned")).toBe(false); + }); + + it("updates data-active-slot on card stack to the new active slot", () => { + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + expect(stack.dataset.activeSlot).toBe("2"); + }); + }); + + // ------------------------------------------------------------------ // + // selectRole slot-circle fade-out // + // ------------------------------------------------------------------ // + + describe("selectRole() slot-circle behaviour", () => { + let circle, stack; + + beforeEach(() => { + // Gate-slot circle for slot 1 (active turn) + circle = document.createElement("div"); + circle.className = "gate-slot filled"; + circle.dataset.slot = "1"; + testDiv.appendChild(circle); + + // Card stack with active-slot=1 so selectRole() knows which circle to hide + stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "eligible"; + stack.dataset.starterRoles = ""; + stack.dataset.userSlots = "1"; + stack.dataset.activeSlot = "1"; + testDiv.appendChild(stack); + + spyOn(Tray, "placeCard"); + }); + + it("adds role-assigned to the active slot's circle immediately on confirm", () => { + RoleSelect.openFan(); + document.querySelector("#id_role_select .card").click(); + expect(circle.classList.contains("role-assigned")).toBe(true); + }); }); // ------------------------------------------------------------------ // @@ -242,9 +367,14 @@ describe("RoleSelect", () => { // ------------------------------------------------------------------ // describe("tray card after successful role selection", () => { - let guardConfirm; + let guardConfirm, trayWrap; beforeEach(() => { + trayWrap = document.createElement("div"); + trayWrap.id = "id_tray_wrap"; + trayWrap.className = "role-select-phase"; + testDiv.appendChild(trayWrap); + // Spy on Tray.placeCard: call the onComplete callback immediately. spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { if (cb) cb(); @@ -261,13 +391,15 @@ describe("RoleSelect", () => { it("calls Tray.placeCard() on success", async () => { guardConfirm(); - await Promise.resolve(); + await Promise.resolve(); // flush fetch .then() + await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout expect(Tray.placeCard).toHaveBeenCalled(); }); it("passes the role code string to Tray.placeCard", async () => { guardConfirm(); - await Promise.resolve(); + await Promise.resolve(); // flush fetch .then() + await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout const roleCode = Tray.placeCard.calls.mostRecent().args[0]; expect(typeof roleCode).toBe("string"); expect(roleCode.length).toBeGreaterThan(0); @@ -281,6 +413,27 @@ describe("RoleSelect", () => { await Promise.resolve(); expect(Tray.placeCard).not.toHaveBeenCalled(); }); + + it("removes role-select-phase from tray wrap on successful pick", async () => { + guardConfirm(); + await Promise.resolve(); + expect(trayWrap.classList.contains("role-select-phase")).toBe(false); + }); + + it("adds role-confirmed class to the seated position after placeCard completes", async () => { + // Add a seat element matching the first available role (PC) + const seat = document.createElement("div"); + seat.className = "table-seat"; + seat.dataset.role = "PC"; + seat.innerHTML = ''; + testDiv.appendChild(seat); + + guardConfirm(); + await Promise.resolve(); // fetch resolves + placeCard fires + await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout + + expect(seat.classList.contains("role-confirmed")).toBe(true); + }); }); // ------------------------------------------------------------------ // @@ -360,17 +513,20 @@ describe("RoleSelect", () => { RoleSelect.openFan(); document.querySelector("#id_role_select .card").click(); guardConfirm(); - await Promise.resolve(); + await Promise.resolve(); // flush fetch .then() + await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set window.dispatchEvent(new CustomEvent("room:turn_changed", { detail: { active_slot: 2, starter_roles: [] } })); - // Fire onComplete — deferred turn_changed should now run + // Fire onComplete — post-tray delay (0 in tests) still uses setTimeout Tray._testFirePlaceCardComplete(); + await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout - const activeSeat = testDiv.querySelector(".table-seat.active"); - expect(activeSeat && activeSeat.dataset.slot).toBe("2"); + // Seat glow is JS-only (tray animation window); after deferred + // handleTurnChanged runs, all seat glows are cleared. + expect(testDiv.querySelector(".table-seat.active")).toBeNull(); }); it("turn_changed after animation completes is processed immediately", () => { @@ -379,8 +535,8 @@ describe("RoleSelect", () => { detail: { active_slot: 2, starter_roles: [] } })); expect(Tray.forceClose).toHaveBeenCalled(); - const activeSeat = testDiv.querySelector(".table-seat.active"); - expect(activeSeat && activeSeat.dataset.slot).toBe("2"); + // Seats are not persistently glowed; all active cleared + expect(testDiv.querySelector(".table-seat.active")).toBeNull(); }); }); diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 8af3f8d..576ba98 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -239,68 +239,6 @@ html:has(.gate-backdrop) { } } - .gate-slots { - display: flex; - flex-direction: row; - align-items: center; - gap: $gate-gap; - - .gate-slot { - position: relative; - width: $gate-node; - height: $gate-node; - border-radius: 50%; - border: $gate-line solid rgba(var(--terUser), 1); - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - flex-shrink: 0; - - &.filled, - &.reserved { - background: rgba(var(--terUser), 0.2); - } - - &.filled:hover, - &.reserved:hover { - box-shadow: - -0.1rem -0.1rem 1rem rgba(var(--ninUser), 1), - -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1), - 0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1), - ; - } - - .slot-number { - font-size: 0.7em; - opacity: 0.5; - } - - .slot-gamer { display: none; } - - form { - position: absolute; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - } - - // CARTE drop-target circle — matches .reserved appearance - &:has(.drop-token-btn) { - background: rgba(var(--terUser), 0.2); - - &:hover { - box-shadow: - -0.1rem -0.1rem 1rem rgba(var(--ninUser), 1), - -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1), - 0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1), - ; - } - } - } - } - .form-container { margin-top: 1rem; } @@ -317,24 +255,6 @@ html:has(.gate-backdrop) { } .token-slot { min-width: 150px; } - - .gate-slots { - display: grid; - grid-template-columns: repeat(3, 52px); - grid-template-rows: repeat(2, 52px); - gap: 24px; - - .gate-slot { - width: 52px; - height: 52px; - &:nth-child(1) { grid-column: 1; grid-row: 1; } - &:nth-child(2) { grid-column: 2; grid-row: 1; } - &:nth-child(3) { grid-column: 3; grid-row: 1; } - &:nth-child(4) { grid-column: 1; grid-row: 2; } - &:nth-child(5) { grid-column: 2; grid-row: 2; } - &:nth-child(6) { grid-column: 3; grid-row: 2; } - } - } } } @@ -366,59 +286,104 @@ $seat-r: 130px; $seat-r-x: round($seat-r * 0.866); // 113px $seat-r-y: round($seat-r * 0.5); // 65px -// .table-position anchors at edge midpoints (pointy-top hex). +// Seat edge-midpoint geometry (pointy-top hex). // Apothem ≈ 80px + 30px clearance = 110px total push from centre. $pos-d: 110px; $pos-d-x: round($pos-d * 0.5); // 55px $pos-d-y: round($pos-d * 0.866); // 95px -.table-position { +// ─── Position strip ──────────────────────────────────────────────────────── +// Numbered gate-slot circles rendered above the backdrop (z 130 > overlay 120 +// > backdrop 100). .room-page is position:relative with no z-index, so its +// absolute children share the root stacking context with the fixed overlays. +.position-strip { position: absolute; - z-index: 110; - pointer-events: none; - transform: translate(-50%, -50%); + top: 0.5rem; + left: 0; + right: 0; + z-index: 130; display: flex; - flex-direction: column; - align-items: center; - gap: 0.15rem; + justify-content: center; + gap: round($gate-gap * 0.6); + pointer-events: none; - // Edge midpoints, clockwise from 3 o'clock (slot drop order → role order) - &[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; } - &[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); } - &[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); } - &[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; } - &[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); } - &[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); } - - .position-body { + .gate-slot { + position: relative; + width: round($gate-node * 0.75); + height: round($gate-node * 0.75); + border-radius: 50%; + border: $gate-line solid rgba(var(--terUser), 0.5); + background: rgba(var(--priUser), 1); display: flex; flex-direction: column; align-items: center; - gap: 0.1rem; - } + justify-content: center; + flex-shrink: 0; + pointer-events: auto; + font-size: 1.8rem; + transition: opacity 0.6s ease, transform 0.6s ease; + box-shadow: + 0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.25), + 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), + 0.25rem 0.25rem 0.25rem rgba(var(--priUser), 0.12) + ; - .fa-chair { - font-size: 1.1rem; - color: rgba(var(--secUser), 0.4); - } + &.role-assigned { + opacity: 0; + transform: scale(0.5); + pointer-events: none; + box-shadow: + 0.1rem 0.1rem 0.12rem rgba(var(--terUser), 0.25), + 0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25), + 0.25rem 0.25rem 0.25rem rgba(var(--terUser), 0.12) + ; + } - .position-role-label { - font-size: 0.6rem; - font-weight: 600; - letter-spacing: 0.05em; - color: rgba(var(--secUser), 0.5); - } + &.filled, &.reserved { + background: rgba(var(--terUser), 0.9); + border-color: rgba(var(--terUser), 1); + color: rgba(var(--priUser), 1); + } - .position-status-icon { - font-size: 0.65rem; - &.fa-ban { color: rgba(var(--priRd), 1); } - &.fa-circle-check { color: rgba(var(--priGn), 1); } - } + &.filled:hover, &.reserved:hover { + box-shadow: + -0.1rem -0.1rem 1rem rgba(var(--ninUser), 1), + -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1), + 0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1); + } - &.active { - .fa-chair { - color: rgba(var(--terUser), 1); - filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1)); + .slot-number { font-size: 0.7em; opacity: 0.5; } + .slot-gamer { display: none; } + + form { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + } + + &:has(.drop-token-btn) { + background: rgba(var(--terUser), 1); + border-color: rgba(var(--ninUser), 0.5); + + &:hover { + box-shadow: + -0.1rem -0.1rem 1rem rgba(var(--ninUser), 1), + -0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1), + 0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1); + } + } + } +} + +@media (max-width: 700px) { + .position-strip { + gap: round($gate-gap * 0.3); + + .gate-slot { + width: round($gate-node * 0.75); + height: round($gate-node * 0.75); } } } @@ -482,20 +447,61 @@ $pos-d-y: round($pos-d * 0.866); // 95px .table-seat { position: absolute; - display: flex; - flex-direction: column; + display: grid; + grid-template-columns: auto auto; + grid-template-rows: auto auto; + column-gap: 0.25rem; align-items: center; - gap: 0.25rem; - // Centre the element on its anchor point transform: translate(-50%, -50%); + pointer-events: none; - // Clockwise from top — slot drop order during ROLE_SELECT - &[data-slot="1"] { left: 50%; top: calc(50% - #{$seat-r}); } - &[data-slot="2"] { left: calc(50% + #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); } - &[data-slot="3"] { left: calc(50% + #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); } - &[data-slot="4"] { left: 50%; top: calc(50% + #{$seat-r}); } - &[data-slot="5"] { left: calc(50% - #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); } - &[data-slot="6"] { left: calc(50% - #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); } + // Edge midpoints, clockwise from 3 o'clock (slot drop order → role order) + &[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; } + &[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); } + &[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); } + &[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; } + &[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); } + &[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); } + + // Chair: col 1, spans both rows + .fa-chair { + grid-column: 1; + grid-row: 1 / 3; + font-size: 1.6rem; + color: rgba(var(--secUser), 0.4); + transition: color 0.6s ease, filter 0.6s ease; + } + + // Abbreviation: col 2, row 1 + .seat-role-label { + grid-column: 2; + grid-row: 1; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.05em; + color: rgba(var(--secUser), 1); + } + + // Status icon: col 2, row 2, centred under the abbreviation + .position-status-icon { + grid-column: 2; + grid-row: 2; + justify-self: center; + font-size: 0.8rem; + &.fa-ban { color: rgba(var(--priRd), 1); } + &.fa-circle-check { color: rgba(var(--priGn), 1); } + } + + &.active .fa-chair { + color: rgba(var(--terUser), 1); + filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1)); + } + + // After role confirmed: chair settles to full-opacity --secUser (no glow) + &.role-confirmed .fa-chair { + color: rgba(var(--secUser), 1); + filter: none; + } .seat-portrait { width: 36px; @@ -690,17 +696,6 @@ $card-h: 120px; } } - .gate-slots { - gap: 14px; - - .gate-slot { - width: 40px; - height: 40px; - - .slot-number { font-size: 0.6em; } - } - } - .form-container { margin-top: 0.75rem; h3 { font-size: 0.85rem; margin: 0.5rem 0; } diff --git a/src/static_src/scss/_tray.scss b/src/static_src/scss/_tray.scss index 0cfe05b..e814db5 100644 --- a/src/static_src/scss/_tray.scss +++ b/src/static_src/scss/_tray.scss @@ -24,6 +24,10 @@ $handle-rect-h: 72px; $handle-exposed: 48px; $handle-r: 1rem; +#id_tray_wrap.role-select-phase { + #id_tray_handle { visibility: hidden; pointer-events: none; } +} + #id_tray_wrap { position: fixed; // left set by JS: closed = vw - handleW; open = vw - wrapW @@ -37,11 +41,11 @@ $handle-r: 1rem; display: flex; flex-direction: row; align-items: stretch; - transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1); + transition: left 1.0s cubic-bezier(0.4, 0, 0.2, 1); &.tray-dragging { transition: none; } - &.wobble { animation: tray-wobble 0.45s ease; } - &.snap { animation: tray-snap 0.30s ease; } + &.wobble { animation: tray-wobble .45s ease; } + &.snap { animation: tray-snap 1.0s ease; } } #id_tray_handle { @@ -134,7 +138,7 @@ $handle-r: 1rem; font-weight: 600; &.arc-in { - animation: tray-role-arc-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; + animation: tray-role-arc-in 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards; } } @@ -218,11 +222,11 @@ $handle-r: 1rem; right: $sidebar-w; top: auto; // JS controls style.top for the Y-axis slide bottom: auto; - transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1); + transition: top 1.0s cubic-bezier(0.4, 0, 0.2, 1); &.tray-dragging { transition: none; } &.wobble { animation: tray-wobble-landscape 0.45s ease; } - &.snap { animation: tray-snap-landscape 0.30s ease; } + &.snap { animation: tray-snap-landscape 1.0s ease; } } @@ -304,7 +308,7 @@ $handle-r: 1rem; } .tray-role-card.arc-in { - animation: tray-role-arc-in-landscape 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; + animation: tray-role-arc-in-landscape 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards; } @keyframes tray-wobble-landscape { diff --git a/src/static_src/tests/RoleSelectSpec.js b/src/static_src/tests/RoleSelectSpec.js index df72887..f1cca80 100644 --- a/src/static_src/tests/RoleSelectSpec.js +++ b/src/static_src/tests/RoleSelectSpec.js @@ -2,6 +2,7 @@ describe("RoleSelect", () => { let testDiv; beforeEach(() => { + RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange testDiv = document.createElement("div"); testDiv.innerHTML = `
{ // ------------------------------------------------------------------ // describe("room:turn_changed event", () => { - let stack; + let stack, trayWrap; beforeEach(() => { // Six table seats, slot 1 starts active @@ -169,6 +170,12 @@ describe("RoleSelect", () => { stack.dataset.userSlots = "1"; stack.dataset.starterRoles = ""; testDiv.appendChild(stack); + + trayWrap = document.createElement("div"); + trayWrap.id = "id_tray_wrap"; + // Simulate server-side class during ROLE_SELECT + trayWrap.className = "role-select-phase"; + testDiv.appendChild(trayWrap); }); it("calls Tray.forceClose() on turn change", () => { @@ -179,13 +186,19 @@ describe("RoleSelect", () => { expect(Tray.forceClose).toHaveBeenCalled(); }); - it("moves .active to the newly active seat", () => { + it("re-adds role-select-phase to tray wrap on turn change", () => { + trayWrap.classList.remove("role-select-phase"); // simulate it was shown window.dispatchEvent(new CustomEvent("room:turn_changed", { detail: { active_slot: 2 } })); - expect( - testDiv.querySelector(".table-seat.active").dataset.slot - ).toBe("2"); + expect(trayWrap.classList.contains("role-select-phase")).toBe(true); + }); + + it("clears .active from all seats on turn change", () => { + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2 } + })); + expect(testDiv.querySelector(".table-seat.active")).toBeNull(); }); it("removes .active from the previously active seat", () => { @@ -231,6 +244,119 @@ describe("RoleSelect", () => { stack.click(); expect(document.querySelector(".role-select-backdrop")).toBeNull(); }); + + it("updates seat icon to fa-circle-check when role appears in starter_roles", () => { + const seat = document.createElement("div"); + seat.className = "table-seat"; + seat.dataset.role = "PC"; + const ban = document.createElement("i"); + ban.className = "position-status-icon fa-solid fa-ban"; + seat.appendChild(ban); + testDiv.appendChild(seat); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(seat.querySelector(".fa-ban")).toBeNull(); + expect(seat.querySelector(".fa-circle-check")).not.toBeNull(); + }); + + it("adds role-confirmed to seat when role appears in starter_roles", () => { + const seat = document.createElement("div"); + seat.className = "table-seat"; + seat.dataset.role = "PC"; + testDiv.appendChild(seat); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(seat.classList.contains("role-confirmed")).toBe(true); + }); + + it("leaves seat icon as fa-ban when role not in starter_roles", () => { + const seat = document.createElement("div"); + seat.className = "table-seat"; + seat.dataset.role = "NC"; + const ban = document.createElement("i"); + ban.className = "position-status-icon fa-solid fa-ban"; + seat.appendChild(ban); + testDiv.appendChild(seat); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(seat.querySelector(".fa-ban")).not.toBeNull(); + expect(seat.querySelector(".fa-circle-check")).toBeNull(); + }); + + it("adds role-assigned to slot-1 circle when 1 role assigned", () => { + const circle = document.createElement("div"); + circle.className = "gate-slot filled"; + circle.dataset.slot = "1"; + testDiv.appendChild(circle); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(circle.classList.contains("role-assigned")).toBe(true); + }); + + it("leaves slot-2 circle visible when only 1 role assigned", () => { + const circle = document.createElement("div"); + circle.className = "gate-slot filled"; + circle.dataset.slot = "2"; + testDiv.appendChild(circle); + + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: ["PC"] } + })); + + expect(circle.classList.contains("role-assigned")).toBe(false); + }); + + it("updates data-active-slot on card stack to the new active slot", () => { + window.dispatchEvent(new CustomEvent("room:turn_changed", { + detail: { active_slot: 2, starter_roles: [] } + })); + expect(stack.dataset.activeSlot).toBe("2"); + }); + }); + + // ------------------------------------------------------------------ // + // selectRole slot-circle fade-out // + // ------------------------------------------------------------------ // + + describe("selectRole() slot-circle behaviour", () => { + let circle, stack; + + beforeEach(() => { + // Gate-slot circle for slot 1 (active turn) + circle = document.createElement("div"); + circle.className = "gate-slot filled"; + circle.dataset.slot = "1"; + testDiv.appendChild(circle); + + // Card stack with active-slot=1 so selectRole() knows which circle to hide + stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "eligible"; + stack.dataset.starterRoles = ""; + stack.dataset.userSlots = "1"; + stack.dataset.activeSlot = "1"; + testDiv.appendChild(stack); + + spyOn(Tray, "placeCard"); + }); + + it("adds role-assigned to the active slot's circle immediately on confirm", () => { + RoleSelect.openFan(); + document.querySelector("#id_role_select .card").click(); + expect(circle.classList.contains("role-assigned")).toBe(true); + }); }); // ------------------------------------------------------------------ // @@ -242,9 +368,14 @@ describe("RoleSelect", () => { // ------------------------------------------------------------------ // describe("tray card after successful role selection", () => { - let guardConfirm; + let guardConfirm, trayWrap; beforeEach(() => { + trayWrap = document.createElement("div"); + trayWrap.id = "id_tray_wrap"; + trayWrap.className = "role-select-phase"; + testDiv.appendChild(trayWrap); + // Spy on Tray.placeCard: call the onComplete callback immediately. spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => { if (cb) cb(); @@ -261,13 +392,15 @@ describe("RoleSelect", () => { it("calls Tray.placeCard() on success", async () => { guardConfirm(); - await Promise.resolve(); + await Promise.resolve(); // flush fetch .then() + await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout expect(Tray.placeCard).toHaveBeenCalled(); }); it("passes the role code string to Tray.placeCard", async () => { guardConfirm(); - await Promise.resolve(); + await Promise.resolve(); // flush fetch .then() + await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout const roleCode = Tray.placeCard.calls.mostRecent().args[0]; expect(typeof roleCode).toBe("string"); expect(roleCode.length).toBeGreaterThan(0); @@ -281,6 +414,27 @@ describe("RoleSelect", () => { await Promise.resolve(); expect(Tray.placeCard).not.toHaveBeenCalled(); }); + + it("removes role-select-phase from tray wrap on successful pick", async () => { + guardConfirm(); + await Promise.resolve(); + expect(trayWrap.classList.contains("role-select-phase")).toBe(false); + }); + + it("adds role-confirmed class to the seated position after placeCard completes", async () => { + // Add a seat element matching the first available role (PC) + const seat = document.createElement("div"); + seat.className = "table-seat"; + seat.dataset.role = "PC"; + seat.innerHTML = ''; + testDiv.appendChild(seat); + + guardConfirm(); + await Promise.resolve(); // fetch resolves + placeCard fires + await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout + + expect(seat.classList.contains("role-confirmed")).toBe(true); + }); }); // ------------------------------------------------------------------ // @@ -360,17 +514,20 @@ describe("RoleSelect", () => { RoleSelect.openFan(); document.querySelector("#id_role_select .card").click(); guardConfirm(); - await Promise.resolve(); + await Promise.resolve(); // flush fetch .then() + await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set window.dispatchEvent(new CustomEvent("room:turn_changed", { detail: { active_slot: 2, starter_roles: [] } })); - // Fire onComplete — deferred turn_changed should now run + // Fire onComplete — post-tray delay (0 in tests) still uses setTimeout Tray._testFirePlaceCardComplete(); + await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout - const activeSeat = testDiv.querySelector(".table-seat.active"); - expect(activeSeat && activeSeat.dataset.slot).toBe("2"); + // Seat glow is JS-only (tray animation window); after deferred + // handleTurnChanged runs, all seat glows are cleared. + expect(testDiv.querySelector(".table-seat.active")).toBeNull(); }); it("turn_changed after animation completes is processed immediately", () => { @@ -379,8 +536,8 @@ describe("RoleSelect", () => { detail: { active_slot: 2, starter_roles: [] } })); expect(Tray.forceClose).toHaveBeenCalled(); - const activeSeat = testDiv.querySelector(".table-seat.active"); - expect(activeSeat && activeSeat.dataset.slot).toBe("2"); + // Seats are not persistently glowed; all active cleared + expect(testDiv.querySelector(".table-seat.active")).toBeNull(); }); }); diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index 49bd038..1884d8b 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -44,43 +44,6 @@ {% endif %}
-
- {% for slot in slots %} -
- {{ slot.slot_number }} - {% if slot.gamer %} - {{ slot.gamer.email }} - {% else %} - empty - {% endif %} - {% if slot.status == 'RESERVED' and slot.gamer == request.user %} -
- {% csrf_token %} - {% if is_last_slot %} - - {% else %} - - {% endif %} -
- {% elif carte_active and slot.status == 'EMPTY' and slot.slot_number == carte_next_slot_number %} -
- {% csrf_token %} - - -
- {% elif carte_active and slot.status == 'FILLED' and slot.slot_number == carte_nvm_slot_number %} -
- {% csrf_token %} - - -
- {% endif %} -
- {% endfor %} -
{% if room.gate_status == 'OPEN' %}
{% csrf_token %} diff --git a/src/templates/apps/gameboard/_partials/_table_positions.html b/src/templates/apps/gameboard/_partials/_table_positions.html index a19f2e4..6580a41 100644 --- a/src/templates/apps/gameboard/_partials/_table_positions.html +++ b/src/templates/apps/gameboard/_partials/_table_positions.html @@ -1,12 +1,31 @@ -{% for pos in gate_positions %} -
-
- - {{ pos.role_label }} -
-

{% if pos.slot.gamer %}@{{ pos.slot.gamer.username|default:pos.slot.gamer.email }}{% else %}Empty Seat{% endif %}

+
+ {% for pos in gate_positions %} +
+ {{ pos.slot.slot_number }} + {% if pos.slot.gamer %}{{ pos.slot.gamer.email }}{% else %}empty{% endif %} + {% if pos.slot.status == 'RESERVED' and pos.slot.gamer == request.user %} + + {% csrf_token %} + {% if is_last_slot %} + + {% else %} + + {% endif %} + + {% elif carte_active and pos.slot.status == 'EMPTY' and pos.slot.slot_number == carte_next_slot_number %} +
+ {% csrf_token %} + + +
+ {% elif carte_active and pos.slot.status == 'FILLED' and pos.slot.slot_number == carte_nvm_slot_number %} +
+ {% csrf_token %} + + +
+ {% endif %}
-
- + {% endfor %}
-{% endfor %} diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index c7752a4..6f4da5a 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -15,7 +15,8 @@ {% if room.table_status == "ROLE_SELECT" and card_stack_state %}
+ data-user-slots="{{ user_slots|join:',' }}" + data-active-slot="{{ active_slot }}"> {% if card_stack_state == "ineligible" %} {% endif %} @@ -35,18 +36,19 @@
{% endfor %} {% else %} - {% for slot in room.gate_slots.all %} -
-
{{ slot.slot_number }}
-
- - {% if slot.gamer %}@{{ slot.gamer.username|default:slot.gamer.email }}{% endif %} - + {% for pos in gate_positions %} +
+ + {{ pos.role_label }} + {% if pos.role_label in starter_roles %} + + {% else %} + + {% endif %}
{% endfor %} {% endif %} - {% include "apps/gameboard/_partials/_table_positions.html" %}
@@ -74,10 +76,14 @@
{% endif %} + {% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %} + {% include "apps/gameboard/_partials/_table_positions.html" %} + {% endif %} {% if not room.table_status and room.gate_status != "RENEWAL_DUE" %} {% include "apps/gameboard/_partials/_gatekeeper.html" %} {% endif %} -
+ {% if room.table_status %} +
+ {% endif %} {% include "apps/gameboard/_partials/_room_gear.html" %}
{% endblock content %}