@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 +18 -0
- package/package.json +9 -4
- package/src/components/ui/command.tsx +9 -1
- package/src/components/ui/custom/__tests__/tuturuuu-wordmark.test.ts +22 -0
- package/src/components/ui/custom/tuturuuu-wordmark.tsx +72 -0
- package/src/components/ui/marketing/accents.ts +139 -0
- package/src/components/ui/marketing/atmosphere.tsx +155 -0
- package/src/components/ui/marketing/cta.tsx +150 -0
- package/src/components/ui/marketing/faq-list.tsx +55 -0
- package/src/components/ui/marketing/fonts.ts +33 -0
- package/src/components/ui/marketing/index.ts +37 -0
- package/src/components/ui/marketing/marketing-footer.tsx +91 -0
- package/src/components/ui/marketing/marketing-nav.tsx +167 -0
- package/src/components/ui/marketing/section-shell.tsx +148 -0
- package/src/components/ui/marketing/stat-band.tsx +46 -0
- package/src/components/ui/marketing/surface-card.tsx +144 -0
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.
|
|
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.
|
|
94
|
+
"@tuturuuu/internal-api": "0.33.0",
|
|
95
95
|
"@tuturuuu/supabase": "0.5.1",
|
|
96
|
-
"@tuturuuu/utils": "0.
|
|
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.
|
|
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
|
|
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
|
+
}
|