@zmzai/theme 0.1.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 (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +76 -0
  3. package/package.json +91 -0
  4. package/src/brand/index.ts +3 -0
  5. package/src/brand/logo/Logo.tsx +46 -0
  6. package/src/brand/logo/favicon.png +0 -0
  7. package/src/brand/logo/index.ts +2 -0
  8. package/src/brand/logo/zmzai-logo.png +0 -0
  9. package/src/brand/wordmark/Wordmark.tsx +48 -0
  10. package/src/brand/wordmark/index.ts +2 -0
  11. package/src/components/aceternity-sidebar/AceternitySidebar.tsx +133 -0
  12. package/src/components/aceternity-sidebar/index.ts +2 -0
  13. package/src/components/animated-tabs/AnimatedTabs.tsx +60 -0
  14. package/src/components/animated-tabs/index.ts +2 -0
  15. package/src/components/animated-tooltip/AnimatedTooltip.tsx +93 -0
  16. package/src/components/animated-tooltip/index.ts +2 -0
  17. package/src/components/avatar/Avatar.tsx +59 -0
  18. package/src/components/avatar/index.ts +2 -0
  19. package/src/components/badge/Badge.tsx +45 -0
  20. package/src/components/badge/index.ts +2 -0
  21. package/src/components/bento-grid/BentoGrid.tsx +85 -0
  22. package/src/components/bento-grid/index.ts +2 -0
  23. package/src/components/button/Button.tsx +66 -0
  24. package/src/components/button/button.styles.ts +41 -0
  25. package/src/components/button/index.ts +4 -0
  26. package/src/components/card/Card.tsx +87 -0
  27. package/src/components/card/card.styles.ts +35 -0
  28. package/src/components/card/index.ts +4 -0
  29. package/src/components/card-3d/Card3D.tsx +84 -0
  30. package/src/components/card-3d/index.ts +2 -0
  31. package/src/components/card-hover-effect/CardHoverEffect.tsx +83 -0
  32. package/src/components/card-hover-effect/index.ts +2 -0
  33. package/src/components/card-spotlight/CardSpotlight.tsx +63 -0
  34. package/src/components/card-spotlight/index.ts +2 -0
  35. package/src/components/carousel/Carousel.tsx +79 -0
  36. package/src/components/carousel/index.ts +2 -0
  37. package/src/components/code-block/CodeBlock.tsx +153 -0
  38. package/src/components/code-block/index.ts +2 -0
  39. package/src/components/comet-card/CometCard.tsx +106 -0
  40. package/src/components/comet-card/index.ts +2 -0
  41. package/src/components/compare/Compare.tsx +128 -0
  42. package/src/components/compare/index.ts +2 -0
  43. package/src/components/dialog/Dialog.tsx +79 -0
  44. package/src/components/dialog/index.ts +10 -0
  45. package/src/components/dropdown-menu/DropdownMenu.tsx +76 -0
  46. package/src/components/dropdown-menu/index.ts +8 -0
  47. package/src/components/empty-state/EmptyState.tsx +78 -0
  48. package/src/components/empty-state/index.ts +2 -0
  49. package/src/components/encrypt-text/EncryptText.tsx +102 -0
  50. package/src/components/encrypt-text/index.ts +2 -0
  51. package/src/components/evervault-card/EvervaultCard.tsx +114 -0
  52. package/src/components/evervault-card/index.ts +2 -0
  53. package/src/components/expandable-card/ExpandableCard.tsx +91 -0
  54. package/src/components/expandable-card/index.ts +2 -0
  55. package/src/components/file-upload/FileUpload.tsx +171 -0
  56. package/src/components/file-upload/index.ts +2 -0
  57. package/src/components/flip-words/FlipWords.tsx +70 -0
  58. package/src/components/flip-words/index.ts +2 -0
  59. package/src/components/floating-navbar/FloatingNavbar.tsx +82 -0
  60. package/src/components/floating-navbar/index.ts +2 -0
  61. package/src/components/focus-cards/FocusCards.tsx +93 -0
  62. package/src/components/focus-cards/index.ts +2 -0
  63. package/src/components/glare-card/GlareCard.tsx +66 -0
  64. package/src/components/glare-card/index.ts +2 -0
  65. package/src/components/glowing-effect/GlowingEffect.tsx +68 -0
  66. package/src/components/glowing-effect/index.ts +2 -0
  67. package/src/components/hover-border-gradient/HoverBorderGradient.tsx +80 -0
  68. package/src/components/hover-border-gradient/index.ts +2 -0
  69. package/src/components/index.ts +71 -0
  70. package/src/components/infinite-moving-cards/InfiniteMovingCards.tsx +84 -0
  71. package/src/components/infinite-moving-cards/index.ts +2 -0
  72. package/src/components/input/Input.tsx +35 -0
  73. package/src/components/input/index.ts +4 -0
  74. package/src/components/input/input.styles.ts +34 -0
  75. package/src/components/keyboard/Keyboard.tsx +38 -0
  76. package/src/components/keyboard/index.ts +2 -0
  77. package/src/components/layout-grid/LayoutGrid.tsx +121 -0
  78. package/src/components/layout-grid/index.ts +2 -0
  79. package/src/components/link-preview/LinkPreview.tsx +121 -0
  80. package/src/components/link-preview/index.ts +2 -0
  81. package/src/components/loader/Loader.tsx +130 -0
  82. package/src/components/loader/index.ts +2 -0
  83. package/src/components/magnetic-button/MagneticButton.tsx +79 -0
  84. package/src/components/magnetic-button/index.ts +2 -0
  85. package/src/components/moving-border/MovingBorder.tsx +72 -0
  86. package/src/components/moving-border/index.ts +2 -0
  87. package/src/components/multi-step-loader/MultiStepLoader.tsx +95 -0
  88. package/src/components/multi-step-loader/index.ts +2 -0
  89. package/src/components/notch/Notch.tsx +47 -0
  90. package/src/components/notch/index.ts +2 -0
  91. package/src/components/select/Select.tsx +100 -0
  92. package/src/components/select/index.ts +10 -0
  93. package/src/components/skeleton/Skeleton.tsx +34 -0
  94. package/src/components/skeleton/index.ts +2 -0
  95. package/src/components/stateful-button/StatefulButton.tsx +115 -0
  96. package/src/components/stateful-button/index.ts +2 -0
  97. package/src/components/sticky-banner/StickyBanner.tsx +74 -0
  98. package/src/components/sticky-banner/index.ts +2 -0
  99. package/src/components/sticky-scroll-reveal/StickyScrollReveal.tsx +141 -0
  100. package/src/components/sticky-scroll-reveal/index.ts +2 -0
  101. package/src/components/terminal/Terminal.tsx +55 -0
  102. package/src/components/terminal/index.ts +2 -0
  103. package/src/components/text-generate/TextGenerate.tsx +59 -0
  104. package/src/components/text-generate/index.ts +2 -0
  105. package/src/components/text-hover-effect/TextHoverEffect.tsx +90 -0
  106. package/src/components/text-hover-effect/index.ts +2 -0
  107. package/src/components/textarea/Textarea.tsx +39 -0
  108. package/src/components/textarea/index.ts +2 -0
  109. package/src/components/timeline/Timeline.tsx +106 -0
  110. package/src/components/timeline/index.ts +3 -0
  111. package/src/components/tooltip/Tooltip.tsx +51 -0
  112. package/src/components/tooltip/index.ts +2 -0
  113. package/src/components/tooltip-card/TooltipCard.tsx +130 -0
  114. package/src/components/tooltip-card/index.ts +2 -0
  115. package/src/components/tracing-beam/TracingBeam.tsx +110 -0
  116. package/src/components/tracing-beam/index.ts +2 -0
  117. package/src/components/typewriter/Typewriter.tsx +69 -0
  118. package/src/components/typewriter/index.ts +2 -0
  119. package/src/components/vanish-input/VanishInput.tsx +170 -0
  120. package/src/components/vanish-input/index.ts +2 -0
  121. package/src/components/wobble-card/WobbleCard.tsx +77 -0
  122. package/src/components/wobble-card/index.ts +2 -0
  123. package/src/index.ts +21 -0
  124. package/src/tokens/fonts.css +42 -0
  125. package/src/tokens/theme.css +120 -0
  126. package/src/utils/cn.ts +13 -0
  127. package/src/utils/index.ts +2 -0
  128. package/src/utils/motion.ts +47 -0
  129. package/src/vite-env.d.ts +6 -0
@@ -0,0 +1,79 @@
1
+ "use client";
2
+
3
+ import { type ReactNode } from "react";
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { motion, AnimatePresence } from "framer-motion";
6
+ import { cn } from "../../utils/cn";
7
+
8
+ export const Dialog = DialogPrimitive.Root;
9
+ export const DialogTrigger = DialogPrimitive.Trigger;
10
+
11
+ export interface DialogContentProps {
12
+ children: ReactNode;
13
+ className?: string;
14
+ }
15
+
16
+ /**
17
+ * DialogContent — animated modal content (Radix + framer-motion spring).
18
+ *
19
+ * Uses Radix for focus trap / escape / portal, framer-motion for
20
+ * scale+fade spring animation on open/close.
21
+ *
22
+ * @example
23
+ * <Dialog>
24
+ * <DialogTrigger asChild><Button>打开</Button></DialogTrigger>
25
+ * <DialogContent>
26
+ * <DialogHeader>
27
+ * <DialogTitle>确认操作</DialogTitle>
28
+ * <DialogDescription>此操作不可撤销。</DialogDescription>
29
+ * </DialogHeader>
30
+ * <p>内容…</p>
31
+ * <DialogFooter>
32
+ * <DialogClose asChild><Button variant="secondary">取消</Button></DialogClose>
33
+ * <DialogClose asChild><Button>确认</Button></DialogClose>
34
+ * </DialogFooter>
35
+ * </DialogContent>
36
+ * </Dialog>
37
+ */
38
+ export function DialogContent({ children, className }: DialogContentProps) {
39
+ return (
40
+ <DialogPrimitive.Portal>
41
+ <DialogPrimitive.Overlay asChild>
42
+ <motion.div
43
+ initial={{ opacity: 0 }}
44
+ animate={{ opacity: 1 }}
45
+ exit={{ opacity: 0 }}
46
+ transition={{ duration: 0.2 }}
47
+ className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
48
+ />
49
+ </DialogPrimitive.Overlay>
50
+ <DialogPrimitive.Content asChild>
51
+ <motion.div
52
+ initial={{ opacity: 0, scale: 0.95, y: 10 }}
53
+ animate={{ opacity: 1, scale: 1, y: 0 }}
54
+ exit={{ opacity: 0, scale: 0.95, y: 10 }}
55
+ transition={{ type: "spring", stiffness: 400, damping: 30 }}
56
+ className={cn(
57
+ "fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2",
58
+ "rounded-xl border border-line bg-bg p-6 shadow-xl",
59
+ className
60
+ )}
61
+ >
62
+ {children}
63
+ </motion.div>
64
+ </DialogPrimitive.Content>
65
+ </DialogPrimitive.Portal>
66
+ );
67
+ }
68
+
69
+ export function DialogHeader({ children, className }: { children: ReactNode; className?: string }) {
70
+ return <div className={cn("mb-4 flex flex-col gap-1", className)}>{children}</div>;
71
+ }
72
+
73
+ export function DialogFooter({ children, className }: { children: ReactNode; className?: string }) {
74
+ return <div className={cn("mt-6 flex justify-end gap-2", className)}>{children}</div>;
75
+ }
76
+
77
+ export const DialogTitle = DialogPrimitive.Title;
78
+ export const DialogDescription = DialogPrimitive.Description;
79
+ export const DialogClose = DialogPrimitive.Close;
@@ -0,0 +1,10 @@
1
+ export {
2
+ Dialog,
3
+ DialogTrigger,
4
+ DialogContent,
5
+ DialogHeader,
6
+ DialogFooter,
7
+ DialogTitle,
8
+ DialogDescription,
9
+ DialogClose,
10
+ } from "./Dialog";
@@ -0,0 +1,76 @@
1
+ "use client";
2
+
3
+ import { type ReactNode } from "react";
4
+ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5
+ import { motion } from "framer-motion";
6
+ import { cn } from "../../utils/cn";
7
+
8
+ export const DropdownMenu = DropdownMenuPrimitive.Root;
9
+ export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
10
+
11
+ export function DropdownMenuContent({
12
+ children,
13
+ className,
14
+ align = "start",
15
+ sideOffset = 4,
16
+ }: {
17
+ children: ReactNode;
18
+ className?: string;
19
+ align?: "start" | "center" | "end";
20
+ sideOffset?: number;
21
+ }) {
22
+ return (
23
+ <DropdownMenuPrimitive.Portal>
24
+ <DropdownMenuPrimitive.Content
25
+ align={align}
26
+ sideOffset={sideOffset}
27
+ className={cn(
28
+ "z-50 min-w-[10rem] overflow-hidden rounded-lg border border-line bg-bg p-1 shadow-lg",
29
+ "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
30
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
31
+ className
32
+ )}
33
+ >
34
+ {children}
35
+ </DropdownMenuPrimitive.Content>
36
+ </DropdownMenuPrimitive.Portal>
37
+ );
38
+ }
39
+
40
+ export function DropdownMenuItem({
41
+ children,
42
+ className,
43
+ onSelect,
44
+ destructive,
45
+ }: {
46
+ children: ReactNode;
47
+ className?: string;
48
+ onSelect?: () => void;
49
+ destructive?: boolean;
50
+ }) {
51
+ return (
52
+ <DropdownMenuPrimitive.Item
53
+ onSelect={onSelect}
54
+ className={cn(
55
+ "flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 text-sm outline-none transition-colors",
56
+ "focus:bg-surface-2 focus:text-ink data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
57
+ destructive ? "text-danger focus:bg-danger/10" : "text-ink-2",
58
+ className
59
+ )}
60
+ >
61
+ {children}
62
+ </DropdownMenuPrimitive.Item>
63
+ );
64
+ }
65
+
66
+ export function DropdownMenuSeparator({ className }: { className?: string }) {
67
+ return <div className={cn("my-1 h-px bg-line", className)} />;
68
+ }
69
+
70
+ export function DropdownMenuLabel({ children, className }: { children: ReactNode; className?: string }) {
71
+ return (
72
+ <div className={cn("px-2.5 py-1.5 text-xs font-semibold text-ink-3", className)}>
73
+ {children}
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,8 @@
1
+ export {
2
+ DropdownMenu,
3
+ DropdownMenuTrigger,
4
+ DropdownMenuContent,
5
+ DropdownMenuItem,
6
+ DropdownMenuSeparator,
7
+ DropdownMenuLabel,
8
+ } from "./DropdownMenu";
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { type ReactNode } from "react";
4
+ import { motion } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface EmptyStateProps {
8
+ /** Optional icon/illustration node (default: a muted dot grid) */
9
+ icon?: ReactNode;
10
+ title: string;
11
+ description?: string;
12
+ /** Optional call-to-action node (button, link, etc.) */
13
+ action?: ReactNode;
14
+ className?: string;
15
+ }
16
+
17
+ const defaultIcon = (
18
+ <svg
19
+ viewBox="0 0 64 64"
20
+ fill="none"
21
+ className="h-12 w-12 text-ink-3"
22
+ aria-hidden
23
+ >
24
+ <circle cx="32" cy="32" r="28" stroke="currentColor" strokeWidth="2" />
25
+ <path
26
+ d="M22 34c2.5 3 5.5 4.5 10 4.5S39.5 37 42 34"
27
+ stroke="currentColor"
28
+ strokeWidth="2"
29
+ strokeLinecap="round"
30
+ />
31
+ <circle cx="24" cy="26" r="2" fill="currentColor" />
32
+ <circle cx="40" cy="26" r="2" fill="currentColor" />
33
+ </svg>
34
+ );
35
+
36
+ /**
37
+ * EmptyState — a designed, centered empty placeholder.
38
+ *
39
+ * Icon + title + description + optional CTA, with generous padding and
40
+ * muted colors. Fades/slides in on mount via framer-motion.
41
+ *
42
+ * @example
43
+ * <EmptyState
44
+ * title="暂无数据"
45
+ * description="创建第一条记录后将会显示在这里"
46
+ * action={<Button>新建</Button>}
47
+ * />
48
+ */
49
+ export function EmptyState({
50
+ icon,
51
+ title,
52
+ description,
53
+ action,
54
+ className,
55
+ }: EmptyStateProps) {
56
+ return (
57
+ <motion.div
58
+ initial={{ opacity: 0, y: 8 }}
59
+ animate={{ opacity: 1, y: 0 }}
60
+ transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
61
+ className={cn(
62
+ "flex w-full flex-col items-center justify-center px-6 py-16 text-center",
63
+ className
64
+ )}
65
+ >
66
+ <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-surface-2">
67
+ {icon ?? defaultIcon}
68
+ </div>
69
+ <h3 className="text-base font-semibold text-ink">{title}</h3>
70
+ {description && (
71
+ <p className="mt-2 max-w-sm text-sm leading-relaxed text-ink-2">
72
+ {description}
73
+ </p>
74
+ )}
75
+ {action && <div className="mt-6">{action}</div>}
76
+ </motion.div>
77
+ );
78
+ }
@@ -0,0 +1,2 @@
1
+ export { EmptyState } from "./EmptyState";
2
+ export type { EmptyStateProps } from "./EmptyState";
@@ -0,0 +1,102 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState, type HTMLAttributes } from "react";
4
+ import { cn } from "../../utils/cn";
5
+
6
+ export interface EncryptTextProps extends HTMLAttributes<HTMLSpanElement> {
7
+ /** The final decrypted text */
8
+ text: string;
9
+ /** Total decrypt duration in seconds */
10
+ duration?: number;
11
+ /** When to trigger the effect */
12
+ trigger?: "mount" | "hover";
13
+ }
14
+
15
+ const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
16
+
17
+ function randomChar(): string {
18
+ return CHARS[Math.floor(Math.random() * CHARS.length)];
19
+ }
20
+
21
+ /**
22
+ * EncryptText — text that starts scrambled and "decrypts" to the real text.
23
+ *
24
+ * Uses requestAnimationFrame to cycle random characters, then settles each
25
+ * character to its real value progressively over `duration`.
26
+ *
27
+ * - `trigger="mount"` (default): decrypts on mount
28
+ * - `trigger="hover"`: waits until hovered, then decrypts
29
+ *
30
+ * @example
31
+ * <EncryptText text="ACCESS_GRANTED" duration={2} />
32
+ * <EncryptText text="Hover me" trigger="hover" />
33
+ */
34
+ export function EncryptText({
35
+ text,
36
+ duration = 2,
37
+ trigger = "mount",
38
+ className,
39
+ ...props
40
+ }: EncryptTextProps) {
41
+ const [display, setDisplay] = useState(
42
+ trigger === "mount" ? text : text
43
+ );
44
+ const [active, setActive] = useState(trigger === "mount");
45
+ const rafRef = useRef<number | null>(null);
46
+
47
+ const run = useCallback(() => {
48
+ const totalChars = text.length;
49
+ const startTime = performance.now();
50
+ const totalMs = duration * 1000;
51
+
52
+ const tick = (now: number) => {
53
+ const elapsed = now - startTime;
54
+ const progress = Math.min(elapsed / totalMs, 1);
55
+ const revealedCount = Math.floor(progress * totalChars);
56
+
57
+ let out = "";
58
+ for (let i = 0; i < totalChars; i++) {
59
+ const ch = text[i];
60
+ if (ch === " ") {
61
+ out += " ";
62
+ } else if (i < revealedCount) {
63
+ out += ch;
64
+ } else {
65
+ out += randomChar();
66
+ }
67
+ }
68
+ setDisplay(out);
69
+
70
+ if (progress < 1) {
71
+ rafRef.current = requestAnimationFrame(tick);
72
+ } else {
73
+ setDisplay(text);
74
+ rafRef.current = null;
75
+ }
76
+ };
77
+
78
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
79
+ rafRef.current = requestAnimationFrame(tick);
80
+ }, [text, duration]);
81
+
82
+ useEffect(() => {
83
+ if (active) run();
84
+ return () => {
85
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
86
+ };
87
+ }, [active, run]);
88
+
89
+ const handleMouseEnter = () => {
90
+ if (trigger === "hover" && !active) setActive(true);
91
+ };
92
+
93
+ return (
94
+ <span
95
+ className={cn("font-mono", trigger === "hover" && "cursor-pointer", className)}
96
+ onMouseEnter={handleMouseEnter}
97
+ {...props}
98
+ >
99
+ {display}
100
+ </span>
101
+ );
102
+ }
@@ -0,0 +1,2 @@
1
+ export { EncryptText } from "./EncryptText";
2
+ export type { EncryptTextProps } from "./EncryptText";
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState, type ReactNode } from "react";
4
+ import { motion } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface EvervaultCardProps {
8
+ children: ReactNode;
9
+ className?: string;
10
+ /** Text shown scrambled as random chars until hovered (default "ENCRYPTED") */
11
+ encryptedText?: string;
12
+ }
13
+
14
+ const CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*";
15
+
16
+ /**
17
+ * EvervaultCard — a dark "encrypted" card whose label shows random chars
18
+ * that resolve into the real text on hover.
19
+ *
20
+ * On mount the label scrambles through random characters. When hovered, the
21
+ * scramble resolves character-by-character into `encryptedText` (kept even
22
+ * when not hovered, so the label is meaningful — the scramble is the
23
+ * effect). Children are revealed/focused on hover.
24
+ *
25
+ * @example
26
+ * <EvervaultCard encryptedText="DECRYPTED">
27
+ * <div className="text-white">隐藏内容</div>
28
+ * </EvervaultCard>
29
+ */
30
+ export function EvervaultCard({
31
+ children,
32
+ className,
33
+ encryptedText = "ENCRYPTED",
34
+ }: EvervaultCardProps) {
35
+ const [hovered, setHovered] = useState(false);
36
+ const [display, setDisplay] = useState(() => scramble(encryptedText.length));
37
+ const frame = useRef<number | null>(null);
38
+
39
+ useEffect(() => {
40
+ function tick(progress: number) {
41
+ const revealed = Math.floor(progress * encryptedText.length);
42
+ const next = encryptedText
43
+ .split("")
44
+ .map((ch, i) => {
45
+ if (ch === " ") return " ";
46
+ return i < revealed ? ch : randomChar();
47
+ })
48
+ .join("");
49
+ setDisplay(next);
50
+ }
51
+
52
+ if (hovered) {
53
+ // Resolve: animate reveal from 0 → 1 over ~600ms
54
+ cancelAnimationFrame(frame.current ?? 0);
55
+ const start = performance.now();
56
+ const duration = 600;
57
+ const step = (now: number) => {
58
+ const p = Math.min((now - start) / duration, 1);
59
+ tick(p);
60
+ if (p < 1) frame.current = requestAnimationFrame(step);
61
+ };
62
+ frame.current = requestAnimationFrame(step);
63
+ } else {
64
+ // Scramble: keep cycling random chars
65
+ cancelAnimationFrame(frame.current ?? 0);
66
+ const interval = setInterval(() => {
67
+ setDisplay(scramble(encryptedText.length));
68
+ }, 60);
69
+ return () => clearInterval(interval);
70
+ }
71
+
72
+ return () => {
73
+ if (frame.current) cancelAnimationFrame(frame.current);
74
+ };
75
+ }, [hovered, encryptedText]);
76
+
77
+ return (
78
+ <motion.div
79
+ onMouseEnter={() => setHovered(true)}
80
+ onMouseLeave={() => setHovered(false)}
81
+ whileHover={{ scale: 1.01 }}
82
+ transition={{ duration: 0.2 }}
83
+ className={cn(
84
+ "group relative flex flex-col items-center gap-6 overflow-hidden rounded-xl border border-dark-line bg-dark-bg p-8",
85
+ className
86
+ )}
87
+ >
88
+ {/* The encrypted / resolving label */}
89
+ <div className="font-mono text-sm tracking-widest text-dark-ink/70 select-none">
90
+ {display}
91
+ </div>
92
+
93
+ {/* Children — fade up on hover */}
94
+ <motion.div
95
+ className="relative z-10 w-full"
96
+ initial={false}
97
+ animate={{ opacity: hovered ? 1 : 0.4, y: hovered ? 0 : 6 }}
98
+ transition={{ duration: 0.3 }}
99
+ >
100
+ {children}
101
+ </motion.div>
102
+ </motion.div>
103
+ );
104
+ }
105
+
106
+ function randomChar(): string {
107
+ return CHARS[Math.floor(Math.random() * CHARS.length)];
108
+ }
109
+
110
+ function scramble(length: number): string {
111
+ let out = "";
112
+ for (let i = 0; i < length; i++) out += randomChar();
113
+ return out;
114
+ }
@@ -0,0 +1,2 @@
1
+ export { EvervaultCard } from "./EvervaultCard";
2
+ export type { EvervaultCardProps } from "./EvervaultCard";
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { useState, type ReactNode } from "react";
4
+ import { motion, AnimatePresence } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface ExpandableCardProps {
8
+ title: string;
9
+ /** Collapsed content (always visible) */
10
+ children?: ReactNode;
11
+ /** Content revealed when expanded */
12
+ expandedContent?: ReactNode;
13
+ defaultExpanded?: boolean;
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * ExpandableCard — card that expands on click to reveal more content.
19
+ *
20
+ * Uses framer-motion `AnimatePresence` with a height "auto" animation so the
21
+ * card smoothly grows. A chevron icon rotates to indicate state.
22
+ *
23
+ * @example
24
+ * <ExpandableCard title="详情" expandedContent={<p>更多内容…</p>}>
25
+ * 摘要文字
26
+ * </ExpandableCard>
27
+ */
28
+ export function ExpandableCard({
29
+ title,
30
+ children,
31
+ expandedContent,
32
+ defaultExpanded = false,
33
+ className,
34
+ }: ExpandableCardProps) {
35
+ const [expanded, setExpanded] = useState(defaultExpanded);
36
+
37
+ return (
38
+ <div
39
+ className={cn(
40
+ "overflow-hidden rounded-xl border border-line bg-bg transition-colors hover:border-line-strong",
41
+ className
42
+ )}
43
+ >
44
+ <button
45
+ type="button"
46
+ onClick={() => setExpanded((prev) => !prev)}
47
+ aria-expanded={expanded}
48
+ className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
49
+ >
50
+ <span className="text-sm font-semibold text-ink">{title}</span>
51
+ <motion.svg
52
+ animate={{ rotate: expanded ? 180 : 0 }}
53
+ transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
54
+ className="h-4 w-4 flex-shrink-0 text-ink-2"
55
+ viewBox="0 0 24 24"
56
+ fill="none"
57
+ stroke="currentColor"
58
+ strokeWidth="2"
59
+ strokeLinecap="round"
60
+ strokeLinejoin="round"
61
+ >
62
+ <polyline points="6 9 12 15 18 9" />
63
+ </motion.svg>
64
+ </button>
65
+
66
+ {/* Collapsed (always-visible) content */}
67
+ {children && (
68
+ <div className="px-4 pb-3 text-sm leading-relaxed text-ink-2">
69
+ {children}
70
+ </div>
71
+ )}
72
+
73
+ {/* Expanded content with height auto animation */}
74
+ <AnimatePresence initial={false}>
75
+ {expanded && expandedContent && (
76
+ <motion.div
77
+ initial={{ height: 0, opacity: 0 }}
78
+ animate={{ height: "auto", opacity: 1 }}
79
+ exit={{ height: 0, opacity: 0 }}
80
+ transition={{ duration: 0.3, ease: [0.16, 1, 0.3, 1] }}
81
+ className="overflow-hidden"
82
+ >
83
+ <div className="border-t border-line px-4 py-3 text-sm leading-relaxed text-ink-2">
84
+ {expandedContent}
85
+ </div>
86
+ </motion.div>
87
+ )}
88
+ </AnimatePresence>
89
+ </div>
90
+ );
91
+ }
@@ -0,0 +1,2 @@
1
+ export { ExpandableCard } from "./ExpandableCard";
2
+ export type { ExpandableCardProps } from "./ExpandableCard";