@tuturuuu/ui 0.29.2 → 0.30.1

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.1](https://github.com/tutur3u/platform/compare/ui-v0.30.0...ui-v0.30.1) (2026-08-27)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **forms:** repair the landing demo, the brand lockup and two analytics labels ([3f16968](https://github.com/tutur3u/platform/commit/3f16968f5802ebbd518ee27025c61a759839fd4e))
9
+ * **forms:** repair the landing demo, the brand lockup and two analytics labels ([#5164](https://github.com/tutur3u/platform/issues/5164)) ([56d7f62](https://github.com/tutur3u/platform/commit/56d7f62325a8ec0caa5bb3ccd76b050dd66b1e53))
10
+
11
+ ## [0.30.0](https://github.com/tutur3u/platform/compare/ui-v0.29.2...ui-v0.30.0) (2026-08-26)
12
+
13
+
14
+ ### Features
15
+
16
+ * **command:** build task-first command center ([cb29d33](https://github.com/tutur3u/platform/commit/cb29d33953745b1a61f09d8c8390459f983d30d7))
17
+ * **command:** build task-first command center ([#5144](https://github.com/tutur3u/platform/issues/5144)) ([292c739](https://github.com/tutur3u/platform/commit/292c7398e2abc3097c2b4ab85522370c302fb030))
18
+ * **forms:** add landing page and per-form SEO controls ([99c5fda](https://github.com/tutur3u/platform/commit/99c5fda3a7d15649a3571b9ccc72759cd815a7ec))
19
+ * **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))
20
+
3
21
  ## [0.29.2](https://github.com/tutur3u/platform/compare/ui-v0.29.1...ui-v0.29.2) (2026-08-25)
4
22
 
5
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.29.2",
3
+ "version": "0.30.1",
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.27.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",
@@ -312,6 +313,10 @@
312
313
  "types": "./src/components/ui/custom/system-language-wrapper.tsx",
313
314
  "import": "./src/components/ui/custom/system-language-wrapper.tsx"
314
315
  },
316
+ "./custom/tuturuuu-wordmark": {
317
+ "types": "./src/components/ui/custom/tuturuuu-wordmark.tsx",
318
+ "import": "./src/components/ui/custom/tuturuuu-wordmark.tsx"
319
+ },
315
320
  "./custom/tuturuuu-logo": {
316
321
  "types": "./src/components/ui/custom/tuturuuu-logo.tsx",
317
322
  "import": "./src/components/ui/custom/tuturuuu-logo.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,22 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { toProductName } from '../tuturuuu-wordmark';
3
+
4
+ describe('toProductName', () => {
5
+ it('strips the company name from a full app name', () => {
6
+ expect(toProductName('Tuturuuu Forms')).toBe('Forms');
7
+ expect(toProductName('Tuturuuu Calendar')).toBe('Calendar');
8
+ });
9
+
10
+ it('is case-insensitive and tolerates extra spacing', () => {
11
+ expect(toProductName('TUTURUUU Forms')).toBe('Forms');
12
+ });
13
+
14
+ it('leaves a bare product name alone', () => {
15
+ expect(toProductName('Forms')).toBe('Forms');
16
+ });
17
+
18
+ it('returns the name unchanged when stripping would empty it', () => {
19
+ // Otherwise the lockup renders with a blank second line.
20
+ expect(toProductName('Tuturuuu')).toBe('Tuturuuu');
21
+ });
22
+ });
@@ -0,0 +1,72 @@
1
+ import { cn } from '@tuturuuu/utils/format';
2
+ import { TuturuuLogo } from './tuturuuu-logo';
3
+
4
+ /**
5
+ * The Tuturuuu product lockup: mark, then "Tuturuuu" over the product name.
6
+ *
7
+ * Two lines rather than one string. "Tuturuuu Forms" set on a single line reads
8
+ * as one long product name; stacking it says what it is — a Tuturuuu product
9
+ * called Forms — and keeps the company name consistent across products while
10
+ * only the second line changes.
11
+ *
12
+ * The logo deliberately does not take a local `src`. Satellites are served from
13
+ * their own domains and do not carry `/media`, so a local path 404s there; the
14
+ * component's default is the absolute URL for exactly that reason.
15
+ */
16
+ export function TuturuuuWordmark({
17
+ className,
18
+ product,
19
+ size = 'md',
20
+ }: {
21
+ className?: string;
22
+ /** The second line — the product name, without "Tuturuuu" in front of it. */
23
+ product: string;
24
+ size?: 'sm' | 'md';
25
+ }) {
26
+ const dimension = size === 'sm' ? 28 : 32;
27
+
28
+ return (
29
+ <span className={cn('flex items-center gap-2.5', className)}>
30
+ <TuturuuLogo
31
+ alt=""
32
+ aria-hidden
33
+ className={cn('object-contain', size === 'sm' ? 'h-7 w-7' : 'h-8 w-8')}
34
+ height={dimension}
35
+ width={dimension}
36
+ />
37
+ <span className="flex flex-col text-left leading-none">
38
+ <span
39
+ className={cn(
40
+ 'uppercase tracking-[0.24em] opacity-70',
41
+ size === 'sm' ? 'text-[9px]' : 'text-[10px]'
42
+ )}
43
+ >
44
+ Tuturuuu
45
+ </span>
46
+ <span
47
+ className={cn(
48
+ 'font-display font-semibold uppercase tracking-[0.08em]',
49
+ size === 'sm' ? 'text-sm' : 'text-lg'
50
+ )}
51
+ >
52
+ {product}
53
+ </span>
54
+ </span>
55
+ </span>
56
+ );
57
+ }
58
+
59
+ /**
60
+ * The product line of a full app name: "Tuturuuu Forms" -> "Forms".
61
+ *
62
+ * Exists so the nav and the footer cannot disagree. They derive the same label
63
+ * from the same prop, and two copies of one regex is exactly the kind of
64
+ * duplication that drifts when only one of them is updated.
65
+ *
66
+ * A name that is only "Tuturuuu" is returned unchanged rather than reduced to
67
+ * an empty string, which would render a lockup with a blank second line.
68
+ */
69
+ export function toProductName(appName: string): string {
70
+ const stripped = appName.replace(/^Tuturuuu\s+/i, '').trim();
71
+ return stripped || appName;
72
+ }
@@ -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
+ }