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 %}