apex-ui-kit 2.2.0 → 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 (50) 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/AnimatedSwitch/AnimatedSwitch.jsx +80 -0
  14. package/dist/ApexUI-Kit/templates/AnimatedTabs/AnimatedTabs.jsx +10 -2
  15. package/dist/ApexUI-Kit/templates/AuroraBackground/AuroraBackground.jsx +48 -0
  16. package/dist/ApexUI-Kit/templates/BounceCards/BounceCards.jsx +53 -0
  17. package/dist/ApexUI-Kit/templates/CardSwap3D/CardSwap3D.jsx +80 -0
  18. package/dist/ApexUI-Kit/templates/CircularText/CircularText.jsx +49 -0
  19. package/dist/ApexUI-Kit/templates/ClickSpark/ClickSpark.jsx +72 -0
  20. package/dist/ApexUI-Kit/templates/Coverflow3D/Coverflow3D.jsx +108 -0
  21. package/dist/ApexUI-Kit/templates/CubeGallery/CubeGallery.jsx +166 -0
  22. package/dist/ApexUI-Kit/templates/CursorTrail/CursorTrail.jsx +200 -0
  23. package/dist/ApexUI-Kit/templates/ElasticSlider/ElasticSlider.jsx +152 -0
  24. package/dist/ApexUI-Kit/templates/ExtrudedText/ExtrudedText.jsx +78 -0
  25. package/dist/ApexUI-Kit/templates/FlipBook/FlipBook.jsx +191 -0
  26. package/dist/ApexUI-Kit/templates/FloatingNavbar/FloatingNavbar.jsx +162 -0
  27. package/dist/ApexUI-Kit/templates/Folder/Folder.jsx +101 -0
  28. package/dist/ApexUI-Kit/templates/GlitchText/GlitchText.jsx +56 -0
  29. package/dist/ApexUI-Kit/templates/GooeyNav/GooeyNav.jsx +310 -0
  30. package/dist/ApexUI-Kit/templates/HoldToConfirm/HoldToConfirm.jsx +169 -0
  31. package/dist/ApexUI-Kit/templates/HoldToConfirm/UndoNotice.jsx +58 -0
  32. package/dist/ApexUI-Kit/templates/HoloCard/HoloCard.jsx +92 -0
  33. package/dist/ApexUI-Kit/templates/ImageCompare/ImageCompare.jsx +94 -0
  34. package/dist/ApexUI-Kit/templates/IsoStack/IsoStack.jsx +86 -0
  35. package/dist/ApexUI-Kit/templates/LoadingScreen/LoadingScreen.jsx +545 -101
  36. package/dist/ApexUI-Kit/templates/MagnetLines/MagnetLines.jsx +67 -0
  37. package/dist/ApexUI-Kit/templates/MagneticCursor/MagneticCursor.jsx +2 -160
  38. package/dist/ApexUI-Kit/templates/PixelTransition/PixelTransition.jsx +75 -0
  39. package/dist/ApexUI-Kit/templates/PricingCards/PricingCards.jsx +115 -0
  40. package/dist/ApexUI-Kit/templates/ProximityText/ProximityText.jsx +66 -0
  41. package/dist/ApexUI-Kit/templates/RotatingText/RotatingText.jsx +50 -0
  42. package/dist/ApexUI-Kit/templates/ShinyText/ShinyText.jsx +30 -0
  43. package/dist/ApexUI-Kit/templates/StarBorder/StarBorder.jsx +33 -0
  44. package/dist/ApexUI-Kit/templates/Stepper/Stepper.jsx +129 -0
  45. package/dist/ApexUI-Kit/templates/SwipeCards/SwipeCards.jsx +123 -0
  46. package/dist/ApexUI-Kit/templates/TextRing3D/TextRing3D.jsx +73 -0
  47. package/dist/ApexUI-Kit/templates/TrueFocus/TrueFocus.jsx +64 -0
  48. package/dist/ApexUI-Kit/templates/TypewriterText/TypewriterText.jsx +70 -0
  49. package/dist/cli/index.js +43 -0
  50. package/package.json +1 -1
@@ -1,17 +1,19 @@
1
1
  import React, { useEffect, useRef, useState } from "react";
2
- import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
2
+ import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "framer-motion";
3
3
 
4
4
  /*
5
5
  * Loading Screen: five ways to wait.
6
6
  * orbit – dots orbit a breathing core around the percentage
7
7
  * progress – a big counting percentage, a sliding bar and rotating status lines
8
8
  * blocks – a grid of tiles fills in like a wave
9
- * catch – a mini game: tap the orb before it fades while things load
10
- * runner – a mini game: jump the blocks (Space / ↑ / tap) while things load
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
11
12
  * Pass `progress` (0–100) yourself, or leave it out and it runs on its own.
12
13
  */
13
14
 
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)))];
15
17
 
16
18
  // Simulated progress that eases toward 100 over `duration` ms, then calls onComplete.
17
19
  function useProgress(progress, duration, loop, onComplete) {
@@ -26,7 +28,7 @@ function useProgress(progress, duration, loop, onComplete) {
26
28
  let start = performance.now();
27
29
  let fired = false;
28
30
  const tick = (now) => {
29
- const t = Math.min(1, (now - start) / duration);
31
+ const t = Math.min(1, Math.max(0, (now - start) / duration));
30
32
  // Fast start, slow middle, quick finish — the way real loads feel.
31
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);
32
34
  setAuto(Math.round(eased * 100));
@@ -55,8 +57,21 @@ function useProgress(progress, duration, loop, onComplete) {
55
57
 
56
58
  function Bar({ value, color, track }) {
57
59
  return (
58
- <div className="h-1.5 w-full overflow-hidden rounded-full" style={{ background: track }}>
59
- <div className="h-full rounded-full transition-[width] duration-300 ease-out" style={{ width: `${value}%`, background: color, boxShadow: `0 0 12px ${color}` }} />
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} />
60
75
  </div>
61
76
  );
62
77
  }
@@ -76,10 +91,7 @@ function Orbit({ value, color, moving }) {
76
91
  >
77
92
  {[0, 1, 2].map((d) => (
78
93
  <div key={d} className="absolute inset-0" style={{ transform: `rotate(${d * 120}deg)` }}>
79
- <span
80
- className="absolute left-1/2 top-0 -ml-[5px] -mt-[5px] h-2.5 w-2.5 rounded-full"
81
- style={{ background: color, opacity: 1 - ring * 0.25, boxShadow: `0 0 10px ${color}` }}
82
- />
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}` }} />
83
95
  </div>
84
96
  ))}
85
97
  </motion.div>
@@ -97,12 +109,12 @@ function Orbit({ value, color, moving }) {
97
109
  }
98
110
 
99
111
  function ProgressView({ value, color, track, moving }) {
100
- const message = MESSAGES[Math.min(MESSAGES.length - 1, Math.floor(value / (100 / MESSAGES.length)))];
112
+ const message = messageFor(value);
101
113
  return (
102
114
  <div className="w-[min(22rem,80vw)]">
103
115
  <p className="font-mono text-[clamp(3.5rem,12vw,5.5rem)] font-bold leading-none tabular-nums tracking-tight">
104
116
  {value}
105
- <span className="text-[0.4em] align-top opacity-50">%</span>
117
+ <span className="align-top text-[0.4em] opacity-50">%</span>
106
118
  </p>
107
119
  <div className="mt-5">
108
120
  <Bar value={value} color={color} track={track} />
@@ -144,56 +156,150 @@ function Blocks({ value, color, track, moving }) {
144
156
  );
145
157
  }
146
158
 
147
- function CatchGame({ value, color, track }) {
159
+ /* ---------------------------------------------------------------- Catch game */
160
+
161
+ const ORB_LIFE = 1.5; // seconds before an orb escapes
162
+
163
+ function CatchGame({ value, color, track, dark }) {
148
164
  const [score, setScore] = useState(0);
149
- const [orb, setOrb] = useState({ id: 0, x: 50, y: 50 });
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 });
150
172
 
173
+ // An orb that escapes resets the combo and nudges the arena.
151
174
  useEffect(() => {
152
- const id = setTimeout(() => {
153
- setScore(0);
154
- setOrb((o) => ({ id: o.id + 1, x: 10 + Math.random() * 80, y: 15 + Math.random() * 70 }));
155
- }, 1400);
156
- return () => clearTimeout(id);
157
- }, [orb.id]);
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)";
158
197
 
159
198
  return (
160
- <div className="w-[min(22rem,84vw)]">
161
- <div className="mb-2 flex items-center justify-between font-mono text-[0.7rem] uppercase tracking-[0.16em] opacity-70">
162
- <span>Catch the orb</span>
163
- <span>Score {score}</span>
164
- </div>
165
- <div className="relative h-40 overflow-hidden rounded-2xl border" style={{ borderColor: track, backgroundImage: `radial-gradient(${track} 1px, transparent 1px)`, backgroundSize: "14px 14px" }}>
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. */}
166
235
  <AnimatePresence>
167
236
  <motion.button
168
237
  key={orb.id}
169
238
  type="button"
170
239
  aria-label="Catch the orb"
171
- onPointerDown={() => {
172
- setScore((s) => s + 1);
173
- setOrb((o) => ({ id: o.id + 1, x: 10 + Math.random() * 80, y: 15 + Math.random() * 70 }));
174
- }}
175
- className="absolute h-9 w-9 rounded-full outline-none"
176
- style={{ left: `${orb.x}%`, top: `${orb.y}%`, marginLeft: -18, marginTop: -18, background: color, boxShadow: `0 0 24px ${color}` }}
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" }}
177
243
  initial={{ scale: 0, opacity: 0 }}
178
- animate={{ scale: [0, 1.15, 1, 0.55], opacity: [0, 1, 1, 0.7] }}
179
- exit={{ scale: 1.8, opacity: 0, transition: { duration: 0.25 } }}
180
- transition={{ duration: 1.4, times: [0, 0.15, 0.3, 1], ease: "easeOut" }}
181
- />
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>
182
259
  </AnimatePresence>
183
- </div>
184
- <div className="mt-4 flex items-center gap-3">
185
- <Bar value={value} color={color} track={track} />
186
- <span className="font-mono text-xs tabular-nums opacity-70">{value}%</span>
187
- </div>
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} />
188
291
  </div>
189
292
  );
190
293
  }
191
294
 
192
- function RunnerGame({ value, color, track, ink }) {
295
+ /* --------------------------------------------------------------- Runner game */
296
+
297
+ function RunnerGame({ value, color, track, dark }) {
193
298
  const canvasRef = useRef(null);
299
+ const jumpRef = useRef(() => {});
194
300
  const [score, setScore] = useState(0);
195
301
  const [best, setBest] = useState(0);
196
- const jumpRef = useRef(() => {});
302
+ const [started, setStarted] = useState(false);
197
303
 
198
304
  useEffect(() => {
199
305
  const canvas = canvasRef.current;
@@ -202,7 +308,11 @@ function RunnerGame({ value, color, track, ink }) {
202
308
  let last = performance.now();
203
309
  let w = 0;
204
310
  let h = 0;
205
- const g = { y: 0, vy: 0, obstacles: [], speed: 220, spawn: 0.9, score: 0, flash: 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 };
206
316
 
207
317
  const resize = () => {
208
318
  const r = canvas.getBoundingClientRect();
@@ -217,76 +327,392 @@ function RunnerGame({ value, color, track, ink }) {
217
327
  ro.observe(canvas);
218
328
  resize();
219
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
+ };
220
333
  jumpRef.current = () => {
221
- if (g.y === 0) g.vy = 520;
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();
222
349
  };
223
350
 
224
351
  const tick = (now) => {
225
352
  const dt = Math.min(0.033, (now - last) / 1000);
226
353
  last = now;
227
- const ground = h - 18;
228
- const px = 42;
229
- const size = 18;
354
+ const ground = h - 26;
355
+ const size = 20;
356
+ const px = Math.max(44, w * 0.14);
230
357
 
231
358
  // Physics.
232
- g.vy -= 1500 * dt;
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;
233
367
  g.y = Math.max(0, g.y + g.vy * dt);
234
- if (g.y === 0) g.vy = Math.max(0, g.vy);
235
- g.speed = Math.min(420, g.speed + 6 * 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;
236
376
  g.spawn -= dt;
237
377
  if (g.spawn <= 0) {
238
- const tall = Math.random() < 0.35;
239
- g.obstacles.push({ x: w + 10, w: tall ? 14 : 18, h: tall ? 30 : 18 });
240
- g.spawn = 0.75 + Math.random() * 0.9;
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);
241
382
  }
242
- g.obstacles.forEach((o) => (o.x -= g.speed * dt));
243
- g.obstacles = g.obstacles.filter((o) => o.x + o.w > -10);
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);
244
392
 
245
- // Collision resets the run.
246
393
  const top = ground - g.y - size;
247
- const hit = g.obstacles.some((o) => px + size > o.x + 2 && px < o.x + o.w - 2 && top + size > ground - o.h + 2);
248
- if (hit) {
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) {
249
400
  g.obstacles = [];
250
- g.speed = 220;
251
- g.flash = 0.25;
401
+ g.speed = 240;
402
+ g.shake = 0.3;
403
+ g.flash = 0.35;
252
404
  g.score = 0;
253
405
  }
254
- g.score += dt * 10;
406
+ g.score += dt * 12;
407
+ g.shake = Math.max(0, g.shake - dt);
255
408
  g.flash = Math.max(0, g.flash - dt);
256
409
  const s = Math.floor(g.score);
257
410
  setScore((prev) => (prev === s ? prev : s));
258
411
  setBest((b) => Math.max(b, s));
259
412
 
260
413
  // Draw.
261
- ctx.clearRect(0, 0, w, h);
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.
262
435
  ctx.fillStyle = track;
263
436
  ctx.fillRect(0, ground, w, 2);
264
- for (let x = (now / 12) % 24; x < w; x += 24) ctx.fillRect(w - x, ground + 8, 6, 2);
265
- ctx.fillStyle = g.flash ? "#ef4444" : color;
266
- ctx.shadowColor = color;
267
- ctx.shadowBlur = 12;
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;
268
598
  ctx.beginPath();
269
- ctx.roundRect(px, top, size, size, 5);
599
+ ctx.roundRect(-cw / 2, -ch / 2, cw, ch, cw * 0.32);
270
600
  ctx.fill();
271
601
  ctx.shadowBlur = 0;
272
- ctx.fillStyle = ink;
273
- ctx.globalAlpha = 0.85;
274
- g.obstacles.forEach((o) => {
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;
275
618
  ctx.beginPath();
276
- ctx.roundRect(o.x, ground - o.h, o.w, o.h, 3);
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);
277
623
  ctx.fill();
278
- });
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
+ }
279
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();
280
705
  raf = requestAnimationFrame(tick);
281
706
  };
282
707
  raf = requestAnimationFrame(tick);
283
708
 
284
709
  const onKey = (e) => {
285
- if (e.code === "Space" || e.code === "ArrowUp") {
286
- if (canvas.closest("[data-loading-screen]")?.matches(":hover") || document.activeElement === canvas) {
287
- e.preventDefault();
288
- jumpRef.current();
289
- }
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);
290
716
  }
291
717
  };
292
718
  window.addEventListener("keydown", onKey);
@@ -295,28 +721,38 @@ function RunnerGame({ value, color, track, ink }) {
295
721
  ro.disconnect();
296
722
  window.removeEventListener("keydown", onKey);
297
723
  };
298
- }, [color, track, ink]);
724
+ }, [color, dark]);
299
725
 
300
726
  return (
301
- <div className="w-[min(26rem,86vw)]">
302
- <div className="mb-2 flex items-center justify-between font-mono text-[0.7rem] uppercase tracking-[0.16em] opacity-70">
303
- <span>Space / tap to jump</span>
304
- <span>
305
- {score} · best {best}
306
- </span>
307
- </div>
308
- <canvas
309
- ref={canvasRef}
310
- tabIndex={0}
311
- aria-label="Runner game: press Space or tap to jump"
312
- onPointerDown={() => jumpRef.current()}
313
- className="h-32 w-full cursor-pointer touch-manipulation rounded-2xl border outline-none focus-visible:ring-2"
314
- style={{ borderColor: track }}
315
- />
316
- <div className="mt-4 flex items-center gap-3">
317
- <Bar value={value} color={color} track={track} />
318
- <span className="font-mono text-xs tabular-nums opacity-70">{value}%</span>
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>
319
754
  </div>
755
+ <Footer value={value} color={color} track={track} />
320
756
  </div>
321
757
  );
322
758
  }
@@ -337,8 +773,8 @@ const LoadingScreen = ({
337
773
  const value = useProgress(progress, duration, loop, onComplete);
338
774
  const dark = theme !== "light";
339
775
  const track = dark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.08)";
340
- const ink = dark ? "#e5e5e5" : "#262626";
341
- const shared = { value, color: accentColor, track, ink, moving };
776
+ const shared = { value, color: accentColor, track, moving, dark };
777
+ const game = variant === "catch" || variant === "runner" || variant === "racer";
342
778
 
343
779
  return (
344
780
  <div
@@ -346,14 +782,22 @@ const LoadingScreen = ({
346
782
  role="status"
347
783
  aria-live="polite"
348
784
  aria-label={`${title} ${value}%`}
349
- className={`flex flex-col items-center justify-center gap-6 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}`}
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}
350
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
+ )}
351
794
  {variant === "orbit" && <Orbit {...shared} />}
352
795
  {variant === "progress" && <ProgressView {...shared} />}
353
796
  {variant === "blocks" && <Blocks {...shared} />}
354
797
  {variant === "catch" && <CatchGame {...shared} />}
355
798
  {variant === "runner" && <RunnerGame {...shared} />}
356
- {variant !== "progress" && <p className="text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</p>}
799
+ {variant === "racer" && <RacerGame {...shared} />}
800
+ {!game && variant !== "progress" && <p className="text-[0.7rem] font-semibold uppercase tracking-[0.3em] opacity-50">{title}</p>}
357
801
  </div>
358
802
  );
359
803
  };