apex-ui-kit 2.1.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/ApexUI-Kit/templates/AiChat/AiChat.jsx +171 -0
  2. package/dist/ApexUI-Kit/templates/AiModelPrompt/AiModelPrompt.jsx +221 -0
  3. package/dist/ApexUI-Kit/templates/AiModelPrompt/index.js +2 -0
  4. package/dist/ApexUI-Kit/templates/AiModelPrompt/models.js +15 -0
  5. package/dist/ApexUI-Kit/templates/AiPromptBox/AiPromptBox.jsx +173 -0
  6. package/dist/ApexUI-Kit/templates/AiResponse/AiResponse.jsx +172 -0
  7. package/dist/ApexUI-Kit/templates/AiThinking/AiThinking.jsx +106 -0
  8. package/dist/ApexUI-Kit/templates/AiVoiceBars/AiVoiceBars.jsx +78 -0
  9. package/dist/ApexUI-Kit/templates/AiVoiceBlob/AiVoiceBlob.jsx +105 -0
  10. package/dist/ApexUI-Kit/templates/AiVoiceOrb/AiVoiceOrb.jsx +74 -0
  11. package/dist/ApexUI-Kit/templates/AiVoiceRing/AiVoiceRing.jsx +120 -0
  12. package/dist/ApexUI-Kit/templates/AiVoiceWave/AiVoiceWave.jsx +107 -0
  13. package/dist/ApexUI-Kit/templates/AnimatedButton/AnimatedButton.jsx +251 -0
  14. package/dist/ApexUI-Kit/templates/AnimatedSwitch/AnimatedSwitch.jsx +80 -0
  15. package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +107 -0
  16. package/dist/ApexUI-Kit/templates/AuroraBackground/AuroraBackground.jsx +48 -0
  17. package/dist/ApexUI-Kit/templates/BlurRevealText/BlurRevealText.jsx +60 -0
  18. package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +53 -0
  19. package/dist/ApexUI-Kit/templates/CardSwap3D/CardSwap3D.jsx +80 -0
  20. package/dist/ApexUI-Kit/templates/Carousel3D/Carousel3D.jsx +222 -0
  21. package/dist/ApexUI-Kit/templates/CircularText/CircularText.jsx +49 -0
  22. package/dist/ApexUI-Kit/templates/ClickSpark/ClickSpark.jsx +72 -0
  23. package/dist/ApexUI-Kit/templates/Coverflow3D/Coverflow3D.jsx +108 -0
  24. package/dist/ApexUI-Kit/templates/CubeGallery/CubeGallery.jsx +166 -0
  25. package/dist/ApexUI-Kit/templates/CursorTrail/CursorTrail.jsx +200 -0
  26. package/dist/ApexUI-Kit/templates/DotGlobe/DotGlobe.jsx +289 -0
  27. package/dist/ApexUI-Kit/templates/ElasticSlider/ElasticSlider.jsx +152 -0
  28. package/dist/ApexUI-Kit/templates/ExtrudedText/ExtrudedText.jsx +78 -0
  29. package/dist/ApexUI-Kit/templates/FaqAccordion/FaqAccordion.jsx +88 -0
  30. package/dist/ApexUI-Kit/templates/FlipBook/FlipBook.jsx +191 -0
  31. package/dist/ApexUI-Kit/templates/FlipClock/FlipClock.jsx +121 -0
  32. package/dist/ApexUI-Kit/templates/FloatingNavbar/FloatingNavbar.jsx +162 -0
  33. package/dist/ApexUI-Kit/templates/Folder/Folder.jsx +101 -0
  34. package/dist/ApexUI-Kit/templates/GlitchText/GlitchText.jsx +56 -0
  35. package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +310 -0
  36. package/dist/ApexUI-Kit/templates/HoldToConfirm/HoldToConfirm.jsx +169 -0
  37. package/dist/ApexUI-Kit/templates/HoldToConfirm/UndoNotice.jsx +58 -0
  38. package/dist/ApexUI-Kit/templates/HoloCard/HoloCard.jsx +92 -0
  39. package/dist/ApexUI-Kit/templates/ImageCompare/ImageCompare.jsx +94 -0
  40. package/dist/ApexUI-Kit/templates/IsoStack/IsoStack.jsx +86 -0
  41. package/dist/ApexUI-Kit/templates/LiquidGradient/LiquidGradient.jsx +238 -0
  42. package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +805 -0
  43. package/dist/ApexUI-Kit/templates/MagnetLines/MagnetLines.jsx +67 -0
  44. package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +148 -0
  45. package/dist/ApexUI-Kit/templates/PixelTransition/PixelTransition.jsx +75 -0
  46. package/dist/ApexUI-Kit/templates/PricingCards/PricingCards.jsx +115 -0
  47. package/dist/ApexUI-Kit/templates/ProfileCard/ProfileCard.jsx +213 -0
  48. package/dist/ApexUI-Kit/templates/ProximityText/ProximityText.jsx +66 -0
  49. package/dist/ApexUI-Kit/templates/RotatingText/RotatingText.jsx +50 -0
  50. package/dist/ApexUI-Kit/templates/ShinyText/ShinyText.jsx +30 -0
  51. package/dist/ApexUI-Kit/templates/SidebarNav/SidebarNav.jsx +220 -0
  52. package/dist/ApexUI-Kit/templates/SpotlightGrid/SpotlightGrid.jsx +110 -0
  53. package/dist/ApexUI-Kit/templates/StarBorder/StarBorder.jsx +33 -0
  54. package/dist/ApexUI-Kit/templates/Stepper/Stepper.jsx +129 -0
  55. package/dist/ApexUI-Kit/templates/SwipeCards/SwipeCards.jsx +123 -0
  56. package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +73 -0
  57. package/dist/ApexUI-Kit/templates/ToastStack/ToastStack.jsx +108 -0
  58. package/dist/ApexUI-Kit/templates/ToolTip/ToolTip.jsx +5 -3
  59. package/dist/ApexUI-Kit/templates/TrueFocus/TrueFocus.jsx +64 -0
  60. package/dist/ApexUI-Kit/templates/TypewriterText/TypewriterText.jsx +70 -0
  61. package/dist/ApexUI-Kit/templates/WebGLCarousel/WebGLCarousel.jsx +326 -0
  62. package/dist/ApexUI-Kit/templates/WorldMap/WorldMap.jsx +359 -0
  63. package/dist/cli/index.js +63 -0
  64. package/package.json +1 -1
@@ -0,0 +1,805 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "framer-motion";
3
+
4
+ /*
5
+ * Loading Screen: five ways to wait.
6
+ * orbit – dots orbit a breathing core around the percentage
7
+ * progress – a big counting percentage, a sliding bar and rotating status lines
8
+ * blocks – a grid of tiles fills in like a wave
9
+ * catch – a mini game: tap the orb before its ring runs out and build a combo
10
+ * runner – a mini game: jump the neon pillars (Space / ↑ / tap) while things load
11
+ * racer – a mini game: steer through traffic (← → / tap a side) while things load
12
+ * Pass `progress` (0–100) yourself, or leave it out and it runs on its own.
13
+ */
14
+
15
+ const MESSAGES = ["Warming up the engines", "Fetching your components", "Polishing pixels", "Almost there"];
16
+ const messageFor = (v) => MESSAGES[Math.min(MESSAGES.length - 1, Math.floor(v / (100 / MESSAGES.length)))];
17
+
18
+ // Simulated progress that eases toward 100 over `duration` ms, then calls onComplete.
19
+ function useProgress(progress, duration, loop, onComplete) {
20
+ const [auto, setAuto] = useState(0);
21
+ const doneRef = useRef(onComplete);
22
+ doneRef.current = onComplete;
23
+ const controlled = typeof progress === "number";
24
+
25
+ useEffect(() => {
26
+ if (controlled) return undefined;
27
+ let raf = 0;
28
+ let start = performance.now();
29
+ let fired = false;
30
+ const tick = (now) => {
31
+ const t = Math.min(1, Math.max(0, (now - start) / duration));
32
+ // Fast start, slow middle, quick finish — the way real loads feel.
33
+ const eased = t < 0.85 ? 0.92 * (1 - Math.pow(1 - t / 0.85, 2.2)) : 0.92 + 0.08 * ((t - 0.85) / 0.15);
34
+ setAuto(Math.round(eased * 100));
35
+ if (t >= 1) {
36
+ if (!fired) {
37
+ fired = true;
38
+ doneRef.current?.();
39
+ }
40
+ if (loop) {
41
+ start = now + 900;
42
+ fired = false;
43
+ } else return;
44
+ }
45
+ raf = requestAnimationFrame(tick);
46
+ };
47
+ raf = requestAnimationFrame(tick);
48
+ return () => cancelAnimationFrame(raf);
49
+ }, [controlled, duration, loop]);
50
+
51
+ useEffect(() => {
52
+ if (controlled && progress >= 100) doneRef.current?.();
53
+ }, [controlled, progress]);
54
+
55
+ return Math.max(0, Math.min(100, controlled ? progress : auto));
56
+ }
57
+
58
+ function Bar({ value, color, track }) {
59
+ return (
60
+ <div className="relative h-1.5 w-full overflow-hidden rounded-full" style={{ background: track }}>
61
+ <div className="h-full rounded-full transition-[width] duration-300 ease-out" style={{ width: `${value}%`, background: `linear-gradient(90deg, ${color}99, ${color})`, boxShadow: `0 0 14px ${color}` }} />
62
+ </div>
63
+ );
64
+ }
65
+
66
+ // Progress row shared by the game variants.
67
+ function Footer({ value, color, track }) {
68
+ return (
69
+ <div className="mt-5 w-full">
70
+ <div className="mb-2 flex items-center justify-between text-[0.75rem]">
71
+ <span className="opacity-60">{messageFor(value)}…</span>
72
+ <span className="font-mono font-semibold tabular-nums">{value}%</span>
73
+ </div>
74
+ <Bar value={value} color={color} track={track} />
75
+ </div>
76
+ );
77
+ }
78
+
79
+ function Orbit({ value, color, moving }) {
80
+ return (
81
+ <div className="flex flex-col items-center">
82
+ <div className="relative grid h-36 w-36 place-items-center">
83
+ {[0, 1, 2].map((ring) => (
84
+ <motion.div
85
+ key={ring}
86
+ className="absolute inset-0"
87
+ style={{ scale: 1 - ring * 0.22, rotate: ring * 40 }}
88
+ // Each ring starts at its own angle so the dots never line up.
89
+ animate={moving ? { rotate: ring * 40 + (ring % 2 ? -360 : 360) } : undefined}
90
+ transition={{ duration: 2.4 + ring * 1.1, repeat: Infinity, ease: "linear" }}
91
+ >
92
+ {[0, 1, 2].map((d) => (
93
+ <div key={d} className="absolute inset-0" style={{ transform: `rotate(${d * 120}deg)` }}>
94
+ <span className="absolute left-1/2 top-0 -ml-[5px] -mt-[5px] h-2.5 w-2.5 rounded-full" style={{ background: color, opacity: 1 - ring * 0.25, boxShadow: `0 0 10px ${color}` }} />
95
+ </div>
96
+ ))}
97
+ </motion.div>
98
+ ))}
99
+ <motion.div
100
+ className="h-12 w-12 rounded-full"
101
+ style={{ background: `radial-gradient(circle at 35% 35%, #fff, ${color} 55%)`, boxShadow: `0 0 40px ${color}` }}
102
+ animate={moving ? { scale: [0.9, 1.08, 0.9] } : undefined}
103
+ transition={{ duration: 1.6, repeat: Infinity, ease: "easeInOut" }}
104
+ />
105
+ </div>
106
+ <p className="mt-5 font-mono text-2xl font-semibold tabular-nums">{value}%</p>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ function ProgressView({ value, color, track, moving }) {
112
+ const message = messageFor(value);
113
+ return (
114
+ <div className="w-[min(22rem,80vw)]">
115
+ <p className="font-mono text-[clamp(3.5rem,12vw,5.5rem)] font-bold leading-none tabular-nums tracking-tight">
116
+ {value}
117
+ <span className="align-top text-[0.4em] opacity-50">%</span>
118
+ </p>
119
+ <div className="mt-5">
120
+ <Bar value={value} color={color} track={track} />
121
+ </div>
122
+ <div className="mt-3 h-5 overflow-hidden text-sm opacity-70">
123
+ <AnimatePresence mode="wait" initial={false}>
124
+ <motion.p key={message} initial={{ y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -14, opacity: 0 }} transition={{ duration: moving ? 0.25 : 0 }}>
125
+ {message}…
126
+ </motion.p>
127
+ </AnimatePresence>
128
+ </div>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ function Blocks({ value, color, track, moving }) {
134
+ const N = 7;
135
+ const filled = Math.round((value / 100) * N * N);
136
+ return (
137
+ <div className="flex flex-col items-center">
138
+ <div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${N}, 1fr)` }}>
139
+ {Array.from({ length: N * N }, (_, i) => {
140
+ const on = i < filled;
141
+ const r = Math.floor(i / N);
142
+ const c = i % N;
143
+ return (
144
+ <motion.span
145
+ key={i}
146
+ className="h-4 w-4 rounded-[5px] sm:h-5 sm:w-5"
147
+ style={{ background: on ? color : track }}
148
+ animate={moving ? { scale: on ? [1, 0.7, 1] : 1, opacity: on ? 1 : [0.5, 1, 0.5] } : undefined}
149
+ transition={{ duration: 1.6, repeat: Infinity, delay: (r + c) * 0.07, ease: "easeInOut" }}
150
+ />
151
+ );
152
+ })}
153
+ </div>
154
+ <p className="mt-5 font-mono text-lg font-semibold tabular-nums">{value}%</p>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ /* ---------------------------------------------------------------- Catch game */
160
+
161
+ const ORB_LIFE = 1.5; // seconds before an orb escapes
162
+
163
+ function CatchGame({ value, color, track, dark }) {
164
+ const [score, setScore] = useState(0);
165
+ const [best, setBest] = useState(0);
166
+ const [combo, setCombo] = useState(0);
167
+ const [orb, setOrb] = useState({ id: 0, x: 50, y: 55 });
168
+ const [bursts, setBursts] = useState([]);
169
+ const arena = useAnimationControls();
170
+
171
+ const nextOrb = (id) => ({ id: id + 1, x: 12 + Math.random() * 76, y: 22 + Math.random() * 60 });
172
+
173
+ // An orb that escapes resets the combo and nudges the arena.
174
+ useEffect(() => {
175
+ const t = setTimeout(() => {
176
+ setCombo(0);
177
+ arena.start({ x: [0, -6, 6, -3, 0], transition: { duration: 0.3 } });
178
+ setOrb((o) => nextOrb(o.id));
179
+ }, ORB_LIFE * 1000);
180
+ return () => clearTimeout(t);
181
+ }, [orb.id, arena]);
182
+
183
+ const hit = () => {
184
+ const nextCombo = combo + 1;
185
+ const gain = Math.min(5, 1 + Math.floor(nextCombo / 3));
186
+ setCombo(nextCombo);
187
+ setScore((s) => {
188
+ const n = s + gain;
189
+ setBest((b) => Math.max(b, n));
190
+ return n;
191
+ });
192
+ setBursts((list) => [...list.slice(-4), { id: performance.now(), x: orb.x, y: orb.y, gain }]);
193
+ setOrb((o) => nextOrb(o.id));
194
+ };
195
+
196
+ const surface = dark ? "rgba(255,255,255,0.03)" : "rgba(0,0,0,0.025)";
197
+
198
+ return (
199
+ <div className="w-[min(24rem,86vw)]">
200
+ <motion.div
201
+ animate={arena}
202
+ className="relative h-52 overflow-hidden rounded-3xl border"
203
+ style={{
204
+ borderColor: track,
205
+ background: `radial-gradient(120% 90% at 50% 0%, ${color}1f, transparent 60%), ${surface}`,
206
+ backgroundImage: `linear-gradient(${track} 1px, transparent 1px), linear-gradient(90deg, ${track} 1px, transparent 1px)`,
207
+ backgroundSize: "28px 28px",
208
+ }}
209
+ >
210
+ {/* HUD */}
211
+ <div className="pointer-events-none absolute inset-x-3 top-3 z-10 flex items-center justify-between text-[0.7rem] font-semibold uppercase tracking-[0.14em]">
212
+ <span className="rounded-full px-2.5 py-1 backdrop-blur" style={{ background: dark ? "rgba(0,0,0,0.45)" : "rgba(255,255,255,0.7)" }}>
213
+ Score <span className="ml-1 font-mono text-[0.85rem] tabular-nums" style={{ color }}>{score}</span>
214
+ </span>
215
+ <AnimatePresence>
216
+ {combo >= 3 && (
217
+ <motion.span
218
+ key={Math.floor(combo / 3)}
219
+ initial={{ scale: 0.6, opacity: 0 }}
220
+ animate={{ scale: 1, opacity: 1 }}
221
+ exit={{ scale: 0.6, opacity: 0 }}
222
+ className="rounded-full px-2.5 py-1 font-mono text-black"
223
+ style={{ background: color, boxShadow: `0 0 16px ${color}` }}
224
+ >
225
+ ×{Math.min(5, 1 + Math.floor(combo / 3))} combo
226
+ </motion.span>
227
+ )}
228
+ </AnimatePresence>
229
+ <span className="rounded-full px-2.5 py-1 opacity-80 backdrop-blur" style={{ background: dark ? "rgba(0,0,0,0.45)" : "rgba(255,255,255,0.7)" }}>
230
+ Best <span className="ml-1 font-mono tabular-nums">{best}</span>
231
+ </span>
232
+ </div>
233
+
234
+ {/* The orb with a ring that empties as time runs out. */}
235
+ <AnimatePresence>
236
+ <motion.button
237
+ key={orb.id}
238
+ type="button"
239
+ aria-label="Catch the orb"
240
+ onPointerDown={hit}
241
+ className="absolute grid h-14 w-14 place-items-center rounded-full outline-none"
242
+ style={{ left: `${orb.x}%`, top: `${orb.y}%`, marginLeft: "-1.75rem", marginTop: "-1.75rem" }}
243
+ initial={{ scale: 0, opacity: 0 }}
244
+ animate={{ scale: 1, opacity: 1 }}
245
+ exit={{ scale: 1.6, opacity: 0, transition: { duration: 0.22 } }}
246
+ transition={{ type: "spring", stiffness: 420, damping: 18 }}
247
+ >
248
+ <svg viewBox="0 0 56 56" className="absolute inset-0 -rotate-90">
249
+ <circle cx="28" cy="28" r="25" fill="none" stroke={track} strokeWidth="3" />
250
+ <motion.circle cx="28" cy="28" r="25" fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" strokeDasharray="157" initial={{ strokeDashoffset: 0 }} animate={{ strokeDashoffset: 157 }} transition={{ duration: ORB_LIFE, ease: "linear" }} />
251
+ </svg>
252
+ <motion.span
253
+ className="h-7 w-7 rounded-full"
254
+ style={{ background: `radial-gradient(circle at 35% 30%, #fff, ${color} 60%)`, boxShadow: `0 0 22px ${color}` }}
255
+ animate={{ scale: [1, 1.12, 1] }}
256
+ transition={{ duration: 0.7, repeat: Infinity }}
257
+ />
258
+ </motion.button>
259
+ </AnimatePresence>
260
+
261
+ {/* Hit bursts: sparks and a floating +points. */}
262
+ {bursts.map((b) => (
263
+ <div key={b.id} className="pointer-events-none absolute" style={{ left: `${b.x}%`, top: `${b.y}%` }}>
264
+ {Array.from({ length: 8 }, (_, i) => {
265
+ const a = (i / 8) * Math.PI * 2;
266
+ return (
267
+ <motion.span
268
+ key={i}
269
+ className="absolute h-1.5 w-1.5 rounded-full"
270
+ style={{ background: color, marginLeft: -3, marginTop: -3 }}
271
+ initial={{ x: 0, y: 0, opacity: 1 }}
272
+ animate={{ x: Math.cos(a) * 34, y: Math.sin(a) * 34, opacity: 0 }}
273
+ transition={{ duration: 0.5, ease: "easeOut" }}
274
+ />
275
+ );
276
+ })}
277
+ <motion.span
278
+ className="absolute w-10 text-center font-mono text-sm font-bold"
279
+ style={{ color, marginLeft: "-1.25rem" }}
280
+ initial={{ y: -10, opacity: 1 }}
281
+ animate={{ y: -42, opacity: 0 }}
282
+ transition={{ duration: 0.7, ease: "easeOut" }}
283
+ onAnimationComplete={() => setBursts((list) => list.filter((x) => x.id !== b.id))}
284
+ >
285
+ +{b.gain}
286
+ </motion.span>
287
+ </div>
288
+ ))}
289
+ </motion.div>
290
+ <Footer value={value} color={color} track={track} />
291
+ </div>
292
+ );
293
+ }
294
+
295
+ /* --------------------------------------------------------------- Runner game */
296
+
297
+ function RunnerGame({ value, color, track, dark }) {
298
+ const canvasRef = useRef(null);
299
+ const jumpRef = useRef(() => {});
300
+ const [score, setScore] = useState(0);
301
+ const [best, setBest] = useState(0);
302
+ const [started, setStarted] = useState(false);
303
+
304
+ useEffect(() => {
305
+ const canvas = canvasRef.current;
306
+ const ctx = canvas.getContext("2d");
307
+ let raf = 0;
308
+ let last = performance.now();
309
+ let w = 0;
310
+ let h = 0;
311
+ const sky = dark ? ["#05060d", "#0d1222"] : ["#eef2ff", "#f8fafc"];
312
+ const hill = dark ? ["rgba(255,255,255,0.05)", "rgba(255,255,255,0.09)"] : ["rgba(15,23,42,0.06)", "rgba(15,23,42,0.1)"];
313
+ const pillar = dark ? "#e5e7eb" : "#1f2937";
314
+ const stars = Array.from({ length: 40 }, () => ({ x: Math.random(), y: Math.random() * 0.6, r: Math.random() * 1.2 + 0.3, p: Math.random() * 6 }));
315
+ const g = { y: 0, vy: 0, rot: 0, obstacles: [], dust: [], ghosts: [], speed: 240, spawn: 1.1, score: 0, shake: 0, flash: 0, dist: 0, buffer: 0 };
316
+
317
+ const resize = () => {
318
+ const r = canvas.getBoundingClientRect();
319
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
320
+ w = r.width;
321
+ h = r.height;
322
+ canvas.width = Math.round(w * dpr);
323
+ canvas.height = Math.round(h * dpr);
324
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
325
+ };
326
+ const ro = new ResizeObserver(resize);
327
+ ro.observe(canvas);
328
+ resize();
329
+
330
+ const puff = (x, y, n) => {
331
+ for (let i = 0; i < n; i++) g.dust.push({ x, y, vx: -40 - Math.random() * 80, vy: -Math.random() * 60, life: 0.5, t: 0 });
332
+ };
333
+ jumpRef.current = () => {
334
+ setStarted(true);
335
+ g.buffer = 0.12; // a press just before landing still counts
336
+ };
337
+
338
+ // Rolling hills, drawn as a smooth line scrolling at `rate`.
339
+ const hills = (base, amp, rate, color) => {
340
+ ctx.fillStyle = color;
341
+ ctx.beginPath();
342
+ ctx.moveTo(0, h);
343
+ for (let x = 0; x <= w; x += 8) {
344
+ const t = (x + g.dist * rate) * 0.012;
345
+ ctx.lineTo(x, base - (Math.sin(t) * 0.6 + Math.sin(t * 2.3) * 0.4) * amp);
346
+ }
347
+ ctx.lineTo(w, h);
348
+ ctx.fill();
349
+ };
350
+
351
+ const tick = (now) => {
352
+ const dt = Math.min(0.033, (now - last) / 1000);
353
+ last = now;
354
+ const ground = h - 26;
355
+ const size = 20;
356
+ const px = Math.max(44, w * 0.14);
357
+
358
+ // Physics.
359
+ g.buffer = Math.max(0, g.buffer - dt);
360
+ if (g.buffer > 0 && g.y === 0) {
361
+ g.vy = 560;
362
+ g.buffer = 0;
363
+ puff(px + size / 2, ground, 5);
364
+ }
365
+ const wasAir = g.y > 0;
366
+ g.vy -= 1650 * dt;
367
+ g.y = Math.max(0, g.y + g.vy * dt);
368
+ if (g.y === 0) {
369
+ g.vy = Math.max(0, g.vy);
370
+ if (wasAir) puff(px + size / 2, ground, 7);
371
+ g.rot = Math.round(g.rot / (Math.PI / 2)) * (Math.PI / 2);
372
+ } else g.rot += dt * 7.5;
373
+
374
+ g.speed = Math.min(460, g.speed + 7 * dt);
375
+ g.dist += g.speed * dt;
376
+ g.spawn -= dt;
377
+ if (g.spawn <= 0) {
378
+ const tall = Math.random() < 0.4;
379
+ g.obstacles.push({ x: w + 20, w: tall ? 16 : 22, h: tall ? 40 : 24 });
380
+ if (Math.random() < 0.25) g.obstacles.push({ x: w + 46, w: 16, h: 22 });
381
+ g.spawn = 0.8 + Math.random() * 0.8 - Math.min(0.3, g.speed / 2000);
382
+ }
383
+ for (const o of g.obstacles) o.x -= g.speed * dt;
384
+ g.obstacles = g.obstacles.filter((o) => o.x + o.w > -20);
385
+ for (const d of g.dust) {
386
+ d.t += dt;
387
+ d.x += d.vx * dt;
388
+ d.y += d.vy * dt;
389
+ d.vy += 200 * dt;
390
+ }
391
+ g.dust = g.dust.filter((d) => d.t < d.life);
392
+
393
+ const top = ground - g.y - size;
394
+ g.ghosts.push({ y: top, rot: g.rot });
395
+ if (g.ghosts.length > 5) g.ghosts.shift();
396
+
397
+ // Collision resets the run.
398
+ const hitIt = g.obstacles.some((o) => px + size - 3 > o.x && px + 3 < o.x + o.w && top + size > ground - o.h + 3);
399
+ if (hitIt) {
400
+ g.obstacles = [];
401
+ g.speed = 240;
402
+ g.shake = 0.3;
403
+ g.flash = 0.35;
404
+ g.score = 0;
405
+ }
406
+ g.score += dt * 12;
407
+ g.shake = Math.max(0, g.shake - dt);
408
+ g.flash = Math.max(0, g.flash - dt);
409
+ const s = Math.floor(g.score);
410
+ setScore((prev) => (prev === s ? prev : s));
411
+ setBest((b) => Math.max(b, s));
412
+
413
+ // Draw.
414
+ ctx.save();
415
+ if (g.shake) ctx.translate((Math.random() - 0.5) * 10 * g.shake, (Math.random() - 0.5) * 10 * g.shake);
416
+ const bg = ctx.createLinearGradient(0, 0, 0, h);
417
+ bg.addColorStop(0, sky[0]);
418
+ bg.addColorStop(1, sky[1]);
419
+ ctx.fillStyle = bg;
420
+ ctx.fillRect(-10, -10, w + 20, h + 20);
421
+ if (dark) {
422
+ for (const st of stars) {
423
+ ctx.globalAlpha = 0.35 + 0.35 * Math.sin(now / 600 + st.p);
424
+ ctx.fillStyle = "#fff";
425
+ ctx.beginPath();
426
+ ctx.arc(((st.x * w - g.dist * 0.02) % w + w) % w, st.y * h, st.r, 0, Math.PI * 2);
427
+ ctx.fill();
428
+ }
429
+ ctx.globalAlpha = 1;
430
+ }
431
+ hills(ground - 22, 16, 0.15, hill[0]);
432
+ hills(ground - 6, 10, 0.35, hill[1]);
433
+
434
+ // Ground with moving dashes.
435
+ ctx.fillStyle = track;
436
+ ctx.fillRect(0, ground, w, 2);
437
+ ctx.fillStyle = dark ? "rgba(255,255,255,0.18)" : "rgba(0,0,0,0.15)";
438
+ for (let x = -(g.dist % 32); x < w; x += 32) ctx.fillRect(x, ground + 9, 12, 2);
439
+
440
+ // Neon pillars.
441
+ for (const o of g.obstacles) {
442
+ ctx.fillStyle = pillar;
443
+ ctx.globalAlpha = 0.9;
444
+ ctx.beginPath();
445
+ ctx.roundRect(o.x, ground - o.h, o.w, o.h, 4);
446
+ ctx.fill();
447
+ ctx.globalAlpha = 1;
448
+ ctx.fillStyle = "#f43f5e";
449
+ ctx.shadowColor = "#f43f5e";
450
+ ctx.shadowBlur = 10;
451
+ ctx.fillRect(o.x + 2, ground - o.h, o.w - 4, 3);
452
+ ctx.shadowBlur = 0;
453
+ }
454
+
455
+ // Dust.
456
+ for (const d of g.dust) {
457
+ ctx.globalAlpha = 1 - d.t / d.life;
458
+ ctx.fillStyle = dark ? "#cbd5e1" : "#64748b";
459
+ ctx.beginPath();
460
+ ctx.arc(d.x, d.y, 2.2, 0, Math.PI * 2);
461
+ ctx.fill();
462
+ }
463
+ ctx.globalAlpha = 1;
464
+
465
+ // Runner: faded echoes, then the glowing square that spins in the air.
466
+ const drawBox = (y, rot, alpha) => {
467
+ ctx.save();
468
+ ctx.globalAlpha = alpha;
469
+ ctx.translate(px + size / 2, y + size / 2);
470
+ ctx.rotate(rot);
471
+ ctx.fillStyle = g.flash ? "#f43f5e" : color;
472
+ ctx.shadowColor = color;
473
+ ctx.shadowBlur = alpha === 1 ? 18 : 0;
474
+ ctx.beginPath();
475
+ ctx.roundRect(-size / 2, -size / 2, size, size, 5);
476
+ ctx.fill();
477
+ if (alpha === 1) {
478
+ ctx.shadowBlur = 0;
479
+ ctx.fillStyle = "rgba(0,0,0,0.75)";
480
+ ctx.fillRect(3, -4, 4, 4);
481
+ }
482
+ ctx.restore();
483
+ };
484
+ g.ghosts.forEach((gh, i) => g.y > 0 && drawBox(gh.y, gh.rot, (i / g.ghosts.length) * 0.25));
485
+ drawBox(top, g.rot, 1);
486
+
487
+ if (g.flash) {
488
+ ctx.fillStyle = `rgba(244,63,94,${g.flash * 0.25})`;
489
+ ctx.fillRect(0, 0, w, h);
490
+ }
491
+ ctx.restore();
492
+ raf = requestAnimationFrame(tick);
493
+ };
494
+ raf = requestAnimationFrame(tick);
495
+
496
+ const onKey = (e) => {
497
+ if (e.code !== "Space" && e.code !== "ArrowUp") return;
498
+ const area = canvas.closest("[data-loading-screen]");
499
+ if (area?.matches(":hover") || area?.contains(document.activeElement)) {
500
+ e.preventDefault();
501
+ jumpRef.current();
502
+ }
503
+ };
504
+ window.addEventListener("keydown", onKey);
505
+ return () => {
506
+ cancelAnimationFrame(raf);
507
+ ro.disconnect();
508
+ window.removeEventListener("keydown", onKey);
509
+ };
510
+ }, [color, track, dark]);
511
+
512
+ return (
513
+ <div className="w-[min(28rem,88vw)]">
514
+ <div className="relative overflow-hidden rounded-3xl border" style={{ borderColor: track }}>
515
+ <canvas
516
+ ref={canvasRef}
517
+ tabIndex={0}
518
+ aria-label="Runner game: press Space or tap to jump"
519
+ onPointerDown={() => jumpRef.current()}
520
+ className="block h-44 w-full cursor-pointer touch-manipulation outline-none"
521
+ />
522
+ <div className="pointer-events-none absolute inset-x-3 top-3 flex items-center justify-between text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-white mix-blend-difference">
523
+ <span>
524
+ Score <span className="ml-1 font-mono text-[0.85rem] tabular-nums">{score}</span>
525
+ </span>
526
+ <span className="opacity-80">
527
+ Best <span className="ml-1 font-mono tabular-nums">{best}</span>
528
+ </span>
529
+ </div>
530
+ <AnimatePresence>
531
+ {!started && (
532
+ <motion.div exit={{ opacity: 0 }} className="pointer-events-none absolute inset-x-0 bottom-10 flex justify-center">
533
+ <motion.span
534
+ animate={{ y: [0, -4, 0] }}
535
+ transition={{ duration: 1.2, repeat: Infinity }}
536
+ className="rounded-full px-3 py-1 text-[0.72rem] font-semibold text-black"
537
+ style={{ background: color, boxShadow: `0 0 18px ${color}` }}
538
+ >
539
+ Tap or press Space to jump
540
+ </motion.span>
541
+ </motion.div>
542
+ )}
543
+ </AnimatePresence>
544
+ </div>
545
+ <Footer value={value} color={color} track={track} />
546
+ </div>
547
+ );
548
+ }
549
+
550
+ /* ---------------------------------------------------------------- Racer game */
551
+
552
+ const LANES = 3;
553
+ const TRAFFIC = ["#f43f5e", "#38bdf8", "#f59e0b", "#a78bfa", "#e5e7eb"];
554
+
555
+ function RacerGame({ value, color, track, dark }) {
556
+ const canvasRef = useRef(null);
557
+ const moveRef = useRef(() => {});
558
+ const [score, setScore] = useState(0);
559
+ const [best, setBest] = useState(0);
560
+ const [started, setStarted] = useState(false);
561
+
562
+ useEffect(() => {
563
+ const canvas = canvasRef.current;
564
+ const ctx = canvas.getContext("2d");
565
+ let raf = 0;
566
+ let last = performance.now();
567
+ let w = 0;
568
+ let h = 0;
569
+ const g = { lane: 1, x: 1, cars: [], speed: 260, spawn: 0.6, score: 0, road: 0, shake: 0, flash: 0, sparks: [] };
570
+
571
+ const resize = () => {
572
+ const r = canvas.getBoundingClientRect();
573
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
574
+ w = r.width;
575
+ h = r.height;
576
+ canvas.width = Math.round(w * dpr);
577
+ canvas.height = Math.round(h * dpr);
578
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
579
+ };
580
+ const ro = new ResizeObserver(resize);
581
+ ro.observe(canvas);
582
+ resize();
583
+
584
+ moveRef.current = (dir) => {
585
+ setStarted(true);
586
+ g.lane = Math.max(0, Math.min(LANES - 1, g.lane + dir));
587
+ };
588
+
589
+ // A top-down car: body, roof, windows and lights.
590
+ const car = (cx, cy, cw, ch, body, player) => {
591
+ ctx.save();
592
+ ctx.translate(cx, cy);
593
+ if (player) {
594
+ ctx.shadowColor = body;
595
+ ctx.shadowBlur = 18;
596
+ }
597
+ ctx.fillStyle = body;
598
+ ctx.beginPath();
599
+ ctx.roundRect(-cw / 2, -ch / 2, cw, ch, cw * 0.32);
600
+ ctx.fill();
601
+ ctx.shadowBlur = 0;
602
+ ctx.fillStyle = "rgba(0,0,0,0.55)";
603
+ ctx.beginPath();
604
+ ctx.roundRect(-cw * 0.36, -ch * (player ? 0.28 : 0.1), cw * 0.72, ch * 0.24, 4);
605
+ ctx.fill();
606
+ ctx.fillStyle = "rgba(255,255,255,0.18)";
607
+ ctx.fillRect(-cw * 0.3, -ch * 0.02, cw * 0.6, ch * 0.18);
608
+ // Headlights for the player (facing up), tail lights for traffic (facing down at us).
609
+ ctx.fillStyle = player ? "#fffbe6" : "#ff3b3b";
610
+ const ly = player ? -ch / 2 + 3 : ch / 2 - 6;
611
+ ctx.fillRect(-cw * 0.38, ly, cw * 0.18, 3);
612
+ ctx.fillRect(cw * 0.2, ly, cw * 0.18, 3);
613
+ if (player && dark) {
614
+ const beam = ctx.createLinearGradient(0, -ch / 2, 0, -ch / 2 - 70);
615
+ beam.addColorStop(0, "rgba(255,251,230,0.35)");
616
+ beam.addColorStop(1, "rgba(255,251,230,0)");
617
+ ctx.fillStyle = beam;
618
+ ctx.beginPath();
619
+ ctx.moveTo(-cw * 0.38, -ch / 2);
620
+ ctx.lineTo(cw * 0.38, -ch / 2);
621
+ ctx.lineTo(cw * 0.75, -ch / 2 - 70);
622
+ ctx.lineTo(-cw * 0.75, -ch / 2 - 70);
623
+ ctx.fill();
624
+ }
625
+ ctx.restore();
626
+ };
627
+
628
+ const tick = (now) => {
629
+ const dt = Math.min(0.033, (now - last) / 1000);
630
+ last = now;
631
+ const roadW = Math.min(w * 0.8, 260);
632
+ const left = (w - roadW) / 2;
633
+ const laneW = roadW / LANES;
634
+ const cw = laneW * 0.5;
635
+ const ch = cw * 1.75;
636
+ const py = h - ch / 2 - 14;
637
+
638
+ g.x += (g.lane - g.x) * Math.min(1, dt * 14);
639
+ g.speed = Math.min(620, g.speed + 9 * dt);
640
+ g.road = (g.road + g.speed * dt) % 40;
641
+ g.spawn -= dt;
642
+ if (g.spawn <= 0) {
643
+ const lane = Math.floor(Math.random() * LANES);
644
+ if (!g.cars.some((c) => c.lane === lane && c.y < ch * 2)) g.cars.push({ lane, y: -ch, color: TRAFFIC[Math.floor(Math.random() * TRAFFIC.length)], v: 0.45 + Math.random() * 0.25 });
645
+ g.spawn = Math.max(0.35, 0.9 - g.speed / 1400) + Math.random() * 0.35;
646
+ }
647
+ for (const c of g.cars) c.y += g.speed * c.v * dt;
648
+ g.cars = g.cars.filter((c) => c.y < h + ch);
649
+
650
+ // Crash: sparks, shake and a fresh start.
651
+ const pxc = left + laneW * (g.x + 0.5);
652
+ const crash = g.cars.find((c) => Math.abs(left + laneW * (c.lane + 0.5) - pxc) < cw * 0.85 && Math.abs(c.y - py) < ch * 0.85);
653
+ if (crash) {
654
+ for (let i = 0; i < 14; i++) g.sparks.push({ x: pxc, y: py - ch / 2, vx: (Math.random() - 0.5) * 240, vy: -Math.random() * 220, t: 0 });
655
+ g.cars = [];
656
+ g.speed = 260;
657
+ g.shake = 0.35;
658
+ g.flash = 0.4;
659
+ g.score = 0;
660
+ }
661
+ g.score += dt * g.speed * 0.04;
662
+ g.shake = Math.max(0, g.shake - dt);
663
+ g.flash = Math.max(0, g.flash - dt);
664
+ for (const sp of g.sparks) {
665
+ sp.t += dt;
666
+ sp.x += sp.vx * dt;
667
+ sp.y += sp.vy * dt;
668
+ sp.vy += 500 * dt;
669
+ }
670
+ g.sparks = g.sparks.filter((sp) => sp.t < 0.6);
671
+ const s = Math.floor(g.score);
672
+ setScore((prev) => (prev === s ? prev : s));
673
+ setBest((b) => Math.max(b, s));
674
+
675
+ // Draw: grass, road, moving lane dashes, edge lines, traffic, player.
676
+ ctx.save();
677
+ if (g.shake) ctx.translate((Math.random() - 0.5) * 12 * g.shake, (Math.random() - 0.5) * 12 * g.shake);
678
+ ctx.fillStyle = dark ? "#071209" : "#dcfce7";
679
+ ctx.fillRect(-10, -10, w + 20, h + 20);
680
+ ctx.fillStyle = dark ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.05)";
681
+ for (let y = -40 + g.road * 1; y < h; y += 40) {
682
+ ctx.fillRect(left - 22, y, 8, 18);
683
+ ctx.fillRect(left + roadW + 14, y, 8, 18);
684
+ }
685
+ ctx.fillStyle = dark ? "#15171d" : "#3f3f46";
686
+ ctx.fillRect(left, -10, roadW, h + 20);
687
+ ctx.fillStyle = color;
688
+ ctx.fillRect(left, -10, 3, h + 20);
689
+ ctx.fillRect(left + roadW - 3, -10, 3, h + 20);
690
+ ctx.fillStyle = "rgba(255,255,255,0.55)";
691
+ for (let i = 1; i < LANES; i++) for (let y = -40 + g.road; y < h; y += 40) ctx.fillRect(left + laneW * i - 1.5, y, 3, 20);
692
+ for (const c of g.cars) car(left + laneW * (c.lane + 0.5), c.y, cw, ch, c.color, false);
693
+ car(pxc, py, cw, ch, g.flash ? "#f43f5e" : color, true);
694
+ for (const sp of g.sparks) {
695
+ ctx.globalAlpha = 1 - sp.t / 0.6;
696
+ ctx.fillStyle = "#fbbf24";
697
+ ctx.fillRect(sp.x, sp.y, 3, 3);
698
+ }
699
+ ctx.globalAlpha = 1;
700
+ if (g.flash) {
701
+ ctx.fillStyle = `rgba(244,63,94,${g.flash * 0.25})`;
702
+ ctx.fillRect(0, 0, w, h);
703
+ }
704
+ ctx.restore();
705
+ raf = requestAnimationFrame(tick);
706
+ };
707
+ raf = requestAnimationFrame(tick);
708
+
709
+ const onKey = (e) => {
710
+ const dir = { ArrowLeft: -1, KeyA: -1, ArrowRight: 1, KeyD: 1 }[e.code];
711
+ if (!dir) return;
712
+ const area = canvas.closest("[data-loading-screen]");
713
+ if (area?.matches(":hover") || area?.contains(document.activeElement)) {
714
+ e.preventDefault();
715
+ moveRef.current(dir);
716
+ }
717
+ };
718
+ window.addEventListener("keydown", onKey);
719
+ return () => {
720
+ cancelAnimationFrame(raf);
721
+ ro.disconnect();
722
+ window.removeEventListener("keydown", onKey);
723
+ };
724
+ }, [color, dark]);
725
+
726
+ return (
727
+ <div className="w-[min(24rem,86vw)]">
728
+ <div className="relative overflow-hidden rounded-3xl border" style={{ borderColor: track }}>
729
+ <canvas
730
+ ref={canvasRef}
731
+ tabIndex={0}
732
+ aria-label="Racing game: use the left and right arrow keys, or tap the left or right side, to change lanes"
733
+ onPointerDown={(e) => {
734
+ const r = e.currentTarget.getBoundingClientRect();
735
+ moveRef.current(e.clientX - r.left < r.width / 2 ? -1 : 1);
736
+ }}
737
+ className="block h-60 w-full cursor-pointer touch-manipulation outline-none"
738
+ />
739
+ <div className="pointer-events-none absolute inset-x-3 top-3 flex items-center justify-between text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-white">
740
+ <span className="rounded-full bg-black/45 px-2.5 py-1 backdrop-blur">
741
+ {score} <span className="opacity-60">m</span>
742
+ </span>
743
+ <span className="rounded-full bg-black/45 px-2.5 py-1 opacity-90 backdrop-blur">Best {best}</span>
744
+ </div>
745
+ <AnimatePresence>
746
+ {!started && (
747
+ <motion.div exit={{ opacity: 0 }} className="pointer-events-none absolute inset-x-0 bottom-24 flex justify-center">
748
+ <motion.span animate={{ y: [0, -4, 0] }} transition={{ duration: 1.2, repeat: Infinity }} className="rounded-full px-3 py-1 text-[0.72rem] font-semibold text-black" style={{ background: color, boxShadow: `0 0 18px ${color}` }}>
749
+ ← → or tap a side to steer
750
+ </motion.span>
751
+ </motion.div>
752
+ )}
753
+ </AnimatePresence>
754
+ </div>
755
+ <Footer value={value} color={color} track={track} />
756
+ </div>
757
+ );
758
+ }
759
+
760
+ const LoadingScreen = ({
761
+ variant = "orbit",
762
+ progress,
763
+ duration = 6000,
764
+ loop = true,
765
+ accentColor = "#a3e635",
766
+ theme = "dark",
767
+ title = "Loading",
768
+ fullScreen = false,
769
+ onComplete,
770
+ className = "",
771
+ }) => {
772
+ const moving = !useReducedMotion();
773
+ const value = useProgress(progress, duration, loop, onComplete);
774
+ const dark = theme !== "light";
775
+ const track = dark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)";
776
+ const shared = { value, color: accentColor, track, moving, dark };
777
+ const game = variant === "catch" || variant === "runner" || variant === "racer";
778
+
779
+ return (
780
+ <div
781
+ data-loading-screen=""
782
+ role="status"
783
+ aria-live="polite"
784
+ aria-label={`${title} ${value}%`}
785
+ className={`flex flex-col items-center justify-center gap-6 p-6 sm:p-8 ${fullScreen ? "fixed inset-0 z-[100]" : "relative h-full w-full"} ${dark ? "bg-neutral-950 text-white" : "bg-white text-neutral-900"} ${className}`}
786
+ style={dark ? { backgroundImage: `radial-gradient(60% 50% at 50% 40%, ${accentColor}14, transparent 70%)` } : undefined}
787
+ >
788
+ {game && (
789
+ <p className="text-center">
790
+ <span className="block text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</span>
791
+ <span className="mt-1 block text-[0.95rem] font-semibold">{{ catch: "Catch the orb while you wait", runner: "Jump the pillars while you wait", racer: "Dodge the traffic while you wait" }[variant]}</span>
792
+ </p>
793
+ )}
794
+ {variant === "orbit" && <Orbit {...shared} />}
795
+ {variant === "progress" && <ProgressView {...shared} />}
796
+ {variant === "blocks" && <Blocks {...shared} />}
797
+ {variant === "catch" && <CatchGame {...shared} />}
798
+ {variant === "runner" && <RunnerGame {...shared} />}
799
+ {variant === "racer" && <RacerGame {...shared} />}
800
+ {!game && variant !== "progress" && <p className="text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</p>}
801
+ </div>
802
+ );
803
+ };
804
+
805
+ export default LoadingScreen;