Smooth screen transitions: new screen waits hidden until its data arrives, no half-built flash

- Navigator stage: the next screen mounts hidden under the current one and is revealed only when its
  first data has arrived (ScreenGate held by useAsync/useGateHold), at most 450 ms; a thin top bar shows
  after 120 ms of waiting. Push slides from the left, back from the right, tabs only fade.
- Session cache (stale-while-revalidate) for screen GETs: revisited screens draw instantly.
- Home map and stats are fetched during the splash, so the first screen arrives complete.
- Entrance pop-ins (mascot, islands, lesson cards) run on first view only, not on every revisit.
- Shop status card shows placeholders instead of a wrong «نداری» while loading.
- Re-tapping the open tab scrolls to top instead of reloading; sheets close with a swipe down.
- Quiz: «۱ از ۴» instead of «۱/۴» (read as a fraction), varied praise, «N تا پشتِ هم درست»,
  growth-mindset copy after a weak round, zero-reward tiles hidden.
- Streak: a concrete next step when the streak is 0 or today is still empty.

Checked in headless Chrome against the local backend (frame captures at 150 ms and 700 ms latency,
rapid tab tapping, full quiz run). Not checked on a real phone or iOS Safari.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 17:55:47 +03:30
parent 19ca3d7de1
commit eafee76a93
24 changed files with 463 additions and 89 deletions

View File

@@ -1,4 +1,4 @@
import { memo, useMemo } from 'react';
import { memo, useEffect, useMemo, useRef } from 'react';
import { motion } from 'motion/react';
import { SEYAR_INFO, SEYAR_SVG, type SeyarChar, type SeyarMood } from './seyar';
@@ -13,13 +13,20 @@ interface Props {
function SeyarMascotBase({ char, mood = 'idle', size = 100, bob = false, className }: Props) {
const svg = useMemo(() => SEYAR_SVG[char](mood), [char, mood]);
// ظاهر شدن با فنر فقط وقتی حالت یا شخصیت عوض می‌شود؛ روی هر صفحه‌ی تازه، ماسکوت دیر از صفر بزرگ
// می‌شد و بعد از رسیدنِ صفحه یک «پرش»ِ جدا می‌ساخت.
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
}, []);
return (
<motion.div
key={char + mood}
role="img"
aria-label={SEYAR_INFO[char].name}
className={className}
initial={{ scale: 0.8, opacity: 0 }}
// نه `initial={false}`: آن انیمیشنِ تکرارشونده‌ی bob را هم از کار می‌انداخت (همان باگِ ۸۸۷۹b42).
initial={mounted.current ? { scale: 0.8, opacity: 0 } : { scale: 1, opacity: 1 }}
animate={{ scale: 1, opacity: 1, y: bob ? [0, -6, 0] : 0 }}
transition={
bob