// Mobile Playlist Carousel — full-screen swipeable cards (§16.16) // On mobile viewport (<768px), playlist entry opens this TikTok-style carousel. // Desktop unaffected — keeps the grid layout from playlists.ts. import type { Playlist, PlaylistMatch } from '../api-types'; import type { Replay } from '../types'; import { computeAllDensities, computeSpeedSchedule, createDirectorState, tickDirectorSpeed, type DirectorState, type DurationPreset, } from './director'; const loadReplayViewer = () => import('../replay-viewer'); function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } // ── Touch tracking for live 60fps swipe ───────────────────────────────────── interface TouchTracker { startX: number; startY: number; startTime: number; tracking: boolean; locked: 'none' | 'vertical' | 'horizontal'; currentDeltaY: number; currentDeltaX: number; } // ── Carousel component ───────────────────────────────────────────────────── export interface CarouselOptions { playlist: Playlist; startIndex?: number; onClose: () => void; autoAdvanceDelay?: number; // ms, default 3000 } const DEFAULT_AUTO_ADVANCE_DELAY = 3000; const METADATA_PANEL_WIDTH = 280; const TRANSITION_MS = 300; const R2_BASE = '/r2'; const B2_FALLBACK = 'https://b2.aicodebattle.com'; const SWIPE_THRESHOLD = 50; // min px to trigger advance const VELOCITY_THRESHOLD = 0.3; // px/ms — fast flick triggers even below threshold const REDUCED_MOTION = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; export class PlaylistCarousel { private overlay: HTMLDivElement; private styleEl: HTMLStyleElement; private playlist: Playlist; private currentIndex: number; private onClose: () => void; private autoAdvanceDelay: number; // Per-card DOM private carouselInner: HTMLDivElement; private canvas: HTMLCanvasElement; private headerBar: HTMLDivElement; private scoreBar: HTMLDivElement; private eventHint: HTMLDivElement; private swipeHint: HTMLDivElement; private metadataPanel: HTMLDivElement; private closeBtn: HTMLButtonElement; private countdownRing: HTMLDivElement; // Replay viewer private viewer: InstanceType | null = null; // Director state private directorState: DirectorState = createDirectorState(); private directorSchedule: ReturnType = []; private directorAnimFrame: number | null = null; // Preloading private preloadedReplays = new Map(); // Auto-advance timer private autoAdvanceTimer: ReturnType | null = null; private countdownAnimFrame: number | null = null; // Metadata panel state private metadataOpen = false; // Transition state private transitioning = false; // Touch tracking for live swipe private touch: TouchTracker = { startX: 0, startY: 0, startTime: 0, tracking: false, locked: 'none', currentDeltaY: 0, currentDeltaX: 0, }; constructor(opts: CarouselOptions) { this.playlist = opts.playlist; this.currentIndex = opts.startIndex ?? 0; this.onClose = opts.onClose; this.autoAdvanceDelay = opts.autoAdvanceDelay ?? DEFAULT_AUTO_ADVANCE_DELAY; // Create overlay this.overlay = document.createElement('div'); this.overlay.className = 'carousel-overlay'; this.overlay.innerHTML = CAROUSEL_HTML; document.body.appendChild(this.overlay); document.body.style.overflow = 'hidden'; // Inject styles this.styleEl = document.createElement('style'); this.styleEl.textContent = CAROUSEL_CSS; document.head.appendChild(this.styleEl); // Grab refs this.carouselInner = this.overlay.querySelector('.carousel-card')!; this.canvas = this.overlay.querySelector('.carousel-canvas')!; this.headerBar = this.overlay.querySelector('.carousel-header')!; this.scoreBar = this.overlay.querySelector('.carousel-score-bar')!; this.eventHint = this.overlay.querySelector('.carousel-event-hint')!; this.swipeHint = this.overlay.querySelector('.carousel-swipe-hint')!; this.metadataPanel = this.overlay.querySelector('.carousel-metadata-panel')!; this.closeBtn = this.overlay.querySelector('.carousel-close-btn')!; this.countdownRing = this.overlay.querySelector('.carousel-countdown-ring')!; // Close button this.closeBtn.addEventListener('click', () => this.destroy()); // Touch events with live tracking this.carouselInner.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true }); this.carouselInner.addEventListener('touchmove', (e) => this.onTouchMove(e), { passive: false }); this.carouselInner.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true }); this.carouselInner.addEventListener('touchcancel', () => this.onTouchCancel(), { passive: true }); // Tap on canvas = play/pause this.canvas.addEventListener('click', () => { if (this.viewer?.getReplay()) this.viewer.togglePlay(); }); // Initialize this.init(); } private async init(): Promise { const { ReplayViewer } = await loadReplayViewer(); this.viewer = new ReplayViewer(this.canvas, { cellSize: 6, animationSpeed: 100, }); this.viewer.onTurnChange = () => { if (!this.viewer) return; if (this.viewer.getIsPlaying() && this.viewer.getTurn() >= this.viewer.getTotalTurns() - 1) { this.viewer.pause(); this.onReplayEnd(); } }; this.viewer.onPlayStateChange = () => { /* keep callback wired */ }; await this.loadCard(this.currentIndex); // Fade swipe hint after 3s setTimeout(() => { if (this.swipeHint) this.swipeHint.classList.add('carousel-hint-fade'); }, 3000); } // ── Touch handling with live 60fps tracking ────────────────────────────── private onTouchStart(e: TouchEvent): void { if (e.touches.length !== 1 || this.transitioning) return; this.touch.startX = e.touches[0].clientX; this.touch.startY = e.touches[0].clientY; this.touch.startTime = Date.now(); this.touch.tracking = true; this.touch.locked = 'none'; this.touch.currentDeltaY = 0; this.touch.currentDeltaX = 0; } private onTouchMove(e: TouchEvent): void { if (!this.touch.tracking || this.transitioning) return; if (e.touches.length !== 1) return; const dx = e.touches[0].clientX - this.touch.startX; const dy = e.touches[0].clientY - this.touch.startY; // Lock axis on first significant movement if (this.touch.locked === 'none' && (Math.abs(dx) > 10 || Math.abs(dy) > 10)) { this.touch.locked = Math.abs(dx) > Math.abs(dy) ? 'horizontal' : 'vertical'; } if (this.touch.locked === 'vertical') { this.touch.currentDeltaY = dy; // Apply live transform for visual feedback if (!REDUCED_MOTION) { this.carouselInner.style.transition = 'none'; this.carouselInner.style.transform = `translateY(${dy}px)`; this.carouselInner.style.opacity = String(1 - Math.min(Math.abs(dy) / 400, 0.4)); } // Prevent scroll-through e.preventDefault(); } else if (this.touch.locked === 'horizontal') { if (this.metadataOpen) { // Drag metadata panel closed const clampedDx = Math.max(0, Math.min(METADATA_PANEL_WIDTH, -dx)); this.touch.currentDeltaX = clampedDx; if (!REDUCED_MOTION) { this.metadataPanel.style.transition = 'none'; this.metadataPanel.style.transform = `translateX(${METADATA_PANEL_WIDTH - clampedDx}px)`; this.carouselInner.style.transition = 'none'; this.carouselInner.style.transform = `translateX(${-METADATA_PANEL_WIDTH + clampedDx}px)`; } } else { this.touch.currentDeltaX = dx; if (!REDUCED_MOTION) { // Peek metadata panel const reveal = Math.max(0, Math.min(METADATA_PANEL_WIDTH, dx)); const ratio = reveal / METADATA_PANEL_WIDTH; this.metadataPanel.style.transition = 'none'; this.metadataPanel.style.transform = `translateX(${METADATA_PANEL_WIDTH - reveal}px)`; this.carouselInner.style.transition = 'none'; this.carouselInner.style.transform = `translateX(${-reveal}px)`; this.metadataPanel.style.opacity = String(ratio); } } } } private onTouchEnd(_e: TouchEvent): void { if (!this.touch.tracking) return; this.touch.tracking = false; const dy = this.touch.currentDeltaY; const dx = this.touch.currentDeltaX; const dt = Date.now() - this.touch.startTime; const velocityY = Math.abs(dy) / Math.max(dt, 1); const velocityX = Math.abs(dx) / Math.max(dt, 1); // Restore transitions this.carouselInner.style.transition = ''; this.carouselInner.style.transform = ''; this.carouselInner.style.opacity = ''; this.metadataPanel.style.transition = ''; this.metadataPanel.style.opacity = ''; if (this.touch.locked === 'vertical') { const shouldAdvance = Math.abs(dy) > SWIPE_THRESHOLD || velocityY > VELOCITY_THRESHOLD; if (shouldAdvance) { if (dy < 0 && this.currentIndex < this.playlist.matches.length - 1) { this.advanceTo(this.currentIndex + 1); } else if (dy > 0 && this.currentIndex > 0) { this.advanceTo(this.currentIndex - 1); } else { // At boundary — snap back this.snapBack(); } } else { // Not enough — snap back this.snapBack(); } } else if (this.touch.locked === 'horizontal') { if (this.metadataOpen) { const shouldClose = dx > SWIPE_THRESHOLD || velocityX > VELOCITY_THRESHOLD; if (shouldClose) { this.closeMetadata(); } else { this.openMetadata(); // snap back to open } } else { const shouldOpen = dx > SWIPE_THRESHOLD || velocityX > VELOCITY_THRESHOLD; if (shouldOpen) { this.openMetadata(); } else { this.closeMetadata(); // snap back to closed } } } this.touch.currentDeltaY = 0; this.touch.currentDeltaX = 0; } private onTouchCancel(): void { this.touch.tracking = false; this.carouselInner.style.transition = ''; this.carouselInner.style.transform = ''; this.carouselInner.style.opacity = ''; this.metadataPanel.style.transition = ''; this.metadataPanel.style.opacity = ''; this.touch.currentDeltaY = 0; this.touch.currentDeltaX = 0; } private snapBack(): void { // CSS transition handles the snap-back animation this.carouselInner.style.transform = ''; this.carouselInner.style.opacity = ''; } // ── Card loading ───────────────────────────────────────────────────────── private async loadCard(index: number): Promise { const match = this.playlist.matches[index]; if (!match) return; // Update header this.headerBar.innerHTML = ` ${escapeHtml(this.playlist.title)} ${index + 1} of ${this.playlist.matches.length} `; // Update score bar with placeholder this.scoreBar.innerHTML = ` Loading... `; // Reset metadata panel this.metadataOpen = false; this.metadataPanel.style.transform = `translateX(${METADATA_PANEL_WIDTH}px)`; this.metadataPanel.style.opacity = '0'; this.carouselInner.classList.remove('carousel-shifted'); this.updateMetadataContent(match, null); // Hide countdown ring this.hideCountdownRing(); // Clear auto-advance timer this.clearAutoAdvance(); // Fetch replay let replay = this.preloadedReplays.get(index); if (!replay) { try { replay = await this.fetchReplay(match.match_id); } catch { this.scoreBar.innerHTML = `Failed to load`; return; } } if (!this.viewer) return; // Load into viewer this.viewer.loadReplay(replay); // Set up director mode for auto-play const densities = computeAllDensities(replay); this.directorSchedule = computeSpeedSchedule(densities, 30 as DurationPreset); this.directorState = createDirectorState(); this.directorState.enabled = true; this.viewer.setDirectorMode(true); // Start playing this.viewer.togglePlay(); this.startDirectorTick(); // Update score bar this.updateScoreBar(match, replay); // Update event hint this.updateEventHint(replay); // Update metadata panel with full info this.updateMetadataContent(match, replay); // Preload next replay this.preloadNext(index + 1); } private async fetchReplay(matchId: string): Promise { const urls = [ `${R2_BASE}/replays/${matchId}.json.gz`, `${B2_FALLBACK}/replays/${matchId}.json.gz`, ]; for (const url of urls) { try { const resp = await fetch(url); if (resp.ok) return await resp.json(); } catch { /* try next */ } } const resp = await fetch(`/replays/${matchId}.json.gz`); if (!resp.ok) throw new Error(`Failed to fetch replay ${matchId}`); return resp.json(); } private preloadNext(index: number): void { if (index >= this.playlist.matches.length) return; if (this.preloadedReplays.has(index)) return; const matchId = this.playlist.matches[index].match_id; this.fetchReplay(matchId) .then(r => this.preloadedReplays.set(index, r)) .catch(() => { /* preload failure is non-critical */ }); } private updateScoreBar(_match: PlaylistMatch, replay: Replay): void { const players = replay.players.map((p, i) => { const score = replay.result.scores?.[i] ?? '-'; const won = replay.result.winner === i; return `${escapeHtml(p.name)} ${score}`; }).join(' vs '); this.scoreBar.innerHTML = players; } private updateEventHint(replay: Replay): void { const events = replay.turns.reduce((count, t) => count + (t.events?.length ?? 0), 0); const icons = events > 20 ? '⚔️💎🏰' : events > 5 ? '⚔️💎' : '⚔️'; const totalTurns = replay.turns.length; const estSeconds = Math.round(totalTurns / 16); this.eventHint.innerHTML = `${icons} ~${estSeconds}s`; } private updateMetadataContent(match: PlaylistMatch, replay: Replay | null): void { const parts: string[] = []; parts.push(``); if (match.curation_tag) parts.push(``); if (replay) { parts.push(``); parts.push(``); if (replay.result.reason) parts.push(``); } if (match.completed_at) { const d = new Date(match.completed_at); parts.push(``); } parts.push(``); this.metadataPanel.innerHTML = parts.join(''); const btn = this.metadataPanel.querySelector('.carousel-meta-watch-full'); if (btn) { btn.addEventListener('click', () => { const id = (btn as HTMLElement).dataset.matchId!; this.destroy(); window.location.hash = `/watch/replay?url=/replays/${id}.json.gz`; }); } } // ── Auto-advance with countdown ring ───────────────────────────────────── private onReplayEnd(): void { if (this.currentIndex >= this.playlist.matches.length - 1) { // Last match — no auto-advance return; } const startTime = performance.now(); const duration = this.autoAdvanceDelay; // Show countdown ring this.showCountdownRing(); // Animate the countdown ring const animate = (now: number) => { const elapsed = now - startTime; const progress = Math.min(elapsed / duration, 1); // Update ring stroke-dashoffset const circle = this.countdownRing.querySelector('.carousel-countdown-circle') as SVGElement | null; if (circle) { const circumference = 2 * Math.PI * 14; // r=14 circle.style.strokeDashoffset = String(circumference * (1 - progress)); } // Update label const label = this.countdownRing.querySelector('.carousel-countdown-label'); if (label) { const remaining = Math.ceil((duration - elapsed) / 1000); label.textContent = String(Math.max(remaining, 0)); } if (progress < 1) { this.countdownAnimFrame = requestAnimationFrame(animate); } }; this.countdownAnimFrame = requestAnimationFrame(animate); // Set the actual timer this.autoAdvanceTimer = setTimeout(() => { this.hideCountdownRing(); if (this.currentIndex < this.playlist.matches.length - 1) { this.advanceTo(this.currentIndex + 1); } }, duration); // Tap anywhere on the ring area to cancel const cancelHandler = () => { this.clearAutoAdvance(); this.hideCountdownRing(); this.countdownRing.removeEventListener('click', cancelHandler); }; this.countdownRing.addEventListener('click', cancelHandler); } private showCountdownRing(): void { this.countdownRing.style.display = 'flex'; this.countdownRing.style.opacity = '1'; const circle = this.countdownRing.querySelector('.carousel-countdown-circle') as SVGElement | null; if (circle) { const circumference = 2 * Math.PI * 14; circle.style.strokeDasharray = String(circumference); circle.style.strokeDashoffset = String(circumference); } } private hideCountdownRing(): void { this.countdownRing.style.opacity = '0'; if (this.countdownAnimFrame !== null) { cancelAnimationFrame(this.countdownAnimFrame); this.countdownAnimFrame = null; } // Keep display for fade-out transition setTimeout(() => { this.countdownRing.style.display = 'none'; }, 200); } private clearAutoAdvance(): void { if (this.autoAdvanceTimer) { clearTimeout(this.autoAdvanceTimer); this.autoAdvanceTimer = null; } if (this.countdownAnimFrame !== null) { cancelAnimationFrame(this.countdownAnimFrame); this.countdownAnimFrame = null; } } // ── Metadata panel ─────────────────────────────────────────────────────── private openMetadata(): void { if (this.metadataOpen) return; this.metadataOpen = true; this.metadataPanel.style.transform = 'translateX(0)'; this.metadataPanel.style.opacity = '1'; this.carouselInner.classList.add('carousel-shifted'); } private closeMetadata(): void { if (!this.metadataOpen) return; this.metadataOpen = false; this.metadataPanel.style.transform = `translateX(${METADATA_PANEL_WIDTH}px)`; this.metadataPanel.style.opacity = '0'; this.carouselInner.classList.remove('carousel-shifted'); } // ── Card transitions ───────────────────────────────────────────────────── private advanceTo(index: number): void { if (index < 0 || index >= this.playlist.matches.length) return; this.transitioning = true; const direction = index > this.currentIndex ? 1 : -1; this.currentIndex = index; this.clearAutoAdvance(); this.hideCountdownRing(); if (REDUCED_MOTION) { // Instant swap this.stopDirectorTick(); this.loadCard(this.currentIndex).then(() => { this.transitioning = false; }); return; } // Animate out this.carouselInner.classList.add(direction > 0 ? 'carousel-exit-up' : 'carousel-exit-down'); setTimeout(() => { this.stopDirectorTick(); this.carouselInner.classList.remove('carousel-exit-up', 'carousel-exit-down'); this.carouselInner.classList.add(direction > 0 ? 'carousel-enter-up' : 'carousel-enter-down'); this.loadCard(this.currentIndex).then(() => { setTimeout(() => { this.carouselInner.classList.remove('carousel-enter-up', 'carousel-enter-down'); this.transitioning = false; }, TRANSITION_MS); }); }, TRANSITION_MS / 2); } // ── Director tick ──────────────────────────────────────────────────────── private startDirectorTick(): void { this.stopDirectorTick(); const tick = () => { if (!this.directorState.enabled || !this.viewer) return; const now = performance.now(); const turn = this.viewer.getTurn(); const ms = tickDirectorSpeed(this.directorState, this.directorSchedule, turn, now); this.viewer.setDirectorSpeed(ms); this.directorAnimFrame = requestAnimationFrame(tick); }; this.directorAnimFrame = requestAnimationFrame(tick); } private stopDirectorTick(): void { if (this.directorAnimFrame !== null) { cancelAnimationFrame(this.directorAnimFrame); this.directorAnimFrame = null; } } // ── Cleanup ────────────────────────────────────────────────────────────── destroy(): void { this.stopDirectorTick(); this.clearAutoAdvance(); if (this.viewer) { this.viewer.pause(); this.viewer.destroy(); } this.styleEl.remove(); this.overlay.remove(); document.body.style.overflow = ''; this.onClose(); } } // ── HTML template ────────────────────────────────────────────────────────── const CAROUSEL_HTML = ` `; // ── CSS ──────────────────────────────────────────────────────────────────── const CAROUSEL_CSS = ` .carousel-overlay { position: fixed; inset: 0; z-index: 1000; background: #000; display: flex; flex-direction: column; animation: carousel-fade-in 200ms ease-out; } @keyframes carousel-fade-in { from { opacity: 0; } to { opacity: 1; } } .carousel-container { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; overflow: hidden; } .carousel-header { position: absolute; top: 0; left: 0; right: 0; z-index: 10; display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%); color: rgba(255,255,255,0.85); font-size: 0.8rem; pointer-events: none; } .carousel-playlist-name { font-weight: 600; } .carousel-counter { opacity: 0.7; } .carousel-card { position: relative; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; transition: transform ${TRANSITION_MS}ms ease-in-out, opacity ${TRANSITION_MS}ms ease-in-out; will-change: transform, opacity; -webkit-user-select: none; user-select: none; } .carousel-card.carousel-shifted { transform: translateX(-${METADATA_PANEL_WIDTH}px); } .carousel-canvas { width: 100%; height: 100%; object-fit: contain; display: block; touch-action: none; } .carousel-score-bar { position: absolute; bottom: 80px; left: 0; right: 0; z-index: 10; text-align: center; padding: 8px 16px; background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%); color: rgba(255,255,255,0.9); font-size: 0.95rem; font-weight: 600; pointer-events: none; } .carousel-vs { color: rgba(255,255,255,0.4); margin: 0 6px; font-size: 0.8rem; } .carousel-winner { color: #22c55e; } .carousel-score-loading { opacity: 0.5; font-weight: 400; } .carousel-event-hint { position: absolute; bottom: 60px; left: 0; right: 0; z-index: 10; text-align: center; padding: 4px 16px; color: rgba(255,255,255,0.5); font-size: 0.75rem; pointer-events: none; } /* ── Countdown ring (auto-advance indicator) ── */ .carousel-countdown-ring { position: absolute; bottom: 120px; left: 50%; transform: translateX(-50%); z-index: 15; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; transition: opacity 200ms ease-out; cursor: pointer; } .carousel-countdown-svg { position: absolute; inset: 0; width: 100%; height: 100%; } .carousel-countdown-bg { /* static background ring */ } .carousel-countdown-circle { transition: stroke-dashoffset 0.1s linear; } .carousel-countdown-label { position: relative; z-index: 1; color: rgba(255,255,255,0.9); font-size: 0.85rem; font-weight: 700; font-family: monospace; pointer-events: none; } /* ── Swipe hint ── */ .carousel-swipe-hint { position: absolute; bottom: 24px; left: 0; right: 0; z-index: 10; text-align: center; color: rgba(255,255,255,0.35); font-size: 0.75rem; padding: 8px; transition: opacity 1s ease-out; pointer-events: none; } .carousel-hint-fade { opacity: 0; } /* ── Metadata panel (revealed on horizontal swipe) ── */ .carousel-metadata-panel { position: absolute; top: 0; right: 0; bottom: 0; width: ${METADATA_PANEL_WIDTH}px; z-index: 20; background: rgba(15, 15, 25, 0.95); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); padding: 60px 16px 16px; transform: translateX(${METADATA_PANEL_WIDTH}px); opacity: 0; transition: transform ${TRANSITION_MS}ms ease-in-out, opacity ${TRANSITION_MS}ms ease-in-out; color: rgba(255,255,255,0.85); font-size: 0.85rem; overflow-y: auto; -webkit-overflow-scrolling: touch; } .carousel-meta-title { font-size: 1rem; font-weight: 600; margin-bottom: 8px; } .carousel-meta-tag { font-size: 0.7rem; color: #94a3b8; font-style: italic; margin-bottom: 12px; } .carousel-meta-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.08); } .carousel-meta-row span:first-child { color: rgba(255,255,255,0.5); } .carousel-meta-watch-full { display: block; width: 100%; margin-top: 16px; padding: 10px; background: #3b82f6; color: white; border: none; border-radius: 6px; font-size: 0.85rem; font-weight: 600; cursor: pointer; text-align: center; } .carousel-meta-watch-full:active { opacity: 0.8; } /* ── Close button ── */ .carousel-close-btn { position: absolute; top: 12px; right: 12px; z-index: 30; width: 36px; height: 36px; border-radius: 50%; background: rgba(0,0,0,0.5); border: none; color: rgba(255,255,255,0.8); font-size: 1.2rem; display: flex; align-items: center; justify-content: center; cursor: pointer; } .carousel-close-btn:active { background: rgba(0,0,0,0.8); } /* ── Exit/enter animations ── */ .carousel-exit-up { animation: carousel-slide-out-up ${TRANSITION_MS}ms ease-in forwards; } .carousel-exit-down { animation: carousel-slide-out-down ${TRANSITION_MS}ms ease-in forwards; } .carousel-enter-up { animation: carousel-slide-in-up ${TRANSITION_MS}ms ease-out forwards; } .carousel-enter-down { animation: carousel-slide-in-down ${TRANSITION_MS}ms ease-out forwards; } @keyframes carousel-slide-out-up { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-100%); opacity: 0; } } @keyframes carousel-slide-out-down { from { transform: translateY(0); opacity: 1; } to { transform: translateY(100%); opacity: 0; } } @keyframes carousel-slide-in-up { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } @keyframes carousel-slide-in-down { from { transform: translateY(-100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } /* ── Reduced motion ── */ @media (prefers-reduced-motion: reduce) { .carousel-overlay { animation: none; } .carousel-card { transition: none !important; } .carousel-metadata-panel { transition: none !important; } .carousel-exit-up, .carousel-exit-down, .carousel-enter-up, .carousel-enter-down { animation: none !important; } } `;