diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js index ef9e1c0..bee2fd0 100644 --- a/src/apps/epic/static/apps/epic/role-select.js +++ b/src/apps/epic/static/apps/epic/role-select.js @@ -104,11 +104,26 @@ var RoleSelect = (function () { card.classList.add("flipped"); }); card.addEventListener("mouseleave", function () { - card.classList.remove("flipped"); + if (!card.classList.contains("guard-active")) { + card.classList.remove("flipped"); + } }); card.addEventListener("click", function (e) { e.stopPropagation(); - selectRole(role.code, card); + card.classList.add("flipped"); + card.classList.add("guard-active"); + window.showGuard( + card, + "Start round 1 as
" + role.name + " (" + role.code + ") …?", + function () { // confirm + card.classList.remove("guard-active"); + selectRole(role.code, card); + }, + function () { // dismiss (NVM / outside click) + card.classList.remove("guard-active"); + card.classList.remove("flipped"); + } + ); }); modal.appendChild(card); diff --git a/src/functional_tests/base.py b/src/functional_tests/base.py index 8c08812..0fd6f74 100644 --- a/src/functional_tests/base.py +++ b/src/functional_tests/base.py @@ -118,6 +118,13 @@ class FunctionalTest(StaticLiveServerTestCase): ) ) + def confirm_guard(self, browser=None): + b = browser or self.browser + def _click(): + btn = b.find_element(By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes") + b.execute_script("arguments[0].click()", btn) + self.wait_for(_click) + @wait def wait_to_be_logged_in(self, email): self.browser.find_element(By.CSS_SELECTOR, "#id_logout"), @@ -199,6 +206,13 @@ class ChannelsFunctionalTest(ChannelsLiveServerTestCase): raise e time.sleep(0.5) + def confirm_guard(self, browser=None): + b = browser or self.browser + def _click(): + btn = b.find_element(By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes") + b.execute_script("arguments[0].click()", btn) + self.wait_for(_click) + def create_pre_authenticated_session(self, email): if self.test_server: session_key = create_session_on_server(self.test_server, email) diff --git a/src/functional_tests/test_gatekeeper.py b/src/functional_tests/test_gatekeeper.py index 9fff7cc..6d70a70 100644 --- a/src/functional_tests/test_gatekeeper.py +++ b/src/functional_tests/test_gatekeeper.py @@ -209,6 +209,7 @@ class GatekeeperTest(FunctionalTest): self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-danger") ).click() + self.confirm_guard() self.wait_for(lambda: self.assertEqual( self.browser.current_url, self.live_server_url + "/gameboard/" @@ -248,6 +249,7 @@ class GatekeeperTest(FunctionalTest): self.wait_for( lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon") ).click() + self.confirm_guard() self.wait_for(lambda: self.assertEqual( self.browser.current_url, self.live_server_url + "/gameboard/" diff --git a/src/functional_tests/test_login.py b/src/functional_tests/test_login.py index 1203174..64d669c 100644 --- a/src/functional_tests/test_login.py +++ b/src/functional_tests/test_login.py @@ -57,5 +57,6 @@ class LoginTest(FunctionalTest): self.wait_to_be_logged_in(email=TEST_EMAIL) self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click() + self.confirm_guard() self.wait_to_be_logged_out(email=TEST_EMAIL) diff --git a/src/functional_tests/test_room_role_select.py b/src/functional_tests/test_room_role_select.py index aa1bd2e..51aa2b1 100644 --- a/src/functional_tests/test_room_role_select.py +++ b/src/functional_tests/test_room_role_select.py @@ -206,6 +206,7 @@ class RoleSelectTest(FunctionalTest): # 5. Click first card to select it cards[0].click() + self.confirm_guard() # 6. Modal closes self.wait_for( @@ -436,6 +437,7 @@ class RoleSelectTest(FunctionalTest): ).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() # No WS — only the JS fix can make this transition happen self.wait_for( @@ -475,6 +477,7 @@ class RoleSelectTest(FunctionalTest): ).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 fan to close (selectRole closes it synchronously) self.wait_for( @@ -588,6 +591,7 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): self.browser2.find_element(By.CSS_SELECTOR, ".card-stack").click() self.wait_for(lambda: self.browser2.find_element(By.ID, "id_role_select")) self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click() + self.confirm_guard(browser=self.browser2) # 3. Watcher's seat arc moves to slot 2 — no page refresh self.wait_for(lambda: self.browser.find_element( @@ -656,6 +660,7 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest): self.browser.find_element(By.CSS_SELECTOR, ".card-stack").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() # 4. Friend's stack becomes eligible via WebSocket — no page refresh self.wait_for(lambda: self.browser2.find_element( diff --git a/src/static_src/scss/_applets.scss b/src/static_src/scss/_applets.scss index 5e7492a..a43142c 100644 --- a/src/static_src/scss/_applets.scss +++ b/src/static_src/scss/_applets.scss @@ -105,7 +105,7 @@ } // In landscape: shift gear btn and applet menus left of the footer right sidebar -@media (orientation: landscape) and (max-width: 1023px) { +@media (orientation: landscape) and (max-width: 1440px) { $sidebar-w: 4rem; .gameboard-page, diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index 3b281e0..45315eb 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -173,8 +173,8 @@ body { } } -@media (orientation: landscape) and (max-width: 1023px) { - $sidebar-w: 4rem; +@media (orientation: landscape) and (max-width: 1440px) { + $sidebar-w: 5rem; // ── Sidebar layout: navbar ← left, footer → right ──────────────────────────── body { @@ -198,34 +198,49 @@ body { .container-fluid { flex-direction: column; height: 100%; + max-height: 700px; align-items: center; justify-content: space-between; - gap: 0.5rem; + gap: 1rem; padding: 0 0.25rem; - > form { margin-left: 0; flex-shrink: 0; order: -1; } // logout at top + > form { flex-shrink: 0; order: -1; } // logout above brand } - .navbar-brand { order: 1; } // brand at bottom - .navbar-brand h1 { writing-mode: vertical-rl; transform: rotate(180deg); - font-size: 1rem; + font-size: 1.2rem; line-height: 1.2; white-space: nowrap; - margin-right: 2.75rem; + // margin-right: 3.25rem; + } + + .navbar-brand { + order: 1; // brand at bottom + width: 100%; + display: flex; + justify-content: center; } - .navbar-text, .navbar-link { display: none; } + .navbar-text { + writing-mode: vertical-rl; + transform: rotate(180deg); + font-size: 0.65rem; + white-space: nowrap; + margin: auto 0; + + .navbar-label { opacity: 0.7; } + } + .btn-primary { width: 3rem; height: 3rem; font-size: 0.75rem; border-width: 0.125rem; - margin-left: 0.75rem; + // margin-left: 0.75rem; } // Login form: email input can't fit in narrow sidebar @@ -275,7 +290,7 @@ body { flex-direction: column; width: auto; max-width: none; - gap: 1.5rem; + gap: 3rem; a { font-size: 1.75rem; @@ -285,7 +300,16 @@ body { } } - .footer-container { display: none; } + .footer-container { + position: absolute; + bottom: 0.75rem; + text-align: center; + font-size: 0.55rem; + line-height: 1.4; + color: rgba(var(--secUser), 0.5); + + br { display: block; } + } } } @@ -293,15 +317,16 @@ body { body .container { .navbar { padding: 0 0 0.25rem 0; + .navbar-brand h1 { font-size: 1.2rem; } .btn-primary { - width: 3rem; - height: 3rem; - font-size: 0.75rem; - border-width: 0.125rem; + width: 3rem; + height: 3rem; + font-size: 0.75rem; + border-width: 0.125rem; } } @@ -319,25 +344,25 @@ body { } } -@media (min-width: 1024px) and (max-height: 700px) { - body .container .navbar { - padding: 0.5rem 0; +// @media (min-width: 1024px) and (max-height: 700px) { +// body .container .navbar { +// padding: 0.5rem 0; - .navbar-brand h1 { - font-size: 1.4rem; - } - } +// .navbar-brand h1 { +// font-size: 1.4rem; +// } +// } - #id_footer { - height: 3.5rem; - padding: 0.7rem 1rem; - gap: 0.35rem; +// #id_footer { +// height: 3.5rem; +// padding: 0.7rem 1rem; +// gap: 0.35rem; - #id_footer_nav a { - font-size: 1.2rem; - } - } -} +// #id_footer_nav a { +// font-size: 1.2rem; +// } +// } +// } #id_footer { flex-shrink: 0; @@ -386,9 +411,41 @@ body { } .footer-container { + br { display: none; } + small { font-size: 0.7rem; opacity: 0.6; } } +} + +#id_guard_portal { + display: none; + position: fixed; + z-index: 10000; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background-color: rgba(var(--tooltip-bg), 0.5); + backdrop-filter: blur(6px); + border: 0.1rem solid rgba(var(--secUser), 0.4); + box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4); + + &.active { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } + + .guard-message { + font-size: 0.85rem; + color: rgba(var(--secUser), 0.9); + text-align: center; + } + + .guard-actions { + display: flex; + gap: 0.5rem; + } } \ No newline at end of file diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss index 64a1000..7aa1f80 100644 --- a/src/static_src/scss/_game-kit.scss +++ b/src/static_src/scss/_game-kit.scss @@ -3,7 +3,7 @@ bottom: 0.5rem; right: 0.5rem; - @media (orientation: landscape) and (max-width: 1023px) { + @media (orientation: landscape) and (max-width: 1440px) { right: calc(4rem + 0.5rem); bottom: 0.75rem; top: auto; diff --git a/src/static_src/tests/RoleSelectSpec.js b/src/static_src/tests/RoleSelectSpec.js index 5fb407a..ecf762d 100644 --- a/src/static_src/tests/RoleSelectSpec.js +++ b/src/static_src/tests/RoleSelectSpec.js @@ -13,11 +13,15 @@ describe("RoleSelect", () => { window.fetch = jasmine.createSpy("fetch").and.returnValue( Promise.resolve({ ok: true }) ); + // Default stub: auto-confirm so existing card-click tests pass unchanged. + // The click-guard integration describe overrides this with a capturing spy. + window.showGuard = (_anchor, _msg, onConfirm) => onConfirm && onConfirm(); }); afterEach(() => { RoleSelect.closeFan(); testDiv.remove(); + delete window.showGuard; }); // ------------------------------------------------------------------ // @@ -237,4 +241,145 @@ describe("RoleSelect", () => { expect(document.querySelector(".role-select-backdrop")).toBeNull(); }); }); + + // ------------------------------------------------------------------ // + // click-guard integration // + // ------------------------------------------------------------------ // + // NOTE: cascade prevention (outside-click on backdrop not closing the // + // fan while the guard is active) relies on the guard portal's capture- // + // phase stopPropagation, which lives in base.html and requires // + // integration testing. The callback contract is fully covered below. // + // ------------------------------------------------------------------ // + + describe("click-guard integration", () => { + let guardAnchor, guardMessage, guardConfirm, guardDismiss; + + beforeEach(() => { + window.showGuard = jasmine.createSpy("showGuard").and.callFake( + (anchor, message, onConfirm, onDismiss) => { + guardAnchor = anchor; + guardMessage = message; + guardConfirm = onConfirm; + guardDismiss = onDismiss; + } + ); + RoleSelect.openFan(); + }); + + describe("clicking a card", () => { + let card; + + beforeEach(() => { + card = document.querySelector("#id_role_select .card"); + card.click(); + }); + + it("calls window.showGuard", () => { + expect(window.showGuard).toHaveBeenCalled(); + }); + + it("passes the card element as the anchor", () => { + expect(guardAnchor).toBe(card); + }); + + it("message contains the role name", () => { + const roleName = card.querySelector(".card-role-name").textContent.trim(); + expect(guardMessage).toContain(roleName); + }); + + it("message contains the role code", () => { + expect(guardMessage).toContain(card.dataset.role); + }); + + it("message contains a
", () => { + expect(guardMessage).toContain("
"); + }); + + it("does not immediately close the fan", () => { + expect(document.querySelector(".role-select-backdrop")).not.toBeNull(); + }); + + it("does not immediately POST to the select_role URL", () => { + expect(window.fetch).not.toHaveBeenCalled(); + }); + + it("adds .flipped to the card", () => { + expect(card.classList.contains("flipped")).toBe(true); + }); + + it("adds .guard-active to the card", () => { + expect(card.classList.contains("guard-active")).toBe(true); + }); + + it("mouseleave does not remove .flipped while guard is active", () => { + card.dispatchEvent(new MouseEvent("mouseleave")); + expect(card.classList.contains("flipped")).toBe(true); + }); + }); + + describe("confirming the guard (OK)", () => { + let card; + + beforeEach(() => { + card = document.querySelector("#id_role_select .card"); + card.click(); + guardConfirm(); + }); + + it("removes .guard-active from the card", () => { + expect(card.classList.contains("guard-active")).toBe(false); + }); + + it("closes the fan", () => { + expect(document.querySelector(".role-select-backdrop")).toBeNull(); + }); + + it("POSTs to the select_role URL", () => { + expect(window.fetch).toHaveBeenCalledWith( + "/epic/room/test-uuid/select-role", + jasmine.objectContaining({ method: "POST" }) + ); + }); + + it("appends a .card to #id_inv_role_card", () => { + expect(document.querySelector("#id_inv_role_card .card")).not.toBeNull(); + }); + }); + + describe("dismissing the guard (NVM or outside click)", () => { + let card; + + beforeEach(() => { + card = document.querySelector("#id_role_select .card"); + card.click(); + guardDismiss(); + }); + + it("removes .guard-active from the card", () => { + expect(card.classList.contains("guard-active")).toBe(false); + }); + + it("removes .flipped from the card", () => { + expect(card.classList.contains("flipped")).toBe(false); + }); + + it("leaves the fan open", () => { + expect(document.querySelector(".role-select-backdrop")).not.toBeNull(); + }); + + it("does not POST to the select_role URL", () => { + expect(window.fetch).not.toHaveBeenCalled(); + }); + + it("does not add a card to inventory", () => { + expect(document.querySelector("#id_inv_role_card .card")).toBeNull(); + }); + + it("restores normal mouseleave behaviour on the card", () => { + card.dispatchEvent(new MouseEvent("mouseenter")); + card.dispatchEvent(new MouseEvent("mouseleave")); + expect(card.classList.contains("flipped")).toBe(false); + }); + }); + }); }); diff --git a/src/templates/apps/billboard/room_scroll.html b/src/templates/apps/billboard/room_scroll.html index 34fe8f0..567fc0e 100644 --- a/src/templates/apps/billboard/room_scroll.html +++ b/src/templates/apps/billboard/room_scroll.html @@ -1,6 +1,7 @@ {% extends "core/base.html" %} -{% block title_text %}{{ room.name }} — Drama Log{% endblock %} +{% block title_text %}{{ room.name }} — Billscroll{% endblock %} +{% block header_text %}Billscroll{% endblock header_text %} {% block content %}
diff --git a/src/templates/apps/gameboard/_partials/_applets.html b/src/templates/apps/gameboard/_partials/_applets.html index f100c8a..e784d4a 100644 --- a/src/templates/apps/gameboard/_partials/_applets.html +++ b/src/templates/apps/gameboard/_partials/_applets.html @@ -1,27 +1,4 @@ +{% include "apps/gameboard/_partials/_applet_menu.html" %}
- {% include "apps/applets/_partials/_applets.html" %}
\ No newline at end of file diff --git a/src/templates/apps/gameboard/_partials/_room_gear.html b/src/templates/apps/gameboard/_partials/_room_gear.html index 80e6901..eb542c5 100644 --- a/src/templates/apps/gameboard/_partials/_room_gear.html +++ b/src/templates/apps/gameboard/_partials/_room_gear.html @@ -3,12 +3,12 @@ {% if request.user == room.owner %}
{% csrf_token %} - +
{% else %}
{% csrf_token %} - +
{% endif %}
diff --git a/src/templates/core/_partials/_footer.html b/src/templates/core/_partials/_footer.html index 865c5b5..3d82a9f 100644 --- a/src/templates/core/_partials/_footer.html +++ b/src/templates/core/_partials/_footer.html @@ -11,6 +11,6 @@ diff --git a/src/templates/core/_partials/_navbar.html b/src/templates/core/_partials/_navbar.html index deb5eda..84a523c 100644 --- a/src/templates/core/_partials/_navbar.html +++ b/src/templates/core/_partials/_navbar.html @@ -15,7 +15,7 @@
{% csrf_token %} -
diff --git a/src/templates/core/base.html b/src/templates/core/base.html index 7b87ab5..26679dd 100644 --- a/src/templates/core/base.html +++ b/src/templates/core/base.html @@ -57,8 +57,91 @@ {% endif %} +
+ +
+ + +
+
+ {% block scripts %} {% endblock scripts %} +