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:
shakespeare.diy 2026-09-09 12:19:17 +07:00
parent a12342f7d1
commit 8d7b4f914b
2 changed files with 196 additions and 85 deletions

View file

@ -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>
);

View file

@ -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); }