ROLE SELECT: block role pick without deck; SigSelect qualifier spec fix — TDD
- role-select.js: _showNoDeckWarning(stack) intercepts at openFan() — fan never
opens when data-equipped-deck=""; warning positioned fixed over card-stack via
getBoundingClientRect(); .guard-actions wrapper for FYI/.btn-caution + NVM/.btn-cancel
- room.html: card-stack gains data-equipped-deck="{{ equipped_deck_id|default:'' }}"
- room view context: equipped_deck_id added
- _room.scss: .role-no-deck-warning — glass guard style matching #id_guard_portal
- _base.scss + _room.scss: guard portal + no-deck warning opacity 0.5 → 0.75
(matches .tt tooltip; light-palette handled via --tooltip-bg CSS var)
- RoleSelectSpec.js: 8 Jasmine specs — no-deck (fan blocked, warning, FYI/NVM,
no duplicate, no POST) + deck-present pass-through; afterEach cleans up warning
- SigSelectSpec.js: card fixture gains data-levity-qualifier + data-gravity-qualifier;
all "Leavened" expectations updated to "Elevated"
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 =
|
||||
"<p>Equip card deck before Role select</p>" +
|
||||
"<div class=\"guard-actions\">" +
|
||||
"<a class=\"btn btn-caution\" href=\"/gameboard/\">FYI</a>" +
|
||||
"<button class=\"btn btn-cancel\">NVM</button>" +
|
||||
"</div>";
|
||||
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; });
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">K</span>
|
||||
</div>
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">K</span>
|
||||
</div>
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
<div class="card-stack" data-state="{{ card_stack_state }}"
|
||||
data-starter-roles="{{ starter_roles|join:',' }}"
|
||||
data-user-slots="{{ user_slots|join:',' }}"
|
||||
data-active-slot="{{ active_slot }}">
|
||||
data-active-slot="{{ active_slot }}"
|
||||
data-equipped-deck="{{ equipped_deck_id|default:'' }}">
|
||||
{% if card_stack_state == "ineligible" %}
|
||||
<i class="fa-solid fa-ban"></i>
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user