SkateSpots/src/components/SplashScreen.tsx
shakespeare.diy 8d7b4f914b 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>
2026-09-09 12:19:17 +07:00

289 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
/**
* 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 and the first spots stream
* in underneath. It NEVER traps the user:
*
* - 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 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 = 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
const STATUS_LINES = [
'Waking the network',
'Tuning the relays',
'Pulling in spots',
'Dropping in',
];
export function SplashScreen() {
const [visible, setVisible] = useState(() => {
try {
return sessionStorage.getItem(SESSION_KEY) !== 'true';
} catch {
return true;
}
});
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 (paused once ready — the label swaps to "Ready").
useEffect(() => {
if (!visible || ready) return;
const id = setInterval(() => {
setStatusIndex((i) => (i + 1) % STATUS_LINES.length);
}, 640);
return () => clearInterval(id);
}, [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.
// 2. Relays connected (`relays-warm`) → short grace, then reveal.
// 3. Hard cap (MAX_MS) → always dismiss, even offline. Never traps anyone.
// All paths respect the MIN_MS floor, then play the 100%→hold→fade sequence.
useEffect(() => {
if (!visible) return;
let done = false;
let graceTimer: ReturnType<typeof setTimeout> | 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(() => {
// Land on 100% + a satisfying "ready" beat, then crossfade out.
setReady(true);
setProgress(100);
setTimeout(() => {
setLeaving(true);
setTimeout(() => {
try {
sessionStorage.setItem(SESSION_KEY, 'true');
} catch {
// ignore private-mode storage failures
}
setVisible(false);
}, FADE_MS);
}, READY_HOLD_MS);
}, wait);
};
const onContent = () => finish();
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);
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]);
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-[#050505] overflow-hidden"
style={{
opacity: leaving ? 0 : 1,
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={ready ? 'SkateSpots ready' : 'Loading SkateSpots'}
>
{/* Fine grid texture — subtle depth, like a skate map underlay */}
<div
className="pointer-events-none absolute inset-0 opacity-[0.04]"
style={{
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%)',
}}
/>
{/* 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-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>
{/* 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>
{/* 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',
}}
>
{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>
);
}
export default SplashScreen;