Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down
2 changes: 2 additions & 0 deletions app.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "./src/preferences.js";
import { MAX_HISTORY, elements, state } from "./src/state.js";
import {
animateCurrentCardFlip,
applyLayoutVars,
closeModal,
getLayoutMetrics,
Expand Down Expand Up @@ -361,6 +362,7 @@ function drawCard() {
state.currentCard = card;
addToHistory(card);
renderCard();
animateCurrentCardFlip();
updateRemaining();
}

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fac-flipper",
"version": "0.0.17",
"version": "0.0.18",
"private": true,
"devDependencies": {
"@biomejs/biome": "1.8.3",
Expand Down
2 changes: 1 addition & 1 deletion src/config.js
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
65 changes: 65 additions & 0 deletions src/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -556,6 +620,7 @@ function setDrawEnabled(enabled) {

export {
applyLayoutVars,
animateCurrentCardFlip,
closeModal,
getLayoutMetrics,
highlightDeck,
Expand Down
140 changes: 140 additions & 0 deletions styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -219,6 +224,8 @@ body {
}

.card {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
background: var(--card);
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions tests/app.confirmations.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/app.deckLoadError.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/app.deckLoadGuard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/app.deckManifestValidation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/app.deckParseWarning.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/app.drawWithoutReplacement.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/app.historyPreview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/app.lastDeckPreference.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading