sea_stage reversed-card open: slow auto-rotate-in + FLIP-btn corner-swap — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

User spec 2026-05-26 for the sea_stage modal (shared by my_sea.html today, room.html SEA SELECT later):

1. **Reversed card opens rightside-up, then slowly auto-rotates 180°.** Preserves the original text-card legibility convention (modal opens w. upright frame + dimmed upright title + highlighted upside-down reversal title) but adds a JS-driven 0.8s rotate-in (2× the SPIN transition's 0.4s) that lands the card upside-down. Stat-block still gets `.is-reversed` immediately so the REVERSAL label is highlighted from the first frame. `_populate` no longer slaps `.stage-card--reversed` on the card up front; `_showStage` schedules `_autoRotateToReversed` 400ms post-flip-in (past the 0.35s `sea-flip-in` keyframe + buffer). Auto-rotate mirrors the SPIN-click pattern — strip the flip-in keyframe class, force reflow, inline-override `transition-duration` to 0.8s, then toggle `.stage-card--reversed` so the static `transition: transform` lerps the rotation. Timers tracked on module-level handles so dismiss-mid-rotate + reopen-mid-rotate cancel cleanly w.o. stacking handlers (cleared in `_populate`, `_hideStage`, `_testInit`).

2. **FLIP btn always lands at visual bottom-left, regardless of reversal.** Previously the in-card btn rode along w. the 180° rotation, ending top-right (user-flagged as wrong: "the game_kit.html carousel already handles this perfectly—FLIP only ever appears bottom-left, regardless of reversal"). The fan-flip-btn pulls this off by being a SIBLING of `.tarot-fan-wrap` (lives outside any rotating card) — sea_stage's btn sits INSIDE `.sea-stage-card` along w. my_sign / my_sign-applet's shared DOM, so restructuring out wasn't an option. Solved via CSS counter-positioning instead: `.sea-stage-card.stage-card--reversed .sea-stage-flip-btn` re-anchors to card-local top-right + counter-rotates 180° on the btn itself, landing it at visual bottom-left w. upright label. Companion `[data-spinning]` attr (joined to the existing flip-btn-mid-flip selector chain) hides the btn during the rotation window so it never jumps visibly between corners. Set by both `_autoRotateToReversed` (0.8s window) + the SPIN click handler (0.4s window).

TDD coverage — `SeaDealSpec.js` gets a new describe block w. jasmine.clock-driven specs:
- `reversed-card open` × 6: `is-reversed` set immediately on stat-block; `.stage-card--reversed` NOT set immediately on card; `data-spinning` NOT set pre-flip-in; both set after 500ms; both cleared (well, `.stage-card--reversed` persists) after 1400ms; upright cards don't trigger auto-rotate at all
- `SPIN click hides FLIP via [data-spinning]` × 2: set on click; cleared after 500ms

Files:
- `apps/epic/static/apps/epic/sea.js` — `_populate` defers `.stage-card--reversed` + clears in-flight rotate state; `_showStage` schedules `_autoRotateToReversed` for reversed cards; SPIN handler sets `data-spinning` for the SPIN_MS window; `_hideStage` + `_testInit` clear rotate timers + spin attr; new module-level timer handles + duration constants
- `static_src/scss/_card-deck.scss` — `.sea-stage-card.stage-card--reversed .sea-stage-flip-btn` counter-positioning rule (bottom→top, left→right, transform: rotate(180deg)); `[data-spinning]` joined to the unified flip-btn mid-rotate-hide selector chain
- `static_src/tests/SeaDealSpec.js` + `static/tests/SeaDealSpec.js` — new describe blocks for the two new behaviors

Jasmine FT green. User-verified visually on `/gameboard/my-sea/` w. an upside-down reversed-card open: "Visually verified just now in my_sea.html, very nicely done".

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-05-26 13:49:48 -04:00
parent a133a9c1c3
commit de9c97a2f8
4 changed files with 261 additions and 6 deletions

View File

@@ -28,6 +28,18 @@ var SeaDeal = (function () {
var _infoIdx = 0; var _infoIdx = 0;
var _infoOpen = false; var _infoOpen = false;
var _spinOrigLabel, _fyiOrigLabel; 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) ────────── // ── Stage card population (delegates to shared StageCard module) ──────────
@@ -42,12 +54,17 @@ var SeaDeal = (function () {
_infoData = StageCard.buildInfoData(card); _infoData = StageCard.buildInfoData(card);
_infoIdx = 0; _infoIdx = 0;
// Sync SPIN state to the card's reversal axis — `card.reversed` is set // Stat-block always lands in the card's true reversal state immediately
// server-side at deck-fetch time (apps.epic.utils.stack_reversal_probability) // (REVERSAL label highlights as the modal opens). The card itself starts
// and persisted in `_seaHand`, so re-clicking a deposited slot must // rightside-up regardless of reversal — `_autoRotateToReversed` (kicked
// restore that state, not reset to upright. // off from `_showStage`) handles the slow 180° rotate-in afterwards.
stageCard.classList.toggle('stage-card--reversed', !!card.reversed); // Cancel any in-flight prior rotate (re-open during animation).
statBlock.classList.toggle('is-reversed', !!card.reversed); 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(); _closeInfo();
} }
@@ -124,6 +141,41 @@ var SeaDeal = (function () {
stage.classList.toggle('sea-stage--levity', !!isLevity); stage.classList.toggle('sea-stage--levity', !!isLevity);
stage.classList.toggle('sea-stage--gravity', !isLevity); stage.classList.toggle('sea-stage--gravity', !isLevity);
stageCard.classList.add('sea-stage-card--shown'); 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() { function _hideStage() {
@@ -137,6 +189,10 @@ var SeaDeal = (function () {
} }
stage.style.display = 'none'; stage.style.display = 'none';
stageCard.classList.remove('sea-stage-card--shown'); 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; _viewingPos = null;
_closeInfo(); _closeInfo();
} }
@@ -215,7 +271,14 @@ var SeaDeal = (function () {
// Remove animation fill, force reflow so the transition has a start state // Remove animation fill, force reflow so the transition has a start state
stageCard.classList.remove('sea-stage-card--shown'); stageCard.classList.remove('sea-stage-card--shown');
stageCard.getBoundingClientRect(); // flush layout 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'); stageCard.classList.toggle('stage-card--reversed');
setTimeout(function () { delete stageCard.dataset.spinning; }, SPIN_MS);
}); });
} }
@@ -343,6 +406,8 @@ var SeaDeal = (function () {
_userPolarity = 'levity'; _userPolarity = 'levity';
_seaHand = {}; _viewingPos = null; _seaHand = {}; _viewingPos = null;
_infoData = []; _infoIdx = 0; _infoOpen = false; _infoData = []; _infoIdx = 0; _infoOpen = false;
if (_rotateTimer) { clearTimeout(_rotateTimer); _rotateTimer = null; }
if (_rotateEndTimer) { clearTimeout(_rotateEndTimer); _rotateEndTimer = null; }
init(); init();
}, },
}; };

View File

@@ -296,4 +296,85 @@ describe("SeaDeal", () => {
expect(rank.textContent).toBe("Q"); 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();
});
});
}); });

View File

@@ -1004,6 +1004,27 @@ html:has(.sig-backdrop) {
left: 0.6rem; 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 // 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 // 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 // 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 // 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- // INSIDE the card on my_sign + applet + sea_stage; sibling under .tarot-fan-
// wrap for the fan carousel). // 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, .sig-stage-card[data-flipping] .my-sign-flip-btn,
.my-sign-applet-card[data-flipping] .my-sign-applet-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-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 { .tarot-fan-wrap:has(.fan-card[data-flipping]) .fan-flip-btn {
@extend %flip-btn-mid-flip; @extend %flip-btn-mid-flip;
} }

View File

@@ -296,4 +296,85 @@ describe("SeaDeal", () => {
expect(rank.textContent).toBe("Q"); 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();
});
});
}); });