@@ -15,7 +15,9 @@ describe("SigSelect", () => {
+
+
@@ -409,4 +411,198 @@ describe("SigSelect", () => {
expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0);
});
});
+
+ // ── WS cursor hover (applyHover) ──────────────────────────────────────── //
+ //
+ // Fixture polarity = levity, userRole = PC.
+ // POLARITY_ROLES: levity → [PC, NC, SC] = [left, mid, right]
+ //
+ // Only tests the JS position mapping — colour is CSS-only.
+
+ describe("WS cursor hover", () => {
+ beforeEach(() => makeFixture());
+
+ it("NC hover activates the --mid cursor", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "NC", active: true },
+ }));
+ expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(true);
+ });
+
+ it("SC hover activates the --right cursor", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "SC", active: true },
+ }));
+ expect(card.querySelector(".sig-cursor--right").classList.contains("active")).toBe(true);
+ });
+
+ it("own role (PC) hover event is ignored — no cursor activates", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "PC", active: true },
+ }));
+ expect(card.querySelectorAll(".sig-cursor.active").length).toBe(0);
+ });
+
+ it("hover-off removes .active from the cursor", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "NC", active: true },
+ }));
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "NC", active: false },
+ }));
+ expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(false);
+ });
+
+ it("hover on unknown card_id is a no-op", () => {
+ expect(() => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 9999, role: "NC", active: true },
+ }));
+ }).not.toThrow();
+ });
+ });
+
+ // ── WS reservation — data-reserved-by attribute ───────────────────────── //
+ //
+ // applyReservation() sets data-reserved-by so the CSS can glow the card in
+ // the reserving gamer's role colour. These tests assert the attribute, not
+ // the colour (CSS variables aren't resolvable in the SpecRunner context).
+
+ describe("WS reservation sets data-reserved-by", () => {
+ beforeEach(() => makeFixture());
+
+ it("peer reservation sets data-reserved-by to the reserving role", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+ expect(card.dataset.reservedBy).toBe("NC");
+ });
+
+ it("peer reservation also adds .sig-reserved class", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+ expect(card.classList.contains("sig-reserved")).toBe(true);
+ });
+
+ it("release removes data-reserved-by", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: false },
+ }));
+ expect(card.dataset.reservedBy).toBeUndefined();
+ });
+
+ it("own reservation (PC) sets data-reserved-by AND .sig-reserved--own", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "PC", reserved: true },
+ }));
+ expect(card.dataset.reservedBy).toBe("PC");
+ expect(card.classList.contains("sig-reserved--own")).toBe(true);
+ });
+
+ it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => {
+ // First, a hover float exists for NC (mid cursor)
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "NC", active: true },
+ }));
+ expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull();
+ expect(document.querySelector('.fa-hand-pointer[data-role="NC"]')).not.toBeNull();
+
+ // NC then clicks OK — reservation arrives
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+
+ // Thumbs-up replaces hand-pointer
+ const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]');
+ expect(floatEl).not.toBeNull();
+ expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true);
+ expect(floatEl.classList.contains("fa-hand-pointer")).toBe(false);
+ });
+
+ it("peer release removes the thumbs-up float", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+ expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).not.toBeNull();
+
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: false },
+ }));
+ expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).toBeNull();
+ });
+ });
+
+ // ── Polarity theming — stage qualifier text ────────────────────────────── //
+ //
+ // On mouseenter, updateStage() injects "Leavened" 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", () => {
+ 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-below").textContent).toBe("");
+ });
+
+ it("levity major arcana card puts 'Leavened' 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");
+ });
+
+ it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
+ makeFixture({ polarity: 'levity', userRole: 'PC' });
+ card.dataset.arcana = "Major Arcana";
+ card.dataset.nameTitle = "The Schizo";
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".fan-card-name").textContent).toBe("The Schizo,");
+ });
+
+ it("non-major arcana title has no trailing comma", () => {
+ makeFixture({ polarity: 'levity', userRole: 'PC' });
+ // fixture default: Minor Arcana, "King of Pentacles"
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles");
+ });
+
+ it("gravity non-major card puts 'Graven' in qualifier-above", () => {
+ makeFixture({ polarity: 'gravity', userRole: 'BC' });
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Graven");
+ });
+
+ it("gravity major arcana card puts 'Graven' in qualifier-below", () => {
+ makeFixture({ polarity: 'gravity', userRole: 'BC' });
+ card.dataset.arcana = "Major Arcana";
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Graven");
+ });
+
+ it("hovering clears qualifier slots from the previous card", () => {
+ makeFixture({ polarity: 'levity', userRole: 'PC' });
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ card.dataset.arcana = "Major Arcana";
+ card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
+ 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");
+ });
+
+ it("correspondence field is never populated", () => {
+ makeFixture({ polarity: 'levity', userRole: 'PC' });
+ card.dataset.correspondence = "Il Bagatto (Minchiate)";
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
+ });
+ });
});
diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss
new file mode 100644
index 0000000..498ef24
--- /dev/null
+++ b/src/static_src/scss/_card-deck.scss
@@ -0,0 +1,669 @@
+// ─── Card deck primitives — fan cards + sig-select overlay ─────────────────────
+//
+// Shared card display classes (.fan-card, .fan-card-corner, .fan-card-face, .fan-nav)
+// extracted from _game-kit.scss; sig-select overlay extracted from _room.scss.
+
+// ── Tarot fan modal ──────────────────────────────────────────────────────────
+
+#id_tarot_fan_dialog {
+ position: fixed;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ max-width: none;
+ max-height: none;
+ margin: 0;
+ padding: 0;
+ border: none;
+ background: rgba(0, 0, 0, 0.88);
+ overflow: hidden;
+
+ &::backdrop { display: none; } // Dialog IS the backdrop
+}
+
+.tarot-fan-wrap {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ perspective: 900px;
+
+ button {
+ box-shadow: none;
+
+ &:hover, &.active {
+ box-shadow: none;
+ }
+ }
+}
+
+.tarot-fan {
+ position: relative;
+ width: 220px;
+ height: 340px;
+}
+
+.fan-card {
+ position: absolute;
+ inset: 0;
+ width: 220px;
+ height: 340px;
+ border-radius: 0.75rem;
+ background: rgba(var(--priUser), 1);
+ border: 0.1rem solid rgba(var(--secUser), 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: transform 0.25s ease, opacity 0.25s ease;
+ transform-style: preserve-3d;
+
+ &--active {
+ border-color: rgba(var(--secUser), 1);
+ box-shadow: 0 0 2rem rgba(var(--secUser), 0.3);
+ }
+}
+
+.fan-card-corner {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.15rem;
+ line-height: 1;
+ color: rgba(var(--secUser), 0.75);
+
+ &--tl { top: 0.4rem; left: 0.4rem; }
+ &--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); }
+
+ .fan-corner-rank {
+ font-size: 1.5rem;
+ font-weight: bold;
+ padding: 0.18rem 0;
+ }
+ i { font-size: 1.5rem; }
+}
+
+.fan-card-face {
+ padding: 1.25rem;
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+
+ .fan-card-number { font-size: 0.65rem; }
+ .fan-card-name-group { font-size: 0.65rem; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(var(--secUser), 1); }
+ .fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; color: rgba(var(--terUser), 1); }
+ .fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(var(--secUser), 1); }
+ .fan-card-correspondence { font-size: 0.6rem; font-style: italic; color: rgba(var(--secUser), 0.5); }
+}
+
+.fan-nav {
+ position: absolute;
+ z-index: 20;
+ font-size: 3rem;
+ line-height: 1;
+ background: none;
+ border: none;
+ color: rgba(var(--secUser), 0.6);
+ cursor: pointer;
+ padding: 1rem;
+ transition: color 0.15s;
+ pointer-events: auto;
+
+ &:hover { color: rgba(var(--secUser), 1); }
+ // Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav
+ &:focus:not(:focus-visible) { outline: none; box-shadow: none; }
+ &--prev { left: 1rem; }
+ &--next { right: 1rem; }
+}
+
+// ─── Sig Select overlay (SIG_SELECT phase) ────────────────────────────────────
+//
+// Two overlays (levity / gravity) run in parallel, one per polarity group.
+// Layout mirrors the gatekeeper: dark Gaussian backdrop + centred modal.
+// Inside the modal: upper stage (card preview) + lower mini card grid (no scroll).
+
+html:has(.sig-backdrop) {
+ overflow: hidden;
+}
+
+.sig-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.75);
+ backdrop-filter: blur(5px);
+ z-index: 100;
+ pointer-events: none;
+}
+
+.sig-overlay {
+ position: fixed;
+ inset: 0;
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ z-index: 120;
+ pointer-events: none;
+}
+
+.sig-modal {
+ pointer-events: auto;
+ display: flex;
+ flex-direction: column;
+ width: 100%; // respects overlay padding-right set by JS
+ max-width: 420px;
+ max-height: 100%; // respects overlay padding-bottom set by JS
+}
+
+// ─── Stage ────────────────────────────────────────────────────────────────────
+// flex: 1 — fills all space above the card grid; no background (backdrop blur).
+// Row layout: preview card bottom-left, stat block fills the right.
+// Card width is set by sizeSigCard() in room.js (smaller of 40% stage width or
+// 80% stage height × 5/8) via --sig-card-w CSS variable — libsass can't handle
+// container query units inside min().
+
+.sig-stage {
+ flex: 1;
+ min-height: 0;
+ position: relative;
+ display: flex;
+ flex-direction: row;
+ align-items: flex-end;
+ padding-left: 1.5rem;
+ gap: 0.75rem;
+
+ // Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height.
+ .sig-stage-card {
+ flex-shrink: 0;
+ width: var(--sig-card-w, 120px);
+ height: auto;
+ aspect-ratio: 5 / 8;
+ border-radius: 0.5rem;
+ background: rgba(var(--priUser), 1);
+ border: 0.15rem solid rgba(var(--secUser), 0.6);
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ padding: 0.25rem;
+ overflow: hidden;
+
+ // game-kit sets .fan-card-corner { position: absolute; top/left offsets }
+ // so these just need display/font overrides; the corners land at the card edges.
+ // All font-sizes scale with --sig-card-w (ratio = original-rem × 16 / 120).
+ .fan-card-corner--tl {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ line-height: 1.1;
+ gap: 0.1rem;
+
+ .fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.133); font-weight: 700; }
+ i { font-size: calc(var(--sig-card-w, 120px) * 0.1); }
+ }
+
+ .fan-card-corner--br {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ line-height: 1.1;
+ gap: 0.1rem;
+
+ .fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.12); font-weight: 700; }
+ i { font-size: calc(var(--sig-card-w, 120px) * 0.1); }
+ }
+
+ .fan-card-face {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ padding: 0.25rem 0.15rem;
+ gap: 0.2rem;
+
+ .fan-card-name-group { font-size: calc(var(--sig-card-w, 120px) * 0.073); opacity: 0.6; }
+ .sig-qualifier-above,
+ .sig-qualifier-below { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; }
+ .fan-card-name { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; }
+ .fan-card-arcana { font-size: calc(var(--sig-card-w, 120px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
+ .fan-card-correspondence{ display: none; } // Minchiate equivalence shown in game-kit only
+ }
+ }
+
+ // Stat block — same dimensions as the preview card (width × 5:8 aspect).
+ // flex: 0 0 auto so it doesn't stretch to fill the stage; the rest of the
+ // stage row is simply empty, giving the card room to breathe.
+ .sig-stat-block {
+ flex: 0 0 auto;
+ width: var(--sig-card-w, 120px);
+ height: calc(var(--sig-card-w, 120px) * 8 / 5);
+ align-self: flex-end;
+ background: rgba(var(--priUser), 0.5);
+ border-radius: 0.4rem;
+ border: 0.1rem solid rgba(var(--terUser), 0.15);
+ display: none;
+ position: relative;
+
+
+ .sig-flip-btn {
+ position: absolute;
+ top: -1rem;
+ right: -1rem;
+ margin: 0;
+ z-index: 50;
+ }
+
+ .sig-caution-btn {
+ position: absolute;
+ top: 1.25rem;
+ right: -1rem;
+ margin: 0;
+ z-index: 50;
+ }
+
+ // Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons.
+ .sig-caution-tooltip {
+ display: none;
+ position: absolute;
+ inset: 0;
+ z-index: 60;
+ background-color: rgba(var(--tooltip-bg), 0.6);
+ backdrop-filter: blur(6px);
+ border-radius: 0.4rem;
+ border: 0.1rem solid rgba(var(--priYl), 0.35);
+ padding: 0.75rem;
+ flex-direction: column;
+ gap: 0.4rem;
+ overflow-y: auto;
+ }
+
+ .sig-caution-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.1rem;
+ }
+
+ .sig-caution-title {
+ font-size: calc(var(--sig-card-w, 120px) * 0.093);
+ font-weight: 700;
+ margin: 0;
+ color: rgba(var(--priYl), 1);
+ }
+
+ .sig-caution-type {
+ font-size: calc(var(--sig-card-w, 120px) * 0.058);
+ opacity: 0.7;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ flex-shrink: 0;
+ }
+
+ .sig-caution-shoptalk {
+ font-size: calc(var(--sig-card-w, 120px) * 0.063);
+ opacity: 0.55;
+ margin: 0;
+ font-style: italic;
+ }
+
+ .sig-caution-effect {
+ flex: 1;
+ font-size: calc(var(--sig-card-w, 120px) * 0.075);
+ margin: 0;
+ line-height: 1.55;
+
+ .card-ref {
+ color: rgba(var(--terUser), 1);
+ font-weight: 600;
+ }
+ }
+
+ .sig-caution-index {
+ font-size: calc(var(--sig-card-w, 120px) * 0.063);
+ opacity: 0.55;
+ }
+
+ // Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70)
+ .sig-caution-prev,
+ .sig-caution-next {
+ display: none;
+ position: absolute;
+ bottom: -1rem;
+ margin: 0;
+ z-index: 70;
+ }
+ .sig-caution-prev { left: -1rem; }
+ .sig-caution-next { right: -1rem; }
+
+ .stat-face {
+ display: none;
+ padding: calc(var(--sig-card-w, 120px) * 0.37) calc(var(--sig-card-w, 120px) * 0.1) calc(var(--sig-card-w, 120px) * 0.08);
+
+ &--upright { display: block; }
+ }
+
+ &.is-reversed {
+ .stat-face--upright { display: none; }
+ .stat-face--reversed { display: block; }
+ }
+
+ .stat-face-label {
+ font-size: calc(var(--sig-card-w, 120px) * 0.063);
+ text-transform: uppercase;
+ letter-spacing: 0.09em;
+ opacity: 0.4;
+ margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07);
+ }
+
+ .stat-keywords {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+
+ li {
+ font-size: calc(var(--sig-card-w, 120px) * 0.083);
+ padding: calc(var(--sig-card-w, 120px) * 0.042) 0;
+ opacity: 0.85;
+ border-bottom: 0.05rem solid rgba(var(--terUser), 0.12);
+
+ &:last-child { border-bottom: none; }
+ }
+ }
+ }
+
+ &.sig-stage--frozen .sig-stat-block { display: block; }
+ &.sig-caution-open .sig-stat-block {
+ .sig-caution-tooltip { display: flex; }
+ .sig-caution-prev, .sig-caution-next { display: inline-flex; }
+ }
+}
+
+// ─── Mini card grid ───────────────────────────────────────────────────────────
+// flex: 0 0 auto — shrinks to card content; no background (backdrop blur).
+// align-content: start prevents CSS grid from distributing extra height between rows.
+
+.sig-deck-grid {
+ flex: 0 0 auto;
+ display: grid;
+ grid-template-columns: repeat(6, 1fr);
+ align-content: start;
+ gap: 2px;
+ padding: 4px;
+ overflow: hidden;
+ margin: 0 1rem 5rem 4rem;
+}
+
+.sig-card {
+ aspect-ratio: 5 / 8;
+ border-radius: 0.4rem;
+ background: rgba(var(--priUser), 0.97);
+ border: 1px solid rgba(var(--secUser), 0.3);
+ position: relative;
+ cursor: grab;
+ transition: border-color 0.15s, box-shadow 0.15s;
+ overflow: hidden;
+
+ // game-kit sets .fan-card-corner { position:absolute; top:0.4rem; left:0.4rem }
+ // Override: center the element within the card instead.
+ .fan-card-corner--tl {
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ gap: 0; // game-kit has gap:0.15rem — too large at 0.5rem font-size
+
+ .fan-corner-rank { font-size: 1rem; font-weight: 700; }
+ i { font-size: 0.75rem; }
+ }
+
+ // OK / NVM overlay — appears on click (focused) or own reservation
+ .sig-card-actions {
+ position: absolute;
+ inset: 0;
+ display: none;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 3px;
+ background: rgba(var(--priUser), 0.92);
+ border-radius: inherit;
+
+ .sig-nvm-btn { display: none; }
+ }
+
+ &.sig-focused .sig-card-actions { display: flex; }
+ &.sig-reserved--own .sig-card-actions {
+ display: flex;
+ .sig-ok-btn { display: none; }
+ .sig-nvm-btn { display: flex; }
+ }
+
+ // Cursor strip — hangs below the card bottom edge; overflow: visible allows this.
+ .sig-card-cursors {
+ position: absolute;
+ bottom: -0.6rem;
+ left: 0;
+ right: 0;
+ display: flex;
+ justify-content: space-between;
+ padding: 0 2px;
+ }
+
+ // Rise above DOM-order siblings when a peer's cursor is active on this card.
+ // Without this, later cards in the grid paint over the overflowing cursor icons.
+ &:has(.sig-cursor.active) { z-index: 5; }
+
+ &:hover:not([data-reserved-by]) {
+ border-color: rgba(var(--secUser), 0.8);
+ box-shadow: 0 0 4px rgba(var(--secUser), 0.25);
+ }
+
+ &.sig-reserved {
+ cursor: not-allowed;
+ }
+
+ // Role-coloured reservation glow — border/shadow matches the reserving gamer's role.
+ // data-reserved-by is set by applyReservation() in sig-select.js.
+ // Own reservation also shows role colour (same as peers see), not a separate style.
+ &.sig-reserved {
+ &[data-reserved-by="PC"] { border-color: rgba(var(--priRd), 1); box-shadow: 0 0 0 2px rgba(var(--priRd), 1); }
+ &[data-reserved-by="NC"] { border-color: rgba(var(--priYl), 1); box-shadow: 0 0 0 2px rgba(var(--priYl), 1); }
+ &[data-reserved-by="EC"] { border-color: rgba(var(--priGn), 1); box-shadow: 0 0 0 2px rgba(var(--priGn), 1); }
+ &[data-reserved-by="SC"] { border-color: rgba(var(--priCy), 1); box-shadow: 0 0 0 2px rgba(var(--priCy), 1); }
+ &[data-reserved-by="AC"] { border-color: rgba(var(--priId), 1); box-shadow: 0 0 0 2px rgba(var(--priId), 1); }
+ &[data-reserved-by="BC"] { border-color: rgba(var(--priFs), 1); box-shadow: 0 0 0 2px rgba(var(--priFs), 1); }
+ }
+
+ &.sig-reserved--own {
+ cursor: grabbing;
+ }
+}
+
+// ─── Cursor anchors ───────────────────────────────────────────────────────────
+//
+// Three tiny dots along the bottom of each mini card, one per role in the group.
+// Inactive: invisible. Active (another gamer is hovering): role-coloured dot.
+// Position order is fixed per polarity (POLARITY_ROLES in sig-select.js):
+// levity (PC / NC / SC) → left / mid / right
+// gravity (BC / EC / AC) → left / mid / right
+
+// In-card cursor elements — invisible anchors only.
+// Visible icons are portaled to document root by applyHover() in sig-select.js.
+.sig-cursor {
+ display: block;
+ font-size: 0; // zero-size: no layout impact, just carries .active class
+ color: transparent;
+ pointer-events: none;
+}
+
+// ─── Floating cursor portal ───────────────────────────────────────────────────
+//
+// sig-select.js creates these
elements inside #id_sig_cursor_portal, a
+// position:fixed root-level container, so they escape all overflow/clip contexts.
+// Positioned via getBoundingClientRect() on the card element.
+
+#id_sig_cursor_portal {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 9999;
+ overflow: visible;
+}
+
+.sig-cursor-float {
+ position: absolute;
+ font-size: 1.5rem;
+ line-height: 1;
+ transform: translateX(-50%); // centre on the x coordinate from JS
+ pointer-events: none;
+}
+
+// Role-specific colour + outline shadow + ninUser glow
+.sig-cursor-float[data-role="PC"] {
+ color: rgba(var(--priRd), 1);
+ text-shadow: 2px 0 0 rgba(var(--priOr),1), -2px 0 0 rgba(var(--priOr),1),
+ 0 2px 0 rgba(var(--priOr),1), 0 -2px 0 rgba(var(--priOr),1),
+ 0 0 6px rgba(0, 0, 0, 0.5);
+}
+.sig-cursor-float[data-role="NC"] {
+ color: rgba(var(--priYl), 1);
+ text-shadow: 2px 0 0 rgba(var(--priLm),1), -2px 0 0 rgba(var(--priLm),1),
+ 0 2px 0 rgba(var(--priLm),1), 0 -2px 0 rgba(var(--priLm),1),
+ 0 0 6px rgba(0, 0, 0, 0.5);
+}
+.sig-cursor-float[data-role="EC"] {
+ color: rgba(var(--priGn), 1);
+ text-shadow: 2px 0 0 rgba(var(--priTk),1), -2px 0 0 rgba(var(--priTk),1),
+ 0 2px 0 rgba(var(--priTk),1), 0 -2px 0 rgba(var(--priTk),1),
+ 0 0 6px rgba(0, 0, 0, 0.5);
+}
+.sig-cursor-float[data-role="SC"] {
+ color: rgba(var(--priCy), 1);
+ text-shadow: 2px 0 0 rgba(var(--priBl),1), -2px 0 0 rgba(var(--priBl),1),
+ 0 2px 0 rgba(var(--priBl),1), 0 -2px 0 rgba(var(--priBl),1),
+ 0 0 6px rgba(0, 0, 0, 0.5);
+}
+.sig-cursor-float[data-role="AC"] {
+ color: rgba(var(--priId), 1);
+ text-shadow: 2px 0 0 rgba(var(--priVt),1), -2px 0 0 rgba(var(--priVt),1),
+ 0 2px 0 rgba(var(--priVt),1), 0 -2px 0 rgba(var(--priVt),1),
+ 0 0 6px rgba(0, 0, 0, 0.5);
+}
+.sig-cursor-float[data-role="BC"] {
+ color: rgba(var(--priFs), 1);
+ text-shadow: 2px 0 0 rgba(var(--priMe),1), -2px 0 0 rgba(var(--priMe),1),
+ 0 2px 0 rgba(var(--priMe),1), 0 -2px 0 rgba(var(--priMe),1),
+ 0 0 6px rgba(0, 0, 0, 0.5);
+}
+
+// ─── Polarity theming — card colour inversion ────────────────────────────────
+//
+// Gravity (Graven): --priUser bg / --secUser text — standard dark palette.
+// Levity (Leavened): --secUser bg / --priUser text — inverted, lighter feel.
+// Both mini-cards and the stage preview card follow the same rule.
+
+.sig-overlay[data-polarity="levity"] {
+ // Mini card: inverted palette. game-kit sets explicit colours on .fan-card-name
+ // and .fan-card-corner that out-specifc the parent color, so re-target them here.
+ .sig-card {
+ background: rgba(var(--secUser), 0.97);
+ border-color: rgba(var(--priUser), 0.3);
+ color: rgba(var(--priUser), 1);
+ .fan-card-corner { color: rgba(var(--priUser), 0.75); }
+ .fan-card-name { color: rgba(var(--quiUser), 1); }
+ // OK / NVM overlay — must match the inverted card background
+ .sig-card-actions { background: rgba(var(--secUser), 0.92); }
+ }
+ // Stage preview card: same inversion + title colour.
+ // .fan-card-name-group and .fan-card-arcana have explicit color in the base
+ // .fan-card-face rule (specificity 0,2,0) — must re-target them here (0,3,0).
+ // Opacity dim is still applied by the nested sig-stage-card rule.
+ .sig-stage-card {
+ background: rgba(var(--secUser), 1);
+ border-color: rgba(var(--priUser), 0.6);
+ color: rgba(var(--priUser), 1);
+ .fan-card-corner { color: rgba(var(--priUser), 0.75); }
+ .fan-card-name-group{ color: rgba(var(--priUser), 1); }
+ .fan-card-name { color: rgba(var(--quiUser), 1); }
+ .fan-card-arcana { color: rgba(var(--priUser), 1); }
+ }
+ // Polarity qualifier: same colour as the card title in this context
+ .sig-qualifier-above,
+ .sig-qualifier-below { color: rgba(var(--quiUser), 1); }
+ // card-ref spans inside the caution tooltip — must match the base rule's
+ // .sig-stat-block .sig-caution-effect .card-ref specificity (0,3,0) to win.
+ .sig-caution-effect .card-ref { color: rgba(var(--quiUser), 1); }
+ // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
+}
+.sig-overlay[data-polarity="gravity"] {
+ // Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards
+ .sig-stat-block {
+ background: rgba(var(--secUser), 0.75);
+ color: rgba(var(--priUser), 1);
+ border-color: rgba(var(--priUser), 0.15);
+ }
+ // Polarity qualifier: terUser for gravity (quiUser is levity's equivalent)
+ .sig-qualifier-above,
+ .sig-qualifier-below { color: rgba(var(--terUser), 1); }
+ // Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
+}
+
+// ─── Sig select: landscape overrides ─────────────────────────────────────────
+// Landscape base: 9×2 grid of 3rem cards. At ≥992px (wide enough for 18 cards
+// at 3rem + 4rem left + ~4rem right): collapse to a single 18×1 row so the
+// stage preview gets maximum vertical real-estate.
+// padding-left clears the fixed left navbar (JS sets right/bottom but not left).
+// Grid margins reset to 0 — overlay padding handles all edge clearance.
+
+@media (orientation: landscape) {
+ .sig-modal {
+ max-width: none;
+ flex-direction: row; // grid to the right, stage + card preview to the left
+ margin-left: 4rem;
+ margin-right: 3rem;
+ }
+ .sig-stage {
+ min-width: 0; // allow shrinking in row layout; align-items:flex-end already set
+ }
+ .sig-deck-grid {
+ grid-template-columns: repeat(6, 2.5rem);
+ margin: 0;
+ align-self: flex-end; // sit at the bottom of the modal row
+ }
+}
+
+@media (orientation: landscape) and (min-width: 900px) {
+ // Wide landscape: revert to stacked layout (stage top, 18-card row grid bottom).
+ .sig-modal {
+ flex-direction: column;
+ align-items: stretch;
+ }
+ .sig-stage {
+ min-width: auto;
+ align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth
+ margin-left: 3rem;
+ }
+ .sig-deck-grid {
+ grid-template-columns: repeat(18, 3rem);
+ align-self: center;
+ }
+}
+
+@media (orientation: landscape) and (min-width: 1800px) {
+ // Sig overlay: clear doubled sidebars (8rem each instead of 4rem/6rem)
+ .sig-overlay { padding-left: 8rem; padding-right: 8rem; }
+ .sig-stage {
+ align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth
+ margin-left: 3rem;
+ }
+ .sig-deck-grid {
+ grid-template-columns: repeat(18, 5rem);
+ align-self: center;
+ }
+
+ // Room menu: base right: 0.5rem (same-specificity ID rule) overrides _applets.scss
+ // XL block because _card-deck.scss is imported after _applets.scss. Re-declare here to win the cascade.
+ #id_room_menu { right: 2.5rem; }
+}
+
diff --git a/src/static_src/scss/_game-kit.scss b/src/static_src/scss/_game-kit.scss
index 701faca..0c8c299 100644
--- a/src/static_src/scss/_game-kit.scss
+++ b/src/static_src/scss/_game-kit.scss
@@ -212,118 +212,3 @@
opacity: 0.45;
}
-// ── Tarot fan modal ──────────────────────────────────────────────────────────
-
-#id_tarot_fan_dialog {
- position: fixed;
- inset: 0;
- width: 100%;
- height: 100%;
- max-width: none;
- max-height: none;
- margin: 0;
- padding: 0;
- border: none;
- background: rgba(0, 0, 0, 0.88);
- overflow: hidden;
-
- &::backdrop { display: none; } // Dialog IS the backdrop
-}
-
-.tarot-fan-wrap {
- position: relative;
- width: 100%;
- height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
- perspective: 900px;
-
- button {
- box-shadow: none;
-
- &:hover, &.active {
- box-shadow: none;
- }
- }
-}
-
-.tarot-fan {
- position: relative;
- width: 220px;
- height: 340px;
-}
-
-.fan-card {
- position: absolute;
- inset: 0;
- width: 220px;
- height: 340px;
- border-radius: 0.75rem;
- background: rgba(var(--priUser), 1);
- border: 0.1rem solid rgba(var(--secUser), 0.4);
- display: flex;
- align-items: center;
- justify-content: center;
- transition: transform 0.25s ease, opacity 0.25s ease;
- transform-style: preserve-3d;
-
- &--active {
- border-color: rgba(var(--secUser), 1);
- box-shadow: 0 0 2rem rgba(var(--secUser), 0.3);
- }
-}
-
-.fan-card-corner {
- position: absolute;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 0.15rem;
- line-height: 1;
- color: rgba(var(--secUser), 0.75);
-
- &--tl { top: 0.4rem; left: 0.4rem; }
- &--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); }
-
- .fan-corner-rank {
- font-size: 1.5rem;
- font-weight: bold;
- padding: 0.18rem 0;
- }
- i { font-size: 1.5rem; }
-}
-
-.fan-card-face {
- padding: 1.25rem;
- text-align: center;
- display: flex;
- flex-direction: column;
- gap: 0.5rem;
-
- .fan-card-number { font-size: 0.65rem; }
- .fan-card-name-group { font-size: 0.65rem; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(var(--secUser), 1); }
- .fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; color: rgba(var(--terUser), 1); }
- .fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(var(--secUser), 1); }
- .fan-card-correspondence { font-size: 0.6rem; font-style: italic; color: rgba(var(--secUser), 0.5); }
-}
-
-.fan-nav {
- position: absolute;
- z-index: 20;
- font-size: 3rem;
- line-height: 1;
- background: none;
- border: none;
- color: rgba(var(--secUser), 0.6);
- cursor: pointer;
- padding: 1rem;
- transition: color 0.15s;
- pointer-events: auto;
-
- &:hover { color: rgba(var(--secUser), 1); }
- // Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav
- &:focus:not(:focus-visible) { outline: none; box-shadow: none; }
- &--prev { left: 1rem; }
- &--next { right: 1rem; }
-}
diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss
index 075361e..f4e9886 100644
--- a/src/static_src/scss/_room.scss
+++ b/src/static_src/scss/_room.scss
@@ -801,11 +801,18 @@ $card-h: 60px;
// Landscape mobile — aggressively scale down to fit short viewport
@media (orientation: landscape) {
- // Sink navbar below gate/role-select overlays when a modal is open.
- // Landscape navbar z-index is 100 (_base.scss); gate-backdrop/overlay are
- // 100/120 — same level causes paint-order ties so we drop it to 50.
+ // Sink navbar + footer sidebar below any modal backdrop when open.
+ // Landscape navbar and footer sidebar are both z-index:100 (_base.scss).
+ // Gate/role-select/sig backdrops are also z-index:100 — DOM paint-order ties
+ // let the footer (later in DOM) bleed through. Drop both to 50.
html:has(.gate-backdrop) body .container .navbar,
- html:has(.role-select-backdrop) body .container .navbar {
+ html:has(.role-select-backdrop) body .container .navbar,
+ html:has(.sig-backdrop) body .container .navbar {
+ z-index: 50;
+ }
+ html:has(.gate-backdrop) body #id_footer,
+ html:has(.role-select-backdrop) body #id_footer,
+ html:has(.sig-backdrop) body #id_footer {
z-index: 50;
}
@@ -832,431 +839,4 @@ $card-h: 60px;
}
-// ─── Sig Select overlay (SIG_SELECT phase) ────────────────────────────────────
-//
-// Two overlays (levity / gravity) run in parallel, one per polarity group.
-// Layout mirrors the gatekeeper: dark Gaussian backdrop + centred modal.
-// Inside the modal: upper stage (card preview) + lower mini card grid (no scroll).
-
-html:has(.sig-backdrop) {
- overflow: hidden;
-}
-
-.sig-backdrop {
- position: fixed;
- inset: 0;
- background: rgba(0, 0, 0, 0.75);
- backdrop-filter: blur(5px);
- z-index: 100;
- pointer-events: none;
-}
-
-.sig-overlay {
- position: fixed;
- inset: 0;
- display: flex;
- align-items: stretch;
- justify-content: center;
- z-index: 120;
- pointer-events: none;
-}
-
-.sig-modal {
- pointer-events: auto;
- display: flex;
- flex-direction: column;
- width: 100%; // respects overlay padding-right set by JS
- max-width: 420px;
- max-height: 100%; // respects overlay padding-bottom set by JS
-}
-
-// ─── Stage ────────────────────────────────────────────────────────────────────
-// flex: 1 — fills all space above the card grid; no background (backdrop blur).
-// Row layout: preview card bottom-left, stat block fills the right.
-// Card width is set by sizeSigCard() in room.js (smaller of 40% stage width or
-// 80% stage height × 5/8) via --sig-card-w CSS variable — libsass can't handle
-// container query units inside min().
-
-.sig-stage {
- flex: 1;
- min-height: 0;
- position: relative;
- display: flex;
- flex-direction: row;
- align-items: flex-end;
- padding-left: 1.5rem;
- gap: 0.75rem;
-
- // Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height.
- .sig-stage-card {
- flex-shrink: 0;
- width: var(--sig-card-w, 120px);
- height: auto;
- aspect-ratio: 5 / 8;
- border-radius: 0.5rem;
- background: rgba(var(--priUser), 1);
- border: 0.15rem solid rgba(var(--secUser), 0.6);
- display: flex;
- flex-direction: column;
- position: relative;
- padding: 0.25rem;
- overflow: hidden;
-
- // game-kit sets .fan-card-corner { position: absolute; top/left offsets }
- // so these just need display/font overrides; the corners land at the card edges.
- // All font-sizes scale with --sig-card-w (ratio = original-rem × 16 / 120).
- .fan-card-corner--tl {
- display: flex;
- flex-direction: column;
- align-items: center;
- line-height: 1.1;
- gap: 0.1rem;
-
- .fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.133); font-weight: 700; }
- i { font-size: calc(var(--sig-card-w, 120px) * 0.1); }
- }
-
- .fan-card-corner--br {
- display: flex;
- flex-direction: column;
- align-items: center;
- line-height: 1.1;
- gap: 0.1rem;
-
- .fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.12); font-weight: 700; }
- i { font-size: calc(var(--sig-card-w, 120px) * 0.1); }
- }
-
- .fan-card-face {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- text-align: center;
- padding: 0.25rem 0.15rem;
- gap: 0.2rem;
-
- .fan-card-name-group { font-size: calc(var(--sig-card-w, 120px) * 0.073); opacity: 0.6; }
- .fan-card-name { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; }
- .fan-card-arcana { font-size: calc(var(--sig-card-w, 120px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
- .fan-card-correspondence{ font-size: calc(var(--sig-card-w, 120px) * 0.067); opacity: 0.5; }
- }
- }
-
- // Stat block — same dimensions as the preview card (width × 5:8 aspect).
- // flex: 0 0 auto so it doesn't stretch to fill the stage; the rest of the
- // stage row is simply empty, giving the card room to breathe.
- .sig-stat-block {
- flex: 0 0 auto;
- width: var(--sig-card-w, 120px);
- height: calc(var(--sig-card-w, 120px) * 8 / 5);
- align-self: flex-end;
- background: rgba(var(--priUser), 0.5);
- border-radius: 0.4rem;
- border: 0.1rem solid rgba(var(--terUser), 0.15);
- display: none;
- position: relative;
-
-
- .sig-flip-btn {
- position: absolute;
- top: -1rem;
- right: -1rem;
- margin: 0;
- z-index: 50;
- }
-
- .sig-caution-btn {
- position: absolute;
- top: 1.25rem;
- right: -1rem;
- margin: 0;
- z-index: 50;
- }
-
- // Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons.
- .sig-caution-tooltip {
- display: none;
- position: absolute;
- inset: 0;
- z-index: 60;
- background-color: rgba(var(--tooltip-bg), 0.6);
- backdrop-filter: blur(6px);
- border-radius: 0.4rem;
- border: 0.1rem solid rgba(var(--priYl), 0.35);
- padding: 0.75rem;
- flex-direction: column;
- gap: 0.4rem;
- overflow-y: auto;
- }
-
- .sig-caution-header {
- display: flex;
- flex-direction: column;
- gap: 0.1rem;
- }
-
- .sig-caution-title {
- font-size: calc(var(--sig-card-w, 120px) * 0.093);
- font-weight: 700;
- margin: 0;
- color: rgba(var(--priYl), 1);
- }
-
- .sig-caution-type {
- font-size: calc(var(--sig-card-w, 120px) * 0.058);
- opacity: 0.7;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- flex-shrink: 0;
- }
-
- .sig-caution-shoptalk {
- font-size: calc(var(--sig-card-w, 120px) * 0.063);
- opacity: 0.55;
- margin: 0;
- font-style: italic;
- }
-
- .sig-caution-effect {
- flex: 1;
- font-size: calc(var(--sig-card-w, 120px) * 0.075);
- margin: 0;
- line-height: 1.55;
-
- .card-ref {
- color: rgba(var(--terUser), 1);
- font-weight: 600;
- }
- }
-
- .sig-caution-index {
- font-size: calc(var(--sig-card-w, 120px) * 0.063);
- opacity: 0.55;
- }
-
- // Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70)
- .sig-caution-prev,
- .sig-caution-next {
- display: none;
- position: absolute;
- bottom: -1rem;
- margin: 0;
- z-index: 70;
- }
- .sig-caution-prev { left: -1rem; }
- .sig-caution-next { right: -1rem; }
-
- .stat-face {
- display: none;
- padding: calc(var(--sig-card-w, 120px) * 0.37) calc(var(--sig-card-w, 120px) * 0.1) calc(var(--sig-card-w, 120px) * 0.08);
-
- &--upright { display: block; }
- }
-
- &.is-reversed {
- .stat-face--upright { display: none; }
- .stat-face--reversed { display: block; }
- }
-
- .stat-face-label {
- font-size: calc(var(--sig-card-w, 120px) * 0.063);
- text-transform: uppercase;
- letter-spacing: 0.09em;
- opacity: 0.4;
- margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07);
- }
-
- .stat-keywords {
- list-style: none;
- padding: 0;
- margin: 0;
-
- li {
- font-size: calc(var(--sig-card-w, 120px) * 0.083);
- padding: calc(var(--sig-card-w, 120px) * 0.042) 0;
- opacity: 0.85;
- border-bottom: 0.05rem solid rgba(var(--terUser), 0.12);
-
- &:last-child { border-bottom: none; }
- }
- }
- }
-
- &.sig-stage--frozen .sig-stat-block { display: block; }
- &.sig-caution-open .sig-stat-block {
- .sig-caution-tooltip { display: flex; }
- .sig-caution-prev, .sig-caution-next { display: inline-flex; }
- }
-}
-
-// ─── Mini card grid ───────────────────────────────────────────────────────────
-// flex: 0 0 auto — shrinks to card content; no background (backdrop blur).
-// align-content: start prevents CSS grid from distributing extra height between rows.
-
-.sig-deck-grid {
- flex: 0 0 auto;
- display: grid;
- grid-template-columns: repeat(6, 1fr);
- align-content: start;
- gap: 2px;
- padding: 4px;
- overflow: hidden;
- margin: 0 1rem 5rem 4rem;
-}
-
-.sig-card {
- aspect-ratio: 5 / 8;
- border-radius: 0.4rem;
- background: rgba(var(--priUser), 0.97);
- border: 1px solid rgba(var(--secUser), 0.3);
- position: relative;
- cursor: grab;
- transition: border-color 0.15s, box-shadow 0.15s;
- overflow: hidden;
-
- // game-kit sets .fan-card-corner { position:absolute; top:0.4rem; left:0.4rem }
- // Override: center the element within the card instead.
- .fan-card-corner--tl {
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- gap: 0; // game-kit has gap:0.15rem — too large at 0.5rem font-size
-
- .fan-corner-rank { font-size: 1rem; font-weight: 700; }
- i { font-size: 0.75rem; }
- }
-
- // OK / NVM overlay — appears on click (focused) or own reservation
- .sig-card-actions {
- position: absolute;
- inset: 0;
- display: none;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- gap: 3px;
- background: rgba(var(--priUser), 0.92);
- border-radius: inherit;
-
- .sig-nvm-btn { display: none; }
- }
-
- &.sig-focused .sig-card-actions { display: flex; }
- &.sig-reserved--own .sig-card-actions {
- display: flex;
- .sig-ok-btn { display: none; }
- .sig-nvm-btn { display: flex; }
- }
-
- // Cursor anchors strip — bottom of card
- .sig-card-cursors {
- position: absolute;
- bottom: 2px;
- left: 2px;
- right: 2px;
- display: flex;
- justify-content: space-between;
- }
-
- &:hover:not([data-reserved-by]) {
- border-color: rgba(var(--secUser), 0.8);
- box-shadow: 0 0 4px rgba(var(--secUser), 0.25);
- }
-
- &.sig-reserved {
- border-color: rgba(var(--terUser), 1);
- box-shadow:
- 0 0 0.4rem rgba(var(--terUser), 0.7),
- 0 0 1rem rgba(var(--ninUser), 0.4);
- cursor: not-allowed;
- }
-
- &.sig-reserved--own {
- border-color: rgba(var(--secUser), 1);
- box-shadow:
- 0 0 0.4rem rgba(var(--secUser), 0.7),
- 0 0 1rem rgba(var(--ninUser), 0.5);
- cursor: grabbing;
- }
-}
-
-// ─── Cursor anchors ───────────────────────────────────────────────────────────
-//
-// Three tiny dots along the bottom of each mini card, one per role in the group.
-// Inactive: invisible. Active (another gamer is hovering): coloured dot.
-
-.sig-cursor {
- display: block;
- width: 5px;
- height: 5px;
- border-radius: 50%;
- background: transparent;
- transition: background 0.1s;
-
- &.active {
- background: rgba(var(--terUser), 1);
- box-shadow: 0 0 3px rgba(var(--ninUser), 0.8);
- }
-}
-
-// ─── Sig select: landscape overrides ─────────────────────────────────────────
-// Landscape base: 9×2 grid of 3rem cards. At ≥992px (wide enough for 18 cards
-// at 3rem + 4rem left + ~4rem right): collapse to a single 18×1 row so the
-// stage preview gets maximum vertical real-estate.
-// padding-left clears the fixed left navbar (JS sets right/bottom but not left).
-// Grid margins reset to 0 — overlay padding handles all edge clearance.
-
-@media (orientation: landscape) {
- .sig-modal {
- max-width: none;
- flex-direction: row; // grid to the right, stage + card preview to the left
- margin-left: 4rem;
- margin-right: 3rem;
- }
- .sig-stage {
- min-width: 0; // allow shrinking in row layout; align-items:flex-end already set
- }
- .sig-deck-grid {
- grid-template-columns: repeat(6, 2.5rem);
- margin: 0;
- align-self: flex-end; // sit at the bottom of the modal row
- }
-}
-
-@media (orientation: landscape) and (min-width: 900px) {
- // Wide landscape: revert to stacked layout (stage top, 18-card row grid bottom).
- .sig-modal {
- flex-direction: column;
- align-items: stretch;
- }
- .sig-stage {
- min-width: auto;
- align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth
- margin-left: 3rem;
- }
- .sig-deck-grid {
- grid-template-columns: repeat(18, 3rem);
- align-self: center;
- }
-}
-
-@media (orientation: landscape) and (min-width: 1800px) {
- // Sig overlay: clear doubled sidebars (8rem each instead of 4rem/6rem)
- .sig-overlay { padding-left: 8rem; padding-right: 8rem; }
- .sig-stage {
- align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth
- margin-left: 3rem;
- }
- .sig-deck-grid {
- grid-template-columns: repeat(18, 5rem);
- align-self: center;
- }
-
- // Room menu: base right: 0.5rem (same-specificity ID rule) overrides _applets.scss
- // XL block because _room.scss is imported later. Re-declare here to win the cascade.
- #id_room_menu { right: 2.5rem; }
-}
-
// ─── Seat tray — see _tray.scss ─────────────────────────────────────────────
diff --git a/src/static_src/scss/core.scss b/src/static_src/scss/core.scss
index d4cc18f..6215a88 100644
--- a/src/static_src/scss/core.scss
+++ b/src/static_src/scss/core.scss
@@ -6,6 +6,7 @@
@import 'gameboard';
@import 'palette-picker';
@import 'room';
+@import 'card-deck';
@import 'tray';
@import 'billboard';
@import 'game-kit';
diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js
index 2f5d658..df5e215 100644
--- a/src/static_src/tests/SigSelectSpec.js
+++ b/src/static_src/tests/SigSelectSpec.js
@@ -1,12 +1,12 @@
describe("SigSelect", () => {
let testDiv, stageCard, card, statBlock;
- function makeFixture({ reservations = '{}', cardCautions = '[]' } = {}) {
+ function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) {
testDiv = document.createElement("div");
testDiv.innerHTML = `
@@ -15,7 +15,9 @@ describe("SigSelect", () => {
+
+
@@ -409,4 +411,198 @@ describe("SigSelect", () => {
expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0);
});
});
+
+ // ── WS cursor hover (applyHover) ──────────────────────────────────────── //
+ //
+ // Fixture polarity = levity, userRole = PC.
+ // POLARITY_ROLES: levity → [PC, NC, SC] = [left, mid, right]
+ //
+ // Only tests the JS position mapping — colour is CSS-only.
+
+ describe("WS cursor hover", () => {
+ beforeEach(() => makeFixture());
+
+ it("NC hover activates the --mid cursor", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "NC", active: true },
+ }));
+ expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(true);
+ });
+
+ it("SC hover activates the --right cursor", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "SC", active: true },
+ }));
+ expect(card.querySelector(".sig-cursor--right").classList.contains("active")).toBe(true);
+ });
+
+ it("own role (PC) hover event is ignored — no cursor activates", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "PC", active: true },
+ }));
+ expect(card.querySelectorAll(".sig-cursor.active").length).toBe(0);
+ });
+
+ it("hover-off removes .active from the cursor", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "NC", active: true },
+ }));
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "NC", active: false },
+ }));
+ expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(false);
+ });
+
+ it("hover on unknown card_id is a no-op", () => {
+ expect(() => {
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 9999, role: "NC", active: true },
+ }));
+ }).not.toThrow();
+ });
+ });
+
+ // ── WS reservation — data-reserved-by attribute ───────────────────────── //
+ //
+ // applyReservation() sets data-reserved-by so the CSS can glow the card in
+ // the reserving gamer's role colour. These tests assert the attribute, not
+ // the colour (CSS variables aren't resolvable in the SpecRunner context).
+
+ describe("WS reservation sets data-reserved-by", () => {
+ beforeEach(() => makeFixture());
+
+ it("peer reservation sets data-reserved-by to the reserving role", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+ expect(card.dataset.reservedBy).toBe("NC");
+ });
+
+ it("peer reservation also adds .sig-reserved class", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+ expect(card.classList.contains("sig-reserved")).toBe(true);
+ });
+
+ it("release removes data-reserved-by", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: false },
+ }));
+ expect(card.dataset.reservedBy).toBeUndefined();
+ });
+
+ it("own reservation (PC) sets data-reserved-by AND .sig-reserved--own", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "PC", reserved: true },
+ }));
+ expect(card.dataset.reservedBy).toBe("PC");
+ expect(card.classList.contains("sig-reserved--own")).toBe(true);
+ });
+
+ it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => {
+ // First, a hover float exists for NC (mid cursor)
+ window.dispatchEvent(new CustomEvent("room:sig_hover", {
+ detail: { card_id: 42, role: "NC", active: true },
+ }));
+ expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull();
+ expect(document.querySelector('.fa-hand-pointer[data-role="NC"]')).not.toBeNull();
+
+ // NC then clicks OK — reservation arrives
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+
+ // Thumbs-up replaces hand-pointer
+ const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]');
+ expect(floatEl).not.toBeNull();
+ expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true);
+ expect(floatEl.classList.contains("fa-hand-pointer")).toBe(false);
+ });
+
+ it("peer release removes the thumbs-up float", () => {
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: true },
+ }));
+ expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).not.toBeNull();
+
+ window.dispatchEvent(new CustomEvent("room:sig_reserved", {
+ detail: { card_id: 42, role: "NC", reserved: false },
+ }));
+ expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).toBeNull();
+ });
+ });
+
+ // ── Polarity theming — stage qualifier text ────────────────────────────── //
+ //
+ // On mouseenter, updateStage() injects "Leavened" 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", () => {
+ 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-below").textContent).toBe("");
+ });
+
+ it("levity major arcana card puts 'Leavened' 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");
+ });
+
+ it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
+ makeFixture({ polarity: 'levity', userRole: 'PC' });
+ card.dataset.arcana = "Major Arcana";
+ card.dataset.nameTitle = "The Schizo";
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".fan-card-name").textContent).toBe("The Schizo,");
+ });
+
+ it("non-major arcana title has no trailing comma", () => {
+ makeFixture({ polarity: 'levity', userRole: 'PC' });
+ // fixture default: Minor Arcana, "King of Pentacles"
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles");
+ });
+
+ it("gravity non-major card puts 'Graven' in qualifier-above", () => {
+ makeFixture({ polarity: 'gravity', userRole: 'BC' });
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Graven");
+ });
+
+ it("gravity major arcana card puts 'Graven' in qualifier-below", () => {
+ makeFixture({ polarity: 'gravity', userRole: 'BC' });
+ card.dataset.arcana = "Major Arcana";
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Graven");
+ });
+
+ it("hovering clears qualifier slots from the previous card", () => {
+ makeFixture({ polarity: 'levity', userRole: 'PC' });
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ card.dataset.arcana = "Major Arcana";
+ card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
+ 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");
+ });
+
+ it("correspondence field is never populated", () => {
+ makeFixture({ polarity: 'levity', userRole: 'PC' });
+ card.dataset.correspondence = "Il Bagatto (Minchiate)";
+ card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
+ expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
+ });
+ });
});
diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
index d76613c..38462ce 100644
--- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
+++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html
@@ -21,9 +21,11 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
+
+
-
+
{# not shown in sig-select — game-kit only #}
@@ -77,9 +79,9 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
-
-
-
+
+
+
{% endfor %}