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

@@ -1,93 +1,226 @@
function initGameKitPage() {
const dialog = document.getElementById('id_tarot_fan_dialog');
if (!dialog) return;
var GameKit = (function () {
'use strict';
const fanContent = document.getElementById('id_fan_content');
const prevBtn = document.getElementById('id_fan_prev');
const nextBtn = document.getElementById('id_fan_next');
var dialog, fanContent, prevBtn, nextBtn, fanWrap, flipBtn;
var stageBlock, spinBtn, fyiBtn, fyiPanel;
var fyiTitle, fyiType, fyiEffect, fyiIndex, fyiPrev, fyiNext;
var statUpright, statReversed;
var _polarity = 'levity'; // FLIP toggles 'levity' ↔ 'gravity'
let currentDeckId = null;
let currentIndex = 0;
let cards = [];
var currentDeckId = null;
var currentIndex = 0;
var cards = [];
var _revealTimer = null;
var REVEAL_DELAY_MS = 500;
function storageKey(deckId) {
return 'tarot-fan-' + deckId;
}
var _infoData = [];
var _infoIdx = 0;
var _infoOpen = false;
var _spinOrigLabel = 'SPIN';
var _fyiOrigLabel = 'FYI';
// ── Storage ────────────────────────────────────────────────────────────────
function storageKey(deckId) { return 'tarot-fan-' + deckId; }
function savePosition() {
if (currentDeckId !== null) {
sessionStorage.setItem(storageKey(currentDeckId), currentIndex);
}
}
function restorePosition(deckId) {
const saved = sessionStorage.getItem(storageKey(deckId));
var saved = sessionStorage.getItem(storageKey(deckId));
return saved !== null ? parseInt(saved, 10) : 0;
}
// ── Carousel transforms ───────────────────────────────────────────────────
function _carouselStep() {
// Pull the per-breakpoint step length from CSS so mobile @media rules
// can shrink the carousel without code changes.
if (!fanWrap) return 200;
var raw = getComputedStyle(fanWrap).getPropertyValue('--fan-carousel-step').trim();
var n = parseFloat(raw);
return isFinite(n) && n > 0 ? n : 200;
}
function cardTransform(offset) {
const abs = Math.abs(offset);
var abs = Math.abs(offset);
var step = _carouselStep();
return {
transform: 'translateX(' + (offset * 200) + 'px) rotateY(' + (offset * 22) + 'deg) scale(' + Math.max(0.3, 1 - abs * 0.15) + ')',
transform: 'translateX(' + (offset * step) + 'px) rotateY(' + (offset * 22) + 'deg) scale(' + Math.max(0.3, 1 - abs * 0.15) + ')',
opacity: Math.max(0.15, 1 - abs * 0.25),
zIndex: 10 - abs,
};
}
function updateFan() {
const total = cards.length;
var total = cards.length;
if (!total) return;
cards.forEach(function(card, i) {
let offset = i - currentIndex;
cards.forEach(function (card, i) {
var offset = i - currentIndex;
if (offset > total / 2) offset -= total;
if (offset < -total / 2) offset += total;
const abs = Math.abs(offset);
var abs = Math.abs(offset);
card.classList.toggle('fan-card--active', offset === 0);
if (abs > 3) {
card.style.display = 'none';
} else {
card.style.display = '';
const t = cardTransform(offset);
card.style.transform = t.transform;
card.style.opacity = t.opacity;
card.style.zIndex = t.zIndex;
var t = cardTransform(offset);
// Active card may also be reversed — append rotate(180deg) to
// the carousel transform without disturbing the layout values.
var spin = (offset === 0 && card.classList.contains('stage-card--reversed'))
? ' rotate(180deg)' : '';
card.style.transform = t.transform + spin;
card.style.opacity = t.opacity;
card.style.zIndex = t.zIndex;
}
});
_populateStage();
_scheduleReveal();
}
// ── Stage block: idle reveal / careen-out ─────────────────────────────────
function _scheduleReveal() {
if (!stageBlock) return;
stageBlock.classList.remove('is-revealed');
if (_revealTimer) clearTimeout(_revealTimer);
_revealTimer = setTimeout(function () {
stageBlock.classList.add('is-revealed');
}, REVEAL_DELAY_MS);
}
function _hideStageImmediate() {
if (!stageBlock) return;
if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; }
stageBlock.classList.remove('is-revealed');
_closeFyi();
}
// ── Stage population (delegates to shared StageCard module) ──────────────
function _populateStage() {
if (!stageBlock || !cards.length) return;
var cardEl = cards[currentIndex];
if (!cardEl) return;
var card = StageCard.fromDataset(cardEl);
// Repaint the focused fan-card's faces in the active polarity (so FLIP
// and per-polarity qualifier rendering stay consistent with the data).
StageCard.populateCard(cardEl, card, _polarity);
cardEl.dataset.polarity = _polarity;
StageCard.populateKeywords(stageBlock, card.keywords_upright, card.keywords_reversed, {
uprightSel: '#id_fan_stat_upright',
reversedSel: '#id_fan_stat_reversed',
});
_infoData = StageCard.buildInfoData(card);
_infoIdx = 0;
// Reset SPIN state on every card change — clears rotation on all cards
// so a previously-reversed card returns to upright when it leaves focus.
stageBlock.classList.remove('is-reversed');
cards.forEach(function (c) { c.classList.remove('stage-card--reversed'); });
_closeFyi();
}
// ── FLIP — toggle polarity with perspective horizontal flip animation ────
// Retains SPIN state across the flip (the .stage-card--reversed class on
// the card is left untouched).
function _flipActive() {
var active = cards[currentIndex];
if (!active) return;
if (active.dataset.flipping) return; // mid-flip
active.dataset.flipping = '1';
// Build the resting transform (carousel offset 0 + optional SPIN rotate(180))
// and the edge-on midpoint by adding a rotateY layer at the end. Keeping
// the carousel + spin intact means the card actually rotates in place
// around its centre rather than just the inner face.
var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : '';
var rest = 'translateX(0px) rotateY(0deg) scale(1)' + spin;
var mid = 'translateX(0px) rotateY(0deg) scale(1)' + spin + ' rotateY(90deg)';
active.animate([
{ transform: rest },
{ transform: mid, offset: 0.5 },
{ transform: rest },
], { duration: 500, easing: 'ease' });
_polarity = (_polarity === 'levity') ? 'gravity' : 'levity';
// Swap polarity content + colour at the edge-on midpoint, so the user
// sees the new face from the start of the second half-rotation.
setTimeout(function () {
var card = StageCard.fromDataset(active);
StageCard.populateCard(active, card, _polarity);
active.dataset.polarity = _polarity;
}, 250);
// Clear the in-flight flag at animation end. Using setTimeout (not
// anim.onfinish) so jasmine.clock().tick() can fake-advance it in tests.
setTimeout(function () { delete active.dataset.flipping; }, 500);
}
// ── FYI panel ─────────────────────────────────────────────────────────────
function _renderFyi() {
StageCard.renderFyi(fyiPanel, _infoData, _infoIdx);
}
function _openFyi() {
_infoOpen = true;
_renderFyi();
if (fyiPanel) fyiPanel.style.display = 'flex';
if (spinBtn) { spinBtn.classList.add('btn-disabled'); spinBtn.textContent = '×'; }
if (fyiBtn) { fyiBtn.classList.add('btn-disabled'); fyiBtn.textContent = '×'; }
if (stageBlock) stageBlock.classList.add('fyi-open');
}
function _closeFyi() {
_infoOpen = false;
if (fyiPanel) fyiPanel.style.display = 'none';
if (spinBtn) { spinBtn.classList.remove('btn-disabled'); spinBtn.textContent = _spinOrigLabel; }
if (fyiBtn) { fyiBtn.classList.remove('btn-disabled'); fyiBtn.textContent = _fyiOrigLabel; }
if (stageBlock) stageBlock.classList.remove('fyi-open');
}
// ── Open / close the dialog ───────────────────────────────────────────────
function openFan(deckId) {
currentDeckId = deckId;
currentIndex = restorePosition(deckId);
fetch('/gameboard/game-kit/deck/' + deckId + '/')
.then(function(r) { return r.text(); })
.then(function(html) {
.then(function (r) { return r.text(); })
.then(function (html) {
fanContent.innerHTML = html;
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
if (currentIndex >= cards.length) currentIndex = 0;
cards.forEach(function(c) {
cards.forEach(function (c) {
c.style.transition = 'transform 0.18s ease-out, opacity 0.18s ease-out';
});
updateFan();
dialog.showModal();
if (dialog && typeof dialog.showModal === 'function') dialog.showModal();
});
}
function closeFan() {
savePosition();
dialog.close();
_hideStageImmediate();
if (dialog && typeof dialog.close === 'function') dialog.close();
}
// ── Navigation ────────────────────────────────────────────────────────────
function navigate(delta) {
if (!cards.length) return;
currentIndex = (currentIndex + delta + cards.length) % cards.length;
savePosition();
_hideStageImmediate();
updateFan();
}
// Step through multiple cards one at a time so intermediate cards are visible
var _navTimer = null;
function navigateAnimated(steps) {
if (!cards.length || steps === 0) return;
@@ -102,64 +235,182 @@ function initGameKitPage() {
tick();
}
// Click on the dark backdrop (the dialog or fan-wrap itself, not on any card child) closes
var fanWrap = dialog.querySelector('.tarot-fan-wrap');
dialog.addEventListener('click', function(e) {
if (e.target === dialog || e.target === fanWrap) closeFan();
});
// ── Init ──────────────────────────────────────────────────────────────────
// Arrow key navigation
dialog.addEventListener('keydown', function(e) {
if (e.key === 'ArrowRight') navigate(1);
if (e.key === 'ArrowLeft') navigate(-1);
});
function init() {
dialog = document.getElementById('id_tarot_fan_dialog');
if (!dialog) return;
// Mousewheel navigation — accumulate delta, cap at 3 cards per event so fast
// spins don't overshoot; CSS transitions handle the visual smoothness.
var wheelAccum = 0;
var wheelDecayTimer = null;
var WHEEL_STEP = 150;
dialog.addEventListener('wheel', function(e) {
e.preventDefault();
clearTimeout(wheelDecayTimer);
wheelAccum += e.deltaY;
var steps = Math.trunc(wheelAccum / WHEEL_STEP);
if (steps !== 0) {
steps = Math.sign(steps) * Math.min(Math.abs(steps), 3);
wheelAccum -= steps * WHEEL_STEP;
navigate(steps);
fanContent = document.getElementById('id_fan_content');
prevBtn = document.getElementById('id_fan_prev');
nextBtn = document.getElementById('id_fan_next');
flipBtn = document.getElementById('id_fan_flip');
fanWrap = dialog.querySelector('.tarot-fan-wrap');
stageBlock = document.getElementById('id_fan_stage_block');
if (stageBlock) {
spinBtn = stageBlock.querySelector('.spin-btn');
fyiBtn = stageBlock.querySelector('.fyi-btn');
fyiPanel = stageBlock.querySelector('#id_fan_fyi_panel');
fyiTitle = fyiPanel && fyiPanel.querySelector('.sig-info-title');
fyiType = fyiPanel && fyiPanel.querySelector('.sig-info-type');
fyiEffect = fyiPanel && fyiPanel.querySelector('.sig-info-effect');
fyiIndex = fyiPanel && fyiPanel.querySelector('.sig-info-index');
fyiPrev = stageBlock.querySelector('.fyi-prev');
fyiNext = stageBlock.querySelector('.fyi-next');
statUpright = stageBlock.querySelector('#id_fan_stat_upright');
statReversed = stageBlock.querySelector('#id_fan_stat_reversed');
_spinOrigLabel = spinBtn ? spinBtn.textContent : 'SPIN';
_fyiOrigLabel = fyiBtn ? fyiBtn.textContent : 'FYI';
}
wheelDecayTimer = setTimeout(function() { wheelAccum = 0; }, 200);
}, { passive: false });
// Touch/swipe navigation — uses navigateAnimated so intermediate cards are visible
var touchStartX = 0;
var touchStartY = 0;
var touchStartTime = 0;
dialog.addEventListener('touchstart', function(e) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
}, { passive: true });
dialog.addEventListener('touchend', function(e) {
var dx = e.changedTouches[0].clientX - touchStartX;
var dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dy) > Math.abs(dx)) return; // vertical swipe — ignore
if (Math.abs(dx) < 60) return; // dead zone — raise to 4060 for more deliberate swipe required
var elapsed = Math.max(1, Date.now() - touchStartTime);
var velocity = Math.abs(dx) / elapsed; // px/ms
var steps = velocity > 0.8 // flick threshold — raise (e.g. 0.6) so more swipes use drag formula
? Math.max(1, Math.round(velocity * 4)) // flick multiplier — lower (e.g. 45) to reduce cards per fast flick
: Math.round(Math.abs(dx) / 150); // slow-drag divisor — raise (e.g. 120150) for fewer cards per short drag
navigateAnimated(dx < 0 ? steps : -steps);
}, { passive: true });
// Backdrop click closes
if (dialog && fanWrap) {
dialog.addEventListener('click', function (e) {
if (e.target === dialog || e.target === fanWrap) closeFan();
});
}
prevBtn.addEventListener('click', function() { navigate(-1); });
nextBtn.addEventListener('click', function() { navigate(1); });
// Keyboard nav
if (dialog) {
dialog.addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') navigate(1);
if (e.key === 'ArrowLeft') navigate(-1);
});
}
document.querySelectorAll('.gk-deck-card[data-deck-id]').forEach(function(card) {
card.addEventListener('click', function() { openFan(card.dataset.deckId); });
});
}
// Wheel nav
var wheelAccum = 0;
var wheelDecayTimer = null;
var WHEEL_STEP = 150;
if (dialog) {
dialog.addEventListener('wheel', function (e) {
e.preventDefault();
clearTimeout(wheelDecayTimer);
wheelAccum += e.deltaY;
var steps = Math.trunc(wheelAccum / WHEEL_STEP);
if (steps !== 0) {
steps = Math.sign(steps) * Math.min(Math.abs(steps), 3);
wheelAccum -= steps * WHEEL_STEP;
navigate(steps);
}
wheelDecayTimer = setTimeout(function () { wheelAccum = 0; }, 200);
}, { passive: false });
}
document.addEventListener('DOMContentLoaded', initGameKitPage);
// Touch nav
var touchStartX = 0, touchStartY = 0, touchStartTime = 0;
if (dialog) {
dialog.addEventListener('touchstart', function (e) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchStartTime = Date.now();
}, { passive: true });
dialog.addEventListener('touchend', function (e) {
var dx = e.changedTouches[0].clientX - touchStartX;
var dy = e.changedTouches[0].clientY - touchStartY;
if (Math.abs(dy) > Math.abs(dx)) return;
if (Math.abs(dx) < 60) return;
var elapsed = Math.max(1, Date.now() - touchStartTime);
var velocity = Math.abs(dx) / elapsed;
var steps = velocity > 0.8
? Math.max(1, Math.round(velocity * 4))
: Math.round(Math.abs(dx) / 150);
navigateAnimated(dx < 0 ? steps : -steps);
}, { passive: true });
}
if (prevBtn) prevBtn.addEventListener('click', function () { navigate(-1); });
if (nextBtn) nextBtn.addEventListener('click', function () { navigate(1); });
if (flipBtn) flipBtn.addEventListener('click', function (e) {
// Without this the click bubbles to the dialog's backdrop-close
// handler and shuts the modal — FLIP is a primary action, not a
// dismiss.
e.stopPropagation();
_flipActive();
});
// SPIN — toggle stat block face AND rotate the active fan-card 180°.
// Reapply the active card's transform directly so the rotation animates
// off the existing carousel transform without going through updateFan
// (which would reset .stage-card--reversed via _populateStage).
if (spinBtn) {
spinBtn.addEventListener('click', function () {
if (spinBtn.classList.contains('btn-disabled')) return;
stageBlock.classList.toggle('is-reversed');
var active = cards[currentIndex];
if (!active) return;
active.classList.toggle('stage-card--reversed');
var t = cardTransform(0);
var spin = active.classList.contains('stage-card--reversed') ? ' rotate(180deg)' : '';
active.style.transform = t.transform + spin;
});
}
// FYI
if (fyiBtn) {
fyiBtn.addEventListener('click', function () {
if (fyiBtn.classList.contains('btn-disabled')) return;
_infoOpen ? _closeFyi() : _openFyi();
});
}
if (fyiPanel) {
fyiPanel.addEventListener('click', function (e) {
if (!e.target.closest('.fyi-prev') && !e.target.closest('.fyi-next')) {
_closeFyi();
}
});
}
if (fyiPrev) {
fyiPrev.addEventListener('click', function () {
if (!_infoData.length) return;
_infoIdx = (_infoIdx - 1 + _infoData.length) % _infoData.length;
_renderFyi();
});
}
if (fyiNext) {
fyiNext.addEventListener('click', function () {
if (!_infoData.length) return;
_infoIdx = (_infoIdx + 1) % _infoData.length;
_renderFyi();
});
}
// Deck-card click → openFan
document.querySelectorAll('.gk-deck-card[data-deck-id]').forEach(function (card) {
card.addEventListener('click', function () { openFan(card.dataset.deckId); });
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
return {
openFan: openFan,
closeFan: closeFan,
navigate: navigate,
reinit: init,
_testInit: function () {
dialog = null; fanContent = null; prevBtn = null; nextBtn = null; fanWrap = null;
flipBtn = null;
stageBlock = null; spinBtn = null; fyiBtn = null; fyiPanel = null;
fyiTitle = null; fyiType = null; fyiEffect = null; fyiIndex = null;
fyiPrev = null; fyiNext = null; statUpright = null; statReversed = null;
currentDeckId = null; currentIndex = 0; cards = [];
_revealTimer = null; _infoData = []; _infoIdx = 0; _infoOpen = false;
_polarity = 'levity';
init();
},
// Test seam: skip fetch by reading already-rendered #id_fan_content
_testOpen: function () {
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
currentIndex = 0;
updateFan();
},
_testNavigate: navigate,
};
}());