diff --git a/src/apps/epic/static/apps/epic/role-select.js b/src/apps/epic/static/apps/epic/role-select.js index 011a704..401a439 100644 --- a/src/apps/epic/static/apps/epic/role-select.js +++ b/src/apps/epic/static/apps/epic/role-select.js @@ -41,6 +41,29 @@ var RoleSelect = (function () { if (backdrop) backdrop.remove(); } + function _showNoDeckWarning(stack) { + if (document.querySelector(".role-no-deck-warning")) return; + var el = document.createElement("div"); + el.className = "role-no-deck-warning"; + el.innerHTML = + "

Equip card deck before Role select

" + + "
" + + "FYI" + + "" + + "
"; + el.querySelector(".btn-cancel").addEventListener("click", function () { + el.remove(); + }); + if (stack) { + var rect = stack.getBoundingClientRect(); + el.style.position = "fixed"; + el.style.left = Math.round(rect.left + rect.width / 2) + "px"; + el.style.top = Math.round(rect.top + rect.height / 2) + "px"; + el.style.transform = "translate(-50%, -50%)"; + } + document.body.appendChild(el); + } + function selectRole(roleCode) { _turnChangedBeforeFetch = false; // fresh selection, reset the race flag closeFan(); @@ -138,6 +161,12 @@ var RoleSelect = (function () { function openFan() { if (document.querySelector(".role-select-backdrop")) return; + var stack = document.querySelector(".card-stack[data-starter-roles]"); + if (stack && stack.dataset.equippedDeck === "") { + _showNoDeckWarning(stack); + return; + } + var taken = getStarterRoles(); var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; }); diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index b654a94..a8c238b 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -276,6 +276,7 @@ def _role_select_context(room, user): _my_role = assigned_seats[0].role if assigned_seats else None ctx = { "card_stack_state": card_stack_state, + "equipped_deck_id": user.equipped_deck_id if user.is_authenticated else None, "starter_roles": starter_roles, "assigned_seats": assigned_seats, "my_tray_role": _my_role, diff --git a/src/static/tests/RoleSelectSpec.js b/src/static/tests/RoleSelectSpec.js index eae2db3..3e39fff 100644 --- a/src/static/tests/RoleSelectSpec.js +++ b/src/static/tests/RoleSelectSpec.js @@ -21,6 +21,8 @@ describe("RoleSelect", () => { afterEach(() => { RoleSelect.closeFan(); testDiv.remove(); + const warning = document.querySelector(".role-no-deck-warning"); + if (warning) warning.remove(); delete window.showGuard; }); @@ -693,4 +695,87 @@ describe("RoleSelect", () => { }); }); }); + + // ------------------------------------------------------------------ // + // No-deck guard // + // data-equipped-deck="" → openFan() blocked; warning shown over stack // + // ------------------------------------------------------------------ // + + describe("openFan() — no deck equipped (data-equipped-deck='')", () => { + let stack; + + beforeEach(() => { + stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "eligible"; + stack.dataset.starterRoles = ""; + stack.dataset.userSlots = "1"; + stack.dataset.activeSlot = "1"; + stack.dataset.equippedDeck = ""; // explicitly empty = no deck + testDiv.appendChild(stack); + + RoleSelect.openFan(); // intercepted — fan must NOT open + }); + + it("does not open the fan backdrop", () => { + expect(document.querySelector(".role-select-backdrop")).toBeNull(); + }); + + it("shows .role-no-deck-warning in the DOM", () => { + expect(document.querySelector(".role-no-deck-warning")).not.toBeNull(); + }); + + it("warning contains the deck prompt text", () => { + const w = document.querySelector(".role-no-deck-warning"); + expect(w.textContent).toContain("Equip card deck before Role select"); + }); + + it("warning has a .btn-caution FYI link to gameboard", () => { + const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution"); + expect(btn).not.toBeNull(); + expect(btn.tagName).toBe("A"); + expect(btn.href).toContain("/gameboard/"); + }); + + it("warning has a .btn-cancel NVM button", () => { + expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull(); + }); + + it("NVM click removes the warning", () => { + document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click(); + expect(document.querySelector(".role-no-deck-warning")).toBeNull(); + }); + + it("second openFan() call does not duplicate the warning", () => { + RoleSelect.openFan(); + expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1); + }); + + it("does not POST to select_role", () => { + expect(window.fetch).not.toHaveBeenCalled(); + }); + }); + + describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => { + beforeEach(() => { + const stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "eligible"; + stack.dataset.starterRoles = ""; + stack.dataset.userSlots = "1"; + stack.dataset.activeSlot = "1"; + stack.dataset.equippedDeck = "42"; // non-empty = deck present + testDiv.appendChild(stack); + + RoleSelect.openFan(); + }); + + it("opens the fan backdrop normally", () => { + expect(document.querySelector(".role-select-backdrop")).not.toBeNull(); + }); + + it("does not show .role-no-deck-warning", () => { + expect(document.querySelector(".role-no-deck-warning")).toBeNull(); + }); + }); }); diff --git a/src/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index 3859d1b..33065c8 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -58,7 +58,9 @@ describe("SigSelect", () => { data-correspondence="" data-keywords-upright="action,impulsiveness,ambition" data-keywords-reversed="no direction,disregard for consequences" - data-cautions="${cardCautions.replace(/"/g, '"')}"> + data-cautions="${cardCautions.replace(/"/g, '"')}" + data-levity-qualifier="Elevated" + data-gravity-qualifier="Graven">
K
@@ -540,25 +542,25 @@ describe("SigSelect", () => { // ── Polarity theming — stage qualifier text ────────────────────────────── // // - // On mouseenter, updateStage() injects "Leavened" or "Graven" into the + // On mouseenter, updateStage() injects "Elevated" 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", () => { + it("levity non-major card puts 'Elevated' 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-above").textContent).toBe("Elevated"); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe(""); }); - it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => { + it("levity major arcana card puts 'Elevated' 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"); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated"); }); it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => { @@ -597,7 +599,7 @@ describe("SigSelect", () => { 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"); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated"); }); it("correspondence field is never populated", () => { diff --git a/src/static_src/scss/_base.scss b/src/static_src/scss/_base.scss index 32802b2..7d206a2 100644 --- a/src/static_src/scss/_base.scss +++ b/src/static_src/scss/_base.scss @@ -563,7 +563,7 @@ body { z-index: 10000; padding: 0.75rem 1rem; border-radius: 0.5rem; - background-color: rgba(var(--tooltip-bg), 0.5); + background-color: rgba(var(--tooltip-bg), 0.75); 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); diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index d2dcbb8..c449c52 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -738,6 +738,37 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut $card-w: 90px; $card-h: 60px; +// ─── No-deck warning overlay ────────────────────────────────────────────── + +.role-no-deck-warning { + position: fixed; + z-index: 10000; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + max-width: 11rem; + border-radius: 0.5rem; + background-color: rgba(var(--tooltip-bg), 0.75); + 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); + + p { + font-size: 0.75rem; + color: rgba(var(--secUser), 0.9); + text-align: center; + margin: 0; + } + + .guard-actions { + display: flex; + gap: 0.5rem; + } +} + // ─── Role select modal ───────────────────────────────────────────────────── .role-select-backdrop { diff --git a/src/static_src/tests/RoleSelectSpec.js b/src/static_src/tests/RoleSelectSpec.js index eae2db3..3e39fff 100644 --- a/src/static_src/tests/RoleSelectSpec.js +++ b/src/static_src/tests/RoleSelectSpec.js @@ -21,6 +21,8 @@ describe("RoleSelect", () => { afterEach(() => { RoleSelect.closeFan(); testDiv.remove(); + const warning = document.querySelector(".role-no-deck-warning"); + if (warning) warning.remove(); delete window.showGuard; }); @@ -693,4 +695,87 @@ describe("RoleSelect", () => { }); }); }); + + // ------------------------------------------------------------------ // + // No-deck guard // + // data-equipped-deck="" → openFan() blocked; warning shown over stack // + // ------------------------------------------------------------------ // + + describe("openFan() — no deck equipped (data-equipped-deck='')", () => { + let stack; + + beforeEach(() => { + stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "eligible"; + stack.dataset.starterRoles = ""; + stack.dataset.userSlots = "1"; + stack.dataset.activeSlot = "1"; + stack.dataset.equippedDeck = ""; // explicitly empty = no deck + testDiv.appendChild(stack); + + RoleSelect.openFan(); // intercepted — fan must NOT open + }); + + it("does not open the fan backdrop", () => { + expect(document.querySelector(".role-select-backdrop")).toBeNull(); + }); + + it("shows .role-no-deck-warning in the DOM", () => { + expect(document.querySelector(".role-no-deck-warning")).not.toBeNull(); + }); + + it("warning contains the deck prompt text", () => { + const w = document.querySelector(".role-no-deck-warning"); + expect(w.textContent).toContain("Equip card deck before Role select"); + }); + + it("warning has a .btn-caution FYI link to gameboard", () => { + const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution"); + expect(btn).not.toBeNull(); + expect(btn.tagName).toBe("A"); + expect(btn.href).toContain("/gameboard/"); + }); + + it("warning has a .btn-cancel NVM button", () => { + expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull(); + }); + + it("NVM click removes the warning", () => { + document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click(); + expect(document.querySelector(".role-no-deck-warning")).toBeNull(); + }); + + it("second openFan() call does not duplicate the warning", () => { + RoleSelect.openFan(); + expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1); + }); + + it("does not POST to select_role", () => { + expect(window.fetch).not.toHaveBeenCalled(); + }); + }); + + describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => { + beforeEach(() => { + const stack = document.createElement("div"); + stack.className = "card-stack"; + stack.dataset.state = "eligible"; + stack.dataset.starterRoles = ""; + stack.dataset.userSlots = "1"; + stack.dataset.activeSlot = "1"; + stack.dataset.equippedDeck = "42"; // non-empty = deck present + testDiv.appendChild(stack); + + RoleSelect.openFan(); + }); + + it("opens the fan backdrop normally", () => { + expect(document.querySelector(".role-select-backdrop")).not.toBeNull(); + }); + + it("does not show .role-no-deck-warning", () => { + expect(document.querySelector(".role-no-deck-warning")).toBeNull(); + }); + }); }); diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index 3859d1b..33065c8 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -58,7 +58,9 @@ describe("SigSelect", () => { data-correspondence="" data-keywords-upright="action,impulsiveness,ambition" data-keywords-reversed="no direction,disregard for consequences" - data-cautions="${cardCautions.replace(/"/g, '"')}"> + data-cautions="${cardCautions.replace(/"/g, '"')}" + data-levity-qualifier="Elevated" + data-gravity-qualifier="Graven">
K
@@ -540,25 +542,25 @@ describe("SigSelect", () => { // ── Polarity theming — stage qualifier text ────────────────────────────── // // - // On mouseenter, updateStage() injects "Leavened" or "Graven" into the + // On mouseenter, updateStage() injects "Elevated" 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", () => { + it("levity non-major card puts 'Elevated' 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-above").textContent).toBe("Elevated"); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe(""); }); - it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => { + it("levity major arcana card puts 'Elevated' 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"); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated"); }); it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => { @@ -597,7 +599,7 @@ describe("SigSelect", () => { 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"); + expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated"); }); it("correspondence field is never populated", () => { diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 0c70677..7808c1d 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -27,7 +27,8 @@
+ data-active-slot="{{ active_slot }}" + data-equipped-deck="{{ equipped_deck_id|default:'' }}"> {% if card_stack_state == "ineligible" %} {% endif %}