create-velocity-astro 1.0.5 → 1.0.7

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.
@@ -0,0 +1,469 @@
1
+ import { useState } from 'react';
2
+ import {
3
+ Palette,
4
+ Search,
5
+ Zap,
6
+ LayoutGrid,
7
+ Globe,
8
+ Copy,
9
+ Check,
10
+ type LucideIcon,
11
+ } from 'lucide-react';
12
+ import type { Locale } from '@/i18n/config';
13
+ import { en } from '@/i18n/translations/en';
14
+ import { es } from '@/i18n/translations/es';
15
+ import { fr } from '@/i18n/translations/fr';
16
+
17
+ // Get translations for a specific locale
18
+ function getTranslations(locale: Locale) {
19
+ const translations = { en, es, fr };
20
+ return translations[locale] || translations.en;
21
+ }
22
+
23
+ interface Tab {
24
+ id: string;
25
+ icon: LucideIcon;
26
+ }
27
+
28
+ interface Props {
29
+ locale?: Locale;
30
+ }
31
+
32
+ const codeExamples: Record<
33
+ string,
34
+ { code: string; filename: string; lang: 'css' | 'astro' | 'typescript' | 'javascript' }
35
+ > = {
36
+ design: {
37
+ lang: 'css',
38
+ code: `/* src/styles/theme.css */
39
+ @theme {
40
+ --font-display: 'Outfit', sans-serif;
41
+ --font-body: 'Manrope', sans-serif;
42
+
43
+ --color-brand-500: oklch(0.623 0.214 25.667);
44
+ --color-brand-600: oklch(0.553 0.201 25.667);
45
+
46
+ --radius-sm: 0.25rem;
47
+ --radius-md: 0.5rem;
48
+ --radius-lg: 0.75rem;
49
+ }
50
+
51
+ /* Usage in components */
52
+ .btn-primary {
53
+ background: var(--color-brand-500);
54
+ font-family: var(--font-display);
55
+ }`,
56
+ filename: 'src/styles/theme.css',
57
+ },
58
+ seo: {
59
+ lang: 'astro',
60
+ code: `---
61
+ // src/layouts/Layout.astro
62
+ interface Props {
63
+ title: string;
64
+ description?: string;
65
+ image?: string;
66
+ }
67
+
68
+ const { title, description, image } = Astro.props;
69
+ const canonicalURL = new URL(Astro.url.pathname, Astro.site);
70
+ ---
71
+
72
+ <head>
73
+ <title>{title} | Velocity</title>
74
+ <meta name="description" content={description} />
75
+ <link rel="canonical" href={canonicalURL} />
76
+ <meta property="og:image" content={image} />
77
+ </head>`,
78
+ filename: 'src/layouts/Layout.astro',
79
+ },
80
+ perf: {
81
+ lang: 'astro',
82
+ code: `---
83
+ // src/pages/index.astro
84
+ import Hero from '../components/Hero.astro';
85
+ import Calculator from '../components/Calculator.tsx';
86
+ ---
87
+
88
+ <!-- Static HTML (0kb JS) -->
89
+ <Hero />
90
+
91
+ <!-- Hydrates only when visible -->
92
+ <Calculator client:visible />
93
+
94
+ <script>
95
+ // Optional: Performance observer
96
+ const observer = new PerformanceObserver((list) => {
97
+ list.getEntries().forEach(console.log);
98
+ });
99
+ observer.observe({ entryTypes: ['lcp'] });
100
+ </script>`,
101
+ filename: 'src/pages/index.astro',
102
+ },
103
+ components: {
104
+ lang: 'typescript',
105
+ code: `// src/components/ui/Button.tsx
106
+ import { cn } from '@/lib/cn';
107
+
108
+ interface ButtonProps {
109
+ variant?: 'primary' | 'secondary' | 'ghost';
110
+ size?: 'sm' | 'md' | 'lg';
111
+ children: React.ReactNode;
112
+ onClick?: () => void;
113
+ }
114
+
115
+ export function Button({
116
+ variant = 'primary',
117
+ size = 'md',
118
+ children,
119
+ onClick
120
+ }: ButtonProps) {
121
+ return (
122
+ <button
123
+ onClick={onClick}
124
+ className={cn(
125
+ 'font-medium rounded-md transition-colors',
126
+ variants[variant],
127
+ sizes[size]
128
+ )}
129
+ >
130
+ {children}
131
+ </button>
132
+ );
133
+ }`,
134
+ filename: 'src/components/ui/Button.tsx',
135
+ },
136
+ i18n: {
137
+ lang: 'typescript',
138
+ code: `// src/i18n/config.ts
139
+ export const languages = {
140
+ en: 'English',
141
+ es: 'Español',
142
+ fr: 'Français',
143
+ } as const;
144
+
145
+ export const defaultLang = 'en';
146
+
147
+ // src/i18n/translations.ts
148
+ export const translations = {
149
+ en: {
150
+ 'nav.home': 'Home',
151
+ 'nav.about': 'About',
152
+ 'hero.title': 'Ship faster with Velocity',
153
+ },
154
+ es: {
155
+ 'nav.home': 'Inicio',
156
+ 'nav.about': 'Acerca de',
157
+ 'hero.title': 'Envía más rápido con Velocity',
158
+ },
159
+ } as const;
160
+
161
+ // Usage: t('hero.title') → "Ship faster..."`,
162
+ filename: 'src/i18n/config.ts',
163
+ },
164
+ };
165
+
166
+ // Simple syntax highlighter
167
+ function highlightCode(code: string, lang: string): React.ReactNode[] {
168
+ const lines = code.split('\n');
169
+
170
+ return lines.map((line, lineIndex) => {
171
+ const tokens: React.ReactNode[] = [];
172
+ let remaining = line;
173
+ let keyIndex = 0;
174
+
175
+ const addToken = (text: string, className?: string) => {
176
+ if (text) {
177
+ tokens.push(
178
+ <span key={`${lineIndex}-${keyIndex++}`} className={className}>
179
+ {text}
180
+ </span>
181
+ );
182
+ }
183
+ };
184
+
185
+ // Process the line character by character with regex patterns
186
+ while (remaining.length > 0) {
187
+ let matched = false;
188
+
189
+ // Comments (// and /* */)
190
+ const commentMatch = remaining.match(/^(\/\/.*|\/\*[\s\S]*?\*\/)/);
191
+ if (commentMatch) {
192
+ addToken(commentMatch[0], 'text-foreground-muted italic');
193
+ remaining = remaining.slice(commentMatch[0].length);
194
+ matched = true;
195
+ continue;
196
+ }
197
+
198
+ // HTML comments
199
+ const htmlCommentMatch = remaining.match(/^(<!--[\s\S]*?-->)/);
200
+ if (htmlCommentMatch) {
201
+ addToken(htmlCommentMatch[0], 'text-foreground-muted italic');
202
+ remaining = remaining.slice(htmlCommentMatch[0].length);
203
+ matched = true;
204
+ continue;
205
+ }
206
+
207
+ // Strings (single, double, template)
208
+ const stringMatch = remaining.match(/^(['"`])(?:(?!\1)[^\\]|\\.)*\1/);
209
+ if (stringMatch) {
210
+ addToken(stringMatch[0], 'text-green-600 dark:text-green-400');
211
+ remaining = remaining.slice(stringMatch[0].length);
212
+ matched = true;
213
+ continue;
214
+ }
215
+
216
+ // Astro frontmatter delimiters
217
+ if (remaining.startsWith('---')) {
218
+ addToken('---', 'text-purple-600 dark:text-purple-400 font-semibold');
219
+ remaining = remaining.slice(3);
220
+ matched = true;
221
+ continue;
222
+ }
223
+
224
+ // HTML/JSX tags
225
+ const tagMatch = remaining.match(/^(<\/?[\w-]+|>|\/>)/);
226
+ if (tagMatch) {
227
+ addToken(tagMatch[0], 'text-pink-600 dark:text-pink-400');
228
+ remaining = remaining.slice(tagMatch[0].length);
229
+ matched = true;
230
+ continue;
231
+ }
232
+
233
+ // CSS at-rules (@theme, @import, etc.)
234
+ const atRuleMatch = remaining.match(/^(@[\w-]+)/);
235
+ if (atRuleMatch) {
236
+ addToken(atRuleMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
237
+ remaining = remaining.slice(atRuleMatch[0].length);
238
+ matched = true;
239
+ continue;
240
+ }
241
+
242
+ // Keywords
243
+ const keywordMatch = remaining.match(
244
+ /^(const|let|var|function|return|import|export|from|interface|type|class|extends|implements|new|async|await|if|else|for|while|switch|case|break|default|try|catch|finally|throw|typeof|instanceof|in|of|as|readonly|public|private|protected)\b/
245
+ );
246
+ if (keywordMatch) {
247
+ addToken(keywordMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
248
+ remaining = remaining.slice(keywordMatch[0].length);
249
+ matched = true;
250
+ continue;
251
+ }
252
+
253
+ // Boolean/null
254
+ const boolMatch = remaining.match(/^(true|false|null|undefined)\b/);
255
+ if (boolMatch) {
256
+ addToken(boolMatch[0], 'text-orange-600 dark:text-orange-400');
257
+ remaining = remaining.slice(boolMatch[0].length);
258
+ matched = true;
259
+ continue;
260
+ }
261
+
262
+ // Numbers
263
+ const numberMatch = remaining.match(/^(\d+\.?\d*)/);
264
+ if (numberMatch) {
265
+ addToken(numberMatch[0], 'text-orange-600 dark:text-orange-400');
266
+ remaining = remaining.slice(numberMatch[0].length);
267
+ matched = true;
268
+ continue;
269
+ }
270
+
271
+ // CSS properties (word followed by colon)
272
+ const cssPropMatch = remaining.match(/^([\w-]+)(:)/);
273
+ if (cssPropMatch && (lang === 'css' || line.includes('{'))) {
274
+ addToken(cssPropMatch[1], 'text-blue-600 dark:text-blue-400');
275
+ addToken(cssPropMatch[2], 'text-foreground-secondary');
276
+ remaining = remaining.slice(cssPropMatch[0].length);
277
+ matched = true;
278
+ continue;
279
+ }
280
+
281
+ // CSS functions (var, oklch, etc.)
282
+ const cssFuncMatch = remaining.match(
283
+ /^(var|oklch|rgb|rgba|hsl|hsla|calc|url|clamp|min|max)(\()/
284
+ );
285
+ if (cssFuncMatch) {
286
+ addToken(cssFuncMatch[1], 'text-yellow-600 dark:text-yellow-400');
287
+ addToken(cssFuncMatch[2], 'text-foreground-secondary');
288
+ remaining = remaining.slice(cssFuncMatch[0].length);
289
+ matched = true;
290
+ continue;
291
+ }
292
+
293
+ // Function calls
294
+ const funcMatch = remaining.match(/^([\w]+)(\()/);
295
+ if (funcMatch) {
296
+ addToken(funcMatch[1], 'text-yellow-600 dark:text-yellow-400');
297
+ addToken(funcMatch[2], 'text-foreground-secondary');
298
+ remaining = remaining.slice(funcMatch[0].length);
299
+ matched = true;
300
+ continue;
301
+ }
302
+
303
+ // Type annotations after colon
304
+ const typeMatch = remaining.match(/^(:\s*)([\w<>\[\]|&]+)/);
305
+ if (typeMatch) {
306
+ addToken(typeMatch[1], 'text-foreground-secondary');
307
+ addToken(typeMatch[2], 'text-cyan-600 dark:text-cyan-400');
308
+ remaining = remaining.slice(typeMatch[0].length);
309
+ matched = true;
310
+ continue;
311
+ }
312
+
313
+ // Default: single character
314
+ if (!matched) {
315
+ addToken(remaining[0], 'text-foreground-secondary');
316
+ remaining = remaining.slice(1);
317
+ }
318
+ }
319
+
320
+ return tokens.length > 0 ? tokens : [<span key={lineIndex}> </span>];
321
+ });
322
+ }
323
+
324
+ function CodeBlock({ code, filename, lang, copyText, copiedText }: { code: string; filename: string; lang: string; copyText: string; copiedText: string }) {
325
+ const [copied, setCopied] = useState(false);
326
+ const highlightedLines = highlightCode(code.trim(), lang);
327
+
328
+ const handleCopy = async () => {
329
+ await navigator.clipboard.writeText(code);
330
+ setCopied(true);
331
+ setTimeout(() => setCopied(false), 2000);
332
+ };
333
+
334
+ return (
335
+ <div className="group border-border bg-background-secondary relative w-full overflow-hidden rounded-md border font-mono text-xs shadow-sm">
336
+ {/* Header */}
337
+ <div className="border-border bg-background flex items-center justify-between border-b px-4 py-2">
338
+ <div className="flex items-center gap-3">
339
+ <div className="flex gap-1.5">
340
+ <div className="bg-border-strong h-2 w-2 rounded-full" />
341
+ <div className="bg-border-strong h-2 w-2 rounded-full" />
342
+ <div className="bg-border-strong h-2 w-2 rounded-full" />
343
+ </div>
344
+ <span className="text-foreground-muted font-sans text-[10px] font-medium">
345
+ {filename}
346
+ </span>
347
+ </div>
348
+ <button
349
+ onClick={handleCopy}
350
+ className="text-foreground-muted hover:bg-secondary hover:text-foreground flex items-center gap-1.5 rounded px-2 py-0.5 text-[10px] font-medium transition-colors"
351
+ >
352
+ {copied ? (
353
+ <>
354
+ <Check className="h-3 w-3 text-green-600" strokeWidth={2} />
355
+ <span className="text-green-600">{copiedText}</span>
356
+ </>
357
+ ) : (
358
+ <>
359
+ <Copy className="h-3 w-3" strokeWidth={2} />
360
+ <span>{copyText}</span>
361
+ </>
362
+ )}
363
+ </button>
364
+ </div>
365
+
366
+ {/* Code Area */}
367
+ <div className="bg-background overflow-x-auto p-3">
368
+ <pre className="flex flex-col leading-5">
369
+ {highlightedLines.map((lineTokens, i) => (
370
+ <div key={i} className="table-row">
371
+ <span className="text-foreground-subtle table-cell w-6 pr-3 text-right text-[10px] select-none">
372
+ {i + 1}
373
+ </span>
374
+ <span className="table-cell whitespace-pre">{lineTokens}</span>
375
+ </div>
376
+ ))}
377
+ </pre>
378
+ </div>
379
+ </div>
380
+ );
381
+ }
382
+
383
+ export function FeatureTabs({ locale = 'en' }: Props) {
384
+ const [activeTab, setActiveTab] = useState('design');
385
+ const t = getTranslations(locale);
386
+
387
+ const tabs: Tab[] = [
388
+ { id: 'design', icon: Palette },
389
+ { id: 'seo', icon: Search },
390
+ { id: 'perf', icon: Zap },
391
+ { id: 'components', icon: LayoutGrid },
392
+ { id: 'i18n', icon: Globe },
393
+ ];
394
+
395
+ // Get translated content for the active tab
396
+ const activeTabData = t.features.tabs[activeTab as keyof typeof t.features.tabs];
397
+ const activeCodeExample = codeExamples[activeTab];
398
+
399
+ return (
400
+ <section id="features" className="bg-background py-24">
401
+ <div className="mx-auto max-w-6xl px-6">
402
+ {/* Header */}
403
+ <div className="mb-16">
404
+ <h2 className="font-display text-foreground text-3xl font-bold md:text-4xl">
405
+ {t.features.sectionTitle}
406
+ <br />
407
+ <span className="text-brand-500">{t.features.sectionTitleHighlight}</span>
408
+ </h2>
409
+ <p className="text-foreground-muted mt-4 max-w-2xl text-lg">
410
+ {t.features.sectionDescription}
411
+ </p>
412
+ </div>
413
+
414
+ {/* Content Grid */}
415
+ <div className="grid grid-cols-1 gap-12 lg:grid-cols-12">
416
+ {/* Sidebar */}
417
+ <div className="flex flex-col gap-2 lg:col-span-4">
418
+ {tabs.map((tab) => {
419
+ const tabData = t.features.tabs[tab.id as keyof typeof t.features.tabs];
420
+ return (
421
+ <button
422
+ key={tab.id}
423
+ onClick={() => setActiveTab(tab.id)}
424
+ className={`group flex flex-col items-start rounded-md p-4 text-left transition-all ${
425
+ activeTab === tab.id
426
+ ? 'bg-secondary ring-border shadow-sm ring-1'
427
+ : 'hover:bg-background-secondary hover:pl-5'
428
+ }`}
429
+ >
430
+ <span
431
+ className={`font-display flex items-center gap-2 text-base font-bold ${
432
+ activeTab === tab.id
433
+ ? 'text-brand-600 dark:text-brand-400'
434
+ : 'text-foreground group-hover:text-brand-600 dark:group-hover:text-brand-400'
435
+ }`}
436
+ >
437
+ <tab.icon
438
+ className={`h-5 w-5 ${activeTab === tab.id ? 'text-brand-500' : 'text-foreground-subtle group-hover:text-brand-500'}`}
439
+ strokeWidth={2}
440
+ />
441
+ {tabData.label}
442
+ </span>
443
+ <span className="text-foreground-muted mt-1 pl-7 text-sm">{tabData.desc}</span>
444
+ </button>
445
+ );
446
+ })}
447
+ </div>
448
+
449
+ {/* Content Preview */}
450
+ <div className="lg:col-span-8">
451
+ <div className="mb-6">
452
+ <h3 className="text-foreground text-xl font-bold">{activeTabData.title}</h3>
453
+ <p className="text-foreground-muted mt-2">{activeTabData.content}</p>
454
+ </div>
455
+ <CodeBlock
456
+ code={activeCodeExample.code}
457
+ filename={activeCodeExample.filename}
458
+ lang={activeCodeExample.lang}
459
+ copyText={t.common.copy}
460
+ copiedText={t.common.copied}
461
+ />
462
+ </div>
463
+ </div>
464
+ </div>
465
+ </section>
466
+ );
467
+ }
468
+
469
+ export default FeatureTabs;
@@ -0,0 +1,89 @@
1
+ ---
2
+ import Button from '@/components/ui/Button.astro';
3
+ import Icon from '@/components/ui/Icon.astro';
4
+ import TerminalDemo from '@/components/ui/TerminalDemo.tsx';
5
+ import { type Locale, defaultLocale } from '@/i18n/config';
6
+ import { useTranslations } from '@/i18n/index';
7
+
8
+ interface Props {
9
+ locale?: Locale;
10
+ }
11
+
12
+ const { locale = defaultLocale } = Astro.props;
13
+ const t = useTranslations(locale);
14
+ ---
15
+
16
+ <section class="relative overflow-hidden pt-32 pb-20 md:pt-40 md:pb-32">
17
+ <!-- Background texture -->
18
+ <div
19
+ class="bg-grid-pattern pointer-events-none absolute inset-0 [mask-image:linear-gradient(to_bottom,white,transparent)] opacity-40"
20
+ >
21
+ </div>
22
+
23
+ <div class="mx-auto grid max-w-6xl grid-cols-1 items-center gap-12 px-6 lg:grid-cols-2 lg:gap-20">
24
+ <!-- Content -->
25
+ <div class="z-10 flex flex-col items-start text-left">
26
+ <!-- Badge -->
27
+ <div
28
+ class="border-border bg-background mb-6 inline-flex items-center rounded-full border px-3 py-1 shadow-sm"
29
+ >
30
+ <span class="bg-brand-500 mr-2 flex h-2 w-2 animate-pulse rounded-full"></span>
31
+ <span class="text-foreground-secondary text-xs font-medium">{t('hero.badge')}</span>
32
+ </div>
33
+
34
+ <!-- Headline -->
35
+ <h1
36
+ class="font-display text-foreground mb-6 text-5xl leading-[1.1] font-bold tracking-tight text-balance md:text-6xl lg:text-7xl"
37
+ >
38
+ {t('hero.title')}
39
+ <span class="text-brand-500">{t('hero.titleHighlight')}</span>
40
+ </h1>
41
+
42
+ <!-- Description -->
43
+ <p class="text-foreground-muted mb-8 max-w-xl text-lg leading-relaxed">
44
+ {t('hero.description')}
45
+ </p>
46
+
47
+ <!-- CTAs -->
48
+ <div class="flex w-full flex-col gap-4 sm:w-auto sm:flex-row">
49
+ <Button size="lg" href="#cta">
50
+ {t('hero.cta')}
51
+ <Icon name="arrow-right" size="sm" />
52
+ </Button>
53
+ <Button
54
+ size="lg"
55
+ variant="outline"
56
+ href="https://github.com/southwell-media/velocity"
57
+ target="_blank"
58
+ >
59
+ <Icon name="github" size="sm" />
60
+ {t('hero.github')}
61
+ </Button>
62
+ </div>
63
+
64
+ <!-- Social Proof -->
65
+ <div class="text-foreground-subtle mt-8 flex items-center gap-4 text-sm">
66
+ <div class="flex -space-x-2">
67
+ {
68
+ [1, 2, 3].map((i) => (
69
+ <div class="border-background bg-border dark:bg-background-tertiary text-foreground-muted flex h-8 w-8 items-center justify-center rounded-full border-2 text-[10px] font-bold">
70
+ {i === 3 ? '+' : ''}
71
+ </div>
72
+ ))
73
+ }
74
+ </div>
75
+ <p>{t('hero.socialProof')}</p>
76
+ </div>
77
+ </div>
78
+
79
+ <!-- Terminal Demo -->
80
+ <div class="relative z-10 w-full">
81
+ <TerminalDemo client:load />
82
+ <!-- Decorative blob -->
83
+ <div
84
+ class="bg-brand-200/20 pointer-events-none absolute -top-20 -right-20 h-64 w-64 rounded-full blur-3xl"
85
+ >
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </section>
@@ -0,0 +1,49 @@
1
+ ---
2
+ import { type Locale, defaultLocale, localePath } from '@/i18n/config';
3
+ import { useTranslations } from '@/i18n/index';
4
+
5
+ interface Props {
6
+ locale?: Locale;
7
+ }
8
+
9
+ const { locale = defaultLocale } = Astro.props;
10
+ const t = useTranslations(locale);
11
+
12
+ const links = [
13
+ { labelKey: 'footer.links.documentation' as const, href: localePath('/blog', locale) },
14
+ { labelKey: 'footer.links.github' as const, href: 'https://github.com/southwell-media/velocity' },
15
+ { labelKey: 'footer.links.twitter' as const, href: 'https://twitter.com/southwellmedia' },
16
+ { labelKey: 'footer.links.license' as const, href: 'https://github.com/southwell-media/velocity/blob/main/LICENSE' },
17
+ ];
18
+
19
+ const homeLink = locale === defaultLocale ? '/' : `/${locale}`;
20
+ ---
21
+
22
+ <footer class="border-t border-border bg-background-secondary py-12">
23
+ <div class="mx-auto max-w-6xl px-6 flex flex-col md:flex-row justify-between items-center gap-6">
24
+ <!-- Logo & Attribution -->
25
+ <div class="flex flex-col gap-2">
26
+ <a href={homeLink} class="flex items-center gap-2">
27
+ <div class="h-5 w-5 bg-brand-500 rounded-sm"></div>
28
+ <span class="font-display text-lg font-bold text-foreground">Velocity</span>
29
+ </a>
30
+ <p class="text-sm text-foreground-subtle">
31
+ {t('footer.maintainedBy')} <a href="https://southwellmedia.com" class="underline hover:text-foreground transition-colors">Southwell Media</a>.
32
+ </p>
33
+ </div>
34
+
35
+ <!-- Links -->
36
+ <nav class="flex gap-8 text-sm text-foreground-muted font-medium">
37
+ {links.map((link) => (
38
+ <a
39
+ href={link.href}
40
+ class="hover:text-foreground transition-colors"
41
+ target={link.href.startsWith('http') ? '_blank' : undefined}
42
+ rel={link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
43
+ >
44
+ {t(link.labelKey)}
45
+ </a>
46
+ ))}
47
+ </nav>
48
+ </div>
49
+ </footer>
@@ -0,0 +1,53 @@
1
+ ---
2
+ import Icon from '@/components/ui/Icon.astro';
3
+ import { type Locale, defaultLocale } from '@/i18n/config';
4
+ import { useTranslations } from '@/i18n/index';
5
+
6
+ interface Props {
7
+ locale?: Locale;
8
+ }
9
+
10
+ const { locale = defaultLocale } = Astro.props;
11
+ const t = useTranslations(locale);
12
+
13
+ const stack = [
14
+ {
15
+ nameKey: 'techStack.astro.name' as const,
16
+ descKey: 'techStack.astro.desc' as const,
17
+ icon: 'zap' as const,
18
+ },
19
+ {
20
+ nameKey: 'techStack.tailwind.name' as const,
21
+ descKey: 'techStack.tailwind.desc' as const,
22
+ icon: 'layout' as const,
23
+ },
24
+ {
25
+ nameKey: 'techStack.typescript.name' as const,
26
+ descKey: 'techStack.typescript.desc' as const,
27
+ icon: 'shield' as const,
28
+ },
29
+ {
30
+ nameKey: 'techStack.motion.name' as const,
31
+ descKey: 'techStack.motion.desc' as const,
32
+ icon: 'box' as const,
33
+ },
34
+ ];
35
+ ---
36
+
37
+ <section class="border-y border-border bg-background-secondary py-12">
38
+ <div class="mx-auto max-w-6xl px-6">
39
+ <div class="grid grid-cols-2 gap-8 md:grid-cols-4">
40
+ {stack.map((item) => (
41
+ <div class="flex flex-col items-center gap-2 text-center md:items-start md:text-left">
42
+ <div class="flex items-center gap-2 text-foreground font-display font-bold text-lg">
43
+ <span class="text-brand-500">
44
+ <Icon name={item.icon} size="md" />
45
+ </span>
46
+ {t(item.nameKey)}
47
+ </div>
48
+ <p class="text-sm text-foreground-muted font-medium">{t(item.descKey)}</p>
49
+ </div>
50
+ ))}
51
+ </div>
52
+ </div>
53
+ </section>