From a8592aeaec1d7aa81359b005bbb1f9d0af2e1e9e Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Mon, 30 Mar 2026 18:31:05 -0400 Subject: [PATCH] hex position indicators: chair icons at hex edge midpoints replace gate-slot circles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split .gate-overlay into .gate-backdrop (z-100, blur) + .gate-overlay modal (z-120) so .table-position elements (z-110) render above backdrop but below modal - New _table_positions.html partial: 6 .table-position divs with .fa-chair, role label, and .fa-ban/.fa-circle-check status icons; included unconditionally in room.html - New epic:room view at /gameboard/room//; gatekeeper redirects there when table_status set; pick_roles redirects there - role-select.js: adds .active glow to position on selectRole(); swaps .fa-ban→.fa-circle-check in placeCard onComplete; handleTurnChanged clears stale .active from all positions - FTs: PositionIndicatorsTest (5 tests) + RoleSelectTest 8a/8b (glow + check state) Co-Authored-By: Claude Sonnet 4.6 --- src/apps/epic/static/apps/epic/role-select.js | 16 +++ src/apps/epic/tests/integrated/test_views.py | 33 ++--- src/apps/epic/urls.py | 1 + src/apps/epic/views.py | 44 +++++-- src/functional_tests/test_gatekeeper.py | 118 ++++++++++++++++++ src/functional_tests/test_room_role_select.py | 98 +++++++++++++++ src/static_src/scss/_game-kit.scss | 6 +- src/static_src/scss/_room.scss | 75 +++++++++-- .../apps/gameboard/_partials/_gatekeeper.html | 1 + .../gameboard/_partials/_table_positions.html | 12 ++ src/templates/apps/gameboard/room.html | 1 + 11 files changed, 370 insertions(+), 35 deletions(-) create mode 100644 src/templates/apps/gameboard/_partials/_table_positions.html diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js index 03106e9..b87384f 100644 --- a/src/apps/epic/static/apps/epic/role-select.js +++ b/src/apps/epic/static/apps/epic/role-select.js @@ -45,6 +45,10 @@ 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 + '"]'); + if (activePos) activePos.classList.add('active'); + var url = getSelectRoleUrl(); if (!url) return; fetch(url, { @@ -72,6 +76,13 @@ var RoleSelect = (function () { _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; @@ -178,6 +189,11 @@ 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) { + p.classList.remove('active'); + }); + var stack = document.querySelector(".card-stack[data-user-slots]"); if (stack) { // Sync starter-roles from server so the fan reflects actual DB state diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index c279f4d..a3039ab 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -367,67 +367,68 @@ class RoleSelectRenderingTest(TestCase): self.room.save() for i, gamer in enumerate(self.gamers, start=1): TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i) + self.url = reverse("epic:room", kwargs={"room_id": self.room.id}) def test_room_view_includes_card_stack_when_role_select(self): self.client.force_login(self.founder) response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertContains(response, "card-stack") def test_card_stack_eligible_for_slot1_gamer(self): self.client.force_login(self.founder) response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertContains(response, 'data-state="eligible"') def test_card_stack_ineligible_for_slot2_gamer(self): self.client.force_login(self.gamers[1]) response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertContains(response, 'data-state="ineligible"') def test_card_stack_ineligible_shows_fa_ban(self): self.client.force_login(self.gamers[1]) response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertContains(response, "fa-ban") def test_card_stack_eligible_omits_fa_ban(self): self.client.force_login(self.founder) response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertNotContains(response, "fa-ban") def test_gatekeeper_overlay_absent_when_role_select(self): self.client.force_login(self.founder) response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertNotContains(response, "gate-overlay") def test_six_table_seats_rendered(self): self.client.force_login(self.founder) response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) 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( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertContains(response, 'class="table-seat active"') def test_inactive_table_seat_lacks_active_class(self): self.client.force_login(self.founder) response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) # Slots 2–6 are not active, so at least one plain table-seat exists self.assertContains(response, 'class="table-seat"') @@ -435,14 +436,14 @@ class RoleSelectRenderingTest(TestCase): def test_card_stack_has_data_user_slots_for_eligible_gamer(self): self.client.force_login(self.founder) # founder is slot 1 only response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertContains(response, 'data-user-slots="1"') def test_card_stack_has_data_user_slots_for_ineligible_gamer(self): self.client.force_login(self.gamers[1]) # slot 2 gamer response = self.client.get( - reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url ) self.assertContains(response, 'data-user-slots="2"') @@ -495,7 +496,7 @@ class PickRolesViewTest(TestCase): reverse("epic:pick_roles", kwargs={"room_id": self.room.id}) ) self.assertRedirects( - response, reverse("epic:gatekeeper", args=[self.room.id]) + response, reverse("epic:room", args=[self.room.id]) ) def test_pick_roles_notifies_channel_layer(self): @@ -633,7 +634,7 @@ class SelectRoleViewTest(TestCase): data={"role": "BOGUS"}, ) self.assertRedirects( - response, reverse("epic:gatekeeper", args=[self.room.id]) + response, reverse("epic:room", args=[self.room.id]) ) def test_same_gamer_cannot_double_pick_sequentially(self): @@ -648,7 +649,7 @@ class SelectRoleViewTest(TestCase): data={"role": "BC"}, ) self.assertRedirects( - response, reverse("epic:gatekeeper", args=[self.room.id]) + response, reverse("epic:room", args=[self.room.id]) ) self.assertEqual( TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1 @@ -778,7 +779,7 @@ class SigSelectRenderingTest(TestCase): def setUp(self): self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self) - self.url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id}) + self.url = reverse("epic:room", kwargs={"room_id": self.room.id}) def test_sig_deck_element_present(self): response = self.client.get(self.url) @@ -878,7 +879,7 @@ class SelectSigCardViewTest(TestCase): self.room.save() response = self._post() self.assertRedirects( - response, reverse("epic:gatekeeper", args=[self.room.id]) + response, reverse("epic:room", args=[self.room.id]) ) def test_select_sig_last_choice_does_not_advance_to_none(self): diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index a723ff3..2ffe7d5 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -6,6 +6,7 @@ app_name = 'epic' urlpatterns = [ path('rooms/create_room', views.create_room, name='create_room'), + path('room//', views.room_view, name='room'), path('room//gate/', views.gatekeeper, name='gatekeeper'), path('room//gate/drop_token', views.drop_token, name='drop_token'), path('room//gate/confirm_token', views.confirm_token, name='confirm_token'), diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 5af7e78..99e142d 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -71,6 +71,17 @@ def _notify_sig_selected(room_id, card_id, role, deck_type='levity'): ) +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 [ + {"slot": slot, "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, "")} + for slot in room.gate_slots.order_by("slot_number") + ] + + def _expire_reserved_slots(room): cutoff = timezone.now() - RESERVE_TIMEOUT room.gate_slots.filter( @@ -135,6 +146,7 @@ def _gate_context(room, user): "carte_slots_claimed": carte_slots_claimed, "carte_nvm_slot_number": carte_nvm_slot_number, "carte_next_slot_number": carte_next_slot_number, + "gate_positions": _gate_positions(room), } @@ -186,6 +198,7 @@ def _role_select_context(room, user): .values_list("slot_number", flat=True) ) if user.is_authenticated else [], "active_slot": active_slot, + "gate_positions": _gate_positions(room), } if room.table_status == Room.SIG_SELECT: user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None @@ -215,9 +228,16 @@ def create_room(request): def gatekeeper(request, room_id): room = Room.objects.get(id=room_id) if room.table_status: - ctx = _role_select_context(room, request.user) - else: - ctx = _gate_context(room, request.user) + return redirect("epic:room", room_id=room_id) + ctx = _gate_context(room, request.user) + ctx["room"] = room + ctx["page_class"] = "page-gameboard" + return render(request, "apps/gameboard/room.html", ctx) + + +def room_view(request, room_id): + room = Room.objects.get(id=room_id) + ctx = _role_select_context(room, request.user) ctx["room"] = room ctx["page_class"] = "page-gameboard" return render(request, "apps/gameboard/room.html", ctx) @@ -390,17 +410,20 @@ def select_role(request, room_id): if request.method == "POST": room = Room.objects.get(id=room_id) if room.table_status != Room.ROLE_SELECT: - return redirect("epic:gatekeeper", room_id=room_id) + return redirect( + "epic:room" if room.table_status else "epic:gatekeeper", + room_id=room_id, + ) role = request.POST.get("role") valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES] if not role or role not in valid_roles: - return redirect("epic:gatekeeper", room_id=room_id) + return redirect("epic:room", room_id=room_id) with transaction.atomic(): active_seat = room.table_seats.select_for_update().filter( role__isnull=True ).order_by("slot_number").first() if not active_seat or active_seat.gamer != request.user: - return redirect("epic:gatekeeper", room_id=room_id) + return redirect("epic:room", room_id=room_id) if room.table_seats.filter(role=role).exists(): return HttpResponse(status=409) active_seat.role = role @@ -416,7 +439,7 @@ def select_role(request, room_id): record(room, GameEvent.ROLES_REVEALED) _notify_roles_revealed(room_id) return HttpResponse(status=200) - return redirect("epic:gatekeeper", room_id=room_id) + return redirect("epic:room", room_id=room_id) @login_required @@ -433,7 +456,7 @@ def pick_roles(request, room_id): slot_number=slot.slot_number, ) _notify_role_select_start(room_id) - return redirect("epic:gatekeeper", room_id=room_id) + return redirect("epic:room", room_id=room_id) @login_required @@ -487,7 +510,10 @@ def select_sig(request, room_id): return redirect("epic:gatekeeper", room_id=room_id) room = Room.objects.get(id=room_id) if room.table_status != Room.SIG_SELECT: - return redirect("epic:gatekeeper", room_id=room_id) + return redirect( + "epic:room" if room.table_status else "epic:gatekeeper", + room_id=room_id, + ) active_seat = active_sig_seat(room) if active_seat is None or active_seat.gamer != request.user: return HttpResponse(status=403) diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 6d70a70..338c54c 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -8,6 +8,7 @@ from .base import FunctionalTest from apps.applets.models import Applet from apps.epic.models import Room, GateSlot, select_token from apps.lyric.models import Token, User +from .test_room_role_select import _fill_room_via_orm class GatekeeperTest(FunctionalTest): @@ -585,3 +586,120 @@ class GameKitInsertTest(FunctionalTest): ) self.assertTrue(Token.objects.filter(id=pass_token.id).exists()) self.assertEqual(self.browser.current_url, self.gate_url) + + +class PositionIndicatorsTest(FunctionalTest): + """Hex-side position indicators — always rendered outside the gatekeeper modal.""" + + def setUp(self): + super().setUp() + Applet.objects.get_or_create( + slug="new-game", defaults={"name": "New Game", "context": "gameboard"} + ) + Applet.objects.get_or_create( + slug="my-games", defaults={"name": "My Games", "context": "gameboard"} + ) + self.create_pre_authenticated_session("founder@test.io") + self.founder, _ = User.objects.get_or_create(email="founder@test.io") + self.room = Room.objects.create(name="Position Test Room", owner=self.founder) + self.gate_url = ( + f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/" + ) + + # ------------------------------------------------------------------ # + # Test P1 — 6 position indicators present while gatekeeper is open # + # ------------------------------------------------------------------ # + + 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()) + + # ------------------------------------------------------------------ # + # Test P2 — URL drops /gate/ after pick_roles # + # ------------------------------------------------------------------ # + + def test_url_drops_gate_after_pick_roles(self): + _fill_room_via_orm(self.room, [ + "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}/" + ) + self.wait_for( + lambda: self.assertEqual(self.browser.current_url, expected_url) + ) + + # ------------------------------------------------------------------ # + # Test P3 — Each position has a chair icon and correct role label # + # ------------------------------------------------------------------ # + + 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"} + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + ) + 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) + + # ------------------------------------------------------------------ # + # Test P4 — Unoccupied position shows ban icon # + # ------------------------------------------------------------------ # + + def test_unoccupied_position_shows_ban_icon(self): + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + ) + # 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')}", + ) + + # ------------------------------------------------------------------ # + # Test P5 — Occupied position shows check icon after token confirmed # + # ------------------------------------------------------------------ # + + def test_occupied_position_shows_check_icon_after_token_confirmed(self): + # Slot 1 is filled via ORM + from apps.epic.models import GateSlot + slot = self.room.gate_slots.get(slot_number=1) + slot.gamer = self.founder + slot.status = GateSlot.FILLED + slot.save() + + self.browser.get(self.gate_url) + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay") + ) + pos1 = self.browser.find_element( + By.CSS_SELECTOR, ".table-position[data-slot='1']" + ) + self.assertTrue(pos1.find_elements(By.CSS_SELECTOR, ".fa-circle-check")) + self.assertFalse(pos1.find_elements(By.CSS_SELECTOR, ".fa-ban")) diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py index e7bcef4..0141253 100644 --- a/src/functional_tests/test_room_role_select.py +++ b/src/functional_tests/test_room_role_select.py @@ -484,6 +484,104 @@ class RoleSelectTest(FunctionalTest): ) + # ------------------------------------------------------------------ # + # Test 8a — Position glows while role card is being placed # + # ------------------------------------------------------------------ # + + 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.""" + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="Position Glow 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", + ]) + 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, + ) + 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) + + # 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" + ) + ) + + # ------------------------------------------------------------------ # + # Test 8b — Position shows check icon after tray sequence ends # + # ------------------------------------------------------------------ # + + 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.""" + founder, _ = User.objects.get_or_create(email="founder@test.io") + room = Room.objects.create(name="Position 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", + ]) + 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, + ) + 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) + + # Open fan, pick PC card, 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() + + # Wait for tray animation to complete (tray closes) + self.wait_for( + lambda: self.assertFalse( + self.browser.execute_script("return Tray.isOpen()"), + "Tray should close after arc-in sequence" + ) + ) + + # PC position now shows check icon, ban icon gone + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-circle-check" + ) + ) + self.assertEqual( + len(self.browser.find_elements( + By.CSS_SELECTOR, ".table-position[data-role-label='PC'] .fa-ban" + )), + 0, + ) + + class RoleSelectTrayTest(FunctionalTest): """After confirming a role pick, the role card enters the tray grid and the tray opens to reveal it. diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss index 6b68dbf..4a2c014 100644 --- a/src/static_src/scss/_game-kit.scss +++ b/src/static_src/scss/_game-kit.scss @@ -40,7 +40,7 @@ margin: 0; padding: 0; border: none; - border-top: 0.1rem solid rgba(var(--terUser), 0.3); + border-top: 0.1rem solid rgba(var(--quaUser), 1); background: rgba(var(--priUser), 0.97); z-index: 316; overflow: hidden; @@ -81,7 +81,7 @@ text-transform: uppercase; text-decoration: underline; letter-spacing: 0.12em; - color: rgba(var(--secUser), 0.35); + color: rgba(var(--quaUser), 0.75); writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg); @@ -117,8 +117,8 @@ .kit-bag-placeholder { font-size: 1.5rem; - opacity: 0.3; padding: 0 0.125rem; + color: rgba(var(--quaUser), 0.3); } } diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index c118f73..8af3f8d 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -36,25 +36,29 @@ $gate-line: 2px; // disrupt pointer events on position:fixed descendants. // NOTE: may be superfluous — root cause of CI kit-btn failures turned out to be // game-kit.js missing from git (was in gitignored STATIC_ROOT only). -html:has(.gate-overlay) { +html:has(.gate-backdrop) { overflow: hidden; } +.gate-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + z-index: 100; + pointer-events: none; +} + .gate-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.7); - backdrop-filter: blur(4px); - z-index: 100; + z-index: 120; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; - // Prevents backdrop from intercepting clicks on position:fixed elements - // (e.g. #id_kit_btn) in Linux headless Firefox. - // NOTE: may be superfluous — see html:has comment above. pointer-events: none; } @@ -362,6 +366,63 @@ $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). +// 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: absolute; + z-index: 110; + pointer-events: none; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + + // 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 { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1rem; + } + + .fa-chair { + font-size: 1.1rem; + color: rgba(var(--secUser), 0.4); + } + + .position-role-label { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.05em; + color: rgba(var(--secUser), 0.5); + } + + .position-status-icon { + font-size: 0.65rem; + &.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)); + } + } +} + .room-table { flex: 2; position: relative; diff --git a/src/templates/apps/gameboard/_partials/_gatekeeper.html b/src/templates/apps/gameboard/_partials/_gatekeeper.html index 14c5729..49bd038 100644 --- a/src/templates/apps/gameboard/_partials/_gatekeeper.html +++ b/src/templates/apps/gameboard/_partials/_gatekeeper.html @@ -2,6 +2,7 @@ id="id_gate_wrapper" data-gate-status-url="{% url 'epic:gate_status' room.id %}" > +
{% endfor %} {% endif %} + {% include "apps/gameboard/_partials/_table_positions.html" %}