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