@tuturuuu/ui 0.29.2 → 0.30.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.0](https://github.com/tutur3u/platform/compare/ui-v0.29.2...ui-v0.30.0) (2026-08-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * **command:** build task-first command center ([cb29d33](https://github.com/tutur3u/platform/commit/cb29d33953745b1a61f09d8c8390459f983d30d7))
9
+ * **command:** build task-first command center ([#5144](https://github.com/tutur3u/platform/issues/5144)) ([292c739](https://github.com/tutur3u/platform/commit/292c7398e2abc3097c2b4ab85522370c302fb030))
10
+ * **forms:** add landing page and per-form SEO controls ([99c5fda](https://github.com/tutur3u/platform/commit/99c5fda3a7d15649a3571b9ccc72759cd815a7ec))
11
+ * **forms:** add landing page, per-form SEO, realtime collaboration, and embeds ([#5145](https://github.com/tutur3u/platform/issues/5145)) ([6896515](https://github.com/tutur3u/platform/commit/6896515b099b28bbebc873f98733879bf108ef89))
12
+
3
13
  ## [0.29.2](https://github.com/tutur3u/platform/compare/ui-v0.29.1...ui-v0.29.2) (2026-08-25)
4
14
 
5
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.29.2",
3
+ "version": "0.30.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -91,9 +91,9 @@
91
91
  "@tuturuuu/apis": "0.11.3",
92
92
  "@tuturuuu/hooks": "0.1.1",
93
93
  "@tuturuuu/icons": "0.1.0",
94
- "@tuturuuu/internal-api": "0.32.0",
94
+ "@tuturuuu/internal-api": "0.33.0",
95
95
  "@tuturuuu/supabase": "0.5.1",
96
- "@tuturuuu/utils": "0.25.5",
96
+ "@tuturuuu/utils": "0.26.0",
97
97
  "@types/debug": "^4.1.13",
98
98
  "browser-image-compression": "^2.0.2",
99
99
  "class-variance-authority": "^0.7.1",
@@ -154,7 +154,7 @@
154
154
  "@tanstack/react-table": "^9.1.2",
155
155
  "@testing-library/jest-dom": "^7.0.1",
156
156
  "@testing-library/react": "^16.3.2",
157
- "@tuturuuu/types": "0.27.0",
157
+ "@tuturuuu/types": "0.28.0",
158
158
  "@tuturuuu/typescript-config": "0.1.2",
159
159
  "@types/html2canvas": "^1.0.0",
160
160
  "@types/lodash": "^4.17.25",
@@ -180,6 +180,7 @@
180
180
  },
181
181
  "exports": {
182
182
  "./globals.css": "./src/globals.css",
183
+ "./marketing": "./src/components/ui/marketing/index.ts",
183
184
  "./postcss.config": "./postcss.config.mjs",
184
185
  "./badge": {
185
186
  "types": "./src/components/ui/badge.tsx",
@@ -34,12 +34,14 @@ function CommandDialog({
34
34
  children,
35
35
  showCloseButton = true,
36
36
  contentClassName,
37
+ commandProps,
37
38
  ...props
38
39
  }: React.ComponentProps<typeof Dialog> & {
39
40
  title?: string;
40
41
  description?: string;
41
42
  showCloseButton?: boolean;
42
43
  contentClassName?: string;
44
+ commandProps?: React.ComponentProps<typeof CommandPrimitive>;
43
45
  }) {
44
46
  return (
45
47
  <Dialog {...props}>
@@ -51,7 +53,13 @@ function CommandDialog({
51
53
  className={cn('overflow-hidden p-0', contentClassName)}
52
54
  showCloseButton={showCloseButton}
53
55
  >
54
- <Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
56
+ <Command
57
+ {...commandProps}
58
+ className={cn(
59
+ '**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5',
60
+ commandProps?.className
61
+ )}
62
+ >
55
63
  {/* TODO: Fix Bun types issue with children prop */}
56
64
  {children as any}
57
65
  </Command>
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Accent tokens for the satellite marketing kit.
3
+ *
4
+ * Tailwind cannot resolve a class name assembled at runtime, so every accent
5
+ * variant has to exist as a literal string somewhere in the source. Keeping the
6
+ * full set in one map means a new accent is added once and every marketing
7
+ * primitive picks it up, instead of each component carrying its own partial
8
+ * copy of the same lookup table.
9
+ */
10
+
11
+ export type MarketingAccent =
12
+ | 'blue'
13
+ | 'cyan'
14
+ | 'green'
15
+ | 'indigo'
16
+ | 'orange'
17
+ | 'pink'
18
+ | 'purple'
19
+ | 'red'
20
+ | 'yellow';
21
+
22
+ export interface MarketingAccentTokens {
23
+ /** Foreground colour for icons and eyebrow labels. */
24
+ text: string;
25
+ /** Mid-stop for a hairline rule that fades out at both ends. */
26
+ rule: string;
27
+ /** Soft corner glow behind a hovered surface. */
28
+ bloom: string;
29
+ /** Filled chip / dot background. */
30
+ solid: string;
31
+ /** Tinted surface for badges and inline highlights. */
32
+ soft: string;
33
+ /** Border tint that pairs with `soft`. */
34
+ border: string;
35
+ /** Two-stop gradient for primary calls to action. */
36
+ gradient: string;
37
+ /** Radial bloom colour used by `SectionBloom`, referencing the raw token. */
38
+ radial: string;
39
+ }
40
+
41
+ export const MARKETING_ACCENTS: Record<MarketingAccent, MarketingAccentTokens> =
42
+ {
43
+ blue: {
44
+ text: 'text-dynamic-blue',
45
+ rule: 'via-dynamic-blue/45',
46
+ bloom: 'bg-dynamic-blue/20',
47
+ solid: 'bg-dynamic-blue',
48
+ soft: 'bg-dynamic-blue/10',
49
+ border: 'border-dynamic-blue/25',
50
+ gradient: 'from-dynamic-blue to-dynamic-cyan',
51
+ radial: 'bg-[radial-gradient(closest-side,var(--blue),transparent)]',
52
+ },
53
+ cyan: {
54
+ text: 'text-dynamic-cyan',
55
+ rule: 'via-dynamic-cyan/45',
56
+ bloom: 'bg-dynamic-cyan/20',
57
+ solid: 'bg-dynamic-cyan',
58
+ soft: 'bg-dynamic-cyan/10',
59
+ border: 'border-dynamic-cyan/25',
60
+ gradient: 'from-dynamic-cyan to-dynamic-blue',
61
+ radial: 'bg-[radial-gradient(closest-side,var(--cyan),transparent)]',
62
+ },
63
+ green: {
64
+ text: 'text-dynamic-green',
65
+ rule: 'via-dynamic-green/45',
66
+ bloom: 'bg-dynamic-green/20',
67
+ solid: 'bg-dynamic-green',
68
+ soft: 'bg-dynamic-green/10',
69
+ border: 'border-dynamic-green/25',
70
+ gradient: 'from-dynamic-green to-dynamic-cyan',
71
+ radial: 'bg-[radial-gradient(closest-side,var(--green),transparent)]',
72
+ },
73
+ indigo: {
74
+ text: 'text-dynamic-indigo',
75
+ rule: 'via-dynamic-indigo/45',
76
+ bloom: 'bg-dynamic-indigo/20',
77
+ solid: 'bg-dynamic-indigo',
78
+ soft: 'bg-dynamic-indigo/10',
79
+ border: 'border-dynamic-indigo/25',
80
+ gradient: 'from-dynamic-indigo to-dynamic-purple',
81
+ radial: 'bg-[radial-gradient(closest-side,var(--indigo),transparent)]',
82
+ },
83
+ orange: {
84
+ text: 'text-dynamic-orange',
85
+ rule: 'via-dynamic-orange/45',
86
+ bloom: 'bg-dynamic-orange/20',
87
+ solid: 'bg-dynamic-orange',
88
+ soft: 'bg-dynamic-orange/10',
89
+ border: 'border-dynamic-orange/25',
90
+ gradient: 'from-dynamic-orange to-dynamic-pink',
91
+ radial: 'bg-[radial-gradient(closest-side,var(--orange),transparent)]',
92
+ },
93
+ pink: {
94
+ text: 'text-dynamic-pink',
95
+ rule: 'via-dynamic-pink/45',
96
+ bloom: 'bg-dynamic-pink/20',
97
+ solid: 'bg-dynamic-pink',
98
+ soft: 'bg-dynamic-pink/10',
99
+ border: 'border-dynamic-pink/25',
100
+ gradient: 'from-dynamic-pink to-dynamic-purple',
101
+ radial: 'bg-[radial-gradient(closest-side,var(--pink),transparent)]',
102
+ },
103
+ purple: {
104
+ text: 'text-dynamic-purple',
105
+ rule: 'via-dynamic-purple/45',
106
+ bloom: 'bg-dynamic-purple/20',
107
+ solid: 'bg-dynamic-purple',
108
+ soft: 'bg-dynamic-purple/10',
109
+ border: 'border-dynamic-purple/25',
110
+ gradient: 'from-dynamic-purple to-dynamic-blue',
111
+ radial: 'bg-[radial-gradient(closest-side,var(--purple),transparent)]',
112
+ },
113
+ red: {
114
+ text: 'text-dynamic-red',
115
+ rule: 'via-dynamic-red/45',
116
+ bloom: 'bg-dynamic-red/20',
117
+ solid: 'bg-dynamic-red',
118
+ soft: 'bg-dynamic-red/10',
119
+ border: 'border-dynamic-red/25',
120
+ gradient: 'from-dynamic-red to-dynamic-orange',
121
+ radial: 'bg-[radial-gradient(closest-side,var(--red),transparent)]',
122
+ },
123
+ yellow: {
124
+ text: 'text-dynamic-yellow',
125
+ rule: 'via-dynamic-yellow/45',
126
+ bloom: 'bg-dynamic-yellow/20',
127
+ solid: 'bg-dynamic-yellow',
128
+ soft: 'bg-dynamic-yellow/10',
129
+ border: 'border-dynamic-yellow/25',
130
+ gradient: 'from-dynamic-yellow to-dynamic-orange',
131
+ radial: 'bg-[radial-gradient(closest-side,var(--yellow),transparent)]',
132
+ },
133
+ };
134
+
135
+ export function getMarketingAccent(
136
+ accent: MarketingAccent
137
+ ): MarketingAccentTokens {
138
+ return MARKETING_ACCENTS[accent];
139
+ }
@@ -0,0 +1,155 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import { getMarketingAccent, type MarketingAccent } from './accents';
3
+
4
+ /**
5
+ * Grain overlay.
6
+ *
7
+ * A single inlined SVG turbulence tile — no network request, no image asset.
8
+ * Keeps large flat areas from banding and gives surfaces a filmic tooth.
9
+ */
10
+ export function Grain({ className }: { className?: string }) {
11
+ return (
12
+ <div
13
+ aria-hidden
14
+ className={cn(
15
+ 'pointer-events-none absolute inset-0 opacity-[0.035] mix-blend-overlay dark:opacity-[0.055]',
16
+ className
17
+ )}
18
+ style={{
19
+ backgroundImage:
20
+ "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")",
21
+ }}
22
+ />
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Fine engineering grid, masked to fade at the edges so it reads as a substrate
28
+ * rather than graph paper.
29
+ */
30
+ export function GridSubstrate({
31
+ className,
32
+ size = '64px',
33
+ }: {
34
+ className?: string;
35
+ size?: string;
36
+ }) {
37
+ const mask =
38
+ 'radial-gradient(ellipse 80% 60% at 50% 40%, black 20%, transparent 75%)';
39
+
40
+ return (
41
+ <div
42
+ aria-hidden
43
+ className={cn(
44
+ 'pointer-events-none absolute inset-0 opacity-[0.5] dark:opacity-[0.35]',
45
+ className
46
+ )}
47
+ style={{
48
+ backgroundImage:
49
+ 'linear-gradient(to right, color-mix(in oklab, var(--foreground) 6%, transparent) 1px, transparent 1px), linear-gradient(to bottom, color-mix(in oklab, var(--foreground) 6%, transparent) 1px, transparent 1px)',
50
+ backgroundSize: `${size} ${size}`,
51
+ maskImage: mask,
52
+ WebkitMaskImage: mask,
53
+ }}
54
+ />
55
+ );
56
+ }
57
+
58
+ interface HeroAtmosphereProps {
59
+ /** Bloom behind the headline. */
60
+ primary?: MarketingAccent;
61
+ /** Cool counterweight in the upper right. */
62
+ secondary?: MarketingAccent;
63
+ /** Accent under the product frame. */
64
+ tertiary?: MarketingAccent;
65
+ /** Fades the rig into the page background at the bottom edge. */
66
+ settle?: boolean;
67
+ className?: string;
68
+ }
69
+
70
+ /**
71
+ * The hero light rig: three placed blooms rather than a full-bleed animated
72
+ * wash, which smears muddy brown across the viewport in dark mode. Each bloom
73
+ * drifts slowly and independently; all motion stops under
74
+ * `prefers-reduced-motion`.
75
+ */
76
+ export function HeroAtmosphere({
77
+ primary = 'purple',
78
+ secondary = 'blue',
79
+ tertiary = 'cyan',
80
+ settle = true,
81
+ className,
82
+ }: HeroAtmosphereProps = {}) {
83
+ const bloom = (accent: MarketingAccent, strength: number) =>
84
+ `radial-gradient(closest-side,color-mix(in oklab,var(--${accent}) ${strength}%,transparent),transparent)`;
85
+
86
+ return (
87
+ <div
88
+ aria-hidden
89
+ className={cn('pointer-events-none absolute inset-0 -z-10', className)}
90
+ >
91
+ {/* Primary bloom — sits behind the headline */}
92
+ <div
93
+ className="absolute -top-32 left-1/2 h-[30rem] w-[34rem] -translate-x-1/2 rounded-full opacity-45 blur-2xl motion-reduce:animate-none sm:-top-40 sm:h-[42rem] sm:w-[52rem] sm:animate-bloom-drift sm:opacity-50 sm:blur-3xl dark:opacity-55 dark:sm:opacity-60"
94
+ style={{ backgroundImage: bloom(primary, 34) }}
95
+ />
96
+
97
+ {/* Cool counterweight — upper right */}
98
+ <div
99
+ className="absolute -top-20 right-[8%] hidden h-[30rem] w-[34rem] animate-bloom-drift-slow rounded-full opacity-45 blur-3xl motion-reduce:animate-none sm:block dark:opacity-55"
100
+ style={{ backgroundImage: bloom(secondary, 34) }}
101
+ />
102
+
103
+ {/* Warm-cool accent — under the product frame */}
104
+ <div
105
+ className="absolute top-[38%] left-[6%] hidden h-[26rem] w-[30rem] animate-bloom-drift rounded-full opacity-40 blur-3xl [animation-delay:-8s] motion-reduce:animate-none sm:block dark:opacity-50"
106
+ style={{ backgroundImage: bloom(tertiary, 28) }}
107
+ />
108
+
109
+ {/* Light seam across the top — the horizon of the rig */}
110
+ <div
111
+ className="absolute inset-x-0 top-0 h-px motion-reduce:animate-none sm:animate-sheen"
112
+ style={{
113
+ backgroundImage: `linear-gradient(90deg,transparent,color-mix(in oklab,var(--${primary}) 55%,transparent) 35%,color-mix(in oklab,var(--${tertiary}) 55%,transparent) 65%,transparent)`,
114
+ }}
115
+ />
116
+
117
+ <GridSubstrate />
118
+
119
+ {settle ? (
120
+ <div className="absolute inset-x-0 bottom-0 h-64 bg-gradient-to-b from-transparent to-background" />
121
+ ) : null}
122
+
123
+ <Grain />
124
+ </div>
125
+ );
126
+ }
127
+
128
+ /**
129
+ * Quieter bloom for sections below the fold — keeps colour continuity down the
130
+ * page without competing with the hero.
131
+ */
132
+ export function SectionBloom({
133
+ tone = 'purple',
134
+ align = 'center',
135
+ }: {
136
+ tone?: MarketingAccent;
137
+ align?: 'center' | 'left' | 'right';
138
+ }) {
139
+ const positions = {
140
+ center: 'left-1/2 -translate-x-1/2',
141
+ left: 'left-[-10%]',
142
+ right: 'right-[-10%]',
143
+ } as const;
144
+
145
+ return (
146
+ <div
147
+ aria-hidden
148
+ className={cn(
149
+ 'pointer-events-none absolute top-0 -z-10 h-[28rem] w-[40rem] rounded-full opacity-[0.07] blur-3xl dark:opacity-[0.12]',
150
+ getMarketingAccent(tone).radial,
151
+ positions[align]
152
+ )}
153
+ />
154
+ );
155
+ }
@@ -0,0 +1,150 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import type { AnchorHTMLAttributes, ReactNode } from 'react';
3
+ import { getMarketingAccent, type MarketingAccent } from './accents';
4
+
5
+ type AnchorProps = Omit<
6
+ AnchorHTMLAttributes<HTMLAnchorElement>,
7
+ 'className' | 'href'
8
+ >;
9
+
10
+ interface CtaLinkProps extends AnchorProps {
11
+ href: string;
12
+ children: ReactNode;
13
+ /** Opens in a new tab with the matching `rel` hardening. */
14
+ external?: boolean;
15
+ /** Stretches to the container on small screens; `auto` never does. */
16
+ width?: 'responsive' | 'auto';
17
+ size?: 'md' | 'lg';
18
+ className?: string;
19
+ }
20
+
21
+ const sizes = {
22
+ md: 'h-11 px-6 text-sm',
23
+ lg: 'h-12 px-8',
24
+ } as const;
25
+
26
+ function externalProps(external?: boolean) {
27
+ return external ? { target: '_blank', rel: 'noopener noreferrer' } : {};
28
+ }
29
+
30
+ /**
31
+ * Filled gradient call to action with a sheen that sweeps on hover.
32
+ *
33
+ * The sweep is a translated pseudo-layer rather than an animated
34
+ * `background-position`, so it composites on the GPU and does not repaint the
35
+ * button's text on every frame.
36
+ */
37
+ export function PrimaryCta({
38
+ href,
39
+ children,
40
+ accent = 'purple',
41
+ external,
42
+ width = 'responsive',
43
+ size = 'lg',
44
+ className,
45
+ ...props
46
+ }: CtaLinkProps & { accent?: MarketingAccent }) {
47
+ return (
48
+ <a
49
+ className={cn(
50
+ 'group relative inline-flex items-center justify-center overflow-hidden rounded-full bg-gradient-to-r font-medium text-white shadow-lg transition-all duration-300 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
51
+ width === 'responsive' && 'w-full sm:w-auto',
52
+ sizes[size],
53
+ getMarketingAccent(accent).gradient,
54
+ className
55
+ )}
56
+ href={href}
57
+ {...externalProps(external)}
58
+ {...props}
59
+ >
60
+ <span
61
+ aria-hidden
62
+ className="absolute inset-0 -translate-x-full bg-[linear-gradient(90deg,transparent,rgb(255_255_255/0.3),transparent)] transition-transform duration-700 group-hover:translate-x-full motion-reduce:hidden"
63
+ />
64
+ <span className="relative flex items-center gap-2">{children}</span>
65
+ </a>
66
+ );
67
+ }
68
+
69
+ /** Quiet, glassy counterpart to `PrimaryCta`. */
70
+ export function SecondaryCta({
71
+ href,
72
+ children,
73
+ external,
74
+ width = 'responsive',
75
+ size = 'lg',
76
+ className,
77
+ ...props
78
+ }: CtaLinkProps) {
79
+ return (
80
+ <a
81
+ className={cn(
82
+ 'inline-flex items-center justify-center gap-2 rounded-full border border-foreground/12 bg-background/40 font-medium text-foreground/75 backdrop-blur-md transition-all duration-300 hover:-translate-y-0.5 hover:border-foreground/25 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
83
+ width === 'responsive' && 'w-full sm:w-auto',
84
+ sizes[size],
85
+ className
86
+ )}
87
+ href={href}
88
+ {...externalProps(external)}
89
+ {...props}
90
+ >
91
+ {children}
92
+ </a>
93
+ );
94
+ }
95
+
96
+ /** Rounded pill label used above hero headlines and inside product frames. */
97
+ export function MarketingPill({
98
+ accent = 'purple',
99
+ icon: Icon,
100
+ children,
101
+ className,
102
+ }: {
103
+ accent?: MarketingAccent;
104
+ icon?: React.ComponentType<{ className?: string }>;
105
+ children: ReactNode;
106
+ className?: string;
107
+ }) {
108
+ return (
109
+ <span
110
+ className={cn(
111
+ 'inline-flex items-center gap-2 rounded-full border border-foreground/10 bg-background/40 py-1.5 pr-4 pl-2.5 font-mono-ui text-[0.65rem] uppercase tracking-[0.2em] backdrop-blur-md',
112
+ getMarketingAccent(accent).text,
113
+ className
114
+ )}
115
+ >
116
+ {Icon ? <Icon className="h-3.5 w-3.5" /> : null}
117
+ {children}
118
+ </span>
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The marketing nav's filled action button.
124
+ *
125
+ * Lives here rather than in `marketing-nav.tsx` so it stays a server component:
126
+ * the nav itself is `'use client'` (it tracks scroll), but the action is often
127
+ * rendered inside a `<Suspense>` boundary on the server and passed in as a
128
+ * slot.
129
+ */
130
+ export function MarketingNavAction({
131
+ accent = 'purple',
132
+ href,
133
+ label,
134
+ }: {
135
+ accent?: MarketingAccent;
136
+ href: string;
137
+ label: string;
138
+ }) {
139
+ return (
140
+ <a
141
+ className={cn(
142
+ 'inline-flex h-9 items-center justify-center whitespace-nowrap rounded-full bg-gradient-to-r px-4 font-medium text-sm text-white shadow-sm transition-transform duration-300 hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
143
+ getMarketingAccent(accent).gradient
144
+ )}
145
+ href={href}
146
+ >
147
+ {label}
148
+ </a>
149
+ );
150
+ }
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@tuturuuu/utils/format';
4
+ import type { ReactNode } from 'react';
5
+ import {
6
+ Accordion,
7
+ AccordionContent,
8
+ AccordionItem,
9
+ AccordionTrigger,
10
+ } from '../accordion';
11
+
12
+ export interface MarketingFaqItem {
13
+ /** Stable value for the accordion item; also the React key. */
14
+ id: string;
15
+ question: ReactNode;
16
+ answer: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Marketing FAQ.
21
+ *
22
+ * A single-open accordion rather than a static definition list: an FAQ block
23
+ * long enough to be useful is long enough to bury the closing CTA if every
24
+ * answer is expanded at once.
25
+ */
26
+ export function MarketingFaqList({
27
+ items,
28
+ className,
29
+ }: {
30
+ items: MarketingFaqItem[];
31
+ className?: string;
32
+ }) {
33
+ return (
34
+ <Accordion
35
+ className={cn('mx-auto w-full max-w-3xl', className)}
36
+ collapsible
37
+ type="single"
38
+ >
39
+ {items.map((item) => (
40
+ <AccordionItem
41
+ className="border-foreground/[0.08] border-b"
42
+ key={item.id}
43
+ value={item.id}
44
+ >
45
+ <AccordionTrigger className="py-5 text-left font-display font-medium text-base tracking-[-0.01em] hover:no-underline sm:text-lg">
46
+ {item.question}
47
+ </AccordionTrigger>
48
+ <AccordionContent className="pb-5 text-foreground/55 text-sm leading-relaxed sm:text-base">
49
+ {item.answer}
50
+ </AccordionContent>
51
+ </AccordionItem>
52
+ ))}
53
+ </Accordion>
54
+ );
55
+ }
@@ -0,0 +1,33 @@
1
+ import { Be_Vietnam_Pro, JetBrains_Mono } from 'next/font/google';
2
+
3
+ /**
4
+ * Display face for marketing headlines.
5
+ *
6
+ * Be Vietnam Pro is drawn for Vietnamese typography, so `vi` headlines keep
7
+ * their diacritics properly fitted instead of falling back mid-headline — and
8
+ * it carries far more character than the body face at large sizes.
9
+ */
10
+ export const marketingDisplayFont = Be_Vietnam_Pro({
11
+ subsets: ['latin', 'vietnamese'],
12
+ weight: ['500', '600', '700', '800'],
13
+ display: 'swap',
14
+ variable: '--font-be-vietnam-pro',
15
+ });
16
+
17
+ /**
18
+ * Micro-label / numeric face: section eyebrows, indices, stat values. Also
19
+ * covers Vietnamese so translated eyebrows stay in the same voice.
20
+ */
21
+ export const marketingMonoFont = JetBrains_Mono({
22
+ subsets: ['latin', 'vietnamese'],
23
+ weight: ['400', '500', '600'],
24
+ display: 'swap',
25
+ variable: '--font-jetbrains-mono',
26
+ });
27
+
28
+ /**
29
+ * Apply to the marketing layout's root element. `--font-display` and
30
+ * `--font-mono-ui` in `globals.css` resolve through these variables and fall
31
+ * back to the app font wherever the marketing faces are not loaded.
32
+ */
33
+ export const marketingFontVariables = `${marketingDisplayFont.variable} ${marketingMonoFont.variable}`;
@@ -0,0 +1,37 @@
1
+ export {
2
+ getMarketingAccent,
3
+ MARKETING_ACCENTS,
4
+ type MarketingAccent,
5
+ type MarketingAccentTokens,
6
+ } from './accents';
7
+ export {
8
+ Grain,
9
+ GridSubstrate,
10
+ HeroAtmosphere,
11
+ SectionBloom,
12
+ } from './atmosphere';
13
+ export {
14
+ MarketingNavAction,
15
+ MarketingPill,
16
+ PrimaryCta,
17
+ SecondaryCta,
18
+ } from './cta';
19
+ export { type MarketingFaqItem, MarketingFaqList } from './faq-list';
20
+ export {
21
+ marketingDisplayFont,
22
+ marketingFontVariables,
23
+ marketingMonoFont,
24
+ } from './fonts';
25
+ export {
26
+ MarketingFooter,
27
+ type MarketingFooterColumn,
28
+ type MarketingFooterLink,
29
+ } from './marketing-footer';
30
+ export { MarketingNav, type MarketingNavLink } from './marketing-nav';
31
+ export {
32
+ SectionEyebrow,
33
+ SectionShell,
34
+ type SectionWidth,
35
+ } from './section-shell';
36
+ export { type MarketingStat, MarketingStatBand } from './stat-band';
37
+ export { SurfaceCard } from './surface-card';
@@ -0,0 +1,90 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import type { ReactNode } from 'react';
3
+ import { TuturuuLogo } from '../custom/tuturuuu-logo';
4
+
5
+ export interface MarketingFooterLink {
6
+ href: string;
7
+ label: string;
8
+ external?: boolean;
9
+ }
10
+
11
+ export interface MarketingFooterColumn {
12
+ id: string;
13
+ title: string;
14
+ links: MarketingFooterLink[];
15
+ }
16
+
17
+ interface MarketingFooterProps {
18
+ appName: string;
19
+ /** One-line positioning statement under the wordmark. */
20
+ tagline: string;
21
+ columns: MarketingFooterColumn[];
22
+ /** Rendered at the bottom left, e.g. "© 2026 Tuturuuu". */
23
+ legal: ReactNode;
24
+ /** Locale switcher or social row, supplied by the host app. */
25
+ utilities?: ReactNode;
26
+ className?: string;
27
+ }
28
+
29
+ /** Shared satellite marketing footer: wordmark column plus link columns. */
30
+ export function MarketingFooter({
31
+ appName,
32
+ tagline,
33
+ columns,
34
+ legal,
35
+ utilities,
36
+ className,
37
+ }: MarketingFooterProps) {
38
+ return (
39
+ <footer
40
+ className={cn(
41
+ 'relative border-foreground/[0.08] border-t px-4 py-16 sm:px-6 lg:px-8',
42
+ className
43
+ )}
44
+ >
45
+ <div className="mx-auto grid w-full max-w-6xl gap-12 lg:grid-cols-[1.5fr_repeat(3,1fr)]">
46
+ <div className="max-w-sm">
47
+ <div className="flex items-center gap-2.5">
48
+ <TuturuuLogo className="h-7 w-7" height={28} width={28} />
49
+ <span className="font-display font-semibold text-[0.95rem] tracking-[-0.01em]">
50
+ {appName}
51
+ </span>
52
+ </div>
53
+ <p className="mt-4 text-foreground/50 text-sm leading-relaxed">
54
+ {tagline}
55
+ </p>
56
+ </div>
57
+
58
+ {columns.map((column) => (
59
+ <nav aria-label={column.title} key={column.id}>
60
+ <h3 className="font-mono-ui text-[0.65rem] text-foreground/40 uppercase tracking-[0.2em]">
61
+ {column.title}
62
+ </h3>
63
+ <ul className="mt-4 space-y-2.5">
64
+ {column.links.map((link) => (
65
+ <li key={`${column.id}-${link.href}`}>
66
+ <a
67
+ className="text-foreground/55 text-sm transition-colors hover:text-foreground"
68
+ href={link.href}
69
+ {...(link.external
70
+ ? { target: '_blank', rel: 'noopener noreferrer' }
71
+ : {})}
72
+ >
73
+ {link.label}
74
+ </a>
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ </nav>
79
+ ))}
80
+ </div>
81
+
82
+ <div className="mx-auto mt-14 flex w-full max-w-6xl flex-col items-center justify-between gap-4 border-foreground/[0.06] border-t pt-8 sm:flex-row">
83
+ <p className="text-foreground/40 text-xs">{legal}</p>
84
+ {utilities ? (
85
+ <div className="flex items-center gap-3">{utilities}</div>
86
+ ) : null}
87
+ </div>
88
+ </footer>
89
+ );
90
+ }
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ import { Menu, X } from '@tuturuuu/icons';
4
+ import { cn } from '@tuturuuu/utils/format';
5
+ import { type ReactNode, useEffect, useState } from 'react';
6
+ import { TuturuuLogo } from '../custom/tuturuuu-logo';
7
+
8
+ export interface MarketingNavLink {
9
+ /** In-page anchor (`#pricing`) or absolute URL. */
10
+ href: string;
11
+ label: string;
12
+ }
13
+
14
+ interface MarketingNavProps {
15
+ /** App name shown next to the logo, e.g. "Forms". */
16
+ appName: string;
17
+ /** Where the wordmark links back to. Defaults to the landing root. */
18
+ homeHref?: string;
19
+ links: MarketingNavLink[];
20
+ /**
21
+ * Primary action button. Taken as a node rather than `{href,label}` so the
22
+ * host can wrap it in `<Suspense>` and let a session-dependent label stream
23
+ * in without making the whole marketing page dynamic. Render
24
+ * `MarketingNavAction` for the standard treatment.
25
+ */
26
+ action: ReactNode;
27
+ /** Secondary text action shown beside the primary on desktop. */
28
+ secondaryAction?: { href: string; label: string };
29
+ /** Locale/theme switchers supplied by the host app. */
30
+ utilities?: ReactNode;
31
+ }
32
+
33
+ /**
34
+ * Sticky marketing header.
35
+ *
36
+ * Starts transparent over the hero and only fades in its border and blur once
37
+ * the page scrolls, so the hero light rig is not cropped by a hard bar on load.
38
+ */
39
+ export function MarketingNav({
40
+ appName,
41
+ homeHref = '/',
42
+ links,
43
+ action,
44
+ secondaryAction,
45
+ utilities,
46
+ }: MarketingNavProps) {
47
+ const [scrolled, setScrolled] = useState(false);
48
+ const [menuOpen, setMenuOpen] = useState(false);
49
+
50
+ useEffect(() => {
51
+ const onScroll = () => setScrolled(window.scrollY > 12);
52
+ onScroll();
53
+ window.addEventListener('scroll', onScroll, { passive: true });
54
+ return () => window.removeEventListener('scroll', onScroll);
55
+ }, []);
56
+
57
+ // A menu left open while the viewport grows to desktop would keep the page
58
+ // scroll-locked behind an invisible panel.
59
+ useEffect(() => {
60
+ if (!menuOpen) return;
61
+ const media = window.matchMedia('(min-width: 768px)');
62
+ const close = () => media.matches && setMenuOpen(false);
63
+ close();
64
+ media.addEventListener('change', close);
65
+ return () => media.removeEventListener('change', close);
66
+ }, [menuOpen]);
67
+
68
+ return (
69
+ <header
70
+ className={cn(
71
+ 'fixed inset-x-0 top-0 z-50 transition-all duration-300',
72
+ scrolled || menuOpen
73
+ ? 'border-foreground/[0.08] border-b bg-background/80 backdrop-blur-xl'
74
+ : 'border-transparent border-b bg-transparent'
75
+ )}
76
+ >
77
+ <nav
78
+ aria-label={appName}
79
+ className="mx-auto flex h-16 w-full max-w-7xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8"
80
+ >
81
+ <a
82
+ className="flex shrink-0 items-center gap-2.5 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
83
+ href={homeHref}
84
+ >
85
+ <TuturuuLogo className="h-7 w-7" height={28} width={28} />
86
+ <span className="font-display font-semibold text-[0.95rem] tracking-[-0.01em]">
87
+ {appName}
88
+ </span>
89
+ </a>
90
+
91
+ <div className="hidden items-center gap-1 md:flex">
92
+ {links.map((link) => (
93
+ <a
94
+ className="rounded-full px-3.5 py-2 text-foreground/55 text-sm transition-colors hover:bg-foreground/[0.04] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
95
+ href={link.href}
96
+ key={link.href}
97
+ >
98
+ {link.label}
99
+ </a>
100
+ ))}
101
+ </div>
102
+
103
+ <div className="flex items-center gap-2">
104
+ {utilities}
105
+ {secondaryAction ? (
106
+ <a
107
+ className="hidden rounded-full px-3.5 py-2 text-foreground/55 text-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:inline-flex"
108
+ href={secondaryAction.href}
109
+ >
110
+ {secondaryAction.label}
111
+ </a>
112
+ ) : null}
113
+ {action}
114
+ <button
115
+ aria-expanded={menuOpen}
116
+ aria-label={appName}
117
+ className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-foreground/10 text-foreground/70 transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:hidden"
118
+ onClick={() => setMenuOpen((open) => !open)}
119
+ type="button"
120
+ >
121
+ {menuOpen ? (
122
+ <X className="h-4 w-4" />
123
+ ) : (
124
+ <Menu className="h-4 w-4" />
125
+ )}
126
+ </button>
127
+ </div>
128
+ </nav>
129
+
130
+ {menuOpen ? (
131
+ <div className="border-foreground/[0.06] border-t px-4 pb-4 md:hidden">
132
+ <div className="flex flex-col py-2">
133
+ {links.map((link) => (
134
+ <a
135
+ className="rounded-lg px-3 py-3 text-foreground/70 text-sm transition-colors hover:bg-foreground/[0.04] hover:text-foreground"
136
+ href={link.href}
137
+ key={link.href}
138
+ onClick={() => setMenuOpen(false)}
139
+ >
140
+ {link.label}
141
+ </a>
142
+ ))}
143
+ {secondaryAction ? (
144
+ <a
145
+ className="rounded-lg px-3 py-3 text-foreground/70 text-sm transition-colors hover:bg-foreground/[0.04] hover:text-foreground"
146
+ href={secondaryAction.href}
147
+ onClick={() => setMenuOpen(false)}
148
+ >
149
+ {secondaryAction.label}
150
+ </a>
151
+ ) : null}
152
+ </div>
153
+ </div>
154
+ ) : null}
155
+ </header>
156
+ );
157
+ }
@@ -0,0 +1,148 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import type { ReactNode } from 'react';
3
+ import type { MarketingAccent } from './accents';
4
+ import { SectionBloom } from './atmosphere';
5
+
6
+ const widths = {
7
+ narrow: 'max-w-4xl',
8
+ default: 'max-w-6xl',
9
+ wide: 'max-w-7xl',
10
+ full: 'max-w-none',
11
+ } as const;
12
+
13
+ export type SectionWidth = keyof typeof widths;
14
+
15
+ /**
16
+ * The mono eyebrow, optionally preceded by a two-digit section index sitting in
17
+ * its own hairline rule. Exported on its own because hero sections want the
18
+ * label without the surrounding section chrome.
19
+ */
20
+ export function SectionEyebrow({
21
+ children,
22
+ index,
23
+ className,
24
+ }: {
25
+ children: ReactNode;
26
+ index?: string;
27
+ className?: string;
28
+ }) {
29
+ return (
30
+ <span
31
+ className={cn(
32
+ 'inline-flex items-center gap-3 font-mono-ui text-[0.65rem] text-foreground/45 uppercase tracking-[0.24em]',
33
+ className
34
+ )}
35
+ >
36
+ {index ? (
37
+ <>
38
+ <span className="text-foreground/30 tabular-nums">{index}</span>
39
+ <span aria-hidden className="h-px w-8 bg-foreground/15" />
40
+ </>
41
+ ) : null}
42
+ {children}
43
+ </span>
44
+ );
45
+ }
46
+
47
+ interface SectionShellProps {
48
+ id?: string;
49
+ /** Two-digit index rendered in the rule beside the eyebrow, e.g. "02". */
50
+ index?: string;
51
+ eyebrow?: ReactNode;
52
+ title: ReactNode;
53
+ subtitle?: ReactNode;
54
+ children: ReactNode;
55
+ /** Constrains the inner content. Defaults to the 6xl marketing column. */
56
+ width?: SectionWidth;
57
+ align?: 'center' | 'start';
58
+ /** Ambient brand bloom behind the section header. */
59
+ bloom?: MarketingAccent | 'none';
60
+ /** Hairline rule across the top of the section. */
61
+ rule?: boolean;
62
+ /** Vertical rhythm. `tight` is for stacked sections that read as one unit. */
63
+ spacing?: 'tight' | 'default';
64
+ className?: string;
65
+ headerClassName?: string;
66
+ }
67
+
68
+ /**
69
+ * Shared vertical rhythm and header treatment for every marketing section.
70
+ *
71
+ * Keeps eyebrow/title/subtitle typography and section padding identical down
72
+ * the page so sections read as one system instead of N variations.
73
+ */
74
+ export function SectionShell({
75
+ id,
76
+ index,
77
+ eyebrow,
78
+ title,
79
+ subtitle,
80
+ children,
81
+ width = 'default',
82
+ align = 'center',
83
+ bloom = 'none',
84
+ rule = true,
85
+ spacing = 'default',
86
+ className,
87
+ headerClassName,
88
+ }: SectionShellProps) {
89
+ return (
90
+ <section
91
+ className={cn(
92
+ // `isolate` is load-bearing: `SectionBloom` paints at `-z-10`, and
93
+ // without a local stacking context it slides behind any ancestor that
94
+ // has its own background and vanishes.
95
+ 'relative isolate scroll-mt-24 px-4 sm:px-6 lg:px-8',
96
+ spacing === 'tight'
97
+ ? 'py-14 sm:py-18 lg:py-20'
98
+ : 'py-20 sm:py-28 lg:py-32',
99
+ className
100
+ )}
101
+ id={id}
102
+ >
103
+ {rule ? (
104
+ <div
105
+ aria-hidden
106
+ className="pointer-events-none absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,color-mix(in_oklab,var(--foreground)_12%,transparent)_25%,color-mix(in_oklab,var(--foreground)_12%,transparent)_75%,transparent)]"
107
+ />
108
+ ) : null}
109
+ {bloom === 'none' ? null : <SectionBloom tone={bloom} />}
110
+
111
+ <div className={cn('relative mx-auto w-full', widths[width])}>
112
+ <header
113
+ className={cn(
114
+ 'mb-14 flex flex-col sm:mb-18',
115
+ align === 'center' ? 'items-center text-center' : 'items-start',
116
+ headerClassName
117
+ )}
118
+ >
119
+ {eyebrow ? (
120
+ <SectionEyebrow index={index}>{eyebrow}</SectionEyebrow>
121
+ ) : null}
122
+
123
+ <h2
124
+ className={cn(
125
+ 'mt-6 max-w-3xl text-balance font-display font-semibold text-4xl tracking-[-0.03em] sm:text-5xl lg:text-[3.5rem] lg:leading-[1.05]',
126
+ align === 'center' && 'mx-auto'
127
+ )}
128
+ >
129
+ {title}
130
+ </h2>
131
+
132
+ {subtitle ? (
133
+ <p
134
+ className={cn(
135
+ 'mt-5 max-w-2xl text-balance text-base text-foreground/55 leading-relaxed sm:text-lg',
136
+ align === 'center' && 'mx-auto'
137
+ )}
138
+ >
139
+ {subtitle}
140
+ </p>
141
+ ) : null}
142
+ </header>
143
+
144
+ {children}
145
+ </div>
146
+ </section>
147
+ );
148
+ }
@@ -0,0 +1,46 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import type { ReactNode } from 'react';
3
+
4
+ export interface MarketingStat {
5
+ id: string;
6
+ /** The number itself — kept short so the row stays on one line on mobile. */
7
+ value: ReactNode;
8
+ label: ReactNode;
9
+ }
10
+
11
+ /**
12
+ * Row of headline figures under a hero.
13
+ *
14
+ * Values render in the mono face with `tabular-nums` so a band that animates or
15
+ * re-renders (live counters, locale switches) does not reflow its neighbours.
16
+ */
17
+ export function MarketingStatBand({
18
+ stats,
19
+ className,
20
+ }: {
21
+ stats: MarketingStat[];
22
+ className?: string;
23
+ }) {
24
+ return (
25
+ <dl
26
+ className={cn(
27
+ 'grid w-full grid-cols-2 gap-px overflow-hidden rounded-2xl border border-foreground/[0.08] bg-foreground/[0.06] sm:grid-cols-4',
28
+ className
29
+ )}
30
+ >
31
+ {stats.map((stat) => (
32
+ <div
33
+ className="flex flex-col items-center gap-1 bg-background px-4 py-6 text-center"
34
+ key={stat.id}
35
+ >
36
+ <dt className="order-2 text-[0.7rem] text-foreground/45 uppercase tracking-[0.16em]">
37
+ {stat.label}
38
+ </dt>
39
+ <dd className="order-1 font-display font-semibold text-2xl tabular-nums tracking-[-0.02em] sm:text-3xl">
40
+ {stat.value}
41
+ </dd>
42
+ </div>
43
+ ))}
44
+ </dl>
45
+ );
46
+ }
@@ -0,0 +1,144 @@
1
+ import { ArrowUpRight } from '@tuturuuu/icons';
2
+ import { cn } from '@tuturuuu/utils/format';
3
+ import type { ComponentType, ReactNode } from 'react';
4
+ import { getMarketingAccent, type MarketingAccent } from './accents';
5
+
6
+ interface SurfaceCardProps {
7
+ accent: MarketingAccent;
8
+ icon?: ComponentType<{ className?: string }>;
9
+ title: ReactNode;
10
+ description: ReactNode;
11
+ /** Small mono label above the title (a role, a category). */
12
+ eyebrow?: ReactNode;
13
+ /** Renders the card as a link and reveals a corner arrow. */
14
+ href?: string;
15
+ /** Opens the link in a new tab — used for apps on their own domain. */
16
+ external?: boolean;
17
+ /** `stack` puts the icon above the copy; `inline` places it alongside. */
18
+ layout?: 'stack' | 'inline';
19
+ size?: 'md' | 'lg';
20
+ /** Extra content rendered under the description (a chip row, a mini preview). */
21
+ footer?: ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ /**
26
+ * The marketing card surface: border, lit top edge, corner bloom, hover lift.
27
+ *
28
+ * Every feature grid, use-case tile and app link on a satellite landing page
29
+ * routes through this, so a change to the treatment lands everywhere at once
30
+ * instead of being re-typed per section.
31
+ */
32
+ export function SurfaceCard({
33
+ accent,
34
+ icon: Icon,
35
+ title,
36
+ description,
37
+ eyebrow,
38
+ href,
39
+ external,
40
+ layout = 'stack',
41
+ size = 'md',
42
+ footer,
43
+ className,
44
+ }: SurfaceCardProps) {
45
+ const styles = getMarketingAccent(accent);
46
+ const Root = href ? 'a' : 'div';
47
+ const isLarge = size === 'lg';
48
+
49
+ return (
50
+ <Root
51
+ {...(href ? { href } : {})}
52
+ {...(href && external
53
+ ? { target: '_blank', rel: 'noopener noreferrer' }
54
+ : {})}
55
+ className={cn(
56
+ 'group relative flex h-full overflow-hidden rounded-2xl border border-foreground/[0.08] bg-foreground/[0.015] transition-all duration-500',
57
+ 'hover:-translate-y-1 hover:border-foreground/15 hover:bg-foreground/[0.03]',
58
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
59
+ layout === 'inline' ? 'items-start gap-3 p-4' : 'flex-col',
60
+ layout === 'stack' && (isLarge ? 'p-6 sm:p-7' : 'p-5'),
61
+ className
62
+ )}
63
+ >
64
+ <span
65
+ aria-hidden
66
+ className={cn(
67
+ 'pointer-events-none absolute inset-x-6 top-0 h-px bg-gradient-to-r from-transparent to-transparent transition-opacity duration-500 group-hover:opacity-100',
68
+ layout === 'inline' ? 'opacity-0' : 'opacity-40',
69
+ styles.rule
70
+ )}
71
+ />
72
+ <span
73
+ aria-hidden
74
+ className={cn(
75
+ 'pointer-events-none absolute -top-16 -right-10 h-40 w-40 rounded-full opacity-0 blur-3xl transition-opacity duration-700 group-hover:opacity-100',
76
+ styles.bloom
77
+ )}
78
+ />
79
+
80
+ {Icon ? (
81
+ layout === 'inline' ? (
82
+ <span
83
+ className={cn(
84
+ 'relative flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-foreground/10 bg-foreground/[0.03] transition-transform duration-500 group-hover:scale-105',
85
+ styles.text
86
+ )}
87
+ >
88
+ <Icon className="h-3.5 w-3.5" />
89
+ </span>
90
+ ) : (
91
+ <Icon
92
+ className={cn(
93
+ 'relative transition-transform duration-500 group-hover:scale-110',
94
+ isLarge ? 'h-5 w-5' : 'h-4 w-4',
95
+ styles.text
96
+ )}
97
+ />
98
+ )
99
+ ) : null}
100
+
101
+ <span className={cn('relative min-w-0', layout === 'inline' && 'flex-1')}>
102
+ {eyebrow ? (
103
+ <span
104
+ className={cn(
105
+ 'block font-mono-ui text-[0.6rem] uppercase tracking-[0.18em]',
106
+ layout === 'stack' && Icon && 'mt-4',
107
+ styles.text
108
+ )}
109
+ >
110
+ {eyebrow}
111
+ </span>
112
+ ) : null}
113
+
114
+ <span
115
+ className={cn(
116
+ 'flex items-center gap-1.5 font-display font-semibold tracking-[-0.01em]',
117
+ layout === 'stack' &&
118
+ !eyebrow &&
119
+ Icon &&
120
+ (isLarge ? 'mt-5' : 'mt-4'),
121
+ eyebrow && 'mt-1.5',
122
+ isLarge ? 'text-xl sm:text-2xl' : 'text-[0.95rem]'
123
+ )}
124
+ >
125
+ {title}
126
+ {href ? (
127
+ <ArrowUpRight className="h-3 w-3 shrink-0 text-foreground/25 transition-all duration-300 group-hover:translate-x-0.5 group-hover:-translate-y-0.5 group-hover:text-foreground/50" />
128
+ ) : null}
129
+ </span>
130
+
131
+ <span
132
+ className={cn(
133
+ 'mt-2 block text-foreground/50 leading-relaxed',
134
+ isLarge ? 'max-w-md text-sm' : 'text-xs'
135
+ )}
136
+ >
137
+ {description}
138
+ </span>
139
+
140
+ {footer ? <span className="relative mt-4 block">{footer}</span> : null}
141
+ </span>
142
+ </Root>
143
+ );
144
+ }