Elevate splash to premium, choreographed launch (1.4.2)
Rebuilt the cold-start splash to a best-in-class standard: - Determinate progress bar driven by real milestones (mount creep → relays-warm nudge → 100% on ready) with a sliding shine highlight — reads as genuine loading, not an infinite spinner. - Choreographed staggered entrance (logo → wordmark → bar → status rise in sequence) on an expressive ease curve. - Signature logo treatment: rotating red→lime conic halo ring + a gentle breathing pulse, over a dual breathing glow and a masked skate-map grid texture for depth. - Satisfying "ready" beat: bar fills to 100%, status swaps to a lime check "Ready", then a soft scale-up crossfade reveals the app. - 8px-grid spacing, refined tracking/typography. - Fully respects prefers-reduced-motion (all motion gated) and safe-area insets on all four edges; identical on phone and desktop; still capped hard at 2.6s so it never traps the user. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
a12342f7d1
commit
8d7b4f914b
2 changed files with 196 additions and 85 deletions
|
|
@ -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 (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-black overflow-hidden"
|
||||
className="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-[#050505] overflow-hidden"
|
||||
style={{
|
||||
opacity: leaving ? 0 : 1,
|
||||
transition: `opacity ${FADE_MS}ms ease-out`,
|
||||
transform: leaving && !prefersReducedMotion ? 'scale(1.04)' : 'scale(1)',
|
||||
transition: `opacity ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1), transform ${FADE_MS}ms cubic-bezier(0.4,0,0.2,1)`,
|
||||
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||
paddingLeft: 'env(safe-area-inset-left, 0px)',
|
||||
paddingRight: 'env(safe-area-inset-right, 0px)',
|
||||
}}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="Loading SkateSpots"
|
||||
aria-label={ready ? 'SkateSpots ready' : 'Loading SkateSpots'}
|
||||
>
|
||||
{/* Ambient neon glow behind the mark */}
|
||||
{/* Fine grid texture — subtle depth, like a skate map underlay */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 h-[420px] w-[420px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[100px]"
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.04]"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle, rgba(255,0,64,0.28) 0%, rgba(204,255,0,0.08) 45%, transparent 70%)',
|
||||
opacity: 0.9,
|
||||
backgroundImage:
|
||||
'linear-gradient(#fff 1px, transparent 1px), linear-gradient(90deg, #fff 1px, transparent 1px)',
|
||||
backgroundSize: '38px 38px',
|
||||
maskImage: 'radial-gradient(ellipse at center, #000 30%, transparent 72%)',
|
||||
WebkitMaskImage: 'radial-gradient(ellipse at center, #000 30%, transparent 72%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Logo + wordmark */}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<div
|
||||
className={
|
||||
prefersReducedMotion
|
||||
? 'relative'
|
||||
: 'relative animate-[splashPulse_1.6s_ease-in-out_infinite]'
|
||||
}
|
||||
>
|
||||
<img
|
||||
src="/images/icon-1024.png"
|
||||
alt="SkateSpots"
|
||||
className="h-20 w-20 rounded-2xl shadow-[0_0_40px_rgba(255,0,64,0.5)]"
|
||||
{/* Dual breathing glow — red core with a lime undertone */}
|
||||
<div
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 h-[520px] w-[520px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-[110px]"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle, rgba(255,0,64,0.30) 0%, rgba(204,255,0,0.07) 46%, transparent 70%)',
|
||||
...anim('splashGlow 3.4s ease-in-out infinite'),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex flex-col items-center px-8">
|
||||
{/* Logo in a glowing conic halo ring */}
|
||||
<div className="relative" style={anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both')}>
|
||||
{/* Rotating conic ring */}
|
||||
<div
|
||||
className="absolute -inset-3 rounded-[26px]"
|
||||
style={{
|
||||
background:
|
||||
'conic-gradient(from 0deg, transparent 0deg, #ff0040 90deg, #ccff00 180deg, transparent 300deg)',
|
||||
opacity: ready ? 0.9 : 0.55,
|
||||
filter: 'blur(6px)',
|
||||
transition: 'opacity 400ms ease',
|
||||
...anim('splashSpin 3.2s linear infinite'),
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/* Mask ring interior so only a thin halo shows */}
|
||||
<div className="absolute -inset-1 rounded-[22px] bg-[#050505]" aria-hidden="true" />
|
||||
<div style={anim('splashPulse 2.2s ease-in-out infinite')} className="relative">
|
||||
<img
|
||||
src="/images/icon-1024.png"
|
||||
alt="SkateSpots"
|
||||
className="relative h-24 w-24 rounded-[20px] shadow-[0_0_50px_rgba(255,0,64,0.55)]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Wordmark */}
|
||||
<h1
|
||||
className="font-display neon-red mt-5 text-3xl tracking-wide"
|
||||
style={{ textShadow: '0 0 12px #ff0040, 0 0 24px #ff0040' }}
|
||||
className="font-display neon-red mt-7 text-4xl tracking-[0.06em]"
|
||||
style={{
|
||||
textShadow: '0 0 14px #ff0040, 0 0 30px rgba(255,0,64,0.6)',
|
||||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||
animationDelay: prefersReducedMotion ? undefined : '120ms',
|
||||
}}
|
||||
>
|
||||
SKATESPOTS
|
||||
</h1>
|
||||
|
||||
{/* Progress sweep */}
|
||||
<div className="mt-6 h-1 w-44 overflow-hidden rounded-full bg-white/10">
|
||||
{prefersReducedMotion ? (
|
||||
<div className="h-full w-1/2 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ccff00]" />
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-1/2 rounded-full bg-gradient-to-r from-[#ff0040] to-[#ccff00]"
|
||||
style={{ animation: 'splashSweep 1.05s ease-in-out infinite' }}
|
||||
/>
|
||||
)}
|
||||
{/* Determinate progress bar with a moving shine */}
|
||||
<div
|
||||
className="relative mt-8 h-1.5 w-52 overflow-hidden rounded-full bg-white/[0.06] ring-1 ring-white/[0.05]"
|
||||
style={{
|
||||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||
animationDelay: prefersReducedMotion ? undefined : '220ms',
|
||||
}}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(progress)}
|
||||
>
|
||||
<div
|
||||
className="relative h-full rounded-full bg-gradient-to-r from-[#ff0040] via-[#ff0040] to-[#ccff00]"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
transition: 'width 420ms cubic-bezier(0.4,0,0.2,1)',
|
||||
boxShadow: '0 0 12px rgba(255,0,64,0.6)',
|
||||
}}
|
||||
>
|
||||
{/* Sliding shine highlight */}
|
||||
{!prefersReducedMotion && (
|
||||
<span
|
||||
className="absolute inset-y-0 w-1/3 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, transparent, rgba(255,255,255,0.55), transparent)',
|
||||
animation: 'splashShine 1.4s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rotating status line */}
|
||||
<p
|
||||
key={statusIndex}
|
||||
className="mt-4 font-mono text-[11px] uppercase tracking-[0.2em] text-gray-500"
|
||||
style={prefersReducedMotion ? undefined : { animation: 'splashFade 420ms ease-out' }}
|
||||
{/* Status line — swaps to a confident "Ready" with a check when done */}
|
||||
<div
|
||||
className="mt-5 flex h-4 items-center gap-2"
|
||||
style={{
|
||||
...anim('splashRise 620ms cubic-bezier(0.22,1,0.36,1) both'),
|
||||
animationDelay: prefersReducedMotion ? undefined : '320ms',
|
||||
}}
|
||||
>
|
||||
{STATUS_LINES[statusIndex]}
|
||||
</p>
|
||||
{ready ? (
|
||||
<span
|
||||
className="flex items-center gap-1.5 font-mono text-[11px] uppercase tracking-[0.28em] text-[#ccff00]"
|
||||
style={anim('splashFade 300ms ease-out')}
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M20 6L9 17l-5-5"
|
||||
stroke="#ccff00"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
Ready
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
key={statusIndex}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.28em] text-gray-500"
|
||||
style={anim('splashFade 360ms ease-out')}
|
||||
>
|
||||
{STATUS_LINES[statusIndex]}
|
||||
{!prefersReducedMotion && <span className="ml-0.5 animate-pulse">…</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -319,12 +319,23 @@
|
|||
|
||||
/* ── Cold-start splash screen ── */
|
||||
@keyframes splashPulse {
|
||||
0%, 100% { transform: scale(1); filter: drop-shadow(0 0 12px rgba(255,0,64,0.4)); }
|
||||
50% { transform: scale(1.06); filter: drop-shadow(0 0 24px rgba(255,0,64,0.7)); }
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.04); }
|
||||
}
|
||||
@keyframes splashSweep {
|
||||
0% { transform: translateX(-120%); }
|
||||
100% { transform: translateX(240%); }
|
||||
@keyframes splashGlow {
|
||||
0%, 100% { opacity: 0.7; transform: translate(-50%, -50%) scale(1); }
|
||||
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.08); }
|
||||
}
|
||||
@keyframes splashSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@keyframes splashRise {
|
||||
from { opacity: 0; transform: translateY(14px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes splashShine {
|
||||
0% { transform: translateX(-160%); }
|
||||
100% { transform: translateX(420%); }
|
||||
}
|
||||
@keyframes splashFade {
|
||||
from { opacity: 0; transform: translateY(3px); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue