feat(tarot): add interactive card flip reveal animation
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 50s

Cards now start face-down and flip individually on click, with a
synthesised flip sound and staggered text fade-in. The draw button
and spread selection buttons are locked until all cards in the current
reading have been revealed. An unflipped card shows 'Click card to
reveal' as a hint.
This commit is contained in:
2026-03-18 11:12:49 -07:00
committed by Naomi Carrigan
parent 7b0b1825e3
commit 3a63aa59a1
+156 -10
View File
@@ -8,6 +8,11 @@
name="description" name="description"
content="Draw tarot cards and receive a reading with traditional card meanings." content="Draw tarot cards and receive a reading with traditional card meanings."
/> />
<link
rel="preload"
as="image"
href="https://cdn.nhcarrigan.com/tarot/back.png"
/>
<script <script
src="https://cdn.nhcarrigan.com/headers/index.js" src="https://cdn.nhcarrigan.com/headers/index.js"
async async
@@ -66,6 +71,11 @@
background: var(--witch-plum); background: var(--witch-plum);
} }
.spread-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.spread-description { .spread-description {
text-align: center; text-align: center;
color: var(--witch-plum); color: var(--witch-plum);
@@ -223,13 +233,10 @@
width: 150px; width: 150px;
height: 260px; height: 260px;
border-radius: 10px; border-radius: 10px;
overflow: hidden;
border: 2px solid var(--witch-plum); border: 2px solid var(--witch-plum);
margin-bottom: 0.75em; margin-bottom: 0.75em;
background: rgba(212, 165, 199, 0.08); perspective: 700px;
display: flex; position: relative;
align-items: center;
justify-content: center;
} }
.card-img-wrap img { .card-img-wrap img {
@@ -243,6 +250,70 @@
transform: rotate(180deg); transform: rotate(180deg);
} }
.flip-inner {
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.flip-inner.flipped {
transform: rotateY(180deg);
}
.flip-face {
position: absolute;
inset: 0;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
border-radius: 8px;
overflow: hidden;
}
.flip-back img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.flip-inner:not(.flipped) {
cursor: pointer;
}
.flip-front {
transform: rotateY(180deg);
}
.flip-front a {
display: block;
width: 100%;
height: 100%;
}
.card-details {
opacity: 0;
transition: opacity 0.4s ease;
}
.card-details.revealed {
opacity: 1;
}
.card-placeholder {
font-style: italic;
font-size: 0.85rem;
color: var(--witch-plum);
opacity: 0.7;
transition: opacity 0.3s ease;
text-align: center;
}
.card-placeholder.hidden {
opacity: 0;
}
.card-name { .card-name {
font-weight: 700; font-weight: 700;
font-size: 0.95rem; font-size: 0.95rem;
@@ -975,6 +1046,31 @@
} }
} }
function playFlipSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const duration = 0.12;
const buf = ctx.createBuffer(1, Math.ceil(ctx.sampleRate * duration), ctx.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / data.length, 4);
}
const src = ctx.createBufferSource();
src.buffer = buf;
const filter = ctx.createBiquadFilter();
filter.type = "highpass";
filter.frequency.value = 900;
const gain = ctx.createGain();
gain.gain.value = 0.3;
src.connect(filter);
filter.connect(gain);
gain.connect(ctx.destination);
src.start();
} catch {
/* audio unavailable */
}
}
async function renderCards(drawn, slots, positionKeys, spreadKey) { async function renderCards(drawn, slots, positionKeys, spreadKey) {
const area = document.getElementById("cards-area"); const area = document.getElementById("cards-area");
area.className = "cards-area layout-" + spreadKey; area.className = "cards-area layout-" + spreadKey;
@@ -982,11 +1078,15 @@
const drawBtn = document.getElementById("draw-btn"); const drawBtn = document.getElementById("draw-btn");
drawBtn.disabled = true; drawBtn.disabled = true;
document.querySelectorAll(".spread-btn").forEach(b => { b.disabled = true; });
const meaningData = await Promise.all( const meaningData = await Promise.all(
drawn.map(card => fetchCardMeanings(card.img.replace(".png", ""))) drawn.map(card => fetchCardMeanings(card.img.replace(".png", "")))
); );
const flipInners = [];
const cardDetails = [];
drawn.forEach((card, i) => { drawn.forEach((card, i) => {
const posData = meaningData[i]; const posData = meaningData[i];
const posKey = positionKeys[i]; const posKey = positionKeys[i];
@@ -1008,6 +1108,19 @@
const imgWrap = document.createElement("div"); const imgWrap = document.createElement("div");
imgWrap.className = "card-img-wrap"; imgWrap.className = "card-img-wrap";
const flipInner = document.createElement("div");
flipInner.className = "flip-inner";
const flipBack = document.createElement("div");
flipBack.className = "flip-face flip-back";
const backImg = document.createElement("img");
backImg.src = "https://cdn.nhcarrigan.com/tarot/back.png";
backImg.alt = "Card back";
flipBack.appendChild(backImg);
const flipFront = document.createElement("div");
flipFront.className = "flip-face flip-front";
const imgLink = document.createElement("a"); const imgLink = document.createElement("a");
imgLink.href = `https://cdn.nhcarrigan.com/tarot/${card.img}`; imgLink.href = `https://cdn.nhcarrigan.com/tarot/${card.img}`;
imgLink.target = "_blank"; imgLink.target = "_blank";
@@ -1020,7 +1133,14 @@
if (card.isReversed) img.classList.add("reversed"); if (card.isReversed) img.classList.add("reversed");
imgLink.appendChild(img); imgLink.appendChild(img);
imgWrap.appendChild(imgLink); flipFront.appendChild(imgLink);
flipInner.appendChild(flipBack);
flipInner.appendChild(flipFront);
imgWrap.appendChild(flipInner);
flipInners.push({ inner: flipInner, flipped: false, placeholder: null });
const details = document.createElement("div");
details.className = "card-details";
const name = document.createElement("div"); const name = document.createElement("div");
name.className = "card-name"; name.className = "card-name";
@@ -1034,15 +1154,41 @@
meaning.className = "card-meaning"; meaning.className = "card-meaning";
meaning.textContent = meaningText; meaning.textContent = meaningText;
details.appendChild(name);
details.appendChild(orientation);
details.appendChild(meaning);
cardDetails.push(details);
const placeholder = document.createElement("div");
placeholder.className = "card-placeholder";
placeholder.textContent = "Click card to reveal";
flipInners[flipInners.length - 1].placeholder = placeholder;
slot.appendChild(label); slot.appendChild(label);
slot.appendChild(imgWrap); slot.appendChild(imgWrap);
slot.appendChild(name); slot.appendChild(placeholder);
slot.appendChild(orientation); slot.appendChild(details);
slot.appendChild(meaning);
area.appendChild(slot); area.appendChild(slot);
}); });
drawBtn.disabled = false; const TEXT_DELAY = 350;
flipInners.forEach((entry, i) => {
entry.inner.addEventListener("click", () => {
if (entry.flipped) return;
entry.flipped = true;
entry.placeholder.classList.add("hidden");
playFlipSound();
entry.inner.classList.add("flipped");
setTimeout(() => {
cardDetails[i].classList.add("revealed");
}, TEXT_DELAY);
if (flipInners.every(e => e.flipped)) {
drawBtn.disabled = false;
document.querySelectorAll(".spread-btn").forEach(b => { b.disabled = false; });
}
});
});
} }
document.querySelectorAll(".spread-btn").forEach(btn => { document.querySelectorAll(".spread-btn").forEach(btn => {