From cf40f626e6110f30acb4fd578af324960f8fbb09 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Wed, 8 Apr 2026 11:52:49 -0400 Subject: [PATCH] Sig select: _card-deck.scss extract, WS cursor fixes, own-role indicators, role icon refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New _card-deck.scss: sig select styles moved out of _room.scss + _game-kit.scss - sig-select.js: 3 WS bug fixes — thumbs-up deferred to window.load (layout settled before getBoundingClientRect), hover cursor cleared for all cards on reservation (not just the reserved card), applyHover guards against already-reserved roles - Own-role indicators: gamer now sees their own role-coloured card outline + thumbs-up - Reservation glow: replaced blurry role+ninUser double-shadow with crisp 2px outline - Gravity qualifier: Graven text set to --terUser (matches Leavened/--quiUser pattern) - Role card SVGs refreshed; starter-role-Blank removed - FTs + Jasmine specs extended for sig select WS behaviour - setup_sig_session management command for multi-browser manual testing Co-Authored-By: Claude Sonnet 4.6 --- .../cards-roles/starter-role-Alchemist.svg | 106 +-- .../icons/cards-roles/starter-role-Blank.svg | 62 -- .../cards-roles/starter-role-Builder.svg | 104 +-- .../cards-roles/starter-role-Economist.svg | 58 +- .../cards-roles/starter-role-Narrator.svg | 100 +-- .../icons/cards-roles/starter-role-Player.svg | 72 +- .../cards-roles/starter-role-Shepherd.svg | 62 +- src/apps/epic/static/apps/epic/sig-select.js | 120 +++- src/apps/lyric/urls.py | 3 +- src/apps/lyric/views.py | 12 + .../management/commands/setup_sig_session.py | 128 ++++ src/functional_tests/test_room_sig_select.py | 231 ++++++ src/static/tests/SigSelectSpec.js | 202 +++++- src/static_src/scss/_card-deck.scss | 669 ++++++++++++++++++ src/static_src/scss/_game-kit.scss | 115 --- src/static_src/scss/_room.scss | 442 +----------- src/static_src/scss/core.scss | 1 + src/static_src/tests/SigSelectSpec.js | 202 +++++- .../_partials/_sig_select_overlay.html | 10 +- 19 files changed, 1721 insertions(+), 978 deletions(-) delete mode 100644 src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg create mode 100644 src/functional_tests/management/commands/setup_sig_session.py create mode 100644 src/static_src/scss/_card-deck.scss diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg index c970f51..3889cfa 100644 --- a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg @@ -3,7 +3,7 @@ - - - - - - + + + + + + - - - - - - - + + + + + + + - - - - + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg deleted file mode 100644 index 563e5a0..0000000 --- a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg +++ /dev/null @@ -1,62 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg index 67c4564..7847681 100644 --- a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg @@ -3,30 +3,22 @@ - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg index 514d7bd..ffc91e4 100644 --- a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg @@ -3,26 +3,14 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg index 6bb50a0..905a011 100644 --- a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg @@ -3,42 +3,10 @@ - - + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg index cc52014..eaaf7c2 100644 --- a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg @@ -3,75 +3,47 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg index 98c360c..2cfc2c7 100644 --- a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg @@ -3,34 +3,22 @@ - - - - - - + + + + + + - - - - - - + + + + + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index b743a95..ca5ae7e 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -18,6 +18,10 @@ var SigSelect = (function () { var _stageFrozen = false; // true after OK — stage locks on reserved card var _requestInFlight = false; + var _floatingCursors = {}; // key: cardId+posClass → portal element (hover) + var _reservedFloats = {}; // key: role → portal element (thumbs-up, frozen) + var _cursorPortal = null; + function getCsrf() { var m = document.cookie.match(/csrftoken=([^;]+)/); return m ? m[1] : ''; @@ -104,9 +108,15 @@ var SigSelect = (function () { } }); stageCard.querySelector('.fan-card-name-group').textContent = group; - stageCard.querySelector('.fan-card-name').textContent = title; stageCard.querySelector('.fan-card-arcana').textContent = arcana; - stageCard.querySelector('.fan-card-correspondence').textContent = corr; + stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only + + var qualifier = userPolarity === 'levity' ? 'Leavened' : 'Graven'; + var isMajor = arcana.toLowerCase().indexOf('major') !== -1; + // Major arcana: qualifier sits below the title — append comma so it reads as a subtitle. + stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title; + stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier; + stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : ''; // Populate stat block keyword faces and reset to upright statBlock.classList.remove('is-reversed'); @@ -178,6 +188,38 @@ var SigSelect = (function () { // ── Apply reservation state (local + from WS) ───────────────────────── + function _placeReservedFloat(cardId, cardEl, role) { + // Remove any pre-existing reserved float for this role (e.g. page-load replay) + if (_reservedFloats[role]) { _reservedFloats[role].remove(); } + + // Retire ALL hover floats for this role — may be on a different card than reserved + var roles = POLARITY_ROLES[userPolarity] || []; + var idx = roles.indexOf(role); + var posClass = ['--left', '--mid', '--right'][idx] || '--left'; + Object.keys(_floatingCursors).forEach(function (key) { + if (key.slice(-posClass.length) === posClass) { + _floatingCursors[key].remove(); + var hCid = key.slice(0, key.length - posClass.length); + var hEl = deckGrid.querySelector('.sig-card[data-card-id="' + hCid + '"]'); + if (hEl) { + var a = hEl.querySelector('.sig-cursor' + posClass); + if (a) a.classList.remove('active'); + } + delete _floatingCursors[key]; + } + }); + + var rect = cardEl.getBoundingClientRect(); + var xFractions = [0.15, 0.5, 0.85]; + var fc = document.createElement('i'); + fc.className = 'fa-solid fa-thumbs-up sig-cursor-float sig-cursor-float--reserved'; + fc.dataset.role = role; + fc.style.left = (rect.left + rect.width * xFractions[idx < 0 ? 1 : idx]) + 'px'; + fc.style.top = rect.bottom + 'px'; + _ensureCursorPortal().appendChild(fc); + _reservedFloats[role] = fc; + } + function applyReservation(cardId, role, reserved) { var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]'); if (!cardEl) return; @@ -195,6 +237,8 @@ var SigSelect = (function () { _stageFrozen = true; stage.classList.add('sig-stage--frozen'); } + // Thumbs-up float for all reservations — own role sees their own indicator too + _placeReservedFloat(cardId, cardEl, role); } else { delete cardEl.dataset.reservedBy; cardEl.classList.remove('sig-reserved', 'sig-reserved--own'); @@ -203,26 +247,65 @@ var SigSelect = (function () { _stageFrozen = false; stage.classList.remove('sig-stage--frozen'); } + // Remove thumbs-up float for all releases — own role included + if (_reservedFloats[role]) { + _reservedFloats[role].remove(); + delete _reservedFloats[role]; + } } } // ── Apply hover cursor (WS only — own hover is CSS :hover) ──────────── + // + // Cursor icons are portaled to document root so they escape overflow/clip + // contexts in the deck grid. The in-card anchor elements only carry the + // .active class (for test assertions and the :has() z-index rule). + + function _ensureCursorPortal() { + if (!_cursorPortal || !document.body.contains(_cursorPortal)) { + _cursorPortal = document.getElementById('id_sig_cursor_portal'); + if (!_cursorPortal) { + _cursorPortal = document.createElement('div'); + _cursorPortal.id = 'id_sig_cursor_portal'; + document.body.appendChild(_cursorPortal); + } + } + return _cursorPortal; + } function applyHover(cardId, role, active) { if (role === userRole) return; + if (_reservedFloats[role]) return; // role already has thumbs-up — ignore hover var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]'); if (!cardEl) return; var roles = POLARITY_ROLES[userPolarity] || []; var idx = roles.indexOf(role); var posClass = ['--left', '--mid', '--right'][idx] || '--left'; - var cursor = cardEl.querySelector('.sig-cursor' + posClass); - if (!cursor) return; + var anchor = cardEl.querySelector('.sig-cursor' + posClass); + if (!anchor) return; + + var key = cardId + posClass; if (active) { - cursor.classList.add('active'); + anchor.classList.add('active'); // kept for test assertions + :has() z-index + + // Place a fixed-position clone in the portal, positioned from card bounds + var rect = cardEl.getBoundingClientRect(); + var xFractions = [0.15, 0.5, 0.85]; + var fc = document.createElement('i'); + fc.className = 'fa-solid fa-hand-pointer sig-cursor-float'; + fc.dataset.role = role; + fc.style.left = (rect.left + rect.width * xFractions[idx]) + 'px'; + fc.style.top = rect.bottom + 'px'; + _ensureCursorPortal().appendChild(fc); + _floatingCursors[key] = fc; } else { - cursor.classList.remove('active'); + anchor.classList.remove('active'); + if (_floatingCursors[key]) { + _floatingCursors[key].remove(); + delete _floatingCursors[key]; + } } } @@ -296,12 +379,24 @@ var SigSelect = (function () { userRole = overlay.dataset.userRole; userPolarity= overlay.dataset.polarity; - // Restore reservations from server-rendered JSON (page-load state) + // Restore reservations from server-rendered JSON (page-load state). + // Deferred to 'load' so sizeSigModal() (also a 'load' listener, registered + // in room.js before this script) has already applied paddingBottom and + // --sig-card-w before _placeReservedFloat calls getBoundingClientRect(). try { var existing = JSON.parse(overlay.dataset.reservations || '{}'); - Object.keys(existing).forEach(function (cardId) { - applyReservation(cardId, existing[cardId], true); - }); + if (Object.keys(existing).length) { + var _replayReservations = function () { + Object.keys(existing).forEach(function (cardId) { + applyReservation(cardId, existing[cardId], true); + }); + }; + if (document.readyState === 'complete') { + _replayReservations(); + } else { + window.addEventListener('load', _replayReservations, { once: true }); + } + } } catch (e) { /* malformed JSON — ignore */ } // Hover: update stage preview + broadcast cursor @@ -369,6 +464,11 @@ var SigSelect = (function () { _requestInFlight = false; _cautionData = []; _cautionIdx = 0; + Object.keys(_floatingCursors).forEach(function (k) { _floatingCursors[k].remove(); }); + _floatingCursors = {}; + Object.keys(_reservedFloats).forEach(function (k) { _reservedFloats[k].remove(); }); + _reservedFloats = {}; + _cursorPortal = null; init(); }, _setFrozen: function (v) { _stageFrozen = v; }, diff --git a/src/apps/lyric/urls.py b/src/apps/lyric/urls.py index 1ccdb51..f4770fe 100644 --- a/src/apps/lyric/urls.py +++ b/src/apps/lyric/urls.py @@ -5,6 +5,7 @@ from . import views as lyric_views urlpatterns = [ path('send_login_email', lyric_views.send_login_email, name='send_login_email'), path('login', lyric_views.login, name='login'), - path('logout', auth_views.LogoutView.as_view(next_page='/'), name='logout') + path('logout', auth_views.LogoutView.as_view(next_page='/'), name='logout'), + path('dev-login//', lyric_views.dev_login, name='dev_login'), ] diff --git a/src/apps/lyric/views.py b/src/apps/lyric/views.py index 586b64a..7719042 100644 --- a/src/apps/lyric/views.py +++ b/src/apps/lyric/views.py @@ -1,4 +1,6 @@ +from django.conf import settings from django.contrib import auth, messages +from django.http import Http404 from django.shortcuts import redirect from django.urls import reverse @@ -27,3 +29,13 @@ def login(request): else: messages.error(request, "Invalid login link!—please request another") return redirect("/") + + +def dev_login(request, session_key): + """DEBUG-only: set session cookie and redirect. Used by setup_sig_session command.""" + if not settings.DEBUG: + raise Http404 + next_url = request.GET.get("next", "/") + response = redirect(next_url) + response.set_cookie(settings.SESSION_COOKIE_NAME, session_key, httponly=True) + return response diff --git a/src/functional_tests/management/commands/setup_sig_session.py b/src/functional_tests/management/commands/setup_sig_session.py new file mode 100644 index 0000000..8c5ecbe --- /dev/null +++ b/src/functional_tests/management/commands/setup_sig_session.py @@ -0,0 +1,128 @@ +""" +Management command for manual multi-user sig-select testing. + +Creates (or reuses) a room with all 6 gate slots filled, roles assigned, +and table_status=SIG_SELECT. Prints one pre-auth URL per gamer so you can +paste them into 6 Firefox Multi-Account Container tabs. + +Usage: + python src/manage.py setup_sig_session + python src/manage.py setup_sig_session --base-url http://localhost:8000 + python src/manage.py setup_sig_session --room # reuse existing room +""" + +from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY +from django.contrib.sessions.backends.db import SessionStore +from django.core.management.base import BaseCommand + +from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat, TarotCard +from apps.lyric.models import User + + +GAMERS = [ + ("founder@test.io", "discoman"), + ("amigo@test.io", "amigo"), + ("bud@test.io", "bud"), + ("pal@test.io", "pal"), + ("dude@test.io", "dude"), + ("bro@test.io", "bro"), +] + +ROLES = ["PC", "NC", "EC", "SC", "AC", "BC"] + + +def _ensure_earthman(): + """Return (or create) the Earthman DeckVariant with enough sig-deck cards seeded.""" + earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, + ) + _NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"} + for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"): + for number in (11, 12, 13, 14): + TarotCard.objects.get_or_create( + deck_variant=earthman, + slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em", + defaults={ + "arcana": "MINOR", + "suit": suit, + "number": number, + "name": f"{_NAME[number]} of {suit.capitalize()}", + }, + ) + return earthman + + +def _make_session(user): + session = SessionStore() + session[SESSION_KEY] = str(user.pk) + session[BACKEND_SESSION_KEY] = "apps.lyric.authentication.PasswordlessAuthenticationBackend" + session[HASH_SESSION_KEY] = user.get_session_auth_hash() + session.save() + return session.session_key + + +class Command(BaseCommand): + help = "Set up a SIG_SELECT room and print pre-auth URLs for all six gamers" + + def add_arguments(self, parser): + parser.add_argument("--base-url", default="http://localhost:8000") + parser.add_argument("--room", default=None, help="UUID of an existing room to reuse") + + def handle(self, *args, **options): + base_url = options["base_url"].rstrip("/") + earthman = _ensure_earthman() + + # ── Users ──────────────────────────────────────────────────────────── + users = [] + for email, _ in GAMERS: + user, _ = User.objects.get_or_create(email=email) + user.is_staff = True + user.is_superuser = True + if not user.equipped_deck: + user.equipped_deck = earthman + user.save() + users.append(user) + + # ── Room ───────────────────────────────────────────────────────────── + if options["room"]: + room = Room.objects.get(pk=options["room"]) + else: + room = Room.objects.create( + name="Sig Select Test Room", + owner=users[0], + visibility=Room.PUBLIC, + ) + + # ── Gate slots ─────────────────────────────────────────────────────── + for i, user in enumerate(users, start=1): + slot = room.gate_slots.get(slot_number=i) + slot.gamer = user + slot.status = GateSlot.FILLED + slot.save() + + room.gate_status = Room.OPEN + room.save() + + # ── Table seats + roles ────────────────────────────────────────────── + for i, (user, role) in enumerate(zip(users, ROLES), start=1): + TableSeat.objects.update_or_create( + room=room, slot_number=i, + defaults={"gamer": user, "role": role, "role_revealed": True}, + ) + + room.table_status = Room.SIG_SELECT + room.save() + + # ── Print URLs ─────────────────────────────────────────────────────── + room_path = f"/gameboard/room/{room.pk}/" + self.stdout.write(f"\nRoom: {base_url}{room_path}\n") + self.stdout.write(f"{'Container':<12} {'Email':<22} {'Role':<6} URL") + self.stdout.write("─" * 100) + + for (email, container), user, role in zip(GAMERS, users, ROLES): + session_key = _make_session(user) + url = f"{base_url}/lyric/dev-login/{session_key}/?next={room_path}" + self.stdout.write(f"{container:<12} {email:<22} {role:<6} {url}") + + self.stdout.write("") diff --git a/src/functional_tests/test_room_sig_select.py b/src/functional_tests/test_room_sig_select.py index 629b2d6..1bdfce1 100644 --- a/src/functional_tests/test_room_sig_select.py +++ b/src/functional_tests/test_room_sig_select.py @@ -138,3 +138,234 @@ class SigSelectChannelsTest(ChannelsFunctionalTest): )) return b + def _setup_sig_select_room(self): + """Create a full SIG_SELECT room; return (room, [user_pc, user_nc, ...]).""" + emails = [ + "founder@test.io", "amigo@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io", + ] + founder, _ = User.objects.get_or_create(email=emails[0]) + room = Room.objects.create(name="Cursor Colour Test", owner=founder) + gamers = _fill_room_via_orm(room, emails) + _assign_all_roles(room) + return room, gamers + + # ── SC1: NC hover → PC sees mid cursor active, coloured --priYl ────────── # + + @tag('channels') + def test_nc_hover_activates_mid_cursor_in_pc_browser(self): + """ + When NC (levity mid) hovers a card, PC (levity left) must see the + --mid cursor become active, coloured --priYl (rgb 255 207 52). + Verifies: WS broadcast pipeline + JS applyHover + CSS role colouring. + """ + room, gamers = self._setup_sig_select_room() + room_url = self.live_server_url + f"/gameboard/room/{room.pk}/" + + # ── Browser 1: PC (founder) ─────────────────────────────────────────── + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + # ── Browser 2: NC (amigo) ───────────────────────────────────────────── + browser2 = self._make_browser2("amigo@test.io") + try: + browser2.get(room_url) + self.wait_for(lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + # Grab the first card ID visible in browser2's deck + first_card = browser2.find_element(By.CSS_SELECTOR, ".sig-card") + card_id = first_card.get_attribute("data-card-id") + + # Hover over it — triggers sendHover() → WS broadcast + from selenium.webdriver.common.action_chains import ActionChains + ActionChains(browser2).move_to_element(first_card).perform() + + # ── Browser 1 should see --mid cursor go active (anchor carries class) ─ + mid_cursor_sel = f'.sig-card[data-card-id="{card_id}"] .sig-cursor--mid' + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, mid_cursor_sel + ".active" + ) + ) + + # CSS colour check: portal float has data-role="NC" → --priYl = 255, 207, 52 + portal_sel = '.sig-cursor-float[data-role="NC"]' + portal_cursor = self.browser.find_element(By.CSS_SELECTOR, portal_sel) + color = self.browser.execute_script( + "return window.getComputedStyle(arguments[0]).color", + portal_cursor, + ) + self.assertEqual(color, "rgb(255, 207, 52)", f"Expected --priYl colour for NC cursor, got {color}") + + # ── Mouse-off: anchor class removed, portal float gone ──────────── + ActionChains(browser2).move_to_element( + browser2.find_element(By.CSS_SELECTOR, ".sig-stage") + ).perform() + self.wait_for( + lambda: not self.browser.find_elements( + By.CSS_SELECTOR, mid_cursor_sel + ".active" + ) + ) + + finally: + browser2.quit() + + # ── SC2: NC reserves → PC sees card border coloured --priYl ──────────── # + + @tag('channels') + def test_nc_reservation_glows_priYl_in_pc_browser(self): + """ + When NC (levity mid) clicks OK on a card, PC must see that card's border + coloured --priYl (rgb 255 207 52) via the data-reserved-by CSS selector. + Verifies: sig_reserve view → WS broadcast → applyReservation → CSS glow. + """ + room, gamers = self._setup_sig_select_room() + room_url = self.live_server_url + f"/gameboard/room/{room.pk}/" + + # ── Browser 1: PC (founder) ─────────────────────────────────────────── + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(room_url) + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + # ── Browser 2: NC (amigo) ───────────────────────────────────────────── + browser2 = self._make_browser2("amigo@test.io") + try: + browser2.get(room_url) + self.wait_for(lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + # Get first card in B2's deck + first_card = browser2.find_element(By.CSS_SELECTOR, ".sig-card") + card_id = first_card.get_attribute("data-card-id") + + # Click card body → .sig-focused → OK button appears + from selenium.webdriver.common.action_chains import ActionChains + ActionChains(browser2).move_to_element(first_card).perform() + first_card.click() + + ok_btn = self.wait_for( + lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-focused .sig-ok-btn") + ) + ok_btn.click() + + # ── B1 should see the card's border turn --priYl ────────────────── + reserved_card_sel = f'.sig-card[data-card-id="{card_id}"]' + self.wait_for( + lambda: self.browser.find_element( + By.CSS_SELECTOR, reserved_card_sel + '[data-reserved-by="NC"]' + ) + ) + + reserved_card = self.browser.find_element(By.CSS_SELECTOR, reserved_card_sel) + border_color = self.browser.execute_script( + "return window.getComputedStyle(arguments[0]).borderTopColor", + reserved_card, + ) + self.assertEqual( + border_color, "rgb(255, 207, 52)", + f"Expected --priYl border for NC reservation, got {border_color}", + ) + + finally: + browser2.quit() + + + +# ── Polarity theming: qualifier text + no correspondence ───────────────────── + +class SigSelectThemeTest(FunctionalTest): + """Polarity-qualifier display (Graven/Leavened) and correspondence suppression. + No WebSocket needed — stage updates are local; uses plain FunctionalTest.""" + + EMAILS = [ + "founder@test.io", "amigo@test.io", "bud@test.io", + "pal@test.io", "dude@test.io", "bro@test.io", + ] + + def setUp(self): + super().setUp() + self.browser.set_window_size(800, 1200) + 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"}) + + def _setup_sig_room(self): + founder, _ = User.objects.get_or_create(email=self.EMAILS[0]) + room = Room.objects.create(name="Theme Test", owner=founder) + _fill_room_via_orm(room, self.EMAILS) + _assign_all_roles(room) + return room + + def _hover_card(self, css): + from selenium.webdriver.common.action_chains import ActionChains + card = self.browser.find_element(By.CSS_SELECTOR, css) + ActionChains(self.browser).move_to_element(card).perform() + return card + + # ── ST1: Levity (Leavened) qualifier ──────────────────────────────────── # + + def test_levity_non_major_card_shows_leavened_above(self): + """Hovering a non-major card in the levity overlay shows 'Leavened' in + qualifier-above and nothing in qualifier-below.""" + room = self._setup_sig_room() + self.create_pre_authenticated_session("founder@test.io") # PC = levity + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + self._hover_card('.sig-card[data-arcana="Minor Arcana"]') + + above = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above") + ) + self.assertEqual(above.text, "Leavened") + below = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below") + self.assertEqual(below.text, "") + + def test_levity_major_card_shows_leavened_below(self): + """Hovering a major arcana card in the levity overlay shows 'Leavened' in + qualifier-below and nothing in qualifier-above.""" + room = self._setup_sig_room() + self.create_pre_authenticated_session("founder@test.io") # PC = levity + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + self._hover_card('.sig-card[data-arcana="Major Arcana"]') + + below = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below") + ) + self.assertEqual(below.text, "Leavened") + above = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above") + self.assertEqual(above.text, "") + + # ── ST2: Gravity (Graven) qualifier ───────────────────────────────────── # + + def test_gravity_non_major_card_shows_graven_above(self): + """EC (bud) sees the gravity overlay; hovering a non-major card shows 'Graven'.""" + room = self._setup_sig_room() + self.create_pre_authenticated_session("bud@test.io") # EC = gravity + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + self._hover_card('.sig-card[data-arcana="Minor Arcana"]') + + above = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above") + ) + self.assertEqual(above.text, "Graven") + + # ── ST3: Correspondence not shown ─────────────────────────────────────── # + + def test_correspondence_not_shown_in_sig_select(self): + """The Minchiate-equivalence field must always be blank on the stage card.""" + room = self._setup_sig_room() + self.create_pre_authenticated_session("founder@test.io") + self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/") + self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay")) + + # Hover any card — correspondence should remain empty regardless + self._hover_card(".sig-card") + self.wait_for(lambda: self.browser.find_element( + By.CSS_SELECTOR, ".sig-stage-card" + )) + corr = self.browser.find_element(By.CSS_SELECTOR, ".fan-card-correspondence") + self.assertEqual(corr.text, "") diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index 2f5d658..df5e215 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -1,12 +1,12 @@ describe("SigSelect", () => { let testDiv, stageCard, card, statBlock; - function makeFixture({ reservations = '{}', cardCautions = '[]' } = {}) { + function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) { testDiv = document.createElement("div"); testDiv.innerHTML = `
@@ -15,7 +15,9 @@ describe("SigSelect", () => {

+

+

@@ -409,4 +411,198 @@ describe("SigSelect", () => { expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0); }); }); + + // ── WS cursor hover (applyHover) ──────────────────────────────────────── // + // + // Fixture polarity = levity, userRole = PC. + // POLARITY_ROLES: levity → [PC, NC, SC] = [left, mid, right] + // + // Only tests the JS position mapping — colour is CSS-only. + + describe("WS cursor hover", () => { + beforeEach(() => makeFixture()); + + it("NC hover activates the --mid cursor", () => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "NC", active: true }, + })); + expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(true); + }); + + it("SC hover activates the --right cursor", () => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "SC", active: true }, + })); + expect(card.querySelector(".sig-cursor--right").classList.contains("active")).toBe(true); + }); + + it("own role (PC) hover event is ignored — no cursor activates", () => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "PC", active: true }, + })); + expect(card.querySelectorAll(".sig-cursor.active").length).toBe(0); + }); + + it("hover-off removes .active from the cursor", () => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "NC", active: true }, + })); + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "NC", active: false }, + })); + expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(false); + }); + + it("hover on unknown card_id is a no-op", () => { + expect(() => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 9999, role: "NC", active: true }, + })); + }).not.toThrow(); + }); + }); + + // ── WS reservation — data-reserved-by attribute ───────────────────────── // + // + // applyReservation() sets data-reserved-by so the CSS can glow the card in + // the reserving gamer's role colour. These tests assert the attribute, not + // the colour (CSS variables aren't resolvable in the SpecRunner context). + + describe("WS reservation sets data-reserved-by", () => { + beforeEach(() => makeFixture()); + + it("peer reservation sets data-reserved-by to the reserving role", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + expect(card.dataset.reservedBy).toBe("NC"); + }); + + it("peer reservation also adds .sig-reserved class", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + expect(card.classList.contains("sig-reserved")).toBe(true); + }); + + it("release removes data-reserved-by", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: false }, + })); + expect(card.dataset.reservedBy).toBeUndefined(); + }); + + it("own reservation (PC) sets data-reserved-by AND .sig-reserved--own", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "PC", reserved: true }, + })); + expect(card.dataset.reservedBy).toBe("PC"); + expect(card.classList.contains("sig-reserved--own")).toBe(true); + }); + + it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => { + // First, a hover float exists for NC (mid cursor) + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "NC", active: true }, + })); + expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull(); + expect(document.querySelector('.fa-hand-pointer[data-role="NC"]')).not.toBeNull(); + + // NC then clicks OK — reservation arrives + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + + // Thumbs-up replaces hand-pointer + const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]'); + expect(floatEl).not.toBeNull(); + expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true); + expect(floatEl.classList.contains("fa-hand-pointer")).toBe(false); + }); + + it("peer release removes the thumbs-up float", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).not.toBeNull(); + + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: false }, + })); + expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).toBeNull(); + }); + }); + + // ── Polarity theming — stage qualifier text ────────────────────────────── // + // + // On mouseenter, updateStage() injects "Leavened" or "Graven" into the + // sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot. + // Correspondence field is never populated in sig-select context. + + describe("polarity theming — stage qualifier", () => { + it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + // data-arcana defaults to "Minor Arcana" in fixture → non-major + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened"); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe(""); + }); + + it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe(""); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened"); + }); + + it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dataset.arcana = "Major Arcana"; + card.dataset.nameTitle = "The Schizo"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".fan-card-name").textContent).toBe("The Schizo,"); + }); + + it("non-major arcana title has no trailing comma", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + // fixture default: Minor Arcana, "King of Pentacles" + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles"); + }); + + it("gravity non-major card puts 'Graven' in qualifier-above", () => { + makeFixture({ polarity: 'gravity', userRole: 'BC' }); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Graven"); + }); + + it("gravity major arcana card puts 'Graven' in qualifier-below", () => { + makeFixture({ polarity: 'gravity', userRole: 'BC' }); + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Graven"); + }); + + it("hovering clears qualifier slots from the previous card", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + // Now major — above should be empty, below filled + expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe(""); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened"); + }); + + it("correspondence field is never populated", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dataset.correspondence = "Il Bagatto (Minchiate)"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe(""); + }); + }); }); diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss new file mode 100644 index 0000000..498ef24 --- /dev/null +++ b/src/static_src/scss/_card-deck.scss @@ -0,0 +1,669 @@ +// ─── Card deck primitives — fan cards + sig-select overlay ───────────────────── +// +// Shared card display classes (.fan-card, .fan-card-corner, .fan-card-face, .fan-nav) +// extracted from _game-kit.scss; sig-select overlay extracted from _room.scss. + +// ── Tarot fan modal ────────────────────────────────────────────────────────── + +#id_tarot_fan_dialog { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: none; + background: rgba(0, 0, 0, 0.88); + overflow: hidden; + + &::backdrop { display: none; } // Dialog IS the backdrop +} + +.tarot-fan-wrap { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + perspective: 900px; + + button { + box-shadow: none; + + &:hover, &.active { + box-shadow: none; + } + } +} + +.tarot-fan { + position: relative; + width: 220px; + height: 340px; +} + +.fan-card { + position: absolute; + inset: 0; + width: 220px; + height: 340px; + border-radius: 0.75rem; + background: rgba(var(--priUser), 1); + border: 0.1rem solid rgba(var(--secUser), 0.4); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.25s ease, opacity 0.25s ease; + transform-style: preserve-3d; + + &--active { + border-color: rgba(var(--secUser), 1); + box-shadow: 0 0 2rem rgba(var(--secUser), 0.3); + } +} + +.fan-card-corner { + position: absolute; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; + line-height: 1; + color: rgba(var(--secUser), 0.75); + + &--tl { top: 0.4rem; left: 0.4rem; } + &--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); } + + .fan-corner-rank { + font-size: 1.5rem; + font-weight: bold; + padding: 0.18rem 0; + } + i { font-size: 1.5rem; } +} + +.fan-card-face { + padding: 1.25rem; + text-align: center; + display: flex; + flex-direction: column; + gap: 0.5rem; + + .fan-card-number { font-size: 0.65rem; } + .fan-card-name-group { font-size: 0.65rem; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(var(--secUser), 1); } + .fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; color: rgba(var(--terUser), 1); } + .fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(var(--secUser), 1); } + .fan-card-correspondence { font-size: 0.6rem; font-style: italic; color: rgba(var(--secUser), 0.5); } +} + +.fan-nav { + position: absolute; + z-index: 20; + font-size: 3rem; + line-height: 1; + background: none; + border: none; + color: rgba(var(--secUser), 0.6); + cursor: pointer; + padding: 1rem; + transition: color 0.15s; + pointer-events: auto; + + &:hover { color: rgba(var(--secUser), 1); } + // Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav + &:focus:not(:focus-visible) { outline: none; box-shadow: none; } + &--prev { left: 1rem; } + &--next { right: 1rem; } +} + +// ─── Sig Select overlay (SIG_SELECT phase) ──────────────────────────────────── +// +// Two overlays (levity / gravity) run in parallel, one per polarity group. +// Layout mirrors the gatekeeper: dark Gaussian backdrop + centred modal. +// Inside the modal: upper stage (card preview) + lower mini card grid (no scroll). + +html:has(.sig-backdrop) { + overflow: hidden; +} + +.sig-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(5px); + z-index: 100; + pointer-events: none; +} + +.sig-overlay { + position: fixed; + inset: 0; + display: flex; + align-items: stretch; + justify-content: center; + z-index: 120; + pointer-events: none; +} + +.sig-modal { + pointer-events: auto; + display: flex; + flex-direction: column; + width: 100%; // respects overlay padding-right set by JS + max-width: 420px; + max-height: 100%; // respects overlay padding-bottom set by JS +} + +// ─── Stage ──────────────────────────────────────────────────────────────────── +// flex: 1 — fills all space above the card grid; no background (backdrop blur). +// Row layout: preview card bottom-left, stat block fills the right. +// Card width is set by sizeSigCard() in room.js (smaller of 40% stage width or +// 80% stage height × 5/8) via --sig-card-w CSS variable — libsass can't handle +// container query units inside min(). + +.sig-stage { + flex: 1; + min-height: 0; + position: relative; + display: flex; + flex-direction: row; + align-items: flex-end; + padding-left: 1.5rem; + gap: 0.75rem; + + // Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height. + .sig-stage-card { + flex-shrink: 0; + width: var(--sig-card-w, 120px); + height: auto; + aspect-ratio: 5 / 8; + border-radius: 0.5rem; + background: rgba(var(--priUser), 1); + border: 0.15rem solid rgba(var(--secUser), 0.6); + display: flex; + flex-direction: column; + position: relative; + padding: 0.25rem; + overflow: hidden; + + // game-kit sets .fan-card-corner { position: absolute; top/left offsets } + // so these just need display/font overrides; the corners land at the card edges. + // All font-sizes scale with --sig-card-w (ratio = original-rem × 16 / 120). + .fan-card-corner--tl { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.1; + gap: 0.1rem; + + .fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.133); font-weight: 700; } + i { font-size: calc(var(--sig-card-w, 120px) * 0.1); } + } + + .fan-card-corner--br { + display: flex; + flex-direction: column; + align-items: center; + line-height: 1.1; + gap: 0.1rem; + + .fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.12); font-weight: 700; } + i { font-size: calc(var(--sig-card-w, 120px) * 0.1); } + } + + .fan-card-face { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 0.25rem 0.15rem; + gap: 0.2rem; + + .fan-card-name-group { font-size: calc(var(--sig-card-w, 120px) * 0.073); opacity: 0.6; } + .sig-qualifier-above, + .sig-qualifier-below { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; } + .fan-card-name { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; } + .fan-card-arcana { font-size: calc(var(--sig-card-w, 120px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; } + .fan-card-correspondence{ display: none; } // Minchiate equivalence shown in game-kit only + } + } + + // Stat block — same dimensions as the preview card (width × 5:8 aspect). + // flex: 0 0 auto so it doesn't stretch to fill the stage; the rest of the + // stage row is simply empty, giving the card room to breathe. + .sig-stat-block { + flex: 0 0 auto; + width: var(--sig-card-w, 120px); + height: calc(var(--sig-card-w, 120px) * 8 / 5); + align-self: flex-end; + background: rgba(var(--priUser), 0.5); + border-radius: 0.4rem; + border: 0.1rem solid rgba(var(--terUser), 0.15); + display: none; + position: relative; + + + .sig-flip-btn { + position: absolute; + top: -1rem; + right: -1rem; + margin: 0; + z-index: 50; + } + + .sig-caution-btn { + position: absolute; + top: 1.25rem; + right: -1rem; + margin: 0; + z-index: 50; + } + + // Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons. + .sig-caution-tooltip { + display: none; + position: absolute; + inset: 0; + z-index: 60; + background-color: rgba(var(--tooltip-bg), 0.6); + backdrop-filter: blur(6px); + border-radius: 0.4rem; + border: 0.1rem solid rgba(var(--priYl), 0.35); + padding: 0.75rem; + flex-direction: column; + gap: 0.4rem; + overflow-y: auto; + } + + .sig-caution-header { + display: flex; + flex-direction: column; + gap: 0.1rem; + } + + .sig-caution-title { + font-size: calc(var(--sig-card-w, 120px) * 0.093); + font-weight: 700; + margin: 0; + color: rgba(var(--priYl), 1); + } + + .sig-caution-type { + font-size: calc(var(--sig-card-w, 120px) * 0.058); + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.05em; + flex-shrink: 0; + } + + .sig-caution-shoptalk { + font-size: calc(var(--sig-card-w, 120px) * 0.063); + opacity: 0.55; + margin: 0; + font-style: italic; + } + + .sig-caution-effect { + flex: 1; + font-size: calc(var(--sig-card-w, 120px) * 0.075); + margin: 0; + line-height: 1.55; + + .card-ref { + color: rgba(var(--terUser), 1); + font-weight: 600; + } + } + + .sig-caution-index { + font-size: calc(var(--sig-card-w, 120px) * 0.063); + opacity: 0.55; + } + + // Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70) + .sig-caution-prev, + .sig-caution-next { + display: none; + position: absolute; + bottom: -1rem; + margin: 0; + z-index: 70; + } + .sig-caution-prev { left: -1rem; } + .sig-caution-next { right: -1rem; } + + .stat-face { + display: none; + padding: calc(var(--sig-card-w, 120px) * 0.37) calc(var(--sig-card-w, 120px) * 0.1) calc(var(--sig-card-w, 120px) * 0.08); + + &--upright { display: block; } + } + + &.is-reversed { + .stat-face--upright { display: none; } + .stat-face--reversed { display: block; } + } + + .stat-face-label { + font-size: calc(var(--sig-card-w, 120px) * 0.063); + text-transform: uppercase; + letter-spacing: 0.09em; + opacity: 0.4; + margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); + } + + .stat-keywords { + list-style: none; + padding: 0; + margin: 0; + + li { + font-size: calc(var(--sig-card-w, 120px) * 0.083); + padding: calc(var(--sig-card-w, 120px) * 0.042) 0; + opacity: 0.85; + border-bottom: 0.05rem solid rgba(var(--terUser), 0.12); + + &:last-child { border-bottom: none; } + } + } + } + + &.sig-stage--frozen .sig-stat-block { display: block; } + &.sig-caution-open .sig-stat-block { + .sig-caution-tooltip { display: flex; } + .sig-caution-prev, .sig-caution-next { display: inline-flex; } + } +} + +// ─── Mini card grid ─────────────────────────────────────────────────────────── +// flex: 0 0 auto — shrinks to card content; no background (backdrop blur). +// align-content: start prevents CSS grid from distributing extra height between rows. + +.sig-deck-grid { + flex: 0 0 auto; + display: grid; + grid-template-columns: repeat(6, 1fr); + align-content: start; + gap: 2px; + padding: 4px; + overflow: hidden; + margin: 0 1rem 5rem 4rem; +} + +.sig-card { + aspect-ratio: 5 / 8; + border-radius: 0.4rem; + background: rgba(var(--priUser), 0.97); + border: 1px solid rgba(var(--secUser), 0.3); + position: relative; + cursor: grab; + transition: border-color 0.15s, box-shadow 0.15s; + overflow: hidden; + + // game-kit sets .fan-card-corner { position:absolute; top:0.4rem; left:0.4rem } + // Override: center the element within the card instead. + .fan-card-corner--tl { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + gap: 0; // game-kit has gap:0.15rem — too large at 0.5rem font-size + + .fan-corner-rank { font-size: 1rem; font-weight: 700; } + i { font-size: 0.75rem; } + } + + // OK / NVM overlay — appears on click (focused) or own reservation + .sig-card-actions { + position: absolute; + inset: 0; + display: none; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + background: rgba(var(--priUser), 0.92); + border-radius: inherit; + + .sig-nvm-btn { display: none; } + } + + &.sig-focused .sig-card-actions { display: flex; } + &.sig-reserved--own .sig-card-actions { + display: flex; + .sig-ok-btn { display: none; } + .sig-nvm-btn { display: flex; } + } + + // Cursor strip — hangs below the card bottom edge; overflow: visible allows this. + .sig-card-cursors { + position: absolute; + bottom: -0.6rem; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + padding: 0 2px; + } + + // Rise above DOM-order siblings when a peer's cursor is active on this card. + // Without this, later cards in the grid paint over the overflowing cursor icons. + &:has(.sig-cursor.active) { z-index: 5; } + + &:hover:not([data-reserved-by]) { + border-color: rgba(var(--secUser), 0.8); + box-shadow: 0 0 4px rgba(var(--secUser), 0.25); + } + + &.sig-reserved { + cursor: not-allowed; + } + + // Role-coloured reservation glow — border/shadow matches the reserving gamer's role. + // data-reserved-by is set by applyReservation() in sig-select.js. + // Own reservation also shows role colour (same as peers see), not a separate style. + &.sig-reserved { + &[data-reserved-by="PC"] { border-color: rgba(var(--priRd), 1); box-shadow: 0 0 0 2px rgba(var(--priRd), 1); } + &[data-reserved-by="NC"] { border-color: rgba(var(--priYl), 1); box-shadow: 0 0 0 2px rgba(var(--priYl), 1); } + &[data-reserved-by="EC"] { border-color: rgba(var(--priGn), 1); box-shadow: 0 0 0 2px rgba(var(--priGn), 1); } + &[data-reserved-by="SC"] { border-color: rgba(var(--priCy), 1); box-shadow: 0 0 0 2px rgba(var(--priCy), 1); } + &[data-reserved-by="AC"] { border-color: rgba(var(--priId), 1); box-shadow: 0 0 0 2px rgba(var(--priId), 1); } + &[data-reserved-by="BC"] { border-color: rgba(var(--priFs), 1); box-shadow: 0 0 0 2px rgba(var(--priFs), 1); } + } + + &.sig-reserved--own { + cursor: grabbing; + } +} + +// ─── Cursor anchors ─────────────────────────────────────────────────────────── +// +// Three tiny dots along the bottom of each mini card, one per role in the group. +// Inactive: invisible. Active (another gamer is hovering): role-coloured dot. +// Position order is fixed per polarity (POLARITY_ROLES in sig-select.js): +// levity (PC / NC / SC) → left / mid / right +// gravity (BC / EC / AC) → left / mid / right + +// In-card cursor elements — invisible anchors only. +// Visible icons are portaled to document root by applyHover() in sig-select.js. +.sig-cursor { + display: block; + font-size: 0; // zero-size: no layout impact, just carries .active class + color: transparent; + pointer-events: none; +} + +// ─── Floating cursor portal ─────────────────────────────────────────────────── +// +// sig-select.js creates these elements inside #id_sig_cursor_portal, a +// position:fixed root-level container, so they escape all overflow/clip contexts. +// Positioned via getBoundingClientRect() on the card element. + +#id_sig_cursor_portal { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; + overflow: visible; +} + +.sig-cursor-float { + position: absolute; + font-size: 1.5rem; + line-height: 1; + transform: translateX(-50%); // centre on the x coordinate from JS + pointer-events: none; +} + +// Role-specific colour + outline shadow + ninUser glow +.sig-cursor-float[data-role="PC"] { + color: rgba(var(--priRd), 1); + text-shadow: 2px 0 0 rgba(var(--priOr),1), -2px 0 0 rgba(var(--priOr),1), + 0 2px 0 rgba(var(--priOr),1), 0 -2px 0 rgba(var(--priOr),1), + 0 0 6px rgba(0, 0, 0, 0.5); +} +.sig-cursor-float[data-role="NC"] { + color: rgba(var(--priYl), 1); + text-shadow: 2px 0 0 rgba(var(--priLm),1), -2px 0 0 rgba(var(--priLm),1), + 0 2px 0 rgba(var(--priLm),1), 0 -2px 0 rgba(var(--priLm),1), + 0 0 6px rgba(0, 0, 0, 0.5); +} +.sig-cursor-float[data-role="EC"] { + color: rgba(var(--priGn), 1); + text-shadow: 2px 0 0 rgba(var(--priTk),1), -2px 0 0 rgba(var(--priTk),1), + 0 2px 0 rgba(var(--priTk),1), 0 -2px 0 rgba(var(--priTk),1), + 0 0 6px rgba(0, 0, 0, 0.5); +} +.sig-cursor-float[data-role="SC"] { + color: rgba(var(--priCy), 1); + text-shadow: 2px 0 0 rgba(var(--priBl),1), -2px 0 0 rgba(var(--priBl),1), + 0 2px 0 rgba(var(--priBl),1), 0 -2px 0 rgba(var(--priBl),1), + 0 0 6px rgba(0, 0, 0, 0.5); +} +.sig-cursor-float[data-role="AC"] { + color: rgba(var(--priId), 1); + text-shadow: 2px 0 0 rgba(var(--priVt),1), -2px 0 0 rgba(var(--priVt),1), + 0 2px 0 rgba(var(--priVt),1), 0 -2px 0 rgba(var(--priVt),1), + 0 0 6px rgba(0, 0, 0, 0.5); +} +.sig-cursor-float[data-role="BC"] { + color: rgba(var(--priFs), 1); + text-shadow: 2px 0 0 rgba(var(--priMe),1), -2px 0 0 rgba(var(--priMe),1), + 0 2px 0 rgba(var(--priMe),1), 0 -2px 0 rgba(var(--priMe),1), + 0 0 6px rgba(0, 0, 0, 0.5); +} + +// ─── Polarity theming — card colour inversion ──────────────────────────────── +// +// Gravity (Graven): --priUser bg / --secUser text — standard dark palette. +// Levity (Leavened): --secUser bg / --priUser text — inverted, lighter feel. +// Both mini-cards and the stage preview card follow the same rule. + +.sig-overlay[data-polarity="levity"] { + // Mini card: inverted palette. game-kit sets explicit colours on .fan-card-name + // and .fan-card-corner that out-specifc the parent color, so re-target them here. + .sig-card { + background: rgba(var(--secUser), 0.97); + border-color: rgba(var(--priUser), 0.3); + color: rgba(var(--priUser), 1); + .fan-card-corner { color: rgba(var(--priUser), 0.75); } + .fan-card-name { color: rgba(var(--quiUser), 1); } + // OK / NVM overlay — must match the inverted card background + .sig-card-actions { background: rgba(var(--secUser), 0.92); } + } + // Stage preview card: same inversion + title colour. + // .fan-card-name-group and .fan-card-arcana have explicit color in the base + // .fan-card-face rule (specificity 0,2,0) — must re-target them here (0,3,0). + // Opacity dim is still applied by the nested sig-stage-card rule. + .sig-stage-card { + background: rgba(var(--secUser), 1); + border-color: rgba(var(--priUser), 0.6); + color: rgba(var(--priUser), 1); + .fan-card-corner { color: rgba(var(--priUser), 0.75); } + .fan-card-name-group{ color: rgba(var(--priUser), 1); } + .fan-card-name { color: rgba(var(--quiUser), 1); } + .fan-card-arcana { color: rgba(var(--priUser), 1); } + } + // Polarity qualifier: same colour as the card title in this context + .sig-qualifier-above, + .sig-qualifier-below { color: rgba(var(--quiUser), 1); } + // card-ref spans inside the caution tooltip — must match the base rule's + // .sig-stat-block .sig-caution-effect .card-ref specificity (0,3,0) to win. + .sig-caution-effect .card-ref { color: rgba(var(--quiUser), 1); } + // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements) +} +.sig-overlay[data-polarity="gravity"] { + // Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards + .sig-stat-block { + background: rgba(var(--secUser), 0.75); + color: rgba(var(--priUser), 1); + border-color: rgba(var(--priUser), 0.15); + } + // Polarity qualifier: terUser for gravity (quiUser is levity's equivalent) + .sig-qualifier-above, + .sig-qualifier-below { color: rgba(var(--terUser), 1); } + // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements) +} + +// ─── Sig select: landscape overrides ───────────────────────────────────────── +// Landscape base: 9×2 grid of 3rem cards. At ≥992px (wide enough for 18 cards +// at 3rem + 4rem left + ~4rem right): collapse to a single 18×1 row so the +// stage preview gets maximum vertical real-estate. +// padding-left clears the fixed left navbar (JS sets right/bottom but not left). +// Grid margins reset to 0 — overlay padding handles all edge clearance. + +@media (orientation: landscape) { + .sig-modal { + max-width: none; + flex-direction: row; // grid to the right, stage + card preview to the left + margin-left: 4rem; + margin-right: 3rem; + } + .sig-stage { + min-width: 0; // allow shrinking in row layout; align-items:flex-end already set + } + .sig-deck-grid { + grid-template-columns: repeat(6, 2.5rem); + margin: 0; + align-self: flex-end; // sit at the bottom of the modal row + } +} + +@media (orientation: landscape) and (min-width: 900px) { + // Wide landscape: revert to stacked layout (stage top, 18-card row grid bottom). + .sig-modal { + flex-direction: column; + align-items: stretch; + } + .sig-stage { + min-width: auto; + align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth + margin-left: 3rem; + } + .sig-deck-grid { + grid-template-columns: repeat(18, 3rem); + align-self: center; + } +} + +@media (orientation: landscape) and (min-width: 1800px) { + // Sig overlay: clear doubled sidebars (8rem each instead of 4rem/6rem) + .sig-overlay { padding-left: 8rem; padding-right: 8rem; } + .sig-stage { + align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth + margin-left: 3rem; + } + .sig-deck-grid { + grid-template-columns: repeat(18, 5rem); + align-self: center; + } + + // Room menu: base right: 0.5rem (same-specificity ID rule) overrides _applets.scss + // XL block because _card-deck.scss is imported after _applets.scss. Re-declare here to win the cascade. + #id_room_menu { right: 2.5rem; } +} + diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss index 701faca..0c8c299 100644 --- a/src/static_src/scss/_game-kit.scss +++ b/src/static_src/scss/_game-kit.scss @@ -212,118 +212,3 @@ opacity: 0.45; } -// ── Tarot fan modal ────────────────────────────────────────────────────────── - -#id_tarot_fan_dialog { - position: fixed; - inset: 0; - width: 100%; - height: 100%; - max-width: none; - max-height: none; - margin: 0; - padding: 0; - border: none; - background: rgba(0, 0, 0, 0.88); - overflow: hidden; - - &::backdrop { display: none; } // Dialog IS the backdrop -} - -.tarot-fan-wrap { - position: relative; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - perspective: 900px; - - button { - box-shadow: none; - - &:hover, &.active { - box-shadow: none; - } - } -} - -.tarot-fan { - position: relative; - width: 220px; - height: 340px; -} - -.fan-card { - position: absolute; - inset: 0; - width: 220px; - height: 340px; - border-radius: 0.75rem; - background: rgba(var(--priUser), 1); - border: 0.1rem solid rgba(var(--secUser), 0.4); - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.25s ease, opacity 0.25s ease; - transform-style: preserve-3d; - - &--active { - border-color: rgba(var(--secUser), 1); - box-shadow: 0 0 2rem rgba(var(--secUser), 0.3); - } -} - -.fan-card-corner { - position: absolute; - display: flex; - flex-direction: column; - align-items: center; - gap: 0.15rem; - line-height: 1; - color: rgba(var(--secUser), 0.75); - - &--tl { top: 0.4rem; left: 0.4rem; } - &--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); } - - .fan-corner-rank { - font-size: 1.5rem; - font-weight: bold; - padding: 0.18rem 0; - } - i { font-size: 1.5rem; } -} - -.fan-card-face { - padding: 1.25rem; - text-align: center; - display: flex; - flex-direction: column; - gap: 0.5rem; - - .fan-card-number { font-size: 0.65rem; } - .fan-card-name-group { font-size: 0.65rem; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(var(--secUser), 1); } - .fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; color: rgba(var(--terUser), 1); } - .fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(var(--secUser), 1); } - .fan-card-correspondence { font-size: 0.6rem; font-style: italic; color: rgba(var(--secUser), 0.5); } -} - -.fan-nav { - position: absolute; - z-index: 20; - font-size: 3rem; - line-height: 1; - background: none; - border: none; - color: rgba(var(--secUser), 0.6); - cursor: pointer; - padding: 1rem; - transition: color 0.15s; - pointer-events: auto; - - &:hover { color: rgba(var(--secUser), 1); } - // Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav - &:focus:not(:focus-visible) { outline: none; box-shadow: none; } - &--prev { left: 1rem; } - &--next { right: 1rem; } -} diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 075361e..f4e9886 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -801,11 +801,18 @@ $card-h: 60px; // Landscape mobile — aggressively scale down to fit short viewport @media (orientation: landscape) { - // Sink navbar below gate/role-select overlays when a modal is open. - // Landscape navbar z-index is 100 (_base.scss); gate-backdrop/overlay are - // 100/120 — same level causes paint-order ties so we drop it to 50. + // Sink navbar + footer sidebar below any modal backdrop when open. + // Landscape navbar and footer sidebar are both z-index:100 (_base.scss). + // Gate/role-select/sig backdrops are also z-index:100 — DOM paint-order ties + // let the footer (later in DOM) bleed through. Drop both to 50. html:has(.gate-backdrop) body .container .navbar, - html:has(.role-select-backdrop) body .container .navbar { + html:has(.role-select-backdrop) body .container .navbar, + html:has(.sig-backdrop) body .container .navbar { + z-index: 50; + } + html:has(.gate-backdrop) body #id_footer, + html:has(.role-select-backdrop) body #id_footer, + html:has(.sig-backdrop) body #id_footer { z-index: 50; } @@ -832,431 +839,4 @@ $card-h: 60px; } -// ─── Sig Select overlay (SIG_SELECT phase) ──────────────────────────────────── -// -// Two overlays (levity / gravity) run in parallel, one per polarity group. -// Layout mirrors the gatekeeper: dark Gaussian backdrop + centred modal. -// Inside the modal: upper stage (card preview) + lower mini card grid (no scroll). - -html:has(.sig-backdrop) { - overflow: hidden; -} - -.sig-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.75); - backdrop-filter: blur(5px); - z-index: 100; - pointer-events: none; -} - -.sig-overlay { - position: fixed; - inset: 0; - display: flex; - align-items: stretch; - justify-content: center; - z-index: 120; - pointer-events: none; -} - -.sig-modal { - pointer-events: auto; - display: flex; - flex-direction: column; - width: 100%; // respects overlay padding-right set by JS - max-width: 420px; - max-height: 100%; // respects overlay padding-bottom set by JS -} - -// ─── Stage ──────────────────────────────────────────────────────────────────── -// flex: 1 — fills all space above the card grid; no background (backdrop blur). -// Row layout: preview card bottom-left, stat block fills the right. -// Card width is set by sizeSigCard() in room.js (smaller of 40% stage width or -// 80% stage height × 5/8) via --sig-card-w CSS variable — libsass can't handle -// container query units inside min(). - -.sig-stage { - flex: 1; - min-height: 0; - position: relative; - display: flex; - flex-direction: row; - align-items: flex-end; - padding-left: 1.5rem; - gap: 0.75rem; - - // Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height. - .sig-stage-card { - flex-shrink: 0; - width: var(--sig-card-w, 120px); - height: auto; - aspect-ratio: 5 / 8; - border-radius: 0.5rem; - background: rgba(var(--priUser), 1); - border: 0.15rem solid rgba(var(--secUser), 0.6); - display: flex; - flex-direction: column; - position: relative; - padding: 0.25rem; - overflow: hidden; - - // game-kit sets .fan-card-corner { position: absolute; top/left offsets } - // so these just need display/font overrides; the corners land at the card edges. - // All font-sizes scale with --sig-card-w (ratio = original-rem × 16 / 120). - .fan-card-corner--tl { - display: flex; - flex-direction: column; - align-items: center; - line-height: 1.1; - gap: 0.1rem; - - .fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.133); font-weight: 700; } - i { font-size: calc(var(--sig-card-w, 120px) * 0.1); } - } - - .fan-card-corner--br { - display: flex; - flex-direction: column; - align-items: center; - line-height: 1.1; - gap: 0.1rem; - - .fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.12); font-weight: 700; } - i { font-size: calc(var(--sig-card-w, 120px) * 0.1); } - } - - .fan-card-face { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - padding: 0.25rem 0.15rem; - gap: 0.2rem; - - .fan-card-name-group { font-size: calc(var(--sig-card-w, 120px) * 0.073); opacity: 0.6; } - .fan-card-name { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; } - .fan-card-arcana { font-size: calc(var(--sig-card-w, 120px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; } - .fan-card-correspondence{ font-size: calc(var(--sig-card-w, 120px) * 0.067); opacity: 0.5; } - } - } - - // Stat block — same dimensions as the preview card (width × 5:8 aspect). - // flex: 0 0 auto so it doesn't stretch to fill the stage; the rest of the - // stage row is simply empty, giving the card room to breathe. - .sig-stat-block { - flex: 0 0 auto; - width: var(--sig-card-w, 120px); - height: calc(var(--sig-card-w, 120px) * 8 / 5); - align-self: flex-end; - background: rgba(var(--priUser), 0.5); - border-radius: 0.4rem; - border: 0.1rem solid rgba(var(--terUser), 0.15); - display: none; - position: relative; - - - .sig-flip-btn { - position: absolute; - top: -1rem; - right: -1rem; - margin: 0; - z-index: 50; - } - - .sig-caution-btn { - position: absolute; - top: 1.25rem; - right: -1rem; - margin: 0; - z-index: 50; - } - - // Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons. - .sig-caution-tooltip { - display: none; - position: absolute; - inset: 0; - z-index: 60; - background-color: rgba(var(--tooltip-bg), 0.6); - backdrop-filter: blur(6px); - border-radius: 0.4rem; - border: 0.1rem solid rgba(var(--priYl), 0.35); - padding: 0.75rem; - flex-direction: column; - gap: 0.4rem; - overflow-y: auto; - } - - .sig-caution-header { - display: flex; - flex-direction: column; - gap: 0.1rem; - } - - .sig-caution-title { - font-size: calc(var(--sig-card-w, 120px) * 0.093); - font-weight: 700; - margin: 0; - color: rgba(var(--priYl), 1); - } - - .sig-caution-type { - font-size: calc(var(--sig-card-w, 120px) * 0.058); - opacity: 0.7; - text-transform: uppercase; - letter-spacing: 0.05em; - flex-shrink: 0; - } - - .sig-caution-shoptalk { - font-size: calc(var(--sig-card-w, 120px) * 0.063); - opacity: 0.55; - margin: 0; - font-style: italic; - } - - .sig-caution-effect { - flex: 1; - font-size: calc(var(--sig-card-w, 120px) * 0.075); - margin: 0; - line-height: 1.55; - - .card-ref { - color: rgba(var(--terUser), 1); - font-weight: 600; - } - } - - .sig-caution-index { - font-size: calc(var(--sig-card-w, 120px) * 0.063); - opacity: 0.55; - } - - // Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70) - .sig-caution-prev, - .sig-caution-next { - display: none; - position: absolute; - bottom: -1rem; - margin: 0; - z-index: 70; - } - .sig-caution-prev { left: -1rem; } - .sig-caution-next { right: -1rem; } - - .stat-face { - display: none; - padding: calc(var(--sig-card-w, 120px) * 0.37) calc(var(--sig-card-w, 120px) * 0.1) calc(var(--sig-card-w, 120px) * 0.08); - - &--upright { display: block; } - } - - &.is-reversed { - .stat-face--upright { display: none; } - .stat-face--reversed { display: block; } - } - - .stat-face-label { - font-size: calc(var(--sig-card-w, 120px) * 0.063); - text-transform: uppercase; - letter-spacing: 0.09em; - opacity: 0.4; - margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07); - } - - .stat-keywords { - list-style: none; - padding: 0; - margin: 0; - - li { - font-size: calc(var(--sig-card-w, 120px) * 0.083); - padding: calc(var(--sig-card-w, 120px) * 0.042) 0; - opacity: 0.85; - border-bottom: 0.05rem solid rgba(var(--terUser), 0.12); - - &:last-child { border-bottom: none; } - } - } - } - - &.sig-stage--frozen .sig-stat-block { display: block; } - &.sig-caution-open .sig-stat-block { - .sig-caution-tooltip { display: flex; } - .sig-caution-prev, .sig-caution-next { display: inline-flex; } - } -} - -// ─── Mini card grid ─────────────────────────────────────────────────────────── -// flex: 0 0 auto — shrinks to card content; no background (backdrop blur). -// align-content: start prevents CSS grid from distributing extra height between rows. - -.sig-deck-grid { - flex: 0 0 auto; - display: grid; - grid-template-columns: repeat(6, 1fr); - align-content: start; - gap: 2px; - padding: 4px; - overflow: hidden; - margin: 0 1rem 5rem 4rem; -} - -.sig-card { - aspect-ratio: 5 / 8; - border-radius: 0.4rem; - background: rgba(var(--priUser), 0.97); - border: 1px solid rgba(var(--secUser), 0.3); - position: relative; - cursor: grab; - transition: border-color 0.15s, box-shadow 0.15s; - overflow: hidden; - - // game-kit sets .fan-card-corner { position:absolute; top:0.4rem; left:0.4rem } - // Override: center the element within the card instead. - .fan-card-corner--tl { - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - gap: 0; // game-kit has gap:0.15rem — too large at 0.5rem font-size - - .fan-corner-rank { font-size: 1rem; font-weight: 700; } - i { font-size: 0.75rem; } - } - - // OK / NVM overlay — appears on click (focused) or own reservation - .sig-card-actions { - position: absolute; - inset: 0; - display: none; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 3px; - background: rgba(var(--priUser), 0.92); - border-radius: inherit; - - .sig-nvm-btn { display: none; } - } - - &.sig-focused .sig-card-actions { display: flex; } - &.sig-reserved--own .sig-card-actions { - display: flex; - .sig-ok-btn { display: none; } - .sig-nvm-btn { display: flex; } - } - - // Cursor anchors strip — bottom of card - .sig-card-cursors { - position: absolute; - bottom: 2px; - left: 2px; - right: 2px; - display: flex; - justify-content: space-between; - } - - &:hover:not([data-reserved-by]) { - border-color: rgba(var(--secUser), 0.8); - box-shadow: 0 0 4px rgba(var(--secUser), 0.25); - } - - &.sig-reserved { - border-color: rgba(var(--terUser), 1); - box-shadow: - 0 0 0.4rem rgba(var(--terUser), 0.7), - 0 0 1rem rgba(var(--ninUser), 0.4); - cursor: not-allowed; - } - - &.sig-reserved--own { - border-color: rgba(var(--secUser), 1); - box-shadow: - 0 0 0.4rem rgba(var(--secUser), 0.7), - 0 0 1rem rgba(var(--ninUser), 0.5); - cursor: grabbing; - } -} - -// ─── Cursor anchors ─────────────────────────────────────────────────────────── -// -// Three tiny dots along the bottom of each mini card, one per role in the group. -// Inactive: invisible. Active (another gamer is hovering): coloured dot. - -.sig-cursor { - display: block; - width: 5px; - height: 5px; - border-radius: 50%; - background: transparent; - transition: background 0.1s; - - &.active { - background: rgba(var(--terUser), 1); - box-shadow: 0 0 3px rgba(var(--ninUser), 0.8); - } -} - -// ─── Sig select: landscape overrides ───────────────────────────────────────── -// Landscape base: 9×2 grid of 3rem cards. At ≥992px (wide enough for 18 cards -// at 3rem + 4rem left + ~4rem right): collapse to a single 18×1 row so the -// stage preview gets maximum vertical real-estate. -// padding-left clears the fixed left navbar (JS sets right/bottom but not left). -// Grid margins reset to 0 — overlay padding handles all edge clearance. - -@media (orientation: landscape) { - .sig-modal { - max-width: none; - flex-direction: row; // grid to the right, stage + card preview to the left - margin-left: 4rem; - margin-right: 3rem; - } - .sig-stage { - min-width: 0; // allow shrinking in row layout; align-items:flex-end already set - } - .sig-deck-grid { - grid-template-columns: repeat(6, 2.5rem); - margin: 0; - align-self: flex-end; // sit at the bottom of the modal row - } -} - -@media (orientation: landscape) and (min-width: 900px) { - // Wide landscape: revert to stacked layout (stage top, 18-card row grid bottom). - .sig-modal { - flex-direction: column; - align-items: stretch; - } - .sig-stage { - min-width: auto; - align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth - margin-left: 3rem; - } - .sig-deck-grid { - grid-template-columns: repeat(18, 3rem); - align-self: center; - } -} - -@media (orientation: landscape) and (min-width: 1800px) { - // Sig overlay: clear doubled sidebars (8rem each instead of 4rem/6rem) - .sig-overlay { padding-left: 8rem; padding-right: 8rem; } - .sig-stage { - align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth - margin-left: 3rem; - } - .sig-deck-grid { - grid-template-columns: repeat(18, 5rem); - align-self: center; - } - - // Room menu: base right: 0.5rem (same-specificity ID rule) overrides _applets.scss - // XL block because _room.scss is imported later. Re-declare here to win the cascade. - #id_room_menu { right: 2.5rem; } -} - // ─── Seat tray — see _tray.scss ───────────────────────────────────────────── diff --git a/src/static_src/scss/core.scss b/src/static_src/scss/core.scss index d4cc18f..6215a88 100644 --- a/src/static_src/scss/core.scss +++ b/src/static_src/scss/core.scss @@ -6,6 +6,7 @@ @import 'gameboard'; @import 'palette-picker'; @import 'room'; +@import 'card-deck'; @import 'tray'; @import 'billboard'; @import 'game-kit'; diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index 2f5d658..df5e215 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -1,12 +1,12 @@ describe("SigSelect", () => { let testDiv, stageCard, card, statBlock; - function makeFixture({ reservations = '{}', cardCautions = '[]' } = {}) { + function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) { testDiv = document.createElement("div"); testDiv.innerHTML = `
@@ -15,7 +15,9 @@ describe("SigSelect", () => {

+

+

@@ -409,4 +411,198 @@ describe("SigSelect", () => { expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0); }); }); + + // ── WS cursor hover (applyHover) ──────────────────────────────────────── // + // + // Fixture polarity = levity, userRole = PC. + // POLARITY_ROLES: levity → [PC, NC, SC] = [left, mid, right] + // + // Only tests the JS position mapping — colour is CSS-only. + + describe("WS cursor hover", () => { + beforeEach(() => makeFixture()); + + it("NC hover activates the --mid cursor", () => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "NC", active: true }, + })); + expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(true); + }); + + it("SC hover activates the --right cursor", () => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "SC", active: true }, + })); + expect(card.querySelector(".sig-cursor--right").classList.contains("active")).toBe(true); + }); + + it("own role (PC) hover event is ignored — no cursor activates", () => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "PC", active: true }, + })); + expect(card.querySelectorAll(".sig-cursor.active").length).toBe(0); + }); + + it("hover-off removes .active from the cursor", () => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "NC", active: true }, + })); + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "NC", active: false }, + })); + expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(false); + }); + + it("hover on unknown card_id is a no-op", () => { + expect(() => { + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 9999, role: "NC", active: true }, + })); + }).not.toThrow(); + }); + }); + + // ── WS reservation — data-reserved-by attribute ───────────────────────── // + // + // applyReservation() sets data-reserved-by so the CSS can glow the card in + // the reserving gamer's role colour. These tests assert the attribute, not + // the colour (CSS variables aren't resolvable in the SpecRunner context). + + describe("WS reservation sets data-reserved-by", () => { + beforeEach(() => makeFixture()); + + it("peer reservation sets data-reserved-by to the reserving role", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + expect(card.dataset.reservedBy).toBe("NC"); + }); + + it("peer reservation also adds .sig-reserved class", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + expect(card.classList.contains("sig-reserved")).toBe(true); + }); + + it("release removes data-reserved-by", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: false }, + })); + expect(card.dataset.reservedBy).toBeUndefined(); + }); + + it("own reservation (PC) sets data-reserved-by AND .sig-reserved--own", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "PC", reserved: true }, + })); + expect(card.dataset.reservedBy).toBe("PC"); + expect(card.classList.contains("sig-reserved--own")).toBe(true); + }); + + it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => { + // First, a hover float exists for NC (mid cursor) + window.dispatchEvent(new CustomEvent("room:sig_hover", { + detail: { card_id: 42, role: "NC", active: true }, + })); + expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull(); + expect(document.querySelector('.fa-hand-pointer[data-role="NC"]')).not.toBeNull(); + + // NC then clicks OK — reservation arrives + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + + // Thumbs-up replaces hand-pointer + const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]'); + expect(floatEl).not.toBeNull(); + expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true); + expect(floatEl.classList.contains("fa-hand-pointer")).toBe(false); + }); + + it("peer release removes the thumbs-up float", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: true }, + })); + expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).not.toBeNull(); + + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "NC", reserved: false }, + })); + expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).toBeNull(); + }); + }); + + // ── Polarity theming — stage qualifier text ────────────────────────────── // + // + // On mouseenter, updateStage() injects "Leavened" or "Graven" into the + // sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot. + // Correspondence field is never populated in sig-select context. + + describe("polarity theming — stage qualifier", () => { + it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + // data-arcana defaults to "Minor Arcana" in fixture → non-major + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened"); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe(""); + }); + + it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe(""); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened"); + }); + + it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dataset.arcana = "Major Arcana"; + card.dataset.nameTitle = "The Schizo"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".fan-card-name").textContent).toBe("The Schizo,"); + }); + + it("non-major arcana title has no trailing comma", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + // fixture default: Minor Arcana, "King of Pentacles" + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles"); + }); + + it("gravity non-major card puts 'Graven' in qualifier-above", () => { + makeFixture({ polarity: 'gravity', userRole: 'BC' }); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Graven"); + }); + + it("gravity major arcana card puts 'Graven' in qualifier-below", () => { + makeFixture({ polarity: 'gravity', userRole: 'BC' }); + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Graven"); + }); + + it("hovering clears qualifier slots from the previous card", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + card.dataset.arcana = "Major Arcana"; + card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true })); + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + // Now major — above should be empty, below filled + expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe(""); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened"); + }); + + it("correspondence field is never populated", () => { + makeFixture({ polarity: 'levity', userRole: 'PC' }); + card.dataset.correspondence = "Il Bagatto (Minchiate)"; + card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); + expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe(""); + }); + }); }); diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html index d76613c..38462ce 100644 --- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html @@ -21,9 +21,11 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_

+

+

-

+

{# not shown in sig-select — game-kit only #}
@@ -77,9 +79,9 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
- - - + + +
{% endfor %}