Game Kit fan stage + FLIP/SPIN; sig/sea/fan refactor — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

- fan modal: stage block w. idle-reveal/careen-out; carousel shifts left so focused card sits left-of-center; SPIN rotates whole card via Element.animate(); FLIP toggles polarity (Levity ↔ Gravity) via perspective rotateY w. mid-flip repaint; SPIN state retained across FLIP; FLIP btn hover-revealed only when focused card or btn is hovered (:has)
- mobile breakpoints: --fan-card-w / --fan-card-h / --fan-stage-shift / --fan-carousel-step lifted to CSS vars on .tarot-fan-wrap; portrait ≤ 480px @ 150×230, landscape ≤ 500h @ 150×235; corners + face text/padding scale w. card width
- shared StageCard JS module (apps/epic/stage-card.js): fromDataset, populateCard, populateKeywords, buildInfoData, renderFyi — sig/sea/fan all delegate; ~150 lines de-duplicated
- shared @mixin stat-block-shared (SCSS) lifts duplicated stat-face / stat-keywords / sig-info rules; @mixin stage-card-polarity unifies sea-stage--levity/--gravity + fan[data-polarity] coloring
- model rename: TarotCard.reversal → reversal_qualifier (migration 0014); render-time fallback to current polarity's qualifier when blank
- class unification: .sig-info-open / .sea-info-open / .fyi-open → .fyi-open (on stat block); .sig-flip-btn / .sea-spin-btn / .fan-spin-btn → .spin-btn; same for .fyi-btn / .fyi-prev / .fyi-next
- custom combobox (apps/epic/combobox.js) replaces native <select> for PICK SEA spread picker — keyboard nav, click-outside-close, aria roles; Firefox/Chrome OS-rendered <option> ignored CSS
- Jasmine: FanStageSpec.js w. idle-reveal / population / SPIN / FYI / FLIP specs; sig + sea fixtures + IT view assertions updated for renamed classes
- 748 ITs + Jasmine green

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:
Disco DeDisco
2026-04-30 21:01:52 -04:00
parent 61162e36da
commit 2f039559e6
23 changed files with 1916 additions and 571 deletions

View File

@@ -30,8 +30,8 @@ describe("SigSelect", () => {
</div>
</div>
<div class="sig-stat-block">
<button class="btn btn-reverse sig-flip-btn" type="button">SPIN</button>
<button class="btn btn-info sig-info-btn" type="button">FYI</button>
<button class="btn btn-reverse spin-btn" type="button">SPIN</button>
<button class="btn btn-info fyi-btn" type="button">FYI</button>
<div class="stat-face stat-face--upright">
<p class="stat-face-label">Emanation</p>
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
@@ -40,8 +40,8 @@ describe("SigSelect", () => {
<p class="stat-face-label">Reversal</p>
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
</div>
<button class="btn btn-nav-left sig-info-prev" type="button">&#9664;</button>
<button class="btn btn-nav-right sig-info-next" type="button">&#9654;</button>
<button class="btn btn-nav-left fyi-prev" type="button">&#9664;</button>
<button class="btn btn-nav-right fyi-next" type="button">&#9654;</button>
<div class="sig-info" id="id_sig_info">
<div class="sig-info-header">
<h4 class="sig-info-title"></h4>
@@ -67,7 +67,7 @@ describe("SigSelect", () => {
data-operations="[]"
data-levity-qualifier="Elevated"
data-gravity-qualifier="Graven"
data-reversal="">
data-reversal-qualifier="">
<div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">K</span>
</div>
@@ -211,24 +211,24 @@ describe("SigSelect", () => {
infoTitle = testDiv.querySelector(".sig-info-title");
infoType = testDiv.querySelector(".sig-info-type");
infoIndex = testDiv.querySelector(".sig-info-index");
infoPrev = testDiv.querySelector(".sig-info-prev");
infoNext = testDiv.querySelector(".sig-info-next");
infoBtn = testDiv.querySelector(".sig-info-btn");
infoPrev = testDiv.querySelector(".fyi-prev");
infoNext = testDiv.querySelector(".fyi-next");
infoBtn = testDiv.querySelector(".fyi-btn");
});
function hover() { card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); }
function openFYI() { hover(); infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); }
it("FYI click adds .sig-info-open to the stage", () => {
it("FYI click adds .fyi-open to the stat block", () => {
openFYI();
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
});
it("FYI click when btn-disabled does not toggle", () => {
openFYI();
expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
infoBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
});
it("shows placeholder when both energies and operations are empty", () => {
@@ -370,9 +370,9 @@ describe("SigSelect", () => {
it("card mouseleave closes the info panel", () => {
openFYI();
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(false);
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(false);
});
it("opening again resets to first entry", () => {
@@ -389,7 +389,7 @@ describe("SigSelect", () => {
it("opening info panel adds .btn-disabled and swaps SPIN/FYI labels to ×", () => {
openFYI();
var flipBtn = testDiv.querySelector(".sig-flip-btn");
var flipBtn = testDiv.querySelector(".spin-btn");
expect(flipBtn.classList.contains("btn-disabled")).toBe(true);
expect(infoBtn.classList.contains("btn-disabled")).toBe(true);
expect(flipBtn.textContent).toBe("×");
@@ -397,7 +397,7 @@ describe("SigSelect", () => {
});
it("closing info panel removes .btn-disabled and restores SPIN/FYI labels", () => {
var flipBtn = testDiv.querySelector(".sig-flip-btn");
var flipBtn = testDiv.querySelector(".spin-btn");
var origFlip = flipBtn.textContent;
var origInfo = infoBtn.textContent;
openFYI();
@@ -411,14 +411,14 @@ describe("SigSelect", () => {
it("clicking the info panel closes it", () => {
openFYI();
infoEffect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(false);
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(false);
});
it("SPIN click when info open (btn-disabled) does nothing", () => {
openFYI();
var flipBtn = testDiv.querySelector(".sig-flip-btn");
var flipBtn = testDiv.querySelector(".spin-btn");
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-info-open")).toBe(true);
expect(testDiv.querySelector(".sig-stat-block").classList.contains("fyi-open")).toBe(true);
expect(statBlock.classList.contains("is-reversed")).toBe(false);
});
});
@@ -447,14 +447,14 @@ describe("SigSelect", () => {
it("SPIN click adds .is-reversed to the stat block", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
var flipBtn = statBlock.querySelector(".sig-flip-btn");
var flipBtn = statBlock.querySelector(".spin-btn");
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(statBlock.classList.contains("is-reversed")).toBe(true);
});
it("second SPIN click removes .is-reversed", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
var flipBtn = statBlock.querySelector(".sig-flip-btn");
var flipBtn = statBlock.querySelector(".spin-btn");
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(statBlock.classList.contains("is-reversed")).toBe(false);
@@ -462,7 +462,7 @@ describe("SigSelect", () => {
it("hovering a new card resets .is-reversed", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
statBlock.querySelector(".sig-flip-btn").dispatchEvent(
statBlock.querySelector(".spin-btn").dispatchEvent(
new MouseEvent("click", { bubbles: true })
);
expect(statBlock.classList.contains("is-reversed")).toBe(true);
@@ -491,7 +491,7 @@ describe("SigSelect", () => {
it("SPIN click adds .stage-card--reversed to the stage card", () => {
makeFixture();
hover();
statBlock.querySelector(".sig-flip-btn")
statBlock.querySelector(".spin-btn")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(stageCard.classList.contains("stage-card--reversed")).toBe(true);
});
@@ -499,7 +499,7 @@ describe("SigSelect", () => {
it("second SPIN click removes .stage-card--reversed", () => {
makeFixture();
hover();
var flipBtn = statBlock.querySelector(".sig-flip-btn");
var flipBtn = statBlock.querySelector(".spin-btn");
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(stageCard.classList.contains("stage-card--reversed")).toBe(false);
@@ -508,7 +508,7 @@ describe("SigSelect", () => {
it("hovering a new card resets .stage-card--reversed", () => {
makeFixture();
hover();
statBlock.querySelector(".sig-flip-btn")
statBlock.querySelector(".spin-btn")
.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(stageCard.classList.contains("stage-card--reversed")).toBe(true);
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
@@ -516,9 +516,9 @@ describe("SigSelect", () => {
expect(stageCard.classList.contains("stage-card--reversed")).toBe(false);
});
it("non-major with data-reversal: reversal-qualifier = suit word, reversal-name = card name", () => {
it("non-major with data-reversal-qualifier: reversal-qualifier = suit word, reversal-name = card name", () => {
makeFixture();
card.dataset.reversal = "Nervous";
card.dataset.reversalQualifier = "Nervous";
hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Nervous");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent)
@@ -539,9 +539,9 @@ describe("SigSelect", () => {
.toBe("Graven");
});
it("non-major with data-reversal: suit qualifier on own line, upright name repeated below", () => {
it("non-major with data-reversal-qualifier: suit qualifier on own line, upright name repeated below", () => {
makeFixture({ polarity: "levity", userRole: "PC" });
card.dataset.reversal = "Vacant";
card.dataset.reversalQualifier = "Vacant";
hover();
expect(stageCard.querySelector(".fan-card-reversal-qualifier").textContent).toBe("Vacant");
expect(stageCard.querySelector(".fan-card-reversal-name").textContent)
@@ -558,7 +558,7 @@ describe("SigSelect", () => {
expect(stageCard.querySelector(".fan-card-reversal-name").textContent).toBe("Elevated");
});
it("non-major without data-reversal: qualifier mirrors polarity, name repeats card title", () => {
it("non-major without data-reversal-qualifier: qualifier mirrors polarity, name repeats card title", () => {
makeFixture({ polarity: "levity", userRole: "PC" });
// fixture default: Minor Arcana, no reversal word
hover();