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

@@ -27,18 +27,26 @@ interface AppData {
const AppDataContext = createContext<AppData | null>(null);
/** آنچه در اسپلش پیش از ورود به اپ گرفته شد (App.prefetchMain). */
export interface Prefetch {
home: HomeResponse | null;
summary: UserSummary | null;
}
export function AppDataProvider({
initialProfile,
grades,
prefetch,
children,
}: {
initialProfile: UserProfile;
grades: Grade[];
prefetch?: Prefetch;
children: ReactNode;
}) {
const [profile, setProfile] = useState(initialProfile);
const [summary, setSummary] = useState<UserSummary | null>(null);
const [home, setHome] = useState<HomeResponse | null>(null);
const [summary, setSummary] = useState<UserSummary | null>(prefetch?.summary ?? null);
const [home, setHome] = useState<HomeResponse | null>(prefetch?.home ?? null);
const [homeError, setHomeError] = useState<unknown>(null);
const [companion, setCompanionChar] = useState<SeyarChar>('sigma');
const [unreadCount, setUnreadCount] = useState(0);
@@ -69,8 +77,9 @@ export function AppDataProvider({
}, []);
useEffect(() => {
void refreshHome();
void refreshSummary();
// آنچه در اسپلش رسیده دوباره گرفته نمی‌شود؛ فقط کم‌ها.
if (!prefetch?.home) void refreshHome();
if (!prefetch?.summary) void refreshSummary();
void refreshUnread();
api.characters
.list()
@@ -79,6 +88,8 @@ export function AppDataProvider({
if (selected) setCompanionChar(selected);
})
.catch(() => {});
// فقط بارِ اول؛ prefetch بعد از mount عوض نمی‌شود.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshHome, refreshSummary, refreshUnread]);
const value = useMemo<AppData>(