added type='button' to both guard portal btns so firefox won't normalize to type='submit'; fixed several FTs for new click-guard functionality on Role card select & room gear menu DEL & BYE btns; several restorations to landscape breakpoint incl. logged-ion display_name, copyright info; provided title to room_scroll.html; a slurry of other minor fixes
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
Disco DeDisco
2026-03-23 19:31:57 -04:00
parent eecb6c2be6
commit 5607f70852
15 changed files with 365 additions and 65 deletions

View File

@@ -104,11 +104,26 @@ var RoleSelect = (function () {
card.classList.add("flipped"); card.classList.add("flipped");
}); });
card.addEventListener("mouseleave", function () { card.addEventListener("mouseleave", function () {
card.classList.remove("flipped"); if (!card.classList.contains("guard-active")) {
card.classList.remove("flipped");
}
}); });
card.addEventListener("click", function (e) { card.addEventListener("click", function (e) {
e.stopPropagation(); e.stopPropagation();
selectRole(role.code, card); card.classList.add("flipped");
card.classList.add("guard-active");
window.showGuard(
card,
"Start round 1 as<br>" + 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); modal.appendChild(card);

View File

@@ -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 @wait
def wait_to_be_logged_in(self, email): def wait_to_be_logged_in(self, email):
self.browser.find_element(By.CSS_SELECTOR, "#id_logout"), self.browser.find_element(By.CSS_SELECTOR, "#id_logout"),
@@ -199,6 +206,13 @@ class ChannelsFunctionalTest(ChannelsLiveServerTestCase):
raise e raise e
time.sleep(0.5) 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): def create_pre_authenticated_session(self, email):
if self.test_server: if self.test_server:
session_key = create_session_on_server(self.test_server, email) session_key = create_session_on_server(self.test_server, email)

View File

@@ -209,6 +209,7 @@ class GatekeeperTest(FunctionalTest):
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-danger") lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-danger")
).click() ).click()
self.confirm_guard()
self.wait_for(lambda: self.assertEqual( self.wait_for(lambda: self.assertEqual(
self.browser.current_url, self.live_server_url + "/gameboard/" self.browser.current_url, self.live_server_url + "/gameboard/"
@@ -248,6 +249,7 @@ class GatekeeperTest(FunctionalTest):
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon") lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon")
).click() ).click()
self.confirm_guard()
self.wait_for(lambda: self.assertEqual( self.wait_for(lambda: self.assertEqual(
self.browser.current_url, self.live_server_url + "/gameboard/" self.browser.current_url, self.live_server_url + "/gameboard/"

View File

@@ -57,5 +57,6 @@ class LoginTest(FunctionalTest):
self.wait_to_be_logged_in(email=TEST_EMAIL) self.wait_to_be_logged_in(email=TEST_EMAIL)
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click() self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
self.confirm_guard()
self.wait_to_be_logged_out(email=TEST_EMAIL) self.wait_to_be_logged_out(email=TEST_EMAIL)

View File

@@ -206,6 +206,7 @@ class RoleSelectTest(FunctionalTest):
# 5. Click first card to select it # 5. Click first card to select it
cards[0].click() cards[0].click()
self.confirm_guard()
# 6. Modal closes # 6. Modal closes
self.wait_for( self.wait_for(
@@ -436,6 +437,7 @@ class RoleSelectTest(FunctionalTest):
).click() ).click()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) 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.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 # No WS — only the JS fix can make this transition happen
self.wait_for( self.wait_for(
@@ -475,6 +477,7 @@ class RoleSelectTest(FunctionalTest):
).click() ).click()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) 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.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
self.confirm_guard()
# Wait for fan to close (selectRole closes it synchronously) # Wait for fan to close (selectRole closes it synchronously)
self.wait_for( self.wait_for(
@@ -588,6 +591,7 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
self.browser2.find_element(By.CSS_SELECTOR, ".card-stack").click() self.browser2.find_element(By.CSS_SELECTOR, ".card-stack").click()
self.wait_for(lambda: self.browser2.find_element(By.ID, "id_role_select")) 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.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
self.confirm_guard(browser=self.browser2)
# 3. Watcher's seat arc moves to slot 2 — no page refresh # 3. Watcher's seat arc moves to slot 2 — no page refresh
self.wait_for(lambda: self.browser.find_element( 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.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select")) 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.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 # 4. Friend's stack becomes eligible via WebSocket — no page refresh
self.wait_for(lambda: self.browser2.find_element( self.wait_for(lambda: self.browser2.find_element(

View File

@@ -105,7 +105,7 @@
} }
// In landscape: shift gear btn and applet menus left of the footer right sidebar // 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; $sidebar-w: 4rem;
.gameboard-page, .gameboard-page,

View File

@@ -173,8 +173,8 @@ body {
} }
} }
@media (orientation: landscape) and (max-width: 1023px) { @media (orientation: landscape) and (max-width: 1440px) {
$sidebar-w: 4rem; $sidebar-w: 5rem;
// ── Sidebar layout: navbar ← left, footer → right ──────────────────────────── // ── Sidebar layout: navbar ← left, footer → right ────────────────────────────
body { body {
@@ -198,34 +198,49 @@ body {
.container-fluid { .container-fluid {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
max-height: 700px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 1rem;
padding: 0 0.25rem; 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 { .navbar-brand h1 {
writing-mode: vertical-rl; writing-mode: vertical-rl;
transform: rotate(180deg); transform: rotate(180deg);
font-size: 1rem; font-size: 1.2rem;
line-height: 1.2; line-height: 1.2;
white-space: nowrap; 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-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 { .btn-primary {
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
font-size: 0.75rem; font-size: 0.75rem;
border-width: 0.125rem; border-width: 0.125rem;
margin-left: 0.75rem; // margin-left: 0.75rem;
} }
// Login form: email input can't fit in narrow sidebar // Login form: email input can't fit in narrow sidebar
@@ -275,7 +290,7 @@ body {
flex-direction: column; flex-direction: column;
width: auto; width: auto;
max-width: none; max-width: none;
gap: 1.5rem; gap: 3rem;
a { a {
font-size: 1.75rem; 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 { body .container {
.navbar { .navbar {
padding: 0 0 0.25rem 0; padding: 0 0 0.25rem 0;
.navbar-brand h1 { .navbar-brand h1 {
font-size: 1.2rem; font-size: 1.2rem;
} }
.btn-primary { .btn-primary {
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
font-size: 0.75rem; font-size: 0.75rem;
border-width: 0.125rem; border-width: 0.125rem;
} }
} }
@@ -319,25 +344,25 @@ body {
} }
} }
@media (min-width: 1024px) and (max-height: 700px) { // @media (min-width: 1024px) and (max-height: 700px) {
body .container .navbar { // body .container .navbar {
padding: 0.5rem 0; // padding: 0.5rem 0;
.navbar-brand h1 { // .navbar-brand h1 {
font-size: 1.4rem; // font-size: 1.4rem;
} // }
} // }
#id_footer { // #id_footer {
height: 3.5rem; // height: 3.5rem;
padding: 0.7rem 1rem; // padding: 0.7rem 1rem;
gap: 0.35rem; // gap: 0.35rem;
#id_footer_nav a { // #id_footer_nav a {
font-size: 1.2rem; // font-size: 1.2rem;
} // }
} // }
} // }
#id_footer { #id_footer {
flex-shrink: 0; flex-shrink: 0;
@@ -386,9 +411,41 @@ body {
} }
.footer-container { .footer-container {
br { display: none; }
small { small {
font-size: 0.7rem; font-size: 0.7rem;
opacity: 0.6; 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;
}
} }

View File

@@ -3,7 +3,7 @@
bottom: 0.5rem; bottom: 0.5rem;
right: 0.5rem; right: 0.5rem;
@media (orientation: landscape) and (max-width: 1023px) { @media (orientation: landscape) and (max-width: 1440px) {
right: calc(4rem + 0.5rem); right: calc(4rem + 0.5rem);
bottom: 0.75rem; bottom: 0.75rem;
top: auto; top: auto;

View File

@@ -13,11 +13,15 @@ describe("RoleSelect", () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue( window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true }) 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(() => { afterEach(() => {
RoleSelect.closeFan(); RoleSelect.closeFan();
testDiv.remove(); testDiv.remove();
delete window.showGuard;
}); });
// ------------------------------------------------------------------ // // ------------------------------------------------------------------ //
@@ -237,4 +241,145 @@ describe("RoleSelect", () => {
expect(document.querySelector(".role-select-backdrop")).toBeNull(); 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 <br>", () => {
expect(guardMessage).toContain("<br>");
});
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);
});
});
});
}); });

View File

@@ -1,6 +1,7 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% block title_text %}{{ room.name }} — Drama Log{% endblock %} {% block title_text %}{{ room.name }} — Billscroll{% endblock %}
{% block header_text %}<span>Bill</span>scroll{% endblock header_text %}
{% block content %} {% block content %}
<div class="billboard-page"> <div class="billboard-page">

View File

@@ -1,27 +1,4 @@
{% include "apps/gameboard/_partials/_applet_menu.html" %}
<div id="id_game_applets_container"> <div id="id_game_applets_container">
<div id="id_game_applet_menu" style="display:none;">
<form
hx-post="{% url "toggle_game_applets" %}"
hx-target="#id_game_applets_container"
hx-swap="outerHTML"
>
{% csrf_token %}
{% for entry in applets %}
<label>
<input
type="checkbox"
name="applets"
value="{{ entry.applet.slug }}"
{% if entry.visible %}checked{% endif %}
>
{{ entry.applet.name }}
</label>
{% endfor %}
<div class="menu-btns">
<button type="submit" class="btn btn-confirm">OK</button>
<button type="button" id="id_game_applet_menu_cancel" class="btn btn-cancel applet-menu-cancel">NVM</button>
</div>
</form>
</div>
{% include "apps/applets/_partials/_applets.html" %} {% include "apps/applets/_partials/_applets.html" %}
</div> </div>

View File

@@ -3,12 +3,12 @@
{% if request.user == room.owner %} {% if request.user == room.owner %}
<form method="POST" action="{% url 'epic:delete_room' room.id %}"> <form method="POST" action="{% url 'epic:delete_room' room.id %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-danger">DEL</button> <button type="submit" class="btn btn-danger" data-confirm="Delete this room?">DEL</button>
</form> </form>
{% else %} {% else %}
<form method="POST" action="{% url 'epic:abandon_room' room.id %}"> <form method="POST" action="{% url 'epic:abandon_room' room.id %}">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="btn btn-abandon">BYE</button> <button type="submit" class="btn btn-abandon" data-confirm="Leave this room?">BYE</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>

View File

@@ -11,6 +11,6 @@
</a> </a>
</nav> </nav>
<div class="footer-container"> <div class="footer-container">
<small>&copy;{% now "Y" %} Dis Co.</small> <small>&copy;{% now "Y" %} <br>Dis&nbsp;Co.</small>
</div> </div>
</footer> </footer>

View File

@@ -15,7 +15,7 @@
</div> </div>
<form method="POST" action="{% url "logout" %}"> <form method="POST" action="{% url "logout" %}">
{% csrf_token %} {% csrf_token %}
<button id="id_logout" class="btn btn-primary btn-xl" type="submit"> <button id="id_logout" class="btn btn-primary btn-xl" type="submit" data-confirm="Log out?">
Log Out Log Out
</button> </button>
</form> </form>

View File

@@ -57,8 +57,91 @@
{% endif %} {% endif %}
<dialog id="id_kit_bag_dialog"></dialog> <dialog id="id_kit_bag_dialog"></dialog>
<div id="id_guard_portal">
<span class="guard-message"></span>
<div class="guard-actions">
<button class="btn btn-confirm guard-yes" type="button">OK</button>
<button class="btn btn-cancel guard-no" type="button">NVM</button>
</div>
</div>
{% block scripts %} {% block scripts %}
{% endblock scripts %} {% endblock scripts %}
<script>
(function () {
var portal = null;
var _cb = null;
var _onDismiss = null;
function show(anchor, message, callback, onDismiss) {
if (!portal) return;
_cb = callback;
_onDismiss = onDismiss || null;
portal.querySelector('.guard-message').innerHTML = message;
portal.classList.add('active');
var rect = anchor.getBoundingClientRect();
var pw = portal.offsetWidth;
var rawLeft = rect.left + rect.width / 2;
var cleft = Math.max(pw / 2 + 8, Math.min(rawLeft, window.innerWidth - pw / 2 - 8));
portal.style.left = Math.round(cleft) + 'px';
if (rect.top > 120) {
portal.style.top = Math.round(rect.top) + 'px';
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
} else {
portal.style.top = Math.round(rect.bottom) + 'px';
portal.style.transform = 'translate(-50%, 0.5rem)';
}
}
function dismiss() {
if (!portal) return;
var od = _onDismiss;
portal.classList.remove('active');
_cb = null;
_onDismiss = null;
if (od) od();
}
function doConfirm() {
var cb = _cb;
portal.classList.remove('active');
_cb = null;
_onDismiss = null;
if (cb) cb();
}
document.addEventListener('DOMContentLoaded', function () {
portal = document.getElementById('id_guard_portal');
if (!portal) return;
portal.querySelector('.guard-yes').addEventListener('click', doConfirm);
portal.querySelector('.guard-no').addEventListener('click', dismiss);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') dismiss();
});
// Outside-click to dismiss — capture phase + stopPropagation
// prevents the click from cascading to backdrop listeners (e.g. closeFan)
document.addEventListener('click', function (e) {
if (!portal.classList.contains('active')) return;
if (portal.contains(e.target)) return;
e.stopPropagation();
dismiss();
}, true);
// Intercept [data-confirm] buttons (capture phase, before form submits)
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-confirm]');
if (!btn) return;
e.preventDefault();
e.stopImmediatePropagation();
var form = btn.closest('form');
show(btn, btn.dataset.confirm, function () {
if (form) form.submit();
});
}, true);
});
window.showGuard = show;
}());
</script>
<script src="{% static "vendor/htmx.min.js" %}"></script> <script src="{% static "vendor/htmx.min.js" %}"></script>
<script src="{% static "apps/applets/applets.js" %}"></script> <script src="{% static "apps/applets/applets.js" %}"></script>
<script src="{% static "apps/dashboard/game-kit.js" %}"></script> <script src="{% static "apps/dashboard/game-kit.js" %}"></script>