diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index 5e8cb57..e2a15ce 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -1,41 +1,40 @@ import { useEffect, useRef, useState } from 'react'; /** - * Branded cold-start splash. + * Branded cold-start splash — a choreographed, premium launch moment. * * Nostr relays connect over WebSockets that NPool opens lazily; on a fresh load * the first content arrives only after the handshakes complete. Rather than show * a half-empty map that reads as "is it broken?", we present a short, intentional - * branded moment while the pre-warmed relays connect underneath (see - * NostrProvider's boot pre-warm). It NEVER traps the user: + * branded moment while the pre-warmed relays connect and the first spots stream + * in underneath. It NEVER traps the user: * - * - Minimum on-screen time keeps it from flickering on fast loads. - * - It dismisses the instant relays signal "warm" (`skatespots:relays-warm`). + * - Determinate progress driven by real milestones (mount → warm → content). + * - Reveals the instant real content arrives (`skatespots:content-ready`), + * with a short grace after connection (`skatespots:relays-warm`). * - A hard max timeout guarantees dismissal even on a dead/slow network. + * - A minimum on-screen time prevents a jarring flash on instant loads. * - * Shown once per browser session (sessionStorage) so returning navigation within - * the same session doesn't replay it. + * Shown once per browser session so in-session navigation never replays it. + * Fully respects prefers-reduced-motion and safe-area insets; identical on + * phone and desktop. */ const SESSION_KEY = 'splash-shown-session'; -const MIN_MS = 650; // don't flash-and-vanish on instant loads -// 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 MIN_MS = 900; // choreography needs room to breathe +const WARM_GRACE_MS = 550; // wait after connection for content to stream in +const MAX_MS = 2600; // hard cap — never block on a slow/offline network +const FADE_MS = 480; +const READY_HOLD_MS = 340; // savour the 100% "ready" beat before revealing -// Skater-voiced status lines that rotate while we warm up. const STATUS_LINES = [ - 'Waking the network…', - 'Tuning the relays…', - 'Pulling in spots…', - 'Dropping in…', + 'Waking the network', + 'Tuning the relays', + 'Pulling in spots', + 'Dropping in', ]; export function SplashScreen() { - // Only show on the first load of a session. const [visible, setVisible] = useState(() => { try { return sessionStorage.getItem(SESSION_KEY) !== 'true'; @@ -44,28 +43,41 @@ export function SplashScreen() { } }); const [leaving, setLeaving] = useState(false); + const [ready, setReady] = useState(false); const [statusIndex, setStatusIndex] = useState(0); + // Determinate progress (0–100). Creeps forward on its own, snaps toward + // milestones, and finishes at 100 the moment we're ready. + const [progress, setProgress] = useState(8); const mountedAt = useRef(Date.now()); const prefersReducedMotion = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; - // Rotate the status line for a sense of progress. + // Rotate the status line (paused once ready — the label swaps to "Ready"). useEffect(() => { - if (!visible) return; + if (!visible || ready) return; const id = setInterval(() => { setStatusIndex((i) => (i + 1) % STATUS_LINES.length); - }, 420); + }, 640); return () => clearInterval(id); - }, [visible]); + }, [visible, ready]); + + // Ambient progress creep — asymptotically approaches ~85% so it always feels + // like it's making headway without ever "completing" before we're truly ready. + useEffect(() => { + if (!visible || ready) return; + const id = setInterval(() => { + setProgress((p) => (p >= 85 ? p : p + Math.max(0.6, (85 - p) * 0.045))); + }, 90); + return () => clearInterval(id); + }, [visible, ready]); // 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). + // 1. Real content arrived (`content-ready`) → reveal a populated app. + // 2. Relays connected (`relays-warm`) → short grace, then reveal. // 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. + // All paths respect the MIN_MS floor, then play the 100%→hold→fade sequence. useEffect(() => { if (!visible) return; @@ -76,33 +88,38 @@ export function SplashScreen() { if (done) return; done = true; if (graceTimer) clearTimeout(graceTimer); + const elapsed = Date.now() - mountedAt.current; const wait = Math.max(0, MIN_MS - elapsed); + setTimeout(() => { - setLeaving(true); + // Land on 100% + a satisfying "ready" beat, then crossfade out. + setReady(true); + setProgress(100); setTimeout(() => { - try { - sessionStorage.setItem(SESSION_KEY, 'true'); - } catch { - // ignore private-mode storage failures - } - setVisible(false); - }, FADE_MS); + setLeaving(true); + setTimeout(() => { + try { + sessionStorage.setItem(SESSION_KEY, 'true'); + } catch { + // ignore private-mode storage failures + } + setVisible(false); + }, FADE_MS); + }, READY_HOLD_MS); }, wait); }; - // 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; + // Nudge progress up on connection so the bar visibly reacts to a milestone. + setProgress((p) => Math.max(p, 78)); 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 () => { @@ -115,72 +132,155 @@ export function SplashScreen() { if (!visible) return null; + const anim = (a: string): React.CSSProperties => + prefersReducedMotion ? {} : { animation: a }; + return (
- {STATUS_LINES[statusIndex]} -
+ {ready ? ( + + + Ready + + ) : ( + + {STATUS_LINES[statusIndex]} + {!prefersReducedMotion && …} + + )} +