From a12342f7d148d3a2059f16936f88636ea255cba2 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 9 Sep 2026 12:10:47 +0700 Subject: [PATCH] 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 --- src/App.tsx | 6 +++++- src/components/SplashScreen.tsx | 29 +++++++++++++++++++++++++---- src/index.css | 10 ++++++++++ src/pages/MapPage.tsx | 10 ++++++++++ 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 34f67e8..afb2218 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -68,7 +68,11 @@ export function App() { - + {/* Gentle opacity reveal so content eases in under the + departing splash (pure opacity — never shifts the map). */} +
+ +
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 642e968..5e8cb57 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -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 | 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]); diff --git a/src/index.css b/src/index.css index 8e02eb4..1966063 100644 --- a/src/index.css +++ b/src/index.css @@ -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)); } diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index fda8625..6d9da5b 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -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