forked from Tadrisino/MathApp_frontend
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:
@@ -6,6 +6,7 @@ import { ZigzagPath, zigzagPositions } from '@/components/ZigzagPath';
|
||||
import { iconFor } from '@/components/NodeIcon';
|
||||
import { shade } from '@/lib/color';
|
||||
import { MathText } from '@/lib/MathText';
|
||||
import { useFirstVisit } from '@/lib/firstVisit';
|
||||
|
||||
const NODE_SIZE = 76;
|
||||
const SPACING = 128;
|
||||
@@ -26,6 +27,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
onStart: (node: HomeNode) => void;
|
||||
}>(function IslandPath({ nodes, color, currentId, onOpen, onStart }, currentRef) {
|
||||
const points = zigzagPositions(nodes.length, SPACING, TOP);
|
||||
const intro = useFirstVisit(`islands:${nodes[0]?.id ?? ''}`);
|
||||
const height = points.length ? points[points.length - 1].y + 80 : 120;
|
||||
|
||||
return (
|
||||
@@ -40,7 +42,7 @@ export const IslandPath = forwardRef<HTMLDivElement, {
|
||||
<motion.div
|
||||
key={node.id}
|
||||
ref={current ? currentRef : undefined}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
initial={intro ? { opacity: 0, scale: 0.7 } : { opacity: 1, scale: 1 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: Math.min(i, 8) * 0.04, type: 'spring', stiffness: 220, damping: 16 }}
|
||||
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user