diff --git a/src/apps/epic/static/apps/epic/sea.js b/src/apps/epic/static/apps/epic/sea.js index c38b72c..7e299dc 100644 --- a/src/apps/epic/static/apps/epic/sea.js +++ b/src/apps/epic/static/apps/epic/sea.js @@ -28,6 +28,18 @@ var SeaDeal = (function () { var _infoIdx = 0; var _infoOpen = false; var _spinOrigLabel, _fyiOrigLabel; + // Timer handles for the reversed-card auto-rotate sequence. Tracked + // so a re-open mid-rotate (or a stage dismiss + reopen) cancels the + // pending kickoff / cleanup instead of stacking handlers. + var _rotateTimer = null, _rotateEndTimer = null; + // Reversed-card auto-rotate timings. SPIN's CSS transition is 0.4s + // (_card-deck.scss:2116); the open-rotate is 2× that per user-spec + // 2026-05-26. The kickoff delay waits for the sea-flip-in keyframe + // (0.35s) plus a small buffer so the rotate doesn't visually fight + // the modal's flip-in. + var SPIN_MS = 400; + var AUTO_ROTATE_MS = 800; + var FLIP_IN_WAIT_MS = 400; // ── Stage card population (delegates to shared StageCard module) ────────── @@ -42,12 +54,17 @@ var SeaDeal = (function () { _infoData = StageCard.buildInfoData(card); _infoIdx = 0; - // Sync SPIN state to the card's reversal axis — `card.reversed` is set - // server-side at deck-fetch time (apps.epic.utils.stack_reversal_probability) - // and persisted in `_seaHand`, so re-clicking a deposited slot must - // restore that state, not reset to upright. - stageCard.classList.toggle('stage-card--reversed', !!card.reversed); - statBlock.classList.toggle('is-reversed', !!card.reversed); + // Stat-block always lands in the card's true reversal state immediately + // (REVERSAL label highlights as the modal opens). The card itself starts + // rightside-up regardless of reversal — `_autoRotateToReversed` (kicked + // off from `_showStage`) handles the slow 180° rotate-in afterwards. + // Cancel any in-flight prior rotate (re-open during animation). + stageCard.classList.remove('stage-card--reversed'); + if (_rotateTimer) { clearTimeout(_rotateTimer); _rotateTimer = null; } + if (_rotateEndTimer) { clearTimeout(_rotateEndTimer); _rotateEndTimer = null; } + delete stageCard.dataset.spinning; + stageCard.style.transitionDuration = ''; + statBlock.classList.toggle('is-reversed', !!card.reversed); _closeInfo(); } @@ -124,6 +141,41 @@ var SeaDeal = (function () { stage.classList.toggle('sea-stage--levity', !!isLevity); stage.classList.toggle('sea-stage--gravity', !isLevity); stageCard.classList.add('sea-stage-card--shown'); + // Reversed cards land rightside-up first (matches the original text- + // card legibility convention — dimmed upright title + highlighted + // upside-down reversal title), then slowly rotate 180° to upside-down + // after the flip-in finishes. Spec 2026-05-26. + if (statBlock.classList.contains('is-reversed')) { + _rotateTimer = setTimeout(_autoRotateToReversed, FLIP_IN_WAIT_MS); + } + } + + // Drive the reversed-card open's 180° auto-rotate. Mirrors the SPIN- + // click pattern (kill the keyframe-forwards fill from sea-flip-in, + // force reflow, then toggle .stage-card--reversed so the static + // `transition: transform` lerps the rotation) but doubles the + // duration via an inline transition-duration override. The + // `data-spinning` marker companions the SCSS rule that hides the + // FLIP btn during the rotation window — the btn reappears at the + // visual bottom-left of the rotated card (top-right in the card's + // local coords, counter-rotated 180°) once cleared, matching the + // game-kit fan's "FLIP only at bottom-left" precedent. + function _autoRotateToReversed() { + _rotateTimer = null; + if (!stageCard || !statBlock) return; + if (stageCard.classList.contains('stage-card--reversed')) return; + if (!statBlock.classList.contains('is-reversed')) return; + stageCard.classList.remove('sea-stage-card--shown'); + stageCard.getBoundingClientRect(); // flush layout — clean transition start + stageCard.dataset.spinning = '1'; + stageCard.style.transitionDuration = (AUTO_ROTATE_MS / 1000) + 's'; + stageCard.classList.add('stage-card--reversed'); + _rotateEndTimer = setTimeout(function () { + _rotateEndTimer = null; + if (!stageCard) return; + stageCard.style.transitionDuration = ''; + delete stageCard.dataset.spinning; + }, AUTO_ROTATE_MS); } function _hideStage() { @@ -137,6 +189,10 @@ var SeaDeal = (function () { } stage.style.display = 'none'; stageCard.classList.remove('sea-stage-card--shown'); + if (_rotateTimer) { clearTimeout(_rotateTimer); _rotateTimer = null; } + if (_rotateEndTimer) { clearTimeout(_rotateEndTimer); _rotateEndTimer = null; } + delete stageCard.dataset.spinning; + stageCard.style.transitionDuration = ''; _viewingPos = null; _closeInfo(); } @@ -215,7 +271,14 @@ var SeaDeal = (function () { // Remove animation fill, force reflow so the transition has a start state stageCard.classList.remove('sea-stage-card--shown'); stageCard.getBoundingClientRect(); // flush layout + // `data-spinning` companions the SCSS hide-flip-btn rule for + // the 0.4s SPIN window so the FLIP btn doesn't ride along to + // top-right mid-rotate — it reappears at the visual bottom- + // left of the rotated card after the spin lands. Matches the + // game-kit fan's "FLIP only at bottom-left" precedent. + stageCard.dataset.spinning = '1'; stageCard.classList.toggle('stage-card--reversed'); + setTimeout(function () { delete stageCard.dataset.spinning; }, SPIN_MS); }); } @@ -343,6 +406,8 @@ var SeaDeal = (function () { _userPolarity = 'levity'; _seaHand = {}; _viewingPos = null; _infoData = []; _infoIdx = 0; _infoOpen = false; + if (_rotateTimer) { clearTimeout(_rotateTimer); _rotateTimer = null; } + if (_rotateEndTimer) { clearTimeout(_rotateEndTimer); _rotateEndTimer = null; } init(); }, }; diff --git a/src/static/tests/SeaDealSpec.js b/src/static/tests/SeaDealSpec.js index 6ad33ac..b81ebeb 100644 --- a/src/static/tests/SeaDealSpec.js +++ b/src/static/tests/SeaDealSpec.js @@ -296,4 +296,85 @@ describe("SeaDeal", () => { expect(rank.textContent).toBe("Q"); }); }); + + // ── Reversed-card auto-rotate on modal open ─────────────────────────────── // + // + // Spec 2026-05-26: a reversed card opens rightside-up (so the user sees + // the upright card frame + a dimmed upright title + a highlighted + // upside-down reversal title) and then SLOWLY auto-rotates 180° to land + // upside-down. Duration is 2× the normal SPIN transition (0.4s → 0.8s). + // The stat-block gets `.is-reversed` immediately (REVERSAL label + // highlights as the modal opens); the card itself gets `.stage-card-- + // reversed` only after the auto-rotate lands. `[data-spinning]` is set + // for the rotation window so the FLIP btn hides during the spin (the + // SCSS-side companion rule also repositions FLIP to visual bottom-left + // post-rotation, matching the game-kit fan precedent). + + describe("reversed-card open", () => { + const REVERSED_CARD = Object.assign({}, CARD, { reversed: true }); + + beforeEach(() => { + jasmine.clock().install(); + makeFixture(); + }); + afterEach(() => jasmine.clock().uninstall()); + + it("sets is-reversed on stat-block immediately", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + expect(statBlock.classList.contains("is-reversed")).toBe(true); + }); + + it("does NOT set stage-card--reversed immediately (card starts rightside-up)", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(false); + }); + + it("does NOT set data-spinning before the flip-in completes", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + expect(stageCard.dataset.spinning).toBeUndefined(); + }); + + it("kicks off the auto-rotate after the flip-in window — data-spinning set + stage-card--reversed added", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + jasmine.clock().tick(500); // past sea-flip-in (0.35s) + buffer + expect(stageCard.dataset.spinning).toBe("1"); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(true); + }); + + it("clears data-spinning once the auto-rotate window has elapsed", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + jasmine.clock().tick(500 + 900); // past flip-in + 0.8s rotate + expect(stageCard.dataset.spinning).toBeUndefined(); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(true); + }); + + it("does NOT auto-rotate an upright card", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + jasmine.clock().tick(2000); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(false); + expect(stageCard.dataset.spinning).toBeUndefined(); + }); + }); + + // ── SPIN click hides FLIP during rotation ───────────────────────────────── // + + describe("SPIN click hides FLIP via [data-spinning]", () => { + beforeEach(() => { + jasmine.clock().install(); + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + }); + afterEach(() => jasmine.clock().uninstall()); + + it("sets data-spinning on SPIN click so the FLIP btn hides during the rotation", () => { + testDiv.querySelector(".spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stageCard.dataset.spinning).toBe("1"); + }); + + it("clears data-spinning after the SPIN transition window elapses", () => { + testDiv.querySelector(".spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + jasmine.clock().tick(500); // past 0.4s SPIN transition + expect(stageCard.dataset.spinning).toBeUndefined(); + }); + }); }); diff --git a/src/static_src/scss/_card-deck.scss b/src/static_src/scss/_card-deck.scss index 2df11f9..82e7a6b 100644 --- a/src/static_src/scss/_card-deck.scss +++ b/src/static_src/scss/_card-deck.scss @@ -1004,6 +1004,27 @@ html:has(.sig-backdrop) { left: 0.6rem; } +// Reversed-card FLIP counter-positioning. The sea_stage card rotates 180° +// via `.stage-card--reversed`; without this override the in-card FLIP btn +// (anchored to card-local bottom-left) visually rides along to top-right +// post-rotation. Re-anchor to card-local top-right + counter-rotate the +// btn's own transform so it visually lands at the same bottom-left it +// would have if the card hadn't rotated — and the label still reads +// upright. Matches the game-kit fan precedent (`.fan-flip-btn` lives +// OUTSIDE the rotating card on `.tarot-fan-wrap`, so it never moves) +// without restructuring the in-card DOM that my_sign + my_sign-applet +// also share. The `[data-spinning]` hide-during-rotate rule below means +// the user never sees the btn jump between the two corners; it +// disappears mid-spin + reappears at bottom-left after the rotation +// lands. Spec 2026-05-26. +.sea-stage-card.stage-card--reversed .sea-stage-flip-btn { + bottom: auto; + left: auto; + top: 0.6rem; + right: 0.6rem; + transform: rotate(180deg); +} + // Hover-reveal on the parent card. `:has(.flip-btn:hover)` pins the btn // visible while the cursor is on it — without this clause, the btn (z-index // 25, on top of the card) steals :hover from the card the moment the cursor @@ -1028,9 +1049,16 @@ html:has(.sig-backdrop) { // differ per surface because the btn-to-card DOM relationship varies (btn is // INSIDE the card on my_sign + applet + sea_stage; sibling under .tarot-fan- // wrap for the fan carousel). +// +// `[data-spinning]` on `.sea-stage-card` companions the SPIN-click and +// reversed-card open auto-rotate windows in sea.js — same hide treatment as +// FLIP-flipping, so the user never sees the btn jump between bottom-left and +// (rotated) top-right mid-rotate. Btn reappears at the post-rotation visual +// bottom-left via the counter-positioning rule above. .sig-stage-card[data-flipping] .my-sign-flip-btn, .my-sign-applet-card[data-flipping] .my-sign-applet-flip-btn, .sea-stage-card[data-flipping] .sea-stage-flip-btn, +.sea-stage-card[data-spinning] .sea-stage-flip-btn, .tarot-fan-wrap:has(.fan-card[data-flipping]) .fan-flip-btn { @extend %flip-btn-mid-flip; } diff --git a/src/static_src/tests/SeaDealSpec.js b/src/static_src/tests/SeaDealSpec.js index 6ad33ac..b81ebeb 100644 --- a/src/static_src/tests/SeaDealSpec.js +++ b/src/static_src/tests/SeaDealSpec.js @@ -296,4 +296,85 @@ describe("SeaDeal", () => { expect(rank.textContent).toBe("Q"); }); }); + + // ── Reversed-card auto-rotate on modal open ─────────────────────────────── // + // + // Spec 2026-05-26: a reversed card opens rightside-up (so the user sees + // the upright card frame + a dimmed upright title + a highlighted + // upside-down reversal title) and then SLOWLY auto-rotates 180° to land + // upside-down. Duration is 2× the normal SPIN transition (0.4s → 0.8s). + // The stat-block gets `.is-reversed` immediately (REVERSAL label + // highlights as the modal opens); the card itself gets `.stage-card-- + // reversed` only after the auto-rotate lands. `[data-spinning]` is set + // for the rotation window so the FLIP btn hides during the spin (the + // SCSS-side companion rule also repositions FLIP to visual bottom-left + // post-rotation, matching the game-kit fan precedent). + + describe("reversed-card open", () => { + const REVERSED_CARD = Object.assign({}, CARD, { reversed: true }); + + beforeEach(() => { + jasmine.clock().install(); + makeFixture(); + }); + afterEach(() => jasmine.clock().uninstall()); + + it("sets is-reversed on stat-block immediately", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + expect(statBlock.classList.contains("is-reversed")).toBe(true); + }); + + it("does NOT set stage-card--reversed immediately (card starts rightside-up)", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(false); + }); + + it("does NOT set data-spinning before the flip-in completes", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + expect(stageCard.dataset.spinning).toBeUndefined(); + }); + + it("kicks off the auto-rotate after the flip-in window — data-spinning set + stage-card--reversed added", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + jasmine.clock().tick(500); // past sea-flip-in (0.35s) + buffer + expect(stageCard.dataset.spinning).toBe("1"); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(true); + }); + + it("clears data-spinning once the auto-rotate window has elapsed", () => { + SeaDeal.openStage(REVERSED_CARD, ".sea-pos-cover", true); + jasmine.clock().tick(500 + 900); // past flip-in + 0.8s rotate + expect(stageCard.dataset.spinning).toBeUndefined(); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(true); + }); + + it("does NOT auto-rotate an upright card", () => { + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + jasmine.clock().tick(2000); + expect(stageCard.classList.contains("stage-card--reversed")).toBe(false); + expect(stageCard.dataset.spinning).toBeUndefined(); + }); + }); + + // ── SPIN click hides FLIP during rotation ───────────────────────────────── // + + describe("SPIN click hides FLIP via [data-spinning]", () => { + beforeEach(() => { + jasmine.clock().install(); + makeFixture(); + SeaDeal.openStage(CARD, ".sea-pos-cover", true); + }); + afterEach(() => jasmine.clock().uninstall()); + + it("sets data-spinning on SPIN click so the FLIP btn hides during the rotation", () => { + testDiv.querySelector(".spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(stageCard.dataset.spinning).toBe("1"); + }); + + it("clears data-spinning after the SPIN transition window elapses", () => { + testDiv.querySelector(".spin-btn").dispatchEvent(new MouseEvent("click", { bubbles: true })); + jasmine.clock().tick(500); // past 0.4s SPIN transition + expect(stageCard.dataset.spinning).toBeUndefined(); + }); + }); });