Splash: reveal on real content + graceful shell fade-in (1.4.1)
Make the landing feel "already loaded" instead of dismissing on bare connection: - MapPage fires `skatespots:content-ready` once the first spots query settles. The splash now dismisses on that signal (populated reveal), falling back to a short grace window after `relays-warm` for non-map cold-start routes, and still capped hard at 2.2s so it never traps. - App shell eases in under the departing splash via a pure-opacity `appReveal` animation (no translate, so a full-bleed map never shifts), respecting prefers-reduced-motion. Result is a smooth crossfade onto content that's already there rather than a pop-in. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
f41af8f1ca
commit
a12342f7d1
4 changed files with 50 additions and 5 deletions
|
|
@ -68,7 +68,11 @@ export function App() {
|
|||
<PWAInstallPrompt />
|
||||
<BetaDisclaimerDialog />
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
{/* Gentle opacity reveal so content eases in under the
|
||||
departing splash (pure opacity — never shifts the map). */}
|
||||
<div className="animate-app-reveal motion-reduce:animate-none">
|
||||
<AppRouter />
|
||||
</div>
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</NWCProvider>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,11 @@ import { useEffect, useRef, useState } from 'react';
|
|||
|
||||
const SESSION_KEY = 'splash-shown-session';
|
||||
const MIN_MS = 650; // don't flash-and-vanish on instant loads
|
||||
const MAX_MS = 1600; // hard cap — never block on a slow/offline network
|
||||
// Once relays are connected we allow content a short grace window to arrive so
|
||||
// the reveal lands on a populated map. If content shows up first, we dismiss
|
||||
// immediately. Either way MAX_MS is the absolute ceiling.
|
||||
const WARM_GRACE_MS = 550; // extra wait AFTER connection for content to stream in
|
||||
const MAX_MS = 2200; // hard cap — never block on a slow/offline network
|
||||
const FADE_MS = 320;
|
||||
|
||||
// Skater-voiced status lines that rotate while we warm up.
|
||||
|
|
@ -56,14 +60,22 @@ export function SplashScreen() {
|
|||
return () => clearInterval(id);
|
||||
}, [visible]);
|
||||
|
||||
// Dismissal logic: warm-signal OR max timeout, always respecting the minimum.
|
||||
// Dismissal logic. Priority:
|
||||
// 1. Real content arrived (`content-ready`) → reveal a populated app now.
|
||||
// 2. Relays connected (`relays-warm`) → wait a short grace for content,
|
||||
// then reveal regardless (covers cold-start routes that aren't the map).
|
||||
// 3. Hard cap (MAX_MS) → always dismiss, even offline. Never traps anyone.
|
||||
// All paths still respect the MIN_MS floor to avoid a jarring flash.
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
let done = false;
|
||||
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (graceTimer) clearTimeout(graceTimer);
|
||||
const elapsed = Date.now() - mountedAt.current;
|
||||
const wait = Math.max(0, MIN_MS - elapsed);
|
||||
setTimeout(() => {
|
||||
|
|
@ -79,15 +91,24 @@ export function SplashScreen() {
|
|||
}, wait);
|
||||
};
|
||||
|
||||
// Relays reported warm → dismiss early (after the minimum).
|
||||
const onWarm = () => finish();
|
||||
// Content is here → reveal immediately (a populated landing).
|
||||
const onContent = () => finish();
|
||||
// Connection is up → give content a brief grace window, then reveal.
|
||||
const onWarm = () => {
|
||||
if (done || graceTimer) return;
|
||||
graceTimer = setTimeout(finish, WARM_GRACE_MS);
|
||||
};
|
||||
|
||||
window.addEventListener('skatespots:content-ready', onContent);
|
||||
window.addEventListener('skatespots:relays-warm', onWarm);
|
||||
|
||||
// Hard cap so we never trap the user.
|
||||
const cap = setTimeout(finish, MAX_MS);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('skatespots:content-ready', onContent);
|
||||
window.removeEventListener('skatespots:relays-warm', onWarm);
|
||||
if (graceTimer) clearTimeout(graceTimer);
|
||||
clearTimeout(cap);
|
||||
};
|
||||
}, [visible]);
|
||||
|
|
|
|||
|
|
@ -307,6 +307,16 @@
|
|||
100% { transform: scale(1.6); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Pure-opacity reveal for the app shell under the departing splash — no
|
||||
translate, so it never nudges a full-bleed map on reveal. */
|
||||
@keyframes appReveal {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.animate-app-reveal {
|
||||
animation: appReveal 0.5s ease-out both;
|
||||
}
|
||||
|
||||
/* ── Cold-start splash screen ── */
|
||||
@keyframes splashPulse {
|
||||
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 12px rgba(255,0,64,0.4)); }
|
||||
|
|
|
|||
|
|
@ -554,6 +554,16 @@ export default function MapPage() {
|
|||
const followCount = followingList?.length ?? 0;
|
||||
const { data: spots, isLoading: spotsLoading } = useSkateSpots(2000);
|
||||
const { data: shops, isLoading: shopsLoading } = useShops(500);
|
||||
|
||||
// Tell the cold-start splash that real content has arrived, so it can reveal
|
||||
// the map already populated (rather than dismissing on bare connection). The
|
||||
// splash still has its own hard cap, so this only ever makes the reveal feel
|
||||
// more "ready" — it can't delay dismissal.
|
||||
useEffect(() => {
|
||||
if (!spotsLoading) {
|
||||
window.dispatchEvent(new Event('skatespots:content-ready'));
|
||||
}
|
||||
}, [spotsLoading]);
|
||||
const { data: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200);
|
||||
const { data: tours, isLoading: toursLoading } = useTours(100);
|
||||
// Upcoming events for the (opt-in) Events map layer
|
||||
|
|
|
|||
Loading…
Reference in a new issue