expxagents 0.3.0 → 0.4.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/assets/agents/development/frontend-developer.agent.md +17 -6
- package/assets/agents/marketing/landing-page-builder.agent.md +78 -13
- package/assets/core/best-practices/_catalog.yaml +6 -0
- package/assets/core/best-practices/fullstack-page-generation.md +936 -0
- package/assets/core/best-practices/landing-page-react.md +2263 -0
- package/dist/cli/src/commands/init.d.ts +3 -1
- package/dist/cli/src/commands/init.js +24 -12
- package/dist/cli/src/index.js +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,2263 @@
|
|
|
1
|
+
# Landing Page React — Best Practices
|
|
2
|
+
|
|
3
|
+
## Project Structure
|
|
4
|
+
|
|
5
|
+
### Next.js (recommended for SEO-critical pages)
|
|
6
|
+
```
|
|
7
|
+
├── app/
|
|
8
|
+
│ ├── layout.tsx # Root layout, fonts, metadata
|
|
9
|
+
│ ├── page.tsx # Landing page entry
|
|
10
|
+
│ └── globals.css # Design tokens + base styles
|
|
11
|
+
├── components/
|
|
12
|
+
│ ├── atoms/ # Button, Badge, Heading, Text, Input, Image
|
|
13
|
+
│ ├── molecules/ # FeatureCard, TestimonialCard, PricingCard, StatCounter, CTAGroup
|
|
14
|
+
│ ├── organisms/ # HeroSection, FeaturesGrid, PricingSection, FAQAccordion, Footer
|
|
15
|
+
│ └── ui/ # Container, Section (layout primitives)
|
|
16
|
+
├── hooks/ # useIntersectionObserver, useMediaQuery, useCountUp
|
|
17
|
+
├── lib/ # cn() utility, constants
|
|
18
|
+
└── public/ # Static assets (images, fonts)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Vite + React (lightweight, no SSR needed)
|
|
22
|
+
```
|
|
23
|
+
├── src/
|
|
24
|
+
│ ├── main.tsx
|
|
25
|
+
│ ├── App.tsx
|
|
26
|
+
│ ├── index.css # Design tokens + base styles
|
|
27
|
+
│ ├── components/
|
|
28
|
+
│ │ ├── atoms/
|
|
29
|
+
│ │ ├── molecules/
|
|
30
|
+
│ │ ├── organisms/
|
|
31
|
+
│ │ └── ui/
|
|
32
|
+
│ ├── hooks/
|
|
33
|
+
│ └── lib/
|
|
34
|
+
└── public/
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### File Naming Conventions
|
|
38
|
+
- Components: `PascalCase.tsx` — `Button.tsx`, `HeroSection.tsx`
|
|
39
|
+
- Hooks: `camelCase.ts` — `useIntersectionObserver.ts`
|
|
40
|
+
- Utilities: `camelCase.ts` — `cn.ts`
|
|
41
|
+
- Styles (CSS Modules): `ComponentName.module.css`
|
|
42
|
+
- One component per file, export as default
|
|
43
|
+
- Co-locate tests: `Button.test.tsx` next to `Button.tsx`
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Design System Setup
|
|
48
|
+
|
|
49
|
+
Use the SAME design tokens from the HTML version, injected via CSS custom properties. This ensures visual parity between HTML and React landing pages.
|
|
50
|
+
|
|
51
|
+
### Design Tokens (globals.css / index.css)
|
|
52
|
+
```css
|
|
53
|
+
:root {
|
|
54
|
+
/* === CORE TOKENS (identical to HTML version) === */
|
|
55
|
+
|
|
56
|
+
/* Colors — off-black/off-white, never pure #000/#fff */
|
|
57
|
+
--color-base-900: #0f172a;
|
|
58
|
+
--color-base-800: #1e293b;
|
|
59
|
+
--color-base-700: #334155;
|
|
60
|
+
--color-base-100: #f1f5f9;
|
|
61
|
+
--color-base-50: #f8fafc;
|
|
62
|
+
--color-white: #ffffff;
|
|
63
|
+
|
|
64
|
+
/* Accent — single brand color + hover state */
|
|
65
|
+
--color-accent: oklch(0.65 0.25 15);
|
|
66
|
+
--color-accent-hover: oklch(0.58 0.25 15);
|
|
67
|
+
--color-accent-subtle: oklch(0.65 0.25 15 / 0.1);
|
|
68
|
+
|
|
69
|
+
/* Semantic */
|
|
70
|
+
--color-surface: var(--color-white);
|
|
71
|
+
--color-surface-alt: var(--color-base-50);
|
|
72
|
+
--color-surface-dark: var(--color-base-900);
|
|
73
|
+
--color-on-surface: var(--color-base-800);
|
|
74
|
+
--color-on-surface-muted: var(--color-base-700);
|
|
75
|
+
--color-on-dark: var(--color-base-50);
|
|
76
|
+
--color-success: oklch(0.72 0.19 150);
|
|
77
|
+
|
|
78
|
+
/* === SPACING (4px base unit) === */
|
|
79
|
+
--space-1: 0.25rem;
|
|
80
|
+
--space-2: 0.5rem;
|
|
81
|
+
--space-3: 0.75rem;
|
|
82
|
+
--space-4: 1rem;
|
|
83
|
+
--space-6: 1.5rem;
|
|
84
|
+
--space-8: 2rem;
|
|
85
|
+
--space-12: 3rem;
|
|
86
|
+
--space-16: 4rem;
|
|
87
|
+
--space-20: 5rem;
|
|
88
|
+
--space-24: 6rem;
|
|
89
|
+
--space-32: 8rem;
|
|
90
|
+
|
|
91
|
+
/* Section spacing */
|
|
92
|
+
--section-py: clamp(var(--space-16), 10vw, var(--space-32));
|
|
93
|
+
--section-px: var(--space-6);
|
|
94
|
+
--container-max: 1200px;
|
|
95
|
+
--container-narrow: 720px;
|
|
96
|
+
|
|
97
|
+
/* === TYPOGRAPHY === */
|
|
98
|
+
--font-display: 'Plus Jakarta Sans', 'Inter', system-ui, sans-serif;
|
|
99
|
+
--font-body: 'Inter', system-ui, sans-serif;
|
|
100
|
+
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
|
101
|
+
|
|
102
|
+
/* Fluid type scale — min @ 320px, max @ 1200px */
|
|
103
|
+
--text-display: clamp(2.5rem, 5vw + 1rem, 4.5rem);
|
|
104
|
+
--text-h1: clamp(2rem, 4vw + 0.5rem, 3.5rem);
|
|
105
|
+
--text-h2: clamp(1.5rem, 3vw + 0.25rem, 2.25rem);
|
|
106
|
+
--text-h3: clamp(1.125rem, 2vw + 0.25rem, 1.5rem);
|
|
107
|
+
--text-body: clamp(1rem, 1vw + 0.5rem, 1.125rem);
|
|
108
|
+
--text-small: 0.875rem;
|
|
109
|
+
--text-caption: 0.75rem;
|
|
110
|
+
|
|
111
|
+
--leading-tight: 1.15;
|
|
112
|
+
--leading-normal: 1.6;
|
|
113
|
+
--leading-relaxed: 1.8;
|
|
114
|
+
|
|
115
|
+
--tracking-tight: -0.02em;
|
|
116
|
+
--tracking-normal: 0;
|
|
117
|
+
--tracking-wide: 0.05em;
|
|
118
|
+
--tracking-widest: 0.1em;
|
|
119
|
+
|
|
120
|
+
/* === RADIUS === */
|
|
121
|
+
--radius-sm: 0.375rem;
|
|
122
|
+
--radius-md: 0.75rem;
|
|
123
|
+
--radius-lg: 1rem;
|
|
124
|
+
--radius-xl: 1.5rem;
|
|
125
|
+
--radius-full: 9999px;
|
|
126
|
+
|
|
127
|
+
/* === SHADOWS (multi-layered) === */
|
|
128
|
+
--shadow-sm:
|
|
129
|
+
0 1px 2px oklch(0 0 0 / 0.04),
|
|
130
|
+
0 1px 3px oklch(0 0 0 / 0.06);
|
|
131
|
+
--shadow-md:
|
|
132
|
+
0 2px 4px oklch(0 0 0 / 0.04),
|
|
133
|
+
0 4px 8px oklch(0 0 0 / 0.06),
|
|
134
|
+
0 8px 16px oklch(0 0 0 / 0.04);
|
|
135
|
+
--shadow-lg:
|
|
136
|
+
0 4px 8px oklch(0 0 0 / 0.03),
|
|
137
|
+
0 8px 16px oklch(0 0 0 / 0.06),
|
|
138
|
+
0 16px 32px oklch(0 0 0 / 0.06),
|
|
139
|
+
0 32px 64px oklch(0 0 0 / 0.04);
|
|
140
|
+
--shadow-xl:
|
|
141
|
+
0 8px 16px oklch(0 0 0 / 0.04),
|
|
142
|
+
0 16px 32px oklch(0 0 0 / 0.08),
|
|
143
|
+
0 32px 64px oklch(0 0 0 / 0.08),
|
|
144
|
+
0 64px 128px oklch(0 0 0 / 0.06);
|
|
145
|
+
--shadow-accent:
|
|
146
|
+
0 4px 12px oklch(0.65 0.25 15 / 0.25),
|
|
147
|
+
0 8px 24px oklch(0.65 0.25 15 / 0.15);
|
|
148
|
+
|
|
149
|
+
/* === TRANSITIONS === */
|
|
150
|
+
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
|
151
|
+
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
|
152
|
+
--duration-fast: 150ms;
|
|
153
|
+
--duration-normal: 250ms;
|
|
154
|
+
--duration-slow: 400ms;
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Tailwind CSS Configuration (optional)
|
|
159
|
+
|
|
160
|
+
If using Tailwind, extend the config to reference the same CSS tokens:
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// tailwind.config.ts
|
|
164
|
+
import type { Config } from 'tailwindcss';
|
|
165
|
+
|
|
166
|
+
const config: Config = {
|
|
167
|
+
content: ['./src/**/*.{ts,tsx}', './app/**/*.{ts,tsx}'],
|
|
168
|
+
theme: {
|
|
169
|
+
extend: {
|
|
170
|
+
colors: {
|
|
171
|
+
base: {
|
|
172
|
+
50: 'var(--color-base-50)',
|
|
173
|
+
100: 'var(--color-base-100)',
|
|
174
|
+
700: 'var(--color-base-700)',
|
|
175
|
+
800: 'var(--color-base-800)',
|
|
176
|
+
900: 'var(--color-base-900)',
|
|
177
|
+
},
|
|
178
|
+
accent: {
|
|
179
|
+
DEFAULT: 'var(--color-accent)',
|
|
180
|
+
hover: 'var(--color-accent-hover)',
|
|
181
|
+
subtle: 'var(--color-accent-subtle)',
|
|
182
|
+
},
|
|
183
|
+
surface: {
|
|
184
|
+
DEFAULT: 'var(--color-surface)',
|
|
185
|
+
alt: 'var(--color-surface-alt)',
|
|
186
|
+
dark: 'var(--color-surface-dark)',
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
fontFamily: {
|
|
190
|
+
display: ['var(--font-display)'],
|
|
191
|
+
body: ['var(--font-body)'],
|
|
192
|
+
mono: ['var(--font-mono)'],
|
|
193
|
+
},
|
|
194
|
+
borderRadius: {
|
|
195
|
+
sm: 'var(--radius-sm)',
|
|
196
|
+
md: 'var(--radius-md)',
|
|
197
|
+
lg: 'var(--radius-lg)',
|
|
198
|
+
xl: 'var(--radius-xl)',
|
|
199
|
+
full: 'var(--radius-full)',
|
|
200
|
+
},
|
|
201
|
+
boxShadow: {
|
|
202
|
+
sm: 'var(--shadow-sm)',
|
|
203
|
+
md: 'var(--shadow-md)',
|
|
204
|
+
lg: 'var(--shadow-lg)',
|
|
205
|
+
xl: 'var(--shadow-xl)',
|
|
206
|
+
accent: 'var(--shadow-accent)',
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
plugins: [],
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
export default config;
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### CSS Modules Approach (alternative to Tailwind)
|
|
217
|
+
|
|
218
|
+
```css
|
|
219
|
+
/* Button.module.css */
|
|
220
|
+
.primary {
|
|
221
|
+
background: var(--color-accent);
|
|
222
|
+
color: var(--color-white);
|
|
223
|
+
padding: var(--space-4) var(--space-8);
|
|
224
|
+
border-radius: var(--radius-md);
|
|
225
|
+
font-weight: 700;
|
|
226
|
+
font-size: var(--text-body);
|
|
227
|
+
box-shadow: var(--shadow-accent);
|
|
228
|
+
transition: all var(--duration-normal) var(--ease-out);
|
|
229
|
+
border: none;
|
|
230
|
+
cursor: pointer;
|
|
231
|
+
}
|
|
232
|
+
.primary:hover {
|
|
233
|
+
background: var(--color-accent-hover);
|
|
234
|
+
transform: translateY(-2px);
|
|
235
|
+
box-shadow: var(--shadow-lg);
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Utility: Class Name Merging
|
|
240
|
+
```ts
|
|
241
|
+
// lib/cn.ts
|
|
242
|
+
export function cn(...classes: (string | false | null | undefined)[]): string {
|
|
243
|
+
return classes.filter(Boolean).join(' ');
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
---
|
|
248
|
+
|
|
249
|
+
## Component Patterns
|
|
250
|
+
|
|
251
|
+
### Atoms
|
|
252
|
+
|
|
253
|
+
#### Button
|
|
254
|
+
```tsx
|
|
255
|
+
// components/atoms/Button.tsx
|
|
256
|
+
import { type ButtonHTMLAttributes, forwardRef } from 'react';
|
|
257
|
+
import styles from './Button.module.css';
|
|
258
|
+
import { cn } from '@/lib/cn';
|
|
259
|
+
|
|
260
|
+
type ButtonVariant = 'primary' | 'secondary' | 'ghost';
|
|
261
|
+
type ButtonSize = 'sm' | 'md' | 'lg';
|
|
262
|
+
|
|
263
|
+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
264
|
+
variant?: ButtonVariant;
|
|
265
|
+
size?: ButtonSize;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
269
|
+
({ variant = 'primary', size = 'md', className, children, ...props }, ref) => {
|
|
270
|
+
return (
|
|
271
|
+
<button
|
|
272
|
+
ref={ref}
|
|
273
|
+
className={cn(styles.base, styles[variant], styles[size], className)}
|
|
274
|
+
{...props}
|
|
275
|
+
>
|
|
276
|
+
{children}
|
|
277
|
+
</button>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
Button.displayName = 'Button';
|
|
283
|
+
export default Button;
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
```css
|
|
287
|
+
/* components/atoms/Button.module.css */
|
|
288
|
+
.base {
|
|
289
|
+
display: inline-flex;
|
|
290
|
+
align-items: center;
|
|
291
|
+
justify-content: center;
|
|
292
|
+
gap: var(--space-2);
|
|
293
|
+
font-family: var(--font-body);
|
|
294
|
+
font-weight: 700;
|
|
295
|
+
border: none;
|
|
296
|
+
cursor: pointer;
|
|
297
|
+
transition: all var(--duration-normal) var(--ease-out);
|
|
298
|
+
text-decoration: none;
|
|
299
|
+
line-height: 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.primary {
|
|
303
|
+
background: var(--color-accent);
|
|
304
|
+
color: var(--color-white);
|
|
305
|
+
border-radius: var(--radius-md);
|
|
306
|
+
box-shadow: var(--shadow-accent);
|
|
307
|
+
}
|
|
308
|
+
.primary:hover {
|
|
309
|
+
background: var(--color-accent-hover);
|
|
310
|
+
transform: translateY(-2px);
|
|
311
|
+
box-shadow: var(--shadow-lg);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.secondary {
|
|
315
|
+
background: transparent;
|
|
316
|
+
color: var(--color-on-surface);
|
|
317
|
+
border: 2px solid var(--color-base-100);
|
|
318
|
+
border-radius: var(--radius-md);
|
|
319
|
+
}
|
|
320
|
+
.secondary:hover {
|
|
321
|
+
border-color: var(--color-accent);
|
|
322
|
+
color: var(--color-accent);
|
|
323
|
+
transform: translateY(-2px);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.ghost {
|
|
327
|
+
background: transparent;
|
|
328
|
+
color: var(--color-accent);
|
|
329
|
+
border-radius: var(--radius-md);
|
|
330
|
+
}
|
|
331
|
+
.ghost:hover {
|
|
332
|
+
background: var(--color-accent-subtle);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.sm { padding: var(--space-2) var(--space-4); font-size: var(--text-small); }
|
|
336
|
+
.md { padding: var(--space-4) var(--space-8); font-size: var(--text-body); }
|
|
337
|
+
.lg { padding: var(--space-4) var(--space-12); font-size: var(--text-body); }
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
#### Badge
|
|
341
|
+
```tsx
|
|
342
|
+
// components/atoms/Badge.tsx
|
|
343
|
+
import styles from './Badge.module.css';
|
|
344
|
+
import { cn } from '@/lib/cn';
|
|
345
|
+
|
|
346
|
+
interface BadgeProps {
|
|
347
|
+
children: React.ReactNode;
|
|
348
|
+
className?: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export default function Badge({ children, className }: BadgeProps) {
|
|
352
|
+
return (
|
|
353
|
+
<span className={cn(styles.badge, className)}>
|
|
354
|
+
{children}
|
|
355
|
+
</span>
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
```css
|
|
361
|
+
/* components/atoms/Badge.module.css */
|
|
362
|
+
.badge {
|
|
363
|
+
display: inline-flex;
|
|
364
|
+
align-items: center;
|
|
365
|
+
gap: var(--space-2);
|
|
366
|
+
padding: var(--space-1) var(--space-3);
|
|
367
|
+
border-radius: var(--radius-full);
|
|
368
|
+
font-size: var(--text-caption);
|
|
369
|
+
font-weight: 600;
|
|
370
|
+
letter-spacing: var(--tracking-wide);
|
|
371
|
+
text-transform: uppercase;
|
|
372
|
+
background: var(--color-accent-subtle);
|
|
373
|
+
color: var(--color-accent);
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
#### Heading
|
|
378
|
+
```tsx
|
|
379
|
+
// components/atoms/Heading.tsx
|
|
380
|
+
import styles from './Heading.module.css';
|
|
381
|
+
import { cn } from '@/lib/cn';
|
|
382
|
+
|
|
383
|
+
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
384
|
+
|
|
385
|
+
interface HeadingProps {
|
|
386
|
+
level: HeadingLevel;
|
|
387
|
+
as?: HeadingLevel;
|
|
388
|
+
children: React.ReactNode;
|
|
389
|
+
className?: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export default function Heading({ level, as, children, className }: HeadingProps) {
|
|
393
|
+
const Tag = `h${as ?? level}` as keyof JSX.IntrinsicElements;
|
|
394
|
+
return <Tag className={cn(styles[`h${level}`], className)}>{children}</Tag>;
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
```css
|
|
399
|
+
/* components/atoms/Heading.module.css */
|
|
400
|
+
.h1 {
|
|
401
|
+
font-family: var(--font-display);
|
|
402
|
+
font-size: var(--text-h1);
|
|
403
|
+
font-weight: 800;
|
|
404
|
+
line-height: var(--leading-tight);
|
|
405
|
+
letter-spacing: var(--tracking-tight);
|
|
406
|
+
color: var(--color-on-surface);
|
|
407
|
+
}
|
|
408
|
+
.h2 {
|
|
409
|
+
font-family: var(--font-display);
|
|
410
|
+
font-size: var(--text-h2);
|
|
411
|
+
font-weight: 700;
|
|
412
|
+
line-height: var(--leading-tight);
|
|
413
|
+
letter-spacing: var(--tracking-tight);
|
|
414
|
+
color: var(--color-on-surface);
|
|
415
|
+
}
|
|
416
|
+
.h3 {
|
|
417
|
+
font-family: var(--font-display);
|
|
418
|
+
font-size: var(--text-h3);
|
|
419
|
+
font-weight: 700;
|
|
420
|
+
line-height: var(--leading-tight);
|
|
421
|
+
color: var(--color-on-surface);
|
|
422
|
+
}
|
|
423
|
+
.h4, .h5, .h6 {
|
|
424
|
+
font-family: var(--font-display);
|
|
425
|
+
font-size: var(--text-body);
|
|
426
|
+
font-weight: 600;
|
|
427
|
+
line-height: var(--leading-tight);
|
|
428
|
+
color: var(--color-on-surface);
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
#### Text
|
|
433
|
+
```tsx
|
|
434
|
+
// components/atoms/Text.tsx
|
|
435
|
+
import styles from './Text.module.css';
|
|
436
|
+
import { cn } from '@/lib/cn';
|
|
437
|
+
|
|
438
|
+
type TextVariant = 'body' | 'caption' | 'label';
|
|
439
|
+
|
|
440
|
+
interface TextProps {
|
|
441
|
+
variant?: TextVariant;
|
|
442
|
+
muted?: boolean;
|
|
443
|
+
children: React.ReactNode;
|
|
444
|
+
className?: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export default function Text({ variant = 'body', muted = false, children, className }: TextProps) {
|
|
448
|
+
return (
|
|
449
|
+
<p className={cn(styles[variant], muted && styles.muted, className)}>
|
|
450
|
+
{children}
|
|
451
|
+
</p>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
```css
|
|
457
|
+
/* components/atoms/Text.module.css */
|
|
458
|
+
.body {
|
|
459
|
+
font-family: var(--font-body);
|
|
460
|
+
font-size: var(--text-body);
|
|
461
|
+
line-height: var(--leading-normal);
|
|
462
|
+
color: var(--color-on-surface);
|
|
463
|
+
}
|
|
464
|
+
.caption {
|
|
465
|
+
font-family: var(--font-body);
|
|
466
|
+
font-size: var(--text-caption);
|
|
467
|
+
line-height: var(--leading-normal);
|
|
468
|
+
color: var(--color-on-surface-muted);
|
|
469
|
+
}
|
|
470
|
+
.label {
|
|
471
|
+
font-family: var(--font-body);
|
|
472
|
+
font-size: var(--text-caption);
|
|
473
|
+
font-weight: 600;
|
|
474
|
+
letter-spacing: var(--tracking-widest);
|
|
475
|
+
text-transform: uppercase;
|
|
476
|
+
color: var(--color-on-surface-muted);
|
|
477
|
+
}
|
|
478
|
+
.muted { color: var(--color-on-surface-muted); }
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
#### Input
|
|
482
|
+
```tsx
|
|
483
|
+
// components/atoms/Input.tsx
|
|
484
|
+
import { type InputHTMLAttributes, forwardRef } from 'react';
|
|
485
|
+
import styles from './Input.module.css';
|
|
486
|
+
import { cn } from '@/lib/cn';
|
|
487
|
+
|
|
488
|
+
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
489
|
+
label?: string;
|
|
490
|
+
error?: string;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
494
|
+
({ label, error, className, id, ...props }, ref) => {
|
|
495
|
+
const inputId = id ?? label?.toLowerCase().replace(/\s+/g, '-');
|
|
496
|
+
return (
|
|
497
|
+
<div className={styles.wrapper}>
|
|
498
|
+
{label && <label htmlFor={inputId} className={styles.label}>{label}</label>}
|
|
499
|
+
<input
|
|
500
|
+
ref={ref}
|
|
501
|
+
id={inputId}
|
|
502
|
+
className={cn(styles.input, error && styles.error, className)}
|
|
503
|
+
aria-invalid={error ? 'true' : undefined}
|
|
504
|
+
aria-describedby={error ? `${inputId}-error` : undefined}
|
|
505
|
+
{...props}
|
|
506
|
+
/>
|
|
507
|
+
{error && <span id={`${inputId}-error`} className={styles.errorText} role="alert">{error}</span>}
|
|
508
|
+
</div>
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
Input.displayName = 'Input';
|
|
514
|
+
export default Input;
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
```css
|
|
518
|
+
/* components/atoms/Input.module.css */
|
|
519
|
+
.wrapper { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
520
|
+
.label {
|
|
521
|
+
font-size: var(--text-small);
|
|
522
|
+
font-weight: 600;
|
|
523
|
+
color: var(--color-on-surface);
|
|
524
|
+
}
|
|
525
|
+
.input {
|
|
526
|
+
padding: var(--space-3) var(--space-4);
|
|
527
|
+
border: 2px solid var(--color-base-100);
|
|
528
|
+
border-radius: var(--radius-sm);
|
|
529
|
+
font-size: var(--text-body);
|
|
530
|
+
font-family: var(--font-body);
|
|
531
|
+
background: var(--color-surface);
|
|
532
|
+
color: var(--color-on-surface);
|
|
533
|
+
transition: border-color var(--duration-fast) var(--ease-out);
|
|
534
|
+
outline: none;
|
|
535
|
+
}
|
|
536
|
+
.input:focus {
|
|
537
|
+
border-color: var(--color-accent);
|
|
538
|
+
box-shadow: 0 0 0 3px var(--color-accent-subtle);
|
|
539
|
+
}
|
|
540
|
+
.error { border-color: oklch(0.55 0.25 25); }
|
|
541
|
+
.errorText { font-size: var(--text-small); color: oklch(0.55 0.25 25); }
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
#### LazyImage
|
|
545
|
+
```tsx
|
|
546
|
+
// components/atoms/LazyImage.tsx
|
|
547
|
+
import { useState, useRef, useEffect } from 'react';
|
|
548
|
+
import styles from './LazyImage.module.css';
|
|
549
|
+
import { cn } from '@/lib/cn';
|
|
550
|
+
|
|
551
|
+
interface LazyImageProps {
|
|
552
|
+
src: string;
|
|
553
|
+
alt: string;
|
|
554
|
+
width: number;
|
|
555
|
+
height: number;
|
|
556
|
+
className?: string;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
export default function LazyImage({ src, alt, width, height, className }: LazyImageProps) {
|
|
560
|
+
const [loaded, setLoaded] = useState(false);
|
|
561
|
+
const imgRef = useRef<HTMLImageElement>(null);
|
|
562
|
+
|
|
563
|
+
useEffect(() => {
|
|
564
|
+
const img = imgRef.current;
|
|
565
|
+
if (!img) return;
|
|
566
|
+
|
|
567
|
+
const observer = new IntersectionObserver(
|
|
568
|
+
([entry]) => {
|
|
569
|
+
if (entry.isIntersecting) {
|
|
570
|
+
img.src = img.dataset.src!;
|
|
571
|
+
observer.disconnect();
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
{ rootMargin: '200px' }
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
observer.observe(img);
|
|
578
|
+
return () => observer.disconnect();
|
|
579
|
+
}, []);
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<img
|
|
583
|
+
ref={imgRef}
|
|
584
|
+
data-src={src}
|
|
585
|
+
alt={alt}
|
|
586
|
+
width={width}
|
|
587
|
+
height={height}
|
|
588
|
+
onLoad={() => setLoaded(true)}
|
|
589
|
+
className={cn(styles.image, loaded && styles.loaded, className)}
|
|
590
|
+
style={{ aspectRatio: `${width} / ${height}` }}
|
|
591
|
+
/>
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
```css
|
|
597
|
+
/* components/atoms/LazyImage.module.css */
|
|
598
|
+
.image {
|
|
599
|
+
display: block;
|
|
600
|
+
max-width: 100%;
|
|
601
|
+
height: auto;
|
|
602
|
+
opacity: 0;
|
|
603
|
+
transition: opacity var(--duration-slow) var(--ease-out);
|
|
604
|
+
object-fit: cover;
|
|
605
|
+
}
|
|
606
|
+
.loaded { opacity: 1; }
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
> **Next.js note:** When using Next.js, prefer the built-in `next/image` component over `LazyImage`. It handles lazy loading, responsive sizing, and format optimization automatically.
|
|
610
|
+
|
|
611
|
+
---
|
|
612
|
+
|
|
613
|
+
### Molecules
|
|
614
|
+
|
|
615
|
+
#### FeatureCard
|
|
616
|
+
```tsx
|
|
617
|
+
// components/molecules/FeatureCard.tsx
|
|
618
|
+
import Heading from '@/components/atoms/Heading';
|
|
619
|
+
import Text from '@/components/atoms/Text';
|
|
620
|
+
import styles from './FeatureCard.module.css';
|
|
621
|
+
import { cn } from '@/lib/cn';
|
|
622
|
+
|
|
623
|
+
interface FeatureCardProps {
|
|
624
|
+
icon: React.ReactNode;
|
|
625
|
+
title: string;
|
|
626
|
+
description: string;
|
|
627
|
+
className?: string;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export default function FeatureCard({ icon, title, description, className }: FeatureCardProps) {
|
|
631
|
+
return (
|
|
632
|
+
<article className={cn(styles.card, className)}>
|
|
633
|
+
<div className={styles.icon}>{icon}</div>
|
|
634
|
+
<Heading level={3}>{title}</Heading>
|
|
635
|
+
<Text muted>{description}</Text>
|
|
636
|
+
</article>
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
```css
|
|
642
|
+
/* components/molecules/FeatureCard.module.css */
|
|
643
|
+
.card {
|
|
644
|
+
background: var(--color-surface);
|
|
645
|
+
border-radius: var(--radius-lg);
|
|
646
|
+
padding: var(--space-8);
|
|
647
|
+
box-shadow: var(--shadow-md);
|
|
648
|
+
border: 1px solid oklch(0 0 0 / 0.05);
|
|
649
|
+
transition: all var(--duration-normal) var(--ease-out);
|
|
650
|
+
display: flex;
|
|
651
|
+
flex-direction: column;
|
|
652
|
+
gap: var(--space-4);
|
|
653
|
+
}
|
|
654
|
+
.card:hover {
|
|
655
|
+
transform: translateY(-4px);
|
|
656
|
+
box-shadow: var(--shadow-lg);
|
|
657
|
+
}
|
|
658
|
+
.icon {
|
|
659
|
+
width: 48px;
|
|
660
|
+
height: 48px;
|
|
661
|
+
display: flex;
|
|
662
|
+
align-items: center;
|
|
663
|
+
justify-content: center;
|
|
664
|
+
border-radius: var(--radius-md);
|
|
665
|
+
background: var(--color-accent-subtle);
|
|
666
|
+
color: var(--color-accent);
|
|
667
|
+
font-size: 1.5rem;
|
|
668
|
+
}
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
#### TestimonialCard
|
|
672
|
+
```tsx
|
|
673
|
+
// components/molecules/TestimonialCard.tsx
|
|
674
|
+
import Text from '@/components/atoms/Text';
|
|
675
|
+
import styles from './TestimonialCard.module.css';
|
|
676
|
+
|
|
677
|
+
interface TestimonialCardProps {
|
|
678
|
+
quote: string;
|
|
679
|
+
author: string;
|
|
680
|
+
role: string;
|
|
681
|
+
avatarUrl?: string;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export default function TestimonialCard({ quote, author, role, avatarUrl }: TestimonialCardProps) {
|
|
685
|
+
return (
|
|
686
|
+
<blockquote className={styles.card}>
|
|
687
|
+
<Text className={styles.quote}>“{quote}”</Text>
|
|
688
|
+
<footer className={styles.footer}>
|
|
689
|
+
{avatarUrl && (
|
|
690
|
+
<img src={avatarUrl} alt={author} className={styles.avatar} width={40} height={40} />
|
|
691
|
+
)}
|
|
692
|
+
<div>
|
|
693
|
+
<strong className={styles.author}>{author}</strong>
|
|
694
|
+
<Text variant="caption">{role}</Text>
|
|
695
|
+
</div>
|
|
696
|
+
</footer>
|
|
697
|
+
</blockquote>
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
```css
|
|
703
|
+
/* components/molecules/TestimonialCard.module.css */
|
|
704
|
+
.card {
|
|
705
|
+
background: var(--color-surface);
|
|
706
|
+
border-radius: var(--radius-lg);
|
|
707
|
+
padding: var(--space-8);
|
|
708
|
+
box-shadow: var(--shadow-md);
|
|
709
|
+
border: 1px solid oklch(0 0 0 / 0.05);
|
|
710
|
+
display: flex;
|
|
711
|
+
flex-direction: column;
|
|
712
|
+
gap: var(--space-6);
|
|
713
|
+
margin: 0;
|
|
714
|
+
}
|
|
715
|
+
.quote {
|
|
716
|
+
font-size: var(--text-body);
|
|
717
|
+
line-height: var(--leading-relaxed);
|
|
718
|
+
font-style: italic;
|
|
719
|
+
}
|
|
720
|
+
.footer { display: flex; align-items: center; gap: var(--space-3); }
|
|
721
|
+
.avatar {
|
|
722
|
+
width: 40px;
|
|
723
|
+
height: 40px;
|
|
724
|
+
border-radius: var(--radius-full);
|
|
725
|
+
object-fit: cover;
|
|
726
|
+
}
|
|
727
|
+
.author {
|
|
728
|
+
font-size: var(--text-small);
|
|
729
|
+
font-weight: 700;
|
|
730
|
+
color: var(--color-on-surface);
|
|
731
|
+
display: block;
|
|
732
|
+
}
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
#### PricingCard
|
|
736
|
+
```tsx
|
|
737
|
+
// components/molecules/PricingCard.tsx
|
|
738
|
+
import Heading from '@/components/atoms/Heading';
|
|
739
|
+
import Text from '@/components/atoms/Text';
|
|
740
|
+
import Badge from '@/components/atoms/Badge';
|
|
741
|
+
import Button from '@/components/atoms/Button';
|
|
742
|
+
import styles from './PricingCard.module.css';
|
|
743
|
+
import { cn } from '@/lib/cn';
|
|
744
|
+
|
|
745
|
+
interface PricingCardProps {
|
|
746
|
+
name: string;
|
|
747
|
+
price: string;
|
|
748
|
+
period?: string;
|
|
749
|
+
description: string;
|
|
750
|
+
features: string[];
|
|
751
|
+
cta: string;
|
|
752
|
+
highlighted?: boolean;
|
|
753
|
+
badge?: string;
|
|
754
|
+
onCtaClick?: () => void;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
export default function PricingCard({
|
|
758
|
+
name, price, period = '/mo', description, features, cta,
|
|
759
|
+
highlighted = false, badge, onCtaClick,
|
|
760
|
+
}: PricingCardProps) {
|
|
761
|
+
return (
|
|
762
|
+
<article className={cn(styles.card, highlighted && styles.highlighted)}>
|
|
763
|
+
{badge && <Badge className={styles.badge}>{badge}</Badge>}
|
|
764
|
+
<Heading level={3}>{name}</Heading>
|
|
765
|
+
<div className={styles.price}>
|
|
766
|
+
<span className={styles.amount}>{price}</span>
|
|
767
|
+
{period && <span className={styles.period}>{period}</span>}
|
|
768
|
+
</div>
|
|
769
|
+
<Text muted>{description}</Text>
|
|
770
|
+
<ul className={styles.features}>
|
|
771
|
+
{features.map((feature) => (
|
|
772
|
+
<li key={feature} className={styles.feature}>
|
|
773
|
+
<span className={styles.check} aria-hidden="true">✓</span>
|
|
774
|
+
{feature}
|
|
775
|
+
</li>
|
|
776
|
+
))}
|
|
777
|
+
</ul>
|
|
778
|
+
<Button
|
|
779
|
+
variant={highlighted ? 'primary' : 'secondary'}
|
|
780
|
+
onClick={onCtaClick}
|
|
781
|
+
className={styles.cta}
|
|
782
|
+
>
|
|
783
|
+
{cta}
|
|
784
|
+
</Button>
|
|
785
|
+
</article>
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
```css
|
|
791
|
+
/* components/molecules/PricingCard.module.css */
|
|
792
|
+
.card {
|
|
793
|
+
background: var(--color-surface);
|
|
794
|
+
border-radius: var(--radius-lg);
|
|
795
|
+
padding: var(--space-8);
|
|
796
|
+
box-shadow: var(--shadow-md);
|
|
797
|
+
border: 1px solid oklch(0 0 0 / 0.05);
|
|
798
|
+
display: flex;
|
|
799
|
+
flex-direction: column;
|
|
800
|
+
gap: var(--space-4);
|
|
801
|
+
position: relative;
|
|
802
|
+
transition: all var(--duration-normal) var(--ease-out);
|
|
803
|
+
}
|
|
804
|
+
.highlighted {
|
|
805
|
+
border-color: var(--color-accent);
|
|
806
|
+
box-shadow: var(--shadow-lg);
|
|
807
|
+
transform: scale(1.03);
|
|
808
|
+
}
|
|
809
|
+
.badge { position: absolute; top: calc(-1 * var(--space-3)); right: var(--space-6); }
|
|
810
|
+
.price { display: flex; align-items: baseline; gap: var(--space-1); }
|
|
811
|
+
.amount {
|
|
812
|
+
font-family: var(--font-display);
|
|
813
|
+
font-size: var(--text-h1);
|
|
814
|
+
font-weight: 800;
|
|
815
|
+
color: var(--color-on-surface);
|
|
816
|
+
}
|
|
817
|
+
.period { font-size: var(--text-small); color: var(--color-on-surface-muted); }
|
|
818
|
+
.features {
|
|
819
|
+
list-style: none;
|
|
820
|
+
padding: 0;
|
|
821
|
+
margin: var(--space-4) 0;
|
|
822
|
+
display: flex;
|
|
823
|
+
flex-direction: column;
|
|
824
|
+
gap: var(--space-3);
|
|
825
|
+
flex: 1;
|
|
826
|
+
}
|
|
827
|
+
.feature {
|
|
828
|
+
display: flex;
|
|
829
|
+
align-items: center;
|
|
830
|
+
gap: var(--space-2);
|
|
831
|
+
font-size: var(--text-body);
|
|
832
|
+
color: var(--color-on-surface);
|
|
833
|
+
}
|
|
834
|
+
.check { color: var(--color-success); font-weight: 700; }
|
|
835
|
+
.cta { width: 100%; margin-top: auto; }
|
|
836
|
+
```
|
|
837
|
+
|
|
838
|
+
#### StatCounter (with animated number)
|
|
839
|
+
```tsx
|
|
840
|
+
// components/molecules/StatCounter.tsx
|
|
841
|
+
import { useCountUp } from '@/hooks/useCountUp';
|
|
842
|
+
import Text from '@/components/atoms/Text';
|
|
843
|
+
import styles from './StatCounter.module.css';
|
|
844
|
+
|
|
845
|
+
interface StatCounterProps {
|
|
846
|
+
value: number;
|
|
847
|
+
suffix?: string;
|
|
848
|
+
prefix?: string;
|
|
849
|
+
label: string;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export default function StatCounter({ value, suffix = '', prefix = '', label }: StatCounterProps) {
|
|
853
|
+
const { ref, count } = useCountUp(value);
|
|
854
|
+
|
|
855
|
+
return (
|
|
856
|
+
<div ref={ref} className={styles.stat}>
|
|
857
|
+
<span className={styles.value}>
|
|
858
|
+
{prefix}{count.toLocaleString()}{suffix}
|
|
859
|
+
</span>
|
|
860
|
+
<Text variant="caption" muted>{label}</Text>
|
|
861
|
+
</div>
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
```
|
|
865
|
+
|
|
866
|
+
```css
|
|
867
|
+
/* components/molecules/StatCounter.module.css */
|
|
868
|
+
.stat {
|
|
869
|
+
display: flex;
|
|
870
|
+
flex-direction: column;
|
|
871
|
+
align-items: center;
|
|
872
|
+
gap: var(--space-2);
|
|
873
|
+
text-align: center;
|
|
874
|
+
}
|
|
875
|
+
.value {
|
|
876
|
+
font-family: var(--font-display);
|
|
877
|
+
font-size: var(--text-h1);
|
|
878
|
+
font-weight: 800;
|
|
879
|
+
color: var(--color-accent);
|
|
880
|
+
line-height: 1;
|
|
881
|
+
}
|
|
882
|
+
```
|
|
883
|
+
|
|
884
|
+
#### CTAGroup
|
|
885
|
+
```tsx
|
|
886
|
+
// components/molecules/CTAGroup.tsx
|
|
887
|
+
import Button from '@/components/atoms/Button';
|
|
888
|
+
import styles from './CTAGroup.module.css';
|
|
889
|
+
import { cn } from '@/lib/cn';
|
|
890
|
+
|
|
891
|
+
interface CTAGroupProps {
|
|
892
|
+
primaryLabel: string;
|
|
893
|
+
primaryHref?: string;
|
|
894
|
+
secondaryLabel?: string;
|
|
895
|
+
secondaryHref?: string;
|
|
896
|
+
onPrimaryClick?: () => void;
|
|
897
|
+
onSecondaryClick?: () => void;
|
|
898
|
+
className?: string;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
export default function CTAGroup({
|
|
902
|
+
primaryLabel, primaryHref, secondaryLabel, secondaryHref,
|
|
903
|
+
onPrimaryClick, onSecondaryClick, className,
|
|
904
|
+
}: CTAGroupProps) {
|
|
905
|
+
return (
|
|
906
|
+
<div className={cn(styles.group, className)}>
|
|
907
|
+
{primaryHref ? (
|
|
908
|
+
<a href={primaryHref}><Button variant="primary" size="lg">{primaryLabel}</Button></a>
|
|
909
|
+
) : (
|
|
910
|
+
<Button variant="primary" size="lg" onClick={onPrimaryClick}>{primaryLabel}</Button>
|
|
911
|
+
)}
|
|
912
|
+
{secondaryLabel && (
|
|
913
|
+
secondaryHref ? (
|
|
914
|
+
<a href={secondaryHref}><Button variant="secondary" size="lg">{secondaryLabel}</Button></a>
|
|
915
|
+
) : (
|
|
916
|
+
<Button variant="secondary" size="lg" onClick={onSecondaryClick}>{secondaryLabel}</Button>
|
|
917
|
+
)
|
|
918
|
+
)}
|
|
919
|
+
</div>
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
```css
|
|
925
|
+
/* components/molecules/CTAGroup.module.css */
|
|
926
|
+
.group {
|
|
927
|
+
display: flex;
|
|
928
|
+
align-items: center;
|
|
929
|
+
gap: var(--space-4);
|
|
930
|
+
flex-wrap: wrap;
|
|
931
|
+
}
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
---
|
|
935
|
+
|
|
936
|
+
### Organisms
|
|
937
|
+
|
|
938
|
+
#### Layout Primitives
|
|
939
|
+
|
|
940
|
+
```tsx
|
|
941
|
+
// components/ui/Container.tsx
|
|
942
|
+
import styles from './Container.module.css';
|
|
943
|
+
import { cn } from '@/lib/cn';
|
|
944
|
+
|
|
945
|
+
interface ContainerProps {
|
|
946
|
+
narrow?: boolean;
|
|
947
|
+
children: React.ReactNode;
|
|
948
|
+
className?: string;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
export default function Container({ narrow = false, children, className }: ContainerProps) {
|
|
952
|
+
return (
|
|
953
|
+
<div className={cn(styles.container, narrow && styles.narrow, className)}>
|
|
954
|
+
{children}
|
|
955
|
+
</div>
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
```css
|
|
961
|
+
/* components/ui/Container.module.css */
|
|
962
|
+
.container {
|
|
963
|
+
max-width: var(--container-max);
|
|
964
|
+
margin: 0 auto;
|
|
965
|
+
padding: 0 var(--section-px);
|
|
966
|
+
}
|
|
967
|
+
.narrow { max-width: var(--container-narrow); }
|
|
968
|
+
```
|
|
969
|
+
|
|
970
|
+
```tsx
|
|
971
|
+
// components/ui/Section.tsx
|
|
972
|
+
import styles from './Section.module.css';
|
|
973
|
+
import { cn } from '@/lib/cn';
|
|
974
|
+
|
|
975
|
+
type SectionBackground = 'default' | 'alt' | 'dark';
|
|
976
|
+
|
|
977
|
+
interface SectionProps {
|
|
978
|
+
bg?: SectionBackground;
|
|
979
|
+
id?: string;
|
|
980
|
+
children: React.ReactNode;
|
|
981
|
+
className?: string;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export default function Section({ bg = 'default', id, children, className }: SectionProps) {
|
|
985
|
+
return (
|
|
986
|
+
<section id={id} className={cn(styles.section, styles[bg], className)}>
|
|
987
|
+
{children}
|
|
988
|
+
</section>
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
```css
|
|
994
|
+
/* components/ui/Section.module.css */
|
|
995
|
+
.section {
|
|
996
|
+
padding: var(--section-py) 0;
|
|
997
|
+
}
|
|
998
|
+
.default { background: var(--color-surface); color: var(--color-on-surface); }
|
|
999
|
+
.alt { background: var(--color-surface-alt); color: var(--color-on-surface); }
|
|
1000
|
+
.dark { background: var(--color-surface-dark); color: var(--color-on-dark); }
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
#### Navbar
|
|
1004
|
+
```tsx
|
|
1005
|
+
// components/organisms/Navbar.tsx
|
|
1006
|
+
import { useState } from 'react';
|
|
1007
|
+
import Button from '@/components/atoms/Button';
|
|
1008
|
+
import Container from '@/components/ui/Container';
|
|
1009
|
+
import styles from './Navbar.module.css';
|
|
1010
|
+
import { cn } from '@/lib/cn';
|
|
1011
|
+
|
|
1012
|
+
interface NavLink {
|
|
1013
|
+
label: string;
|
|
1014
|
+
href: string;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
interface NavbarProps {
|
|
1018
|
+
logo: React.ReactNode;
|
|
1019
|
+
links: NavLink[];
|
|
1020
|
+
ctaLabel?: string;
|
|
1021
|
+
ctaHref?: string;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
export default function Navbar({ logo, links, ctaLabel, ctaHref }: NavbarProps) {
|
|
1025
|
+
const [open, setOpen] = useState(false);
|
|
1026
|
+
|
|
1027
|
+
return (
|
|
1028
|
+
<header className={styles.header}>
|
|
1029
|
+
<Container>
|
|
1030
|
+
<nav className={styles.nav} aria-label="Main navigation">
|
|
1031
|
+
<div className={styles.logo}>{logo}</div>
|
|
1032
|
+
|
|
1033
|
+
<button
|
|
1034
|
+
className={styles.toggle}
|
|
1035
|
+
onClick={() => setOpen(!open)}
|
|
1036
|
+
aria-expanded={open}
|
|
1037
|
+
aria-label="Toggle menu"
|
|
1038
|
+
>
|
|
1039
|
+
<span className={cn(styles.bar, open && styles.open)} />
|
|
1040
|
+
</button>
|
|
1041
|
+
|
|
1042
|
+
<div className={cn(styles.menu, open && styles.menuOpen)}>
|
|
1043
|
+
<ul className={styles.links}>
|
|
1044
|
+
{links.map(({ label, href }) => (
|
|
1045
|
+
<li key={href}>
|
|
1046
|
+
<a href={href} className={styles.link}>{label}</a>
|
|
1047
|
+
</li>
|
|
1048
|
+
))}
|
|
1049
|
+
</ul>
|
|
1050
|
+
{ctaLabel && ctaHref && (
|
|
1051
|
+
<a href={ctaHref}>
|
|
1052
|
+
<Button variant="primary" size="sm">{ctaLabel}</Button>
|
|
1053
|
+
</a>
|
|
1054
|
+
)}
|
|
1055
|
+
</div>
|
|
1056
|
+
</nav>
|
|
1057
|
+
</Container>
|
|
1058
|
+
</header>
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
```css
|
|
1064
|
+
/* components/organisms/Navbar.module.css */
|
|
1065
|
+
.header {
|
|
1066
|
+
position: sticky;
|
|
1067
|
+
top: 0;
|
|
1068
|
+
z-index: 100;
|
|
1069
|
+
background: oklch(1 0 0 / 0.85);
|
|
1070
|
+
backdrop-filter: blur(12px);
|
|
1071
|
+
border-bottom: 1px solid oklch(0 0 0 / 0.05);
|
|
1072
|
+
}
|
|
1073
|
+
.nav {
|
|
1074
|
+
display: flex;
|
|
1075
|
+
align-items: center;
|
|
1076
|
+
justify-content: space-between;
|
|
1077
|
+
height: 64px;
|
|
1078
|
+
}
|
|
1079
|
+
.logo { font-weight: 800; font-size: var(--text-h3); }
|
|
1080
|
+
.menu { display: flex; align-items: center; gap: var(--space-8); }
|
|
1081
|
+
.links {
|
|
1082
|
+
display: flex;
|
|
1083
|
+
list-style: none;
|
|
1084
|
+
gap: var(--space-6);
|
|
1085
|
+
margin: 0;
|
|
1086
|
+
padding: 0;
|
|
1087
|
+
}
|
|
1088
|
+
.link {
|
|
1089
|
+
font-size: var(--text-small);
|
|
1090
|
+
font-weight: 500;
|
|
1091
|
+
color: var(--color-on-surface-muted);
|
|
1092
|
+
text-decoration: none;
|
|
1093
|
+
transition: color var(--duration-fast) var(--ease-out);
|
|
1094
|
+
}
|
|
1095
|
+
.link:hover { color: var(--color-accent); }
|
|
1096
|
+
.toggle {
|
|
1097
|
+
display: none;
|
|
1098
|
+
background: none;
|
|
1099
|
+
border: none;
|
|
1100
|
+
cursor: pointer;
|
|
1101
|
+
padding: var(--space-2);
|
|
1102
|
+
}
|
|
1103
|
+
.bar {
|
|
1104
|
+
display: block;
|
|
1105
|
+
width: 24px;
|
|
1106
|
+
height: 2px;
|
|
1107
|
+
background: var(--color-on-surface);
|
|
1108
|
+
position: relative;
|
|
1109
|
+
transition: background var(--duration-fast);
|
|
1110
|
+
}
|
|
1111
|
+
.bar::before, .bar::after {
|
|
1112
|
+
content: '';
|
|
1113
|
+
display: block;
|
|
1114
|
+
width: 24px;
|
|
1115
|
+
height: 2px;
|
|
1116
|
+
background: var(--color-on-surface);
|
|
1117
|
+
position: absolute;
|
|
1118
|
+
transition: transform var(--duration-normal) var(--ease-out);
|
|
1119
|
+
}
|
|
1120
|
+
.bar::before { top: -7px; }
|
|
1121
|
+
.bar::after { top: 7px; }
|
|
1122
|
+
.open { background: transparent; }
|
|
1123
|
+
.open::before { transform: rotate(45deg) translate(5px, 5px); }
|
|
1124
|
+
.open::after { transform: rotate(-45deg) translate(5px, -5px); }
|
|
1125
|
+
|
|
1126
|
+
@media (max-width: 768px) {
|
|
1127
|
+
.toggle { display: block; }
|
|
1128
|
+
.menu {
|
|
1129
|
+
display: none;
|
|
1130
|
+
position: absolute;
|
|
1131
|
+
top: 64px;
|
|
1132
|
+
left: 0;
|
|
1133
|
+
right: 0;
|
|
1134
|
+
flex-direction: column;
|
|
1135
|
+
background: var(--color-surface);
|
|
1136
|
+
padding: var(--space-6);
|
|
1137
|
+
border-bottom: 1px solid oklch(0 0 0 / 0.05);
|
|
1138
|
+
box-shadow: var(--shadow-lg);
|
|
1139
|
+
}
|
|
1140
|
+
.menuOpen { display: flex; }
|
|
1141
|
+
.links { flex-direction: column; align-items: center; }
|
|
1142
|
+
}
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
#### HeroSection
|
|
1146
|
+
```tsx
|
|
1147
|
+
// components/organisms/HeroSection.tsx
|
|
1148
|
+
import Section from '@/components/ui/Section';
|
|
1149
|
+
import Container from '@/components/ui/Container';
|
|
1150
|
+
import Badge from '@/components/atoms/Badge';
|
|
1151
|
+
import Heading from '@/components/atoms/Heading';
|
|
1152
|
+
import Text from '@/components/atoms/Text';
|
|
1153
|
+
import CTAGroup from '@/components/molecules/CTAGroup';
|
|
1154
|
+
import styles from './HeroSection.module.css';
|
|
1155
|
+
|
|
1156
|
+
interface HeroSectionProps {
|
|
1157
|
+
badge?: string;
|
|
1158
|
+
title: string;
|
|
1159
|
+
subtitle: string;
|
|
1160
|
+
primaryCta: string;
|
|
1161
|
+
primaryHref: string;
|
|
1162
|
+
secondaryCta?: string;
|
|
1163
|
+
secondaryHref?: string;
|
|
1164
|
+
image?: React.ReactNode;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
export default function HeroSection({
|
|
1168
|
+
badge, title, subtitle, primaryCta, primaryHref,
|
|
1169
|
+
secondaryCta, secondaryHref, image,
|
|
1170
|
+
}: HeroSectionProps) {
|
|
1171
|
+
return (
|
|
1172
|
+
<Section bg="dark" className={styles.hero}>
|
|
1173
|
+
<Container>
|
|
1174
|
+
<div className={styles.grid}>
|
|
1175
|
+
<div className={styles.content}>
|
|
1176
|
+
{badge && <Badge>{badge}</Badge>}
|
|
1177
|
+
<h1 className={styles.title}>{title}</h1>
|
|
1178
|
+
<Text className={styles.subtitle}>{subtitle}</Text>
|
|
1179
|
+
<CTAGroup
|
|
1180
|
+
primaryLabel={primaryCta}
|
|
1181
|
+
primaryHref={primaryHref}
|
|
1182
|
+
secondaryLabel={secondaryCta}
|
|
1183
|
+
secondaryHref={secondaryHref}
|
|
1184
|
+
/>
|
|
1185
|
+
</div>
|
|
1186
|
+
{image && <div className={styles.visual}>{image}</div>}
|
|
1187
|
+
</div>
|
|
1188
|
+
</Container>
|
|
1189
|
+
</Section>
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
```css
|
|
1195
|
+
/* components/organisms/HeroSection.module.css */
|
|
1196
|
+
.hero { padding-top: var(--space-24); padding-bottom: var(--space-24); }
|
|
1197
|
+
.grid {
|
|
1198
|
+
display: grid;
|
|
1199
|
+
grid-template-columns: 1fr;
|
|
1200
|
+
gap: var(--space-12);
|
|
1201
|
+
align-items: center;
|
|
1202
|
+
}
|
|
1203
|
+
.content {
|
|
1204
|
+
display: flex;
|
|
1205
|
+
flex-direction: column;
|
|
1206
|
+
gap: var(--space-6);
|
|
1207
|
+
}
|
|
1208
|
+
.title {
|
|
1209
|
+
font-family: var(--font-display);
|
|
1210
|
+
font-size: var(--text-display);
|
|
1211
|
+
font-weight: 800;
|
|
1212
|
+
line-height: var(--leading-tight);
|
|
1213
|
+
letter-spacing: var(--tracking-tight);
|
|
1214
|
+
color: var(--color-on-dark);
|
|
1215
|
+
margin: 0;
|
|
1216
|
+
}
|
|
1217
|
+
.subtitle {
|
|
1218
|
+
font-size: var(--text-body);
|
|
1219
|
+
line-height: var(--leading-relaxed);
|
|
1220
|
+
color: var(--color-on-dark);
|
|
1221
|
+
opacity: 0.8;
|
|
1222
|
+
max-width: 540px;
|
|
1223
|
+
}
|
|
1224
|
+
.visual {
|
|
1225
|
+
display: flex;
|
|
1226
|
+
justify-content: center;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
@media (min-width: 1024px) {
|
|
1230
|
+
.grid { grid-template-columns: 1.2fr 0.8fr; }
|
|
1231
|
+
}
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
#### FeaturesGrid
|
|
1235
|
+
```tsx
|
|
1236
|
+
// components/organisms/FeaturesGrid.tsx
|
|
1237
|
+
import Section from '@/components/ui/Section';
|
|
1238
|
+
import Container from '@/components/ui/Container';
|
|
1239
|
+
import Heading from '@/components/atoms/Heading';
|
|
1240
|
+
import Text from '@/components/atoms/Text';
|
|
1241
|
+
import FeatureCard from '@/components/molecules/FeatureCard';
|
|
1242
|
+
import { useScrollReveal } from '@/hooks/useIntersectionObserver';
|
|
1243
|
+
import styles from './FeaturesGrid.module.css';
|
|
1244
|
+
|
|
1245
|
+
interface Feature {
|
|
1246
|
+
icon: React.ReactNode;
|
|
1247
|
+
title: string;
|
|
1248
|
+
description: string;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
interface FeaturesGridProps {
|
|
1252
|
+
badge?: string;
|
|
1253
|
+
title: string;
|
|
1254
|
+
subtitle?: string;
|
|
1255
|
+
features: Feature[];
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
export default function FeaturesGrid({ badge, title, subtitle, features }: FeaturesGridProps) {
|
|
1259
|
+
const { ref, isVisible } = useScrollReveal();
|
|
1260
|
+
|
|
1261
|
+
return (
|
|
1262
|
+
<Section bg="alt" id="features">
|
|
1263
|
+
<Container>
|
|
1264
|
+
<div className={styles.header}>
|
|
1265
|
+
{badge && <span className={styles.badge}>{badge}</span>}
|
|
1266
|
+
<Heading level={2}>{title}</Heading>
|
|
1267
|
+
{subtitle && <Text muted>{subtitle}</Text>}
|
|
1268
|
+
</div>
|
|
1269
|
+
<div ref={ref} className={styles.grid}>
|
|
1270
|
+
{features.map((feature, i) => (
|
|
1271
|
+
<div
|
|
1272
|
+
key={feature.title}
|
|
1273
|
+
className={styles.item}
|
|
1274
|
+
style={{
|
|
1275
|
+
opacity: isVisible ? 1 : 0,
|
|
1276
|
+
transform: isVisible ? 'translateY(0)' : 'translateY(24px)',
|
|
1277
|
+
transition: `all var(--duration-slow) var(--ease-out)`,
|
|
1278
|
+
transitionDelay: `${i * 80}ms`,
|
|
1279
|
+
}}
|
|
1280
|
+
>
|
|
1281
|
+
<FeatureCard {...feature} />
|
|
1282
|
+
</div>
|
|
1283
|
+
))}
|
|
1284
|
+
</div>
|
|
1285
|
+
</Container>
|
|
1286
|
+
</Section>
|
|
1287
|
+
);
|
|
1288
|
+
}
|
|
1289
|
+
```
|
|
1290
|
+
|
|
1291
|
+
```css
|
|
1292
|
+
/* components/organisms/FeaturesGrid.module.css */
|
|
1293
|
+
.header {
|
|
1294
|
+
text-align: center;
|
|
1295
|
+
max-width: var(--container-narrow);
|
|
1296
|
+
margin: 0 auto var(--space-12);
|
|
1297
|
+
display: flex;
|
|
1298
|
+
flex-direction: column;
|
|
1299
|
+
gap: var(--space-4);
|
|
1300
|
+
}
|
|
1301
|
+
.badge {
|
|
1302
|
+
display: inline-flex;
|
|
1303
|
+
align-self: center;
|
|
1304
|
+
padding: var(--space-1) var(--space-3);
|
|
1305
|
+
border-radius: var(--radius-full);
|
|
1306
|
+
font-size: var(--text-caption);
|
|
1307
|
+
font-weight: 600;
|
|
1308
|
+
letter-spacing: var(--tracking-wide);
|
|
1309
|
+
text-transform: uppercase;
|
|
1310
|
+
background: var(--color-accent-subtle);
|
|
1311
|
+
color: var(--color-accent);
|
|
1312
|
+
}
|
|
1313
|
+
.grid {
|
|
1314
|
+
display: grid;
|
|
1315
|
+
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
1316
|
+
gap: var(--space-8);
|
|
1317
|
+
}
|
|
1318
|
+
.item { will-change: transform, opacity; }
|
|
1319
|
+
```
|
|
1320
|
+
|
|
1321
|
+
#### FAQAccordion
|
|
1322
|
+
```tsx
|
|
1323
|
+
// components/organisms/FAQAccordion.tsx
|
|
1324
|
+
import { useState, useId } from 'react';
|
|
1325
|
+
import Section from '@/components/ui/Section';
|
|
1326
|
+
import Container from '@/components/ui/Container';
|
|
1327
|
+
import Heading from '@/components/atoms/Heading';
|
|
1328
|
+
import styles from './FAQAccordion.module.css';
|
|
1329
|
+
|
|
1330
|
+
interface FAQItem {
|
|
1331
|
+
question: string;
|
|
1332
|
+
answer: string;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
interface FAQAccordionProps {
|
|
1336
|
+
title: string;
|
|
1337
|
+
items: FAQItem[];
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function FAQEntry({ question, answer }: FAQItem) {
|
|
1341
|
+
const [open, setOpen] = useState(false);
|
|
1342
|
+
const id = useId();
|
|
1343
|
+
|
|
1344
|
+
return (
|
|
1345
|
+
<div className={styles.item}>
|
|
1346
|
+
<button
|
|
1347
|
+
className={styles.trigger}
|
|
1348
|
+
onClick={() => setOpen(!open)}
|
|
1349
|
+
aria-expanded={open}
|
|
1350
|
+
aria-controls={`${id}-panel`}
|
|
1351
|
+
id={`${id}-trigger`}
|
|
1352
|
+
>
|
|
1353
|
+
<span className={styles.question}>{question}</span>
|
|
1354
|
+
<span className={styles.icon} aria-hidden="true">{open ? '\u2212' : '+'}</span>
|
|
1355
|
+
</button>
|
|
1356
|
+
<div
|
|
1357
|
+
id={`${id}-panel`}
|
|
1358
|
+
role="region"
|
|
1359
|
+
aria-labelledby={`${id}-trigger`}
|
|
1360
|
+
className={styles.panel}
|
|
1361
|
+
style={{
|
|
1362
|
+
maxHeight: open ? '500px' : '0',
|
|
1363
|
+
opacity: open ? 1 : 0,
|
|
1364
|
+
paddingBottom: open ? 'var(--space-6)' : '0',
|
|
1365
|
+
}}
|
|
1366
|
+
>
|
|
1367
|
+
<p className={styles.answer}>{answer}</p>
|
|
1368
|
+
</div>
|
|
1369
|
+
</div>
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
export default function FAQAccordion({ title, items }: FAQAccordionProps) {
|
|
1374
|
+
return (
|
|
1375
|
+
<Section bg="alt" id="faq">
|
|
1376
|
+
<Container narrow>
|
|
1377
|
+
<Heading level={2} className={styles.title}>{title}</Heading>
|
|
1378
|
+
<div className={styles.list}>
|
|
1379
|
+
{items.map((item) => (
|
|
1380
|
+
<FAQEntry key={item.question} {...item} />
|
|
1381
|
+
))}
|
|
1382
|
+
</div>
|
|
1383
|
+
</Container>
|
|
1384
|
+
</Section>
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1389
|
+
```css
|
|
1390
|
+
/* components/organisms/FAQAccordion.module.css */
|
|
1391
|
+
.title { text-align: center; margin-bottom: var(--space-12); }
|
|
1392
|
+
.list {
|
|
1393
|
+
display: flex;
|
|
1394
|
+
flex-direction: column;
|
|
1395
|
+
gap: var(--space-2);
|
|
1396
|
+
}
|
|
1397
|
+
.item {
|
|
1398
|
+
border-bottom: 1px solid oklch(0 0 0 / 0.08);
|
|
1399
|
+
}
|
|
1400
|
+
.trigger {
|
|
1401
|
+
width: 100%;
|
|
1402
|
+
display: flex;
|
|
1403
|
+
justify-content: space-between;
|
|
1404
|
+
align-items: center;
|
|
1405
|
+
padding: var(--space-6) 0;
|
|
1406
|
+
background: none;
|
|
1407
|
+
border: none;
|
|
1408
|
+
cursor: pointer;
|
|
1409
|
+
text-align: left;
|
|
1410
|
+
}
|
|
1411
|
+
.question {
|
|
1412
|
+
font-family: var(--font-display);
|
|
1413
|
+
font-size: var(--text-h3);
|
|
1414
|
+
font-weight: 600;
|
|
1415
|
+
color: var(--color-on-surface);
|
|
1416
|
+
}
|
|
1417
|
+
.icon {
|
|
1418
|
+
font-size: 1.5rem;
|
|
1419
|
+
color: var(--color-accent);
|
|
1420
|
+
flex-shrink: 0;
|
|
1421
|
+
margin-left: var(--space-4);
|
|
1422
|
+
}
|
|
1423
|
+
.panel {
|
|
1424
|
+
overflow: hidden;
|
|
1425
|
+
transition: max-height var(--duration-normal) var(--ease-out),
|
|
1426
|
+
opacity var(--duration-normal) var(--ease-out),
|
|
1427
|
+
padding-bottom var(--duration-normal) var(--ease-out);
|
|
1428
|
+
}
|
|
1429
|
+
.answer {
|
|
1430
|
+
font-size: var(--text-body);
|
|
1431
|
+
line-height: var(--leading-relaxed);
|
|
1432
|
+
color: var(--color-on-surface-muted);
|
|
1433
|
+
margin: 0;
|
|
1434
|
+
}
|
|
1435
|
+
```
|
|
1436
|
+
|
|
1437
|
+
#### CTASection
|
|
1438
|
+
```tsx
|
|
1439
|
+
// components/organisms/CTASection.tsx
|
|
1440
|
+
import Section from '@/components/ui/Section';
|
|
1441
|
+
import Container from '@/components/ui/Container';
|
|
1442
|
+
import Heading from '@/components/atoms/Heading';
|
|
1443
|
+
import Text from '@/components/atoms/Text';
|
|
1444
|
+
import CTAGroup from '@/components/molecules/CTAGroup';
|
|
1445
|
+
import styles from './CTASection.module.css';
|
|
1446
|
+
|
|
1447
|
+
interface CTASectionProps {
|
|
1448
|
+
title: string;
|
|
1449
|
+
subtitle: string;
|
|
1450
|
+
primaryLabel: string;
|
|
1451
|
+
primaryHref: string;
|
|
1452
|
+
secondaryLabel?: string;
|
|
1453
|
+
secondaryHref?: string;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
export default function CTASection({
|
|
1457
|
+
title, subtitle, primaryLabel, primaryHref,
|
|
1458
|
+
secondaryLabel, secondaryHref,
|
|
1459
|
+
}: CTASectionProps) {
|
|
1460
|
+
return (
|
|
1461
|
+
<Section bg="dark" className={styles.cta}>
|
|
1462
|
+
<Container narrow>
|
|
1463
|
+
<div className={styles.content}>
|
|
1464
|
+
<Heading level={2} className={styles.title}>{title}</Heading>
|
|
1465
|
+
<Text className={styles.subtitle}>{subtitle}</Text>
|
|
1466
|
+
<CTAGroup
|
|
1467
|
+
primaryLabel={primaryLabel}
|
|
1468
|
+
primaryHref={primaryHref}
|
|
1469
|
+
secondaryLabel={secondaryLabel}
|
|
1470
|
+
secondaryHref={secondaryHref}
|
|
1471
|
+
className={styles.actions}
|
|
1472
|
+
/>
|
|
1473
|
+
</div>
|
|
1474
|
+
</Container>
|
|
1475
|
+
</Section>
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
```
|
|
1479
|
+
|
|
1480
|
+
```css
|
|
1481
|
+
/* components/organisms/CTASection.module.css */
|
|
1482
|
+
.cta {
|
|
1483
|
+
background: linear-gradient(135deg, var(--color-base-900), var(--color-base-800));
|
|
1484
|
+
}
|
|
1485
|
+
.content {
|
|
1486
|
+
text-align: center;
|
|
1487
|
+
display: flex;
|
|
1488
|
+
flex-direction: column;
|
|
1489
|
+
align-items: center;
|
|
1490
|
+
gap: var(--space-6);
|
|
1491
|
+
}
|
|
1492
|
+
.title { color: var(--color-on-dark); }
|
|
1493
|
+
.subtitle { color: var(--color-on-dark); opacity: 0.8; }
|
|
1494
|
+
.actions { justify-content: center; }
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
#### Footer
|
|
1498
|
+
```tsx
|
|
1499
|
+
// components/organisms/Footer.tsx
|
|
1500
|
+
import Container from '@/components/ui/Container';
|
|
1501
|
+
import Text from '@/components/atoms/Text';
|
|
1502
|
+
import styles from './Footer.module.css';
|
|
1503
|
+
|
|
1504
|
+
interface FooterLink {
|
|
1505
|
+
label: string;
|
|
1506
|
+
href: string;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
interface FooterColumn {
|
|
1510
|
+
title: string;
|
|
1511
|
+
links: FooterLink[];
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
interface FooterProps {
|
|
1515
|
+
logo: React.ReactNode;
|
|
1516
|
+
columns: FooterColumn[];
|
|
1517
|
+
copyright: string;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
export default function Footer({ logo, columns, copyright }: FooterProps) {
|
|
1521
|
+
return (
|
|
1522
|
+
<footer className={styles.footer}>
|
|
1523
|
+
<Container>
|
|
1524
|
+
<div className={styles.grid}>
|
|
1525
|
+
<div className={styles.brand}>{logo}</div>
|
|
1526
|
+
{columns.map((col) => (
|
|
1527
|
+
<nav key={col.title} className={styles.column} aria-label={col.title}>
|
|
1528
|
+
<h4 className={styles.columnTitle}>{col.title}</h4>
|
|
1529
|
+
<ul className={styles.links}>
|
|
1530
|
+
{col.links.map(({ label, href }) => (
|
|
1531
|
+
<li key={href}>
|
|
1532
|
+
<a href={href} className={styles.link}>{label}</a>
|
|
1533
|
+
</li>
|
|
1534
|
+
))}
|
|
1535
|
+
</ul>
|
|
1536
|
+
</nav>
|
|
1537
|
+
))}
|
|
1538
|
+
</div>
|
|
1539
|
+
<div className={styles.bottom}>
|
|
1540
|
+
<Text variant="caption" muted>{copyright}</Text>
|
|
1541
|
+
</div>
|
|
1542
|
+
</Container>
|
|
1543
|
+
</footer>
|
|
1544
|
+
);
|
|
1545
|
+
}
|
|
1546
|
+
```
|
|
1547
|
+
|
|
1548
|
+
```css
|
|
1549
|
+
/* components/organisms/Footer.module.css */
|
|
1550
|
+
.footer {
|
|
1551
|
+
background: var(--color-base-900);
|
|
1552
|
+
padding: var(--space-16) 0 var(--space-8);
|
|
1553
|
+
color: var(--color-on-dark);
|
|
1554
|
+
}
|
|
1555
|
+
.grid {
|
|
1556
|
+
display: grid;
|
|
1557
|
+
grid-template-columns: 1.5fr repeat(auto-fit, minmax(150px, 1fr));
|
|
1558
|
+
gap: var(--space-12);
|
|
1559
|
+
margin-bottom: var(--space-12);
|
|
1560
|
+
}
|
|
1561
|
+
.brand { font-weight: 800; font-size: var(--text-h3); }
|
|
1562
|
+
.columnTitle {
|
|
1563
|
+
font-size: var(--text-small);
|
|
1564
|
+
font-weight: 600;
|
|
1565
|
+
text-transform: uppercase;
|
|
1566
|
+
letter-spacing: var(--tracking-widest);
|
|
1567
|
+
margin: 0 0 var(--space-4);
|
|
1568
|
+
color: var(--color-on-dark);
|
|
1569
|
+
}
|
|
1570
|
+
.links { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-3); }
|
|
1571
|
+
.link {
|
|
1572
|
+
font-size: var(--text-small);
|
|
1573
|
+
color: var(--color-on-dark);
|
|
1574
|
+
opacity: 0.6;
|
|
1575
|
+
text-decoration: none;
|
|
1576
|
+
transition: opacity var(--duration-fast);
|
|
1577
|
+
}
|
|
1578
|
+
.link:hover { opacity: 1; }
|
|
1579
|
+
.bottom {
|
|
1580
|
+
border-top: 1px solid oklch(1 0 0 / 0.1);
|
|
1581
|
+
padding-top: var(--space-8);
|
|
1582
|
+
text-align: center;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
@media (max-width: 768px) {
|
|
1586
|
+
.grid { grid-template-columns: 1fr 1fr; }
|
|
1587
|
+
.brand { grid-column: 1 / -1; }
|
|
1588
|
+
}
|
|
1589
|
+
```
|
|
1590
|
+
|
|
1591
|
+
---
|
|
1592
|
+
|
|
1593
|
+
## Custom Hooks
|
|
1594
|
+
|
|
1595
|
+
### useIntersectionObserver (scroll reveals)
|
|
1596
|
+
```ts
|
|
1597
|
+
// hooks/useIntersectionObserver.ts
|
|
1598
|
+
import { useRef, useState, useEffect, type RefObject } from 'react';
|
|
1599
|
+
|
|
1600
|
+
interface UseScrollRevealOptions {
|
|
1601
|
+
threshold?: number;
|
|
1602
|
+
rootMargin?: string;
|
|
1603
|
+
triggerOnce?: boolean;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
export function useScrollReveal<T extends HTMLElement = HTMLDivElement>(
|
|
1607
|
+
options: UseScrollRevealOptions = {}
|
|
1608
|
+
): { ref: RefObject<T | null>; isVisible: boolean } {
|
|
1609
|
+
const { threshold = 0.15, rootMargin = '0px', triggerOnce = true } = options;
|
|
1610
|
+
const ref = useRef<T>(null);
|
|
1611
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
1612
|
+
|
|
1613
|
+
useEffect(() => {
|
|
1614
|
+
const el = ref.current;
|
|
1615
|
+
if (!el) return;
|
|
1616
|
+
|
|
1617
|
+
const observer = new IntersectionObserver(
|
|
1618
|
+
([entry]) => {
|
|
1619
|
+
if (entry.isIntersecting) {
|
|
1620
|
+
setIsVisible(true);
|
|
1621
|
+
if (triggerOnce) observer.disconnect();
|
|
1622
|
+
} else if (!triggerOnce) {
|
|
1623
|
+
setIsVisible(false);
|
|
1624
|
+
}
|
|
1625
|
+
},
|
|
1626
|
+
{ threshold, rootMargin }
|
|
1627
|
+
);
|
|
1628
|
+
|
|
1629
|
+
observer.observe(el);
|
|
1630
|
+
return () => observer.disconnect();
|
|
1631
|
+
}, [threshold, rootMargin, triggerOnce]);
|
|
1632
|
+
|
|
1633
|
+
return { ref, isVisible };
|
|
1634
|
+
}
|
|
1635
|
+
```
|
|
1636
|
+
|
|
1637
|
+
### useCountUp (animated numbers)
|
|
1638
|
+
```ts
|
|
1639
|
+
// hooks/useCountUp.ts
|
|
1640
|
+
import { useRef, useState, useEffect, type RefObject } from 'react';
|
|
1641
|
+
import { useScrollReveal } from './useIntersectionObserver';
|
|
1642
|
+
|
|
1643
|
+
interface UseCountUpResult {
|
|
1644
|
+
ref: RefObject<HTMLDivElement | null>;
|
|
1645
|
+
count: number;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
export function useCountUp(target: number, duration = 2000): UseCountUpResult {
|
|
1649
|
+
const { ref, isVisible } = useScrollReveal<HTMLDivElement>();
|
|
1650
|
+
const [count, setCount] = useState(0);
|
|
1651
|
+
const hasAnimated = useRef(false);
|
|
1652
|
+
|
|
1653
|
+
useEffect(() => {
|
|
1654
|
+
if (!isVisible || hasAnimated.current) return;
|
|
1655
|
+
hasAnimated.current = true;
|
|
1656
|
+
|
|
1657
|
+
const startTime = performance.now();
|
|
1658
|
+
|
|
1659
|
+
function animate(now: number) {
|
|
1660
|
+
const elapsed = now - startTime;
|
|
1661
|
+
const progress = Math.min(elapsed / duration, 1);
|
|
1662
|
+
// Ease-out quad
|
|
1663
|
+
const eased = 1 - (1 - progress) * (1 - progress);
|
|
1664
|
+
setCount(Math.round(eased * target));
|
|
1665
|
+
|
|
1666
|
+
if (progress < 1) {
|
|
1667
|
+
requestAnimationFrame(animate);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
requestAnimationFrame(animate);
|
|
1672
|
+
}, [isVisible, target, duration]);
|
|
1673
|
+
|
|
1674
|
+
return { ref, count };
|
|
1675
|
+
}
|
|
1676
|
+
```
|
|
1677
|
+
|
|
1678
|
+
### useMediaQuery (responsive hooks)
|
|
1679
|
+
```ts
|
|
1680
|
+
// hooks/useMediaQuery.ts
|
|
1681
|
+
import { useState, useEffect } from 'react';
|
|
1682
|
+
|
|
1683
|
+
export function useMediaQuery(query: string): boolean {
|
|
1684
|
+
const [matches, setMatches] = useState(false);
|
|
1685
|
+
|
|
1686
|
+
useEffect(() => {
|
|
1687
|
+
const mql = window.matchMedia(query);
|
|
1688
|
+
setMatches(mql.matches);
|
|
1689
|
+
|
|
1690
|
+
function onChange(e: MediaQueryListEvent) {
|
|
1691
|
+
setMatches(e.matches);
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
mql.addEventListener('change', onChange);
|
|
1695
|
+
return () => mql.removeEventListener('change', onChange);
|
|
1696
|
+
}, [query]);
|
|
1697
|
+
|
|
1698
|
+
return matches;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Convenience hooks using the same breakpoints as CSS
|
|
1702
|
+
export const useIsMobile = () => useMediaQuery('(max-width: 767px)');
|
|
1703
|
+
export const useIsTablet = () => useMediaQuery('(min-width: 768px) and (max-width: 1023px)');
|
|
1704
|
+
export const useIsDesktop = () => useMediaQuery('(min-width: 1024px)');
|
|
1705
|
+
```
|
|
1706
|
+
|
|
1707
|
+
---
|
|
1708
|
+
|
|
1709
|
+
## Animation Patterns
|
|
1710
|
+
|
|
1711
|
+
### CSS-Only Animations (default, no dependencies)
|
|
1712
|
+
|
|
1713
|
+
```css
|
|
1714
|
+
/* globals.css — add to design tokens file */
|
|
1715
|
+
|
|
1716
|
+
/* Scroll reveal base */
|
|
1717
|
+
.reveal {
|
|
1718
|
+
opacity: 0;
|
|
1719
|
+
transform: translateY(24px);
|
|
1720
|
+
transition:
|
|
1721
|
+
opacity var(--duration-slow) var(--ease-out),
|
|
1722
|
+
transform var(--duration-slow) var(--ease-out);
|
|
1723
|
+
}
|
|
1724
|
+
.reveal.visible {
|
|
1725
|
+
opacity: 1;
|
|
1726
|
+
transform: translateY(0);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/* Stagger children */
|
|
1730
|
+
.stagger > * {
|
|
1731
|
+
transition-delay: calc(var(--index, 0) * 80ms);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
/* Reduced motion */
|
|
1735
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1736
|
+
.reveal,
|
|
1737
|
+
.reveal.visible {
|
|
1738
|
+
transition: none;
|
|
1739
|
+
opacity: 1;
|
|
1740
|
+
transform: none;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
```
|
|
1744
|
+
|
|
1745
|
+
### React component for scroll reveal
|
|
1746
|
+
```tsx
|
|
1747
|
+
// components/ui/Reveal.tsx
|
|
1748
|
+
import { useScrollReveal } from '@/hooks/useIntersectionObserver';
|
|
1749
|
+
import { cn } from '@/lib/cn';
|
|
1750
|
+
import styles from './Reveal.module.css';
|
|
1751
|
+
|
|
1752
|
+
interface RevealProps {
|
|
1753
|
+
children: React.ReactNode;
|
|
1754
|
+
className?: string;
|
|
1755
|
+
delay?: number;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
export default function Reveal({ children, className, delay = 0 }: RevealProps) {
|
|
1759
|
+
const { ref, isVisible } = useScrollReveal();
|
|
1760
|
+
|
|
1761
|
+
return (
|
|
1762
|
+
<div
|
|
1763
|
+
ref={ref}
|
|
1764
|
+
className={cn(styles.reveal, isVisible && styles.visible, className)}
|
|
1765
|
+
style={{ transitionDelay: `${delay}ms` }}
|
|
1766
|
+
>
|
|
1767
|
+
{children}
|
|
1768
|
+
</div>
|
|
1769
|
+
);
|
|
1770
|
+
}
|
|
1771
|
+
```
|
|
1772
|
+
|
|
1773
|
+
```css
|
|
1774
|
+
/* components/ui/Reveal.module.css */
|
|
1775
|
+
.reveal {
|
|
1776
|
+
opacity: 0;
|
|
1777
|
+
transform: translateY(24px);
|
|
1778
|
+
transition:
|
|
1779
|
+
opacity var(--duration-slow) var(--ease-out),
|
|
1780
|
+
transform var(--duration-slow) var(--ease-out);
|
|
1781
|
+
will-change: transform, opacity;
|
|
1782
|
+
}
|
|
1783
|
+
.visible {
|
|
1784
|
+
opacity: 1;
|
|
1785
|
+
transform: translateY(0);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1789
|
+
.reveal {
|
|
1790
|
+
opacity: 1;
|
|
1791
|
+
transform: none;
|
|
1792
|
+
transition: none;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
```
|
|
1796
|
+
|
|
1797
|
+
### Framer Motion (optional, for complex animations)
|
|
1798
|
+
|
|
1799
|
+
Only add `framer-motion` when CSS-only is insufficient (e.g., layout animations, gesture-based, exit animations).
|
|
1800
|
+
|
|
1801
|
+
```tsx
|
|
1802
|
+
// Example: stagger list with Framer Motion
|
|
1803
|
+
import { motion } from 'framer-motion';
|
|
1804
|
+
|
|
1805
|
+
const container = {
|
|
1806
|
+
hidden: {},
|
|
1807
|
+
show: { transition: { staggerChildren: 0.08 } },
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
const item = {
|
|
1811
|
+
hidden: { opacity: 0, y: 24 },
|
|
1812
|
+
show: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] } },
|
|
1813
|
+
};
|
|
1814
|
+
|
|
1815
|
+
function AnimatedList({ children }: { children: React.ReactNode[] }) {
|
|
1816
|
+
return (
|
|
1817
|
+
<motion.div variants={container} initial="hidden" whileInView="show" viewport={{ once: true, margin: '-50px' }}>
|
|
1818
|
+
{children.map((child, i) => (
|
|
1819
|
+
<motion.div key={i} variants={item}>{child}</motion.div>
|
|
1820
|
+
))}
|
|
1821
|
+
</motion.div>
|
|
1822
|
+
);
|
|
1823
|
+
}
|
|
1824
|
+
```
|
|
1825
|
+
|
|
1826
|
+
### Animation Rules
|
|
1827
|
+
- Only animate `opacity` and `transform` — never width, height, margin, or padding
|
|
1828
|
+
- Use `will-change: transform, opacity` on animated elements
|
|
1829
|
+
- ALWAYS respect `prefers-reduced-motion`
|
|
1830
|
+
- CSS-only is the default; reach for Framer Motion only when needed
|
|
1831
|
+
- Stagger delay: 60-100ms per item, cap at ~400ms total
|
|
1832
|
+
- Scroll reveal threshold: 10-20% visibility before triggering
|
|
1833
|
+
|
|
1834
|
+
---
|
|
1835
|
+
|
|
1836
|
+
## Performance
|
|
1837
|
+
|
|
1838
|
+
### Image Strategy
|
|
1839
|
+
|
|
1840
|
+
**Next.js:**
|
|
1841
|
+
```tsx
|
|
1842
|
+
import Image from 'next/image';
|
|
1843
|
+
|
|
1844
|
+
<Image
|
|
1845
|
+
src="/hero-visual.webp"
|
|
1846
|
+
alt="Product screenshot"
|
|
1847
|
+
width={800}
|
|
1848
|
+
height={600}
|
|
1849
|
+
priority // for above-the-fold
|
|
1850
|
+
sizes="(max-width: 768px) 100vw, 50vw"
|
|
1851
|
+
/>
|
|
1852
|
+
```
|
|
1853
|
+
|
|
1854
|
+
**Vite + React:**
|
|
1855
|
+
Use the `LazyImage` atom defined above, or a simple native approach:
|
|
1856
|
+
```tsx
|
|
1857
|
+
<img
|
|
1858
|
+
src="/hero.webp"
|
|
1859
|
+
alt="Product screenshot"
|
|
1860
|
+
width={800}
|
|
1861
|
+
height={600}
|
|
1862
|
+
loading="lazy"
|
|
1863
|
+
decoding="async"
|
|
1864
|
+
style={{ aspectRatio: '800 / 600' }}
|
|
1865
|
+
/>
|
|
1866
|
+
```
|
|
1867
|
+
|
|
1868
|
+
### Code Splitting
|
|
1869
|
+
```tsx
|
|
1870
|
+
import { lazy, Suspense } from 'react';
|
|
1871
|
+
|
|
1872
|
+
// Split heavy below-the-fold sections
|
|
1873
|
+
const PricingSection = lazy(() => import('@/components/organisms/PricingSection'));
|
|
1874
|
+
const TestimonialsSection = lazy(() => import('@/components/organisms/TestimonialsSection'));
|
|
1875
|
+
|
|
1876
|
+
function LandingPage() {
|
|
1877
|
+
return (
|
|
1878
|
+
<>
|
|
1879
|
+
<HeroSection {...heroProps} />
|
|
1880
|
+
<FeaturesGrid {...featuresProps} />
|
|
1881
|
+
<Suspense fallback={<div style={{ minHeight: '400px' }} />}>
|
|
1882
|
+
<PricingSection {...pricingProps} />
|
|
1883
|
+
</Suspense>
|
|
1884
|
+
<Suspense fallback={<div style={{ minHeight: '300px' }} />}>
|
|
1885
|
+
<TestimonialsSection {...testimonialsProps} />
|
|
1886
|
+
</Suspense>
|
|
1887
|
+
<CTASection {...ctaProps} />
|
|
1888
|
+
</>
|
|
1889
|
+
);
|
|
1890
|
+
}
|
|
1891
|
+
```
|
|
1892
|
+
|
|
1893
|
+
### Font Loading
|
|
1894
|
+
|
|
1895
|
+
**Next.js (built-in):**
|
|
1896
|
+
```tsx
|
|
1897
|
+
// app/layout.tsx
|
|
1898
|
+
import { Inter, Plus_Jakarta_Sans } from 'next/font/google';
|
|
1899
|
+
|
|
1900
|
+
const inter = Inter({ subsets: ['latin'], variable: '--font-body' });
|
|
1901
|
+
const jakarta = Plus_Jakarta_Sans({ subsets: ['latin'], variable: '--font-display', weight: ['600', '700', '800'] });
|
|
1902
|
+
|
|
1903
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1904
|
+
return (
|
|
1905
|
+
<html lang="pt-BR" className={`${inter.variable} ${jakarta.variable}`}>
|
|
1906
|
+
<body>{children}</body>
|
|
1907
|
+
</html>
|
|
1908
|
+
);
|
|
1909
|
+
}
|
|
1910
|
+
```
|
|
1911
|
+
|
|
1912
|
+
**Vite (manual):**
|
|
1913
|
+
```html
|
|
1914
|
+
<!-- index.html -->
|
|
1915
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
1916
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
1917
|
+
<link
|
|
1918
|
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Plus+Jakarta+Sans:wght@600;700;800&display=swap"
|
|
1919
|
+
rel="stylesheet"
|
|
1920
|
+
/>
|
|
1921
|
+
```
|
|
1922
|
+
|
|
1923
|
+
### Static Export
|
|
1924
|
+
|
|
1925
|
+
**Next.js:**
|
|
1926
|
+
```ts
|
|
1927
|
+
// next.config.ts
|
|
1928
|
+
const config = { output: 'export' };
|
|
1929
|
+
export default config;
|
|
1930
|
+
```
|
|
1931
|
+
|
|
1932
|
+
**Vite:** Runs `vite build` by default — output in `dist/`.
|
|
1933
|
+
|
|
1934
|
+
---
|
|
1935
|
+
|
|
1936
|
+
## Responsive Strategy
|
|
1937
|
+
|
|
1938
|
+
### Mobile-First Approach
|
|
1939
|
+
|
|
1940
|
+
All base styles target mobile. Use `min-width` media queries for larger screens:
|
|
1941
|
+
|
|
1942
|
+
```css
|
|
1943
|
+
/* Base: mobile */
|
|
1944
|
+
.grid {
|
|
1945
|
+
display: grid;
|
|
1946
|
+
grid-template-columns: 1fr;
|
|
1947
|
+
gap: var(--space-6);
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
/* Tablet */
|
|
1951
|
+
@media (min-width: 768px) {
|
|
1952
|
+
.grid { grid-template-columns: 1fr 1fr; gap: var(--space-8); }
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/* Desktop */
|
|
1956
|
+
@media (min-width: 1024px) {
|
|
1957
|
+
.grid { grid-template-columns: repeat(3, 1fr); gap: var(--space-12); }
|
|
1958
|
+
}
|
|
1959
|
+
```
|
|
1960
|
+
|
|
1961
|
+
### Container Component Pattern
|
|
1962
|
+
```tsx
|
|
1963
|
+
// Already defined above — use <Container> and <Container narrow> everywhere
|
|
1964
|
+
// Never set max-width or horizontal padding on individual sections
|
|
1965
|
+
```
|
|
1966
|
+
|
|
1967
|
+
### Responsive Component Example
|
|
1968
|
+
```tsx
|
|
1969
|
+
import { useIsMobile } from '@/hooks/useMediaQuery';
|
|
1970
|
+
|
|
1971
|
+
function TestimonialsSection({ testimonials }: { testimonials: Testimonial[] }) {
|
|
1972
|
+
const isMobile = useIsMobile();
|
|
1973
|
+
|
|
1974
|
+
return (
|
|
1975
|
+
<Section bg="default">
|
|
1976
|
+
<Container>
|
|
1977
|
+
<div className={styles.grid}>
|
|
1978
|
+
{testimonials.slice(0, isMobile ? 2 : 6).map((t) => (
|
|
1979
|
+
<TestimonialCard key={t.author} {...t} />
|
|
1980
|
+
))}
|
|
1981
|
+
</div>
|
|
1982
|
+
</Container>
|
|
1983
|
+
</Section>
|
|
1984
|
+
);
|
|
1985
|
+
}
|
|
1986
|
+
```
|
|
1987
|
+
|
|
1988
|
+
### Touch Targets
|
|
1989
|
+
- Minimum 44x44px for all interactive elements on mobile
|
|
1990
|
+
- Use padding rather than fixed dimensions to achieve this
|
|
1991
|
+
- Apply `min-height: 44px; min-width: 44px` on clickable atoms
|
|
1992
|
+
|
|
1993
|
+
---
|
|
1994
|
+
|
|
1995
|
+
## SEO
|
|
1996
|
+
|
|
1997
|
+
### Meta Tags
|
|
1998
|
+
|
|
1999
|
+
**Next.js (app router):**
|
|
2000
|
+
```tsx
|
|
2001
|
+
// app/layout.tsx
|
|
2002
|
+
import type { Metadata } from 'next';
|
|
2003
|
+
|
|
2004
|
+
export const metadata: Metadata = {
|
|
2005
|
+
title: 'Product Name — Tagline',
|
|
2006
|
+
description: 'Clear value proposition in 150-160 characters.',
|
|
2007
|
+
openGraph: {
|
|
2008
|
+
title: 'Product Name — Tagline',
|
|
2009
|
+
description: 'Clear value proposition.',
|
|
2010
|
+
url: 'https://example.com',
|
|
2011
|
+
siteName: 'Product Name',
|
|
2012
|
+
images: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'Product preview' }],
|
|
2013
|
+
type: 'website',
|
|
2014
|
+
locale: 'pt_BR',
|
|
2015
|
+
},
|
|
2016
|
+
twitter: {
|
|
2017
|
+
card: 'summary_large_image',
|
|
2018
|
+
title: 'Product Name — Tagline',
|
|
2019
|
+
description: 'Clear value proposition.',
|
|
2020
|
+
images: ['/og-image.png'],
|
|
2021
|
+
},
|
|
2022
|
+
robots: { index: true, follow: true },
|
|
2023
|
+
alternates: { canonical: 'https://example.com' },
|
|
2024
|
+
};
|
|
2025
|
+
```
|
|
2026
|
+
|
|
2027
|
+
**Vite + react-helmet-async:**
|
|
2028
|
+
```tsx
|
|
2029
|
+
import { Helmet } from 'react-helmet-async';
|
|
2030
|
+
|
|
2031
|
+
function SEO({ title, description, url, image }: SEOProps) {
|
|
2032
|
+
return (
|
|
2033
|
+
<Helmet>
|
|
2034
|
+
<title>{title}</title>
|
|
2035
|
+
<meta name="description" content={description} />
|
|
2036
|
+
<link rel="canonical" href={url} />
|
|
2037
|
+
<meta property="og:title" content={title} />
|
|
2038
|
+
<meta property="og:description" content={description} />
|
|
2039
|
+
<meta property="og:url" content={url} />
|
|
2040
|
+
<meta property="og:image" content={image} />
|
|
2041
|
+
<meta property="og:type" content="website" />
|
|
2042
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
2043
|
+
<meta name="twitter:title" content={title} />
|
|
2044
|
+
<meta name="twitter:description" content={description} />
|
|
2045
|
+
<meta name="twitter:image" content={image} />
|
|
2046
|
+
</Helmet>
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
```
|
|
2050
|
+
|
|
2051
|
+
### Structured Data (JSON-LD)
|
|
2052
|
+
```tsx
|
|
2053
|
+
// components/ui/JsonLd.tsx
|
|
2054
|
+
interface JsonLdProps {
|
|
2055
|
+
data: Record<string, unknown>;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
export default function JsonLd({ data }: JsonLdProps) {
|
|
2059
|
+
return (
|
|
2060
|
+
<script
|
|
2061
|
+
type="application/ld+json"
|
|
2062
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
|
|
2063
|
+
/>
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
// Usage in page:
|
|
2068
|
+
<JsonLd data={{
|
|
2069
|
+
'@context': 'https://schema.org',
|
|
2070
|
+
'@type': 'SoftwareApplication',
|
|
2071
|
+
name: 'Product Name',
|
|
2072
|
+
description: 'Product description.',
|
|
2073
|
+
url: 'https://example.com',
|
|
2074
|
+
applicationCategory: 'BusinessApplication',
|
|
2075
|
+
offers: {
|
|
2076
|
+
'@type': 'Offer',
|
|
2077
|
+
price: '29',
|
|
2078
|
+
priceCurrency: 'USD',
|
|
2079
|
+
},
|
|
2080
|
+
}} />
|
|
2081
|
+
```
|
|
2082
|
+
|
|
2083
|
+
### Semantic HTML in JSX
|
|
2084
|
+
- Use `<header>`, `<main>`, `<section>`, `<article>`, `<footer>` in organisms
|
|
2085
|
+
- Single `<h1>` per page (in HeroSection)
|
|
2086
|
+
- Sequential heading hierarchy: h1 -> h2 -> h3
|
|
2087
|
+
- Use `<nav>` with `aria-label` for navigation blocks
|
|
2088
|
+
- Use `<blockquote>` for testimonials
|
|
2089
|
+
|
|
2090
|
+
---
|
|
2091
|
+
|
|
2092
|
+
## Full Page Assembly Example
|
|
2093
|
+
|
|
2094
|
+
```tsx
|
|
2095
|
+
// app/page.tsx (Next.js) or src/App.tsx (Vite)
|
|
2096
|
+
import Navbar from '@/components/organisms/Navbar';
|
|
2097
|
+
import HeroSection from '@/components/organisms/HeroSection';
|
|
2098
|
+
import FeaturesGrid from '@/components/organisms/FeaturesGrid';
|
|
2099
|
+
import FAQAccordion from '@/components/organisms/FAQAccordion';
|
|
2100
|
+
import CTASection from '@/components/organisms/CTASection';
|
|
2101
|
+
import Footer from '@/components/organisms/Footer';
|
|
2102
|
+
import JsonLd from '@/components/ui/JsonLd';
|
|
2103
|
+
|
|
2104
|
+
const NAV_LINKS = [
|
|
2105
|
+
{ label: 'Features', href: '#features' },
|
|
2106
|
+
{ label: 'Pricing', href: '#pricing' },
|
|
2107
|
+
{ label: 'FAQ', href: '#faq' },
|
|
2108
|
+
];
|
|
2109
|
+
|
|
2110
|
+
export default function LandingPage() {
|
|
2111
|
+
return (
|
|
2112
|
+
<>
|
|
2113
|
+
<JsonLd data={{ '@context': 'https://schema.org', '@type': 'WebSite', name: 'Product' }} />
|
|
2114
|
+
|
|
2115
|
+
<a href="#main-content" className="sr-only focus:not-sr-only">
|
|
2116
|
+
Skip to content
|
|
2117
|
+
</a>
|
|
2118
|
+
|
|
2119
|
+
<Navbar
|
|
2120
|
+
logo={<span>Product</span>}
|
|
2121
|
+
links={NAV_LINKS}
|
|
2122
|
+
ctaLabel="Get Started"
|
|
2123
|
+
ctaHref="#pricing"
|
|
2124
|
+
/>
|
|
2125
|
+
|
|
2126
|
+
<main id="main-content">
|
|
2127
|
+
<HeroSection
|
|
2128
|
+
badge="Now in Beta"
|
|
2129
|
+
title="The headline that hooks visitors"
|
|
2130
|
+
subtitle="A clear explanation of the value you deliver, in one or two sentences."
|
|
2131
|
+
primaryCta="Start Free Trial"
|
|
2132
|
+
primaryHref="/signup"
|
|
2133
|
+
secondaryCta="Watch Demo"
|
|
2134
|
+
secondaryHref="#demo"
|
|
2135
|
+
/>
|
|
2136
|
+
|
|
2137
|
+
<FeaturesGrid
|
|
2138
|
+
badge="Features"
|
|
2139
|
+
title="Everything you need"
|
|
2140
|
+
subtitle="Short supporting text that reinforces the value proposition."
|
|
2141
|
+
features={[
|
|
2142
|
+
{ icon: <span>⚡</span>, title: 'Fast', description: 'Lightning-fast performance.' },
|
|
2143
|
+
{ icon: <span>🔒</span>, title: 'Secure', description: 'Enterprise-grade security.' },
|
|
2144
|
+
{ icon: <span>⚙</span>, title: 'Flexible', description: 'Customize everything.' },
|
|
2145
|
+
]}
|
|
2146
|
+
/>
|
|
2147
|
+
|
|
2148
|
+
<FAQAccordion
|
|
2149
|
+
title="Frequently Asked Questions"
|
|
2150
|
+
items={[
|
|
2151
|
+
{ question: 'How does it work?', answer: 'Clear, concise answer.' },
|
|
2152
|
+
{ question: 'What does it cost?', answer: 'Transparent pricing details.' },
|
|
2153
|
+
]}
|
|
2154
|
+
/>
|
|
2155
|
+
|
|
2156
|
+
<CTASection
|
|
2157
|
+
title="Ready to get started?"
|
|
2158
|
+
subtitle="Join thousands of teams already using Product."
|
|
2159
|
+
primaryLabel="Start Free"
|
|
2160
|
+
primaryHref="/signup"
|
|
2161
|
+
secondaryLabel="Talk to Sales"
|
|
2162
|
+
secondaryHref="/contact"
|
|
2163
|
+
/>
|
|
2164
|
+
</main>
|
|
2165
|
+
|
|
2166
|
+
<Footer
|
|
2167
|
+
logo={<span>Product</span>}
|
|
2168
|
+
columns={[
|
|
2169
|
+
{ title: 'Product', links: [{ label: 'Features', href: '#features' }, { label: 'Pricing', href: '#pricing' }] },
|
|
2170
|
+
{ title: 'Company', links: [{ label: 'About', href: '/about' }, { label: 'Blog', href: '/blog' }] },
|
|
2171
|
+
]}
|
|
2172
|
+
copyright={`\u00A9 ${new Date().getFullYear()} Product. All rights reserved.`}
|
|
2173
|
+
/>
|
|
2174
|
+
</>
|
|
2175
|
+
);
|
|
2176
|
+
}
|
|
2177
|
+
```
|
|
2178
|
+
|
|
2179
|
+
### Section Rhythm
|
|
2180
|
+
|
|
2181
|
+
Follow the same alternating density pattern as the HTML version:
|
|
2182
|
+
|
|
2183
|
+
```
|
|
2184
|
+
[HERO — dark background, dense, above-the-fold]
|
|
2185
|
+
[PROBLEM — light (alt), spacious, breathing room]
|
|
2186
|
+
[SOLUTION — default surface, medium density, alternating columns]
|
|
2187
|
+
[FEATURES — alt surface, grid layout]
|
|
2188
|
+
[TESTIMONIALS/PROOF — dark, full-bleed, contrasting]
|
|
2189
|
+
[PRICING — default surface, card grid]
|
|
2190
|
+
[FAQ — alt surface, accordion]
|
|
2191
|
+
[CTA — dark, gradient, dense]
|
|
2192
|
+
[FOOTER — darkest, links grid]
|
|
2193
|
+
```
|
|
2194
|
+
|
|
2195
|
+
---
|
|
2196
|
+
|
|
2197
|
+
## Quality Checklist
|
|
2198
|
+
|
|
2199
|
+
Before finalizing any React landing page:
|
|
2200
|
+
|
|
2201
|
+
### Design System
|
|
2202
|
+
- [ ] All values use CSS custom properties (zero hardcoded colors, spacing, or font sizes)
|
|
2203
|
+
- [ ] Design tokens match the HTML landing-page best practices exactly
|
|
2204
|
+
- [ ] Typography uses `clamp()` for fluid sizing
|
|
2205
|
+
- [ ] Shadows are multi-layered (min 2 layers)
|
|
2206
|
+
- [ ] Whitespace between sections is generous (80-128px via `--section-py`)
|
|
2207
|
+
- [ ] Max 3 color families used (base + surface + accent)
|
|
2208
|
+
|
|
2209
|
+
### Components
|
|
2210
|
+
- [ ] Components follow Atomic Design (atoms, molecules, organisms)
|
|
2211
|
+
- [ ] All components are typed with TypeScript interfaces
|
|
2212
|
+
- [ ] `forwardRef` used on interactive atoms (Button, Input)
|
|
2213
|
+
- [ ] Components accept `className` prop for composition
|
|
2214
|
+
- [ ] No hardcoded content in organisms — all data via props
|
|
2215
|
+
|
|
2216
|
+
### Accessibility
|
|
2217
|
+
- [ ] Semantic HTML: `<header>`, `<nav>`, `<main>`, `<section>`, `<footer>`
|
|
2218
|
+
- [ ] Single `<h1>`, sequential heading hierarchy
|
|
2219
|
+
- [ ] Skip-to-content link present
|
|
2220
|
+
- [ ] Color contrast: 4.5:1 text, 3:1 UI elements
|
|
2221
|
+
- [ ] `focus-visible` on all interactive elements
|
|
2222
|
+
- [ ] `aria-expanded`, `aria-controls` on accordions and toggles
|
|
2223
|
+
- [ ] `aria-label` on navigation landmarks
|
|
2224
|
+
- [ ] Form inputs have associated `<label>` elements
|
|
2225
|
+
- [ ] `alt` text on all images
|
|
2226
|
+
- [ ] `lang` attribute on `<html>`
|
|
2227
|
+
|
|
2228
|
+
### Animation
|
|
2229
|
+
- [ ] Scroll animations use `IntersectionObserver` (not scroll events)
|
|
2230
|
+
- [ ] Only `opacity` and `transform` are animated
|
|
2231
|
+
- [ ] `prefers-reduced-motion` respected with `@media` query
|
|
2232
|
+
- [ ] Stagger delay capped at ~400ms total
|
|
2233
|
+
- [ ] `will-change` applied to animated elements
|
|
2234
|
+
|
|
2235
|
+
### Performance
|
|
2236
|
+
- [ ] Above-the-fold content renders without JavaScript (SSR or static)
|
|
2237
|
+
- [ ] Below-the-fold sections use `React.lazy` + `Suspense`
|
|
2238
|
+
- [ ] Images: WebP format, lazy loading, explicit `width`/`height` or `aspect-ratio`
|
|
2239
|
+
- [ ] Fonts: max 2 families, `font-display: swap`, preconnected
|
|
2240
|
+
- [ ] Bundle size: no unnecessary dependencies (prefer CSS over JS animations)
|
|
2241
|
+
- [ ] Lighthouse score > 90 in all categories
|
|
2242
|
+
|
|
2243
|
+
### SEO
|
|
2244
|
+
- [ ] `<title>` and `<meta description>` present
|
|
2245
|
+
- [ ] Open Graph tags (title, description, image, url, type)
|
|
2246
|
+
- [ ] Twitter card meta tags
|
|
2247
|
+
- [ ] Canonical URL set
|
|
2248
|
+
- [ ] Structured data (JSON-LD) for product/organization
|
|
2249
|
+
- [ ] Semantic HTML throughout
|
|
2250
|
+
|
|
2251
|
+
### Responsive
|
|
2252
|
+
- [ ] Mobile-first CSS (`min-width` media queries)
|
|
2253
|
+
- [ ] Touch targets minimum 44x44px
|
|
2254
|
+
- [ ] Content stacks vertically on mobile
|
|
2255
|
+
- [ ] Non-essential content hidden on mobile
|
|
2256
|
+
- [ ] Tested at 320px, 768px, 1024px, 1280px widths
|
|
2257
|
+
|
|
2258
|
+
### Final Test
|
|
2259
|
+
- [ ] "Squint test" passes — hierarchy is clear at a glance
|
|
2260
|
+
- [ ] Page loads and is interactive under 3 seconds on 3G
|
|
2261
|
+
- [ ] No layout shifts (CLS < 0.1)
|
|
2262
|
+
- [ ] All links and CTAs work
|
|
2263
|
+
- [ ] Looks correct with and without JavaScript
|