@launchui/launch-ui 1.0.0 → 1.0.7

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.
@@ -0,0 +1,449 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState, useMemo } from "react";
4
+ import { motion } from "motion/react";
5
+
6
+ /**
7
+ * Internal utility replacing external lib requirements ensuring zero-friction installations.
8
+ */
9
+ const cn = (...classes: any[]) => {
10
+ return classes.filter(Boolean).join(" ");
11
+ };
12
+
13
+ const FLAP_CHARS = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$()-+&=;:'\"%,./?°";
14
+
15
+ const BOARD_ROWS = 6;
16
+ const BOARD_COLS = 22;
17
+
18
+ const BASE_COL_DELAY = 30;
19
+ const BASE_ROW_DELAY = 20;
20
+ const BASE_STEP_MS = 55;
21
+ const BASE_FLIP_S = 0.35;
22
+ const BASE_TOTAL_S =
23
+ ((BOARD_COLS - 1) * BASE_COL_DELAY +
24
+ (BOARD_ROWS - 1) * BASE_ROW_DELAY +
25
+ 8 * BASE_STEP_MS) /
26
+ 1000;
27
+
28
+ type AccentColor = {
29
+ top: string;
30
+ bottom: string;
31
+ text: string;
32
+ };
33
+
34
+ // CUSTOMIZED PALETTE: Elevated vivid color spectrum slightly shifted from the original.
35
+ const ACCENT_COLORS: AccentColor[] = [
36
+ { top: "bg-rose-600", bottom: "bg-rose-700", text: "text-white" },
37
+ { top: "bg-amber-500", bottom: "bg-amber-600", text: "text-white" },
38
+ { top: "bg-yellow-300", bottom: "bg-yellow-400", text: "text-black" },
39
+ { top: "bg-emerald-600", bottom: "bg-emerald-700", text: "text-white" },
40
+ { top: "bg-cyan-600", bottom: "bg-cyan-700", text: "text-white" },
41
+ { top: "bg-indigo-600", bottom: "bg-indigo-700", text: "text-white" },
42
+ { top: "bg-slate-50", bottom: "bg-slate-200", text: "text-slate-900" },
43
+ ];
44
+
45
+ const CELL_TEXT_STYLE: React.CSSProperties = {
46
+ fontSize: "clamp(6px, 2vw, 22px)",
47
+ lineHeight: 1,
48
+ };
49
+
50
+ // ── Individual Split-Flap Character ───────────────────────────────────
51
+
52
+ const FlapCell = React.memo(function FlapCell({
53
+ target,
54
+ delay,
55
+ stepMs,
56
+ flipDuration,
57
+ }: {
58
+ target: string;
59
+ delay: number;
60
+ stepMs: number;
61
+ flipDuration: number;
62
+ }) {
63
+ const [current, setCurrent] = useState(" ");
64
+ const [prev, setPrev] = useState(" ");
65
+ const [flipId, setFlipId] = useState(0);
66
+ const [accent, setAccent] = useState<AccentColor | null>(null);
67
+ const [prevAccent, setPrevAccent] = useState<AccentColor | null>(null);
68
+ const curRef = useRef(" ");
69
+ const tgtRef = useRef<string | null>(null);
70
+ const accentRef = useRef<AccentColor | null>(null);
71
+ const startTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
72
+ const stepTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
73
+
74
+ useEffect(() => {
75
+ if (startTimer.current) clearTimeout(startTimer.current);
76
+ if (stepTimer.current) clearTimeout(stepTimer.current);
77
+ startTimer.current = null;
78
+ stepTimer.current = null;
79
+
80
+ const normalized = FLAP_CHARS.includes(target.toUpperCase())
81
+ ? target.toUpperCase()
82
+ : " ";
83
+ if (normalized === tgtRef.current) return;
84
+ tgtRef.current = normalized;
85
+
86
+ if (normalized === " " && curRef.current === " ") return;
87
+
88
+ const scrambleCount =
89
+ normalized === " "
90
+ ? 8 + Math.floor(Math.random() * 8)
91
+ : 25 + Math.floor(Math.random() * 15);
92
+
93
+ const runStep = (i: number) => {
94
+ const isLast = i === scrambleCount;
95
+ const ch = isLast
96
+ ? normalized
97
+ : FLAP_CHARS[1 + Math.floor(Math.random() * (FLAP_CHARS.length - 1))];
98
+
99
+ const newAccent = isLast
100
+ ? null
101
+ : Math.random() < 0.2
102
+ ? ACCENT_COLORS[Math.floor(Math.random() * ACCENT_COLORS.length)]
103
+ : null;
104
+
105
+ setPrev(curRef.current);
106
+ setPrevAccent(accentRef.current);
107
+ curRef.current = ch;
108
+ accentRef.current = newAccent;
109
+ setCurrent(ch);
110
+ setAccent(newAccent);
111
+ setFlipId((n) => n + 1);
112
+
113
+ if (!isLast) {
114
+ stepTimer.current = setTimeout(() => runStep(i + 1), stepMs);
115
+ }
116
+ };
117
+
118
+ startTimer.current = setTimeout(() => runStep(1), delay);
119
+
120
+ return () => {
121
+ if (startTimer.current) clearTimeout(startTimer.current);
122
+ if (stepTimer.current) clearTimeout(stepTimer.current);
123
+ startTimer.current = null;
124
+ stepTimer.current = null;
125
+ tgtRef.current = null;
126
+ };
127
+ }, [target, delay, stepMs]);
128
+
129
+ const show = current === " " ? "\u00A0" : current;
130
+ const showPrev = prev === " " ? "\u00A0" : prev;
131
+
132
+ const textCx =
133
+ "absolute inset-x-0 flex select-none items-center justify-center font-mono font-bold tracking-wide";
134
+
135
+ // MODIFIED: Swapped neutral scaling for rich slate/black high-contrast mapping
136
+ const topBg = accent?.top ?? "bg-slate-100 dark:bg-black";
137
+ const bottomBg = accent?.bottom ?? "bg-slate-200 dark:bg-zinc-900";
138
+ const textColor = accent?.text ?? "text-slate-800 dark:text-zinc-100";
139
+
140
+ const flapTopBg = prevAccent?.top ?? "bg-slate-50 dark:bg-zinc-800";
141
+ const flapTextColor = prevAccent?.text ?? "text-slate-800 dark:text-zinc-100";
142
+
143
+ const bottomDelay = flipDuration * 0.5;
144
+
145
+ return (
146
+ <div className="flex aspect-3/6 flex-col overflow-hidden rounded-[2px] border border-slate-300 md:rounded-[3px] md:border-2 dark:border-black">
147
+ {/* Flap content area */}
148
+ <div className="relative flex-1 [perspective:800px] [transform-style:preserve-3d]">
149
+ <div className="absolute inset-0 z-40 hidden flex-row items-center justify-center md:flex">
150
+ <div className="h-1/2 w-px rounded-tr-sm rounded-br-sm bg-slate-300 dark:bg-black" />
151
+ <div className="flex h-px flex-1 bg-slate-300 dark:bg-black" />
152
+ <div className="h-1/2 w-px rounded-tl-sm rounded-bl-sm bg-slate-300 dark:bg-black" />
153
+ </div>
154
+
155
+ {/* Static top – new character top half */}
156
+ <div
157
+ className={cn(
158
+ "absolute inset-x-0 top-0 h-[calc(50%-0.5px)] overflow-hidden rounded-t-[3px]",
159
+ topBg,
160
+ )}
161
+ >
162
+ <div
163
+ className={cn(textCx, textColor, "top-0 h-[200%]")}
164
+ style={CELL_TEXT_STYLE}
165
+ >
166
+ {show}
167
+ </div>
168
+ </div>
169
+
170
+ {/* Static bottom – new character bottom half */}
171
+ <div
172
+ className={cn(
173
+ "absolute inset-x-0 bottom-0 h-[calc(50%-0.5px)] overflow-hidden rounded-b-[3px]",
174
+ bottomBg,
175
+ )}
176
+ >
177
+ <div
178
+ className={cn(textCx, textColor, "bottom-0 h-[200%]")}
179
+ style={CELL_TEXT_STYLE}
180
+ >
181
+ {show}
182
+ </div>
183
+ {flipId > 0 && (
184
+ <motion.div
185
+ key={`s${flipId}`}
186
+ className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,rgba(255,255,255,0.8),transparent_60%)] dark:bg-[linear-gradient(to_bottom,rgba(0,0,0,0.8),transparent_60%)]"
187
+ initial={{ opacity: 0.5 }}
188
+ animate={{ opacity: 0 }}
189
+ transition={{ duration: flipDuration * 1.3, ease: "easeOut" }}
190
+ />
191
+ )}
192
+ </div>
193
+
194
+ {/* Flipping top flap – old character top half, drops down */}
195
+ {flipId > 0 && (
196
+ <motion.div
197
+ key={flipId}
198
+ className={cn(
199
+ "absolute inset-x-0 top-0 z-10 h-[calc(50%-0.5px)] origin-bottom overflow-hidden rounded-t-[3px] [backface-visibility:hidden] [transform-style:preserve-3d]",
200
+ flapTopBg,
201
+ )}
202
+ initial={{ rotateX: 0 }}
203
+ animate={{ rotateX: -100 }}
204
+ transition={{
205
+ duration: flipDuration,
206
+ ease: [0.55, 0.055, 0.675, 0.19],
207
+ }}
208
+ >
209
+ <div
210
+ className={cn(textCx, flapTextColor, "top-0 h-[200%]")}
211
+ style={CELL_TEXT_STYLE}
212
+ >
213
+ {showPrev}
214
+ </div>
215
+ <motion.div
216
+ className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,rgba(255,255,255,0),rgba(255,255,255,1))] dark:bg-[linear-gradient(to_bottom,rgba(0,0,0,0),rgba(0,0,0,1))]"
217
+ initial={{ opacity: 0 }}
218
+ animate={{ opacity: 0.6 }}
219
+ transition={{ duration: flipDuration }}
220
+ />
221
+ </motion.div>
222
+ )}
223
+
224
+ {/* Flipping bottom flap – new character bottom half, rises up */}
225
+ {flipId > 0 && (
226
+ <motion.div
227
+ key={`b${flipId}`}
228
+ className={cn(
229
+ "absolute inset-x-0 bottom-0 z-10 h-[calc(50%-0.5px)] origin-top overflow-hidden rounded-b-[3px] [backface-visibility:hidden] [transform-style:preserve-3d]",
230
+ bottomBg,
231
+ )}
232
+ initial={{ rotateX: 90 }}
233
+ animate={{ rotateX: 0 }}
234
+ transition={{
235
+ duration: flipDuration * 0.85,
236
+ delay: bottomDelay,
237
+ ease: [0.33, 1.55, 0.64, 1],
238
+ }}
239
+ >
240
+ <div
241
+ className={cn(textCx, textColor, "bottom-0 h-[200%]")}
242
+ style={CELL_TEXT_STYLE}
243
+ >
244
+ {show}
245
+ </div>
246
+ <motion.div
247
+ className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgba(255,255,255,0),rgba(255,255,255,0.6))] dark:bg-[linear-gradient(to_top,rgba(0,0,0,0),rgba(0,0,0,0.6))]"
248
+ initial={{ opacity: 0.4 }}
249
+ animate={{ opacity: 0 }}
250
+ transition={{
251
+ duration: flipDuration * 0.85,
252
+ delay: bottomDelay,
253
+ }}
254
+ />
255
+ </motion.div>
256
+ )}
257
+
258
+ {/* Split line */}
259
+ <div className="pointer-events-none absolute inset-x-0 top-1/2 z-20 h-px -translate-y-[0.5px] bg-slate-400/50 dark:bg-black/50" />
260
+ </div>
261
+
262
+ {/* Bottom stripes – decorative, outside the flap area */}
263
+ <div className="h-2 w-full bg-[repeating-linear-gradient(to_bottom,currentColor_0,currentColor_1px,transparent_1px,transparent_0.15rem)] text-slate-400 opacity-20 md:h-4 md:bg-[repeating-linear-gradient(to_bottom,currentColor_0,currentColor_1px,transparent_1px,transparent_0.2rem)] dark:text-black dark:opacity-100" />
264
+ </div>
265
+ );
266
+ },
267
+ (prevProps, nextProps) =>
268
+ prevProps.target === nextProps.target &&
269
+ prevProps.delay === nextProps.delay &&
270
+ prevProps.stepMs === nextProps.stepMs &&
271
+ prevProps.flipDuration === nextProps.flipDuration,
272
+ );
273
+
274
+ // ── Color Tile ────────────────────────────────────────────────────────
275
+
276
+ const COLOR_MAP: Record<string, string> = {
277
+ "{R}": "#E11D48", // rose 600
278
+ "{O}": "#D97706", // amber 600
279
+ "{Y}": "#FBBF24", // yellow 400
280
+ "{G}": "#059669", // emerald 600
281
+ "{B}": "#0891B2", // cyan 600
282
+ "{V}": "#4F46E5", // indigo 600
283
+ "{W}": "#F8FAFC", // slate 50
284
+ };
285
+
286
+ const ColorCell = React.memo(function ColorCell({ color }: { color: string }) {
287
+ return (
288
+ <div
289
+ className="aspect-3/5 rounded-[3px] border-2 border-slate-300 dark:border-black"
290
+ style={{ backgroundColor: color }}
291
+ />
292
+ );
293
+ });
294
+
295
+ // ── Row Parser ────────────────────────────────────────────────────────
296
+
297
+ type ParsedCell =
298
+ | { type: "char"; value: string }
299
+ | { type: "color"; hex: string };
300
+
301
+ function parseRow(row: string): ParsedCell[] {
302
+ const cells: ParsedCell[] = [];
303
+ let i = 0;
304
+ while (i < row.length) {
305
+ if (row[i] === "{" && i + 2 < row.length && row[i + 2] === "}") {
306
+ const code = row.substring(i, i + 3);
307
+ if (COLOR_MAP[code]) {
308
+ cells.push({ type: "color", hex: COLOR_MAP[code] });
309
+ i += 3;
310
+ continue;
311
+ }
312
+ }
313
+ cells.push({ type: "char", value: row[i] });
314
+ i++;
315
+ }
316
+ return cells;
317
+ }
318
+
319
+ // ── Word Wrap ─────────────────────────────────────────────────────────
320
+
321
+ function wrapParagraph(paragraph: string, maxCols: number): string[] {
322
+ const lines: string[] = [];
323
+ const words = paragraph.split(/[ \t]+/).filter(Boolean);
324
+ let currentLine = "";
325
+
326
+ for (const word of words) {
327
+ if (word.length > maxCols) {
328
+ if (currentLine) {
329
+ lines.push(currentLine);
330
+ currentLine = "";
331
+ }
332
+ lines.push(word.slice(0, maxCols));
333
+ continue;
334
+ }
335
+
336
+ if (!currentLine) {
337
+ currentLine = word;
338
+ } else if (currentLine.length + 1 + word.length <= maxCols) {
339
+ currentLine += " " + word;
340
+ } else {
341
+ lines.push(currentLine);
342
+ currentLine = word;
343
+ }
344
+ }
345
+
346
+ if (currentLine) lines.push(currentLine);
347
+ return lines;
348
+ }
349
+
350
+ function wrapText(input: string, maxCols: number): string[] {
351
+ return input
352
+ .split("\n")
353
+ .flatMap((paragraph) =>
354
+ paragraph.trim() === "" ? [""] : wrapParagraph(paragraph, maxCols),
355
+ );
356
+ }
357
+
358
+ // ── Main TextFlippingBoard Component ──────────────────────────────────
359
+
360
+ export interface TextFlippingBoardProps {
361
+ rows?: string[];
362
+ text?: string;
363
+ className?: string;
364
+ /** Total animation duration in seconds. Defaults to ~1.2s. */
365
+ duration?: number;
366
+ }
367
+
368
+ export function TextFlippingBoard({
369
+ rows,
370
+ text,
371
+ className,
372
+ duration = BASE_TOTAL_S,
373
+ }: TextFlippingBoardProps) {
374
+ const scale = duration / BASE_TOTAL_S;
375
+ const colDelay = BASE_COL_DELAY * scale;
376
+ const rowDelay = BASE_ROW_DELAY * scale;
377
+ const stepMs = BASE_STEP_MS * scale;
378
+ const flipDur = Math.min(0.6, Math.max(0.15, BASE_FLIP_S * scale));
379
+
380
+ const board = useMemo(() => {
381
+ const grid: ParsedCell[][] = Array.from({ length: BOARD_ROWS }, () =>
382
+ Array.from({ length: BOARD_COLS }, () => ({
383
+ type: "char" as const,
384
+ value: " ",
385
+ })),
386
+ );
387
+
388
+ if (text) {
389
+ const lines = wrapText(text, BOARD_COLS).slice(0, BOARD_ROWS);
390
+ const startRow = Math.max(0, Math.floor((BOARD_ROWS - lines.length) / 2));
391
+ lines.forEach((line, i) => {
392
+ const row = startRow + i;
393
+ if (row >= BOARD_ROWS) return;
394
+ const parsed = parseRow(line);
395
+ const startCol = Math.max(
396
+ 0,
397
+ Math.floor((BOARD_COLS - parsed.length) / 2),
398
+ );
399
+ parsed.forEach((cell, c) => {
400
+ if (startCol + c < BOARD_COLS) {
401
+ grid[row][startCol + c] = cell;
402
+ }
403
+ });
404
+ });
405
+ } else if (rows) {
406
+ rows.forEach((row, r) => {
407
+ if (r >= BOARD_ROWS) return;
408
+ const parsed = parseRow(row);
409
+ parsed.forEach((cell, c) => {
410
+ if (c < BOARD_COLS) {
411
+ grid[r][c] = cell;
412
+ }
413
+ });
414
+ });
415
+ }
416
+
417
+ return grid;
418
+ }, [rows, text]);
419
+
420
+ return (
421
+ <div
422
+ className={cn(
423
+ "relative mx-auto w-full max-w-3xl rounded-xl bg-slate-100 p-2 shadow-xl md:rounded-2xl md:p-4 dark:bg-zinc-950 dark:shadow-[0_20px_70px_-15px_rgba(0,0,0,0.6)]",
424
+ className,
425
+ )}
426
+ >
427
+ <div
428
+ className="grid gap-px md:gap-[3px]"
429
+ style={{ gridTemplateColumns: `repeat(${BOARD_COLS}, 1fr)` }}
430
+ >
431
+ {board.map((row, r) =>
432
+ row.map((cell, c) =>
433
+ cell.type === "color" ? (
434
+ <ColorCell key={`${r}-${c}`} color={cell.hex} />
435
+ ) : (
436
+ <FlapCell
437
+ key={`${r}-${c}`}
438
+ target={cell.value}
439
+ delay={c * colDelay + r * rowDelay}
440
+ stepMs={stepMs}
441
+ flipDuration={flipDur}
442
+ />
443
+ ),
444
+ ),
445
+ )}
446
+ </div>
447
+ </div>
448
+ );
449
+ }