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,8 +1,11 @@
import { useEffect, type ReactNode } from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { AnimatePresence, motion, useDragControls } from 'motion/react';
import { X } from 'lucide-react';
/** برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه یا Escape بسته می‌شود. */
/**
* برگه‌ای که از پایین بالا می‌آید؛ با زدنِ پس‌زمینه، Escape، یا کشیدنِ دستگیره/سرِ برگه به پایین بسته می‌شود.
* کشیدن فقط از بالای برگه شروع می‌شود تا با اسکرولِ محتوای بلندِ برگه قاطی نشود.
*/
export function Sheet({
open,
onClose,
@@ -14,6 +17,7 @@ export function Sheet({
title?: ReactNode;
children: ReactNode;
}) {
const drag = useDragControls();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
@@ -41,10 +45,20 @@ export function Sheet({
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', stiffness: 320, damping: 32 }}
drag="y"
dragControls={drag}
dragListener={false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.9 }}
onDragEnd={(_, info) => {
if (info.offset.y > 90 || info.velocity.y > 600) onClose();
}}
>
<div className="mx-auto mb-3 h-1.5 w-10 rounded-full bg-soft" />
<div className="-mx-5 -mt-3 cursor-grab touch-none px-5 pb-3 pt-3" onPointerDown={(e) => drag.start(e)}>
<div className="mx-auto h-1.5 w-10 rounded-full bg-line" />
</div>
{title && (
<div className="mb-4 flex items-center justify-between gap-3">
<div className="mb-4 flex touch-none items-center justify-between gap-3" onPointerDown={(e) => drag.start(e)}>
<h2 className="text-base font-black text-ink">{title}</h2>
<button type="button" aria-label="بستن" onClick={onClose} className="rounded-full bg-soft p-1.5 text-muted">
<X size={16} />