Initial release — Astro Rocket v1.0.0

This commit is contained in:
Claude
2026-04-06 07:31:47 +00:00
commit ddd0c22311
275 changed files with 38839 additions and 0 deletions
+27
View File
@@ -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>
+106
View File
@@ -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>
+496
View File
@@ -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 &middot; Desktop & Mobile emulation &middot;
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>
+75
View File
@@ -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>