@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,141 @@
1
+ "use client";
2
+
3
+ import { useRef, type ReactNode } from "react";
4
+ import { motion, useScroll, useTransform } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface StickyScrollItem {
8
+ title: string;
9
+ description: string;
10
+ content?: ReactNode;
11
+ }
12
+
13
+ export interface StickyScrollRevealProps {
14
+ items: StickyScrollItem[];
15
+ className?: string;
16
+ /** Tailwind text token applied to the active title accent underline */
17
+ contentClassName?: string;
18
+ }
19
+
20
+ /**
21
+ * StickyScrollReveal — content blocks that stick and swap on scroll.
22
+ *
23
+ * The left column holds a sticky title + description that changes as the
24
+ * user scrolls through each item; the right column shows optional content.
25
+ * Each item reveals with a fade/slide driven by scroll progress. Based on
26
+ * Aceternity's Sticky Scroll Reveal, adapted to the zmzai monochrome palette.
27
+ *
28
+ * @example
29
+ * <StickyScrollReveal items={[
30
+ * { title: "步骤一", description: "上传文件", content: <Preview /> },
31
+ * { title: "步骤二", description: "生成内容" },
32
+ * ]} />
33
+ */
34
+ export function StickyScrollReveal({
35
+ items,
36
+ className,
37
+ contentClassName,
38
+ }: StickyScrollRevealProps) {
39
+ const ref = useRef<HTMLDivElement>(null);
40
+ const { scrollYProgress } = useScroll({
41
+ target: ref,
42
+ offset: ["start start", "end end"],
43
+ });
44
+
45
+ return (
46
+ <div
47
+ ref={ref}
48
+ className={cn(
49
+ "relative flex flex-col",
50
+ className
51
+ )}
52
+ >
53
+ {items.map((item, idx) => (
54
+ <StickyItem
55
+ key={idx}
56
+ item={item}
57
+ index={idx}
58
+ total={items.length}
59
+ progress={scrollYProgress}
60
+ contentClassName={contentClassName}
61
+ />
62
+ ))}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ interface StickyItemProps {
68
+ item: StickyScrollItem;
69
+ index: number;
70
+ total: number;
71
+ progress: ReturnType<typeof useScroll>["scrollYProgress"];
72
+ contentClassName?: string;
73
+ }
74
+
75
+ function StickyItem({
76
+ item,
77
+ index,
78
+ total,
79
+ progress,
80
+ contentClassName,
81
+ }: StickyItemProps) {
82
+ const segment = 1 / total;
83
+ // active window for this item
84
+ const start = index * segment;
85
+ const end = start + segment;
86
+
87
+ // opacity/scale for left column based on scroll position within segment
88
+ const opacity = useTransform(
89
+ progress,
90
+ [Math.max(0, start - segment * 0.4), start, end, end + segment * 0.4],
91
+ [0.3, 1, 1, 0.3]
92
+ );
93
+ const translateY = useTransform(
94
+ progress,
95
+ [start - segment * 0.4, start, end, end + segment * 0.4],
96
+ [20, 0, 0, -20]
97
+ );
98
+
99
+ return (
100
+ <div className="flex min-h-screen items-start gap-6 md:gap-10 lg:gap-16">
101
+ {/* Left: sticky text column */}
102
+ <div className="sticky top-0 flex h-screen w-full flex-col justify-center md:w-1/2">
103
+ <motion.div style={{ opacity, y: translateY }}>
104
+ {/* Index marker */}
105
+ <span className="mb-4 inline-flex h-7 w-7 items-center justify-center rounded-full border border-line text-xs text-ink-2">
106
+ {String(index + 1).padStart(2, "0")}
107
+ </span>
108
+ <h3 className="text-2xl font-semibold tracking-tight text-ink md:text-3xl lg:text-4xl">
109
+ {item.title}
110
+ </h3>
111
+ <p className="mt-4 max-w-md text-sm text-ink-2 md:text-base">
112
+ {item.description}
113
+ </p>
114
+ </motion.div>
115
+ </div>
116
+
117
+ {/* Right: content column */}
118
+ <div className="flex w-full items-center py-24 md:w-1/2">
119
+ {item.content ? (
120
+ <div
121
+ className={cn(
122
+ "w-full rounded-xl border border-line bg-surface p-6",
123
+ contentClassName
124
+ )}
125
+ >
126
+ {item.content}
127
+ </div>
128
+ ) : (
129
+ <div
130
+ className={cn(
131
+ "flex h-72 w-full items-center justify-center rounded-xl border border-line bg-surface text-sm text-ink-3",
132
+ contentClassName
133
+ )}
134
+ >
135
+ {item.title}
136
+ </div>
137
+ )}
138
+ </div>
139
+ </div>
140
+ );
141
+ }
@@ -0,0 +1,2 @@
1
+ export { StickyScrollReveal } from "./StickyScrollReveal";
2
+ export type { StickyScrollRevealProps, StickyScrollItem } from "./StickyScrollReveal";
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
4
+ import { cn } from "../../utils/cn";
5
+
6
+ export interface TerminalProps extends HTMLAttributes<HTMLDivElement> {
7
+ /** Title shown in the title bar */
8
+ title?: string;
9
+ /** Command-line content */
10
+ children?: ReactNode;
11
+ }
12
+
13
+ /**
14
+ * Terminal — a Mac-style terminal window.
15
+ *
16
+ * Renders a dark window with a title bar (traffic lights + title) and a
17
+ * mono-font command area. A green prompt symbol prefixes the children.
18
+ *
19
+ * @example
20
+ * <Terminal title="bash">
21
+ * <span>npm run dev</span>
22
+ * </Terminal>
23
+ */
24
+ export const Terminal = forwardRef<HTMLDivElement, TerminalProps>(
25
+ ({ title = "bash", children, className, ...props }, ref) => {
26
+ return (
27
+ <div
28
+ ref={ref}
29
+ className={cn(
30
+ "overflow-hidden rounded-xl border border-dark-line bg-dark-bg shadow-lg",
31
+ className
32
+ )}
33
+ {...props}
34
+ >
35
+ {/* Title bar */}
36
+ <div className="flex items-center gap-2 border-b border-dark-line bg-dark-surface px-4 py-2.5">
37
+ <div className="flex gap-1.5">
38
+ <span className="h-3 w-3 rounded-full bg-[#ff5f56]" />
39
+ <span className="h-3 w-3 rounded-full bg-[#ffbd2e]" />
40
+ <span className="h-3 w-3 rounded-full bg-[#27c93f]" />
41
+ </div>
42
+ <span className="ml-2 font-mono text-xs text-dark-ink/60">{title}</span>
43
+ </div>
44
+
45
+ {/* Body */}
46
+ <div className="p-4 font-mono text-[13px] leading-relaxed text-dark-ink">
47
+ <span className="mr-2 select-none text-[#27c93f]">$</span>
48
+ {children}
49
+ </div>
50
+ </div>
51
+ );
52
+ }
53
+ );
54
+
55
+ Terminal.displayName = "Terminal";
@@ -0,0 +1,2 @@
1
+ export { Terminal } from "./Terminal";
2
+ export type { TerminalProps } from "./Terminal";
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { type HTMLMotionProps } from "framer-motion";
4
+ import { motion } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface TextGenerateProps
8
+ extends Omit<HTMLMotionProps<"span">, "ref"> {
9
+ /** Text content to animate */
10
+ text: string;
11
+ /** Duration of each word's fade-in (seconds) */
12
+ duration?: number;
13
+ }
14
+
15
+ /**
16
+ * TextGenerate — text that fades in word by word on mount.
17
+ *
18
+ * Splits `text` by spaces and staggers each word's opacity 0→1.
19
+ * Preserves inline layout via a wrapping span.
20
+ *
21
+ * @example
22
+ * <TextGenerate text="Hello world from zmzai" duration={0.5} />
23
+ */
24
+ export function TextGenerate({
25
+ text,
26
+ duration = 0.5,
27
+ className,
28
+ ...props
29
+ }: TextGenerateProps) {
30
+ const words = text.split(" ");
31
+
32
+ return (
33
+ <motion.span
34
+ className={cn("inline-block", className)}
35
+ initial="hidden"
36
+ animate="visible"
37
+ {...props}
38
+ >
39
+ {words.map((word, i) => (
40
+ <motion.span
41
+ key={i}
42
+ className="inline-block will-change-transform"
43
+ variants={{
44
+ hidden: { opacity: 0, y: 4 },
45
+ visible: { opacity: 1, y: 0 },
46
+ }}
47
+ transition={{
48
+ duration,
49
+ delay: i * (duration * 0.4),
50
+ ease: [0.16, 1, 0.3, 1],
51
+ }}
52
+ >
53
+ {word}
54
+ {i < words.length - 1 ? "\u00A0" : ""}
55
+ </motion.span>
56
+ ))}
57
+ </motion.span>
58
+ );
59
+ }
@@ -0,0 +1,2 @@
1
+ export { TextGenerate } from "./TextGenerate";
2
+ export type { TextGenerateProps } from "./TextGenerate";
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { type Variants } from "framer-motion";
4
+ import { motion } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface TextHoverEffectProps {
8
+ /** The text to display and animate */
9
+ text: string;
10
+ className?: string;
11
+ /** Per-letter animation duration in seconds (default 0.3) */
12
+ duration?: number;
13
+ }
14
+
15
+ const containerVariants: Variants = {
16
+ rest: { transition: { staggerChildren: 0.025, staggerDirection: -1 } },
17
+ hover: { transition: { staggerChildren: 0.025 } },
18
+ };
19
+
20
+ function makeLetterVariants(duration: number): Variants {
21
+ return {
22
+ rest: { y: 0, opacity: 1 },
23
+ hover: { y: "-100%", opacity: 0 },
24
+ transition: { duration },
25
+ } as Variants;
26
+ }
27
+
28
+ /**
29
+ * TextHoverEffect — text whose letters slide up and are replaced on hover.
30
+ *
31
+ * Each letter animates upward and out while a duplicate slides in from
32
+ * below (Aceternity-style hover reveal). Uses framer-motion `staggerChildren`
33
+ * so letters cascade rather than moving in lockstep.
34
+ *
35
+ * @example
36
+ * <TextHoverEffect text="开始使用" className="text-2xl font-semibold" />
37
+ */
38
+ export function TextHoverEffect({
39
+ text,
40
+ className,
41
+ duration = 0.3,
42
+ }: TextHoverEffectProps) {
43
+ const letters = Array.from(text);
44
+ const letterVariants = makeLetterVariants(duration);
45
+
46
+ return (
47
+ <motion.span
48
+ className={cn(
49
+ "relative inline-block cursor-pointer overflow-hidden align-bottom text-ink",
50
+ className
51
+ )}
52
+ variants={containerVariants}
53
+ initial="rest"
54
+ animate="rest"
55
+ whileHover="hover"
56
+ >
57
+ {/* Top row: slides up & fades out on hover */}
58
+ <span className="inline-flex" aria-hidden>
59
+ {letters.map((char, i) => (
60
+ <motion.span
61
+ key={`top-${i}`}
62
+ className="inline-block whitespace-pre"
63
+ variants={letterVariants}
64
+ transition={{ duration }}
65
+ >
66
+ {char === " " ? "\u00A0" : char}
67
+ </motion.span>
68
+ ))}
69
+ </span>
70
+ {/* Bottom row: slides up into place on hover */}
71
+ <span className="absolute inset-0 inline-flex" aria-hidden>
72
+ {letters.map((char, i) => (
73
+ <motion.span
74
+ key={`bottom-${i}`}
75
+ className="inline-block whitespace-pre text-ink-2"
76
+ variants={{
77
+ rest: { y: "100%", opacity: 0 },
78
+ hover: { y: 0, opacity: 1 },
79
+ }}
80
+ transition={{ duration }}
81
+ >
82
+ {char === " " ? "\u00A0" : char}
83
+ </motion.span>
84
+ ))}
85
+ </span>
86
+ {/* Accessible label for screen readers */}
87
+ <span className="sr-only">{text}</span>
88
+ </motion.span>
89
+ );
90
+ }
@@ -0,0 +1,2 @@
1
+ export { TextHoverEffect } from "./TextHoverEffect";
2
+ export type { TextHoverEffectProps } from "./TextHoverEffect";
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { forwardRef, type TextareaHTMLAttributes } from "react";
4
+ import { cn } from "../../utils/cn";
5
+
6
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
7
+ variant?: "default" | "brutal";
8
+ }
9
+
10
+ /**
11
+ * Textarea — multi-line text input.
12
+ *
13
+ * - `default`: light border, focus border turns ink
14
+ * - `brutal`: 2px ink border + hard offset shadow
15
+ *
16
+ * @example
17
+ * <Textarea placeholder="描述任务…" rows={4} />
18
+ * <Textarea variant="brutal" placeholder="描述任务…" />
19
+ */
20
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
21
+ ({ className, variant = "default", ...props }, ref) => {
22
+ return (
23
+ <textarea
24
+ ref={ref}
25
+ className={cn(
26
+ "w-full resize-none bg-bg font-sans text-ink placeholder:text-ink-3 outline-none transition-all",
27
+ variant === "default" &&
28
+ "border border-line rounded-lg px-4 py-2.5 text-sm focus:border-ink",
29
+ variant === "brutal" &&
30
+ "border-2 border-ink rounded-xl px-4 py-3 text-sm shadow-[4px_4px_0_var(--color-ink)] focus:shadow-[6px_6px_0_var(--color-accent)] focus:border-accent focus:-translate-x-px focus:-translate-y-px",
31
+ className
32
+ )}
33
+ {...props}
34
+ />
35
+ );
36
+ }
37
+ );
38
+
39
+ Textarea.displayName = "Textarea";
@@ -0,0 +1,2 @@
1
+ export { Textarea } from "./Textarea";
2
+ export type { TextareaProps } from "./Textarea";
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ import { useRef, type ReactNode } from "react";
4
+ import { motion, useScroll, useSpring } from "framer-motion";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ export interface TimelineItem {
8
+ title: string;
9
+ content: ReactNode;
10
+ /** Optional timestamp shown beside title */
11
+ time?: string;
12
+ /** Status: default (gray), active (filled black), done (filled black + check) */
13
+ status?: "default" | "active" | "done";
14
+ }
15
+
16
+ export interface TimelineProps {
17
+ items: TimelineItem[];
18
+ className?: string;
19
+ }
20
+
21
+ /**
22
+ * Timeline — scroll-reveal timeline with progress beam.
23
+ *
24
+ * Minimal black-gray design: thin line fills as you scroll,
25
+ * dots are black/gray only (no green/blue).
26
+ *
27
+ * @example
28
+ * <Timeline items={[
29
+ * { title: "任务创建", time: "14:32", status: "done", content: <p>用户发起请求</p> },
30
+ * { title: "执行中", time: "14:34", status: "active", content: <p>正在生成…</p> },
31
+ * { title: "等待审批", status: "default", content: <p>写入文件</p> },
32
+ * ]} />
33
+ */
34
+ export function Timeline({ items, className }: TimelineProps) {
35
+ const ref = useRef<HTMLDivElement>(null);
36
+ const { scrollYProgress } = useScroll({
37
+ target: ref,
38
+ offset: ["start center", "end end"],
39
+ });
40
+ const scaleY = useSpring(scrollYProgress, {
41
+ stiffness: 100,
42
+ damping: 30,
43
+ restDelta: 0.001,
44
+ });
45
+
46
+ return (
47
+ <div ref={ref} className={cn("relative", className)}>
48
+ {/* Track */}
49
+ <div className="absolute left-[7px] top-2 bottom-2 w-px bg-line" />
50
+
51
+ {/* Progress fill */}
52
+ <motion.div
53
+ className="absolute left-[7px] top-2 w-px origin-top bg-ink"
54
+ style={{ scaleY, height: "calc(100% - 1rem)" }}
55
+ />
56
+
57
+ {/* Items */}
58
+ <div className="flex flex-col">
59
+ {items.map((item, index) => (
60
+ <TimelineItemRow key={index} item={item} isLast={index === items.length - 1} />
61
+ ))}
62
+ </div>
63
+ </div>
64
+ );
65
+ }
66
+
67
+ function TimelineItemRow({ item, isLast }: { item: TimelineItem; isLast: boolean }) {
68
+ const status = item.status ?? "default";
69
+
70
+ return (
71
+ <div className={cn("relative flex gap-4", isLast ? "pb-0" : "pb-6")}>
72
+ {/* Dot — black/gray only */}
73
+ <div className="relative z-10 flex-shrink-0 pt-0.5">
74
+ {status === "done" && (
75
+ <div className="flex h-3.5 w-3.5 items-center justify-center rounded-full bg-ink">
76
+ <svg className="h-2 w-2 text-white" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.5">
77
+ <polyline points="10 3 5 8 2 5" />
78
+ </svg>
79
+ </div>
80
+ )}
81
+ {status === "active" && (
82
+ <div className="h-3.5 w-3.5 rounded-full border-2 border-ink bg-ink animate-pulse" />
83
+ )}
84
+ {status === "default" && (
85
+ <div className="h-3.5 w-3.5 rounded-full border-2 border-line bg-bg" />
86
+ )}
87
+ </div>
88
+
89
+ {/* Content */}
90
+ <div className="min-w-0 flex-1">
91
+ <div className="mb-1 flex items-center gap-2">
92
+ <span className={cn(
93
+ "text-sm font-semibold",
94
+ status === "default" ? "text-ink-3" : "text-ink"
95
+ )}>
96
+ {item.title}
97
+ </span>
98
+ {item.time && (
99
+ <span className="ml-auto font-mono text-xs text-ink-3">{item.time}</span>
100
+ )}
101
+ </div>
102
+ <div className="text-sm leading-relaxed text-ink-2">{item.content}</div>
103
+ </div>
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,3 @@
1
+ export { Timeline } from "./Timeline";
2
+ export type { TimelineProps } from "./Timeline";
3
+ export type { TimelineItem } from "./Timeline";
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { type ReactNode } from "react";
4
+ import * as TooltipPrimitive from "@radix-ui/react-tooltip";
5
+ import { cn } from "../../utils/cn";
6
+
7
+ /**
8
+ * TooltipProvider — wrap app root once.
9
+ * Required by Radix Tooltip for delay/positioning context.
10
+ */
11
+ export const TooltipProvider = TooltipPrimitive.Provider;
12
+
13
+ export interface TooltipProps {
14
+ children: ReactNode;
15
+ content: ReactNode;
16
+ side?: "top" | "right" | "bottom" | "left";
17
+ className?: string;
18
+ }
19
+
20
+ /**
21
+ * Tooltip — hover/focus tooltip (Radix UI).
22
+ *
23
+ * Wrap your app in `<TooltipProvider>` (once, at root), then use `<Tooltip>`.
24
+ *
25
+ * @example
26
+ * <Tooltip content="删除">
27
+ * <button>×</button>
28
+ * </Tooltip>
29
+ */
30
+ export function Tooltip({ children, content, side = "top", className }: TooltipProps) {
31
+ return (
32
+ <TooltipPrimitive.Root delayDuration={200}>
33
+ <TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
34
+ <TooltipPrimitive.Portal>
35
+ <TooltipPrimitive.Content
36
+ side={side}
37
+ sideOffset={6}
38
+ className={cn(
39
+ "z-50 rounded-lg bg-ink px-2.5 py-1.5 text-xs font-medium text-white shadow-md",
40
+ "data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95",
41
+ "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
42
+ className
43
+ )}
44
+ >
45
+ {content}
46
+ <TooltipPrimitive.Arrow className="fill-ink" />
47
+ </TooltipPrimitive.Content>
48
+ </TooltipPrimitive.Portal>
49
+ </TooltipPrimitive.Root>
50
+ );
51
+ }
@@ -0,0 +1,2 @@
1
+ export { Tooltip, TooltipProvider } from "./Tooltip";
2
+ export type { TooltipProps } from "./Tooltip";