Commit d369c086 authored by Mahmoud Aglan's avatar Mahmoud Aglan

juice: full animation + interaction feedback system

CSS:
- Button ripple effect (radial gradient follows finger position)
- Gold button glow pulse (breathing box-shadow)
- Gold button shimmer sweep (light streak animation)
- Game card entrance pop (staggered scale+fade per card)
- Game card icon float (subtle idle bob animation)
- Game card hover glow (radial light on press)
- Nav active indicator (animated bar slides in)
- Nav tap bounce (scale down on press)
- Streak claim celebration (pop + color change)
- Page transition (fade+slide on every screen change)
- Section stagger (each section fades in sequentially)
- Activity items slide in (staggered translateX)
- Chip selection pop (scale bounce on select)
- Input focus glow (cyan ring)
- Brand logo pulse on tap
- Online dot ping ring (expanding ripple)
- Login brand float (idle vertical bob)
- Ambient background (slow-moving radial gradients)
- Toast in/out animations
- Card shadow compression on press
- Counter tick scale animation

JS:
- Haptic feedback on every interactive element (pointerdown)
- Web Audio API synthesizer (tap, click, coin, win, error sounds)
- Ripple position tracking (--ripple-x, --ripple-y CSS vars)
- tickCounter() for animated number updates
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e620362f
......@@ -260,7 +260,271 @@ img { display: block; max-width: 100%; }
width: min(100vw - 32px, 400px); aspect-ratio: 1; margin: 0 auto;
}
/* === JUICE: BUTTON RIPPLE === */
.btn, .streak-btn, .chip, .game-card, .nav-item, .hdr-btn {
position: relative;
overflow: hidden;
}
.btn::after, .streak-btn::after, .game-card::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at var(--ripple-x, 50%) var(--ripple-y, 50%), rgba(255,255,255,0.2) 0%, transparent 60%);
opacity: 0;
transition: opacity 0.4s;
pointer-events: none;
}
.btn:active::after, .streak-btn:active::after, .game-card:active::after {
opacity: 1;
transition: opacity 0s;
}
/* === JUICE: BUTTON GLOW PULSE (gold buttons) === */
.btn-gold {
animation: btn-glow 3s ease-in-out infinite;
}
@keyframes btn-glow {
0%, 100% { box-shadow: 0 4px 16px var(--gold-glow); }
50% { box-shadow: 0 4px 28px var(--gold-glow), 0 0 60px rgba(245, 183, 49, 0.1); }
}
/* === JUICE: SHIMMER ON GOLD BUTTONS === */
.btn-gold::before {
content: '';
position: absolute;
top: 0; left: -100%; width: 60%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: shimmer 4s ease-in-out infinite;
}
@keyframes shimmer { 0%{left:-100%} 50%{left:150%} 100%{left:150%} }
/* === JUICE: CARD ENTRANCE ANIMATION === */
.game-card {
animation: card-pop 0.4s var(--ease-bounce) both;
}
.game-card:nth-child(1) { animation-delay: 0ms; }
.game-card:nth-child(2) { animation-delay: 80ms; }
.game-card:nth-child(3) { animation-delay: 160ms; }
.game-card:nth-child(4) { animation-delay: 240ms; }
@keyframes card-pop {
0% { opacity: 0; transform: scale(0.85) translateY(12px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
/* === JUICE: GAME CARD HOVER GLOW === */
.game-card-hero::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at 50% 100%, rgba(255,255,255,0.08) 0%, transparent 70%);
opacity: 0;
transition: opacity 0.3s;
}
.game-card:hover .game-card-hero::after,
.game-card:active .game-card-hero::after { opacity: 1; }
/* === JUICE: GAME CARD ICON FLOAT === */
.game-card-hero-icon {
animation: icon-float 3s ease-in-out infinite;
}
.game-card:nth-child(1) .game-card-hero-icon { animation-delay: 0s; }
.game-card:nth-child(2) .game-card-hero-icon { animation-delay: 0.5s; }
.game-card:nth-child(3) .game-card-hero-icon { animation-delay: 1s; }
.game-card:nth-child(4) .game-card-hero-icon { animation-delay: 1.5s; }
@keyframes icon-float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
/* === JUICE: NAV ACTIVE INDICATOR === */
.nav-item.active::before {
content: '';
position: absolute;
top: 0; left: 50%; transform: translateX(-50%);
width: 24px; height: 3px;
background: var(--gold);
border-radius: 0 0 var(--radius-pill) var(--radius-pill);
animation: nav-dot-in 0.3s var(--ease-bounce);
}
.nav-item { position: relative; }
@keyframes nav-dot-in { 0%{transform:translateX(-50%) scaleX(0)} 100%{transform:translateX(-50%) scaleX(1)} }
/* === JUICE: NAV TAP BOUNCE === */
.nav-item:active {
transform: scale(0.9);
transition: transform 0.1s var(--ease-bounce);
}
/* === JUICE: STREAK CLAIM CELEBRATION === */
.streak-btn.claimed {
animation: claim-pop 0.5s var(--ease-bounce);
background: var(--win);
box-shadow: 0 4px 16px rgba(52, 211, 153, 0.3);
}
@keyframes claim-pop {
0% { transform: scale(1); }
30% { transform: scale(1.15); }
60% { transform: scale(0.95); }
100% { transform: scale(1); }
}
/* === JUICE: PAGE TRANSITIONS === */
.main {
animation: page-in 0.3s var(--ease) both;
}
@keyframes page-in {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
/* === JUICE: SECTION FADE-IN ON SCROLL === */
.sec {
animation: sec-in 0.4s var(--ease) both;
}
.sec:nth-child(1) { animation-delay: 0ms; }
.sec:nth-child(2) { animation-delay: 100ms; }
.sec:nth-child(3) { animation-delay: 200ms; }
.sec:nth-child(4) { animation-delay: 300ms; }
.sec:nth-child(5) { animation-delay: 400ms; }
@keyframes sec-in {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
/* === JUICE: ACTIVITY ITEMS SLIDE IN === */
.activity-item {
animation: item-slide 0.3s var(--ease) both;
}
.activity-item:nth-child(1) { animation-delay: 50ms; }
.activity-item:nth-child(2) { animation-delay: 100ms; }
.activity-item:nth-child(3) { animation-delay: 150ms; }
.activity-item:nth-child(4) { animation-delay: 200ms; }
.activity-item:nth-child(5) { animation-delay: 250ms; }
@keyframes item-slide {
from { opacity: 0; transform: translateX(12px); }
to { opacity: 1; transform: translateX(0); }
}
/* === JUICE: CHIP SELECTION POP === */
.chip.on {
animation: chip-select 0.25s var(--ease-bounce);
}
@keyframes chip-select {
0% { transform: scale(1); }
50% { transform: scale(1.08); }
100% { transform: scale(1); }
}
/* === JUICE: INPUT FOCUS GLOW === */
.input:focus {
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.12);
border-color: var(--cyan);
}
.input {
transition: border-color 0.2s, box-shadow 0.2s;
}
/* === JUICE: STAT PILL HOVER === */
.stat-pill {
transition: transform 0.15s var(--ease-bounce), background 0.15s;
}
.stat-pill:active {
transform: scale(0.95);
background: var(--bg-3);
}
/* === JUICE: BRAND LOGO PULSE === */
.hdr-brand {
transition: text-shadow 0.3s;
}
.hdr-brand:active {
text-shadow: 0 0 20px var(--gold-glow);
}
/* === JUICE: ONLINE DOT PING === */
.game-card-dot {
position: relative;
}
.game-card-dot::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: var(--online);
animation: dot-ping 2s ease-out infinite;
opacity: 0;
}
@keyframes dot-ping {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.5); opacity: 0; }
}
/* === JUICE: LOGIN BRAND FLOAT === */
.login-brand {
animation: brand-float 4s ease-in-out infinite;
}
@keyframes brand-float {
0%, 100% { transform: translateY(0); text-shadow: 0 0 40px var(--gold-glow); }
50% { transform: translateY(-4px); text-shadow: 0 0 60px var(--gold-glow), 0 8px 32px rgba(245, 183, 49, 0.15); }
}
/* === JUICE: CARD SHADOW ON PRESS === */
.card {
transition: transform 0.15s var(--ease), box-shadow 0.15s;
}
.card:active {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* === JUICE: HEADER CURRENCY TICK === */
.hdr-stat span {
transition: transform 0.2s var(--ease-bounce);
}
.hdr-stat span.tick {
transform: scale(1.3);
color: var(--gold);
}
/* === JUICE: AMBIENT GRADIENT SHIFT ON BODY === */
body::before {
content: '';
position: fixed;
top: -50%; left: -50%;
width: 200%; height: 200%;
background: radial-gradient(ellipse at 30% 20%, rgba(245, 183, 49, 0.02) 0%, transparent 50%),
radial-gradient(ellipse at 70% 80%, rgba(0, 212, 255, 0.015) 0%, transparent 50%);
animation: ambient 20s ease-in-out infinite alternate;
pointer-events: none;
z-index: -1;
}
@keyframes ambient {
0% { transform: translate(0, 0) rotate(0deg); }
100% { transform: translate(3%, 3%) rotate(2deg); }
}
/* === JUICE: TOAST ANIMATION === */
.toast-msg {
position: fixed;
top: calc(var(--header-h) + 12px);
left: 50%; transform: translateX(-50%);
padding: 10px 20px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 13px; font-weight: 500;
box-shadow: var(--shadow);
z-index: 200;
animation: toast-in 0.3s var(--ease-bounce), toast-out 0.3s var(--ease) 2.7s forwards;
pointer-events: none;
}
.toast-msg.success { border-color: var(--win); }
.toast-msg.error { border-color: var(--loss); }
@keyframes toast-in { from{opacity:0;transform:translateX(-50%) translateY(-12px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }
@keyframes toast-out { to{opacity:0;transform:translateX(-50%) translateY(-8px)} }
/* === REDUCED MOTION === */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
body::before { display: none; }
}
......@@ -68,6 +68,50 @@ if (str_starts_with($route, 'api/')) {
<!-- Compatibility: game engines need window.App -->
<script src="/public/js/app.js"></script>
<!-- JUICE: Global interaction feedback -->
<script>
// Haptics
const H = { tap:()=>navigator.vibrate&&navigator.vibrate(8), heavy:()=>navigator.vibrate&&navigator.vibrate(25), success:()=>navigator.vibrate&&navigator.vibrate([15,40,15]), error:()=>navigator.vibrate&&navigator.vibrate([40,20,40,20,40]) };
// Audio (Web Audio API synth)
let _ac;
function beep(freq,dur,type='sine',vol=0.1){try{if(!_ac)_ac=new(window.AudioContext||window.webkitAudioContext)();if(_ac.state==='suspended')_ac.resume();const o=_ac.createOscillator(),g=_ac.createGain();o.type=type;o.frequency.value=freq;g.gain.value=vol;g.gain.exponentialRampToValueAtTime(0.001,_ac.currentTime+dur);o.connect(g).connect(_ac.destination);o.start();o.stop(_ac.currentTime+dur);}catch(e){}}
const SFX = {
tap(){beep(600,0.04,'sine',0.06)},
click(){beep(900,0.03,'sine',0.08)},
coin(){beep(1200,0.08,'sine',0.1);setTimeout(()=>beep(1500,0.08,'sine',0.08),60)},
win(){beep(523,0.12,'triangle',0.12);setTimeout(()=>beep(659,0.12,'triangle',0.12),80);setTimeout(()=>beep(784,0.2,'triangle',0.14),160)},
error(){beep(200,0.15,'square',0.06)},
};
// Global tap feedback on all interactive elements
document.addEventListener('pointerdown', function(e) {
const t = e.target.closest('.btn, .chip, .game-card, .nav-item, .hdr-btn, .streak-btn, .activity-item, .stat-pill');
if (!t) return;
// Set ripple position
const rect = t.getBoundingClientRect();
t.style.setProperty('--ripple-x', ((e.clientX - rect.left) / rect.width * 100) + '%');
t.style.setProperty('--ripple-y', ((e.clientY - rect.top) / rect.height * 100) + '%');
// Haptic
H.tap();
// Sound (only for buttons/chips/cards — not every single element)
if (t.matches('.btn, .game-card, .streak-btn')) SFX.click();
else if (t.matches('.chip')) SFX.tap();
});
// Coin counter tick animation
function tickCounter(elId, newVal) {
const el = document.getElementById(elId);
if (!el) return;
el.classList.add('tick');
el.textContent = typeof newVal === 'number' ? newVal.toLocaleString() : newVal;
setTimeout(() => el.classList.remove('tick'), 200);
}
</script>
<!-- SPA Router -->
<script>
const main = document.getElementById('main');
......@@ -177,17 +221,24 @@ defineScreen('/', async function homeScreen() {
}
}
// Streak claim
// Streak claim with juice
document.getElementById('streak-btn').onclick = async function() {
this.disabled = true;
H.tap();
const res = await App.fetch('/api/daily-reward', { method: 'POST' });
if (res && res.ok) {
App.toast('+' + res.reward + ' عملة!', 'success');
// Celebration!
SFX.coin();
H.success();
this.textContent = 'تم ✓';
this.classList.add('claimed');
document.getElementById('streak-day').textContent = 'اليوم ' + res.streak;
document.getElementById('hdr-coins').textContent = res.coins;
tickCounter('hdr-coins', res.coins);
App.toast('+' + res.reward + ' عملة!', 'success');
} else {
this.disabled = false;
SFX.error();
H.error();
App.toast(res?.error === 'already_claimed' ? 'جمعتها اليوم' : 'خطأ', 'error');
}
};
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment