role-select UX: tray timing delays, seat/circle state polish, 394 ITs green
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

- _animationPending set before fetch (not in .then()) — blocks WS turn advance during in-flight request
- _placeCardDelay (3s) + _postTrayDelay (3s) give gamer time to see each step; both zeroed by _testReset()
- .role-confirmed class: full-opacity chair after placeCard completes; server-rendered on reload
- Slot circles disappear in join order (slot 1 first) via count-based logic, not role-label matching
- data-active-slot on card-stack; handleTurnChanged writes it for selectRole() to read
- #id_tray_wrap not rendered during gate phase ({% if room.table_status %})
- Tray slide/arc-in slowed to 1s for diagnostics; wobble kept at 0.45s
- Obsolete test_roles_revealed_simultaneously FT removed; T8 tray FT uses ROLE_SELECT room
- Jasmine macrotask flush pattern: await new Promise(r => setTimeout(r, 0)) after fetch .then()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-03-31 00:01:04 -04:00
parent a8592aeaec
commit 736b59b5c0
13 changed files with 833 additions and 400 deletions

View File

@@ -239,68 +239,6 @@ html:has(.gate-backdrop) {
}
}
.gate-slots {
display: flex;
flex-direction: row;
align-items: center;
gap: $gate-gap;
.gate-slot {
position: relative;
width: $gate-node;
height: $gate-node;
border-radius: 50%;
border: $gate-line solid rgba(var(--terUser), 1);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-shrink: 0;
&.filled,
&.reserved {
background: rgba(var(--terUser), 0.2);
}
&.filled:hover,
&.reserved:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
;
}
.slot-number {
font-size: 0.7em;
opacity: 0.5;
}
.slot-gamer { display: none; }
form {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
// CARTE drop-target circle — matches .reserved appearance
&:has(.drop-token-btn) {
background: rgba(var(--terUser), 0.2);
&:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1),
;
}
}
}
}
.form-container {
margin-top: 1rem;
}
@@ -317,24 +255,6 @@ html:has(.gate-backdrop) {
}
.token-slot { min-width: 150px; }
.gate-slots {
display: grid;
grid-template-columns: repeat(3, 52px);
grid-template-rows: repeat(2, 52px);
gap: 24px;
.gate-slot {
width: 52px;
height: 52px;
&:nth-child(1) { grid-column: 1; grid-row: 1; }
&:nth-child(2) { grid-column: 2; grid-row: 1; }
&:nth-child(3) { grid-column: 3; grid-row: 1; }
&:nth-child(4) { grid-column: 1; grid-row: 2; }
&:nth-child(5) { grid-column: 2; grid-row: 2; }
&:nth-child(6) { grid-column: 3; grid-row: 2; }
}
}
}
}
@@ -366,59 +286,104 @@ $seat-r: 130px;
$seat-r-x: round($seat-r * 0.866); // 113px
$seat-r-y: round($seat-r * 0.5); // 65px
// .table-position anchors at edge midpoints (pointy-top hex).
// Seat edge-midpoint geometry (pointy-top hex).
// Apothem ≈ 80px + 30px clearance = 110px total push from centre.
$pos-d: 110px;
$pos-d-x: round($pos-d * 0.5); // 55px
$pos-d-y: round($pos-d * 0.866); // 95px
.table-position {
// ─── Position strip ────────────────────────────────────────────────────────
// Numbered gate-slot circles rendered above the backdrop (z 130 > overlay 120
// > backdrop 100). .room-page is position:relative with no z-index, so its
// absolute children share the root stacking context with the fixed overlays.
.position-strip {
position: absolute;
z-index: 110;
pointer-events: none;
transform: translate(-50%, -50%);
top: 0.5rem;
left: 0;
right: 0;
z-index: 130;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
justify-content: center;
gap: round($gate-gap * 0.6);
pointer-events: none;
// Edge midpoints, clockwise from 3 o'clock (slot drop order → role order)
&[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
&[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
&[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
&[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
.position-body {
.gate-slot {
position: relative;
width: round($gate-node * 0.75);
height: round($gate-node * 0.75);
border-radius: 50%;
border: $gate-line solid rgba(var(--terUser), 0.5);
background: rgba(var(--priUser), 1);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
justify-content: center;
flex-shrink: 0;
pointer-events: auto;
font-size: 1.8rem;
transition: opacity 0.6s ease, transform 0.6s ease;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--priUser), 0.12)
;
.fa-chair {
font-size: 1.1rem;
color: rgba(var(--secUser), 0.4);
}
&.role-assigned {
opacity: 0;
transform: scale(0.5);
pointer-events: none;
box-shadow:
0.1rem 0.1rem 0.12rem rgba(var(--terUser), 0.25),
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
0.25rem 0.25rem 0.25rem rgba(var(--terUser), 0.12)
;
}
.position-role-label {
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.05em;
color: rgba(var(--secUser), 0.5);
}
&.filled, &.reserved {
background: rgba(var(--terUser), 0.9);
border-color: rgba(var(--terUser), 1);
color: rgba(var(--priUser), 1);
}
.position-status-icon {
font-size: 0.65rem;
&.fa-ban { color: rgba(var(--priRd), 1); }
&.fa-circle-check { color: rgba(var(--priGn), 1); }
}
&.filled:hover, &.reserved:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
}
&.active {
.fa-chair {
color: rgba(var(--terUser), 1);
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
.slot-number { font-size: 0.7em; opacity: 0.5; }
.slot-gamer { display: none; }
form {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
&:has(.drop-token-btn) {
background: rgba(var(--terUser), 1);
border-color: rgba(var(--ninUser), 0.5);
&:hover {
box-shadow:
-0.1rem -0.1rem 1rem rgba(var(--ninUser), 1),
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 1),
0.05rem 0.05rem 0.5rem rgba(0, 0, 0, 1);
}
}
}
}
@media (max-width: 700px) {
.position-strip {
gap: round($gate-gap * 0.3);
.gate-slot {
width: round($gate-node * 0.75);
height: round($gate-node * 0.75);
}
}
}
@@ -482,20 +447,61 @@ $pos-d-y: round($pos-d * 0.866); // 95px
.table-seat {
position: absolute;
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: auto auto;
grid-template-rows: auto auto;
column-gap: 0.25rem;
align-items: center;
gap: 0.25rem;
// Centre the element on its anchor point
transform: translate(-50%, -50%);
pointer-events: none;
// Clockwise from top — slot drop order during ROLE_SELECT
&[data-slot="1"] { left: 50%; top: calc(50% - #{$seat-r}); }
&[data-slot="2"] { left: calc(50% + #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); }
&[data-slot="3"] { left: calc(50% + #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); }
&[data-slot="4"] { left: 50%; top: calc(50% + #{$seat-r}); }
&[data-slot="5"] { left: calc(50% - #{$seat-r-x}); top: calc(50% + #{$seat-r-y}); }
&[data-slot="6"] { left: calc(50% - #{$seat-r-x}); top: calc(50% - #{$seat-r-y}); }
// Edge midpoints, clockwise from 3 o'clock (slot drop order → role order)
&[data-slot="1"] { left: calc(50% + #{$pos-d}); top: 50%; }
&[data-slot="2"] { left: calc(50% + #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="3"] { left: calc(50% - #{$pos-d-x}); top: calc(50% + #{$pos-d-y}); }
&[data-slot="4"] { left: calc(50% - #{$pos-d}); top: 50%; }
&[data-slot="5"] { left: calc(50% - #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
&[data-slot="6"] { left: calc(50% + #{$pos-d-x}); top: calc(50% - #{$pos-d-y}); }
// Chair: col 1, spans both rows
.fa-chair {
grid-column: 1;
grid-row: 1 / 3;
font-size: 1.6rem;
color: rgba(var(--secUser), 0.4);
transition: color 0.6s ease, filter 0.6s ease;
}
// Abbreviation: col 2, row 1
.seat-role-label {
grid-column: 2;
grid-row: 1;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.05em;
color: rgba(var(--secUser), 1);
}
// Status icon: col 2, row 2, centred under the abbreviation
.position-status-icon {
grid-column: 2;
grid-row: 2;
justify-self: center;
font-size: 0.8rem;
&.fa-ban { color: rgba(var(--priRd), 1); }
&.fa-circle-check { color: rgba(var(--priGn), 1); }
}
&.active .fa-chair {
color: rgba(var(--terUser), 1);
filter: drop-shadow(0 0 4px rgba(var(--ninUser), 1));
}
// After role confirmed: chair settles to full-opacity --secUser (no glow)
&.role-confirmed .fa-chair {
color: rgba(var(--secUser), 1);
filter: none;
}
.seat-portrait {
width: 36px;
@@ -690,17 +696,6 @@ $card-h: 120px;
}
}
.gate-slots {
gap: 14px;
.gate-slot {
width: 40px;
height: 40px;
.slot-number { font-size: 0.6em; }
}
}
.form-container {
margin-top: 0.75rem;
h3 { font-size: 0.85rem; margin: 0.5rem 0; }

View File

@@ -24,6 +24,10 @@ $handle-rect-h: 72px;
$handle-exposed: 48px;
$handle-r: 1rem;
#id_tray_wrap.role-select-phase {
#id_tray_handle { visibility: hidden; pointer-events: none; }
}
#id_tray_wrap {
position: fixed;
// left set by JS: closed = vw - handleW; open = vw - wrapW
@@ -37,11 +41,11 @@ $handle-r: 1rem;
display: flex;
flex-direction: row;
align-items: stretch;
transition: left 0.35s cubic-bezier(0.4, 0, 0.2, 1);
transition: left 1.0s cubic-bezier(0.4, 0, 0.2, 1);
&.tray-dragging { transition: none; }
&.wobble { animation: tray-wobble 0.45s ease; }
&.snap { animation: tray-snap 0.30s ease; }
&.wobble { animation: tray-wobble .45s ease; }
&.snap { animation: tray-snap 1.0s ease; }
}
#id_tray_handle {
@@ -134,7 +138,7 @@ $handle-r: 1rem;
font-weight: 600;
&.arc-in {
animation: tray-role-arc-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation: tray-role-arc-in 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
}
@@ -218,11 +222,11 @@ $handle-r: 1rem;
right: $sidebar-w;
top: auto; // JS controls style.top for the Y-axis slide
bottom: auto;
transition: top 0.35s cubic-bezier(0.4, 0, 0.2, 1);
transition: top 1.0s cubic-bezier(0.4, 0, 0.2, 1);
&.tray-dragging { transition: none; }
&.wobble { animation: tray-wobble-landscape 0.45s ease; }
&.snap { animation: tray-snap-landscape 0.30s ease; }
&.snap { animation: tray-snap-landscape 1.0s ease; }
}
@@ -304,7 +308,7 @@ $handle-r: 1rem;
}
.tray-role-card.arc-in {
animation: tray-role-arc-in-landscape 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation: tray-role-arc-in-landscape 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes tray-wobble-landscape {

View File

@@ -2,6 +2,7 @@ describe("RoleSelect", () => {
let testDiv;
beforeEach(() => {
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
testDiv = document.createElement("div");
testDiv.innerHTML = `
<div class="room-page"
@@ -152,7 +153,7 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ //
describe("room:turn_changed event", () => {
let stack;
let stack, trayWrap;
beforeEach(() => {
// Six table seats, slot 1 starts active
@@ -169,6 +170,12 @@ describe("RoleSelect", () => {
stack.dataset.userSlots = "1";
stack.dataset.starterRoles = "";
testDiv.appendChild(stack);
trayWrap = document.createElement("div");
trayWrap.id = "id_tray_wrap";
// Simulate server-side class during ROLE_SELECT
trayWrap.className = "role-select-phase";
testDiv.appendChild(trayWrap);
});
it("calls Tray.forceClose() on turn change", () => {
@@ -179,13 +186,19 @@ describe("RoleSelect", () => {
expect(Tray.forceClose).toHaveBeenCalled();
});
it("moves .active to the newly active seat", () => {
it("re-adds role-select-phase to tray wrap on turn change", () => {
trayWrap.classList.remove("role-select-phase"); // simulate it was shown
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 }
}));
expect(
testDiv.querySelector(".table-seat.active").dataset.slot
).toBe("2");
expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
});
it("clears .active from all seats on turn change", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2 }
}));
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
});
it("removes .active from the previously active seat", () => {
@@ -231,6 +244,119 @@ describe("RoleSelect", () => {
stack.click();
expect(document.querySelector(".role-select-backdrop")).toBeNull();
});
it("updates seat icon to fa-circle-check when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).toBeNull();
expect(seat.querySelector(".fa-circle-check")).not.toBeNull();
});
it("adds role-confirmed to seat when role appears in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
it("leaves seat icon as fa-ban when role not in starter_roles", () => {
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "NC";
const ban = document.createElement("i");
ban.className = "position-status-icon fa-solid fa-ban";
seat.appendChild(ban);
testDiv.appendChild(seat);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(seat.querySelector(".fa-ban")).not.toBeNull();
expect(seat.querySelector(".fa-circle-check")).toBeNull();
});
it("adds role-assigned to slot-1 circle when 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(true);
});
it("leaves slot-2 circle visible when only 1 role assigned", () => {
const circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "2";
testDiv.appendChild(circle);
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: ["PC"] }
}));
expect(circle.classList.contains("role-assigned")).toBe(false);
});
it("updates data-active-slot on card stack to the new active slot", () => {
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(stack.dataset.activeSlot).toBe("2");
});
});
// ------------------------------------------------------------------ //
// selectRole slot-circle fade-out //
// ------------------------------------------------------------------ //
describe("selectRole() slot-circle behaviour", () => {
let circle, stack;
beforeEach(() => {
// Gate-slot circle for slot 1 (active turn)
circle = document.createElement("div");
circle.className = "gate-slot filled";
circle.dataset.slot = "1";
testDiv.appendChild(circle);
// Card stack with active-slot=1 so selectRole() knows which circle to hide
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
testDiv.appendChild(stack);
spyOn(Tray, "placeCard");
});
it("adds role-assigned to the active slot's circle immediately on confirm", () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
expect(circle.classList.contains("role-assigned")).toBe(true);
});
});
// ------------------------------------------------------------------ //
@@ -242,9 +368,14 @@ describe("RoleSelect", () => {
// ------------------------------------------------------------------ //
describe("tray card after successful role selection", () => {
let guardConfirm;
let guardConfirm, trayWrap;
beforeEach(() => {
trayWrap = document.createElement("div");
trayWrap.id = "id_tray_wrap";
trayWrap.className = "role-select-phase";
testDiv.appendChild(trayWrap);
// Spy on Tray.placeCard: call the onComplete callback immediately.
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
if (cb) cb();
@@ -261,13 +392,15 @@ describe("RoleSelect", () => {
it("calls Tray.placeCard() on success", async () => {
guardConfirm();
await Promise.resolve();
await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
expect(Tray.placeCard).toHaveBeenCalled();
});
it("passes the role code string to Tray.placeCard", async () => {
guardConfirm();
await Promise.resolve();
await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
expect(typeof roleCode).toBe("string");
expect(roleCode.length).toBeGreaterThan(0);
@@ -281,6 +414,27 @@ describe("RoleSelect", () => {
await Promise.resolve();
expect(Tray.placeCard).not.toHaveBeenCalled();
});
it("removes role-select-phase from tray wrap on successful pick", async () => {
guardConfirm();
await Promise.resolve();
expect(trayWrap.classList.contains("role-select-phase")).toBe(false);
});
it("adds role-confirmed class to the seated position after placeCard completes", async () => {
// Add a seat element matching the first available role (PC)
const seat = document.createElement("div");
seat.className = "table-seat";
seat.dataset.role = "PC";
seat.innerHTML = '<i class="position-status-icon fa-solid fa-ban"></i>';
testDiv.appendChild(seat);
guardConfirm();
await Promise.resolve(); // fetch resolves + placeCard fires
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
expect(seat.classList.contains("role-confirmed")).toBe(true);
});
});
// ------------------------------------------------------------------ //
@@ -360,17 +514,20 @@ describe("RoleSelect", () => {
RoleSelect.openFan();
document.querySelector("#id_role_select .card").click();
guardConfirm();
await Promise.resolve();
await Promise.resolve(); // flush fetch .then()
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
window.dispatchEvent(new CustomEvent("room:turn_changed", {
detail: { active_slot: 2, starter_roles: [] }
}));
// Fire onComplete — deferred turn_changed should now run
// Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
Tray._testFirePlaceCardComplete();
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
// Seat glow is JS-only (tray animation window); after deferred
// handleTurnChanged runs, all seat glows are cleared.
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
});
it("turn_changed after animation completes is processed immediately", () => {
@@ -379,8 +536,8 @@ describe("RoleSelect", () => {
detail: { active_slot: 2, starter_roles: [] }
}));
expect(Tray.forceClose).toHaveBeenCalled();
const activeSeat = testDiv.querySelector(".table-seat.active");
expect(activeSeat && activeSeat.dataset.slot).toBe("2");
// Seats are not persistently glowed; all active cleared
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
});
});