diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fd7aac..873e866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to this project will be documented in this file. - Make deck selection keyboard accessible ([#27](https://github.com/ButteredGroove/fac-flipper/issues/27)) - Add favicon and app icons with manifest wiring ([#39](https://github.com/ButteredGroove/fac-flipper/issues/39)) - Remember last successfully loaded deck and restore it on startup with safe fallback behavior ([#52](https://github.com/ButteredGroove/fac-flipper/issues/52)) +- Add visual flip feedback on `CURRENT CARD` draws with reduced-motion-safe fallback styling ([#51](https://github.com/ButteredGroove/fac-flipper/issues/51)) ## Changed diff --git a/README.md b/README.md index 2e42f9f..32421c5 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Cards are often fundamental to baseball sims, but digital implementations are of - Automatic shuffle and reshuffle - Optional reshuffle with or without clearing history - Clean, minimalist card rendering +- Subtle visual flip cue on `CURRENT CARD` draws - Support for multiple FAC layouts and variants - Recent draw history - Click history entries to preview previous cards @@ -270,7 +271,7 @@ sure to update to point to your counter. ## Status -Current version: **v0.0.17**. +Current version: **v0.0.18**. This release establishes: diff --git a/app.js b/app.js index 6e504cc..566b6c1 100644 --- a/app.js +++ b/app.js @@ -11,6 +11,7 @@ import { } from "./src/preferences.js"; import { MAX_HISTORY, elements, state } from "./src/state.js"; import { + animateCurrentCardFlip, applyLayoutVars, closeModal, getLayoutMetrics, @@ -361,6 +362,7 @@ function drawCard() { state.currentCard = card; addToHistory(card); renderCard(); + animateCurrentCardFlip(); updateRemaining(); } diff --git a/package-lock.json b/package-lock.json index e854ed2..fd1196f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fac-flipper", - "version": "0.0.17", + "version": "0.0.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fac-flipper", - "version": "0.0.17", + "version": "0.0.18", "dependencies": { "papaparse": "^5.5.3" }, diff --git a/package.json b/package.json index cc712ff..6eab5d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fac-flipper", - "version": "0.0.17", + "version": "0.0.18", "private": true, "devDependencies": { "@biomejs/biome": "1.8.3", diff --git a/src/config.js b/src/config.js index 3eb42bf..54b4e3e 100644 --- a/src/config.js +++ b/src/config.js @@ -1,5 +1,5 @@ const APP_NAME = "fac-flipper"; -const APP_VERSION = "0.0.17"; +const APP_VERSION = "0.0.18"; const APP_AUTHOR = "ButteredGroove"; const APP_ISSUES_URL = "https://github.com/ButteredGroove/fac-flipper/issues"; const APP_SHORTCUTS = "Space/Enter draws, R reshuffles"; diff --git a/src/ui.js b/src/ui.js index c2a21cf..87aa8b3 100644 --- a/src/ui.js +++ b/src/ui.js @@ -8,11 +8,75 @@ import { import { elements, state } from "./state.js"; import { formatVersion, getValue } from "./utils.js"; +const FLIP_ANIMATION_CLASS = "is-flip-animating"; +const FLIP_ANIMATION_DURATION_MS = 240; +const flipAnimationState = new WeakMap(); + function updateDeckDisplay(deck) { elements.deckName.textContent = deck.name; elements.deckVersion.textContent = formatVersion(deck.version); } +function clearFlipAnimationState(element) { + const existing = flipAnimationState.get(element); + if (!existing) { + return; + } + window.clearTimeout(existing.timeoutId); + element.removeEventListener("animationend", existing.onEnd); + flipAnimationState.delete(element); +} + +function restartElementAnimation(element, className, durationMs) { + if (!(element instanceof HTMLElement)) { + return; + } + + clearFlipAnimationState(element); + element.classList.remove(className); + void element.offsetWidth; + element.classList.add(className); + + const cleanup = () => { + const current = flipAnimationState.get(element); + if (!current || current.onEnd !== onEnd) { + return; + } + clearFlipAnimationState(element); + element.classList.remove(className); + }; + + const onEnd = (event) => { + if (event.target !== element) { + return; + } + cleanup(); + }; + + const timeoutId = window.setTimeout(cleanup, durationMs + 80); + flipAnimationState.set(element, { onEnd, timeoutId }); + element.addEventListener("animationend", onEnd); +} + +function animateCurrentCardFlip() { + const frameEl = elements.cardFrame; + const cardEl = elements.card; + if (!(frameEl instanceof HTMLElement) || !(cardEl instanceof HTMLElement)) { + return; + } + + restartElementAnimation( + frameEl, + FLIP_ANIMATION_CLASS, + FLIP_ANIMATION_DURATION_MS, + ); + restartElementAnimation( + cardEl, + FLIP_ANIMATION_CLASS, + FLIP_ANIMATION_DURATION_MS, + ); +} + function renderDeckWarning(warnings) { if (!elements.deckWarning) { return; @@ -556,6 +620,7 @@ function setDrawEnabled(enabled) { export { applyLayoutVars, + animateCurrentCardFlip, closeModal, getLayoutMetrics, highlightDeck, diff --git a/styles.css b/styles.css index 89c0153..d792b98 100644 --- a/styles.css +++ b/styles.css @@ -8,6 +8,11 @@ --accent: #3b82f6; --shadow-panel: 0 1px 2px rgba(0, 0, 0, 0.04); --shadow-card: 0 6px 18px rgba(0, 0, 0, 0.08); + --flip-anim-ms: 240ms; + --flip-anim-ease: cubic-bezier(0.22, 1, 0.36, 1); + --flip-anim-shadow-frame: 0 10px 24px rgba(0, 0, 0, 0.14); + --flip-anim-shadow-card: 0 10px 24px rgba(0, 0, 0, 0.16); + --flip-anim-accent: rgba(59, 130, 246, 0.28); --radius: 12px; --space-1: 8px; --space-2: 16px; @@ -219,6 +224,8 @@ body { } .card { + position: relative; + overflow: hidden; width: 100%; height: 100%; background: var(--card); @@ -232,6 +239,111 @@ body { gap: 0; } +.card::before, +.card::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; +} + +.card::before { + opacity: 0; + z-index: 1; + background: rgba(59, 130, 246, 0.14); +} + +.card::after { + z-index: 2; + opacity: 0; + transform: translateX(-120%); + background: linear-gradient( + 120deg, + rgba(255, 255, 255, 0) 12%, + rgba(147, 197, 253, 0.38) 40%, + rgba(255, 255, 255, 0.62) 54%, + rgba(191, 219, 254, 0.3) 68%, + rgba(255, 255, 255, 0) 88% + ); +} + +@keyframes cardFrameFlipPulse { + 0% { + transform: translateY(0) scale(1); + box-shadow: var(--shadow-panel); + } + 45% { + transform: translateY(-4px) scale(1.012); + box-shadow: var(--flip-anim-shadow-frame); + } + 100% { + transform: translateY(0) scale(1); + box-shadow: var(--shadow-panel); + } +} + +@keyframes cardSurfaceFlipCue { + 0% { + transform: translateY(0) scale(1); + opacity: 1; + box-shadow: inset 0 0 0 0 rgba(59, 130, 246, 0), var(--shadow-card); + } + 45% { + transform: translateY(-2px) scale(1.008); + opacity: 0.97; + box-shadow: inset 0 0 0 3px var(--flip-anim-accent), var(--flip-anim-shadow-card); + } + 100% { + transform: translateY(0) scale(1); + opacity: 1; + box-shadow: inset 0 0 0 0 rgba(59, 130, 246, 0), var(--shadow-card); + } +} + +@keyframes cardSurfaceTint { + 0% { + opacity: 0; + } + 35% { + opacity: 0.26; + } + 100% { + opacity: 0; + } +} + +@keyframes cardSurfaceSheen { + 0% { + opacity: 0; + transform: translateX(-120%); + } + 35% { + opacity: 0.58; + } + 100% { + opacity: 0; + transform: translateX(120%); + } +} + +.card-frame.is-flip-animating { + animation: cardFrameFlipPulse var(--flip-anim-ms) var(--flip-anim-ease); + will-change: transform, box-shadow; +} + +.card.is-flip-animating { + animation: cardSurfaceFlipCue var(--flip-anim-ms) var(--flip-anim-ease); + will-change: transform, opacity, box-shadow; +} + +.card.is-flip-animating::before { + animation: cardSurfaceTint var(--flip-anim-ms) var(--flip-anim-ease); +} + +.card.is-flip-animating::after { + animation: cardSurfaceSheen var(--flip-anim-ms) var(--flip-anim-ease); +} + .card-placeholder { grid-column: 1 / -1; grid-row: 1 / -1; @@ -723,6 +835,34 @@ body { font-weight: 600; } +@media (prefers-reduced-motion: reduce) { + .card-frame.is-flip-animating, + .card.is-flip-animating { + animation: none; + } + + .card-frame.is-flip-animating { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.22); + } + + .card.is-flip-animating { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.22), var(--shadow-card); + opacity: 1; + transform: none; + } + + .card.is-flip-animating::after { + animation: none; + opacity: 0; + transform: translateX(0); + } + + .card.is-flip-animating::before { + animation: none; + opacity: 0.14; + } +} + @media (max-width: 520px) { .about-row { grid-template-columns: 1fr; diff --git a/tests/app.confirmations.test.js b/tests/app.confirmations.test.js index b52baa1..c027007 100644 --- a/tests/app.confirmations.test.js +++ b/tests/app.confirmations.test.js @@ -33,6 +33,7 @@ const flushPromises = () => vi.mock("../src/ui.js", () => ({ applyLayoutVars: vi.fn(), + animateCurrentCardFlip: vi.fn(), closeModal: vi.fn(), getLayoutMetrics: vi.fn(() => ({ cols: 12, diff --git a/tests/app.deckLoadError.test.js b/tests/app.deckLoadError.test.js index bd4d064..7e11aa2 100644 --- a/tests/app.deckLoadError.test.js +++ b/tests/app.deckLoadError.test.js @@ -33,6 +33,7 @@ const flushPromises = () => vi.mock("../src/ui.js", () => ({ applyLayoutVars: vi.fn(), + animateCurrentCardFlip: vi.fn(), closeModal: vi.fn(), getLayoutMetrics: vi.fn(() => ({ cols: 12, diff --git a/tests/app.deckLoadGuard.test.js b/tests/app.deckLoadGuard.test.js index 54ed757..33a456f 100644 --- a/tests/app.deckLoadGuard.test.js +++ b/tests/app.deckLoadGuard.test.js @@ -54,6 +54,7 @@ const resolveLoad = (name) => { vi.mock("../src/ui.js", () => ({ applyLayoutVars: vi.fn(), + animateCurrentCardFlip: vi.fn(), closeModal: vi.fn(), getLayoutMetrics: vi.fn(() => ({ cols: 12, diff --git a/tests/app.deckManifestValidation.test.js b/tests/app.deckManifestValidation.test.js index 3d88fe2..7552865 100644 --- a/tests/app.deckManifestValidation.test.js +++ b/tests/app.deckManifestValidation.test.js @@ -47,6 +47,7 @@ const fetchJson = vi.fn(async () => ({ vi.mock("../src/ui.js", () => ({ applyLayoutVars: vi.fn(), + animateCurrentCardFlip: vi.fn(), closeModal: vi.fn(), getLayoutMetrics: vi.fn(() => ({ cols: 12, diff --git a/tests/app.deckParseWarning.test.js b/tests/app.deckParseWarning.test.js index a6dfe83..ab00364 100644 --- a/tests/app.deckParseWarning.test.js +++ b/tests/app.deckParseWarning.test.js @@ -34,6 +34,7 @@ const flushPromises = () => vi.mock("../src/ui.js", () => ({ applyLayoutVars: vi.fn(), + animateCurrentCardFlip: vi.fn(), closeModal: vi.fn(), getLayoutMetrics: vi.fn(() => ({ cols: 12, diff --git a/tests/app.drawWithoutReplacement.test.js b/tests/app.drawWithoutReplacement.test.js index 0be8e21..3508af8 100644 --- a/tests/app.drawWithoutReplacement.test.js +++ b/tests/app.drawWithoutReplacement.test.js @@ -37,6 +37,7 @@ const cards = Array.from({ length: 3 }, (_, i) => ({ vi.mock("../src/ui.js", () => ({ applyLayoutVars: vi.fn(), + animateCurrentCardFlip: vi.fn(), closeModal: vi.fn(), getLayoutMetrics: vi.fn(() => ({ cols: 12, diff --git a/tests/app.historyPreview.test.js b/tests/app.historyPreview.test.js index c92bbf4..8906adf 100644 --- a/tests/app.historyPreview.test.js +++ b/tests/app.historyPreview.test.js @@ -37,6 +37,7 @@ const cards = Array.from({ length: 11 }, (_, i) => ({ vi.mock("../src/ui.js", () => ({ applyLayoutVars: vi.fn(), + animateCurrentCardFlip: vi.fn(), closeModal: vi.fn(), getLayoutMetrics: vi.fn(() => ({ cols: 12, diff --git a/tests/app.lastDeckPreference.test.js b/tests/app.lastDeckPreference.test.js index 876ac4c..eca22f2 100644 --- a/tests/app.lastDeckPreference.test.js +++ b/tests/app.lastDeckPreference.test.js @@ -70,6 +70,7 @@ const loadDeckDefinition = vi.fn(async (path) => { vi.mock("../src/ui.js", () => ({ applyLayoutVars: vi.fn(), + animateCurrentCardFlip: vi.fn(), closeModal: vi.fn(), getLayoutMetrics: vi.fn(() => ({ cols: 12, diff --git a/tests/ui.animation.test.js b/tests/ui.animation.test.js new file mode 100644 index 0000000..a679f08 --- /dev/null +++ b/tests/ui.animation.test.js @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const fixture = ` +
+ + + + + + + + + + + + + + + + + + + + + + + +`; + +async function loadUi() { + vi.resetModules(); + const ui = await import("../src/ui.js"); + const stateModule = await import("../src/state.js"); + return { ui, elements: stateModule.elements }; +} + +describe("animateCurrentCardFlip", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + }); + + it("applies and removes animation classes when animation ends", async () => { + document.body.innerHTML = fixture; + const { ui, elements } = await loadUi(); + + ui.animateCurrentCardFlip(); + + expect(elements.cardFrame.classList.contains("is-flip-animating")).toBe( + true, + ); + expect(elements.card.classList.contains("is-flip-animating")).toBe(true); + + elements.cardFrame.dispatchEvent(new Event("animationend")); + elements.card.dispatchEvent(new Event("animationend")); + + expect(elements.cardFrame.classList.contains("is-flip-animating")).toBe( + false, + ); + expect(elements.card.classList.contains("is-flip-animating")).toBe(false); + }); + + it("restarts animation cleanly on rapid repeated calls", async () => { + document.body.innerHTML = fixture; + const { ui, elements } = await loadUi(); + + ui.animateCurrentCardFlip(); + ui.animateCurrentCardFlip(); + + expect(elements.cardFrame.classList.contains("is-flip-animating")).toBe( + true, + ); + expect(elements.card.classList.contains("is-flip-animating")).toBe(true); + + vi.advanceTimersByTime(400); + + expect(elements.cardFrame.classList.contains("is-flip-animating")).toBe( + false, + ); + expect(elements.card.classList.contains("is-flip-animating")).toBe(false); + }); + + it("falls back to timeout cleanup when animationend does not fire", async () => { + document.body.innerHTML = fixture; + const { ui, elements } = await loadUi(); + + ui.animateCurrentCardFlip(); + + expect(elements.cardFrame.classList.contains("is-flip-animating")).toBe( + true, + ); + expect(elements.card.classList.contains("is-flip-animating")).toBe(true); + + vi.advanceTimersByTime(400); + + expect(elements.cardFrame.classList.contains("is-flip-animating")).toBe( + false, + ); + expect(elements.card.classList.contains("is-flip-animating")).toBe(false); + }); + + it("is a no-op when required elements are missing", async () => { + document.body.innerHTML = ""; + const { ui } = await loadUi(); + + expect(() => ui.animateCurrentCardFlip()).not.toThrow(); + }); +});