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 />
|
<PWAInstallPrompt />
|
||||||
<BetaDisclaimerDialog />
|
<BetaDisclaimerDialog />
|
||||||
<Suspense>
|
<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>
|
</Suspense>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</NWCProvider>
|
</NWCProvider>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,11 @@ import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
const SESSION_KEY = 'splash-shown-session';
|
const SESSION_KEY = 'splash-shown-session';
|
||||||
const MIN_MS = 650; // don't flash-and-vanish on instant loads
|
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;
|
const FADE_MS = 320;
|
||||||
|
|
||||||
// Skater-voiced status lines that rotate while we warm up.
|
// Skater-voiced status lines that rotate while we warm up.
|
||||||
|
|
@ -56,14 +60,22 @@ export function SplashScreen() {
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [visible]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
|
|
||||||
let done = false;
|
let done = false;
|
||||||
|
let graceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
if (done) return;
|
if (done) return;
|
||||||
done = true;
|
done = true;
|
||||||
|
if (graceTimer) clearTimeout(graceTimer);
|
||||||
const elapsed = Date.now() - mountedAt.current;
|
const elapsed = Date.now() - mountedAt.current;
|
||||||
const wait = Math.max(0, MIN_MS - elapsed);
|
const wait = Math.max(0, MIN_MS - elapsed);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -79,15 +91,24 @@ export function SplashScreen() {
|
||||||
}, wait);
|
}, wait);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Relays reported warm → dismiss early (after the minimum).
|
// Content is here → reveal immediately (a populated landing).
|
||||||
const onWarm = () => finish();
|
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);
|
window.addEventListener('skatespots:relays-warm', onWarm);
|
||||||
|
|
||||||
// Hard cap so we never trap the user.
|
// Hard cap so we never trap the user.
|
||||||
const cap = setTimeout(finish, MAX_MS);
|
const cap = setTimeout(finish, MAX_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('skatespots:content-ready', onContent);
|
||||||
window.removeEventListener('skatespots:relays-warm', onWarm);
|
window.removeEventListener('skatespots:relays-warm', onWarm);
|
||||||
|
if (graceTimer) clearTimeout(graceTimer);
|
||||||
clearTimeout(cap);
|
clearTimeout(cap);
|
||||||
};
|
};
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,16 @@
|
||||||
100% { transform: scale(1.6); opacity: 0; }
|
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 ── */
|
/* ── Cold-start splash screen ── */
|
||||||
@keyframes splashPulse {
|
@keyframes splashPulse {
|
||||||
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 12px rgba(255,0,64,0.4)); }
|
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 followCount = followingList?.length ?? 0;
|
||||||
const { data: spots, isLoading: spotsLoading } = useSkateSpots(2000);
|
const { data: spots, isLoading: spotsLoading } = useSkateSpots(2000);
|
||||||
const { data: shops, isLoading: shopsLoading } = useShops(500);
|
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: marketplaceItems, isLoading: marketplaceLoading } = useMarketplaceItems(200);
|
||||||
const { data: tours, isLoading: toursLoading } = useTours(100);
|
const { data: tours, isLoading: toursLoading } = useTours(100);
|
||||||
// Upcoming events for the (opt-in) Events map layer
|
// Upcoming events for the (opt-in) Events map layer
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue