One static splash: app icon + name in index.html from the first frame, no companion, no timers

The first second was a blank page, then a splash with the selected companion, then the
home screen. Now index.html carries the splash (icon from the Android launcher, name,
white to match the native window background) and SplashScreen draws the same markup, so
the hand-over from HTML to React shows no change. Not run on a device.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 22:33:02 +03:30
parent 16cf5c91f7
commit 3520279143
5 changed files with 27 additions and 73 deletions

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import type { Prefetch } from '@/state/AppData';
@@ -24,22 +24,11 @@ type Flow =
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch };
/**
* اسپلش فقط وقتی نشان داده می‌شود که سرور دیرتر از SPLASH_DELAY_MS جواب بدهد؛ اگر زودتر رسید، کاربر
* اسپلش را نمی‌بیند و مستقیم به صفحه‌ی بعدی می‌رود (به‌جای چند صد میلی‌ثانیه چشمک).
* اگر یک بار نشان داده شد، دست‌کم SPLASH_MIN_MS می‌ماند تا خودش چشمک نزند.
* تا آن لحظه فقط پس‌زمینه‌ی هم‌رنگِ اسپلش دیده می‌شود، نه صفحه‌ی سفید.
*/
const SPLASH_DELAY_MS = 350;
const SPLASH_MIN_MS = 700;
/**
* جریانِ ورود: اسپلش → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
* جریانِ ورود: اسپلشِ ثابت (تا بررسیِ نشست) → (معرفی، فقط بارِ اول) → ورود → تکمیلِ پروفایل → اپ.
* خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است.
*/
export function App() {
const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
const [splashVisible, setSplashVisible] = useState(false);
const splashShownAt = useRef<number | null>(null);
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => {
@@ -53,12 +42,6 @@ export function App() {
const boot = useCallback(async () => {
setFlow({ kind: 'boot' });
splashShownAt.current = null;
setSplashVisible(false);
const timer = setTimeout(() => {
splashShownAt.current = Date.now();
setSplashVisible(true);
}, SPLASH_DELAY_MS);
let next: Flow | null = null;
if (api.auth.isSignedIn()) {
try {
@@ -72,13 +55,6 @@ export function App() {
} else {
next = store.get(KEYS.onboarded) ? { kind: 'login' } : { kind: 'onboarding' };
}
clearTimeout(timer);
// اسپلش دیده شده؟ فقط در آن صورت تا کمینه‌ی زمانش نگه دار.
const shownAt = splashShownAt.current;
if (shownAt !== null) {
const wait = SPLASH_MIN_MS - (Date.now() - shownAt);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
}
if (next) setFlow(next);
}, [enter]);
@@ -110,17 +86,17 @@ export function App() {
<MotionConfig reducedMotion="user">
<AppShell>
<ToastProvider>
<AnimatePresence mode="wait">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={flow.kind}
className="flex h-full flex-col"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
// اگر اسپلش هرگز دیده نشد، محو‌شدن هم لازم نیست (وگرنه همان چشمک از راهِ دیگر برمی‌گردد).
transition={{ duration: splashVisible || flow.kind !== 'boot' ? 0.2 : 0 }}
// اسپلش بی‌محو: با عوض‌شدنِ صفحه فوراً می‌رود و صفحه‌ی بعد خودش محو می‌شود.
transition={{ duration: flow.kind === 'boot' ? 0 : 0.2 }}
>
{flow.kind === 'boot' && <SplashScreen showContent={splashVisible} />}
{flow.kind === 'boot' && <SplashScreen />}
{flow.kind === 'bootError' && (
<div className="flex h-full flex-col justify-center">
<ErrorState error={flow.error} onRetry={() => void boot()} />

View File

@@ -1,45 +1,13 @@
import { motion } from 'motion/react';
import { SeyarMascot } from '@/mascots/SeyarMascot';
import { BrandMark } from '@/components/BrandMark';
/** نمادهای ریاضی که آرام از پایینِ صفحه بالا می‌روند؛ فقط CSS (transform/opacity)، بی‌بارِ اضافه. */
const GLYPHS = [
{ c: 'π', x: 12, d: 0 }, { c: '+', x: 28, d: 2.6 }, { c: '√', x: 46, d: 1.2 },
{ c: '÷', x: 64, d: 3.8 }, { c: '∑', x: 80, d: 0.6 }, { c: '×', x: 90, d: 4.6 },
];
/**
* تا وقتی نشستِ قبلی بررسی می‌شود نمایش داده می‌شود. `showContent=false` فقط پس‌زمینه است؛ App
* محتوا را فقط وقتی روشن می‌کند که پاسخِ سرور دیر کرده باشد.
* اسپلشِ ثابت: آیکونِ اپ و نام، بی‌انیمیشن و بی‌همراه (همراهِ انتخابی هنوز معلوم نیست و تکرارش
* بد است). عینِ `.boot-splash` در index.html است تا تحویل از HTML به React دیده نشود؛ استایلش
* هم آن‌جا است چون باید پیش از رسیدنِ CSS و JS کار کند.
*/
export function SplashScreen({ showContent }: { showContent: boolean }) {
if (!showContent) return <div className="h-full bg-rose" />;
export function SplashScreen() {
return (
<div className="relative flex h-full flex-col items-center justify-center overflow-hidden bg-rose">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2">
{GLYPHS.map((g) => (
<span key={g.c} className="rise absolute bottom-0 text-3xl font-black text-white/60" style={{ left: `${g.x}%`, animationDelay: `${g.d}s` }}>
{g.c}
</span>
))}
</div>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: 'spring', stiffness: 220, damping: 18 }}
className="relative flex flex-col items-center gap-4"
>
<div className="rounded-[32px] bg-white/15 p-6 backdrop-blur-sm">
<SeyarMascot char="sigma" mood="happy" size={130} />
</div>
<BrandMark size={44} />
<p className="text-sm text-white/80">ریاضی، قدم‌به‌قدم، از روی کتابِ درسی</p>
</motion.div>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.8, repeat: Infinity, ease: 'linear' }}
className="absolute bottom-20 h-6 w-6 rounded-full border-2 border-white/25 border-t-white"
/>
<div className="boot-splash" dir="rtl">
<img src="/app-icon.png" alt="" width={144} height={144} />
<span>تدریسینو</span>
</div>
);
}

View File

@@ -117,8 +117,6 @@
.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; }
@keyframes spark { 0%, 100% { opacity: .25; transform: scale(.7); } 50% { opacity: 1; transform: scale(1); } }
.spark { animation: spark 2.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
@keyframes rise { 0% { transform: translateY(16px); opacity: 0; } 15%, 85% { opacity: .55; } 100% { transform: translateY(-110px); opacity: 0; } }
.rise { animation: rise 7s linear infinite; will-change: transform, opacity; }
/* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */
@keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } }