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

@@ -6,13 +6,25 @@
<meta name="theme-color" content="#ED6956" /> <meta name="theme-color" content="#ED6956" />
<meta name="description" content="تدریسینو — یادگیریِ ریاضیِ پایه‌ی سوم تا دوازدهم، قدم‌به‌قدم و با دستیارِ هوشمند." /> <meta name="description" content="تدریسینو — یادگیریِ ریاضیِ پایه‌ی سوم تا دوازدهم، قدم‌به‌قدم و با دستیارِ هوشمند." />
<meta name="robots" content="noindex, nofollow" /> <meta name="robots" content="noindex, nofollow" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>تدریسینو</title> <title>تدریسینو</title>
<!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. --> <!-- تنظیماتِ زمانِ اجرا (آدرسِ API)؛ پیش از خودِ اپ بار می‌شود. -->
<script src="/config.js"></script> <script src="/config.js"></script>
<!-- اسپلشِ ثابت: همین‌جا در HTML است تا از اولین فریم دیده شود (نه صفحه‌ی سفید تا بار شدنِ JS)؛
SplashScreen در React عینِ همین را می‌کشد، پس هنگامِ تحویل چیزی نمی‌پرد. فونتِ سیستم، چون
Vazirmatn بعد از CSS می‌رسد و عوض‌شدنِ فونت خودش یک پرش است. -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preload" as="image" href="/app-icon.png" />
<style>
.boot-splash { position: fixed; inset: 0; z-index: 50; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 20px; background: #fff; }
.boot-splash img { width: 144px; height: 144px; display: block; }
.boot-splash span { font: 900 34px/1.2 system-ui, -apple-system, sans-serif; color: #1a1a2e; }
</style>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root">
<div class="boot-splash" dir="rtl"><img src="/app-icon.png" alt="" width="144" height="144" /><span>تدریسینو</span></div>
</div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

BIN
public/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

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 { AnimatePresence, MotionConfig, motion } from 'motion/react';
import { api, ApiError, http, type Grade, type UserProfile } from '@/api'; import { api, ApiError, http, type Grade, type UserProfile } from '@/api';
import type { Prefetch } from '@/state/AppData'; import type { Prefetch } from '@/state/AppData';
@@ -24,22 +24,11 @@ type Flow =
| { kind: 'main'; profile: UserProfile; grades: Grade[]; prefetch?: Prefetch }; | { 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 است. * خودِ اپ (تب‌ها و صفحه‌ها) در MainApp است.
*/ */
export function App() { export function App() {
const [flow, setFlow] = useState<Flow>({ kind: 'boot' }); const [flow, setFlow] = useState<Flow>({ kind: 'boot' });
const [splashVisible, setSplashVisible] = useState(false);
const splashShownAt = useRef<number | null>(null);
/** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */ /** پروفایل و پایه‌ها را می‌گیرد و تصمیم می‌گیرد کاربر کجا برود. */
const enter = useCallback(async () => { const enter = useCallback(async () => {
@@ -53,12 +42,6 @@ export function App() {
const boot = useCallback(async () => { const boot = useCallback(async () => {
setFlow({ kind: 'boot' }); 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; let next: Flow | null = null;
if (api.auth.isSignedIn()) { if (api.auth.isSignedIn()) {
try { try {
@@ -72,13 +55,6 @@ export function App() {
} else { } else {
next = store.get(KEYS.onboarded) ? { kind: 'login' } : { kind: 'onboarding' }; 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); if (next) setFlow(next);
}, [enter]); }, [enter]);
@@ -110,17 +86,17 @@ export function App() {
<MotionConfig reducedMotion="user"> <MotionConfig reducedMotion="user">
<AppShell> <AppShell>
<ToastProvider> <ToastProvider>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait" initial={false}>
<motion.div <motion.div
key={flow.kind} key={flow.kind}
className="flex h-full flex-col" className="flex h-full flex-col"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} 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' && ( {flow.kind === 'bootError' && (
<div className="flex h-full flex-col justify-center"> <div className="flex h-full flex-col justify-center">
<ErrorState error={flow.error} onRetry={() => void boot()} /> <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 }) { export function SplashScreen() {
if (!showContent) return <div className="h-full bg-rose" />;
return ( return (
<div className="relative flex h-full flex-col items-center justify-center overflow-hidden bg-rose"> <div className="boot-splash" dir="rtl">
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 bottom-0 h-1/2"> <img src="/app-icon.png" alt="" width={144} height={144} />
{GLYPHS.map((g) => ( <span>تدریسینو</span>
<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> </div>
); );
} }

View File

@@ -117,8 +117,6 @@
.shine { animation: shine 3.2s ease-in-out infinite; will-change: transform; } .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); } } @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; } .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). */ /* نوارِ انتظارِ میانِ دو صفحه (MainApp → HoldBar). */
@keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } } @keyframes hold-bar { 0% { transform: translateX(120%); } 100% { transform: translateX(-320%); } }