Initial release — Astro Rocket v1.0.0
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import CTASection from '@/components/ui/marketing/CTA/CTA.astro';
|
||||
import Button from '@/components/ui/form/Button/Button.astro';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
|
||||
import NpmCopyButton from '@/components/ui/marketing/NpmCopyButton/NpmCopyButton.astro';
|
||||
---
|
||||
|
||||
<CTASection id="cta" variant="default" size="xl" maxWidth="lg">
|
||||
<Logo slot="logo" size="2xl" class="mx-auto" />
|
||||
|
||||
<h2 slot="heading">
|
||||
Stop configuring. <span class="text-brand-500">Start building.</span>
|
||||
</h2>
|
||||
|
||||
<p slot="description">
|
||||
Join the developers building faster, better websites with Astro Rocket. Open source and free forever.
|
||||
</p>
|
||||
|
||||
<Fragment slot="actions">
|
||||
<NpmCopyButton command="npm create astro@latest" />
|
||||
<Button variant="outline" size="lg" href="https://github.com/hansmartens68/Astro-Rocket#readme" target="_blank">
|
||||
<Icon name="book" size="md" />
|
||||
View docs
|
||||
</Button>
|
||||
</Fragment>
|
||||
</CTASection>
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
|
||||
const steps = [
|
||||
{ cmd: 'git clone https://github.com/hansmartens68/Astro-Rocket.git', desc: 'Clone the repository' },
|
||||
{ cmd: 'cd Astro-Rocket && pnpm install', desc: 'Install dependencies' },
|
||||
{ cmd: 'pnpm dev', desc: 'Start dev server on localhost:4321' },
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ value: '3', label: 'steps to start' },
|
||||
{ value: '57+', label: 'components included' },
|
||||
{ value: '40+', label: 'pages & layouts' },
|
||||
];
|
||||
---
|
||||
|
||||
<section id="architecture" class="invert-section bg-background py-[var(--space-section-md)]">
|
||||
<div class="mx-auto grid max-w-6xl grid-cols-1 items-center gap-[var(--space-section-gap)] px-6 lg:grid-cols-2 lg:gap-[var(--space-section-gap)]">
|
||||
<!-- Left Column - Content -->
|
||||
<div>
|
||||
<!-- Badge -->
|
||||
<div
|
||||
class="text-brand-500 mb-4 flex items-center gap-2 text-sm font-bold tracking-wide uppercase"
|
||||
>
|
||||
<Icon name="terminal" size="sm" />
|
||||
<span>Up and running fast</span>
|
||||
</div>
|
||||
|
||||
<h2 class="font-display mb-[var(--space-heading-gap)] text-3xl font-bold text-balance md:text-4xl">
|
||||
Clone, install, <span class="text-brand-500">ship.</span>
|
||||
</h2>
|
||||
|
||||
<div class="text-foreground-secondary space-y-4 text-lg leading-relaxed">
|
||||
<p>
|
||||
Astro Rocket is a ready-to-go starter — no CLI wizard, no configuration maze. Clone the repo and you have a full production-grade site in minutes.
|
||||
</p>
|
||||
<p>
|
||||
Swap out the content, adjust the design tokens, and deploy. Everything is already wired: routing, components, blog, i18n support, dark mode, and SEO.
|
||||
</p>
|
||||
<p class="text-foreground-muted">
|
||||
Open source. No licence fees. No hidden dependencies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="border-border mt-[var(--space-stack-lg)] grid grid-cols-3 gap-[var(--space-stack-lg)] border-t pt-[var(--space-stack-lg)]">
|
||||
{
|
||||
stats.map((stat) => (
|
||||
<div>
|
||||
<div class="font-display text-foreground text-3xl font-bold">{stat.value}</div>
|
||||
<div class="text-foreground-muted text-sm">{stat.label}</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Terminal Card -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="bg-card border-border overflow-hidden rounded-lg border shadow-xl"
|
||||
>
|
||||
<!-- Terminal Header -->
|
||||
<div
|
||||
class="bg-secondary border-border flex items-center gap-2 border-b px-4 py-3"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: var(--error);"></span>
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: var(--warning);"></span>
|
||||
<span class="h-3 w-3 rounded-full" style="background-color: var(--success);"></span>
|
||||
</div>
|
||||
<span class="text-foreground-muted ml-2 font-mono text-xs">terminal</span>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Body -->
|
||||
<div class="p-5 font-mono text-sm leading-relaxed">
|
||||
{
|
||||
steps.map((step, i) => (
|
||||
<div class="mb-4">
|
||||
<div class="text-foreground-muted mb-1 text-xs">#{i + 1} {step.desc}</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="shrink-0 text-green-400 select-none">$</span>
|
||||
<span class="text-cyan-400 break-all">{step.cmd}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
<!-- Output -->
|
||||
<div class="border-border mt-2 border-t pt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-green-400">✓</span>
|
||||
<span class="text-foreground">Ready at <span class="text-cyan-400">localhost:4321</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decorative glow -->
|
||||
<div
|
||||
class="bg-brand-500/10 pointer-events-none absolute top-0 right-0 h-32 w-32 rounded-full blur-3xl"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,496 @@
|
||||
import { useState } from 'react';
|
||||
import { Palette, Search, Zap, LayoutGrid, Globe, Copy, Check, Newspaper } from 'lucide-react';
|
||||
import { VerticalTabs, type VerticalTab } from '@/components/ui/overlay/VerticalTabs';
|
||||
|
||||
interface TabContent {
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const tabContent: Record<string, TabContent> = {
|
||||
theming: {
|
||||
title: 'Design Tokens & Dark Mode',
|
||||
content:
|
||||
"Complete design system using Tailwind v4's CSS-first configuration with built-in dark mode. Semantic color tokens, system preference detection, and localStorage persistence.",
|
||||
},
|
||||
seo: {
|
||||
title: 'Automated SEO Handling',
|
||||
content:
|
||||
'Strictly typed metadata injection for every page with automatic OG image generation. Includes sitemap, robots.txt, and JSON-LD structured data.',
|
||||
},
|
||||
perf: {
|
||||
title: 'Zero JS by Default',
|
||||
content:
|
||||
"Astro's island architecture ensures your pages ship 0kb of JavaScript unless explicitly interactive. Optimized for Core Web Vitals.",
|
||||
},
|
||||
components: {
|
||||
title: 'Type-Safe Components',
|
||||
content:
|
||||
'TypeScript-first UI primitives with full prop validation and IDE autocompletion. Includes buttons, inputs, cards, modals, and more.',
|
||||
},
|
||||
i18n: {
|
||||
title: 'i18n Ready',
|
||||
content:
|
||||
'Add multi-language support with the --i18n flag. Includes type-safe translations, automatic locale detection, and SEO-friendly URL structures.',
|
||||
},
|
||||
content: {
|
||||
title: 'Content & Search',
|
||||
content:
|
||||
'Type-safe content collections with Zod schemas, MDX support, RSS feeds, and Pagefind integration for lightning-fast static search.',
|
||||
},
|
||||
};
|
||||
|
||||
const codeExamples: Record<
|
||||
string,
|
||||
{ code: string; filename: string; lang: 'css' | 'astro' | 'typescript' | 'javascript' }
|
||||
> = {
|
||||
theming: {
|
||||
lang: 'css',
|
||||
code: `/* src/styles/themes/default.css — swap this file to re-theme */
|
||||
:root {
|
||||
/* Semantic Tokens - Light Mode */
|
||||
--background: var(--gray-0);
|
||||
--foreground: var(--gray-900);
|
||||
--border: var(--gray-200);
|
||||
--primary: var(--gray-900);
|
||||
--primary-foreground: var(--gray-0);
|
||||
--accent: var(--brand-500);
|
||||
--card: var(--gray-0);
|
||||
--ring: var(--gray-900);
|
||||
}
|
||||
|
||||
/* Dark Mode */
|
||||
.dark {
|
||||
--background: var(--gray-950);
|
||||
--foreground: var(--gray-50);
|
||||
--border: var(--gray-800);
|
||||
--primary: var(--gray-0);
|
||||
--primary-foreground: var(--gray-900);
|
||||
}`,
|
||||
filename: 'src/styles/themes/default.css',
|
||||
},
|
||||
seo: {
|
||||
lang: 'astro',
|
||||
code: `---
|
||||
// src/components/seo/SEO.astro
|
||||
import siteConfig from '@/config/site.config';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const { title, description, image } = Astro.props;
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
// Auto-generate OG image if none provided
|
||||
const ogImage = image || \`/og/\${Astro.url.pathname}.png\`;
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalURL.toString()} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:image" content={ogImage} />`,
|
||||
filename: 'src/components/seo/SEO.astro',
|
||||
},
|
||||
perf: {
|
||||
lang: 'astro',
|
||||
code: `---
|
||||
// src/pages/index.astro
|
||||
import LandingLayout from '@/layouts/LandingLayout.astro';
|
||||
import { Hero } from '@/components/hero';
|
||||
import { TerminalDemo } from '@/components/ui/marketing/TerminalDemo';
|
||||
import FeatureTabs from '@/components/landing/FeatureTabs.tsx';
|
||||
import TechStack from '@/components/landing/TechStack.astro';
|
||||
---
|
||||
|
||||
<!-- Static Astro components - ships 0kb JS -->
|
||||
<Hero layout="split" size="lg">
|
||||
<!-- React component - hydrates immediately -->
|
||||
<TerminalDemo slot="aside" client:load />
|
||||
</Hero>
|
||||
|
||||
<!-- Static HTML, no JS -->
|
||||
<TechStack />
|
||||
|
||||
<!-- React component - hydrates when scrolled into view -->
|
||||
<FeatureTabs client:visible />`,
|
||||
filename: 'src/pages/index.astro',
|
||||
},
|
||||
components: {
|
||||
lang: 'typescript',
|
||||
code: `// src/components/ui/form/Button/Button.tsx
|
||||
import { type Ref } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { isExternalUrl } from '@/lib/utils';
|
||||
import { buttonVariants, type ButtonVariants } from './button.variants';
|
||||
|
||||
interface BaseProps {
|
||||
ref?: Ref<HTMLButtonElement | HTMLAnchorElement>;
|
||||
variant?: ButtonVariants['variant'];
|
||||
size?: ButtonVariants['size'];
|
||||
loading?: boolean;
|
||||
href?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({ ref, variant = 'primary', size = 'md', href, ...rest }: BaseProps) {
|
||||
const classes = cn(buttonVariants({ variant, size }), rest.className);
|
||||
const isExternal = href ? isExternalUrl(href) : false;
|
||||
|
||||
if (href) {
|
||||
return <a ref={ref} href={href} className={classes} target={isExternal ? '_blank' : undefined} />;
|
||||
}
|
||||
return <button ref={ref} className={classes} {...rest} />;
|
||||
}`,
|
||||
filename: 'src/components/ui/form/Button/Button.tsx',
|
||||
},
|
||||
i18n: {
|
||||
lang: 'typescript',
|
||||
code: `// src/i18n/config.ts (with --i18n flag)
|
||||
export const languages = {
|
||||
en: 'English',
|
||||
es: 'Español',
|
||||
fr: 'Français',
|
||||
} as const;
|
||||
|
||||
export const defaultLang = 'en';
|
||||
|
||||
// src/i18n/translations/en.ts
|
||||
export default {
|
||||
'nav.home': 'Home',
|
||||
'nav.about': 'About',
|
||||
'hero.title': 'Ship faster with Astro Rocket',
|
||||
'hero.subtitle': 'The modern Astro starter',
|
||||
} as const;
|
||||
|
||||
// Usage in components
|
||||
import { t } from '@/i18n';
|
||||
const title = t('hero.title'); // "Ship faster..."`,
|
||||
filename: 'src/i18n/config.ts',
|
||||
},
|
||||
content: {
|
||||
lang: 'typescript',
|
||||
code: `// src/content.config.ts
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
import { glob } from 'astro/loaders';
|
||||
|
||||
const blog = defineCollection({
|
||||
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/blog' }),
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string().max(100),
|
||||
description: z.string().max(200),
|
||||
publishedAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date().optional(),
|
||||
author: z.string().default('Team'),
|
||||
image: image().optional(),
|
||||
tags: z.array(z.string()).default([]),
|
||||
featured: z.boolean().default(false),
|
||||
draft: z.boolean().default(false),
|
||||
locale: z.enum(['en', 'es', 'fr']).default('en'),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog, pages, authors, faqs };
|
||||
// + Pagefind indexes all content at build time`,
|
||||
filename: 'src/content.config.ts',
|
||||
},
|
||||
};
|
||||
|
||||
// Simple syntax highlighter
|
||||
function highlightCode(code: string, lang: string): React.ReactNode[] {
|
||||
const lines = code.split('\n');
|
||||
|
||||
return lines.map((line, lineIndex) => {
|
||||
const tokens: React.ReactNode[] = [];
|
||||
let remaining = line;
|
||||
let keyIndex = 0;
|
||||
|
||||
const addToken = (text: string, className?: string) => {
|
||||
if (text) {
|
||||
tokens.push(
|
||||
<span key={`${lineIndex}-${keyIndex++}`} className={className}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Process the line character by character with regex patterns
|
||||
while (remaining.length > 0) {
|
||||
let matched = false;
|
||||
|
||||
// Comments (// and /* */)
|
||||
const commentMatch = remaining.match(/^(\/\/.*|\/\*[\s\S]*?\*\/)/);
|
||||
if (commentMatch) {
|
||||
addToken(commentMatch[0], 'text-foreground-muted italic');
|
||||
remaining = remaining.slice(commentMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// HTML comments
|
||||
const htmlCommentMatch = remaining.match(/^(<!--[\s\S]*?-->)/);
|
||||
if (htmlCommentMatch) {
|
||||
addToken(htmlCommentMatch[0], 'text-foreground-muted italic');
|
||||
remaining = remaining.slice(htmlCommentMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strings (single, double, template)
|
||||
const stringMatch = remaining.match(/^(['"`])(?:(?!\1)[^\\]|\\.)*\1/);
|
||||
if (stringMatch) {
|
||||
addToken(stringMatch[0], 'text-green-600 dark:text-green-400');
|
||||
remaining = remaining.slice(stringMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Astro frontmatter delimiters
|
||||
if (remaining.startsWith('---')) {
|
||||
addToken('---', 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||
remaining = remaining.slice(3);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// HTML/JSX tags
|
||||
const tagMatch = remaining.match(/^(<\/?[\w-]+|>|\/>)/);
|
||||
if (tagMatch) {
|
||||
addToken(tagMatch[0], 'text-pink-600 dark:text-pink-400');
|
||||
remaining = remaining.slice(tagMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CSS at-rules (@theme, @import, etc.)
|
||||
const atRuleMatch = remaining.match(/^(@[\w-]+)/);
|
||||
if (atRuleMatch) {
|
||||
addToken(atRuleMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||
remaining = remaining.slice(atRuleMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keywords
|
||||
const keywordMatch = remaining.match(
|
||||
/^(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/
|
||||
);
|
||||
if (keywordMatch) {
|
||||
addToken(keywordMatch[0], 'text-purple-600 dark:text-purple-400 font-semibold');
|
||||
remaining = remaining.slice(keywordMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Boolean/null
|
||||
const boolMatch = remaining.match(/^(true|false|null|undefined)\b/);
|
||||
if (boolMatch) {
|
||||
addToken(boolMatch[0], 'text-orange-700 dark:text-orange-300');
|
||||
remaining = remaining.slice(boolMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbers
|
||||
const numberMatch = remaining.match(/^(\d+\.?\d*)/);
|
||||
if (numberMatch) {
|
||||
addToken(numberMatch[0], 'text-orange-700 dark:text-orange-300');
|
||||
remaining = remaining.slice(numberMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CSS properties (word followed by colon)
|
||||
const cssPropMatch = remaining.match(/^([\w-]+)(:)/);
|
||||
if (cssPropMatch && (lang === 'css' || line.includes('{'))) {
|
||||
addToken(cssPropMatch[1], 'text-blue-600 dark:text-blue-400');
|
||||
addToken(cssPropMatch[2], 'text-foreground-secondary');
|
||||
remaining = remaining.slice(cssPropMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CSS functions (var, oklch, etc.)
|
||||
const cssFuncMatch = remaining.match(
|
||||
/^(var|oklch|rgb|rgba|hsl|hsla|calc|url|clamp|min|max)(\()/
|
||||
);
|
||||
if (cssFuncMatch) {
|
||||
addToken(cssFuncMatch[1], 'text-amber-700 dark:text-amber-300');
|
||||
addToken(cssFuncMatch[2], 'text-foreground-secondary');
|
||||
remaining = remaining.slice(cssFuncMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Function calls
|
||||
const funcMatch = remaining.match(/^([\w]+)(\()/);
|
||||
if (funcMatch) {
|
||||
addToken(funcMatch[1], 'text-amber-700 dark:text-amber-300');
|
||||
addToken(funcMatch[2], 'text-foreground-secondary');
|
||||
remaining = remaining.slice(funcMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type annotations after colon
|
||||
const typeMatch = remaining.match(/^(:\s*)([\w<>[\]|&]+)/);
|
||||
if (typeMatch) {
|
||||
addToken(typeMatch[1], 'text-foreground-secondary');
|
||||
addToken(typeMatch[2], 'text-cyan-700 dark:text-cyan-300');
|
||||
remaining = remaining.slice(typeMatch[0].length);
|
||||
matched = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default: single character
|
||||
if (!matched) {
|
||||
addToken(remaining[0], 'text-foreground-secondary');
|
||||
remaining = remaining.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens.length > 0 ? tokens : [<span key={lineIndex}> </span>];
|
||||
});
|
||||
}
|
||||
|
||||
function CodeBlock({ code, filename, lang }: { code: string; filename: string; lang: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const highlightedLines = highlightCode(code.trim(), lang);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group border-border bg-background-secondary relative w-full overflow-hidden rounded-md border font-mono text-xs shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="border-border bg-background flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1.5">
|
||||
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||
<div className="bg-border-strong h-2 w-2 rounded-full" />
|
||||
</div>
|
||||
<span className="text-foreground-muted font-sans text-[10px] font-medium">
|
||||
{filename}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
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"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 text-green-600" strokeWidth={2} />
|
||||
<span className="text-green-600">Copied</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3" strokeWidth={2} />
|
||||
<span>Copy</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Code Area */}
|
||||
<div className="bg-background overflow-x-auto p-3">
|
||||
<pre className="flex flex-col leading-5">
|
||||
{highlightedLines.map((lineTokens, i) => (
|
||||
<div key={i} className="table-row">
|
||||
<span className="text-foreground-subtle table-cell w-6 pr-3 text-right text-[10px] select-none">
|
||||
{i + 1}
|
||||
</span>
|
||||
<span className="table-cell whitespace-pre">{lineTokens}</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tab definitions with icons for VerticalTabs
|
||||
const tabs: VerticalTab[] = [
|
||||
{ id: 'theming', label: 'Theming', description: 'Design tokens & dark mode', icon: Palette },
|
||||
{ id: 'seo', label: 'SEO & Meta', description: 'OG images & structured data', icon: Search },
|
||||
{ id: 'perf', label: 'Performance', description: 'Zero JS by default', icon: Zap },
|
||||
{
|
||||
id: 'components',
|
||||
label: 'Components',
|
||||
description: 'Type-safe UI primitives',
|
||||
icon: LayoutGrid,
|
||||
},
|
||||
{ id: 'i18n', label: 'i18n Ready', description: 'Optional multi-language', icon: Globe },
|
||||
{ id: 'content', label: 'Content', description: 'Blog, MDX & search', icon: Newspaper },
|
||||
];
|
||||
|
||||
export function FeatureTabs() {
|
||||
const [activeTab, setActiveTab] = useState('theming');
|
||||
|
||||
return (
|
||||
<section id="features" className="bg-background relative overflow-hidden py-[var(--space-section-md)]">
|
||||
{/* Decorative logomark watermark */}
|
||||
<div
|
||||
className="pointer-events-none absolute -top-8 right-8 hidden h-[28rem] w-[28rem] opacity-[0.04] grayscale md:block lg:top-10 lg:right-24 lg:h-[44rem] lg:w-[44rem] dark:opacity-[0.06]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg viewBox="0 0 90 101" fill="none" className="h-full w-full">
|
||||
<path
|
||||
d="M35.1288 23.8398L45.1667 49.4151L56.2482 23.8398H87.1082C86.5647 23.3764 85.9776 22.9637 85.3616 22.5944L48.6165 0.704798C46.377 -0.0699896 43.4273 -0.439281 41.2675 0.842377L3.36286 23.3692C3.13819 23.5067 2.92801 23.666 2.72508 23.8326H35.1288V23.8398Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M0.144951 28.8578C0.079723 29.2851 0.0434853 29.7123 0.0434853 30.1323L0 72.036C0 76.1778 1.95684 78.3936 5.26172 80.3631L39.4919 100.703L0.144951 28.8578Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M89.9203 28.7058L50.0588 101L86.6661 79.1539C88.7027 77.9374 90 75.0265 90 72.6442L89.9783 29.6037C89.9783 29.2923 89.9493 28.9954 89.913 28.6985L89.9203 28.7058Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto max-w-6xl px-6">
|
||||
{/* Header */}
|
||||
<div className="mb-[var(--space-section-header)]">
|
||||
<h2 className="font-display text-foreground text-3xl font-bold md:text-4xl">
|
||||
Everything you need.
|
||||
<br />
|
||||
<span className="text-brand-500">Nothing you don't.</span>
|
||||
</h2>
|
||||
<p className="text-foreground-muted mt-4 max-w-2xl text-lg">
|
||||
We stripped away the bloat and kept the primitives that actually speed up development
|
||||
for agencies and freelancers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Vertical Tabs */}
|
||||
<VerticalTabs tabs={tabs} value={activeTab} onChange={setActiveTab} mobileVariant="sheet">
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.id} data-tab-content={tab.id}>
|
||||
<div className="mb-[var(--space-heading-gap)]">
|
||||
<h3 className="text-foreground text-xl font-semibold">{tabContent[tab.id].title}</h3>
|
||||
<p className="text-foreground-muted mt-2">{tabContent[tab.id].content}</p>
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={codeExamples[tab.id].code}
|
||||
filename={codeExamples[tab.id].filename}
|
||||
lang={codeExamples[tab.id].lang}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</VerticalTabs>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default FeatureTabs;
|
||||
@@ -0,0 +1,192 @@
|
||||
---
|
||||
/**
|
||||
* LighthouseScores.astro
|
||||
* A callout component displaying perfect Lighthouse scores
|
||||
* Designed to match the authentic Lighthouse report aesthetic
|
||||
*/
|
||||
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
|
||||
|
||||
interface Score {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const scores: Score[] = [
|
||||
{ label: 'Performance', value: 100 },
|
||||
{ label: 'Accessibility', value: 100 },
|
||||
{ label: 'Best Practices', value: 100 },
|
||||
{ label: 'SEO', value: 100 },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="bg-background-secondary border-border border-y py-[var(--space-section-md)]">
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 text-center">
|
||||
<div class="mb-4 flex justify-center">
|
||||
<Badge variant="success">Lighthouse Report</Badge>
|
||||
</div>
|
||||
<h3 class="font-display text-foreground text-2xl font-bold md:text-3xl">
|
||||
Perfect scores. Out of the box.
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Scores Grid -->
|
||||
<div
|
||||
class="lighthouse-scores mx-auto grid max-w-2xl grid-cols-4 gap-[var(--space-content-gap)]"
|
||||
role="list"
|
||||
aria-label="Lighthouse scores"
|
||||
>
|
||||
{
|
||||
scores.map((score, index) => (
|
||||
<div
|
||||
class="lighthouse-score group flex flex-col items-center"
|
||||
role="listitem"
|
||||
style={`--delay: ${index * 80}ms;`}
|
||||
>
|
||||
{/* Circular gauge - authentic Lighthouse style */}
|
||||
<div class="relative">
|
||||
<svg
|
||||
class="lighthouse-gauge h-16 w-16 sm:h-20 sm:w-20 md:h-24 md:w-24"
|
||||
viewBox="0 0 120 120"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Outer gray track */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="54"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
class="text-gray-200 dark:text-gray-800"
|
||||
/>
|
||||
{/* Green progress arc */}
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r="54"
|
||||
fill="none"
|
||||
stroke="var(--success)"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
stroke-dasharray="339.292"
|
||||
stroke-dashoffset="0"
|
||||
class="lighthouse-progress"
|
||||
style="transform: rotate(-90deg); transform-origin: center;"
|
||||
/>
|
||||
{/* Score number - authentic Lighthouse sizing */}
|
||||
<text
|
||||
x="60"
|
||||
y="60"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
class="lighthouse-number fill-current"
|
||||
style="font-size: 26px; font-weight: 600; font-family: Roboto, system-ui, sans-serif;"
|
||||
>
|
||||
{score.value}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span class="text-foreground-muted mt-2 text-center text-xs font-medium sm:text-sm">
|
||||
{score.label}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Footer note -->
|
||||
<p class="text-foreground-subtle mt-[var(--space-stack-lg)] text-center text-xs">
|
||||
*Tested in production for landing page demo · Desktop & Mobile emulation ·
|
||||
Results will vary.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Authentic Lighthouse green for the number */
|
||||
.lighthouse-number {
|
||||
fill: var(--success);
|
||||
}
|
||||
|
||||
/* Entrance animations */
|
||||
.lighthouse-score {
|
||||
animation: score-enter 0.5s ease-out both;
|
||||
animation-delay: calc(0.2s + var(--delay, 0ms));
|
||||
}
|
||||
|
||||
.lighthouse-progress {
|
||||
animation: progress-fill 1s ease-out both;
|
||||
animation-delay: calc(0.3s + var(--delay, 0ms));
|
||||
}
|
||||
|
||||
@keyframes score-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes progress-fill {
|
||||
from {
|
||||
stroke-dashoffset: 339.292;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animation-paused {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.animation-running {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.lighthouse-score,
|
||||
.lighthouse-progress {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.lighthouse-progress {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Intersection Observer for scroll-triggered animations
|
||||
const section = document.querySelector('.lighthouse-scores');
|
||||
if (section) {
|
||||
const elements = section.querySelectorAll('.lighthouse-score, .lighthouse-progress');
|
||||
elements.forEach((el) => {
|
||||
el.classList.add('animation-paused');
|
||||
});
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
elements.forEach((el) => {
|
||||
el.classList.remove('animation-paused');
|
||||
el.classList.add('animation-running');
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.3 }
|
||||
);
|
||||
|
||||
observer.observe(section);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
/**
|
||||
* TechStack — MDX-driven stack showcase
|
||||
*
|
||||
* Content lives in src/content/stack/*.mdx — add, remove, or
|
||||
* edit a tool there without touching this component.
|
||||
*/
|
||||
import { getCollection } from 'astro:content';
|
||||
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
|
||||
import Card from '@/components/ui/data-display/Card/Card.astro';
|
||||
import Badge from '@/components/ui/data-display/Badge/Badge.astro';
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
const { title, description, badge } = Astro.props;
|
||||
|
||||
const items = await getCollection('stack');
|
||||
const stack = items.sort((a, b) => a.data.order - b.data.order);
|
||||
---
|
||||
|
||||
<section class="border-y border-border bg-background-secondary py-[var(--space-section-md)]">
|
||||
<div class="mx-auto max-w-6xl px-6 flex flex-col gap-8">
|
||||
{(badge || title || description) && (
|
||||
<div class="flex flex-col items-center gap-4 text-center" data-reveal>
|
||||
{(badge || title) && (
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
{badge && (
|
||||
<Badge variant="brand" pill>{badge}</Badge>
|
||||
)}
|
||||
{title && (
|
||||
<h2 class="font-display text-4xl font-bold text-foreground">{title}</h2>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p class="text-lg text-foreground-muted max-w-2xl mx-auto">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div class="grid grid-cols-2 gap-[var(--space-content-gap)] md:grid-cols-4" data-reveal data-reveal-delay="1">
|
||||
{stack.map((item) => (
|
||||
<div
|
||||
style={`--tech-color: oklch(${item.data.colorOklch}); --tech-bg: oklch(${item.data.colorOklch} / 0.1);`}
|
||||
>
|
||||
<Card variant="elevated" padding="md" hover={true} href={item.data.url} target="_blank" rel="noopener noreferrer" class="h-full text-center">
|
||||
<div class="flex flex-col items-center gap-[var(--space-stack-sm)]">
|
||||
<div class="tech-icon-wrap">
|
||||
<Icon name={item.data.icon} size="lg" />
|
||||
</div>
|
||||
<span class="font-display text-lg font-bold text-foreground">{item.data.name}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* ─── Shared icon container ─────────────────────────────── */
|
||||
.tech-icon-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--color-brand-500);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 10%, transparent);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user