@ugurdemirel/landcraft 0.1.3 → 0.1.5

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 (143) hide show
  1. package/dist/index.cjs +2 -2
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.ts +38 -21
  4. package/dist/index.js +2276 -1956
  5. package/dist/index.js.map +1 -1
  6. package/dist/styles.css +15 -0
  7. package/package.json +11 -4
  8. package/src/components/Badge.test.tsx +55 -0
  9. package/src/components/Blog/Blog.test.tsx +64 -0
  10. package/src/components/Blog/BlogCard.tsx +12 -0
  11. package/src/components/Blog/BlogSection.tsx +95 -0
  12. package/src/components/Blog/index.ts +8 -0
  13. package/src/components/Blog/parts.tsx +38 -0
  14. package/src/components/Blog/types.ts +42 -0
  15. package/src/components/Blog/variants/Card.tsx +62 -0
  16. package/src/components/Blog/variants/Row.tsx +41 -0
  17. package/src/components/Button.test.tsx +91 -0
  18. package/src/components/CTA/CTA.test.tsx +53 -0
  19. package/src/components/CTA/CTA.tsx +12 -0
  20. package/src/components/CTA/index.ts +2 -0
  21. package/src/components/CTA/parts.tsx +26 -0
  22. package/src/components/CTA/types.ts +12 -0
  23. package/src/components/CTA/variants/Inverse.tsx +50 -0
  24. package/src/components/CTA/variants/Panel.tsx +60 -0
  25. package/src/components/CTA/variants/Surface.tsx +34 -0
  26. package/src/components/Card.test.tsx +49 -0
  27. package/src/components/Container.test.tsx +52 -0
  28. package/src/components/FAQ/FAQ.test.tsx +54 -0
  29. package/src/components/FAQ/FAQ.tsx +48 -0
  30. package/src/components/FAQ/index.ts +2 -0
  31. package/src/components/FAQ/parts.tsx +74 -0
  32. package/src/components/FAQ/types.ts +18 -0
  33. package/src/components/FAQ/variants/Accordion.tsx +46 -0
  34. package/src/components/FAQ/variants/Cards.tsx +40 -0
  35. package/src/components/FAQ/variants/Split.tsx +44 -0
  36. package/src/components/Features/FeatureCard.tsx +44 -0
  37. package/src/components/Features/FeatureGrid.tsx +14 -0
  38. package/src/components/Features/Features.test.tsx +49 -0
  39. package/src/components/Features/index.ts +3 -0
  40. package/src/components/Features/parts.ts +15 -0
  41. package/src/components/Features/types.ts +25 -0
  42. package/src/components/Features/variants/Bento.tsx +32 -0
  43. package/src/components/Features/variants/Columns.tsx +21 -0
  44. package/src/components/Features/variants/EditorialRows.tsx +38 -0
  45. package/src/components/Footer/Footer.test.tsx +49 -0
  46. package/src/components/Footer/Footer.tsx +11 -0
  47. package/src/components/Footer/index.ts +2 -0
  48. package/src/components/Footer/parts.tsx +101 -0
  49. package/src/components/Footer/types.ts +32 -0
  50. package/src/components/Footer/variants/Classic.tsx +59 -0
  51. package/src/components/Footer/variants/Editorial.tsx +47 -0
  52. package/src/components/Footer/variants/Minimal.tsx +33 -0
  53. package/src/components/Hero/Hero.test.tsx +71 -0
  54. package/src/components/Hero/Hero.tsx +12 -0
  55. package/src/components/Hero/index.ts +2 -0
  56. package/src/components/Hero/parts.tsx +29 -0
  57. package/src/components/Hero/types.ts +20 -0
  58. package/src/components/Hero/variants/Centered.tsx +54 -0
  59. package/src/components/Hero/variants/Split.tsx +53 -0
  60. package/src/components/Hero/variants/Statement.tsx +60 -0
  61. package/src/components/LanguageSwitcher/LanguageSwitcher.test.tsx +64 -0
  62. package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +37 -0
  63. package/src/components/LanguageSwitcher/index.ts +6 -0
  64. package/src/components/LanguageSwitcher/parts.tsx +144 -0
  65. package/src/components/LanguageSwitcher/types.ts +40 -0
  66. package/src/components/LanguageSwitcher/useLanguageSwitcher.ts +23 -0
  67. package/src/components/LanguageSwitcher/variants/Dropdown.tsx +88 -0
  68. package/src/components/LanguageSwitcher/variants/Modal.tsx +56 -0
  69. package/src/components/LogoCloud/LogoCloud.test.tsx +35 -0
  70. package/src/components/LogoCloud/LogoCloud.tsx +14 -0
  71. package/src/components/LogoCloud/index.ts +2 -0
  72. package/src/components/LogoCloud/parts.tsx +32 -0
  73. package/src/components/LogoCloud/types.ts +16 -0
  74. package/src/components/LogoCloud/variants/Marquee.tsx +30 -0
  75. package/src/components/LogoCloud/variants/Quiet.tsx +26 -0
  76. package/src/components/LogoCloud/variants/Strip.tsx +25 -0
  77. package/src/components/MegaMenu/MegaMenu.test.tsx +89 -0
  78. package/src/components/{MegaMenu.tsx → MegaMenu/MegaMenu.tsx} +100 -127
  79. package/src/components/MegaMenu/index.ts +9 -0
  80. package/src/components/MegaMenu/types.ts +56 -0
  81. package/src/components/Modal.test.tsx +80 -0
  82. package/src/components/Modal.tsx +1 -0
  83. package/src/components/Navbar/Navbar.test.tsx +91 -0
  84. package/src/components/Navbar/Navbar.tsx +185 -0
  85. package/src/components/Navbar/index.ts +2 -0
  86. package/src/components/Navbar/types.ts +30 -0
  87. package/src/components/Newsletter/Newsletter.test.tsx +45 -0
  88. package/src/components/Newsletter/Newsletter.tsx +12 -0
  89. package/src/components/Newsletter/index.ts +2 -0
  90. package/src/components/Newsletter/parts.tsx +23 -0
  91. package/src/components/Newsletter/types.ts +12 -0
  92. package/src/components/Newsletter/useNewsletterForm.ts +27 -0
  93. package/src/components/Newsletter/variants/Card.tsx +62 -0
  94. package/src/components/Newsletter/variants/Inline.tsx +54 -0
  95. package/src/components/Newsletter/variants/Underline.tsx +52 -0
  96. package/src/components/Pricing/Pricing.test.tsx +67 -0
  97. package/src/components/Pricing/Pricing.tsx +37 -0
  98. package/src/components/Pricing/index.ts +2 -0
  99. package/src/components/Pricing/parts.tsx +132 -0
  100. package/src/components/Pricing/types.ts +28 -0
  101. package/src/components/Pricing/variants/Bento.tsx +72 -0
  102. package/src/components/Pricing/variants/Cards.tsx +79 -0
  103. package/src/components/Pricing/variants/Compact.tsx +69 -0
  104. package/src/components/Prose.test.tsx +49 -0
  105. package/src/components/Section.test.tsx +51 -0
  106. package/src/components/Slot.test.tsx +45 -0
  107. package/src/components/Stats/Stats.test.tsx +47 -0
  108. package/src/components/Stats/Stats.tsx +16 -0
  109. package/src/components/Stats/index.ts +2 -0
  110. package/src/components/Stats/parts.tsx +29 -0
  111. package/src/components/Stats/types.ts +23 -0
  112. package/src/components/Stats/variants/Cells.tsx +34 -0
  113. package/src/components/Stats/variants/Editorial.tsx +41 -0
  114. package/src/components/Stats/variants/Hairline.tsx +31 -0
  115. package/src/components/Stats/variants/Ticker.tsx +36 -0
  116. package/src/components/Testimonials/TestimonialCard.tsx +61 -0
  117. package/src/components/Testimonials/Testimonials.test.tsx +48 -0
  118. package/src/components/Testimonials/Testimonials.tsx +14 -0
  119. package/src/components/Testimonials/index.ts +3 -0
  120. package/src/components/Testimonials/parts.ts +7 -0
  121. package/src/components/Testimonials/types.ts +24 -0
  122. package/src/components/Testimonials/variants/Carousel.tsx +94 -0
  123. package/src/components/Testimonials/variants/Grid.tsx +19 -0
  124. package/src/components/Testimonials/variants/Marquee.tsx +28 -0
  125. package/src/test/setup.ts +7 -0
  126. package/src/utils/cn.test.ts +21 -0
  127. package/src/utils/contrast.test.ts +71 -0
  128. package/src/utils/iso2Flag.test.ts +26 -0
  129. package/src/utils/useTokenForeground.test.ts +50 -0
  130. package/src/utils/useTokenForeground.ts +1 -0
  131. package/src/components/Blog.tsx +0 -251
  132. package/src/components/CTA.tsx +0 -155
  133. package/src/components/FAQ.tsx +0 -163
  134. package/src/components/Features.tsx +0 -153
  135. package/src/components/Footer.tsx +0 -185
  136. package/src/components/Hero.tsx +0 -169
  137. package/src/components/LanguageSwitcher.tsx +0 -223
  138. package/src/components/LogoCloud.tsx +0 -100
  139. package/src/components/Navbar.tsx +0 -195
  140. package/src/components/Newsletter.tsx +0 -180
  141. package/src/components/Pricing.tsx +0 -336
  142. package/src/components/Stats.tsx +0 -181
  143. package/src/components/Testimonials.tsx +0 -205
package/dist/styles.css CHANGED
@@ -1679,6 +1679,9 @@
1679
1679
  .aspect-\[16\/10\] {
1680
1680
  aspect-ratio: 16/10;
1681
1681
  }
1682
+ .h-1 {
1683
+ height: var(--spacing);
1684
+ }
1682
1685
  .h-1\.5 {
1683
1686
  height: calc(var(--spacing) * 1.5);
1684
1687
  }
@@ -2071,6 +2074,9 @@
2071
2074
  .overflow-y-auto {
2072
2075
  overflow-y: auto;
2073
2076
  }
2077
+ .rounded {
2078
+ border-radius: 0.25rem;
2079
+ }
2074
2080
  .rounded-2xl {
2075
2081
  border-radius: var(--radius-2xl);
2076
2082
  }
@@ -2265,6 +2271,12 @@
2265
2271
  .p-1 {
2266
2272
  padding: var(--spacing);
2267
2273
  }
2274
+ .p-2 {
2275
+ padding: calc(var(--spacing) * 2);
2276
+ }
2277
+ .p-4 {
2278
+ padding: calc(var(--spacing) * 4);
2279
+ }
2268
2280
  .p-5 {
2269
2281
  padding: calc(var(--spacing) * 5);
2270
2282
  }
@@ -2733,6 +2745,9 @@
2733
2745
  .text-white {
2734
2746
  color: var(--color-white);
2735
2747
  }
2748
+ .lowercase {
2749
+ text-transform: lowercase;
2750
+ }
2736
2751
  .uppercase {
2737
2752
  text-transform: uppercase;
2738
2753
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ugurdemirel/landcraft",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -35,14 +35,16 @@
35
35
  "build:js": "vite build",
36
36
  "build:css": "tailwindcss -i ./src/styles/index.css -o ./dist/styles.css",
37
37
  "typecheck": "tsc --noEmit",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
38
40
  "clean": "rimraf dist",
39
41
  "prepublishOnly": "pnpm run build"
40
42
  },
41
43
  "peerDependencies": {
44
+ "@tailwindcss/typography": "^0.5.15",
42
45
  "react": ">=18.0.0",
43
46
  "react-dom": ">=18.0.0",
44
- "tailwindcss": "^4.0.0",
45
- "@tailwindcss/typography": "^0.5.15"
47
+ "tailwindcss": "^4.0.0"
46
48
  },
47
49
  "peerDependenciesMeta": {
48
50
  "@tailwindcss/typography": {
@@ -56,14 +58,19 @@
56
58
  "devDependencies": {
57
59
  "@tailwindcss/cli": "^4.0.0",
58
60
  "@tailwindcss/typography": "^0.5.15",
61
+ "@testing-library/jest-dom": "^7.0.1",
62
+ "@testing-library/react": "^16.3.2",
63
+ "@testing-library/user-event": "^14.6.5",
59
64
  "@types/node": "^22.9.0",
60
65
  "@types/react": "^18.3.12",
61
66
  "@types/react-dom": "^18.3.1",
62
67
  "@vitejs/plugin-react": "^4.3.4",
68
+ "jsdom": "^30.0.1",
63
69
  "rimraf": "^6.0.1",
64
70
  "tailwindcss": "^4.0.0",
65
71
  "typescript": "^5.6.3",
66
72
  "vite": "^5.4.11",
67
- "vite-plugin-dts": "^4.3.0"
73
+ "vite-plugin-dts": "^4.3.0",
74
+ "vitest": "^3.2.7"
68
75
  }
69
76
  }
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { Badge } from "./Badge";
4
+ import { Zap } from "../icons";
5
+
6
+ describe("Badge", () => {
7
+ it("renders a span with children", () => {
8
+ render(<Badge>New</Badge>);
9
+ expect(screen.getByText("New")).toBeInTheDocument();
10
+ expect(screen.getByText("New").tagName).toBe("SPAN");
11
+ });
12
+
13
+ it("defaults to the soft variant", () => {
14
+ render(<Badge>Soft</Badge>);
15
+ expect(screen.getByText("Soft")).toHaveClass("bg-primary-soft");
16
+ });
17
+
18
+ it("applies the requested variant", () => {
19
+ render(<Badge variant="solid">Solid</Badge>);
20
+ expect(screen.getByText("Solid")).toHaveClass("bg-primary");
21
+ });
22
+
23
+ it("renders a dot indicator for the dot variant", () => {
24
+ const { container } = render(<Badge variant="dot">Online</Badge>);
25
+ // The dot is an inner decorative span.
26
+ const dots = container.querySelectorAll("span.h-1\\.5");
27
+ expect(dots.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ it("renders an icon", () => {
31
+ render(
32
+ <Badge icon={<Zap data-testid="icon" />} variant="soft">
33
+ Fast
34
+ </Badge>,
35
+ );
36
+ expect(screen.getByTestId("icon")).toBeInTheDocument();
37
+ });
38
+
39
+ it("applies sizes", () => {
40
+ render(<Badge size="sm">Small</Badge>);
41
+ expect(screen.getByText("Small")).toHaveClass("px-2.5");
42
+ });
43
+
44
+ it("sets a computed text color for customColor", () => {
45
+ render(<Badge customColor="#111111">Dark</Badge>);
46
+ const badge = screen.getByText("Dark");
47
+ expect(badge).toHaveStyle({ backgroundColor: "#111111" });
48
+ expect(badge).toHaveStyle({ color: "#ffffff" });
49
+ });
50
+
51
+ it("forwards arbitrary props like aria-label", () => {
52
+ render(<Badge aria-label="status">v1</Badge>);
53
+ expect(screen.getByLabelText("status")).toBeInTheDocument();
54
+ });
55
+ });
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { BlogCard } from "./BlogCard";
4
+ import { BlogSection } from "./BlogSection";
5
+
6
+ const posts = [
7
+ {
8
+ title: "Token-based theming",
9
+ excerpt: "A short excerpt.",
10
+ date: "12 Aug 2026",
11
+ readTime: "5 min",
12
+ category: "Strategy",
13
+ href: "/post",
14
+ author: { name: "Aylin Demir" },
15
+ },
16
+ {
17
+ title: "Second post",
18
+ date: "1 Aug 2026",
19
+ },
20
+ ];
21
+
22
+ describe("BlogCard", () => {
23
+ it("renders a card with title, category badge and meta (card option)", () => {
24
+ render(<BlogCard post={posts[0]} />);
25
+ expect(screen.getByText("Token-based theming")).toBeInTheDocument();
26
+ expect(screen.getByText("Strategy")).toBeInTheDocument();
27
+ expect(screen.getByText("5 min")).toBeInTheDocument();
28
+ });
29
+
30
+ it("renders the row option", () => {
31
+ render(<BlogCard post={posts[0]} option="row" />);
32
+ expect(screen.getByText("Token-based theming")).toBeInTheDocument();
33
+ });
34
+
35
+ it("links to the post href", () => {
36
+ render(<BlogCard post={posts[0]} />);
37
+ expect(
38
+ screen.getByRole("link", { name: /Token-based theming/ }),
39
+ ).toHaveAttribute("href", "/post");
40
+ });
41
+ });
42
+
43
+ describe("BlogSection", () => {
44
+ it("renders the heading and posts as a card grid", () => {
45
+ render(
46
+ <BlogSection
47
+ posts={posts}
48
+ eyebrow="Blog"
49
+ title="Latest"
50
+ description="A description."
51
+ />,
52
+ );
53
+ expect(screen.getByText("Blog")).toBeInTheDocument();
54
+ expect(screen.getByText("Latest")).toBeInTheDocument();
55
+ expect(screen.getByText("Token-based theming")).toBeInTheDocument();
56
+ expect(screen.getByText("Second post")).toBeInTheDocument();
57
+ });
58
+
59
+ it("limits the number of posts with the limit prop", () => {
60
+ render(<BlogSection posts={posts} limit={1} />);
61
+ expect(screen.getByText("Token-based theming")).toBeInTheDocument();
62
+ expect(screen.queryByText("Second post")).not.toBeInTheDocument();
63
+ });
64
+ });
@@ -0,0 +1,12 @@
1
+ import { forwardRef } from "react";
2
+ import type { BlogCardProps } from "./types";
3
+ import { BlogCardCard } from "./variants/Card";
4
+ import { BlogCardRow } from "./variants/Row";
5
+
6
+ export const BlogCard = forwardRef<HTMLAnchorElement, BlogCardProps>(
7
+ ({ option = "card", ...props }, ref) => {
8
+ if (option === "row") return <BlogCardRow ref={ref} {...props} />;
9
+ return <BlogCardCard ref={ref} {...props} />;
10
+ },
11
+ );
12
+ BlogCard.displayName = "BlogCard";
@@ -0,0 +1,95 @@
1
+ import { forwardRef } from "react";
2
+ import { cn } from "../../utils/cn";
3
+ import { ArrowRight } from "../../icons";
4
+ import { Container } from "../Container";
5
+ import { BlogCard } from "./BlogCard";
6
+ import type { BlogSectionProps } from "./types";
7
+
8
+ const gridCols: Record<2 | 3, string> = {
9
+ 2: "sm:grid-cols-2",
10
+ 3: "sm:grid-cols-2 lg:grid-cols-3",
11
+ };
12
+
13
+ export const BlogSection = forwardRef<HTMLElement, BlogSectionProps>(
14
+ (
15
+ {
16
+ className,
17
+ posts,
18
+ limit,
19
+ columns = 3,
20
+ option = "card",
21
+ eyebrow,
22
+ eyebrowStyle = "caps",
23
+ title,
24
+ description,
25
+ showAllLabel,
26
+ showAllHref,
27
+ LinkComponent,
28
+ ...props
29
+ },
30
+ ref,
31
+ ) => {
32
+ const visible = limit ? posts.slice(0, limit) : posts;
33
+ const Link = LinkComponent ?? "a";
34
+
35
+ return (
36
+ <section ref={ref} className={cn("w-full py-20 sm:py-24", className)} {...props}>
37
+ <Container>
38
+ {(eyebrow || title || description || showAllHref) && (
39
+ <div className="mb-12 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
40
+ <div className="max-w-2xl">
41
+ {eyebrow ? (
42
+ <p
43
+ className={cn(
44
+ "text-xs font-semibold uppercase tracking-[0.18em]",
45
+ eyebrowStyle === "caps" && "text-foreground/50",
46
+ eyebrowStyle === "soft" &&
47
+ "inline-flex items-center rounded-full bg-primary-soft px-3 py-1.5 text-primary",
48
+ eyebrowStyle === "plain" && "text-primary",
49
+ )}
50
+ >
51
+ {eyebrow}
52
+ </p>
53
+ ) : null}
54
+ {title ? (
55
+ <h2 className="mt-4 font-display text-balance text-3xl font-semibold leading-[1.08] tracking-tight text-foreground sm:text-4xl">
56
+ {title}
57
+ </h2>
58
+ ) : null}
59
+ {description ? (
60
+ <p className="mt-4 text-pretty text-base leading-7 text-muted-foreground">
61
+ {description}
62
+ </p>
63
+ ) : null}
64
+ </div>
65
+ {showAllHref ? (
66
+ <Link
67
+ href={showAllHref}
68
+ className="group inline-flex shrink-0 cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground transition-colors duration-150 hover:text-primary"
69
+ >
70
+ {showAllLabel ?? "All posts"}
71
+ <ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
72
+ </Link>
73
+ ) : null}
74
+ </div>
75
+ )}
76
+
77
+ {option === "row" ? (
78
+ <div className="divide-y divide-border border-t border-border">
79
+ {visible.map((post) => (
80
+ <BlogCard key={post.title} post={post} option="row" LinkComponent={LinkComponent} />
81
+ ))}
82
+ </div>
83
+ ) : (
84
+ <div className={cn("grid grid-cols-1 gap-4", gridCols[columns])}>
85
+ {visible.map((post) => (
86
+ <BlogCard key={post.title} post={post} option="card" LinkComponent={LinkComponent} />
87
+ ))}
88
+ </div>
89
+ )}
90
+ </Container>
91
+ </section>
92
+ );
93
+ },
94
+ );
95
+ BlogSection.displayName = "BlogSection";
@@ -0,0 +1,8 @@
1
+ export { BlogCard } from "./BlogCard";
2
+ export { BlogSection } from "./BlogSection";
3
+ export type {
4
+ BlogCardProps,
5
+ BlogSectionProps,
6
+ BlogPost,
7
+ BlogAuthor,
8
+ } from "./types";
@@ -0,0 +1,38 @@
1
+ import type { ReactNode } from "react";
2
+ import type { BlogPost } from "./types";
3
+
4
+ export function AuthorMark({ name, avatar }: { name: string; avatar?: ReactNode }) {
5
+ if (avatar) return avatar;
6
+ const initials = name
7
+ .split(" ")
8
+ .map((p) => p[0])
9
+ .join("")
10
+ .slice(0, 2)
11
+ .toUpperCase();
12
+ return (
13
+ <span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-primary-soft text-[11px] font-bold text-primary">
14
+ {initials}
15
+ </span>
16
+ );
17
+ }
18
+
19
+ export const Cover = ({ post }: { post: BlogPost }) =>
20
+ post.cover ? (
21
+ <img src={post.cover} alt="" loading="lazy" className="aspect-[16/10] w-full object-cover" />
22
+ ) : (
23
+ <div
24
+ className="grid aspect-[16/10] w-full place-items-center bg-surface-strong"
25
+ style={
26
+ post.accent
27
+ ? { background: `linear-gradient(135deg, ${post.accent}22, ${post.accent}11)` }
28
+ : undefined
29
+ }
30
+ >
31
+ <span
32
+ className="font-display text-4xl font-bold tracking-[-0.04em]"
33
+ style={{ color: post.accent ?? "rgb(var(--color-foreground) / 0.12)" }}
34
+ >
35
+ {post.category?.[0]?.toUpperCase()}
36
+ </span>
37
+ </div>
38
+ );
@@ -0,0 +1,42 @@
1
+ import type { ElementType, HTMLAttributes, ReactNode } from "react";
2
+
3
+ export interface BlogAuthor {
4
+ name: string;
5
+ avatar?: ReactNode;
6
+ }
7
+
8
+ export interface BlogPost {
9
+ title: string;
10
+ excerpt?: string;
11
+ /** Display date, e.g. "12 Aug 2026". */
12
+ date: string;
13
+ readTime?: string;
14
+ category?: string;
15
+ cover?: string;
16
+ /** Arbitrary CSS color for the cover placeholder accent. */
17
+ accent?: string;
18
+ href?: string;
19
+ author?: BlogAuthor;
20
+ }
21
+
22
+ export interface BlogCardProps extends HTMLAttributes<HTMLAnchorElement> {
23
+ post: BlogPost;
24
+ option?: "card" | "row";
25
+ /** Component used to render the card link. Defaults to `<a>`. Pass your router's `<Link>` (Next.js, Remix, React Router…) for framework-aware navigation. */
26
+ LinkComponent?: ElementType;
27
+ }
28
+
29
+ export interface BlogSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
30
+ posts: BlogPost[];
31
+ /** Show only the first N posts. */
32
+ limit?: number;
33
+ columns?: 2 | 3;
34
+ option?: "card" | "row";
35
+ eyebrow?: ReactNode;
36
+ eyebrowStyle?: "caps" | "soft" | "plain";
37
+ title?: ReactNode;
38
+ description?: ReactNode;
39
+ showAllLabel?: string;
40
+ showAllHref?: string;
41
+ LinkComponent?: ElementType;
42
+ }
@@ -0,0 +1,62 @@
1
+ import { forwardRef, type ElementType } from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import { ArrowRight } from "../../../icons";
4
+ import { Badge } from "../../Badge";
5
+ import { AuthorMark, Cover } from "../parts";
6
+ import type { BlogPost } from "../types";
7
+
8
+ interface BlogCardCardProps {
9
+ post: BlogPost;
10
+ LinkComponent?: ElementType;
11
+ className?: string;
12
+ }
13
+
14
+ export const BlogCardCard = forwardRef<HTMLAnchorElement, BlogCardCardProps>(
15
+ ({ className, post, LinkComponent, ...props }, ref) => {
16
+ const Link = LinkComponent ?? "a";
17
+ const author = post.author;
18
+ return (
19
+ <Link
20
+ ref={ref}
21
+ href={post.href ?? "#"}
22
+ className={cn(
23
+ "group flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border border-border bg-surface transition-colors duration-200 hover:border-foreground/20",
24
+ className,
25
+ )}
26
+ {...props}
27
+ >
28
+ <div className="overflow-hidden">
29
+ <Cover post={post} />
30
+ </div>
31
+ <div className="flex flex-1 flex-col p-6">
32
+ <div className="flex items-center justify-between gap-3">
33
+ {post.category ? <Badge variant="soft" size="sm">{post.category}</Badge> : <span />}
34
+ {post.readTime ? (
35
+ <span className="text-xs text-muted-foreground">{post.readTime}</span>
36
+ ) : null}
37
+ </div>
38
+ <h3 className="mt-4 font-display text-lg font-semibold leading-snug tracking-tight text-foreground sm:text-xl">
39
+ {post.title}
40
+ </h3>
41
+ {post.excerpt ? (
42
+ <p className="mt-2.5 line-clamp-2 text-sm leading-6 text-muted-foreground">
43
+ {post.excerpt}
44
+ </p>
45
+ ) : null}
46
+ <div className="mt-5 flex items-center justify-between gap-3 border-t border-border pt-4">
47
+ {author ? (
48
+ <span className="flex min-w-0 items-center gap-2.5">
49
+ <AuthorMark name={author.name} avatar={author.avatar} />
50
+ <span className="truncate text-xs font-medium text-foreground">{author.name}</span>
51
+ </span>
52
+ ) : (
53
+ <span className="text-xs tabular-nums text-muted-foreground">{post.date}</span>
54
+ )}
55
+ <ArrowRight className="h-4 w-4 text-foreground/35 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-foreground" />
56
+ </div>
57
+ </div>
58
+ </Link>
59
+ );
60
+ },
61
+ );
62
+ BlogCardCard.displayName = "BlogCardCard";
@@ -0,0 +1,41 @@
1
+ import { forwardRef, type ElementType } from "react";
2
+ import { cn } from "../../../utils/cn";
3
+ import { ArrowUpRight } from "../../../icons";
4
+ import type { BlogPost } from "../types";
5
+
6
+ interface BlogCardRowProps {
7
+ post: BlogPost;
8
+ LinkComponent?: ElementType;
9
+ className?: string;
10
+ }
11
+
12
+ export const BlogCardRow = forwardRef<HTMLAnchorElement, BlogCardRowProps>(
13
+ ({ className, post, LinkComponent, ...props }, ref) => {
14
+ const Link = LinkComponent ?? "a";
15
+ return (
16
+ <Link
17
+ ref={ref}
18
+ href={post.href ?? "#"}
19
+ className={cn("group flex cursor-pointer items-baseline gap-5 py-6", className)}
20
+ {...props}
21
+ >
22
+ <div className="min-w-0 flex-1">
23
+ <div className="flex items-center gap-3">
24
+ {post.category ? <span className="rounded-full bg-primary-soft px-2.5 py-0.5 text-xs font-semibold text-primary">{post.category}</span> : null}
25
+ <span className="text-xs tabular-nums text-muted-foreground">
26
+ {post.date}
27
+ {post.readTime ? ` · ${post.readTime}` : ""}
28
+ </span>
29
+ </div>
30
+ <h3 className="mt-2.5 font-display text-xl font-semibold tracking-tight text-foreground transition-transform duration-200 group-hover:translate-x-1 sm:text-2xl">
31
+ {post.title}
32
+ </h3>
33
+ </div>
34
+ <span className="text-foreground/30 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-foreground">
35
+ <ArrowUpRight className="h-5 w-5" />
36
+ </span>
37
+ </Link>
38
+ );
39
+ },
40
+ );
41
+ BlogCardRow.displayName = "BlogCardRow";
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { Button } from "./Button";
5
+ import { ArrowRight } from "../icons";
6
+
7
+ describe("Button", () => {
8
+ it("renders a button element with children", () => {
9
+ render(<Button>Hello</Button>);
10
+ const btn = screen.getByRole("button", { name: "Hello" });
11
+ expect(btn).toBeInTheDocument();
12
+ expect(btn.tagName).toBe("BUTTON");
13
+ });
14
+
15
+ it("defaults to type=button so it never submits forms unintentionally", () => {
16
+ render(<Button>Save</Button>);
17
+ expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("type", "button");
18
+ });
19
+
20
+ it("applies the default primary variant", () => {
21
+ render(<Button>Start</Button>);
22
+ expect(screen.getByRole("button", { name: "Start" })).toHaveClass("bg-primary");
23
+ });
24
+
25
+ it("applies the requested variant class", () => {
26
+ render(<Button variant="ghost">Ghost</Button>);
27
+ expect(screen.getByRole("button", { name: "Ghost" })).toHaveClass("hover:bg-surface");
28
+ });
29
+
30
+ it("applies the link variant underline behavior", () => {
31
+ render(<Button variant="link">Link</Button>);
32
+ expect(screen.getByRole("button", { name: "Link" })).toHaveClass("hover:underline");
33
+ });
34
+
35
+ it("renders leading and trailing icons", () => {
36
+ render(
37
+ <Button iconLeft={<span data-testid="left" />} iconRight={<ArrowRight data-testid="right" />}>
38
+ Next
39
+ </Button>,
40
+ );
41
+ const btn = screen.getByRole("button", { name: "Next" });
42
+ expect(btn.querySelector('[data-testid="left"]')).toBeInTheDocument();
43
+ expect(btn.querySelector('[data-testid="right"]')).toBeInTheDocument();
44
+ });
45
+
46
+ it("applies fullWidth", () => {
47
+ render(<Button fullWidth>Wide</Button>);
48
+ expect(screen.getByRole("button", { name: "Wide" })).toHaveClass("w-full");
49
+ });
50
+
51
+ it("disables and blocks clicks when disabled", async () => {
52
+ const onClick = vi.fn();
53
+ render(
54
+ <Button disabled onClick={onClick}>
55
+ Disabled
56
+ </Button>,
57
+ );
58
+ const btn = screen.getByRole("button", { name: "Disabled" });
59
+ expect(btn).toBeDisabled();
60
+ await userEvent.click(btn);
61
+ expect(onClick).not.toHaveBeenCalled();
62
+ });
63
+
64
+ it("sets a computed text color and background for customColor", () => {
65
+ render(<Button customColor="#111111">Custom</Button>);
66
+ const btn = screen.getByRole("button", { name: "Custom" });
67
+ expect(btn).toHaveStyle({ backgroundColor: "#111111" });
68
+ // Text is the light candidate for a very dark background.
69
+ expect(btn).toHaveStyle({ color: "#ffffff" });
70
+ });
71
+
72
+ it("forwards an onClick handler and fires it", async () => {
73
+ const onClick = vi.fn();
74
+ render(<Button onClick={onClick}>Click</Button>);
75
+ await userEvent.click(screen.getByRole("button", { name: "Click" }));
76
+ expect(onClick).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it("applies button styles onto a single child when asChild is set", () => {
80
+ render(
81
+ <Button asChild>
82
+ <a href="#pricing">Pricing link</a>
83
+ </Button>,
84
+ );
85
+ const link = screen.getByRole("link", { name: "Pricing link" });
86
+ expect(link.tagName).toBe("A");
87
+ expect(link).toHaveClass("bg-primary");
88
+ // No wrapping <button> is rendered.
89
+ expect(screen.queryByRole("button")).not.toBeInTheDocument();
90
+ });
91
+ });
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { CTA } from "./CTA";
4
+
5
+ const base = { title: "Ready to get started?" };
6
+
7
+ describe("CTA", () => {
8
+ it("renders the title and description", () => {
9
+ render(<CTA {...base} description="Free for 14 days." />);
10
+ expect(screen.getByText("Ready to get started?")).toBeInTheDocument();
11
+ expect(screen.getByText("Free for 14 days.")).toBeInTheDocument();
12
+ });
13
+
14
+ it("renders primary and secondary actions", () => {
15
+ render(
16
+ <CTA
17
+ {...base}
18
+ action={<a href="#a">Action</a>}
19
+ secondaryAction={<a href="#b">Secondary</a>}
20
+ />,
21
+ );
22
+ expect(screen.getByRole("link", { name: "Action" })).toBeInTheDocument();
23
+ expect(screen.getByRole("link", { name: "Secondary" })).toBeInTheDocument();
24
+ });
25
+
26
+ it("renders the panel option (default) with a radial brand background", () => {
27
+ const { container } = render(<CTA {...base} option="panel" />);
28
+ const panel = container.querySelector("div[class*='rounded-']");
29
+ expect(panel).toBeInTheDocument();
30
+ });
31
+
32
+ it("renders the surface option as a paper band", () => {
33
+ const { container } = render(<CTA {...base} option="surface" />);
34
+ expect(container.querySelector("section")).toHaveClass("w-full");
35
+ });
36
+
37
+ it("renders the inverse option on a secondary surface", () => {
38
+ const { container } = render(<CTA {...base} option="inverse" />);
39
+ // The inverse option paints its band with the --color-secondary token.
40
+ const ink = container.querySelector("[style*='--color-secondary']");
41
+ expect(ink).toBeInTheDocument();
42
+ });
43
+
44
+ it("applies centered alignment", () => {
45
+ render(<CTA {...base} option="surface" align="center" />);
46
+ expect(screen.getByText("Ready to get started?")).toBeInTheDocument();
47
+ });
48
+
49
+ it("forwards an id", () => {
50
+ const { container } = render(<CTA {...base} id="cta" />);
51
+ expect(container.querySelector("section#cta")).not.toBeNull();
52
+ });
53
+ });
@@ -0,0 +1,12 @@
1
+ import { forwardRef } from "react";
2
+ import type { CTAProps } from "./types";
3
+ import { CTAPanel } from "./variants/Panel";
4
+ import { CTASurface } from "./variants/Surface";
5
+ import { CTAInverse } from "./variants/Inverse";
6
+
7
+ export const CTA = forwardRef<HTMLElement, CTAProps>(({ option = "panel", ...props }, ref) => {
8
+ if (option === "surface") return <CTASurface ref={ref} {...props} />;
9
+ if (option === "inverse") return <CTAInverse ref={ref} {...props} />;
10
+ return <CTAPanel ref={ref} {...props} />;
11
+ });
12
+ CTA.displayName = "CTA";
@@ -0,0 +1,2 @@
1
+ export { CTA } from "./CTA";
2
+ export type { CTAProps, CTAOption } from "./types";