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:
shakespeare.diy 2026-09-09 12:10:47 +07:00
parent f41af8f1ca
commit a12342f7d1
4 changed files with 50 additions and 5 deletions

View file

@ -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>

View file

@ -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]);

View file

@ -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)); }

View file

@ -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