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
+64
View File
@@ -0,0 +1,64 @@
# =============================================================================
# Astro Rocket - Environment Configuration
# =============================================================================
# Copy this file to .env and fill in your values
# Variables prefixed with PUBLIC_ are exposed to the browser - NEVER prefix secrets
# -----------------------------------------------------------------------------
# Required
# -----------------------------------------------------------------------------
# Your production site URL (used for sitemap, canonical URLs, OG images)
SITE_URL=https://example.com
# Deployment target — set to "netlify" when deploying to Netlify, leave unset for Vercel
# DEPLOY_TARGET=netlify
# -----------------------------------------------------------------------------
# Analytics (optional)
# -----------------------------------------------------------------------------
# Google Analytics 4 Measurement ID
# PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
# Google Tag Manager Container ID
# PUBLIC_GTM_ID=GTM-XXXXXXX
# -----------------------------------------------------------------------------
# Form Handling (optional)
# -----------------------------------------------------------------------------
# Resend API key for contact form emails and newsletter (server-side only)
# Get your key at https://resend.com
RESEND_API_KEY=your-resend-api-key
# Sender address for contact form emails — must be a verified Resend domain
# Defaults to the email set in site.config.ts if not provided
# RESEND_FROM_EMAIL=noreply@yourdomain.com
# Resend Audience ID for newsletter subscriptions (server-side only)
# Create an audience at resend.com/audiences and paste its ID here
# RESEND_AUDIENCE_ID=your-audience-id
# Newsletter API key (server-side only) — used by the /api/newsletter endpoint
# NEWSLETTER_API_KEY=your-newsletter-api-key
# -----------------------------------------------------------------------------
# Search Engine Verification (optional)
# -----------------------------------------------------------------------------
# Google Search Console verification code
# GOOGLE_SITE_VERIFICATION=your-verification-code
# Bing Webmaster Tools verification code
# BING_SITE_VERIFICATION=your-verification-code
# -----------------------------------------------------------------------------
# Consent / Privacy (optional)
# -----------------------------------------------------------------------------
# Enable the cookie consent banner (requires analytics to be configured)
# PUBLIC_CONSENT_ENABLED=true
# Link to your privacy policy page (shown in consent banner when set)
# PUBLIC_PRIVACY_POLICY_URL=/privacy
+29
View File
@@ -0,0 +1,29 @@
# Code of Conduct
## Our Standards
Astro Rocket is an open-source project and everyone who participates — whether through issues, pull requests, discussions, or any other channel — is expected to treat others with respect and professionalism.
**Expected behaviour:**
- Be welcoming and considerate in your language and actions
- Offer and accept constructive feedback graciously
- Focus on what is best for the project and the community
- Show patience with contributors of all experience levels
**Unacceptable behaviour:**
- Personal attacks, insults, or derogatory comments
- Public or private intimidation of any kind
- Sharing others' private information without permission
- Any conduct that would be considered unprofessional in a work setting
## Scope
This code of conduct applies to all project spaces — GitHub issues, pull requests, discussions, and any other official communication channel.
## Enforcement
Instances of unacceptable behaviour may be reported to [hello@hansmartens.dev](mailto:hello@hansmartens.dev). All reports will be reviewed and handled with discretion. Maintainers reserve the right to remove, edit, or reject contributions that do not align with this code of conduct, and to temporarily or permanently exclude anyone whose behaviour is deemed harmful to the project.
## Attribution
This code of conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1.
+93
View File
@@ -0,0 +1,93 @@
# Contributing to Astro Rocket
Thank you for your interest in contributing. Astro Rocket is a free, open-source Astro 6 starter theme — every improvement, however small, makes it better for everyone who builds with it.
## Ways to contribute
- **Report a bug** — something broken or behaving unexpectedly
- **Suggest a feature** — an idea for a new component, page, or configuration option
- **Fix a bug** — pick up an open issue and submit a pull request
- **Improve documentation** — fix a typo, clarify an instruction, or improve an example
- **Improve accessibility** — help make the theme usable for everyone
## Before you start
- Check the [open issues](https://github.com/hansmartens68/Astro-Rocket/issues) to avoid duplicating work
- For significant changes, open an issue first to discuss the approach before writing code
- All contributions are released under the [MIT License](../LICENSE)
## Development setup
**Prerequisites:** Node.js ≥ 22.12.0 and pnpm
```bash
# Fork and clone the repo
git clone https://github.com/YOUR_USERNAME/Astro-Rocket.git
cd Astro-Rocket
# Copy the environment file
cp .env.example .env
# Install dependencies
pnpm install
# Start the dev server
pnpm dev
```
## Branch naming
| Type | Pattern | Example |
|---|---|---|
| New feature | `feature/short-description` | `feature/add-breadcrumbs` |
| Bug fix | `fix/short-description` | `fix/mobile-nav-overflow` |
| Content or config | `update/short-description` | `update/readme-installation` |
Always branch from `main`.
## Before submitting a pull request
All three checks must pass with zero errors:
```bash
pnpm lint # ESLint
pnpm check # Astro type checking
pnpm build # Production build
```
If any check fails, fix the errors before opening the PR. The CI workflow runs the same checks automatically.
## Commit messages
Write clear, specific commit messages that describe what changed and why.
```
# Good
Add keyboard navigation to theme selector dropdown
Fix contact form validation on mobile Safari
Update installation docs to reflect pnpm lockfile requirement
# Too vague
fix
update
changes
```
## Pull request guidelines
- Keep pull requests focused — one concern per PR
- Reference the issue number if applicable: `Fixes #42`
- Fill in the PR description template — describe what changed, why, and how to test it
- Be responsive to review feedback
## Code style
- TypeScript is required for all `.astro` and `.ts` files
- Tailwind utility classes follow the existing ordering conventions
- No inline styles unless unavoidable
- Components go in `src/components/`, pages in `src/pages/`, layouts in `src/layouts/`
- Follow the existing file and folder naming conventions
## Questions
Open a [GitHub Discussion](https://github.com/hansmartens68/Astro-Rocket/discussions) or reach out on X at [@hansmartens_dev](https://x.com/hansmartens_dev).
+1
View File
@@ -0,0 +1 @@
github: [hansmartens68]
+40
View File
@@ -0,0 +1,40 @@
---
name: Bug report
about: Something is broken or not working as expected
title: "[Bug] "
labels: bug
assignees: ''
---
## Describe the bug
A clear description of what the bug is.
## Steps to reproduce
1. Go to '...'
2. Click on '...'
3. See error
## Expected behaviour
What you expected to happen.
## Actual behaviour
What actually happened.
## Environment
- OS: [e.g. macOS 14, Windows 11]
- Node version: [e.g. 22.12.0]
- pnpm version: [e.g. 9.x]
- Browser (if relevant): [e.g. Chrome 120, Safari 17]
## Screenshots or error output
If applicable, add screenshots or paste any error messages here.
## Additional context
Any other context that might be relevant.
+23
View File
@@ -0,0 +1,23 @@
---
name: Feature request
about: Suggest a new component, page, or improvement for Astro Rocket
title: "[Feature] "
labels: enhancement
assignees: ''
---
## What problem does this solve?
A clear description of the problem or gap this feature would address.
## Describe the solution you'd like
What would you like to see added or changed? Be as specific as you can.
## Alternatives you've considered
Any other approaches or workarounds you've thought about.
## Additional context
Screenshots, links, or examples that illustrate your idea.
+25
View File
@@ -0,0 +1,25 @@
## What does this PR do?
A clear description of the change and why it was made.
Fixes # (issue number, if applicable)
## Type of change
- [ ] Bug fix
- [ ] New feature or component
- [ ] Documentation update
- [ ] Refactor or code quality improvement
- [ ] Other: ___
## Checklist
- [ ] `pnpm lint` passes with 0 errors
- [ ] `pnpm check` passes with 0 errors
- [ ] `pnpm build` completes successfully
- [ ] I have tested this change in the browser
- [ ] No unnecessary files are included in this PR
## Screenshots (if relevant)
Add before/after screenshots for visual changes.
+73
View File
@@ -0,0 +1,73 @@
name: Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v3
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm run lint
- name: Type check
run: pnpm run check
- name: Build
run: pnpm run build
env:
SITE_URL: ${{ secrets.SITE_URL }}
# Uncomment and configure for your deployment platform
#
# Vercel deployment:
# - name: Deploy to Vercel
# if: github.ref == 'refs/heads/main'
# uses: amondnet/vercel-action@v25
# with:
# vercel-token: ${{ secrets.VERCEL_TOKEN }}
# vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
# vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
# vercel-args: '--prod'
#
# Netlify deployment:
# - name: Deploy to Netlify
# if: github.ref == 'refs/heads/main'
# uses: nwtgck/actions-netlify@v2
# with:
# publish-dir: './dist'
# production-branch: main
# github-token: ${{ secrets.GITHUB_TOKEN }}
# env:
# NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
# NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
#
# Cloudflare Pages deployment:
# - name: Deploy to Cloudflare Pages
# if: github.ref == 'refs/heads/main'
# uses: cloudflare/wrangler-action@v3
# with:
# apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
# accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
# command: pages deploy dist --project-name=astro-rocket
+64
View File
@@ -0,0 +1,64 @@
# Dependencies
node_modules/
# Build output
dist/
.vercel/
.netlify/
# Astro
.astro/
# Environment variables
.env
.env.local
.env.*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.swp
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Testing
coverage/
playwright-report/
test-results/
# Misc
*.local
*.tsbuildinfo
# Pagefind
public/pagefind/
# Claude Code
.claude/
# npm lockfile (pnpm only)
package-lock.json
# Internal docs
southwell-astro-boilerplate-docs.md
southwell-astro-boilerplate-prd.md
claude-ai-web-design.png
dark-mode-sessionstorage.png
domain-email-vercel-setup.png
why-i-build-with-astro.png
View File
+1
View File
@@ -0,0 +1 @@
22
+5
View File
@@ -0,0 +1,5 @@
dist/
node_modules/
.astro/
public/pagefind/
pnpm-lock.yaml
+16
View File
@@ -0,0 +1,16 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
}
+41
View File
@@ -0,0 +1,41 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
---
## [1.0.0] — 2026-04-04
Initial public release of Astro Rocket.
### Added
- Production-ready Astro 6 starter theme built on Tailwind CSS v4 and TypeScript
- 57 UI and pattern components (buttons, forms, cards, badges, inputs, selects, etc.)
- 12 live colour themes (Orange, Amber, Lime, Emerald, Teal, Cyan, Sky, Blue, Indigo, Violet, Purple, Magenta) switchable at runtime without a rebuild
- Full blog with MDX support, syntax highlighting (Shiki), and RSS feed
- Auto-generated SVG favicon and monogram logo badge from `site.config.ts`
- Unified `Icon` component via Iconify (350+ Lucide icons + 3000+ Simple Icons)
- Animated typing effect in hero section
- Contact form with Zod validation, honeypot bot detection, and Resend integration
- Newsletter signup form with Resend Audiences integration
- Cookie consent banner with Google Consent Mode v2 support
- Google Analytics 4 and Google Tag Manager integration (consent-aware)
- Built-in SEO layer: JSON-LD structured data, Open Graph, sitemap, robots.txt
- Dark mode via `sessionStorage` (resets to dark on each new session)
- Search powered by Pagefind
- Multiple deployment targets: Vercel, Netlify, Cloudflare Pages
- Security headers configured for all deployment targets
- GitHub Actions CI/CD workflow (lint, type-check, build)
- Vitest unit tests for API endpoint validation schemas
### Changed (from Velocity)
- Forked and extended [Velocity](https://github.com/southwellmedia/velocity) by Southwell Media
- Added theme switching, 12 colour themes, typed logo badge, auto favicon
- Replaced localStorage with sessionStorage for dark mode preference
- Added blog image gradients that update with the active theme
- Upgraded icon system to Iconify
- Targeted at complete, ready-to-launch sites rather than a bare boilerplate
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Hans Martens
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+707
View File
@@ -0,0 +1,707 @@
<p align="center">
<img src="public/readme-hero.svg" alt="Astro Rocket" width="880" />
</p>
<p align="center">
<strong>Astro Rocket</strong> — A production-ready Astro 6 starter theme. Change the text, launch your site.
</p>
<p align="center">
<a href="https://astro.build"><img src="https://img.shields.io/badge/Astro-6.0-bc52ee?logo=astro&logoColor=white" alt="Astro" /></a>
<a href="https://tailwindcss.com"><img src="https://img.shields.io/badge/Tailwind-4.0-38bdf8?logo=tailwindcss&logoColor=white" alt="Tailwind CSS" /></a>
<a href="https://www.typescriptlang.org"><img src="https://img.shields.io/badge/TypeScript-5.7-3178c6?logo=typescript&logoColor=white" alt="TypeScript" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-22c55e" alt="License" /></a>
<a href="https://github.com/hansmartens68/astro-rocket"><img src="https://img.shields.io/github/stars/hansmartens68/astro-rocket?style=flat&label=%E2%AD%90%20Star%20on%20GitHub&color=f59e0b" alt="Star on GitHub" /></a>
<img src="https://visitor-badge.laobi.icu/badge?page_id=hansmartens68.astro-rocket" alt="Visitors" />
</p>
---
## Overview
Astro Rocket is a **launch-ready starter theme** for web designers, developers, bloggers, and anyone who needs a portfolio website. Every page is already built and styled — you change the text and content, and your site is ready to go live.
It ships with a full blog, a complete component library, a built-in SEO layer, dark mode, a contact form, and 12 colour themes you can switch with one click. It's built on Astro 6 and Tailwind CSS v4.
**[Live demo → astrorocket.dev](https://astrorocket.dev)** · **[Built by Hans Martens → hansmartens.dev](https://hansmartens.dev)**
> **Astro Rocket is a fork of [Velocity](https://github.com/southwellmedia/velocity) by [Southwell Media](https://southwellmedia.com).** Velocity is the foundation — a powerful Astro boilerplate with a comprehensive design system and component library. Full credit to the Southwell Media team for that work. Astro Rocket builds on it with a different goal: a complete, ready-to-launch website where you only change the text to make it your own.
---
## What changed from Velocity
The following changes were made to the free Velocity theme to create Astro Rocket:
| Change | Velocity | Astro Rocket |
|--------|----------|--------------|
| **Theme switching** | Edit a CSS import file and rebuild | 12 colour swatches in the header — click one and the logo badge, blog images, and every brand color update live on screen. No file edits, no rebuilds. Selector can be removed from the header once you've chosen a color. |
| **Colour themes** | 1 default theme | 12 Tailwind-based themes — all 12 shown as swatches in the header selector (Orange, Amber, Lime, Emerald, Teal, Cyan, Sky, Blue, Indigo, Violet, Purple, Magenta) |
| **Logo badge** | Requires a custom logo file | Auto-generated monogram badge — first letter of your site name on brand color, live-updates with active theme |
| **Favicon** | Static file to replace manually | Auto-generated SVG favicon — first letter + brand color, pre-rendered at build time from `site.config.ts`, no design tools needed |
| **Blog image gradients** | Plain image containers | Every blog cover and card uses a brand-color gradient background that updates live when the active theme changes |
| **Icon system** | Basic SVG `Icon` component | Unified `Icon` component powered by Iconify — 350+ Lucide UI icons + 3000+ Simple Icons brand icons |
| **Typing effect** | Not included | Hero section includes an animated typing effect |
| **Dark mode storage** | `localStorage` | `sessionStorage` — resets to dark on every new tab/session (see [why](#dark-mode)) |
| **Target audience** | Developers & agencies | Web designers, developers, bloggers, and portfolio sites |
| **Ready to launch** | Boilerplate starting point | Fully styled pages — replace the text and your site is live |
| **Maintained by** | Southwell Media | Hans Martens |
---
## Key Features
| Feature | Description |
|---------|-------------|
| **Astro 6** | Latest version with Content Layer API, security features, and performance optimizations |
| **Tailwind CSS v4** | CSS-first configuration with OKLCH color system and fluid typography |
| **12 Colour Themes** | All 12 colour swatches are shown in the header dropdown — click one and the logo badge, blog image gradients, and every brand color update live instantly. No file edits, no rebuilds. The selector can be removed from the header once you've settled on a color. |
| **Scroll Progress Bar** | A thin 2px brand-coloured bar on the header edge that fills as you scroll. Enabled on the homepage (above the floating header), blog index, and post pages (below the solid header). Controlled via `showScrollProgress` and `scrollProgressPosition` props on the Header component. |
| **Design Tokens** | Three-tier token architecture (reference → semantic → component) |
| **57 Components** | 31 UI, 7 patterns, 1 hero, 4 layout, 4 blog, 7 landing, 3 SEO — all accessible with TypeScript |
| **Auto Logo & Favicon** | First letter of your site name on brand color — generated automatically from `site.config.ts`, no design tools needed |
| **Icon System** | Unified `Icon` component (Astro + React) — 350+ [Lucide](https://lucide.dev) UI icons and 3000+ [Simple Icons](https://simpleicons.org) brand icons via Iconify |
| **Typing Effect** | Animated typing effect in the hero section |
| **Page Animations** | Smooth page transitions via Astro View Transitions, scroll-triggered counter and score animations, scroll-reactive header, card hover effects, and a full suite of UI micro-animations — all with reduced-motion support |
| **SEO Toolkit** | Meta tags, JSON-LD structured data, sitemap, and robots.txt |
| **Static OG Image** | A polished default Open Graph image serves as social preview for all pages — no build-time generation required |
| **Dark Mode** | Dark-first design with `sessionStorage` persistence |
| **Content Collections** | Type-safe blog, pages, authors, and FAQs with Zod validation |
| **API Routes** | Contact form and newsletter endpoints with validation |
| **React Islands** | Optional client-side interactivity where needed |
### Internationalization (i18n)
The base theme is i18n-ready with locale-aware content collection schemas. Full i18n support with language routing and a `LanguageSwitcher` component can be added via the **[create-velocity-astro](https://github.com/southwellmedia/create-velocity-astro)** CLI from Southwell Media.
---
## Quick Start
### Prerequisites
- **Node.js 22.12.0+** (required for Astro 6)
- **pnpm 9.x** (recommended) or npm/yarn
### Installation
```bash
# Clone the repository
git clone https://github.com/hansmartens68/astro-rocket.git my-project
cd my-project
# Install dependencies
pnpm install
# Copy environment variables
cp .env.example .env
# Start development server
pnpm dev
```
Visit `http://localhost:4321` to see your site.
---
## Project Structure
```
astro-rocket/
├── public/ # Static assets (fonts, favicon)
├── src/
│ ├── assets/ # Images and icons (processed by Astro)
│ ├── components/
│ │ ├── ui/ # UI component library (31 components)
│ │ │ ├── form/ # Button, Input, Textarea, Select, Checkbox, Radio, Switch
│ │ │ ├── data-display/ # Card, Badge, Avatar, Table, Pagination, Progress, Skeleton
│ │ │ ├── feedback/ # Alert, Toast, Tooltip
│ │ │ ├── overlay/ # Dialog, Dropdown, Tabs, VerticalTabs, Accordion
│ │ │ ├── layout/ # Separator
│ │ │ ├── primitives/ # Icon
│ │ │ ├── content/ # CodeBlock
│ │ │ └── marketing/ # Logo, CTA, NpmCopyButton, SocialProof, TerminalDemo
│ │ ├── patterns/ # Composed patterns (ContactForm, SearchInput, StatCard, etc.)
│ │ ├── layout/ # Header, Footer, Navigation, ThemeToggle, ThemeSelector
│ │ ├── seo/ # SEO, JsonLd, Breadcrumbs
│ │ ├── blog/ # Blog-specific components
│ │ └── landing/ # Landing page components
│ ├── content/ # Content collections
│ │ ├── blog/ # Blog posts (en/, es/, fr/)
│ │ ├── projects/ # Portfolio project pages
│ │ ├── authors/ # Author profiles
│ │ └── faqs/ # FAQ entries
│ ├── layouts/ # Page layouts
│ ├── lib/ # Utilities (schema, cn)
│ ├── pages/ # Routes and API endpoints
│ │ ├── api/ # Contact, newsletter endpoints
│ │ └── blog/ # Blog routes
│ ├── styles/ # Global CSS and design tokens
│ │ ├── tokens/ # colors.css, typography.css, spacing.css
│ │ └── themes/ # 12 colour theme files
│ └── config/ # Site and navigation configuration
├── astro.config.mjs # Astro configuration
├── package.json
└── tsconfig.json
```
---
## Commands
| Command | Description |
|---------|-------------|
| `pnpm dev` | Start development server with hot reload |
| `pnpm build` | Build for production |
| `pnpm preview` | Preview production build locally |
| `pnpm check` | Run Astro type checker |
| `pnpm lint` | Run ESLint |
| `pnpm lint:fix` | Fix ESLint issues |
| `pnpm format` | Format code with Prettier |
| `pnpm format:check` | Check code formatting |
| `pnpm test` | Run Vitest tests |
| `pnpm test:e2e` | Run Playwright E2E tests |
---
## Configuration
### Site Configuration
Edit `src/config/site.config.ts`:
```typescript
const siteConfig = {
name: 'Your Site Name',
description: 'Your site description for SEO',
url: 'https://yoursite.com',
ogImage: '/og-default.svg',
author: 'Your Name',
email: 'hello@yoursite.com',
twitter: {
site: '@yourhandle',
creator: '@yourhandle',
},
};
```
### Environment Variables
Create a `.env` file from `.env.example`:
```bash
# Required
SITE_URL=https://yoursite.com
# Optional - Analytics
PUBLIC_GA_MEASUREMENT_ID=G-XXXXXXXXXX
PUBLIC_GTM_ID=GTM-XXXXXXX
# Optional - Verification
GOOGLE_SITE_VERIFICATION=your-code
BING_SITE_VERIFICATION=your-code
```
---
## Design System
Astro Rocket uses a three-tier design token system with OKLCH colors for perceptual uniformity:
1. **Primitives** (`src/styles/tokens/primitives.css`) — raw color scales (gray, brand, status)
2. **Semantic tokens** (`src/styles/themes/*.css`) — purpose-based mappings (background, foreground, border, etc.)
3. **Tailwind** (`src/styles/global.css`) — `@theme` directives that expose tokens as utility classes
### Switching Themes
Astro Rocket ships with 12 colour themes, all based on Tailwind's color palette. All 12 are shown as colour swatches in the header dropdown (`ThemeSelectorDropdown`) on desktop and in the mobile menu (`ThemeSelector`). Clicking a swatch applies the theme instantly — the logo badge, blog image gradients, and every brand color on the page update live. No file edits, no rebuilds. This is the key difference from Velocity, where switching theme requires editing a CSS import file and rebuilding.
The 12 themes in order: Orange, Amber, Lime, Emerald, Teal, Cyan, Sky, Blue (default), Indigo, Violet, Purple, and Magenta. The `themes` array in `src/components/layout/ThemeSelector.astro` controls which swatches are shown and in what order. You can also **remove the selector from the header entirely** once you've settled on a color — just remove `showThemeSelector` from the layout file.
The theme files live in `src/styles/themes/`:
```
amber.css blue.css cyan.css emerald.css
green.css indigo.css lime.css magenta.css
orange.css purple.css sky.css teal.css
violet.css
```
### Customizing Brand Colors
Edit `src/styles/tokens/primitives.css` and update the `--brand-*` OKLCH values:
```css
:root {
--brand-50: oklch(97.5% 0.02 45); /* lightest tint */
--brand-100: oklch(94.8% 0.04 45);
--brand-200: oklch(87.5% 0.08 45);
--brand-300: oklch(77.8% 0.14 45);
--brand-400: oklch(68.5% 0.19 40);
--brand-500: oklch(62.5% 0.22 38); /* primary brand color */
--brand-600: oklch(53.2% 0.19 38);
--brand-700: oklch(45.5% 0.16 38);
--brand-800: oklch(37.2% 0.13 38);
--brand-900: oklch(26.5% 0.09 38);
}
```
OKLCH values are `oklch(lightness chroma hue)`. To shift your brand to blue, change the hue from `38-45` to `~260`. Use [oklch.com](https://oklch.com) to pick colors visually.
### Creating a New Theme
1. Duplicate `src/styles/themes/default.css` as your starting point
2. Implement all ~35 semantic tokens for both `:root` (light) and `.dark` (dark):
**Backgrounds**: `--background`, `--background-secondary`, `--background-tertiary`, `--background-elevated`
**Foregrounds**: `--foreground`, `--foreground-secondary`, `--foreground-muted`, `--foreground-subtle`
**Borders**: `--border`, `--border-strong`, `--border-subtle`
**Interactive**: `--primary`, `--primary-hover`, `--primary-foreground`, `--secondary`, `--secondary-hover`, `--secondary-foreground`, `--accent`, `--accent-hover`, `--accent-light`
**Surfaces**: `--muted`, `--muted-foreground`, `--card`, `--card-border`, `--input-bg`, `--input-border`, `--input-focus`, `--ring`
**Destructive**: `--destructive`, `--destructive-foreground`
**Gradients**: `--gradient-start`, `--gradient-end`
**Invert sections**: `--surface-invert`, `--surface-invert-secondary`, `--surface-invert-tertiary`, `--on-invert`, `--on-invert-secondary`, `--on-invert-muted`, `--border-invert`, `--border-invert-strong`
3. Update the import in `src/styles/tokens/colors.css` to point to your new theme file
### Dark Mode
Dark mode toggles via the `.dark` class on `<html>`. The default is **dark** — the design was built dark-first and it looks great for portfolios and creative sites.
FOUC is prevented by an inline script that reads `sessionStorage` before first paint. Use the included `ThemeToggle` component:
```astro
---
import ThemeToggle from '@/components/layout/ThemeToggle.astro';
---
<ThemeToggle />
```
To opt out of dark mode, remove the `.dark { ... }` block from your theme file.
> **Why `sessionStorage` instead of `localStorage`?** This is a deliberate choice. `sessionStorage` persists the user's preference during their visit but resets when the tab is closed — so every new visit starts with the intended dark design. For a portfolio or marketing site this is the right call. For a product users return to daily (a SaaS dashboard, editor, etc.), switch to `localStorage` so the preference survives across sessions. Read the full reasoning in [this blog post](https://hansmartens.dev/blog/dark-mode-sessionstorage).
### WCAG Contrast Requirements
Foreground tokens are documented with their contrast ratios inline. When customizing, maintain these minimums:
| Token | Minimum ratio | Standard |
|-------|:---:|:---:|
| `--foreground` | 7:1 | WCAG AAA |
| `--foreground-secondary` | 7:1 | WCAG AAA |
| `--foreground-muted` | 4.5:1 | WCAG AA |
| `--foreground-subtle` | 4.5:1 | WCAG AA |
| Status `-foreground` tokens | 4.5:1 | WCAG AA (on their `-light` bg) |
### Using Design Tokens
```astro
<!-- Tailwind utilities (recommended) -->
<div class="bg-background text-foreground">
<h1 class="text-primary font-display">Hello</h1>
</div>
<!-- CSS custom properties -->
<style>
.custom {
background: var(--background-secondary);
color: var(--foreground);
}
</style>
```
---
## Components
Astro Rocket includes 57 components across 7 categories. All UI components use [class-variance-authority (CVA)](https://cva.style) for type-safe variant management.
### UI Components (31)
#### Form (`ui/form/`)
| Component | Description |
|-----------|-------------|
| Button | Interactive button with primary, secondary, outline, ghost, destructive variants and loading state |
| Input | Text input with label, hint, and error states |
| Textarea | Multi-line text input |
| Select | Dropdown selection |
| Checkbox | Boolean toggle with indeterminate state |
| Radio | Single selection from group |
| Switch | Toggle switch input |
#### Data Display (`ui/data-display/`)
| Component | Description |
|-----------|-------------|
| Card | Content container with variant, padding, and hover options |
| Badge | Status labels and tags with contextual variants |
| Avatar | User images with fallback |
| AvatarGroup | Grouped avatar display with overlap |
| Table | Styled data table |
| Pagination | Page navigation controls |
| Progress | Progress bar indicator |
| Skeleton | Loading placeholders |
#### Feedback (`ui/feedback/`)
| Component | Description |
|-----------|-------------|
| Alert | Contextual feedback messages (info, success, warning, error) |
| Toast | Temporary notification messages |
| Tooltip | Hover tooltips with positioning |
#### Overlay (`ui/overlay/`)
| Component | Description |
|-----------|-------------|
| Dialog | Modal overlay |
| Dropdown | Menu with trigger |
| Tabs | Horizontal tabbed content panels |
| VerticalTabs | Vertical tab navigation |
| Accordion | Collapsible content sections |
#### Layout (`ui/layout/`)
| Component | Description |
|-----------|-------------|
| Separator | Visual divider between sections |
#### Primitives (`ui/primitives/`)
| Component | Description |
|-----------|-------------|
| Icon | Unified icon component (Astro + React) powered by Iconify. Supports all [Lucide](https://lucide.dev) icons (`lucide:*`) and all [Simple Icons](https://simpleicons.org) brand icons (`simple-icons:*`). Includes shorthand names for common social and brand icons. Five size variants: `xs`, `sm`, `md`, `lg`, `xl`. |
#### Content (`ui/content/`)
| Component | Description |
|-----------|-------------|
| CodeBlock | Syntax-highlighted code display |
#### Marketing (`ui/marketing/`)
| Component | Description |
|-----------|-------------|
| Logo | Auto-generated monogram badge — renders the first letter of `siteConfig.name` on the active brand color. Five sizes: `sm`, `md`, `lg`, `xl`, `2xl`. No logo file required. |
| CTA | Call-to-action sections with slot-based composition |
| NpmCopyButton | NPM install command with copy-to-clipboard |
| SocialProof | Testimonial and trust indicator cards |
| TerminalDemo | Animated terminal demonstration (React) |
### Pattern Components (7)
| Component | Description |
|-----------|-------------|
| ContactForm | Complete contact form with validation |
| NewsletterForm | Email subscription form |
| FormField | Reusable form field wrapper |
| SearchInput | Search input with icon |
| PasswordInput | Password input with visibility toggle |
| StatCard | Statistics display card |
| EmptyState | Empty state placeholder with icon and action |
### Other Categories
| Category | Count | Components |
|----------|-------|------------|
| Hero | 1 | Hero section with centered/split layouts, grid pattern, and typing effect |
| Layout | 6 | Header (with scroll progress bar), Footer, ThemeToggle, ThemeSelector, ThemeSelectorDropdown, Analytics |
| Blog | 4 | ArticleHero, BlogCard, ShareButtons, RelatedPosts |
| Landing | 5 | Credibility, LighthouseScores, TechStack, FeatureTabs, and more |
| SEO | 3 | SEO, JsonLd, Breadcrumbs |
### Usage Example
```astro
---
import { Button, Input, Card } from '@/components/ui';
---
<Card>
<Input label="Email" type="email" name="email" required />
<Button variant="primary">Submit</Button>
</Card>
```
### Icon Usage
```astro
---
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
---
<!-- Lucide UI icons — use any icon name from lucide.dev -->
<Icon name="arrow-right" size="md" />
<Icon name="mail" size="sm" />
<Icon name="layers" size="lg" />
<!-- Simple Icons brand icons — shorthand names available -->
<Icon name="github" size="md" />
<Icon name="x-twitter" size="md" />
<Icon name="brand-astro" size="md" />
<Icon name="brand-tailwind" size="md" />
<!-- Or use the full Iconify name directly -->
<Icon name="simple-icons:vercel" size="md" />
<Icon name="lucide:rocket" size="xl" />
```
All UI components are imported via barrel exports from `@/components/ui`. View all components at `/components` in development.
---
## Content Management
### Blog Posts
Create posts in `src/content/blog/[locale]/`:
```markdown
---
title: "Your Post Title"
description: "Brief description for SEO"
publishedAt: 2026-01-30
author: "Author Name"
tags: ["astro", "tutorial"]
locale: en
---
Your content here...
```
### Querying Content
```astro
---
import { getCollection } from 'astro:content';
const posts = await getCollection('blog', ({ data }) => {
return import.meta.env.PROD ? !data.draft : true;
});
---
```
---
## SEO
### Automatic Features
- **Meta tags**: Title, description, canonical URL
- **Open Graph**: Complete OG tags for social sharing
- **Twitter Cards**: Large image cards
- **JSON-LD**: WebSite, Organization, BlogPosting, Breadcrumb, FAQ schemas
- **Sitemap**: Auto-generated at `/sitemap-index.xml`
- **robots.txt**: Dynamic generation with sitemap reference
- **OG Images**: A static default OG image serves all pages and blog posts
### Using the SEO Component
```astro
---
import SEO from '@/components/seo/SEO.astro';
---
<head>
<SEO
title="Page Title"
description="Page description"
/>
</head>
```
### OG Image
A static default OG image (`public/og-default.svg`) serves as the social preview for all pages. The path is set via `ogImage` in `src/config/site.config.ts`. To use a custom image for a specific page, pass it as the `image` prop to the layout component.
---
## API Routes
### Contact Form
**POST** `/api/contact`
```typescript
// Request (FormData)
{
name: string, // 2-100 chars
email: string, // Valid email
subject: string, // Required
message: string, // 10-5000 chars
honeypot: string // Must be empty (spam check)
}
// Response
{ success: true }
// or
{ success: false, errors: { field: ["message"] } }
```
### Newsletter
**POST** `/api/newsletter`
```typescript
// Request (FormData)
{ email: string }
// Response
{ success: true }
// or
{ success: false, error: "message" }
```
---
## Deployment
Configuration files included for major platforms.
### Vercel (Recommended)
```bash
vercel
```
### Netlify
```bash
netlify deploy --prod
```
### Cloudflare Pages
```bash
wrangler pages deploy dist
```
### Static Export
Build outputs to `dist/` for any static host:
```bash
pnpm build
```
---
## Browser Support
- Chrome (last 2 versions)
- Firefox (last 2 versions)
- Safari (last 2 versions)
- Edge (last 2 versions)
---
## Performance
Astro Rocket is optimized for Core Web Vitals:
- **Lighthouse Score**: 95+ across all categories
- **Zero JavaScript** by default (islands architecture)
- **Optimized fonts** with `font-display: swap`
- **Image optimization** via Astro's built-in processing
- **Prefetching** for instant page transitions
---
## Animations
Every page in Astro Rocket includes purposeful animations that make the site feel polished and alive. All animations respect the user's `prefers-reduced-motion` setting — they are disabled automatically for users who prefer less motion.
### Page transitions
Astro Rocket uses Astro's built-in `<ClientRouter />` (View Transitions API) to animate between pages. Instead of a full browser reload, content fades smoothly from one page to the next. This is enabled globally in `BaseLayout.astro` and requires no per-page configuration.
### Scroll-triggered animations
Two components use an `IntersectionObserver` to trigger animations when elements enter the viewport:
- **Counter animation** — the stats block on the homepage (Years Experience, Projects Delivered, etc.) counts up from zero when it scrolls into view. Each number animates with a cubic ease-out over 1.2 seconds.
- **Lighthouse score bars** — the `LighthouseScores` landing component animates its score bars into place as the section becomes visible.
### Scroll-reactive header
The floating header changes its appearance as the user scrolls. When the page is at the top, the header is transparent with inverted text. Once the user scrolls past 60px, the header gains a solid background and the text flips to normal colors — all driven by CSS transitions via a `data-scrolled` attribute.
### Scroll progress bar
A thin 2px brand-coloured bar on the header edge that grows from left to right as the user scrolls, showing reading progress at a glance. Enable it with two props on the `<Header>` component:
| Prop | Type | Default | What it does |
|------|------|:-------:|--------------|
| `showScrollProgress` | `boolean` | `false` | Renders the progress bar |
| `scrollProgressPosition` | `'top'` \| `'bottom'` | `'bottom'` | Edge of the header where the bar sits |
The bar is enabled by default on three page types: the **homepage** (above the floating header), the **blog index**, and **individual blog posts** (both below the solid bar header). Use `scrollProgressPosition="top"` on a floating capsule header and `'bottom'` on a solid bar header. The bar colour always matches `--color-brand-500` and updates instantly when the visitor switches themes.
### Card hover effects
Cards throughout the site lift slightly on hover (`-translate-y-1`) and gain a subtle shadow. This is a Tailwind utility applied consistently to all interactive cards.
### UI micro-animations
The full animation library is defined in `src/styles/global.css`. These classes are used by components throughout the site:
| Class | What it does |
|-------|-------------|
| `animate-fade-in` | Fades an element from transparent to visible (0.5s ease-out) |
| `animate-slide-up` | Slides an element up from 12px below while fading in (0.5s ease-out) |
| `animate-slide-down` | Slides an element down from 12px above while fading in (0.5s ease-out) |
| `animate-dropdown-in` | Slides and scales a dropdown menu into view (0.2s spring) |
| `animate-dropdown-out` | Collapses a dropdown menu out of view (0.15s) |
| `animate-sheet-up` | Slides a bottom sheet up from off-screen (0.25s spring) |
| `animate-menu-down` | Slides the mobile navigation drawer open (0.25s spring) |
| `animate-tab-enter` | Crossfades tab panel content when switching tabs |
| `animate-toast-in` | Slides a toast notification in from the right (350ms spring) |
| `animate-tooltip-in` | Fades and scales a tooltip into view |
| `animate-pulse` | Breathing pulse for skeleton loading states |
| `animate-spin` | Continuous rotation for loading spinners |
| `animate-shake` | Brief shake for error feedback (400ms) |
Animation delay utilities (`.delay-0` through `.delay-5`, in 50ms steps) let you stagger multiple elements into view.
---
## Contributing
Contributions are welcome!
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
Please ensure your code passes linting (`pnpm lint`) and type checking (`pnpm check`) before submitting.
---
## License
MIT License — see [LICENSE](LICENSE) for details.
---
## Links
- [Astro Rocket on GitHub](https://github.com/hansmartens68/astro-rocket)
- [Velocity — the original theme](https://github.com/southwellmedia/velocity) by [Southwell Media](https://southwellmedia.com)
- [Astro Documentation](https://docs.astro.build)
- [Tailwind CSS v4](https://tailwindcss.com/docs)
---
**Astro Rocket** is designed and maintained by [Hans Martens](https://hansmartens.dev).
Built on [Velocity](https://github.com/southwellmedia/velocity) — the original theme by [Southwell Media](https://southwellmedia.com).
+58
View File
@@ -0,0 +1,58 @@
import { defineConfig, envField } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import react from '@astrojs/react';
import icon from 'astro-icon';
import tailwindcss from '@tailwindcss/vite';
import vercel from '@astrojs/vercel';
import netlify from '@astrojs/netlify';
const isNetlify = process.env.DEPLOY_TARGET === 'netlify';
export default defineConfig({
adapter: isNetlify ? netlify() : vercel(),
site: process.env.SITE_URL || 'https://example.com',
env: {
schema: {
SITE_URL: envField.string({ context: 'server', access: 'public', optional: true }),
PUBLIC_GA_MEASUREMENT_ID: envField.string({ context: 'client', access: 'public', optional: true }),
PUBLIC_GTM_ID: envField.string({ context: 'client', access: 'public', optional: true }),
RESEND_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
RESEND_FROM_EMAIL: envField.string({ context: 'server', access: 'secret', optional: true }),
NEWSLETTER_API_KEY: envField.string({ context: 'server', access: 'secret', optional: true }),
GOOGLE_SITE_VERIFICATION: envField.string({ context: 'server', access: 'public', optional: true }),
BING_SITE_VERIFICATION: envField.string({ context: 'server', access: 'public', optional: true }),
PUBLIC_GOOGLE_MAPS_API_KEY: envField.string({ context: 'client', access: 'public', optional: true, default: '' }),
PUBLIC_CONSENT_ENABLED: envField.boolean({ context: 'client', access: 'public', optional: true, default: false }),
PUBLIC_PRIVACY_POLICY_URL: envField.string({ context: 'client', access: 'public', optional: true, default: '' }),
},
},
image: {
layout: 'constrained',
},
integrations: [
react(),
mdx(),
sitemap(),
icon(),
],
vite: {
plugins: [tailwindcss()],
},
security: {
checkOrigin: true,
},
markdown: {
shikiConfig: {
theme: 'github-dark',
wrap: true,
},
},
});
+638
View File
@@ -0,0 +1,638 @@
{
"$schema": "./component-registry.schema.json",
"version": "2.0.0",
"categories": {
"ui": {
"name": "UI Components",
"description": "Core building blocks - buttons, forms, cards, dialogs",
"subcategories": {
"form": {
"name": "Form",
"description": "Form input components - buttons, inputs, selects"
},
"data-display": {
"name": "Data Display",
"description": "Components for displaying data - cards, badges, tables"
},
"feedback": {
"name": "Feedback",
"description": "User feedback components - alerts, toasts, tooltips"
},
"overlay": {
"name": "Overlay",
"description": "Overlay components - dialogs, dropdowns, tabs"
},
"layout": {
"name": "Layout",
"description": "Layout components - separators, dividers"
},
"primitives": {
"name": "Primitives",
"description": "Fundamental components used across the system"
},
"content": {
"name": "Content",
"description": "Content display components"
},
"marketing": {
"name": "Marketing",
"description": "Marketing and landing page components"
}
}
},
"layout": {
"name": "Layout Components",
"description": "Page structure components - header, footer, navigation"
},
"patterns": {
"name": "Patterns",
"description": "Reusable form and UI patterns"
},
"hero": {
"name": "Hero",
"description": "Flexible hero section component"
}
},
"utilities": {
"cn": {
"name": "cn utility",
"description": "Tailwind CSS class merging utility",
"files": ["src/lib/cn.ts"],
"npm": ["clsx", "tailwind-merge"]
}
},
"components": {
"button": {
"name": "Button",
"category": "ui",
"subcategory": "form",
"files": [
"src/components/ui/form/Button/Button.astro",
"src/components/ui/form/Button/Button.tsx",
"src/components/ui/form/Button/button.variants.ts",
"src/components/ui/form/Button/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"input": {
"name": "Input",
"category": "ui",
"subcategory": "form",
"files": [
"src/components/ui/form/Input/Input.astro",
"src/components/ui/form/Input/Input.tsx",
"src/components/ui/form/Input/input.variants.ts",
"src/components/ui/form/Input/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"textarea": {
"name": "Textarea",
"category": "ui",
"subcategory": "form",
"files": [
"src/components/ui/form/Textarea/Textarea.astro",
"src/components/ui/form/Textarea/Textarea.tsx",
"src/components/ui/form/Textarea/textarea.variants.ts",
"src/components/ui/form/Textarea/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"select": {
"name": "Select",
"category": "ui",
"subcategory": "form",
"files": [
"src/components/ui/form/Select/Select.astro",
"src/components/ui/form/Select/Select.tsx",
"src/components/ui/form/Select/select.variants.ts",
"src/components/ui/form/Select/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"checkbox": {
"name": "Checkbox",
"category": "ui",
"subcategory": "form",
"files": [
"src/components/ui/form/Checkbox/Checkbox.astro",
"src/components/ui/form/Checkbox/Checkbox.tsx",
"src/components/ui/form/Checkbox/checkbox.variants.ts",
"src/components/ui/form/Checkbox/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"radio": {
"name": "Radio",
"category": "ui",
"subcategory": "form",
"files": [
"src/components/ui/form/Radio/Radio.astro",
"src/components/ui/form/Radio/Radio.tsx",
"src/components/ui/form/Radio/radio.variants.ts",
"src/components/ui/form/Radio/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"switch": {
"name": "Switch",
"category": "ui",
"subcategory": "form",
"files": [
"src/components/ui/form/Switch/Switch.astro",
"src/components/ui/form/Switch/Switch.tsx",
"src/components/ui/form/Switch/switch.variants.ts",
"src/components/ui/form/Switch/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"card": {
"name": "Card",
"category": "ui",
"subcategory": "data-display",
"files": [
"src/components/ui/data-display/Card/Card.astro",
"src/components/ui/data-display/Card/Card.tsx",
"src/components/ui/data-display/Card/card.variants.ts",
"src/components/ui/data-display/Card/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"badge": {
"name": "Badge",
"category": "ui",
"subcategory": "data-display",
"files": [
"src/components/ui/data-display/Badge/Badge.astro",
"src/components/ui/data-display/Badge/badge.variants.ts",
"src/components/ui/data-display/Badge/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"avatar": {
"name": "Avatar",
"category": "ui",
"subcategory": "data-display",
"files": [
"src/components/ui/data-display/Avatar/Avatar.astro",
"src/components/ui/data-display/Avatar/avatar.variants.ts",
"src/components/ui/data-display/Avatar/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"avatar-group": {
"name": "AvatarGroup",
"category": "ui",
"subcategory": "data-display",
"files": [
"src/components/ui/data-display/AvatarGroup/AvatarGroup.astro",
"src/components/ui/data-display/AvatarGroup/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": ["avatar"]
},
"premium": false
},
"table": {
"name": "Table",
"category": "ui",
"subcategory": "data-display",
"files": [
"src/components/ui/data-display/Table/Table.astro",
"src/components/ui/data-display/Table/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"pagination": {
"name": "Pagination",
"category": "ui",
"subcategory": "data-display",
"files": [
"src/components/ui/data-display/Pagination/Pagination.astro",
"src/components/ui/data-display/Pagination/pagination.variants.ts",
"src/components/ui/data-display/Pagination/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": ["icon"]
},
"premium": false
},
"progress": {
"name": "Progress",
"category": "ui",
"subcategory": "data-display",
"files": [
"src/components/ui/data-display/Progress/Progress.astro",
"src/components/ui/data-display/Progress/progress.variants.ts",
"src/components/ui/data-display/Progress/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"skeleton": {
"name": "Skeleton",
"category": "ui",
"subcategory": "data-display",
"files": [
"src/components/ui/data-display/Skeleton/Skeleton.astro",
"src/components/ui/data-display/Skeleton/skeleton.variants.ts",
"src/components/ui/data-display/Skeleton/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"alert": {
"name": "Alert",
"category": "ui",
"subcategory": "feedback",
"files": [
"src/components/ui/feedback/Alert/Alert.astro",
"src/components/ui/feedback/Alert/alert.variants.ts",
"src/components/ui/feedback/Alert/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"toast": {
"name": "Toast",
"category": "ui",
"subcategory": "feedback",
"files": [
"src/components/ui/feedback/Toast/Toast.astro",
"src/components/ui/feedback/Toast/Toast.tsx",
"src/components/ui/feedback/Toast/ToastDemo.tsx",
"src/components/ui/feedback/Toast/toast.variants.ts",
"src/components/ui/feedback/Toast/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"tooltip": {
"name": "Tooltip",
"category": "ui",
"subcategory": "feedback",
"files": [
"src/components/ui/feedback/Tooltip/Tooltip.astro",
"src/components/ui/feedback/Tooltip/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"dialog": {
"name": "Dialog",
"category": "ui",
"subcategory": "overlay",
"files": [
"src/components/ui/overlay/Dialog/Dialog.astro",
"src/components/ui/overlay/Dialog/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"dropdown": {
"name": "Dropdown",
"category": "ui",
"subcategory": "overlay",
"files": [
"src/components/ui/overlay/Dropdown/Dropdown.astro",
"src/components/ui/overlay/Dropdown/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"tabs": {
"name": "Tabs",
"category": "ui",
"subcategory": "overlay",
"files": [
"src/components/ui/overlay/Tabs/Tabs.astro",
"src/components/ui/overlay/Tabs/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"vertical-tabs": {
"name": "VerticalTabs",
"category": "ui",
"subcategory": "overlay",
"files": [
"src/components/ui/overlay/VerticalTabs/VerticalTabs.astro",
"src/components/ui/overlay/VerticalTabs/VerticalTabs.tsx",
"src/components/ui/overlay/VerticalTabs/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"accordion": {
"name": "Accordion",
"category": "ui",
"subcategory": "overlay",
"files": [
"src/components/ui/overlay/Accordion/Accordion.astro",
"src/components/ui/overlay/Accordion/accordion.variants.ts",
"src/components/ui/overlay/Accordion/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": ["icon"]
},
"premium": false
},
"separator": {
"name": "Separator",
"category": "ui",
"subcategory": "layout",
"files": [
"src/components/ui/layout/Separator/Separator.astro",
"src/components/ui/layout/Separator/separator.variants.ts",
"src/components/ui/layout/Separator/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"icon": {
"name": "Icon",
"category": "ui",
"subcategory": "primitives",
"files": [
"src/components/ui/primitives/Icon/Icon.astro",
"src/components/ui/primitives/Icon/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"code-block": {
"name": "CodeBlock",
"category": "ui",
"subcategory": "content",
"files": [
"src/components/ui/content/CodeBlock/CodeBlock.astro",
"src/components/ui/content/CodeBlock/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"logo": {
"name": "Logo",
"category": "ui",
"subcategory": "marketing",
"files": [
"src/components/ui/marketing/Logo/Logo.astro",
"src/components/ui/marketing/Logo/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"cta": {
"name": "CTA",
"category": "ui",
"subcategory": "marketing",
"files": [
"src/components/ui/marketing/CTA/CTA.astro",
"src/components/ui/marketing/CTA/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": ["logo"]
},
"premium": false
},
"npm-copy-button": {
"name": "NpmCopyButton",
"category": "ui",
"subcategory": "marketing",
"files": [
"src/components/ui/marketing/NpmCopyButton/NpmCopyButton.astro",
"src/components/ui/marketing/NpmCopyButton/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": ["icon"]
},
"premium": false
},
"social-proof": {
"name": "SocialProof",
"category": "ui",
"subcategory": "marketing",
"files": [
"src/components/ui/marketing/SocialProof/SocialProof.astro",
"src/components/ui/marketing/SocialProof/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"terminal-demo": {
"name": "TerminalDemo",
"category": "ui",
"subcategory": "marketing",
"files": [
"src/components/ui/marketing/TerminalDemo/TerminalDemo.tsx",
"src/components/ui/marketing/TerminalDemo/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"contact-form": {
"name": "ContactForm",
"category": "patterns",
"files": ["src/components/patterns/ContactForm.astro"],
"dependencies": {
"utilities": ["cn"],
"components": ["input", "textarea", "button"]
},
"premium": false
},
"newsletter-form": {
"name": "NewsletterForm",
"category": "patterns",
"files": ["src/components/patterns/NewsletterForm.astro"],
"dependencies": {
"utilities": ["cn"],
"components": ["input", "button"]
},
"premium": false
},
"form-field": {
"name": "FormField",
"category": "patterns",
"files": ["src/components/patterns/FormField.astro"],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"search-input": {
"name": "SearchInput",
"category": "patterns",
"files": ["src/components/patterns/SearchInput.astro"],
"dependencies": {
"utilities": ["cn"],
"components": ["input"]
},
"premium": false
},
"password-input": {
"name": "PasswordInput",
"category": "patterns",
"files": ["src/components/patterns/PasswordInput.astro"],
"dependencies": {
"utilities": ["cn"],
"components": ["input", "icon"]
},
"premium": false
},
"stat-card": {
"name": "StatCard",
"category": "patterns",
"files": ["src/components/patterns/StatCard.astro"],
"dependencies": {
"utilities": ["cn"],
"components": ["card", "icon"]
},
"premium": false
},
"empty-state": {
"name": "EmptyState",
"category": "patterns",
"files": ["src/components/patterns/EmptyState.astro"],
"dependencies": {
"utilities": ["cn"],
"components": ["icon", "button"]
},
"premium": false
},
"hero": {
"name": "Hero",
"category": "hero",
"files": [
"src/components/hero/Hero.astro",
"src/components/hero/hero.variants.ts",
"src/components/hero/index.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": []
},
"premium": false
},
"header": {
"name": "Header",
"category": "layout",
"files": [
"src/components/layout/Header.astro",
"src/components/layout/header.variants.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": ["button", "icon", "logo"]
},
"premium": false
},
"footer": {
"name": "Footer",
"category": "layout",
"files": [
"src/components/layout/Footer.astro",
"src/components/layout/footer.variants.ts"
],
"dependencies": {
"utilities": ["cn"],
"components": ["icon", "logo"]
},
"premium": false
}
}
}
+133
View File
@@ -0,0 +1,133 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/hansmartens68/astro--rocket/component-registry.schema.json",
"title": "Astro Rocket Component Registry",
"description": "Schema for the Astro Rocket component registry",
"type": "object",
"required": ["version", "categories", "utilities", "components"],
"properties": {
"$schema": {
"type": "string",
"description": "JSON schema reference"
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$",
"description": "Registry version (semver)"
},
"categories": {
"type": "object",
"description": "Component categories",
"additionalProperties": {
"$ref": "#/$defs/category"
}
},
"utilities": {
"type": "object",
"description": "Shared utility files",
"additionalProperties": {
"$ref": "#/$defs/utility"
}
},
"components": {
"type": "object",
"description": "Component definitions",
"additionalProperties": {
"$ref": "#/$defs/component"
}
}
},
"$defs": {
"category": {
"type": "object",
"required": ["name", "description"],
"properties": {
"name": {
"type": "string",
"description": "Display name of the category"
},
"description": {
"type": "string",
"description": "Brief description of the category"
}
}
},
"utility": {
"type": "object",
"required": ["name", "description", "files", "npm"],
"properties": {
"name": {
"type": "string",
"description": "Display name of the utility"
},
"description": {
"type": "string",
"description": "Brief description of the utility"
},
"files": {
"type": "array",
"items": {
"type": "string"
},
"description": "File paths for this utility"
},
"npm": {
"type": "array",
"items": {
"type": "string"
},
"description": "NPM packages required by this utility"
}
}
},
"component": {
"type": "object",
"required": ["name", "category", "files", "dependencies", "premium"],
"properties": {
"name": {
"type": "string",
"description": "Display name of the component"
},
"category": {
"type": "string",
"description": "Category ID this component belongs to"
},
"subcategory": {
"type": "string",
"description": "Subcategory within the main category (e.g., form, data-display, feedback)"
},
"files": {
"type": "array",
"items": {
"type": "string"
},
"description": "File paths for this component"
},
"dependencies": {
"type": "object",
"required": ["utilities", "components"],
"properties": {
"utilities": {
"type": "array",
"items": {
"type": "string"
},
"description": "Utility IDs this component depends on"
},
"components": {
"type": "array",
"items": {
"type": "string"
},
"description": "Component IDs this component depends on"
}
}
},
"premium": {
"type": "boolean",
"description": "Whether this is a premium component"
}
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import eslintPluginAstro from 'eslint-plugin-astro';
import globals from 'globals';
export default [
eslint.configs.recommended,
...tseslint.configs.recommended,
...eslintPluginAstro.configs.recommended,
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ['**/*.astro'],
languageOptions: {
parserOptions: {
parser: tseslint.parser,
},
},
},
{
ignores: ['dist/', 'node_modules/', '.astro/', '.vercel/', 'public/pagefind/'],
},
{
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
];
+30
View File
@@ -0,0 +1,30 @@
[build]
command = "pnpm run build"
publish = "dist"
[build.environment]
NODE_VERSION = "22"
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
[[headers]]
for = "/fonts/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "/_astro/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
# Redirect rules
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/:splat"
status = 200
+95
View File
@@ -0,0 +1,95 @@
{
"name": "astro-rocket",
"version": "1.0.0",
"description": "Astro Rocket — A production-ready Astro 6 theme built on Tailwind CSS v4",
"type": "module",
"author": "Hans Martens",
"license": "MIT",
"keywords": [
"astro",
"astro-theme",
"theme",
"tailwind",
"tailwindcss",
"typescript",
"blog",
"starter",
"boilerplate",
"seo"
],
"repository": {
"type": "git",
"url": "https://github.com/hansmartens68/astro-rocket"
},
"homepage": "https://github.com/hansmartens68/astro-rocket#readme",
"bugs": {
"url": "https://github.com/hansmartens68/astro-rocket/issues"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"check": "astro check",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"validate": "pnpm lint && pnpm check && pnpm build",
"test": "vitest",
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/mdx": "5.0.0",
"@astrojs/netlify": "^7.0.2",
"@astrojs/react": "5.0.0",
"@astrojs/sitemap": "^3.7.1",
"@astrojs/vercel": "^10.0.0",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/outfit": "^5.2.8",
"@iconify-json/lucide": "^1.2.98",
"@iconify-json/simple-icons": "^1.2.74",
"@iconify/react": "^6.0.2",
"astro": "6.0.0",
"astro-icon": "^1.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"resend": "^6.9.3",
"schema-dts": "^1.1.2",
"tailwind-merge": "^2.6.0"
},
"devDependencies": {
"@astrojs/check": "0.9.7",
"@eslint/js": "^9.17.0",
"@pagefind/default-ui": "^1.3.0",
"@playwright/test": "^1.49.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.17.0",
"eslint-plugin-astro": "^1.3.0",
"globals": "^15.14.0",
"pagefind": "^1.3.0",
"prettier": "^3.4.0",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.9",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.18.0",
"vitest": "^3.2.0",
"zod": "^4.0.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"sharp"
]
},
"engines": {
"node": ">=22.12.0"
}
}
+11292
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="400" rx="80" fill="#F94C10"/>
<text
x="200" y="200"
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-family="system-ui, -apple-system, sans-serif"
font-weight="700"
font-size="260"
>A</text>
</svg>

After

Width:  |  Height:  |  Size: 376 B

View File
+35
View File
@@ -0,0 +1,35 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="1200" height="630" fill="#2563eb"/>
<!-- Lucide Rocket — scale(5), visual centre at (600, 240) -->
<g transform="translate(540, 180) scale(5)"
stroke="#dbeafe" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.9">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</g>
<!-- Wordmark -->
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-4"
fill="#eff6ff"
font-family="system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
font-weight="800">Astro Rocket</text>
<!-- Divider -->
<line x1="380" y1="436" x2="820" y2="436" stroke="#60a5fa" stroke-width="1" stroke-opacity="0.35"/>
<!-- Domain pill -->
<rect x="450" y="464" width="300" height="38" rx="19"
fill="#3b82f6" fill-opacity="0.25" stroke="#93c5fd" stroke-opacity="0.4" stroke-width="1"/>
<text x="600" y="488" font-size="13" text-anchor="middle" letter-spacing="1.5"
fill="#dbeafe"
font-family="system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif">hansmartens.dev</text>
<!-- Corner marks -->
<path d="M 36 60 L 36 36 L 60 36" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
<path d="M 1140 36 L 1164 36 L 1164 60" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
<path d="M 36 570 L 36 594 L 60 594" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
<path d="M 1140 594 L 1164 594 L 1164 570" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg width="880" height="260" viewBox="0 0 880 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Background -->
<rect width="880" height="260" fill="#2563eb"/>
<!-- Lucide Rocket — scale(5), visual centre at (440, 100) -->
<g transform="translate(379, 41) scale(5)"
stroke="#dbeafe" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none" opacity="0.9">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</g>
<!-- Wordmark -->
<text x="440" y="209"
text-anchor="middle"
font-family="system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
font-weight="800" font-size="62" letter-spacing="-1.5"
fill="#eff6ff">Astro Rocket</text>
<!-- Corner marks -->
<path d="M 30 50 L 30 30 L 50 30" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
<path d="M 830 30 L 850 30 L 850 50" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
<path d="M 30 210 L 30 230 L 50 230" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
<path d="M 830 230 L 850 230 L 850 210" stroke="#60a5fa" stroke-width="1.5" fill="none" stroke-opacity="0.45"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
// Schema mirroring src/pages/api/contact.ts
const contactSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
email: z.email('Please enter a valid email address'),
subject: z.string().max(200).optional(),
message: z.string().min(10, 'Message must be at least 10 characters').max(5000),
honeypot: z.string().max(0),
});
describe('Contact form validation', () => {
const validData = {
name: 'Jane Doe',
email: 'jane@example.com',
subject: 'Hello',
message: 'This is a test message that is long enough.',
honeypot: '',
};
it('accepts valid form data', () => {
const result = contactSchema.safeParse(validData);
expect(result.success).toBe(true);
});
it('accepts data without optional subject', () => {
const { subject: _s, ...data } = validData;
const result = contactSchema.safeParse(data);
expect(result.success).toBe(true);
});
it('rejects name shorter than 2 characters', () => {
const result = contactSchema.safeParse({ ...validData, name: 'J' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Name must be at least 2 characters');
}
});
it('rejects invalid email address', () => {
const result = contactSchema.safeParse({ ...validData, email: 'not-an-email' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path[0]).toBe('email');
}
});
it('rejects message shorter than 10 characters', () => {
const result = contactSchema.safeParse({ ...validData, message: 'Too short' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Message must be at least 10 characters');
}
});
it('rejects filled honeypot field (bot detection)', () => {
const result = contactSchema.safeParse({ ...validData, honeypot: 'bot-value' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path[0]).toBe('honeypot');
}
});
it('rejects subject longer than 200 characters', () => {
const result = contactSchema.safeParse({ ...validData, subject: 'a'.repeat(201) });
expect(result.success).toBe(false);
});
it('rejects message longer than 5000 characters', () => {
const result = contactSchema.safeParse({ ...validData, message: 'a'.repeat(5001) });
expect(result.success).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
// Schema mirroring src/pages/api/newsletter.ts
const newsletterSchema = z.object({
email: z.email('Please enter a valid email address'),
honeypot: z.string().max(0).optional(),
});
describe('Newsletter form validation', () => {
it('accepts a valid email address', () => {
const result = newsletterSchema.safeParse({ email: 'user@example.com', honeypot: '' });
expect(result.success).toBe(true);
});
it('rejects an invalid email address', () => {
const result = newsletterSchema.safeParse({ email: 'not-an-email' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Please enter a valid email address');
}
});
it('rejects an empty email field', () => {
const result = newsletterSchema.safeParse({ email: '' });
expect(result.success).toBe(false);
});
it('rejects filled honeypot field (bot detection)', () => {
const result = newsletterSchema.safeParse({ email: 'user@example.com', honeypot: 'bot' });
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path[0]).toBe('honeypot');
}
});
it('accepts missing honeypot field (it is optional)', () => {
const result = newsletterSchema.safeParse({ email: 'user@example.com' });
expect(result.success).toBe(true);
});
});
View File
@@ -0,0 +1,24 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Zap -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</g>
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-2" class="txt">animations</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,32 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Sliders -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<line x1="4" x2="4" y1="21" y2="14"/>
<line x1="4" x2="4" y1="10" y2="3"/>
<line x1="12" x2="12" y1="21" y2="12"/>
<line x1="12" x2="12" y1="8" y2="3"/>
<line x1="20" x2="20" y1="21" y2="16"/>
<line x1="20" x2="20" y1="12" y2="3"/>
<line x1="2" x2="6" y1="14" y2="14"/>
<line x1="10" x2="14" y1="8" y2="8"/>
<line x1="18" x2="22" y1="16" y2="16"/>
</g>
<text x="600" y="410" font-size="92" text-anchor="middle" letter-spacing="-3" class="txt">configuration</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+26
View File
@@ -0,0 +1,26 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Sparkles -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>
<path d="M20 3v4"/><path d="M22 5h-4"/>
<path d="M4 17v2"/><path d="M5 18H3"/>
</g>
<text x="600" y="410" font-size="120" text-anchor="middle" letter-spacing="-4" class="txt">features</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,27 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Rocket -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</g>
<text x="600" y="410" font-size="108" text-anchor="middle" letter-spacing="-3" class="txt">get started</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Zap -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
</g>
<text x="600" y="410" font-size="120" text-anchor="middle" letter-spacing="-4" class="txt">intro</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Rocket -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/>
<path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/>
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
</g>
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-4" class="txt">Astro Rocket</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+30
View File
@@ -0,0 +1,30 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
.num { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; fill-opacity: 0.18; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Large faint "57" in background -->
<text x="600" y="435" font-size="340" text-anchor="middle" letter-spacing="-8" class="num">57</text>
<!-- Lucide layout-dashboard icon: 4 panels of different sizes, scale=5, centered at (600,255) -->
<g transform="translate(540, 195) scale(5)" class="ico" opacity="0.9">
<rect width="7" height="9" x="3" y="3" rx="1"/>
<rect width="7" height="5" x="14" y="3" rx="1"/>
<rect width="7" height="9" x="14" y="12" rx="1"/>
<rect width="7" height="5" x="3" y="16" rx="1"/>
</g>
<text x="600" y="410" font-size="88" text-anchor="middle" letter-spacing="-2" class="txt">components</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,31 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Sunset -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<path d="M12 10V2"/>
<path d="m4.93 10.93 1.41 1.41"/>
<path d="M2 18h2"/>
<path d="M20 18h2"/>
<path d="m19.07 10.93-1.41 1.41"/>
<path d="M22 22H2"/>
<path d="m16 6-4 4-4-4"/>
<path d="M16 18a4 4 0 0 0-8 0"/>
</g>
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-2" class="txt">gradient</text>
<line x1="370" y1="436" x2="830" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,24 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Moon -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</g>
<text x="600" y="410" font-size="120" text-anchor="middle" letter-spacing="-4" class="txt">dark mode</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,24 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Droplet -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"/>
</g>
<text x="600" y="410" font-size="100" text-anchor="middle" letter-spacing="-3" class="txt">color tokens</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Terminal -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<polyline points="4 17 10 11 4 5"/>
<line x1="12" x2="20" y1="19" y2="19"/>
</g>
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-2" class="txt">typing effect</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+31
View File
@@ -0,0 +1,31 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
.bar-track { fill: var(--brand-400); fill-opacity: 0.25; rx: 4; }
.bar-fill { fill: var(--brand-100); fill-opacity: 0.85; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Progress bar illustration -->
<g transform="translate(360, 218)">
<!-- Track -->
<rect x="0" y="16" width="480" height="8" rx="4" class="bar-track"/>
<!-- Filled portion (~62%) -->
<rect x="0" y="16" width="298" height="8" rx="4" class="bar-fill"/>
<!-- Indicator dot -->
<circle cx="298" cy="20" r="7" fill="var(--brand-50)" fill-opacity="0.95"/>
</g>
<text x="600" y="410" font-size="96" text-anchor="middle" letter-spacing="-2" class="txt">progress</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
<style>
.bg { fill: var(--brand-500); }
.ico { stroke: var(--brand-100); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; fill: none; }
.txt { fill: var(--brand-50); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; font-weight: 800; }
.ln { stroke: var(--brand-300); stroke-width: 1; stroke-opacity: 0.35; }
.pil { fill: var(--brand-400); fill-opacity: 0.25; stroke: var(--brand-200); stroke-opacity: 0.4; stroke-width: 1; }
.ptx { fill: var(--brand-100); font-family: 'Outfit Variable', Outfit, system-ui, -apple-system, sans-serif; }
.cor { stroke: var(--brand-300); stroke-width: 1.5; fill: none; stroke-opacity: 0.45; }
</style>
<rect width="1200" height="630" class="bg"/>
<!-- Lucide Search -->
<g transform="translate(540, 180) scale(5)" class="ico" opacity="0.9">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</g>
<text x="600" y="410" font-size="120" text-anchor="middle" letter-spacing="-4" class="txt">seo</text>
<line x1="380" y1="436" x2="820" y2="436" class="ln"/>
<rect x="450" y="464" width="300" height="38" rx="19" class="pil"/>
<text x="600" y="487" font-size="16" text-anchor="middle" class="ptx">hansmartens.dev</text>
<path d="M 36 60 L 36 36 L 60 36" class="cor"/>
<path d="M 1140 36 L 1164 36 L 1164 60" class="cor"/>
<path d="M 36 570 L 36 594 L 60 594" class="cor"/>
<path d="M 1140 594 L 1164 594 L 1164 570" class="cor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+38
View File
@@ -0,0 +1,38 @@
<svg width="880" height="260" viewBox="0 0 880 260" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="glow" cx="440" cy="90" r="280" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#10b981" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#10b981" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Background -->
<rect width="880" height="260" fill="#0d1117"/>
<rect width="880" height="260" fill="url(#glow)"/>
<!-- Top accent stripe -->
<rect width="880" height="4" fill="#10b981"/>
<!-- Corner marks -->
<path d="M 30 50 L 30 30 L 50 30" stroke="#10b981" stroke-width="1.5" fill="none" stroke-opacity="0.5"/>
<path d="M 830 30 L 850 30 L 850 50" stroke="#10b981" stroke-width="1.5" fill="none" stroke-opacity="0.5"/>
<path d="M 30 210 L 30 230 L 50 230" stroke="#10b981" stroke-width="1.5" fill="none" stroke-opacity="0.5"/>
<path d="M 830 230 L 850 230 L 850 210" stroke="#10b981" stroke-width="1.5" fill="none" stroke-opacity="0.5"/>
<!-- Lucide Rocket — scale(5), visual centre at (440, 90) -->
<!-- Icon x≈2.522, y≈221 in 24×24; at scale 5: translate(379, 35) -->
<g transform="translate(379, 35) scale(5)"
stroke="#10b981" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none">
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09"/>
<path d="M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05"/>
</g>
<!-- Wordmark — baseline y=218, icon bottom ≈ y=140, gap ≈ 33 px -->
<text x="440" y="218"
text-anchor="middle"
font-family="system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
font-weight="800" font-size="62" letter-spacing="-1.5"
fill="white">Astro <tspan fill="#10b981">Rocket</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+132
View File
@@ -0,0 +1,132 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import { formatDate } from '@/lib/utils';
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
import BlogImageSVG from '@/components/blog/BlogImageSVG.astro';
interface Props {
title: string;
description: string;
publishedAt: Date;
updatedAt?: Date;
author?: string;
tags?: string[];
image?: ImageMetadata;
imageAlt?: string;
svgSlug?: string;
}
const {
title,
description,
publishedAt,
updatedAt,
author = 'Team',
tags = [],
image,
imageAlt,
svgSlug,
} = Astro.props;
// Estimate reading time
const wordsPerMinute = 200;
const estimatedWords = description.split(' ').length * 15;
const readingTime = Math.max(1, Math.ceil(estimatedWords / wordsPerMinute));
---
<header class="relative overflow-hidden pt-[var(--space-page-top-sm)] pb-[var(--space-section)]">
<div class="relative mx-auto max-w-4xl px-6 animate-hero-slide-up">
<!-- Tags -->
{tags.length > 0 && (
<div class="mb-[var(--space-heading-gap)] flex flex-wrap gap-2">
{tags.map((tag) => (
<span class="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/30 px-3 py-1 text-xs font-semibold text-brand-700 dark:text-brand-300 ring-1 ring-inset ring-brand-200 dark:ring-brand-800">
{tag}
</span>
))}
</div>
)}
<!-- Title -->
<h1 class="font-display text-4xl font-bold tracking-tight text-foreground md:text-5xl lg:text-6xl mb-[var(--space-heading-gap)]">
{title}
</h1>
<!-- Description -->
<p class="text-xl text-foreground-muted leading-relaxed max-w-3xl mb-[var(--space-stack-lg)]">
{description}
</p>
<!-- Meta info -->
<div class="flex flex-wrap items-center gap-[var(--space-stack-lg)] text-sm text-foreground-muted">
<!-- Author -->
<div class="flex items-center gap-3">
<Logo size="sm" letter={author.charAt(0).toUpperCase()} />
<p class="font-semibold text-foreground">{author}</p>
</div>
<div class="h-8 w-px bg-border hidden md:block"></div>
<!-- Published date -->
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
<time datetime={publishedAt.toISOString()}>
{formatDate(publishedAt)}
</time>
</div>
{updatedAt && (
<>
<div class="h-8 w-px bg-border hidden md:block"></div>
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<time datetime={updatedAt.toISOString()}>
Updated {formatDate(updatedAt)}
</time>
</div>
</>
)}
<div class="h-8 w-px bg-border hidden md:block"></div>
<!-- Reading time -->
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{readingTime} min read</span>
</div>
</div>
</div>
{(svgSlug || image) && (
<div class="relative mx-auto max-w-5xl px-6 mt-[var(--space-section)] animate-hero-slide-up [animation-delay:200ms]">
{svgSlug ? (
<div
class="relative overflow-hidden rounded-xl border border-border shadow-2xl
bg-gradient-to-br from-brand-100/50 to-brand-50/30 dark:from-brand-900/50 dark:to-brand-800/30"
style="color: var(--brand-500);"
>
<BlogImageSVG slug={svgSlug} title={imageAlt || title} />
</div>
) : image ? (
<div class="relative overflow-hidden rounded-xl border border-border shadow-2xl">
<Image
src={image}
alt={imageAlt || title}
layout="full-width"
widths={[640, 960, 1280, 1920]}
sizes="100vw"
class="aspect-video w-full object-cover"
loading="eager"
/>
</div>
) : null}
</div>
)}
</header>
+104
View File
@@ -0,0 +1,104 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import { formatDate } from '@/lib/utils';
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
import BlogImageSVG from '@/components/blog/BlogImageSVG.astro';
interface Props {
title: string;
description: string;
href: string;
publishedAt: Date;
tags?: string[];
featured?: boolean;
author?: string;
image?: ImageMetadata;
svgSlug?: string;
}
const {
title,
description,
href,
publishedAt,
tags = [],
author,
image,
svgSlug,
} = Astro.props;
// Estimate reading time (rough estimate based on average words)
const wordsPerMinute = 200;
const estimatedWords = description.split(' ').length * 10; // Rough estimate
const readingTime = Math.max(1, Math.ceil(estimatedWords / wordsPerMinute));
---
<article class="group rounded-lg border border-brand-500/30 bg-background p-6 ring-1 ring-brand-500/20 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md">
<a href={href} class="block">
<div
class="relative mb-4 overflow-hidden rounded-md
bg-background-secondary bg-gradient-to-br from-brand-100/65 to-transparent dark:from-brand-900/60 dark:to-brand-800/25"
style="color: var(--brand-500);"
>
{svgSlug ? (
<div class="transition-transform duration-300 group-hover:scale-105">
<BlogImageSVG slug={svgSlug} title={title} />
</div>
) : image ? (
<Image
src={image}
alt={title}
widths={[320, 640, 960]}
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 400px"
class="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
) : (
<div class="aspect-video w-full" />
)}
</div>
<h2 class="font-display text-xl font-bold text-foreground transition-colors group-hover:text-brand-600 dark:group-hover:text-brand-400 mb-2">
{title}
</h2>
<p class="text-foreground-muted line-clamp-2 mb-4">
{description}
</p>
<div class="flex flex-wrap items-center gap-3 text-sm text-foreground-subtle">
{author && (
<div class="flex items-center gap-2">
<Logo size="sm" letter={author.charAt(0).toUpperCase()} />
<span class="font-medium">{author}</span>
</div>
)}
<time datetime={publishedAt.toISOString()} class="flex items-center gap-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
{formatDate(publishedAt)}
</time>
<span class="flex items-center gap-1">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{readingTime} min read
</span>
</div>
{tags.length > 0 && (
<div class="mt-4 flex flex-wrap gap-2">
{tags.map((tag) => (
<span class="inline-flex items-center rounded-full bg-background-secondary px-2.5 py-0.5 text-xs font-medium text-foreground-muted ring-1 ring-inset ring-border transition-colors group-hover:bg-brand-50 group-hover:text-brand-700 group-hover:ring-brand-200 dark:group-hover:bg-brand-900/20 dark:group-hover:text-brand-400 dark:group-hover:ring-brand-800">
{tag}
</span>
))}
</div>
)}
</a>
</article>
+56
View File
@@ -0,0 +1,56 @@
---
const svgs = import.meta.glob<string>('/src/assets/blog/*.svg', { as: 'raw', eager: true });
interface Props {
slug: string;
title: string;
}
const { slug, title } = Astro.props;
const svgContent = svgs[`/src/assets/blog/${slug}.svg`] ?? '';
---
{svgContent && (
<div class="svg-host" role="img" aria-label={title} set:html={svgContent} />
)}
<style>
.svg-host :global(svg) {
width: 100%;
height: auto;
display: block;
}
@media (min-width: 640px) {
.svg-host :global(svg) {
transform: scale(1.35);
transform-origin: center center;
}
}
/* ── Light mode ─────────────────────────────────────────────────────────
Brand-500 background — the saturated mid-tone brand colour. Light
icons and text sit on top for vivid, colourful images in both modes.
── */
.svg-host :global(.bg) { fill: var(--brand-500); }
.svg-host :global(.ico) { stroke: var(--brand-50); }
.svg-host :global(.txt) { fill: var(--brand-50); }
.svg-host :global(.ln) { stroke: var(--brand-200); stroke-opacity: 0.5; }
.svg-host :global(.pil) { fill: var(--brand-300); fill-opacity: 0.35; stroke: var(--brand-100); stroke-opacity: 0.8; }
.svg-host :global(.ptx) { fill: var(--brand-50); }
.svg-host :global(.cor) { stroke: var(--brand-200); stroke-opacity: 0.7; }
.svg-host :global(.num) { fill: var(--brand-100); fill-opacity: 0.18; }
/* ── Dark mode ───────────────────────────────────────────────────────────
Deep background with all brand colors at full opacity — vivid, not faded.
── */
:global(html.dark) .svg-host :global(.bg) { fill: var(--brand-800); }
:global(html.dark) .svg-host :global(.ico) { stroke: var(--brand-200); }
:global(html.dark) .svg-host :global(.txt) { fill: var(--brand-50); }
:global(html.dark) .svg-host :global(.ln) { stroke: var(--brand-300); stroke-opacity: 0.5; }
:global(html.dark) .svg-host :global(.pil) { fill: var(--brand-600); fill-opacity: 0.4; stroke: var(--brand-300); stroke-opacity: 0.7; }
:global(html.dark) .svg-host :global(.ptx) { fill: var(--brand-100); }
:global(html.dark) .svg-host :global(.cor) { stroke: var(--brand-300); stroke-opacity: 0.6; }
:global(html.dark) .svg-host :global(.num) { fill: var(--brand-50); fill-opacity: 0.18; }
</style>
+67
View File
@@ -0,0 +1,67 @@
---
import BlogCard from './BlogCard.astro';
import { getCollection } from 'astro:content';
interface Props {
currentSlug: string;
tags: string[];
locale?: string;
maxPosts?: number;
}
const { currentSlug, tags, locale = 'en', maxPosts = 3 } = Astro.props;
// Get all published posts in the same locale
const allPosts = await getCollection('blog', ({ data }) => {
return data.locale === locale && (import.meta.env.PROD ? data.draft !== true : true);
});
// Filter to find posts with matching tags
const relatedPosts = allPosts
.filter((post) => {
// Exclude current post
if (post.id === currentSlug || post.id.endsWith(`/${currentSlug}`)) return false;
// Must have at least one matching tag
if (!tags.length) return false;
return post.data.tags.some((tag) => tags.includes(tag));
})
// Sort by number of matching tags, then by date
.sort((a, b) => {
const aMatches = a.data.tags.filter((tag) => tags.includes(tag)).length;
const bMatches = b.data.tags.filter((tag) => tags.includes(tag)).length;
if (bMatches !== aMatches) return bMatches - aMatches;
return b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf();
})
.slice(0, maxPosts);
// Generate URLs for each post (remove locale prefix from id)
const getPostUrl = (postId: string) => {
const slug = postId.replace(`${locale}/`, '');
return `/blog/${slug}`;
};
---
{relatedPosts.length > 0 && (
<section class="border-t border-border py-[var(--space-section-sm)]">
<h2 class="font-display text-2xl font-bold text-foreground mb-[var(--space-stack-lg)]">
Related Posts
</h2>
<div class="grid gap-[var(--space-stack-lg)] md:grid-cols-2 lg:grid-cols-3">
{relatedPosts.map((post) => (
<BlogCard
title={post.data.title}
description={post.data.description}
href={getPostUrl(post.id)}
publishedAt={post.data.publishedAt}
tags={post.data.tags}
author={post.data.author}
image={post.data.image}
svgSlug={post.data.svgSlug}
/>
))}
</div>
</section>
)}
+96
View File
@@ -0,0 +1,96 @@
---
interface Props {
title: string;
url: string;
}
const { title, url } = Astro.props;
const encodedTitle = encodeURIComponent(title);
const encodedUrl = encodeURIComponent(url);
const shareLinks = {
twitter: `https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`,
linkedin: `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`,
};
---
<div class="flex items-center gap-4">
<span class="text-sm font-medium text-foreground-muted">Share:</span>
<div class="flex items-center gap-2">
<!-- Twitter/X -->
<a
href={shareLinks.twitter}
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600"
aria-label="Share on Twitter"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
<!-- LinkedIn -->
<a
href={shareLinks.linkedin}
target="_blank"
rel="noopener noreferrer"
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600"
aria-label="Share on LinkedIn"
>
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
<!-- Copy Link -->
<button
type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-brand-500 text-white border border-brand-500 transition-all hover:bg-brand-600 hover:border-brand-600 copy-link-btn"
aria-label="Copy link"
data-url={url}
>
<svg class="h-4 w-4 copy-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
</svg>
<svg class="h-4 w-4 check-icon hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</button>
</div>
</div>
<script>
function initShareButtons() {
document.querySelectorAll('.copy-link-btn').forEach((button) => {
button.addEventListener('click', async () => {
const url = button.getAttribute('data-url');
if (!url) return;
try {
await navigator.clipboard.writeText(url);
const copyIcon = button.querySelector('.copy-icon');
const checkIcon = button.querySelector('.check-icon');
if (copyIcon && checkIcon) {
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
}, 2000);
}
} catch {
// Clipboard API failed - user will need to copy manually
}
});
});
}
initShareButtons();
document.addEventListener('astro:page-load', initShareButtons);
</script>
+147
View File
@@ -0,0 +1,147 @@
---
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { heroSectionVariants } from './hero.variants';
interface Props extends HTMLAttributes<'section'> {
/** Layout mode: centered single column or split two-column */
layout?: 'centered' | 'split';
/** Vertical padding size */
size?: 'sm' | 'md' | 'lg' | 'xl';
/** Show background grid pattern */
showGrid?: boolean;
/** Apply the dark-mode hero gradient (black → brand). Homepage only. */
gradient?: boolean;
}
const {
layout = 'centered',
size = 'lg',
showGrid = false,
gradient = false,
class: className,
...attrs
} = Astro.props;
// Check which slots are provided
const hasBadgeSlot = Astro.slots.has('badge');
const hasTitleSlot = Astro.slots.has('title');
const hasDescriptionSlot = Astro.slots.has('description');
const hasActionsSlot = Astro.slots.has('actions');
const hasAsideSlot = Astro.slots.has('aside');
// Compute alignment based on layout
const alignment = layout === 'centered' ? 'text-center items-center' : 'text-left items-start';
// Compute classes
const sectionClasses = cn(heroSectionVariants({ size }), gradient && 'hero-dark-gradient', className);
const contentClasses = cn(
'z-10 flex flex-col',
alignment
);
const gridClasses = cn(
'mx-auto grid max-w-6xl grid-cols-1 items-center gap-[var(--space-section-gap)] px-6',
layout === 'split' && 'lg:grid-cols-2 lg:gap-[var(--space-section-gap)]'
);
---
<section class={sectionClasses} {...attrs}>
{showGrid && (
<div
class="bg-grid-pattern pointer-events-none absolute inset-x-0 -inset-y-[20%] opacity-30"
style="mask-image: radial-gradient(ellipse 50% 50% at 50% 40%, black 0%, transparent 70%);"
data-parallax="0.2"
aria-hidden="true"
/>
)}
<div class={gridClasses}>
{/* Content Column */}
<div class={cn(contentClasses, 'order-1 lg:order-none animate-hero-slide-up')}>
{/* Badge Slot */}
{hasBadgeSlot && (
<div class="mb-[var(--space-heading-gap)]">
<slot name="badge" />
</div>
)}
{/* Title Slot */}
{hasTitleSlot && (
<div class={cn(
'hero-title-slot mb-[var(--space-heading-gap)]',
'[&>h1]:font-display [&>h1]:text-5xl [&>h1]:leading-[1.1] [&>h1]:font-bold [&>h1]:tracking-tight [&>h1]:text-balance [&>h1]:text-foreground md:[&>h1]:text-6xl lg:[&>h1]:text-7xl',
'[&>h2]:font-display [&>h2]:text-4xl [&>h2]:leading-[1.1] [&>h2]:font-bold [&>h2]:tracking-tight [&>h2]:text-balance [&>h2]:text-foreground md:[&>h2]:text-5xl lg:[&>h2]:text-6xl'
)}>
<slot name="title" />
</div>
)}
{/* Description Slot */}
{hasDescriptionSlot && (
<div class={cn(
'mb-[var(--space-stack-lg)] max-w-xl text-lg leading-relaxed text-foreground-muted',
'[&>p]:text-lg [&>p]:leading-relaxed [&>p]:text-foreground-muted',
layout === 'centered' && 'mx-auto'
)}>
<slot name="description" />
</div>
)}
{/* Actions Slot */}
{hasActionsSlot && (
<div class={cn(
'flex w-full flex-col gap-4 sm:w-auto sm:flex-row',
layout === 'centered' && 'justify-center'
)}>
<slot name="actions" />
</div>
)}
{/* Default slot for additional content (social proof, etc.) */}
<slot />
</div>
{/* Aside Column (for split layout) */}
{layout === 'split' && hasAsideSlot && (
<div class="relative z-10 w-full order-2 lg:order-none">
<slot name="aside" />
</div>
)}
</div>
</section>
<script>
// Parallax: move [data-parallax] layers at a fraction of scroll speed.
// Elements with -inset-y-[20%] have extra room to move without clipping.
// Skipped entirely when the user prefers reduced motion.
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
const layers = document.querySelectorAll<HTMLElement>('[data-parallax]');
function applyParallax() {
layers.forEach((el) => {
const section = el.closest('section');
if (!section) return;
const rect = section.getBoundingClientRect();
// Skip sections fully outside the viewport
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
const speed = parseFloat(el.dataset.parallax ?? '0.3');
// Offset relative to how far the section top is from the viewport top
const offset = -rect.top * speed;
el.style.transform = `translateY(${offset.toFixed(2)}px)`;
});
}
window.addEventListener('scroll', applyParallax, { passive: true });
applyParallax();
// Clean up the listener when navigating away to prevent accumulation
document.addEventListener('astro:before-swap', () => {
window.removeEventListener('scroll', applyParallax);
}, { once: true });
}
</script>
+17
View File
@@ -0,0 +1,17 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const heroSectionVariants = cva('relative overflow-hidden bg-background', {
variants: {
size: {
sm: 'pt-[var(--space-page-top-sm)] pb-[var(--space-section-sm)]',
md: 'pt-[var(--space-page-top)] pb-[var(--space-section-md)]',
lg: 'pt-[calc(var(--space-page-top)_+_var(--space-8))] pb-[var(--space-section-lg)]',
xl: 'pt-[calc(var(--space-page-top)_+_var(--space-16))] pb-[var(--space-section-xl)]',
},
},
defaultVariants: {
size: 'lg',
},
});
export type HeroSectionVariants = VariantProps<typeof heroSectionVariants>;
+3
View File
@@ -0,0 +1,3 @@
export { default as Hero } from './Hero.astro';
export { heroSectionVariants } from './hero.variants';
export type { HeroSectionVariants } from './hero.variants';
+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>
+261
View File
@@ -0,0 +1,261 @@
---
/**
* Analytics Component
*
* Conditionally loads analytics scripts based on environment variables.
* Supports Google Analytics (GA4) and Google Tag Manager.
*
* When PUBLIC_CONSENT_ENABLED is true, integrates with Google Consent Mode v2:
* - consent_mode_v2: Scripts load normally but with denied defaults (cookieless pings)
* - strict: Scripts only load after explicit user consent
*
* Environment variables:
* - PUBLIC_GA_MEASUREMENT_ID: Google Analytics 4 Measurement ID (e.g., G-XXXXXXXXXX)
* - PUBLIC_GTM_ID: Google Tag Manager Container ID (e.g., GTM-XXXXXXX)
* - PUBLIC_CONSENT_ENABLED: Enable cookie consent integration (boolean)
*
* CSP: All is:inline scripts here are STATIC (no define:vars). Dynamic values are
* passed via <script type="application/json"> data elements. Pin each script's
* sha256 hash in astro.config.mjs → security.csp.scriptDirective.hashes.
*
* Required CSP directives for GA4/GTM:
*
* script-src:
* https://*.googletagmanager.com
* https://*.google-analytics.com
* https://analytics.google.com
*
* connect-src:
* https://*.google-analytics.com (covers region1, region2, etc.)
* https://analytics.google.com (separate domain, not a subdomain)
* https://*.googletagmanager.com
*
* IMPORTANT: GA4 sends data to regional endpoints like
* https://region1.google-analytics.com/g/s/collect — whitelisting only
* www.google-analytics.com will silently drop all tracking requests with
* no console errors. The wildcard *.google-analytics.com is required.
*/
import { PUBLIC_GA_MEASUREMENT_ID, PUBLIC_GTM_ID, PUBLIC_CONSENT_ENABLED } from 'astro:env/client';
import consentConfig from '@/config/consent.config';
const gaId = PUBLIC_GA_MEASUREMENT_ID;
const gtmId = PUBLIC_GTM_ID;
const consentEnabled = PUBLIC_CONSENT_ENABLED;
const consentMode = consentConfig.mode;
const storageKey = consentConfig.storageKey;
const configVersion = consentConfig.version;
// Build GCM default values from config categories
const gcmDefaults: Record<string, string> = {};
for (const [, cat] of Object.entries(consentConfig.categories)) {
for (const gcmType of cat.gcmTypes) {
gcmDefaults[gcmType] = cat.defaultEnabled ? 'granted' : 'denied';
}
}
// Build category default states from config (e.g. { necessary: true, analytics: false, ... })
const categoryDefaults: Record<string, boolean> = {};
for (const [key, cat] of Object.entries(consentConfig.categories)) {
categoryDefaults[key] = cat.required || cat.defaultEnabled;
}
// Build category → GCM type mapping for dynamic resolution
const categoryGcmMap: Record<string, string[]> = {};
for (const [key, cat] of Object.entries(consentConfig.categories)) {
categoryGcmMap[key] = cat.gcmTypes;
}
// Derive the category key that maps to analytics_storage (for strict-mode guards)
let analyticsCategoryKey = 'analytics';
for (const [key, cat] of Object.entries(consentConfig.categories)) {
if (cat.gcmTypes.includes('analytics_storage')) {
analyticsCategoryKey = key;
break;
}
}
// Rendering flags — keeps template conditions simple and highlighter-friendly
const hasGtm = !!gtmId;
const hasGa = !!gaId && !gtmId;
const hasAnalytics = !!(gaId || gtmId);
const noConsent = !consentEnabled;
const isV2 = consentEnabled && consentMode === 'consent_mode_v2';
const isStrict = consentEnabled && consentMode === 'strict';
// JSON data for all analytics scripts (single data element avoids duplication)
const analyticsDataJson = JSON.stringify({
gtmId: gtmId || null,
gaId: gaId || null,
storageKey,
configVersion,
gcmDefaults,
categoryDefaults,
categoryGcmMap,
analyticsCategoryKey,
});
---
{/* Analytics data element — read by all inline scripts below */}
{hasAnalytics && (
<script type="application/json" id="analytics-data" set:html={analyticsDataJson} />
)}
{/* ── No consent: load GTM directly ── */}
{noConsent && hasGtm && (
<script is:inline>
(function(){
const d = JSON.parse(document.getElementById('analytics-data').textContent);
const i = d.gtmId;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
const f = document.getElementsByTagName('script')[0],
j = document.createElement('script');
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
f.parentNode.insertBefore(j, f);
})();
</script>
)}
{/* ── No consent: load GA directly ── */}
{noConsent && hasGa && (
<>
<script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
<script is:inline>
(function(){
const d = JSON.parse(document.getElementById('analytics-data').textContent);
window.dataLayer = window.dataLayer || [];
function gtag(...args){window.dataLayer.push(args);}
gtag('js', new Date());
gtag('config', d.gaId);
})();
</script>
</>
)}
{/* ── Consent enabled: set consent defaults BEFORE loading scripts ── */}
{consentEnabled && hasAnalytics && (
<script is:inline>
(function(){
const d = JSON.parse(document.getElementById('analytics-data').textContent);
window.dataLayer = window.dataLayer || [];
function gtag(...args){window.dataLayer.push(args);}
let stored = null;
try {
const raw = localStorage.getItem(d.storageKey);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && parsed.version === d.configVersion) {
stored = parsed;
}
}
} catch { /* ignored */ }
const gcmValues = {};
const keys = Object.keys(d.gcmDefaults);
for (let k = 0; k < keys.length; k++) { gcmValues[keys[k]] = d.gcmDefaults[keys[k]]; }
let decided = false;
let categories = {};
const catKeys = Object.keys(d.categoryDefaults);
for (let c = 0; c < catKeys.length; c++) { categories[catKeys[c]] = d.categoryDefaults[catKeys[c]]; }
if (stored && stored.categories) {
decided = true;
categories = stored.categories;
const mapKeys = Object.keys(d.categoryGcmMap);
for (let m = 0; m < mapKeys.length; m++) {
const catKey = mapKeys[m];
const granted = !!categories[catKey];
const gcmTypes = d.categoryGcmMap[catKey];
for (let g = 0; g < gcmTypes.length; g++) {
gcmValues[gcmTypes[g]] = granted ? 'granted' : 'denied';
}
}
}
gtag('consent', 'default', gcmValues);
window.__consentState = { decided: decided, categories: categories };
})();
</script>
)}
{/* ── V2 mode: load GTM after consent defaults ── */}
{isV2 && hasGtm && (
<script is:inline>
(function(){
const d = JSON.parse(document.getElementById('analytics-data').textContent);
const i = d.gtmId;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
const f = document.getElementsByTagName('script')[0],
j = document.createElement('script');
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
f.parentNode.insertBefore(j, f);
})();
</script>
)}
{/* ── V2 mode: load GA after consent defaults ── */}
{isV2 && hasGa && (
<>
<script is:inline async src={`https://www.googletagmanager.com/gtag/js?id=${gaId}`}></script>
<script is:inline>
(function(){
const d = JSON.parse(document.getElementById('analytics-data').textContent);
window.dataLayer = window.dataLayer || [];
function gtag(...args){window.dataLayer.push(args);}
gtag('js', new Date());
gtag('config', d.gaId);
})();
</script>
</>
)}
{/* ── Strict mode: meta tags for consent banner dynamic injection ── */}
{isStrict && hasGtm && (
<meta name="gtm-id" content={gtmId} />
)}
{isStrict && hasGa && (
<meta name="ga-id" content={gaId} />
)}
{/* ── Strict mode: conditionally load GTM if consent already granted ── */}
{isStrict && hasGtm && (
<script is:inline>
(function(){
const d = JSON.parse(document.getElementById('analytics-data').textContent);
if (window.__consentState && window.__consentState.decided && window.__consentState.categories[d.analyticsCategoryKey]) {
const i = d.gtmId;
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' });
const f = document.getElementsByTagName('script')[0],
j = document.createElement('script');
j.async = true;
j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i;
f.parentNode.insertBefore(j, f);
}
})();
</script>
)}
{/* ── Strict mode: conditionally load GA if consent already granted ── */}
{isStrict && hasGa && (
<script is:inline>
(function(){
const d = JSON.parse(document.getElementById('analytics-data').textContent);
if (window.__consentState && window.__consentState.decided && window.__consentState.categories[d.analyticsCategoryKey]) {
const s = document.createElement('script');
s.async = true;
s.src = 'https://www.googletagmanager.com/gtag/js?id=' + d.gaId;
document.head.appendChild(s);
window.dataLayer = window.dataLayer || [];
function gtag(...args){window.dataLayer.push(args);}
gtag('js', new Date());
gtag('config', d.gaId);
}
})();
</script>
)}
+392
View File
@@ -0,0 +1,392 @@
---
/**
* Footer Component
* Flexible footer with variant-based configuration
*
* Layouts:
* - simple: Single row with logo, nav links, and social
* - columns: Multi-column layout with link groups
* - minimal: Just copyright
* - stacked: Vertically stacked logo, nav, copyright
*
* Features:
* - Dynamic navigation from nav.config.ts (default) or custom nav prop
* - Social links with icon support
* - Copyright with {year} placeholder
* - Legal links section
* - Full slot support for customization
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { getNavItems, type NavItem as NavConfigItem } from '@/config/nav.config';
import { footerVariants, footerColumnGridVariants } from './footer.variants';
import Logo from '@/components/ui/marketing/Logo/Logo.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
import siteConfig from '@/config/site.config';
export interface NavItem {
label: string;
href: string;
external?: boolean;
}
export interface FooterLinkGroup {
title: string;
links: NavItem[];
}
export interface SocialLink {
platform: 'github' | 'twitter' | 'linkedin' | string;
href: string;
label?: string;
}
export interface LegalLink {
label: string;
href: string;
}
interface Props extends HTMLAttributes<'footer'> {
/** Layout style */
layout?: 'simple' | 'columns' | 'minimal' | 'stacked';
/** Number of columns (only for columns layout) */
columns?: 2 | 3 | 4;
/** Background variant */
background?: 'default' | 'secondary' | 'invert';
/** Override default navigation */
nav?: NavItem[];
/** Link groups for columns layout */
linkGroups?: FooterLinkGroup[];
/** Social media links */
socialLinks?: SocialLink[];
/** Show social links */
showSocial?: boolean;
/** Show copyright */
showCopyright?: boolean;
/** Custom copyright text (supports {year} and {siteName} placeholders) */
copyright?: string;
/** Legal links (Privacy, Terms, etc.) */
legalLinks?: LegalLink[];
/** Hide logo */
hideLogo?: boolean;
/** Tagline text under logo */
tagline?: string;
}
const {
layout = 'simple',
columns = 3,
background = 'default',
nav,
linkGroups = [],
socialLinks = [],
showSocial = true,
showCopyright = true,
copyright = '© {year} {siteName}. Designed by <a href="https://hansmartens.dev" class="underline hover:text-foreground transition-colors" target="_blank" rel="noopener noreferrer">Hans Martens</a>.',
legalLinks = [],
hideLogo = false,
tagline,
class: className,
...attrs
} = Astro.props;
// Get navigation items
const defaultNav: NavItem[] = getNavItems().map((item: NavConfigItem) => ({
label: item.label,
href: item.href,
}));
const navItems: NavItem[] = nav || defaultNav;
// Default external links if none provided via nav
const allNavItems: NavItem[] = nav ? navItems : navItems;
// Process copyright text
const currentYear = new Date().getFullYear();
const processedCopyright = copyright
.replace('{year}', String(currentYear))
.replace('{siteName}', siteConfig.name);
// Check slots
const hasLogoSlot = Astro.slots.has('logo');
const hasTaglineSlot = Astro.slots.has('tagline');
const hasColumnsSlot = Astro.slots.has('columns');
const hasSocialSlot = Astro.slots.has('social');
const hasBottomSlot = Astro.slots.has('bottom');
// Compute footer classes
const footerClasses = cn(footerVariants({ background }), className);
// Social platform to icon mapping
const socialIcons: Record<string, string> = {
github: 'github',
twitter: 'x-twitter',
linkedin: 'linkedin',
};
// Get icon for social platform
function getSocialIcon(platform: string): string {
return socialIcons[platform] || platform;
}
---
<footer class={footerClasses} {...attrs}>
<div class="mx-auto max-w-6xl px-6">
{layout === 'simple' && (
<>
<div class="flex flex-col md:flex-row justify-between items-center gap-[var(--space-stack-lg)]">
{/* Logo & Tagline */}
{!hideLogo && (
<div class="flex flex-col items-center md:items-start gap-2">
{hasLogoSlot ? (
<slot name="logo" />
) : (
<a href="/" class="flex items-center gap-2">
<Logo size="md" />
<span class="font-display text-base font-bold text-brand-500">
{siteConfig.name}
</span>
</a>
)}
{(hasTaglineSlot || tagline) && (
<p class="text-sm text-foreground-muted">
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
</p>
)}
</div>
)}
{/* Navigation Links */}
<nav class="flex flex-wrap justify-center gap-[var(--space-inline-lg)] md:gap-[var(--space-stack-lg)] text-sm font-medium text-foreground-muted">
{allNavItems.map((item) => (
<a
href={item.href}
class="transition-colors hover:text-foreground"
target={item.external || item.href.startsWith('http') ? '_blank' : undefined}
rel={item.external || item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{item.label}
</a>
))}
</nav>
{/* Social Links */}
{showSocial && socialLinks.length > 0 && (
hasSocialSlot ? (
<slot name="social" />
) : (
<div class="flex items-center gap-[var(--space-stack-md)]">
{socialLinks.map((social) => (
<a
href={social.href}
class="transition-colors text-foreground-muted hover:text-foreground"
target="_blank"
rel="noopener noreferrer"
aria-label={social.label || `Follow us on ${social.platform}`}
>
<Icon name={getSocialIcon(social.platform)} size="md" />
</a>
))}
</div>
)
)}
</div>
{showCopyright && (
<div class="mt-[var(--space-stack-lg)] pt-[var(--space-stack-lg)] border-t border-border text-center">
<p class="text-sm text-foreground-muted" set:html={processedCopyright} />
</div>
)}
</>
)}
{layout === 'columns' && (
<>
<div class={footerColumnGridVariants({ columns })}>
{/* Logo Column */}
{!hideLogo && (
<div class="space-y-[var(--space-stack-md)]">
{hasLogoSlot ? (
<slot name="logo" />
) : (
<a href="/" class="flex items-center gap-2">
<Logo size="md" />
<span class="font-display text-base font-bold text-brand-500">
{siteConfig.name}
</span>
</a>
)}
{(hasTaglineSlot || tagline) && (
<p class="text-sm max-w-xs text-foreground-muted">
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
</p>
)}
{/* Social Links in columns layout */}
{showSocial && socialLinks.length > 0 && (
<div class="flex items-center gap-[var(--space-stack-md)] pt-2">
{socialLinks.map((social) => (
<a
href={social.href}
class="transition-colors text-foreground-muted hover:text-foreground"
target="_blank"
rel="noopener noreferrer"
aria-label={social.label || `Follow us on ${social.platform}`}
>
<Icon name={getSocialIcon(social.platform)} size="md" />
</a>
))}
</div>
)}
</div>
)}
{/* Link Groups */}
{hasColumnsSlot ? (
<slot name="columns" />
) : (
linkGroups.map((group) => (
<div class="space-y-[var(--space-stack-md)]">
<h3 class="font-semibold text-sm text-foreground">
{group.title}
</h3>
<ul class="space-y-2">
{group.links.map((link) => (
<li>
<a
href={link.href}
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
target={link.external || link.href.startsWith('http') ? '_blank' : undefined}
rel={link.external || link.href.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{link.label}
</a>
</li>
))}
</ul>
</div>
))
)}
</div>
{/* Bottom section */}
{(showCopyright || legalLinks.length > 0) && (
<div class="mt-[var(--space-section-header)] pt-[var(--space-stack-lg)] border-t border-border flex flex-col md:flex-row justify-between items-center gap-[var(--space-stack-md)]">
{hasBottomSlot ? (
<slot name="bottom" />
) : (
<>
{showCopyright && (
<p class="text-sm text-foreground-muted">
{processedCopyright}
</p>
)}
{legalLinks.length > 0 && (
<div class="flex items-center gap-[var(--space-inline-lg)]">
{legalLinks.map((link) => (
<a
href={link.href}
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
>
{link.label}
</a>
))}
</div>
)}
</>
)}
</div>
)}
</>
)}
{layout === 'minimal' && (
<div class="text-center">
{showCopyright && (
<p class="text-sm text-foreground-muted">
{processedCopyright}
</p>
)}
</div>
)}
{layout === 'stacked' && (
<div class="flex flex-col items-center gap-[var(--space-stack-lg)] text-center">
{/* Logo */}
{!hideLogo && (
hasLogoSlot ? (
<slot name="logo" />
) : (
<a href="/" class="flex items-center gap-2">
<Logo size="md" />
<span class="font-display text-xl font-bold text-brand-500">
{siteConfig.name}
</span>
</a>
)
)}
{/* Tagline */}
{(hasTaglineSlot || tagline) && (
<p class="text-sm max-w-md text-foreground-muted">
{hasTaglineSlot ? <slot name="tagline" /> : tagline}
</p>
)}
{/* Navigation Links */}
<nav class="flex flex-wrap justify-center gap-[var(--space-inline-lg)] text-sm font-medium text-foreground-muted">
{allNavItems.map((item) => (
<a
href={item.href}
class="transition-colors hover:text-foreground"
target={item.external || item.href.startsWith('http') ? '_blank' : undefined}
rel={item.external || item.href.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{item.label}
</a>
))}
</nav>
{/* Social Links */}
{showSocial && socialLinks.length > 0 && (
<div class="flex items-center gap-[var(--space-stack-md)]">
{socialLinks.map((social) => (
<a
href={social.href}
class="transition-colors text-foreground-muted hover:text-foreground"
target="_blank"
rel="noopener noreferrer"
aria-label={social.label || `Follow us on ${social.platform}`}
>
<Icon name={getSocialIcon(social.platform)} size="md" />
</a>
))}
</div>
)}
{/* Copyright & Legal */}
{(showCopyright || legalLinks.length > 0) && (
<div class="pt-[var(--space-stack-lg)] border-t border-border w-full flex flex-col items-center gap-[var(--space-stack-md)]">
{showCopyright && (
<p class="text-sm text-foreground-muted">
{processedCopyright}
</p>
)}
{legalLinks.length > 0 && (
<div class="flex items-center gap-[var(--space-inline-lg)]">
{legalLinks.map((link) => (
<a
href={link.href}
class="text-sm transition-colors text-foreground-muted hover:text-foreground"
>
{link.label}
</a>
))}
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Default slot for additional content */}
<slot />
</footer>
+753
View File
@@ -0,0 +1,753 @@
---
/**
* Header Component
* Flexible navigation header with variant-based configuration
*
* Variants:
* - layout: 'default' | 'centered' | 'minimal'
* - position: 'fixed' | 'sticky' | 'static'
* - size: 'sm' | 'md' | 'lg'
* - variant: 'default' | 'solid' | 'transparent'
* - colorScheme: 'default' | 'invert' (use 'invert' for dark backgrounds)
* - shape: 'bar' | 'floating' (use 'floating' for capsule header)
*
* Features:
* - Dynamic navigation from nav.config.ts (default) or custom nav prop
* - Optional CTA button with customization
* - Mobile menu with Escape key support
* - Theme toggle
* - GitHub/action buttons
* - Full slot support for customization
* - Inverted color scheme for use on dark/image backgrounds
* - Floating capsule shape with scroll-reactive bg + color flip
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { getNavItems, type NavItem as NavConfigItem } from '@/config/nav.config';
import { headerVariants, headerInnerVariants } from './header.variants';
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 ThemeToggle from '@/components/layout/ThemeToggle.astro';
import ThemeSelector from '@/components/layout/ThemeSelector.astro';
import ThemeSelectorDropdown from '@/components/layout/ThemeSelectorDropdown.astro';
import siteConfig from '@/config/site.config';
export interface NavItem {
label: string;
href: string;
}
export interface HeaderAction {
icon: string;
href: string;
label: string;
iconOnly?: boolean;
target?: string;
}
interface Props extends HTMLAttributes<'header'> {
/** Layout style: default (logo left, nav right), centered (logo center), minimal (logo + cta only) */
layout?: 'default' | 'centered' | 'minimal';
/** Position behavior */
position?: 'fixed' | 'sticky' | 'static';
/** Header height */
size?: 'sm' | 'md' | 'lg';
/** Background variant */
variant?: 'default' | 'solid' | 'transparent';
/** Color scheme for text/icons - use 'invert' for dark backgrounds */
colorScheme?: 'default' | 'invert';
/** Shape: 'bar' (full-width, default) or 'floating' (centered capsule) */
shape?: 'bar' | 'floating';
/** Override default navigation (replaces getNavRoutes()) */
nav?: NavItem[];
/** Additional navigation items (e.g., #features for landing pages) */
extraNav?: NavItem[];
/** Show CTA button */
showCta?: boolean;
/** CTA button configuration */
cta?: { label?: string; href?: string; icon?: string };
/** Action buttons (GitHub, etc.) */
actions?: HeaderAction[];
/** Show theme toggle (default: true) */
showThemeToggle?: boolean;
/** Show colour-theme selector swatches */
showThemeSelector?: boolean;
/** Show mobile menu (default: true) */
showMobileMenu?: boolean;
/** Show active state for current page (default: true) */
showActiveState?: boolean;
/** Logo text override */
logoText?: string;
/** Hide logo entirely */
hideLogo?: boolean;
/** Show language switcher */
showLanguageSwitcher?: boolean;
/** Show social icon links (desktop/tablet only, reads from siteConfig.socialLinks) */
showSocialLinks?: boolean;
/** Show scroll progress bar at the bottom of the header */
showScrollProgress?: boolean;
/** Position of the scroll progress bar: 'top' (above header) or 'bottom' (below header, default) */
scrollProgressPosition?: 'top' | 'bottom';
}
const {
layout = 'default',
position = 'fixed',
size = 'lg',
variant = 'solid',
colorScheme = 'default',
shape = 'bar',
nav,
extraNav = [],
showCta = false,
cta = { label: 'Start a project', href: '/contact' },
actions = [],
showThemeToggle = true,
showThemeSelector = false,
showMobileMenu = true,
showSocialLinks = false,
showActiveState = true,
showScrollProgress = false,
scrollProgressPosition = 'bottom',
logoText,
hideLogo = false,
class: className,
...attrs
} = Astro.props;
// Shape + color scheme helpers
const isFloating = shape === 'floating';
const isInvert = colorScheme === 'invert';
// Get navigation items
const defaultNav = getNavItems().map((item: NavConfigItem) => ({
label: item.label,
href: item.href,
}));
const navItems: NavItem[] = nav || [...extraNav, ...defaultNav];
// Current path for active state
const currentPath = Astro.url.pathname;
// Check if we're on the landing page
const isLandingPage = currentPath === '/';
// Process CTA href for landing page anchor links
const ctaHref = cta.href?.startsWith('#') && !isLandingPage ? `/${cta.href}` : cta.href;
// Check slots
const hasLogoSlot = Astro.slots.has('logo');
const hasNavSlot = Astro.slots.has('nav');
const hasActionsSlot = Astro.slots.has('actions');
const hasMobileMenuSlot = Astro.slots.has('mobile-menu');
// Compute header classes
const headerClasses = cn(
headerVariants({ position, variant, shape }),
isInvert && !isFloating && 'invert-section',
className
);
// Compute inner container classes
const innerClasses = headerInnerVariants({ size, shape });
// Check if a nav item is active
function isActive(href: string): boolean {
if (!showActiveState) return false;
if (href.startsWith('#')) return false;
return currentPath === href || currentPath.startsWith(href + '/');
}
// Map a social URL to its icon name + accessible label
function getSocialIconData(url: string): { icon: string; label: string } {
if (url.includes('github.com')) return { icon: 'github', label: 'GitHub' };
if (url.includes('instagram.com')) return { icon: 'instagram', label: 'Instagram' };
if (url.includes('x.com') || url.includes('twitter.com')) return { icon: 'x-twitter', label: 'X' };
if (url.includes('linkedin.com')) return { icon: 'linkedin', label: 'LinkedIn' };
if (url.includes('bsky.app')) return { icon: 'bluesky', label: 'Bluesky' };
return { icon: 'link', label: 'Social' };
}
// Generate unique ID for this header instance
const menuId = `mobile-menu-${Math.random().toString(36).slice(2, 9)}`;
const buttonId = `${menuId}-button`;
---
<header
class={headerClasses}
data-menu-id={menuId}
data-button-id={buttonId}
data-header-shape={shape}
data-header-variant={variant}
data-header-color-scheme={colorScheme}
{...attrs}
>
<div class={innerClasses}>
{/* Logo */}
{
!hideLogo &&
(hasLogoSlot ? (
<slot name="logo" />
) : (
<a href="/" class="flex items-center gap-2">
<Logo size={size === 'lg' ? 'lg' : 'md'} forceDark={isInvert} />
<span
class={cn(
'font-display text-xl font-bold tracking-tight',
isFloating ? 'hdr-logo-text' : (isInvert ? 'text-on-invert' : 'text-brand-500')
)}
>
{logoText || siteConfig.name}
</span>
</a>
))
}
{/* Desktop Navigation */}
{
layout !== 'minimal' &&
(hasNavSlot ? (
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
<slot name="nav" />
</nav>
) : (
<nav class="hidden items-center gap-1 md:flex" aria-label="Main navigation">
{navItems.map(({ label, href }) => (
<a
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
class={cn(
'nav-link relative rounded-md px-3 py-2 text-sm',
'transition-all duration-(--transition-fast)',
isFloating && 'hdr-invert-text',
isFloating
? (isActive(href)
? 'hdr-nav-active font-semibold'
: 'font-medium opacity-80 hover:opacity-100')
: (isActive(href)
? 'nav-link-active font-semibold text-foreground bg-secondary'
: 'nav-link-inactive font-medium text-foreground-muted hover:text-foreground hover:bg-secondary/70')
)}
aria-current={isActive(href) ? 'page' : undefined}
>
{label}
</a>
))}
</nav>
))
}
{/* Actions Area */}
<div class="flex items-center gap-2 justify-self-end">
{
hasActionsSlot ? (
<slot name="actions" />
) : (
<>
{showThemeToggle && (
<ThemeToggle class={isFloating ? 'hdr-invert-text' : undefined} />
)}
{showThemeSelector && (
<div class="hidden md:flex">
<ThemeSelectorDropdown class={isFloating ? 'hdr-invert-text' : undefined} />
</div>
)}
{showSocialLinks && siteConfig.socialLinks.length > 0 && (
<div class="hidden md:flex items-center gap-0.5">
{siteConfig.socialLinks.map((url) => {
const { icon, label } = getSocialIconData(url);
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
class={cn(
'rounded-md p-2 transition-colors duration-(--transition-fast)',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isFloating
? 'hdr-invert-text'
: 'text-foreground-muted hover:text-foreground hover:bg-secondary/70'
)}
>
<Icon name={icon} size="md" />
</a>
);
})}
</div>
)}
{actions.map((action) => (
<Button
variant="ghost"
size="sm"
icon={action.iconOnly}
href={action.href}
target={action.target}
aria-label={action.label}
class={isFloating ? 'hdr-invert-text' : undefined}
>
<Icon name={action.icon} size="sm" />
{!action.iconOnly && action.label}
</Button>
))}
{showCta && (
<div class="hidden md:flex">
<Button
size="sm"
href={ctaHref}
target={ctaHref?.startsWith('http') ? '_blank' : undefined}
class={cn('hdr-cta-brand', isFloating ? 'hdr-invert-cta' : undefined)}
>
{cta.icon && <Icon name={cta.icon} size="sm" />}
{cta.label}
</Button>
</div>
)}
</>
)
}
{/* Mobile Menu Toggle */}
{
showMobileMenu && layout !== 'minimal' && (
<button
type="button"
id={buttonId}
class={cn(
'inline-flex items-center justify-center rounded-md p-2 md:hidden',
'transition-colors',
'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
isFloating
? 'hdr-invert-text'
: 'text-foreground-muted hover:text-foreground hover:bg-secondary'
)}
aria-expanded="false"
aria-controls={menuId}
aria-label="Toggle menu"
>
<span class="menu-icon">
<Icon name="menu" size="md" />
</span>
<span class="close-icon hidden">
<Icon name="x" size="md" />
</span>
</button>
)
}
</div>
</div>
{/* Scroll Progress Bar */}
{showScrollProgress && (
<div
id="scroll-progress-bar"
class={`absolute left-0 h-[2px] w-0 bg-brand-500 transition-none ${scrollProgressPosition === 'top' ? 'top-0' : 'bottom-0'}`}
aria-hidden="true"
/>
)}
{/* Mobile Menu */}
{
showMobileMenu &&
layout !== 'minimal' &&
(hasMobileMenuSlot ? (
<div
id={menuId}
class={cn(
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
isFloating
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
: 'border-border bg-background border-t'
)}
role="navigation"
aria-label="Mobile navigation"
>
<slot name="mobile-menu" />
</div>
) : (
<div
id={menuId}
class={cn(
'hidden origin-top scale-y-0 opacity-0 shadow-[0_8px_24px_-12px_rgba(0,0,0,0.12)] md:hidden',
isFloating
? 'rounded-b-2xl bg-background/95 backdrop-blur-xl'
: 'border-border bg-background border-t'
)}
role="navigation"
aria-label="Mobile navigation"
>
<div class={cn(
'space-y-1 py-4',
isFloating ? 'px-4' : 'mx-auto max-w-6xl px-6'
)}>
{navItems.map(({ label, href }) => (
<a
href={href.startsWith('#') && !isLandingPage ? `/${href}` : href}
class={cn(
'mobile-nav-link block rounded-md px-3 py-2 text-sm',
'transition-all duration-(--transition-fast)',
isActive(href)
? 'mobile-nav-link-active bg-secondary text-foreground font-semibold'
: 'mobile-nav-link-inactive text-foreground-muted hover:bg-secondary/70 hover:text-foreground font-medium'
)}
aria-current={isActive(href) ? 'page' : undefined}
>
{label}
</a>
))}
{showCta && (
<div class="border-border mt-3 border-t pt-3">
<Button fullWidth href={ctaHref} target={ctaHref?.startsWith('http') ? '_blank' : undefined}>
{cta.label}
</Button>
</div>
)}
{showThemeSelector && (
<div class="border-border mt-3 border-t pt-3">
<div class="flex items-center justify-between px-1">
<span class="text-sm text-foreground-muted">Colour theme</span>
<ThemeSelector />
</div>
</div>
)}
</div>
</div>
))
}
</header>
{/* Mobile Menu Backdrop - positioned outside header to blur page content */}
{
showMobileMenu && layout !== 'minimal' && (
<div
id={`${menuId}-backdrop`}
class="pointer-events-none fixed inset-0 z-40 opacity-0 transition-opacity duration-200 md:hidden"
aria-hidden="true"
/>
)
}
<script>
function initMobileMenu() {
const menuHeaders = document.querySelectorAll<HTMLElement>('header[data-menu-id]');
menuHeaders.forEach((header) => {
const menuId = header.dataset.menuId!;
const buttonId = header.dataset.buttonId!;
const isFloating = header.dataset.headerShape === 'floating';
const button = document.getElementById(buttonId);
const menu = document.getElementById(menuId);
const backdrop = document.getElementById(`${menuId}-backdrop`);
const menuIcon = button?.querySelector('.menu-icon');
const closeIcon = button?.querySelector('.close-icon');
if (!button || !menu || !menuIcon || !closeIcon) return;
if (button.dataset.menuInit) return;
button.dataset.menuInit = 'true';
let isOpen = false;
let isAnimating = false;
function open() {
if (isOpen || isAnimating) return;
isAnimating = true;
isOpen = true;
button!.setAttribute('aria-expanded', 'true');
menuIcon!.classList.add('hidden');
closeIcon!.classList.remove('hidden');
if (isFloating) {
// Force scrolled state + flatten bottom corners
header.setAttribute('data-scrolled', '');
header.classList.remove('rounded-2xl');
header.classList.add('rounded-t-2xl');
} else {
header.classList.add('!bg-background');
}
// Fade out and blur the page content
const mainContent = document.querySelector('main');
const footer = document.querySelector('footer');
if (mainContent) mainContent.classList.add('mobile-menu-blur');
if (footer) footer.classList.add('mobile-menu-blur');
// Show menu and backdrop with animations
menu!.classList.remove('hidden', 'animate-menu-up', 'opacity-0', 'scale-y-0');
menu!.classList.add('animate-menu-down');
if (backdrop) {
backdrop.classList.remove('pointer-events-none', 'animate-backdrop-out');
backdrop.classList.add('animate-backdrop');
}
isAnimating = false;
}
function close() {
if (!isOpen || isAnimating) return;
isAnimating = true;
button!.setAttribute('aria-expanded', 'false');
menuIcon!.classList.remove('hidden');
closeIcon!.classList.add('hidden');
// Start closing animation
menu!.classList.remove('animate-menu-down');
menu!.classList.add('animate-menu-up');
if (backdrop) {
backdrop.classList.remove('animate-backdrop');
backdrop.classList.add('animate-backdrop-out');
}
// Restore page content
const mainContent = document.querySelector('main');
const footer = document.querySelector('footer');
if (mainContent) mainContent.classList.remove('mobile-menu-blur');
if (footer) footer.classList.remove('mobile-menu-blur');
// Wait for animation to complete before hiding
setTimeout(() => {
menu!.classList.add('hidden', 'opacity-0', 'scale-y-0');
if (backdrop) {
backdrop.classList.add('pointer-events-none');
}
if (isFloating) {
// Restore rounded corners
header.classList.remove('rounded-t-2xl');
header.classList.add('rounded-2xl');
// Only remove scrolled if actually at top
if (window.scrollY <= 60) {
header.removeAttribute('data-scrolled');
}
} else {
header.classList.remove('!bg-background');
}
isOpen = false;
isAnimating = false;
}, 200);
}
function toggle() {
if (isOpen) {
close();
} else {
open();
}
}
button.addEventListener('click', toggle);
// Close on backdrop click
if (backdrop) {
backdrop.addEventListener('click', close);
}
// Close on Escape key
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && isOpen) {
close();
}
});
// Close when clicking on mobile menu links
menu.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', close);
});
});
}
initMobileMenu();
document.addEventListener('astro:page-load', initMobileMenu);
document.addEventListener('astro:after-swap', initMobileMenu);
</script>
<script>
const SCROLL_THRESHOLD = 60;
const BAR_SCROLLED_CLASSES = ['bg-background/80', 'backdrop-blur-lg', 'border-b', 'border-border/50'];
function initScrollWatcher() {
const scrollHeaders = document.querySelectorAll<HTMLElement>('header[data-header-shape="floating"], header[data-header-shape="bar"]');
scrollHeaders.forEach((header) => {
if (header.dataset.scrollInit) return;
header.dataset.scrollInit = 'true';
const isBar = header.dataset.headerShape === 'bar';
const isTransparentBar = isBar && header.dataset.headerVariant === 'transparent';
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
if (window.scrollY > SCROLL_THRESHOLD) {
header.setAttribute('data-scrolled', '');
if (isTransparentBar) {
header.classList.add(...BAR_SCROLLED_CLASSES);
header.classList.remove('bg-transparent');
}
} else {
// Don't remove if mobile menu is open
const menuId = header.dataset.menuId;
const menu = menuId ? document.getElementById(menuId) : null;
const menuOpen = menu && !menu.classList.contains('hidden');
if (!menuOpen) {
header.removeAttribute('data-scrolled');
if (isTransparentBar) {
header.classList.remove(...BAR_SCROLLED_CLASSES);
header.classList.add('bg-transparent');
}
}
}
ticking = false;
});
}
window.addEventListener('scroll', onScroll, { passive: true });
// Set initial state
onScroll();
});
}
initScrollWatcher();
document.addEventListener('astro:page-load', initScrollWatcher);
document.addEventListener('astro:after-swap', initScrollWatcher);
</script>
<script>
function initScrollProgress() {
const bar = document.getElementById('scroll-progress-bar');
if (!bar) return;
if (bar.dataset.progressInit) return;
bar.dataset.progressInit = 'true';
let ticking = false;
function update() {
if (!bar) return;
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
const pct = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
bar!.style.width = `${pct}%`;
ticking = false;
}
window.addEventListener('scroll', () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(update);
}
}, { passive: true });
update();
}
initScrollProgress();
document.addEventListener('astro:page-load', initScrollProgress);
document.addEventListener('astro:after-swap', initScrollProgress);
</script>
<style is:global>
.mobile-menu-blur {
opacity: 0.3;
filter: blur(4px);
transition: opacity 200ms, filter 200ms;
}
/* ===== Floating header: scroll state ===== */
[data-header-shape="floating"][data-scrolled] {
background: color-mix(in oklch, var(--color-background) 92%, transparent);
backdrop-filter: blur(24px);
border-color: var(--color-border);
box-shadow: 0 4px 20px -6px rgba(0, 0, 0, 0.1);
}
/* ===== Floating header: color flip (invert → normal on scroll) ===== */
/* Text elements: on-invert → foreground */
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-text {
color: var(--color-on-invert);
transition: color 300ms;
}
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-text {
color: var(--color-foreground);
}
/* Logo text: on-invert → brand-500 */
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-logo-text {
color: var(--color-on-invert);
transition: color 300ms;
}
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-logo-text {
color: var(--color-brand-500);
}
/* Non-invert floating: use normal colors */
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-logo-text {
color: var(--color-brand-500);
}
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text {
color: var(--color-foreground-muted);
transition: color 300ms;
}
[data-header-shape="floating"][data-header-color-scheme="default"] .hdr-invert-text:hover {
color: var(--color-foreground);
}
/* ===== Floating nav link underline indicators ===== */
[data-header-shape="floating"] .nav-link::after {
content: '';
position: absolute;
bottom: 2px;
left: 50%;
right: 50%;
height: 2px;
background: currentColor;
border-radius: 1px;
transition: left 200ms, right 200ms;
}
[data-header-shape="floating"] .nav-link:hover::after,
[data-header-shape="floating"] .nav-link.hdr-nav-active::after {
left: 12px;
right: 12px;
}
/* ===== CTA: invert color flip ===== */
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta {
background: white;
color: #111;
border: 1px solid rgba(0, 0, 0, 0.15);
transition: background 300ms, color 300ms, border-color 300ms;
}
[data-header-shape="floating"][data-header-color-scheme="invert"] .hdr-invert-cta:hover {
background: rgba(255, 255, 255, 0.9);
}
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta {
background: var(--color-primary);
color: var(--color-primary-foreground);
}
[data-header-shape="floating"][data-header-color-scheme="invert"][data-scrolled] .hdr-invert-cta:hover {
opacity: 0.9;
}
/* ===== Reduced motion ===== */
@media (prefers-reduced-motion: reduce) {
[data-header-shape="floating"],
[data-header-shape="floating"] .hdr-invert-text,
[data-header-shape="floating"] .hdr-logo-text,
[data-header-shape="floating"] .hdr-invert-cta,
[data-header-shape="floating"] .nav-link::after {
transition: none !important;
}
}
</style>
+135
View File
@@ -0,0 +1,135 @@
---
/**
* ThemeSelector
* A row of five colour swatches that switches the active colour theme at
* runtime by writing `data-theme` on <html> and persisting to localStorage.
*
* Usage in Header: <ThemeSelector />
* Pass `class` to tint the label colour for floating / inverted headers.
*/
interface Props {
class?: string;
}
const { class: className } = Astro.props;
// All 13 themes in Tailwind color order
const themes = [
{ id: 'orange', name: 'Orange', color: 'oklch(62.5% 0.22 38)' },
{ id: 'amber', name: 'Amber', color: 'oklch(68% 0.19 75)' },
{ id: 'lime', name: 'Lime', color: 'oklch(64% 0.27 130)' },
{ id: 'emerald', name: 'Emerald', color: 'oklch(62.5% 0.22 160)' },
{ id: 'teal', name: 'Teal', color: 'oklch(62.5% 0.22 190)' },
{ id: 'cyan', name: 'Cyan', color: 'oklch(65% 0.22 200)' },
{ id: 'sky', name: 'Sky', color: 'oklch(67% 0.21 222)' },
{ id: 'blue', name: 'Blue', color: 'oklch(62.5% 0.22 255)' },
{ id: 'indigo', name: 'Indigo', color: 'oklch(60% 0.24 264)' },
{ id: 'violet', name: 'Violet', color: 'oklch(62.5% 0.26 277)' },
{ id: 'purple', name: 'Purple', color: 'oklch(62.5% 0.25 303)' },
{ id: 'magenta', name: 'Magenta', color: 'oklch(58% 0.28 330)' },
];
---
<div
class:list={['theme-selector flex flex-wrap items-center gap-1', className]}
role="group"
aria-label="Select colour theme"
>
{themes.map((theme) => (
<button
type="button"
class="theme-swatch relative h-3.5 w-3.5 rounded-full transition-transform duration-150 focus-visible:outline-none"
data-theme-id={theme.id}
style={`background-color:${theme.color};--swatch:${theme.color}`}
title={theme.name}
aria-label={`${theme.name} theme`}
/>
))}
</div>
<style>
/* Inactive: subtle border + dimmed so active pops without extra decoration */
.theme-swatch {
box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.12);
opacity: 0.55;
transition:
opacity 150ms ease,
transform 150ms ease;
}
/* Active: full opacity + slim underline pill in the swatch colour */
.theme-swatch[data-active] {
opacity: 1;
box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.12);
}
.theme-swatch[data-active]::after {
content: '';
position: absolute;
bottom: -3px;
left: 50%;
transform: translateX(-50%);
width: 10px;
height: 2px;
background: var(--swatch);
border-radius: 1px;
}
/* Hover: lift opacity on inactive */
.theme-swatch:not([data-active]):hover {
opacity: 0.85;
transform: scale(1.1);
}
</style>
<script>
const STORAGE_KEY = 'color-theme';
const DEFAULT_THEME = 'blue';
function getActiveTheme(): string {
try {
return sessionStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
function setTheme(id: string) {
document.documentElement.setAttribute('data-theme', id);
try { sessionStorage.setItem(STORAGE_KEY, id); } catch { /* private mode */ }
// Update every swatch on the page (handles multiple selector instances)
const swatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
swatches.forEach((btn) => {
if (btn.dataset.themeId === id) {
btn.setAttribute('data-active', '');
} else {
btn.removeAttribute('data-active');
}
});
}
function initThemeSelector() {
const active = getActiveTheme();
const activeSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
activeSwatches.forEach((btn) => {
// Mark current active
if (btn.dataset.themeId === active) {
btn.setAttribute('data-active', '');
} else {
btn.removeAttribute('data-active');
}
// Guard against double-binding across re-runs
if (btn.dataset.selectorInit) return;
btn.dataset.selectorInit = 'true';
btn.addEventListener('click', () => setTheme(btn.dataset.themeId!));
});
}
initThemeSelector();
document.addEventListener('astro:page-load', initThemeSelector);
document.addEventListener('astro:after-swap', initThemeSelector);
</script>
@@ -0,0 +1,222 @@
---
/**
* ThemeSelectorDropdown
* A dropdown button for desktop that exposes the colour-theme swatches.
* The mobile menu still uses the flat ThemeSelector component.
*/
import { cn } from '@/lib/cn';
interface Props {
class?: string;
}
const { class: className } = Astro.props;
// All 13 themes in Tailwind color order
const themes = [
{ id: 'orange', name: 'Orange', color: 'oklch(62.5% 0.22 38)' },
{ id: 'amber', name: 'Amber', color: 'oklch(68% 0.19 75)' },
{ id: 'lime', name: 'Lime', color: 'oklch(64% 0.27 130)' },
{ id: 'emerald', name: 'Emerald', color: 'oklch(62.5% 0.22 160)' },
{ id: 'teal', name: 'Teal', color: 'oklch(62.5% 0.22 190)' },
{ id: 'cyan', name: 'Cyan', color: 'oklch(65% 0.22 200)' },
{ id: 'sky', name: 'Sky', color: 'oklch(67% 0.21 222)' },
{ id: 'blue', name: 'Blue', color: 'oklch(62.5% 0.22 255)' },
{ id: 'indigo', name: 'Indigo', color: 'oklch(60% 0.24 264)' },
{ id: 'violet', name: 'Violet', color: 'oklch(62.5% 0.26 277)' },
{ id: 'purple', name: 'Purple', color: 'oklch(62.5% 0.25 303)' },
{ id: 'magenta', name: 'Magenta', color: 'oklch(58% 0.28 330)' },
];
---
<div class={cn('relative theme-dropdown-wrapper', className)}>
<!-- Trigger button -->
<button
type="button"
id="theme-dropdown-trigger"
aria-haspopup="true"
aria-expanded="false"
aria-label="Select colour theme"
class={cn(
'inline-flex items-center gap-1.5 rounded-full border border-border-strong px-2.5 py-1.5',
'text-foreground-muted hover:text-foreground hover:bg-secondary',
'transition-colors duration-(--transition-fast)',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
)}
>
<!-- Active theme dot -->
<span
class="h-3.5 w-3.5 rounded-full block flex-shrink-0"
style="background-color: var(--color-brand-500)"
aria-hidden="true"
/>
<!-- Chevron — inline, rotates on open -->
<svg
class="theme-chevron h-3 w-3 flex-shrink-0 transition-transform duration-200"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m6 9 6 6 6-6"/>
</svg>
</button>
<!-- Dropdown panel -->
<div
id="theme-dropdown-panel"
role="dialog"
aria-label="Colour theme"
class={cn(
'absolute right-0 top-full mt-2 z-50',
'w-52 rounded-xl border border-border bg-background shadow-lg',
'p-2.5',
'hidden'
)}
>
<p class="text-xs font-medium text-foreground-muted mb-2.5 px-0.5">Colour theme</p>
<div class="grid grid-cols-4 gap-1.5">
{themes.map((theme) => (
<button
type="button"
class="theme-swatch-dd group flex flex-col items-center gap-1 rounded-lg p-1 hover:bg-secondary transition-colors duration-(--transition-fast) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-theme-id={theme.id}
aria-label={`${theme.name} theme`}
>
<span
class="h-4 w-4 rounded-full block"
style={`background-color:${theme.color}`}
/>
<span class="text-[9px] font-medium text-foreground-muted group-hover:text-foreground leading-none">
{theme.name}
</span>
</button>
))}
</div>
</div>
</div>
<style>
.theme-swatch-dd[data-active] {
background-color: var(--color-secondary);
}
.theme-swatch-dd[data-active] span:first-child {
box-shadow: 0 0 0 2px var(--color-background), 0 0 0 4px var(--color-brand-500);
}
.theme-swatch-dd[data-active] span:last-child {
color: var(--color-foreground);
font-weight: 600;
}
</style>
<script>
const STORAGE_KEY = 'color-theme';
const DEFAULT_THEME = 'blue';
function getActiveTheme(): string {
try {
return sessionStorage.getItem(STORAGE_KEY) || DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
function setTheme(id: string) {
document.documentElement.setAttribute('data-theme', id);
try { sessionStorage.setItem(STORAGE_KEY, id); } catch { /* private mode */ }
// Update dropdown swatches
const ddSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch-dd');
ddSwatches.forEach((btn) => {
if (btn.dataset.themeId === id) {
btn.setAttribute('data-active', '');
} else {
btn.removeAttribute('data-active');
}
});
// Also sync flat ThemeSelector swatches (mobile menu)
const flatSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch');
flatSwatches.forEach((btn) => {
if (btn.dataset.themeId === id) {
btn.setAttribute('data-active', '');
} else {
btn.removeAttribute('data-active');
}
});
}
function closeDropdown() {
const panel = document.getElementById('theme-dropdown-panel');
const trigger = document.getElementById('theme-dropdown-trigger');
panel?.classList.add('hidden');
trigger?.setAttribute('aria-expanded', 'false');
trigger?.querySelector('.theme-chevron')?.classList.remove('rotate-180');
}
function initThemeDropdown() {
const trigger = document.getElementById('theme-dropdown-trigger');
const panel = document.getElementById('theme-dropdown-panel');
if (!trigger || !panel) return;
// Guard against double-binding
if (trigger.dataset.dropdownInit) return;
trigger.dataset.dropdownInit = 'true';
const active = getActiveTheme();
// Mark active swatch
const activeDdSwatches = document.querySelectorAll<HTMLButtonElement>('.theme-swatch-dd');
activeDdSwatches.forEach((btn) => {
if (btn.dataset.themeId === active) {
btn.setAttribute('data-active', '');
} else {
btn.removeAttribute('data-active');
}
if (!btn.dataset.selectorInit) {
btn.dataset.selectorInit = 'true';
btn.addEventListener('click', () => {
setTheme(btn.dataset.themeId!);
closeDropdown();
});
}
});
// Toggle dropdown
trigger.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = panel.classList.contains('hidden') === false;
if (isOpen) {
closeDropdown();
} else {
panel.classList.remove('hidden');
trigger.setAttribute('aria-expanded', 'true');
trigger.querySelector('.theme-chevron')?.classList.add('rotate-180');
}
});
// Close on outside click
document.addEventListener('click', (e) => {
const wrapper = trigger.closest('.theme-dropdown-wrapper');
if (wrapper && !wrapper.contains(e.target as Node)) {
closeDropdown();
}
});
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDropdown();
});
}
initThemeDropdown();
document.addEventListener('astro:page-load', initThemeDropdown);
document.addEventListener('astro:after-swap', initThemeDropdown);
</script>
+82
View File
@@ -0,0 +1,82 @@
---
import { cn } from '@/lib/cn';
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<button
type="button"
id="theme-toggle"
class={cn(
'inline-flex items-center justify-center rounded-md p-2',
'text-muted-foreground hover:text-foreground hover:bg-secondary',
'transition-colors duration-(--transition-fast)',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className
)}
aria-label="Toggle theme"
>
<!-- Sun icon (shown in dark mode) -->
<svg
class="h-5 w-5 hidden dark:block"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
<!-- Moon icon (shown in light mode) -->
<svg
class="h-5 w-5 block dark:hidden"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
</button>
<script>
function initThemeToggle() {
const toggle = document.getElementById('theme-toggle');
// Guard: skip if not found or already initialised on this element
if (!toggle || toggle.dataset.themeInit) return;
toggle.dataset.themeInit = 'true';
toggle.addEventListener('click', () => {
const isDark = document.documentElement.classList.contains('dark');
if (isDark) {
document.documentElement.classList.remove('dark');
sessionStorage.setItem('theme', 'light');
} else {
document.documentElement.classList.add('dark');
sessionStorage.removeItem('theme');
}
});
}
// Run on initial load and after every view-transition swap
initThemeToggle();
document.addEventListener('astro:after-swap', initThemeToggle);
</script>
+30
View File
@@ -0,0 +1,30 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const footerVariants = cva('py-[var(--space-stack-lg)]', {
variants: {
background: {
default: 'bg-background border-t border-border',
secondary: 'bg-surface-secondary border-t border-border',
invert: 'invert-section bg-background border-t border-border',
},
},
defaultVariants: {
background: 'default',
},
});
export const footerColumnGridVariants = cva('grid grid-cols-1 gap-[var(--space-stack-lg)]', {
variants: {
columns: {
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-4',
},
},
defaultVariants: {
columns: 3,
},
});
export type FooterVariants = VariantProps<typeof footerVariants>;
export type FooterColumnGridVariants = VariantProps<typeof footerColumnGridVariants>;
+63
View File
@@ -0,0 +1,63 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const headerVariants = cva('z-50', {
variants: {
position: {
fixed: 'fixed top-0 left-0 right-0',
sticky: 'sticky top-0',
static: 'relative',
},
variant: {
default: 'bg-background/80 backdrop-blur-lg border-b border-border/50',
solid: 'bg-background border-b border-border-strong',
transparent: 'bg-transparent',
},
shape: {
bar: 'w-full transition-[background,border-color,box-shadow,backdrop-filter] duration-300',
floating: 'rounded-2xl transition-[background,border-color,box-shadow] duration-300',
},
},
compoundVariants: [
// Floating + fixed: centered with gap
{ shape: 'floating', position: 'fixed', class: '!left-1/2 !right-auto -translate-x-1/2 w-[calc(100%-2rem)] max-w-6xl mt-4' },
// Floating + sticky: centered with gap
{ shape: 'floating', position: 'sticky', class: '!top-4 mx-auto max-w-6xl' },
// Floating + static: centered
{ shape: 'floating', position: 'static', class: 'mx-auto max-w-6xl' },
// Floating + transparent: glass effect
{ shape: 'floating', variant: 'transparent', class: 'bg-white/[0.06] backdrop-blur-xl border border-white/[0.08]' },
// Floating + default: semi-transparent with blur
{ shape: 'floating', variant: 'default', class: '!bg-background/80 backdrop-blur-xl !border border-border/50 !border-b-border/50' },
// Floating + solid: opaque
{ shape: 'floating', variant: 'solid', class: '!bg-background !border border-border !border-b-border' },
],
defaultVariants: {
position: 'sticky',
variant: 'default',
shape: 'bar',
},
});
export const headerInnerVariants = cva(
'flex items-center justify-between md:grid md:grid-cols-[1fr_auto_1fr]',
{
variants: {
size: {
sm: 'h-12',
md: 'h-14',
lg: 'h-16',
},
shape: {
bar: 'mx-auto max-w-6xl px-6',
floating: 'px-4',
},
},
defaultVariants: {
size: 'md',
shape: 'bar',
},
}
);
export type HeaderVariants = VariantProps<typeof headerVariants>;
export type HeaderInnerVariants = VariantProps<typeof headerInnerVariants>;
+124
View File
@@ -0,0 +1,124 @@
---
import Input from '@/components/ui/form/Input/Input.astro';
import Textarea from '@/components/ui/form/Textarea/Textarea.astro';
import Button from '@/components/ui/form/Button/Button.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
import { cn } from '@/lib/cn';
interface Props {
action?: string;
successMessage?: string;
class?: string;
}
const {
action = '/api/contact',
successMessage = 'Message sent successfully!',
class: className,
} = Astro.props;
---
<form
id="contact-form"
action={action}
method="POST"
class={cn('space-y-6', className)}
data-success-message={successMessage}
>
<!-- Name + Email side by side on sm+ -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Name"
name="name"
type="text"
required
autocomplete="name"
/>
<Input
label="Email"
name="email"
type="email"
required
autocomplete="email"
/>
</div>
<Input
label="Subject"
name="subject"
type="text"
/>
<Textarea
label="Message"
name="message"
required
rows={5}
/>
<!-- Honeypot field for spam protection -->
<div class="hidden" aria-hidden="true">
<input type="text" name="honeypot" tabindex="-1" autocomplete="off" />
</div>
<div id="form-message" class="hidden text-sm"></div>
<div class="flex justify-center">
<Button type="submit" id="submit-button" class="gap-2">
Send message
<Icon name="arrow-right" size="sm" />
</Button>
</div>
</form>
<script>
function initContactForm() {
const form = document.getElementById('contact-form') as HTMLFormElement;
const button = document.getElementById('submit-button') as HTMLButtonElement;
const message = document.getElementById('form-message') as HTMLDivElement;
if (!form || !button || !message) return;
const successMessage = form.dataset.successMessage || 'Message sent successfully!';
form.addEventListener('submit', async (e) => {
e.preventDefault();
button.disabled = true;
button.textContent = 'Sending…';
message.classList.add('hidden');
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST',
body: formData,
});
const data = await response.json();
if (data.success) {
message.textContent = successMessage;
message.className = 'text-sm text-success';
form.reset();
} else {
const errors = data.errors
? Object.values(data.errors).flat().join(', ')
: 'Something went wrong';
message.textContent = errors as string;
message.className = 'text-sm text-destructive';
}
} catch {
message.textContent = 'Failed to send message. Please try again.';
message.className = 'text-sm text-destructive';
} finally {
button.disabled = false;
button.textContent = 'Send message';
message.classList.remove('hidden');
}
});
}
initContactForm();
document.addEventListener('astro:after-swap', initContactForm);
</script>
+49
View File
@@ -0,0 +1,49 @@
---
/**
* EmptyState Pattern
* Composition example: Icon + text + action for empty data states.
* Shows how to compose UI primitives into a reusable pattern.
*/
import { cn } from '@/lib/cn';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
import Button from '@/components/ui/form/Button/Button.astro';
interface Props {
/** Icon name to display */
icon?: string;
/** Title text */
title: string;
/** Description text */
description?: string;
/** Primary action button label */
actionLabel?: string;
/** Primary action button href */
actionHref?: string;
class?: string;
}
const {
icon = 'inbox',
title,
description,
actionLabel,
actionHref,
class: className,
} = Astro.props;
---
<div class={cn('flex flex-col items-center justify-center text-center py-12 px-4', className)}>
<div class="w-12 h-12 rounded-xl bg-secondary flex items-center justify-center text-foreground-muted mb-4">
<Icon name={icon} size="lg" />
</div>
<h3 class="text-base font-semibold text-foreground mb-1">{title}</h3>
{description && (
<p class="text-sm text-foreground-muted max-w-sm mb-4">{description}</p>
)}
{actionLabel && (
<Button variant="secondary" size="sm" href={actionHref}>
{actionLabel}
</Button>
)}
<slot />
</div>
+45
View File
@@ -0,0 +1,45 @@
---
import { cn } from '@/lib/cn';
import { generateId } from '@/lib/utils';
interface Props {
label?: string;
error?: string;
hint?: string;
required?: boolean;
class?: string;
}
const { label, error, hint, required = false, class: className } = Astro.props;
const fieldId = generateId('field');
---
<div class={cn('space-y-1.5', className)}>
{
label && (
<label for={fieldId} class="text-sm font-medium leading-none">
{label}
{required && <span class="text-destructive ml-0.5">*</span>}
</label>
)
}
<slot name="input" id={fieldId} />
{
error && (
<p class="text-sm text-destructive" role="alert">
{error}
</p>
)
}
{
hint && !error && (
<p class="text-sm text-muted-foreground">
{hint}
</p>
)
}
</div>
@@ -0,0 +1,98 @@
---
import Input from '@/components/ui/form/Input/Input.astro';
import Button from '@/components/ui/form/Button/Button.astro';
import { cn } from '@/lib/cn';
interface Props {
action?: string;
placeholder?: string;
buttonText?: string;
successMessage?: string;
class?: string;
}
const {
action = '/api/newsletter',
placeholder = 'Enter your email',
buttonText = 'Subscribe',
successMessage = 'Thanks for subscribing!',
class: className,
} = Astro.props;
---
<form
class={cn('newsletter-form', className)}
action={action}
method="POST"
data-success-message={successMessage}
>
<div class="flex flex-col sm:flex-row gap-3">
<div class="flex-1">
<Input
name="email"
type="email"
placeholder={placeholder}
required
autocomplete="email"
/>
</div>
<Button type="submit" class="newsletter-submit">
{buttonText}
</Button>
</div>
<p class="newsletter-message hidden mt-3 text-sm"></p>
</form>
<script>
function initNewsletterForms() {
const forms = document.querySelectorAll<HTMLFormElement>('.newsletter-form');
forms.forEach((form) => {
const button = form.querySelector('.newsletter-submit') as HTMLButtonElement;
const message = form.querySelector('.newsletter-message') as HTMLParagraphElement;
if (!button || !message) return;
const successMessage = form.dataset.successMessage || 'Thanks for subscribing!';
const originalText = button.textContent || 'Subscribe';
form.addEventListener('submit', async (e) => {
e.preventDefault();
button.disabled = true;
button.textContent = 'Subscribing...';
message.classList.add('hidden');
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST',
body: formData,
});
const data = await response.json();
if (data.success) {
message.textContent = successMessage;
message.className = 'newsletter-message mt-3 text-sm text-success';
form.reset();
} else {
message.textContent = data.error || 'Something went wrong';
message.className = 'newsletter-message mt-3 text-sm text-destructive';
}
} catch {
message.textContent = 'Subscription failed. Please try again.';
message.className = 'newsletter-message mt-3 text-sm text-destructive';
} finally {
button.disabled = false;
button.textContent = originalText;
message.classList.remove('hidden');
}
});
});
}
initNewsletterForms();
document.addEventListener('astro:after-swap', initNewsletterForms);
</script>
+114
View File
@@ -0,0 +1,114 @@
---
/**
* PasswordInput Pattern
* Composition example: Input with show/hide password toggle.
* Demonstrates building interactive patterns from UI primitives.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { generateId } from '@/lib/utils';
import { inputVariants, inputSizeConfig } from '@/components/ui/form/Input/input.variants';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
interface Props extends HTMLAttributes<'input'> {
label?: string;
error?: string;
hint?: string;
size?: 'sm' | 'md' | 'lg';
placeholder?: string;
class?: string;
id?: string;
autocomplete?: string;
}
const {
label,
error,
hint,
size = 'md',
placeholder = 'Enter password',
autocomplete = 'current-password',
class: className,
id,
...rest
} = Astro.props;
const inputId = id || generateId('password');
const config = inputSizeConfig[size];
const inputStyles = cn(
inputVariants({ size }),
error && 'border-destructive focus-visible:ring-destructive',
config.baseLeftPadding,
config.trailingPadding
);
---
<div class={cn('space-y-1.5', className)}>
{label && (
<label for={inputId} class="text-sm font-medium leading-none">
{label}
</label>
)}
<div class="relative">
<input
type="password"
id={inputId}
class={inputStyles}
placeholder={placeholder}
autocomplete={autocomplete}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
data-password-input
{...rest}
/>
<button
type="button"
class={cn(
'absolute right-0 top-0 flex items-center justify-center h-full',
'text-foreground-muted hover:text-foreground transition-colors',
config.iconWrapper
)}
data-password-toggle
aria-label="Toggle password visibility"
>
<span data-icon-show><Icon name="eye" size="sm" /></span>
<span data-icon-hide class="hidden"><Icon name="eye-off" size="sm" /></span>
</button>
</div>
{error && (
<p id={`${inputId}-error`} class="text-sm text-destructive">{error}</p>
)}
{hint && !error && (
<p id={`${inputId}-hint`} class="text-sm text-muted-foreground">{hint}</p>
)}
</div>
<script>
function initPasswordInputs() {
document.querySelectorAll('[data-password-toggle]').forEach((el) => {
const btn = el as HTMLElement;
if (btn.dataset.initialized) return;
btn.dataset.initialized = 'true';
btn.addEventListener('click', () => {
const input = btn.parentElement?.querySelector('[data-password-input]') as HTMLInputElement;
const showIcon = btn.querySelector('[data-icon-show]') as HTMLElement;
const hideIcon = btn.querySelector('[data-icon-hide]') as HTMLElement;
if (!input || !showIcon || !hideIcon) return;
const isPassword = input.type === 'password';
input.type = isPassword ? 'text' : 'password';
showIcon.classList.toggle('hidden', isPassword);
hideIcon.classList.toggle('hidden', !isPassword);
});
});
}
initPasswordInputs();
document.addEventListener('astro:page-load', initPasswordInputs);
</script>
+28
View File
@@ -0,0 +1,28 @@
---
/**
* SearchInput Pattern
* Composition example: Input + Icon for a search field.
* Demonstrates building on UI primitives.
*/
import Input from '@/components/ui/form/Input/Input.astro';
interface Props {
placeholder?: string;
size?: 'sm' | 'md' | 'lg';
class?: string;
}
const {
placeholder = 'Search...',
size = 'md',
class: className,
} = Astro.props;
---
<Input
type="search"
placeholder={placeholder}
size={size}
leadingIcon="search"
class={className}
/>
+63
View File
@@ -0,0 +1,63 @@
---
/**
* StatCard Pattern
* Composition example: Card + typography for a metric display.
* Common in dashboards and landing pages.
*/
import { cn } from '@/lib/cn';
import Card from '@/components/ui/data-display/Card/Card.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
interface Props {
label: string;
value: string;
/** Optional trend indicator: 'up' | 'down' | 'neutral' */
trend?: 'up' | 'down' | 'neutral';
/** Trend description text (e.g., "+12% from last month") */
trendText?: string;
/** Icon name from the Icon component */
icon?: string;
class?: string;
}
const {
label,
value,
trend,
trendText,
icon,
class: className,
} = Astro.props;
const trendColors = {
up: 'text-[var(--success)]',
down: 'text-[var(--error)]',
neutral: 'text-foreground-muted',
};
const trendIcons = {
up: 'trending-up',
down: 'trending-down',
neutral: 'minus',
};
---
<Card variant="default" padding="md" hover class={className}>
<div class="flex items-start justify-between">
<div>
<p class="text-sm text-foreground-muted">{label}</p>
<p class="text-2xl font-bold text-foreground mt-1">{value}</p>
{trend && trendText && (
<div class={cn('flex items-center gap-1 mt-2 text-xs font-medium', trendColors[trend])}>
<Icon name={trendIcons[trend]} size="xs" />
<span>{trendText}</span>
</div>
)}
</div>
{icon && (
<div class="p-2.5 rounded-lg bg-secondary text-foreground-muted">
<Icon name={icon} size="md" />
</div>
)}
</div>
</Card>
+132
View File
@@ -0,0 +1,132 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
import Button from '@/components/ui/form/Button/Button.astro';
import Icon from '@/components/ui/primitives/Icon/Icon.astro';
interface Props {
title: string;
description: string;
tags?: string[];
year?: number;
client?: string;
role?: string;
services?: string[];
url?: string;
repo?: string;
image?: ImageMetadata;
imageAlt?: string;
}
const {
title,
description,
tags = [],
year,
client,
role,
url,
repo,
image,
imageAlt,
} = Astro.props;
const hasMeta = year || client || role;
---
<header class="relative overflow-hidden pt-[var(--space-page-top-sm)] pb-[var(--space-section)]">
<div class="relative mx-auto max-w-4xl px-6 animate-hero-slide-up">
<!-- Tags -->
{tags.length > 0 && (
<div class="mb-[var(--space-heading-gap)] flex flex-wrap gap-2">
{tags.map((tag) => (
<span class="inline-flex items-center rounded-full bg-brand-100 dark:bg-brand-900/30 px-3 py-1 text-xs font-semibold text-brand-700 dark:text-brand-300 ring-1 ring-inset ring-brand-200 dark:ring-brand-800">
{tag}
</span>
))}
</div>
)}
<!-- Title -->
<h1 class="font-display text-4xl font-bold tracking-tight text-foreground md:text-5xl lg:text-6xl mb-[var(--space-heading-gap)]">
{title}
</h1>
<!-- Description -->
<p class="text-xl text-foreground-muted leading-relaxed max-w-3xl mb-[var(--space-stack-lg)]">
{description}
</p>
<!-- Meta row -->
{hasMeta && (
<div class="flex flex-wrap items-center gap-[var(--space-stack-lg)] text-sm text-foreground-muted mb-[var(--space-stack-lg)]">
{year && (
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />
</svg>
<span>{year}</span>
</div>
)}
{client && (
<>
{year && <div class="h-8 w-px bg-border hidden md:block" aria-hidden="true"></div>}
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
<span>{client}</span>
</div>
</>
)}
{role && (
<>
{(year || client) && <div class="h-8 w-px bg-border hidden md:block" aria-hidden="true"></div>}
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-foreground-subtle" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 14.15v4.25c0 1.094-.787 2.036-1.872 2.18-2.087.277-4.216.42-6.378.42s-4.291-.143-6.378-.42c-1.085-.144-1.872-1.086-1.872-2.18v-4.25m16.5 0a2.18 2.18 0 00.75-1.661V8.706c0-1.081-.768-2.015-1.837-2.175a48.114 48.114 0 00-3.413-.387m4.5 8.006c-.194.165-.42.295-.673.38A23.978 23.978 0 0112 15.75c-2.648 0-5.195-.429-7.577-1.22a2.016 2.016 0 01-.673-.38m0 0A2.18 2.18 0 013 12.489V8.706c0-1.081.768-2.015 1.837-2.175a48.111 48.111 0 013.413-.387m7.5 0V5.25A2.25 2.25 0 0013.5 3h-3a2.25 2.25 0 00-2.25 2.25v.894m7.5 0a48.667 48.667 0 00-7.5 0M12 12.75h.008v.008H12v-.008z" />
</svg>
<span>{role}</span>
</div>
</>
)}
</div>
)}
<!-- Action buttons -->
{(url || repo) && (
<div class="flex flex-wrap gap-3">
{url && (
<Button href={url} target="_blank" rel="noopener noreferrer" size="md">
<Icon name="external-link" size="sm" />
View live site
</Button>
)}
{repo && (
<Button href={repo} target="_blank" rel="noopener noreferrer" variant="outline" size="md">
<Icon name="github" size="sm" />
View source
</Button>
)}
</div>
)}
</div>
{image && (
<div class="relative mx-auto max-w-5xl px-6 mt-[var(--space-section)] animate-hero-slide-up [animation-delay:200ms]">
<div class="relative overflow-hidden rounded-xl border border-border shadow-2xl">
<Image
src={image}
alt={imageAlt || title}
widths={[640, 960, 1280, 1920]}
sizes="(max-width: 640px) 640px, (max-width: 960px) 960px, (max-width: 1280px) 1280px, 1920px"
class="aspect-video w-full object-cover"
loading="eager"
/>
</div>
</div>
)}
</header>
+59
View File
@@ -0,0 +1,59 @@
---
import { cn } from '@/lib/cn';
import JsonLd from './JsonLd.astro';
import { createBreadcrumbSchema } from '@/lib/schema';
import siteConfig from '@/config/site.config';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface Props {
items: BreadcrumbItem[];
class?: string;
}
const { items, class: className } = Astro.props;
// Build schema items with full URLs
const schemaItems = items.map((item) => ({
name: item.label,
url: item.href ? new URL(item.href, siteConfig.url).toString() : siteConfig.url,
}));
const schema = createBreadcrumbSchema(schemaItems);
---
<JsonLd schema={schema} />
<nav aria-label="Breadcrumb" class={cn('text-sm', className)}>
<ol class="flex flex-wrap items-center gap-2">
{
items.map((item, index) => (
<li class="flex items-center gap-2">
{index > 0 && (
<svg
class="h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
)}
{item.href && index !== items.length - 1 ? (
<a href={item.href} class="text-muted-foreground hover:text-foreground transition-colors">
{item.label}
</a>
) : (
<span class="text-foreground font-medium" aria-current={index === items.length - 1 ? 'page' : undefined}>
{item.label}
</span>
)}
</li>
))
}
</ol>
</nav>
+17
View File
@@ -0,0 +1,17 @@
---
import type { Thing, WithContext } from 'schema-dts';
interface Props {
schema: WithContext<Thing> | WithContext<Thing>[];
}
const { schema } = Astro.props;
const schemas = Array.isArray(schema) ? schema : [schema];
---
{
schemas.map((s) => (
<script is:inline type="application/ld+json" set:html={JSON.stringify(s, null, 0)} />
))
}
+101
View File
@@ -0,0 +1,101 @@
---
import siteConfig from '@/config/site.config';
interface Props {
title?: string;
description?: string;
image?: string;
imageAlt?: string;
article?: {
publishedTime?: Date;
modifiedTime?: Date;
authors?: string[];
tags?: string[];
};
noindex?: boolean;
nofollow?: boolean;
}
const {
title,
description = siteConfig.description,
image,
imageAlt,
article,
noindex = false,
nofollow = false,
} = Astro.props;
const pageTitle = title ? `${title} — ${siteConfig.name}` : siteConfig.name;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
// Process image - fall back to the static default OG image
let ogImage: string;
if (image) {
ogImage = image.startsWith('http') ? image : new URL(image, Astro.site).toString();
} else {
ogImage = new URL(siteConfig.ogImage, Astro.site).toString();
}
const robotsContent = [noindex ? 'noindex' : 'index', nofollow ? 'nofollow' : 'follow'].join(', ');
// Normalize BCP-47 locale (e.g. "en") to OG language_TERRITORY format (e.g. "en_US")
const localeMap: Record<string, string> = {
en: 'en_US', fr: 'fr_FR', de: 'de_DE', es: 'es_ES', it: 'it_IT',
pt: 'pt_BR', nl: 'nl_NL', ja: 'ja_JP', ko: 'ko_KR', zh: 'zh_CN',
ru: 'ru_RU', ar: 'ar_SA', hi: 'hi_IN', pl: 'pl_PL', sv: 'sv_SE',
};
const rawLocale = Astro.currentLocale || 'en_US';
const locale = rawLocale.includes('_') || rawLocale.includes('-')
? rawLocale.replace('-', '_')
: (localeMap[rawLocale] || `${rawLocale}_${rawLocale.toUpperCase()}`);
// Determine OG image MIME type from file extension
const extToMime: Record<string, string> = {
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.webp': 'image/webp', '.gif': 'image/gif', '.svg': 'image/svg+xml',
};
function getImageType(url: string): string | undefined {
const ext = url.match(/(\.\w+)(?:\?|$)/)?.[1]?.toLowerCase();
return ext ? extToMime[ext] : undefined;
}
const ogImageType = getImageType(ogImage);
---
<!-- Primary Meta Tags -->
<title>{pageTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL.toString()} />
<meta name="robots" content={robotsContent} />
<!-- Open Graph -->
<meta property="og:title" content={pageTitle} />
<meta property="og:type" content={article ? 'article' : 'website'} />
<meta property="og:image" content={ogImage} />
<meta property="og:url" content={canonicalURL.toString()} />
<meta property="og:description" content={description} />
<meta property="og:site_name" content={siteConfig.name} />
<meta property="og:locale" content={locale} />
<meta property="og:image:alt" content={imageAlt || pageTitle} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
{ogImageType && <meta property="og:image:type" content={ogImageType} />}
<!-- Article Metadata -->
{article?.publishedTime && <meta property="article:published_time" content={article.publishedTime.toISOString()} />}
{article?.modifiedTime && <meta property="article:modified_time" content={article.modifiedTime.toISOString()} />}
{article?.authors?.map((author) => <meta property="article:author" content={author} />)}
{article?.tags?.map((tag) => <meta property="article:tag" content={tag} />)}
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={pageTitle} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={ogImage} />
{siteConfig.twitter?.site && <meta name="twitter:site" content={siteConfig.twitter.site} />}
{siteConfig.twitter?.creator && <meta name="twitter:creator" content={siteConfig.twitter.creator} />}
<!-- Verification -->
{siteConfig.verification?.google && <meta name="google-site-verification" content={siteConfig.verification.google} />}
{siteConfig.verification?.bing && <meta name="msvalidate.01" content={siteConfig.verification.bing} />}
+115
View File
@@ -0,0 +1,115 @@
---
interface Props {
/** Array of strings to cycle through */
words: string[];
/** Typing speed in ms per character */
typeSpeed?: number;
/** Deleting speed in ms per character */
deleteSpeed?: number;
/** Pause after fully typed, in ms */
pauseAfterType?: number;
/** Pause after fully deleted, in ms */
pauseAfterDelete?: number;
}
const {
words,
typeSpeed = 120,
deleteSpeed = 70,
pauseAfterType = 1800,
pauseAfterDelete = 400,
} = Astro.props;
const id = `typing-${Math.random().toString(36).slice(2, 8)}`;
---
<span id={id} class="typing-effect" aria-label={words.join(', ')}>
<span class="typing-text"></span><span class="typing-cursor" aria-hidden="true">|</span>
</span>
<style>
.typing-effect {
display: inline-block;
white-space: nowrap;
vertical-align: bottom;
}
.typing-cursor {
display: inline-block;
margin-left: 1px;
animation: blink 0.75s step-end infinite;
color: var(--color-brand-500, currentColor);
font-weight: 300;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
</style>
<script define:vars={{ id, words, typeSpeed, deleteSpeed, pauseAfterType, pauseAfterDelete }}>
function startTyping() {
const root = document.getElementById(id);
if (!root) return;
const textEl = root.querySelector('.typing-text');
// Lock the element width to the widest word so the layout never shifts
const measurer = document.createElement('span');
measurer.setAttribute('aria-hidden', 'true');
measurer.style.cssText = 'visibility:hidden;position:absolute;white-space:nowrap;pointer-events:none;';
const cs = getComputedStyle(root);
measurer.style.font = cs.font;
measurer.style.letterSpacing = cs.letterSpacing;
document.body.appendChild(measurer);
let maxWidth = 0;
for (const word of words) {
measurer.textContent = word + '|'; // include cursor character in measurement
maxWidth = Math.max(maxWidth, measurer.offsetWidth);
}
document.body.removeChild(measurer);
root.style.minWidth = maxWidth + 'px';
let wordIndex = 0;
let charIndex = 0;
let isDeleting = false;
let timer;
function tick() {
const current = words[wordIndex];
if (isDeleting) {
charIndex--;
textEl.textContent = current.slice(0, charIndex);
if (charIndex === 0) {
isDeleting = false;
wordIndex = (wordIndex + 1) % words.length;
timer = setTimeout(tick, pauseAfterDelete);
return;
}
timer = setTimeout(tick, deleteSpeed);
} else {
charIndex++;
textEl.textContent = current.slice(0, charIndex);
if (charIndex === current.length) {
isDeleting = true;
timer = setTimeout(tick, pauseAfterType);
return;
}
timer = setTimeout(tick, typeSpeed);
}
}
// Start after a short initial delay so the page paint settles
timer = setTimeout(tick, 600);
// Clean up pending timer when navigating away
document.addEventListener('astro:before-swap', () => clearTimeout(timer), { once: true });
}
// Run on initial load and on every client-side navigation back to this page
document.addEventListener('astro:page-load', startTyping);
</script>
@@ -0,0 +1,113 @@
---
import { cn } from '@/lib/cn';
interface Props {
code: string;
filename?: string;
showLineNumbers?: boolean;
class?: string;
}
const {
code,
filename,
showLineNumbers = true,
class: className,
} = Astro.props;
const lines = code.trim().split('\n');
const codeId = `code-${Math.random().toString(36).slice(2, 9)}`;
---
<div class={cn(
"group relative w-full overflow-hidden rounded-md border border-border bg-background-secondary shadow-sm font-mono text-sm",
className
)}>
<!-- Header -->
<div class="flex items-center justify-between border-b border-border bg-card px-4 py-2.5">
<div class="flex items-center gap-3">
<div class="flex gap-1.5">
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
<div class="h-2.5 w-2.5 rounded-full bg-gray-300 dark:bg-gray-600"></div>
</div>
{filename && (
<span class="text-xs font-medium text-foreground-muted font-sans">{filename}</span>
)}
</div>
<button
type="button"
class="copy-button flex items-center gap-1.5 rounded px-2 py-0.5 text-xs font-medium text-foreground-muted transition-colors hover:bg-secondary hover:text-foreground focus:outline-none"
data-code-id={codeId}
aria-label="Copy code to clipboard"
>
<svg class="copy-icon h-3 w-3" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<svg class="check-icon hidden h-3 w-3 text-success" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span class="copy-text">Copy</span>
</button>
</div>
<!-- Code Area -->
<div class="overflow-x-auto p-4 bg-card">
<pre id={codeId} class="flex flex-col leading-6">{lines.map((line, i) => (
<div class="table-row">
{showLineNumbers && (
<span class="table-cell select-none pr-4 text-right text-xs text-foreground-subtle w-8">
{i + 1}
</span>
)}
<span class="table-cell whitespace-pre text-foreground-secondary">{line || ' '}</span>
</div>
))}</pre>
</div>
</div>
<script>
function initCodeBlocks() {
const copyButtons = document.querySelectorAll<HTMLButtonElement>('.copy-button');
copyButtons.forEach((button) => {
button.addEventListener('click', async () => {
const codeId = button.dataset.codeId;
if (!codeId) return;
const codeEl = document.getElementById(codeId);
if (!codeEl) return;
const code = codeEl.textContent || '';
try {
await navigator.clipboard.writeText(code);
const copyIcon = button.querySelector('.copy-icon');
const checkIcon = button.querySelector('.check-icon');
const copyText = button.querySelector('.copy-text');
if (copyIcon && checkIcon && copyText) {
copyIcon.classList.add('hidden');
checkIcon.classList.remove('hidden');
copyText.textContent = 'Copied';
setTimeout(() => {
copyIcon.classList.remove('hidden');
checkIcon.classList.add('hidden');
copyText.textContent = 'Copy';
}, 2000);
}
} catch {
// Clipboard API failed - user will need to copy manually
}
});
});
}
// Initialize on page load
initCodeBlocks();
// Re-initialize on view transitions (Astro)
document.addEventListener('astro:page-load', initCodeBlocks);
</script>
@@ -0,0 +1 @@
export { default } from './CodeBlock.astro';
+2
View File
@@ -0,0 +1,2 @@
// Content Display Components
export { default as CodeBlock } from './CodeBlock';
@@ -0,0 +1,67 @@
---
/**
* Avatar Component
* Displays user avatars with fallback initials
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { avatarVariants } from './avatar.variants';
interface Props extends HTMLAttributes<'div'> {
src?: string;
alt?: string;
fallback?: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}
const { src, alt = '', fallback, size = 'md', class: className, ...attrs } = Astro.props;
// Generate initials from alt text or fallback
const initials = fallback || alt
.split(' ')
.map((word) => word[0])
.join('')
.slice(0, 2)
.toUpperCase();
---
<div class={cn(avatarVariants({ size }), className)} {...attrs}>
{src ? (
<img
src={src}
alt={alt}
class="w-full h-full object-cover"
loading="lazy"
decoding="async"
data-avatar-img
/>
<span class="hidden items-center justify-center w-full h-full" aria-hidden="true" data-avatar-fallback>
{initials || '?'}
</span>
) : (
<span aria-hidden="true">{initials || '?'}</span>
)}
<span class="sr-only">{alt || 'User avatar'}</span>
</div>
<script>
function initAvatarFallbacks() {
const avatarImgs = document.querySelectorAll<HTMLImageElement>('[data-avatar-img]');
avatarImgs.forEach((img) => {
if (img.dataset.avatarInit) return;
img.dataset.avatarInit = 'true';
img.addEventListener('error', () => {
img.classList.add('hidden');
const fallback = img.nextElementSibling as HTMLElement | null;
if (fallback && fallback.hasAttribute('data-avatar-fallback')) {
fallback.classList.remove('hidden');
fallback.classList.add('flex');
}
});
});
}
initAvatarFallbacks();
document.addEventListener('astro:page-load', initAvatarFallbacks);
</script>
@@ -0,0 +1,47 @@
import { type HTMLAttributes, type Ref, useState } from 'react';
import { cn } from '@/lib/cn';
import { avatarVariants, type AvatarVariants } from './avatar.variants';
interface AvatarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
src?: string;
alt?: string;
fallback?: string;
size?: AvatarVariants['size'];
}
export function Avatar({ ref, src, alt = '', fallback, size = 'md', className, ...rest }: AvatarProps) {
const [imgError, setImgError] = useState(false);
const initials = fallback || alt
.split(' ')
.map((word) => word[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<div ref={ref} className={cn(avatarVariants({ size }), className)} {...rest}>
{src && !imgError ? (
<>
<img
src={src}
alt={alt}
className="w-full h-full object-cover"
loading="lazy"
decoding="async"
onError={() => setImgError(true)}
/>
<span className="sr-only">{alt || 'User avatar'}</span>
</>
) : (
<>
<span aria-hidden="true">{initials || '?'}</span>
<span className="sr-only">{alt || 'User avatar'}</span>
</>
)}
</div>
);
}
export default Avatar;
@@ -0,0 +1,26 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const avatarVariants = cva(
[
'relative inline-flex items-center justify-center',
'rounded-full overflow-hidden',
'bg-secondary text-secondary-foreground font-medium',
'ring-2 ring-background',
],
{
variants: {
size: {
xs: 'w-6 h-6 text-[10px]',
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-16 h-16 text-lg',
},
},
defaultVariants: {
size: 'md',
},
}
);
export type AvatarVariants = VariantProps<typeof avatarVariants>;
@@ -0,0 +1,3 @@
export { default } from './Avatar.astro';
export { Avatar } from './Avatar';
export { avatarVariants, type AvatarVariants } from './avatar.variants';
@@ -0,0 +1,66 @@
---
/**
* AvatarGroup Component
* Displays stacked avatars with an optional "+N" overflow indicator.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import Avatar from '../Avatar/Avatar.astro';
interface AvatarItem {
src?: string;
alt?: string;
fallback?: string;
}
interface Props extends HTMLAttributes<'div'> {
avatars: AvatarItem[];
/** Maximum number of avatars to show before "+N" */
max?: number;
size?: 'xs' | 'sm' | 'md' | 'lg';
}
const {
avatars,
max = 4,
size = 'md',
class: className,
...attrs
} = Astro.props;
const visibleAvatars = avatars.slice(0, max);
const overflowCount = Math.max(0, avatars.length - max);
const overflowSizes = {
xs: 'w-6 h-6 text-[8px]',
sm: 'w-8 h-8 text-[10px]',
md: 'w-10 h-10 text-xs',
lg: 'w-12 h-12 text-sm',
};
---
<div class={cn('flex -space-x-2', className)} {...attrs}>
{visibleAvatars.map((avatar) => (
<Avatar
src={avatar.src}
alt={avatar.alt || ''}
fallback={avatar.fallback}
size={size}
class="ring-2 ring-background"
/>
))}
{overflowCount > 0 && (
<div
class={cn(
'relative inline-flex items-center justify-center',
'rounded-full overflow-hidden',
'bg-secondary text-foreground-muted font-semibold',
'ring-2 ring-background',
overflowSizes[size]
)}
aria-label={`${overflowCount} more`}
>
+{overflowCount}
</div>
)}
</div>
@@ -0,0 +1,61 @@
import { type HTMLAttributes, type Ref } from 'react';
import { cn } from '@/lib/cn';
import { Avatar } from '../Avatar/Avatar';
import type { AvatarVariants } from '../Avatar/avatar.variants';
interface AvatarItem {
src?: string;
alt?: string;
fallback?: string;
}
interface AvatarGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
avatars: AvatarItem[];
max?: number;
size?: NonNullable<AvatarVariants['size']>;
}
const overflowSizes: Record<string, string> = {
xs: 'w-6 h-6 text-[8px]',
sm: 'w-8 h-8 text-[10px]',
md: 'w-10 h-10 text-xs',
lg: 'w-12 h-12 text-sm',
xl: 'w-14 h-14 text-base',
};
export function AvatarGroup({ ref, avatars, max = 4, size = 'md', className, ...rest }: AvatarGroupProps) {
const visibleAvatars = avatars.slice(0, max);
const overflowCount = Math.max(0, avatars.length - max);
return (
<div ref={ref} className={cn('flex -space-x-2', className)} {...rest}>
{visibleAvatars.map((avatar, i) => (
<Avatar
key={i}
src={avatar.src}
alt={avatar.alt || ''}
fallback={avatar.fallback}
size={size}
className="ring-2 ring-background"
/>
))}
{overflowCount > 0 && (
<div
className={cn(
'relative inline-flex items-center justify-center',
'rounded-full overflow-hidden',
'bg-secondary text-foreground-muted font-semibold',
'ring-2 ring-background',
overflowSizes[size]
)}
aria-label={`${overflowCount} more`}
>
+{overflowCount}
</div>
)}
</div>
);
}
export default AvatarGroup;
@@ -0,0 +1,2 @@
export { default } from './AvatarGroup.astro';
export { AvatarGroup } from './AvatarGroup';
@@ -0,0 +1,30 @@
---
/**
* Badge Component
* Displays a small status indicator or label with proper icon spacing
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { badgeVariants } from './badge.variants';
interface Props extends HTMLAttributes<'span'> {
variant?: 'default' | 'success' | 'warning' | 'error' | 'info' | 'brand';
size?: 'sm' | 'md';
/** Show a pulsing dot indicator */
pulse?: boolean;
/** Use pill styling (fully rounded with shadow) */
pill?: boolean;
}
const { variant = 'default', size = 'md', pulse = false, pill = false, class: className, ...attrs } = Astro.props;
---
<span class={cn(badgeVariants({ variant, size, pill }), className)} {...attrs}>
{pulse && (
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-brand-500 opacity-75" />
<span class="relative inline-flex h-2 w-2 rounded-full bg-brand-500" />
</span>
)}
<slot />
</span>
@@ -0,0 +1,25 @@
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { badgeVariants, type BadgeVariants } from './badge.variants';
interface BadgeProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'ref'> {
ref?: Ref<HTMLSpanElement>;
variant?: BadgeVariants['variant'];
size?: BadgeVariants['size'];
pulse?: boolean;
pill?: boolean;
children?: ReactNode;
}
export function Badge({ ref, variant = 'default', size = 'md', pulse = false, pill = false, className, children, ...rest }: BadgeProps) {
return (
<span ref={ref} className={cn(badgeVariants({ variant, size, pill }), className)} {...rest}>
{pulse && (
<span className="flex h-2 w-2 shrink-0 animate-pulse rounded-full bg-brand-500" aria-hidden="true" />
)}
{children}
</span>
);
}
export default Badge;
@@ -0,0 +1,44 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const badgeVariants = cva(
[
'inline-flex items-center font-medium border',
'transition-colors',
'[&>svg]:shrink-0 [&>svg]:h-3 [&>svg]:w-3',
],
{
variants: {
variant: {
default: 'bg-secondary text-secondary-foreground border-border',
success:
'bg-[var(--success-light)] text-[var(--success-foreground)] border-[var(--success)]/20',
warning:
'bg-[var(--warning-light)] text-[var(--warning-foreground)] border-[var(--warning)]/20',
error:
'bg-[var(--error-light)] text-[var(--error-foreground)] border-[var(--error)]/20',
info: 'bg-[var(--info-light)] text-[var(--info-foreground)] border-[var(--info)]/20',
brand:
'bg-brand-500/10 text-brand-600 border-brand-500/20 dark:text-brand-400',
},
size: {
sm: 'text-[10px] px-2 py-0.5 gap-1',
md: 'text-sm sm:text-xs px-2.5 py-1 gap-1.5',
},
pill: {
true: 'rounded-full shadow-sm',
false: 'rounded-md',
},
},
compoundVariants: [
{ pill: true, size: 'sm', class: 'px-2.5' },
{ pill: true, size: 'md', class: 'px-3.5 sm:px-3' },
],
defaultVariants: {
variant: 'default',
size: 'md',
pill: false,
},
}
);
export type BadgeVariants = VariantProps<typeof badgeVariants>;
@@ -0,0 +1,3 @@
export { default } from './Badge.astro';
export { Badge } from './Badge';
export { badgeVariants, type BadgeVariants } from './badge.variants';
@@ -0,0 +1,33 @@
---
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { cardVariants } from './card.variants';
interface Props extends HTMLAttributes<'div'>, Pick<HTMLAttributes<'a'>, 'target' | 'rel'> {
variant?: 'default' | 'solid' | 'outline' | 'ghost' | 'elevated';
padding?: 'none' | 'sm' | 'md' | 'lg';
hover?: boolean;
href?: string;
}
const {
variant = 'default',
padding = 'md',
hover = false,
href,
class: className,
...attrs
} = Astro.props;
const Element = href ? 'a' : 'div';
const cardStyles = cn(
cardVariants({ variant, padding, hover }),
href && 'block cursor-pointer',
className
);
---
<Element class={cardStyles} href={href} {...attrs}>
<slot />
</Element>
@@ -0,0 +1,156 @@
import { type HTMLAttributes, type Ref, type ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { cardVariants, type CardVariants } from './card.variants';
type CardShadow = 'none' | 'sm' | 'md' | 'lg';
interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
padding?: CardVariants['padding'];
shadow?: CardShadow;
hover?: boolean;
/** Visual style variant */
variant?: CardVariants['variant'];
/** Icon element to display in the card header */
icon?: ReactNode;
/** Card title */
title?: string;
/** Card subtitle/byline */
subtitle?: string;
/** Card description */
description?: string;
/** Whether to use the structured layout with icon/title/description */
structured?: boolean;
}
const shadows: Record<CardShadow, string> = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
};
export function Card({
ref,
padding = 'md',
shadow = 'none',
hover = false,
variant = 'default',
icon,
title,
subtitle,
description,
structured = false,
className,
children,
...props
}: CardProps) {
const cardStyles = cn(
cardVariants({ variant, padding, hover }),
shadows[shadow],
className
);
// If using structured layout with icon/title/description
if (structured || icon || title) {
return (
<div ref={ref} className={cardStyles} {...props}>
<div className="flex items-start gap-4">
{icon && (
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-500/5 flex items-center justify-center text-brand-500 shrink-0">
{icon}
</div>
)}
{(title || subtitle) && (
<div className="flex-1 min-w-0">
{title && (
<h3 className="text-base font-semibold text-foreground">{title}</h3>
)}
{subtitle && (
<p className="text-xs text-foreground-subtle mt-0.5 font-medium">{subtitle}</p>
)}
</div>
)}
</div>
{description && (
<div className="mt-4">
<p className="text-sm text-foreground-muted leading-relaxed">{description}</p>
</div>
)}
{children}
</div>
);
}
return (
<div ref={ref} className={cardStyles} {...props}>
{children}
</div>
);
}
// Card sub-components with refined spacing
interface CardSubComponentProps extends Omit<HTMLAttributes<HTMLDivElement>, 'ref'> {
ref?: Ref<HTMLDivElement>;
}
interface CardTitleProps extends Omit<HTMLAttributes<HTMLHeadingElement>, 'ref'> {
ref?: Ref<HTMLHeadingElement>;
}
interface CardTextProps extends Omit<HTMLAttributes<HTMLParagraphElement>, 'ref'> {
ref?: Ref<HTMLParagraphElement>;
}
export function CardHeader({ ref, className, ...props }: CardSubComponentProps) {
return <div ref={ref} className={cn('flex flex-col gap-1', className)} {...props} />;
}
export function CardTitle({ ref, className, ...props }: CardTitleProps) {
return (
<h3
ref={ref}
className={cn(
'text-base font-black leading-tight tracking-tight text-foreground',
className
)}
{...props}
/>
);
}
export function CardByline({ ref, className, ...props }: CardTextProps) {
return (
<p
ref={ref}
className={cn('text-xs text-foreground-subtle mt-0.5 font-medium', className)}
{...props}
/>
);
}
export function CardDescription({ ref, className, ...props }: CardTextProps) {
return (
<p
ref={ref}
className={cn('text-sm text-foreground-muted leading-relaxed mt-1.5', className)}
{...props}
/>
);
}
export function CardContent({ ref, className, ...props }: CardSubComponentProps) {
return <div ref={ref} className={cn('mt-4', className)} {...props} />;
}
export function CardFooter({ ref, className, ...props }: CardSubComponentProps) {
return (
<div
ref={ref}
className={cn('flex items-center mt-4 pt-4 border-t border-border', className)}
{...props}
/>
);
}
export default Card;
@@ -0,0 +1,31 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const cardVariants = cva(
['rounded-xl', 'transition-all duration-200 ease-out'],
{
variants: {
variant: {
default: 'bg-card border border-brand-500/30 hover:border-brand-500/70',
solid: 'bg-secondary border border-transparent',
outline: 'bg-transparent border-2 border-brand-500/30 hover:border-brand-500/70',
ghost: 'bg-transparent border border-transparent',
elevated: 'bg-card border border-brand-500/30 shadow-lg hover:border-brand-500/70',
},
padding: {
none: '',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
},
hover: {
true: 'hover:border-brand-500 hover:shadow-md hover:-translate-y-0.5',
},
},
defaultVariants: {
variant: 'default',
padding: 'md',
},
}
);
export type CardVariants = VariantProps<typeof cardVariants>;
@@ -0,0 +1,3 @@
export { default } from './Card.astro';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export { cardVariants, type CardVariants } from './card.variants';
@@ -0,0 +1,378 @@
---
/**
* GoogleMap — consent-aware Google Maps embed
*
* 3 states:
* 1. No API key → setup prompt with instructions
* 2. API key + consent required but not granted → placeholder with "Load Map" button
* 3. API key + consent granted (or consent disabled) → iframe loads immediately
*/
import type { HTMLAttributes } from 'astro/types';
import { PUBLIC_GOOGLE_MAPS_API_KEY, PUBLIC_CONSENT_ENABLED } from 'astro:env/client';
import siteConfig from '@/config/site.config';
import { cn } from '@/lib/cn';
import { googleMapVariants } from './googleMap.variants';
interface Props extends HTMLAttributes<'div'> {
lat?: number;
lng?: number;
address?: string;
zoom?: number;
size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
mode?: 'place' | 'view' | 'directions' | 'streetview' | 'search';
mapType?: 'roadmap' | 'satellite';
consentCategory?: string;
ariaLabel?: string;
placeholderTitle?: string;
placeholderDescription?: string;
externalLinkText?: string;
}
const {
lat,
lng,
address,
zoom = 15,
size = 'md',
mode = 'place',
mapType = 'roadmap',
consentCategory = 'marketing',
ariaLabel = 'Google Maps',
placeholderTitle = 'Map',
placeholderDescription = 'Accept cookies to load the interactive map.',
externalLinkText = 'View on Google Maps',
class: className,
...rest
} = Astro.props;
const hasApiKey = !!PUBLIC_GOOGLE_MAPS_API_KEY;
// Build query — prefer lat/lng, fall back to address, then siteConfig.address
let query = '';
if (lat !== undefined && lng !== undefined) {
query = `${lat},${lng}`;
} else if (address) {
query = address;
} else if (siteConfig.address) {
const a = siteConfig.address;
query = [a.street, a.city, a.state, a.zip, a.country].filter(Boolean).join(', ');
}
// Build iframe src (only when key exists)
let iframeSrc = '';
if (hasApiKey) {
const params = new URLSearchParams({
key: PUBLIC_GOOGLE_MAPS_API_KEY,
q: query,
zoom: String(zoom),
maptype: mapType,
});
iframeSrc = `https://www.google.com/maps/embed/v1/${mode}?${params.toString()}`;
}
// External link for placeholder
const externalUrl = query
? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`
: 'https://maps.google.com';
const consentEnabled = PUBLIC_CONSENT_ENABLED;
// Config for client script
const mapConfig = JSON.stringify({
consentCategory,
consentEnabled,
});
const id = `google-map-${Math.random().toString(36).slice(2, 9)}`;
---
<div
class={cn(googleMapVariants({ size }), className)}
data-google-map={id}
{...rest}
>
{!hasApiKey ? (
/* No API key — setup prompt */
<div class="google-map-setup">
<div class="google-map-setup__inner">
{/* Lucide map-pin-off */}
<svg class="google-map-setup__icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M5.43 5.43A8.06 8.06 0 0 0 4 10c0 6 8 12 8 12a29.94 29.94 0 0 0 5-5" />
<path d="M19.18 13.52A8.66 8.66 0 0 0 20 10a8 8 0 0 0-8-8 7.88 7.88 0 0 0-3.52.82" />
<path d="M9.13 9.13a3 3 0 0 0 3.74 3.74" />
<path d="M14.9 9.25a3 3 0 0 0-2.15-2.16" />
<line x1="2" x2="22" y1="2" y2="22" />
</svg>
<p class="google-map-setup__title">Google Maps</p>
<p class="google-map-setup__desc">
Add <code>PUBLIC_GOOGLE_MAPS_API_KEY</code> to your <code>.env</code> file to enable the map.
</p>
</div>
</div>
) : (
<>
{/* Consent placeholder — shown when consent is required but not granted */}
<div class="google-map-placeholder" data-map-placeholder={id}>
<div class="google-map-placeholder__icon">
{/* Lucide map-pin */}
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0" />
<circle cx="12" cy="10" r="3" />
</svg>
</div>
{query && <p class="google-map-placeholder__address">{query}</p>}
<p class="google-map-placeholder__title">{placeholderTitle}</p>
<p class="google-map-placeholder__desc">{placeholderDescription}</p>
<button class="google-map-placeholder__btn" data-map-load={id} type="button">
Load Map
</button>
<a
href={externalUrl}
target="_blank"
rel="noopener noreferrer"
class="google-map-placeholder__link"
>
{/* Lucide external-link */}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
{externalLinkText}
</a>
</div>
{/* Iframe — hidden until consent granted */}
<iframe
data-map-iframe={id}
data-src={iframeSrc}
hidden
width="100%"
height="100%"
style="border:0;"
loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
allow="fullscreen"
aria-label={ariaLabel}
></iframe>
<script type="application/json" data-google-map-config={id} set:html={mapConfig} />
</>
)}
</div>
<script>
interface MapWindow extends Window {
__consentState?: { decided: boolean; categories: Record<string, boolean> };
}
function initGoogleMaps() {
const maps = document.querySelectorAll<HTMLElement>('[data-google-map]');
maps.forEach((container) => {
const id = container.dataset.googleMap!;
const configEl = container.querySelector<HTMLScriptElement>(`[data-google-map-config="${id}"]`);
if (!configEl) return;
const config = JSON.parse(configEl.textContent!);
const iframe = container.querySelector<HTMLIFrameElement>(`[data-map-iframe="${id}"]`);
const placeholder = container.querySelector<HTMLElement>(`[data-map-placeholder="${id}"]`);
const loadBtn = container.querySelector<HTMLButtonElement>(`[data-map-load="${id}"]`);
if (!iframe) return;
// Already loaded (idempotent)
if (iframe.src && iframe.src !== 'about:blank') return;
const w = window as unknown as MapWindow;
function loadMap() {
const src = iframe!.dataset.src;
if (!src || (iframe!.src && iframe!.src !== 'about:blank')) return;
iframe!.src = src;
iframe!.removeAttribute('hidden');
if (placeholder) placeholder.hidden = true;
}
function hasConsent(): boolean {
if (!config.consentEnabled) return true;
if (!w.__consentState?.decided) return false;
return !!w.__consentState.categories[config.consentCategory];
}
// Check if consent is already granted
if (hasConsent()) {
loadMap();
return;
}
// Consent required — show placeholder
if (config.consentEnabled) {
if (placeholder) placeholder.hidden = false;
// "Load Map" button grants consent for this embed only
if (loadBtn) {
loadBtn.addEventListener('click', loadMap, { once: true });
}
// Listen for consent-updated event
window.addEventListener('consent-updated', function onConsent(e: Event) {
const detail = (e as CustomEvent).detail;
if (detail?.categories?.[config.consentCategory]) {
loadMap();
window.removeEventListener('consent-updated', onConsent);
}
});
} else {
// No consent system — load immediately
loadMap();
}
});
}
initGoogleMaps();
document.addEventListener('astro:page-load', initGoogleMaps);
</script>
<style>
/* ── No API key: setup prompt ── */
.google-map-setup {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
text-align: center;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 8px,
color-mix(in srgb, var(--foreground-muted) 4%, transparent) 8px,
color-mix(in srgb, var(--foreground-muted) 4%, transparent) 9px
);
}
.google-map-setup__inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 1.5rem 2rem;
background-color: var(--card);
border: 1px dashed var(--border);
border-radius: 0.75rem;
}
.google-map-setup__icon {
color: var(--foreground-muted);
opacity: 0.6;
}
.google-map-setup__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--foreground);
}
.google-map-setup__desc {
font-size: 0.8125rem;
color: var(--foreground-muted);
max-width: 22rem;
line-height: 1.6;
}
.google-map-setup__desc code {
font-family: ui-monospace, 'SF Mono', 'Cascadia Code', monospace;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background-color: var(--secondary);
border: 1px solid var(--border);
border-radius: 0.25rem;
white-space: nowrap;
}
/* ── Consent placeholder ── */
.google-map-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background-color: var(--secondary);
padding: 2rem;
text-align: center;
}
.google-map-placeholder__icon {
color: var(--brand-500);
margin-bottom: 0.25rem;
}
.google-map-placeholder__address {
font-size: 0.875rem;
font-weight: 500;
color: var(--foreground);
max-width: 24rem;
}
.google-map-placeholder__title {
font-size: 1.125rem;
font-weight: 600;
color: var(--foreground);
}
.google-map-placeholder__desc {
font-size: 0.875rem;
color: var(--foreground-muted);
max-width: 20rem;
}
.google-map-placeholder__btn {
margin-top: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
color: white;
background-color: var(--brand-500);
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: opacity var(--transition-fast) ease;
}
.google-map-placeholder__btn:hover {
opacity: 0.9;
}
.google-map-placeholder__link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.25rem;
font-size: 0.8125rem;
color: var(--foreground-muted);
text-decoration: none;
transition: color var(--transition-fast) ease;
}
.google-map-placeholder__link:hover {
color: var(--foreground);
}
/* ── Iframe ── */
[data-map-iframe] {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,21 @@
import { cva, type VariantProps } from 'class-variance-authority';
export const googleMapVariants = cva(
'relative w-full overflow-hidden rounded-xl border border-border',
{
variants: {
size: {
sm: 'h-[250px]',
md: 'h-[400px]',
lg: 'h-[500px]',
xl: 'h-[600px]',
full: 'h-[70vh]',
},
},
defaultVariants: {
size: 'md',
},
}
);
export type GoogleMapVariants = VariantProps<typeof googleMapVariants>;
@@ -0,0 +1,2 @@
export { default } from './GoogleMap.astro';
export { googleMapVariants, type GoogleMapVariants } from './googleMap.variants';
@@ -0,0 +1,135 @@
---
/**
* Pagination Component
* Page navigation with prev/next and numbered pages.
*/
import type { HTMLAttributes } from 'astro/types';
import { cn } from '@/lib/cn';
import { paginationItemVariants } from './pagination.variants';
import Icon from '../../primitives/Icon/Icon.astro';
interface Props extends HTMLAttributes<'nav'> {
/** Current active page (1-indexed) */
currentPage: number;
/** Total number of pages */
totalPages: number;
/** Base URL for page links (page number appended) */
baseUrl?: string;
/** Maximum number of visible page buttons */
maxVisible?: number;
size?: 'sm' | 'md' | 'lg';
}
const {
currentPage,
totalPages,
baseUrl = '?page=',
maxVisible = 5,
size = 'md',
class: className,
...attrs
} = Astro.props;
/**
* Build the array of page numbers and ellipsis markers to render.
*
* @param current - The 1-indexed active page.
* @param total - Total number of pages.
* @param max - Size of the central sliding window. When `total <= max`,
* every page is returned directly. Otherwise a window of
* `max` pages is centered around `current`, and first/last
* pages plus `'...'` ellipsis markers are added outside the
* window as needed — so the returned array can contain more
* than `max` entries.
* @returns An array of page numbers and `'...'` separators.
*/
function getPageRange(current: number, total: number, max: number): (number | '...')[] {
if (total <= max) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | '...')[] = [];
const half = Math.floor(max / 2);
let start = Math.max(1, current - half);
let end = Math.min(total, start + max - 1);
if (end - start < max - 1) {
start = Math.max(1, end - max + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push('...');
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < total) {
if (end < total - 1) pages.push('...');
pages.push(total);
}
return pages;
}
const pages = getPageRange(currentPage, totalPages, maxVisible);
const prevUrl = currentPage > 1 ? `${baseUrl}${currentPage - 1}` : undefined;
const nextUrl = currentPage < totalPages ? `${baseUrl}${currentPage + 1}` : undefined;
---
<nav class={cn('flex items-center gap-1', className)} aria-label="Pagination" {...attrs}>
{/* Previous */}
{prevUrl ? (
<a
href={prevUrl}
class={cn(paginationItemVariants({ variant: 'default', size }))}
aria-label="Previous page"
>
<Icon name="chevron-left" size="sm" />
</a>
) : (
<span class={cn(paginationItemVariants({ variant: 'disabled', size }))} aria-disabled="true">
<Icon name="chevron-left" size="sm" />
</span>
)}
{/* Page Numbers */}
{pages.map((page) =>
page === '...' ? (
<span class={cn(paginationItemVariants({ variant: 'default', size }), 'cursor-default hover:bg-transparent')} role="separator" aria-label="More pages">
<span aria-hidden="true">...</span>
</span>
) : page === currentPage ? (
<span
class={cn(paginationItemVariants({ variant: 'active', size }))}
aria-current="page"
>
{page}
</span>
) : (
<a
href={`${baseUrl}${page}`}
class={cn(paginationItemVariants({ variant: 'default', size }))}
>
{page}
</a>
)
)}
{/* Next */}
{nextUrl ? (
<a
href={nextUrl}
class={cn(paginationItemVariants({ variant: 'default', size }))}
aria-label="Next page"
>
<Icon name="chevron-right" size="sm" />
</a>
) : (
<span class={cn(paginationItemVariants({ variant: 'disabled', size }))} aria-disabled="true">
<Icon name="chevron-right" size="sm" />
</span>
)}
</nav>
@@ -0,0 +1,208 @@
/**
* Pagination Component (React)
* Page navigation with prev/next and numbered pages.
*/
import type { HTMLAttributes } from 'react';
import { cn } from '@/lib/cn';
import { paginationItemVariants } from './pagination.variants';
interface PaginationProps extends HTMLAttributes<HTMLElement> {
/** Current active page (1-indexed) */
currentPage: number;
/** Total number of pages */
totalPages: number;
/** Callback when a page is selected */
onPageChange: (page: number) => void;
/** Maximum number of visible page buttons */
maxVisible?: number;
/** Button size variant */
size?: 'sm' | 'md' | 'lg';
}
/**
* Build the array of page numbers and ellipsis markers to render.
*
* @param current - The 1-indexed active page.
* @param total - Total number of pages.
* @param max - Size of the central sliding window. When `total <= max`,
* every page is returned directly. Otherwise a window of
* `max` pages is centered around `current`, and first/last
* pages plus `'...'` ellipsis markers are added outside the
* window as needed — so the returned array can contain more
* than `max` entries.
* @returns An array of page numbers and `'...'` separators.
*/
function getPageRange(current: number, total: number, max: number): (number | '...')[] {
if (total <= max) {
return Array.from({ length: total }, (_, i) => i + 1);
}
const pages: (number | '...')[] = [];
const half = Math.floor(max / 2);
let start = Math.max(1, current - half);
const end = Math.min(total, start + max - 1);
if (end - start < max - 1) {
start = Math.max(1, end - max + 1);
}
if (start > 1) {
pages.push(1);
if (start > 2) pages.push('...');
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (end < total) {
if (end < total - 1) pages.push('...');
pages.push(total);
}
return pages;
}
export function Pagination({
currentPage,
totalPages,
onPageChange,
maxVisible = 5,
size = 'md',
className,
...attrs
}: PaginationProps) {
const pages = getPageRange(currentPage, totalPages, maxVisible);
const hasPrev = currentPage > 1;
const hasNext = currentPage < totalPages;
return (
<nav className={cn('flex items-center gap-1', className)} aria-label="Pagination" {...attrs}>
{/* Previous */}
{hasPrev ? (
<button
type="button"
className={cn(paginationItemVariants({ variant: 'default', size }))}
aria-label="Previous page"
onClick={() => onPageChange(currentPage - 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
) : (
<span
className={cn(paginationItemVariants({ variant: 'disabled', size }))}
aria-disabled="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</span>
)}
{/* Page Numbers */}
{pages.map((page, index) =>
page === '...' ? (
<span
key={`ellipsis-${index}`}
className={cn(
paginationItemVariants({ variant: 'default', size }),
'cursor-default hover:bg-transparent',
)}
role="separator"
aria-label="More pages"
>
<span aria-hidden="true">...</span>
</span>
) : page === currentPage ? (
<span
key={page}
className={cn(paginationItemVariants({ variant: 'active', size }))}
aria-current="page"
>
{page}
</span>
) : (
<button
key={page}
type="button"
className={cn(paginationItemVariants({ variant: 'default', size }))}
onClick={() => onPageChange(page)}
>
{page}
</button>
),
)}
{/* Next */}
{hasNext ? (
<button
type="button"
className={cn(paginationItemVariants({ variant: 'default', size }))}
aria-label="Next page"
onClick={() => onPageChange(currentPage + 1)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
) : (
<span
className={cn(paginationItemVariants({ variant: 'disabled', size }))}
aria-disabled="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9 18l6-6-6-6" />
</svg>
</span>
)}
</nav>
);
}
export default Pagination;

Some files were not shown because too many files have changed in this diff Show More