feat: Racing Cards landing page - Astro + Tailwind v4
- Mobile-first dark mode landing - Lucide icons (zero emojis) - Self-hostable (Dockerfile + nginx) - 10 card images generated with Nano Banana 2 - Sections: Hero, How It Works, Card Gallery, Features, Early Access, Footer - Simple HTML form (no Netlify dependency) - Zero JS output, static HTML
This commit is contained in:
1
.astro/content-assets.mjs
Normal file
1
.astro/content-assets.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export default new Map();
|
||||
1
.astro/content-modules.mjs
Normal file
1
.astro/content-modules.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export default new Map();
|
||||
199
.astro/content.d.ts
vendored
Normal file
199
.astro/content.d.ts
vendored
Normal file
@@ -0,0 +1,199 @@
|
||||
declare module 'astro:content' {
|
||||
export interface RenderResult {
|
||||
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
|
||||
headings: import('astro').MarkdownHeading[];
|
||||
remarkPluginFrontmatter: Record<string, any>;
|
||||
}
|
||||
interface Render {
|
||||
'.md': Promise<RenderResult>;
|
||||
}
|
||||
|
||||
export interface RenderedContent {
|
||||
html: string;
|
||||
metadata?: {
|
||||
imagePaths: Array<string>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'astro:content' {
|
||||
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
|
||||
|
||||
export type CollectionKey = keyof AnyEntryMap;
|
||||
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
|
||||
|
||||
export type ContentCollectionKey = keyof ContentEntryMap;
|
||||
export type DataCollectionKey = keyof DataEntryMap;
|
||||
|
||||
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
|
||||
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
|
||||
ContentEntryMap[C]
|
||||
>['slug'];
|
||||
|
||||
export type ReferenceDataEntry<
|
||||
C extends CollectionKey,
|
||||
E extends keyof DataEntryMap[C] = string,
|
||||
> = {
|
||||
collection: C;
|
||||
id: E;
|
||||
};
|
||||
export type ReferenceContentEntry<
|
||||
C extends keyof ContentEntryMap,
|
||||
E extends ValidContentEntrySlug<C> | (string & {}) = string,
|
||||
> = {
|
||||
collection: C;
|
||||
slug: E;
|
||||
};
|
||||
export type ReferenceLiveEntry<C extends keyof LiveContentConfig['collections']> = {
|
||||
collection: C;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/** @deprecated Use `getEntry` instead. */
|
||||
export function getEntryBySlug<
|
||||
C extends keyof ContentEntryMap,
|
||||
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
// Note that this has to accept a regular string too, for SSR
|
||||
entrySlug: E,
|
||||
): E extends ValidContentEntrySlug<C>
|
||||
? Promise<CollectionEntry<C>>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
|
||||
/** @deprecated Use `getEntry` instead. */
|
||||
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
|
||||
collection: C,
|
||||
entryId: E,
|
||||
): Promise<CollectionEntry<C>>;
|
||||
|
||||
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => entry is E,
|
||||
): Promise<E[]>;
|
||||
export function getCollection<C extends keyof AnyEntryMap>(
|
||||
collection: C,
|
||||
filter?: (entry: CollectionEntry<C>) => unknown,
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function getLiveCollection<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter?: LiveLoaderCollectionFilterType<C>,
|
||||
): Promise<
|
||||
import('astro').LiveDataCollectionResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>
|
||||
>;
|
||||
|
||||
export function getEntry<
|
||||
C extends keyof ContentEntryMap,
|
||||
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||
>(
|
||||
entry: ReferenceContentEntry<C, E>,
|
||||
): E extends ValidContentEntrySlug<C>
|
||||
? Promise<CollectionEntry<C>>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
entry: ReferenceDataEntry<C, E>,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getEntry<
|
||||
C extends keyof ContentEntryMap,
|
||||
E extends ValidContentEntrySlug<C> | (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
slug: E,
|
||||
): E extends ValidContentEntrySlug<C>
|
||||
? Promise<CollectionEntry<C>>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getEntry<
|
||||
C extends keyof DataEntryMap,
|
||||
E extends keyof DataEntryMap[C] | (string & {}),
|
||||
>(
|
||||
collection: C,
|
||||
id: E,
|
||||
): E extends keyof DataEntryMap[C]
|
||||
? string extends keyof DataEntryMap[C]
|
||||
? Promise<DataEntryMap[C][E]> | undefined
|
||||
: Promise<DataEntryMap[C][E]>
|
||||
: Promise<CollectionEntry<C> | undefined>;
|
||||
export function getLiveEntry<C extends keyof LiveContentConfig['collections']>(
|
||||
collection: C,
|
||||
filter: string | LiveLoaderEntryFilterType<C>,
|
||||
): Promise<import('astro').LiveDataEntryResult<LiveLoaderDataType<C>, LiveLoaderErrorType<C>>>;
|
||||
|
||||
/** Resolve an array of entry references from the same collection */
|
||||
export function getEntries<C extends keyof ContentEntryMap>(
|
||||
entries: ReferenceContentEntry<C, ValidContentEntrySlug<C>>[],
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
export function getEntries<C extends keyof DataEntryMap>(
|
||||
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
|
||||
): Promise<CollectionEntry<C>[]>;
|
||||
|
||||
export function render<C extends keyof AnyEntryMap>(
|
||||
entry: AnyEntryMap[C][string],
|
||||
): Promise<RenderResult>;
|
||||
|
||||
export function reference<C extends keyof AnyEntryMap>(
|
||||
collection: C,
|
||||
): import('astro/zod').ZodEffects<
|
||||
import('astro/zod').ZodString,
|
||||
C extends keyof ContentEntryMap
|
||||
? ReferenceContentEntry<C, ValidContentEntrySlug<C>>
|
||||
: ReferenceDataEntry<C, keyof DataEntryMap[C]>
|
||||
>;
|
||||
// Allow generic `string` to avoid excessive type errors in the config
|
||||
// if `dev` is not running to update as you edit.
|
||||
// Invalid collection names will be caught at build time.
|
||||
export function reference<C extends string>(
|
||||
collection: C,
|
||||
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
|
||||
|
||||
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
|
||||
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
|
||||
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
|
||||
>;
|
||||
|
||||
type ContentEntryMap = {
|
||||
|
||||
};
|
||||
|
||||
type DataEntryMap = {
|
||||
|
||||
};
|
||||
|
||||
type AnyEntryMap = ContentEntryMap & DataEntryMap;
|
||||
|
||||
type ExtractLoaderTypes<T> = T extends import('astro/loaders').LiveLoader<
|
||||
infer TData,
|
||||
infer TEntryFilter,
|
||||
infer TCollectionFilter,
|
||||
infer TError
|
||||
>
|
||||
? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError }
|
||||
: { data: never; entryFilter: never; collectionFilter: never; error: never };
|
||||
type ExtractDataType<T> = ExtractLoaderTypes<T>['data'];
|
||||
type ExtractEntryFilterType<T> = ExtractLoaderTypes<T>['entryFilter'];
|
||||
type ExtractCollectionFilterType<T> = ExtractLoaderTypes<T>['collectionFilter'];
|
||||
type ExtractErrorType<T> = ExtractLoaderTypes<T>['error'];
|
||||
|
||||
type LiveLoaderDataType<C extends keyof LiveContentConfig['collections']> =
|
||||
LiveContentConfig['collections'][C]['schema'] extends undefined
|
||||
? ExtractDataType<LiveContentConfig['collections'][C]['loader']>
|
||||
: import('astro/zod').infer<
|
||||
Exclude<LiveContentConfig['collections'][C]['schema'], undefined>
|
||||
>;
|
||||
type LiveLoaderEntryFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractEntryFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderCollectionFilterType<C extends keyof LiveContentConfig['collections']> =
|
||||
ExtractCollectionFilterType<LiveContentConfig['collections'][C]['loader']>;
|
||||
type LiveLoaderErrorType<C extends keyof LiveContentConfig['collections']> = ExtractErrorType<
|
||||
LiveContentConfig['collections'][C]['loader']
|
||||
>;
|
||||
|
||||
export type ContentConfig = typeof import("../src/content.config.mjs");
|
||||
export type LiveContentConfig = never;
|
||||
}
|
||||
2
.astro/types.d.ts
vendored
Normal file
2
.astro/types.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.astro/
|
||||
.DS_Store
|
||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
9
astro.config.mjs
Normal file
9
astro.config.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
output: 'static',
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
});
|
||||
1
dist/_astro/index.BfoiI1Is.css
vendored
Normal file
1
dist/_astro/index.BfoiI1Is.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dist/favicon.svg
vendored
Normal file
4
dist/favicon.svg
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0a0a12"/>
|
||||
<text x="16" y="23" text-anchor="middle" font-size="20" font-family="sans-serif" font-weight="bold" fill="#e10600">R</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 249 B |
BIN
dist/images/cards/hero-card.png
vendored
Normal file
BIN
dist/images/cards/hero-card.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
dist/images/cards/recta-aleron.png
vendored
Normal file
BIN
dist/images/cards/recta-aleron.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
47
dist/index.html
vendored
Normal file
47
dist/index.html
vendored
Normal file
File diff suppressed because one or more lines are too long
18
netlify.toml
Normal file
18
netlify.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[build]
|
||||
command = "npm 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"
|
||||
|
||||
[[headers]]
|
||||
for = "/_astro/*"
|
||||
[headers.values]
|
||||
Cache-Control = "public, max-age=31536000, immutable"
|
||||
27
nginx.conf
Normal file
27
nginx.conf
Normal file
@@ -0,0 +1,27 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Cache static assets
|
||||
location /_astro/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Gzip
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 256;
|
||||
}
|
||||
6123
package-lock.json
generated
Normal file
6123
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "racing-cards-landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"astro": "^5.18.0",
|
||||
"tailwindcss": "^4.2.1"
|
||||
}
|
||||
}
|
||||
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0a0a12"/>
|
||||
<text x="16" y="23" text-anchor="middle" font-size="20" font-family="sans-serif" font-weight="bold" fill="#e10600">R</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 249 B |
BIN
public/images/cards/hero-card.png
Normal file
BIN
public/images/cards/hero-card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/images/cards/recta-aleron.png
Normal file
BIN
public/images/cards/recta-aleron.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
71
src/components/CardGallery.astro
Normal file
71
src/components/CardGallery.astro
Normal file
@@ -0,0 +1,71 @@
|
||||
---
|
||||
interface Card {
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'recta' | 'curva' | 'especial';
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const cardTypes = {
|
||||
recta: { label: 'Recta', borderClass: 'border-green-500/30', barClass: 'bg-green-500', textClass: 'text-green-400', bgClass: 'bg-green-500/10' },
|
||||
curva: { label: 'Curva', borderClass: 'border-yellow-500/30', barClass: 'bg-yellow-500', textClass: 'text-yellow-400', bgClass: 'bg-yellow-500/10' },
|
||||
especial: { label: 'Especial', borderClass: 'border-primary/30', barClass: 'bg-primary', textClass: 'text-primary', bgClass: 'bg-primary/10' },
|
||||
};
|
||||
|
||||
// Lucide icon SVGs for card type icons
|
||||
const typeIcons = {
|
||||
recta: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
|
||||
curva: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17m-2 0a2 2 0 1 0 4 0a2 2 0 1 0-4 0"/><path d="M17 7m-2 0a2 2 0 1 0 4 0a2 2 0 1 0-4 0"/><path d="M7 15V9a5 5 0 0 1 10 0"/></svg>`,
|
||||
especial: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
|
||||
};
|
||||
|
||||
const cards: Card[] = [
|
||||
{ name: 'Aleron Movil', description: 'Abre el aleron y vuela. Bonus de velocidad en zonas de recta.', type: 'recta', image: '/images/cards/recta-aleron.png' },
|
||||
{ name: 'Potencia', description: 'Mas caballos, mas recta. Tu carta base para tramos rapidos.', type: 'recta' },
|
||||
{ name: 'Rebufo', description: 'Pegate al de delante y aprovecha el aire.', type: 'recta' },
|
||||
{ name: 'Acelerar', description: 'Salida de curva limpia, pisas a fondo.', type: 'recta' },
|
||||
{ name: 'Curva Limpia', description: 'La linea perfecta. Ni un metro de mas.', type: 'curva' },
|
||||
{ name: 'Frenada Tardia', description: 'Frena mas tarde que nadie. Alto riesgo, alta recompensa.', type: 'curva' },
|
||||
{ name: 'Pit Stop', description: 'Neumaticos nuevos. Pierdes tiempo ahora, lo recuperas despues.', type: 'especial' },
|
||||
{ name: 'Coche de Seguridad', description: 'Se compacta el peloton. Tu ventaja desaparece... o tu desventaja tambien.', type: 'especial' },
|
||||
{ name: 'Bandera Roja', description: 'Carrera detenida. Caos para unos, oportunidad para otros.', type: 'especial' },
|
||||
{ name: 'Lluvia', description: 'Cambia la pista entera. Intermedios o slicks?', type: 'especial' },
|
||||
];
|
||||
---
|
||||
|
||||
<section class="px-4 py-16 md:py-24 bg-surface/50 border-y border-white/5">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="flex justify-between items-end mb-10">
|
||||
<h2 class="font-heading text-text text-3xl md:text-4xl font-bold tracking-tight">
|
||||
Tu baraja,<br />tu estrategia
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Horizontal scroll on mobile -->
|
||||
<div class="flex gap-4 overflow-x-auto pb-6 snap-x snap-mandatory hide-scrollbar -mx-4 px-4 md:grid md:grid-cols-3 lg:grid-cols-4 md:overflow-visible md:mx-0 md:px-0">
|
||||
{cards.map((card) => {
|
||||
const t = cardTypes[card.type];
|
||||
return (
|
||||
<div class={`min-w-[200px] md:min-w-0 snap-center shrink-0 md:shrink flex flex-col bg-surface rounded-xl border ${t.borderClass} overflow-hidden shadow-lg hover:-translate-y-1 hover:shadow-xl transition-all duration-200`}>
|
||||
<div class={`h-1.5 ${t.barClass}`}></div>
|
||||
{card.image ? (
|
||||
<div class="h-28 overflow-hidden">
|
||||
<img src={card.image} alt={card.name} class="w-full h-full object-cover" loading="lazy" />
|
||||
</div>
|
||||
) : null}
|
||||
<div class={`px-3 py-2 ${t.bgClass} flex justify-between items-center`}>
|
||||
<span class={`text-xs font-bold ${t.textClass} uppercase tracking-wider`}>{t.label}</span>
|
||||
<span class={t.textClass}>
|
||||
<Fragment set:html={typeIcons[card.type]} />
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col gap-2 flex-1">
|
||||
<h4 class="text-text font-bold">{card.name}</h4>
|
||||
<p class="text-xs text-text-muted leading-relaxed flex-1">{card.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
57
src/components/EarlyAccess.astro
Normal file
57
src/components/EarlyAccess.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
// Simple HTML form — no Netlify Forms, no honeypot, self-hostable
|
||||
// Lucide: Flag (checkered flag equivalent)
|
||||
---
|
||||
|
||||
<section id="early-access" class="px-4 py-16 md:py-24 relative overflow-hidden">
|
||||
<!-- Decorative skewed bg -->
|
||||
<div class="absolute inset-0 bg-primary/5 -skew-y-3 origin-top-left -z-10"></div>
|
||||
|
||||
<div class="bg-surface rounded-2xl p-8 md:p-12 relative border border-primary/20 shadow-[0_0_40px_rgba(225,6,0,0.15)] text-center max-w-lg mx-auto">
|
||||
<!-- Lucide: Flag icon -->
|
||||
<div class="flex justify-center mb-4 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/></svg>
|
||||
</div>
|
||||
|
||||
<h2 class="font-heading text-text text-3xl font-bold leading-tight mb-4 tracking-tight">
|
||||
Apuntate a la parrilla de salida
|
||||
</h2>
|
||||
|
||||
<p class="text-text-muted text-sm mb-6 max-w-sm mx-auto">
|
||||
Los primeros en registrarse seran los primeros en jugar. Dejanos tu email.
|
||||
</p>
|
||||
|
||||
<form
|
||||
id="early-access-form"
|
||||
method="POST"
|
||||
action="/api/register"
|
||||
class="flex flex-col gap-4"
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
placeholder="tu@email.com"
|
||||
autocomplete="email"
|
||||
class="w-full bg-bg border-2 border-border rounded-full h-12 px-6 text-text placeholder:text-text-disabled
|
||||
focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent
|
||||
transition-colors duration-150"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full h-12 rounded-full bg-primary text-text text-base font-bold
|
||||
shadow-[0_0_20px_rgba(225,6,0,0.3)]
|
||||
hover:shadow-[0_0_30px_rgba(225,6,0,0.5)] hover:-translate-y-0.5
|
||||
active:translate-y-0
|
||||
transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Reservar mi plaza
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-xs text-text-disabled mt-4">
|
||||
Solo email. Sin spam. Te escribimos cuando este listo.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
57
src/components/Features.astro
Normal file
57
src/components/Features.astro
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
// Lucide icons inline SVG — no emojis
|
||||
const features = [
|
||||
{
|
||||
// Lucide: Zap
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
|
||||
title: 'Partidas de 15-20 minutos',
|
||||
description: 'Una carrera completa en el tiempo de un cafe. Sin sesiones de 2 horas.',
|
||||
iconBg: 'bg-primary/20',
|
||||
iconColor: 'text-primary',
|
||||
},
|
||||
{
|
||||
// Lucide: Car (using a racing-appropriate icon)
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9C18.7 10.6 16 10 16 10s-1.3-1.4-2.2-2.3c-.5-.4-1.1-.7-1.8-.7H5c-.6 0-1.1.4-1.4.9l-1.4 2.9A3.7 3.7 0 0 0 2 12v4c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><path d="M9 17h6"/><circle cx="17" cy="17" r="2"/></svg>`,
|
||||
title: 'Mecanicas de automovilismo real',
|
||||
description: 'Gestion de neumaticos, pit stops, coche de seguridad. Las mecanicas reales del motorsport son el motor del juego.',
|
||||
iconBg: 'bg-accent/20',
|
||||
iconColor: 'text-accent',
|
||||
},
|
||||
{
|
||||
// Lucide: Users
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
||||
title: 'Online con amigos',
|
||||
description: 'Crea una sala, comparte el enlace, a correr. Multijugador desde cualquier dispositivo.',
|
||||
iconBg: 'bg-blue-500/20',
|
||||
iconColor: 'text-blue-400',
|
||||
},
|
||||
{
|
||||
// Lucide: Ban
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>`,
|
||||
title: 'Sin pay-to-win',
|
||||
description: 'Mismas cartas para todos. Aqui gana quien juega mejor. Punto.',
|
||||
iconBg: 'bg-green-500/20',
|
||||
iconColor: 'text-green-400',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="px-4 py-16 md:py-24">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="font-heading text-text text-3xl md:text-4xl font-bold text-center mb-10 tracking-tight">
|
||||
Por que Racing Cards
|
||||
</h2>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 md:gap-6">
|
||||
{features.map((feature) => (
|
||||
<div class="bg-surface p-6 rounded-xl border border-white/5 flex flex-col gap-3 hover:border-accent/20 transition-colors duration-200">
|
||||
<div class={`w-10 h-10 rounded-lg ${feature.iconBg} ${feature.iconColor} flex items-center justify-center`}>
|
||||
<Fragment set:html={feature.icon} />
|
||||
</div>
|
||||
<h3 class="font-heading text-text font-bold text-lg">{feature.title}</h3>
|
||||
<p class="text-text-muted text-sm leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
26
src/components/Footer.astro
Normal file
26
src/components/Footer.astro
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
// Lucide: ExternalLink icon inline
|
||||
---
|
||||
|
||||
<footer class="bg-surface border-t border-white/5 py-8 px-4 text-center">
|
||||
<div class="flex flex-col gap-4 items-center max-w-4xl mx-auto">
|
||||
<span class="font-heading font-bold text-xl text-text tracking-tight">RACING CARDS</span>
|
||||
|
||||
<p class="text-sm text-text-muted max-w-md">
|
||||
Un juego de cartas de carreras hecho con estrategia (y sin microtransacciones).
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm text-accent hover:underline inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Probar version actual
|
||||
<!-- Lucide: ChevronRight -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/></svg>
|
||||
</a>
|
||||
|
||||
<p class="text-sm text-text-disabled mt-2">
|
||||
© 2026 Racing Cards. Hecho en Barcelona.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
13
src/components/Header.astro
Normal file
13
src/components/Header.astro
Normal file
@@ -0,0 +1,13 @@
|
||||
---
|
||||
// Sticky header — mobile-first
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 flex items-center justify-between px-4 py-3 bg-bg/90 backdrop-blur-md border-b border-border">
|
||||
<span class="text-text text-xl font-bold font-heading tracking-tight">Racing Cards</span>
|
||||
<a
|
||||
href="#early-access"
|
||||
class="bg-primary/10 text-primary px-4 py-1.5 rounded-full text-sm font-bold hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
Acceso anticipado
|
||||
</a>
|
||||
</header>
|
||||
79
src/components/Hero.astro
Normal file
79
src/components/Hero.astro
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
// Hero section — full viewport, centered, radial gradient bg
|
||||
---
|
||||
|
||||
<section class="relative flex flex-col items-center justify-center text-center min-h-screen px-4 py-16 overflow-hidden">
|
||||
<!-- Background gradient -->
|
||||
<div class="absolute inset-0 -z-10 bg-[radial-gradient(ellipse_at_center,_rgba(225,6,0,0.15)_0%,_transparent_60%)]"></div>
|
||||
|
||||
<!-- Speed lines decoration -->
|
||||
<div class="speed-lines"></div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-4 max-w-md lg:max-w-lg relative z-10">
|
||||
<!-- Badge -->
|
||||
<div class="inline-flex items-center justify-center gap-2 bg-primary/20 text-accent rounded-full px-3 py-1 text-xs font-bold w-fit mx-auto border border-primary/30">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-primary"></span>
|
||||
</span>
|
||||
Acceso Anticipado Abierto
|
||||
</div>
|
||||
|
||||
<!-- Heading -->
|
||||
<h1 class="font-heading text-text text-4xl md:text-5xl lg:text-6xl font-bold leading-[1.1] tracking-tight">
|
||||
Cartas, estrategia<br />y carreras. <span class="text-primary">Online.</span>
|
||||
</h1>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<p class="text-text-muted text-lg leading-relaxed mt-2 lg:text-xl">
|
||||
El card game de automovilismo por turnos. Trazados reales, partidas de 15 min y cero pay-to-win. Solo estrategia.
|
||||
</p>
|
||||
|
||||
<!-- CTA -->
|
||||
<a
|
||||
href="#early-access"
|
||||
class="inline-flex items-center justify-center w-full sm:w-auto min-w-[220px] h-14 px-8 mt-4
|
||||
bg-primary text-text text-lg font-bold rounded-full
|
||||
shadow-[0_0_20px_rgba(225,6,0,0.4)]
|
||||
hover:shadow-[0_0_30px_rgba(225,6,0,0.6)] hover:-translate-y-0.5
|
||||
transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Quiero acceso anticipado
|
||||
</a>
|
||||
|
||||
<!-- Trust text -->
|
||||
<p class="text-text-disabled text-sm mt-1">
|
||||
Gratis. Sin tarjeta. Te avisamos cuando este listo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Floating card visual -->
|
||||
<div class="mt-12 relative w-56 h-72 mx-auto lg:mt-8" style="perspective: 1000px;">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-accent/40 to-primary/40 rounded-xl blur-xl opacity-50 scale-95 translate-y-4"></div>
|
||||
<div
|
||||
class="w-full h-full bg-surface rounded-xl border border-white/10 overflow-hidden shadow-2xl relative flex flex-col
|
||||
hover:rotate-0 transition-transform duration-500"
|
||||
style="transform: rotateY(8deg) rotateZ(2deg);"
|
||||
>
|
||||
<!-- Card top half — use hero-card image if available -->
|
||||
<div class="h-1/2 bg-gradient-to-br from-slate-800 to-slate-900 p-4 relative overflow-hidden">
|
||||
<img
|
||||
src="/images/cards/hero-card.png"
|
||||
alt="Carta de Racing Cards - Trazada Perfecta"
|
||||
class="absolute inset-0 w-full h-full object-cover"
|
||||
loading="eager"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
<div class="absolute top-2 left-2 bg-bg/80 px-2 py-1 rounded text-xs font-bold text-accent z-10">CURVA</div>
|
||||
</div>
|
||||
<!-- Card bottom half -->
|
||||
<div class="p-4 flex flex-col flex-1 bg-surface">
|
||||
<h3 class="font-heading font-bold text-lg text-text mb-1">Trazada Perfecta</h3>
|
||||
<p class="text-xs text-text-muted flex-1">
|
||||
Ignora la penalizacion de desgaste en la proxima curva cerrada y manten la velocidad punta.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
64
src/components/HowItWorks.astro
Normal file
64
src/components/HowItWorks.astro
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
// Lucide icon SVGs inline
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
title: 'Elige tus cartas',
|
||||
description: 'Cada turno juegas cartas de tu mano: rectas para ganar velocidad, curvas para no salirte, especiales para cambiar la carrera.',
|
||||
borderColor: 'border-primary',
|
||||
textColor: 'text-primary',
|
||||
shadow: 'shadow-[0_0_15px_rgba(225,6,0,0.3)]',
|
||||
// Lucide: Layers
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>`,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: 'Compite en trazados reales',
|
||||
description: 'Rectas, curvas, chicanes. El trazado dicta la estrategia. Aleron abierto en la recta o rebufo en el ultimo sector?',
|
||||
borderColor: 'border-accent',
|
||||
textColor: 'text-accent',
|
||||
shadow: 'shadow-[0_0_15px_rgba(0,229,200,0.3)]',
|
||||
// Lucide: Flag
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/></svg>`,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: 'Gana con la cabeza',
|
||||
description: 'Turnos simultaneos, sin esperas. Gestiona neumaticos, calcula pit stops, lee a tu rival. 15-20 minutos y hay un ganador.',
|
||||
borderColor: 'border-text',
|
||||
textColor: 'text-text',
|
||||
shadow: 'shadow-[0_0_15px_rgba(255,255,255,0.2)]',
|
||||
// Lucide: Trophy
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/></svg>`,
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<section class="px-4 py-16 md:py-24">
|
||||
<h2 class="font-heading text-text text-3xl md:text-4xl font-bold text-center mb-12 tracking-tight">
|
||||
Asi se corre una carrera
|
||||
</h2>
|
||||
|
||||
<div class="max-w-md mx-auto relative pl-4">
|
||||
<!-- Timeline line -->
|
||||
<div class="absolute left-[35px] top-6 bottom-6 w-0.5 bg-gradient-to-b from-primary via-accent to-primary/20"></div>
|
||||
|
||||
{steps.map((step) => (
|
||||
<div class="grid grid-cols-[60px_1fr] gap-x-4 mb-8 last:mb-0 relative">
|
||||
<div class="flex flex-col items-center pt-1 z-10">
|
||||
<div class={`w-12 h-12 rounded-full bg-surface border-2 ${step.borderColor} flex items-center justify-center ${step.shadow} ${step.textColor}`}>
|
||||
<Fragment set:html={step.icon} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col pt-2 pb-4">
|
||||
<h3 class="font-heading text-text text-xl font-bold mb-2">
|
||||
{step.number}. {step.title}
|
||||
</h3>
|
||||
<p class="text-text-muted text-sm leading-relaxed">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
70
src/layouts/Layout.astro
Normal file
70
src/layouts/Layout.astro
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = 'Racing Cards — Cartas, estrategia y carreras. Online.',
|
||||
description = 'El card game de automovilismo por turnos. Trazados reales, partidas de 15 min y cero pay-to-win. Solo estrategia.',
|
||||
} = Astro.props;
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site || 'https://racingcards.com');
|
||||
const ogImage = new URL('/og-image.png', Astro.site || 'https://racingcards.com');
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<!-- SEO -->
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={ogImage} />
|
||||
<meta property="og:url" content={canonicalURL} />
|
||||
<meta property="og:locale" content="es_ES" />
|
||||
<meta property="og:site_name" content="Racing Cards" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={ogImage} />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;700&family=Inter:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Styles -->
|
||||
<style>
|
||||
@import '../styles/global.css';
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen overflow-x-hidden">
|
||||
<!-- Skip navigation -->
|
||||
<a
|
||||
href="#main-content"
|
||||
class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-primary focus:text-text focus:rounded-md"
|
||||
>
|
||||
Saltar al contenido
|
||||
</a>
|
||||
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
22
src/pages/index.astro
Normal file
22
src/pages/index.astro
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Hero from '../components/Hero.astro';
|
||||
import HowItWorks from '../components/HowItWorks.astro';
|
||||
import CardGallery from '../components/CardGallery.astro';
|
||||
import Features from '../components/Features.astro';
|
||||
import EarlyAccess from '../components/EarlyAccess.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
---
|
||||
|
||||
<Layout>
|
||||
<Header />
|
||||
<main id="main-content">
|
||||
<Hero />
|
||||
<HowItWorks />
|
||||
<CardGallery />
|
||||
<Features />
|
||||
<EarlyAccess />
|
||||
</main>
|
||||
<Footer />
|
||||
</Layout>
|
||||
123
src/styles/global.css
Normal file
123
src/styles/global.css
Normal file
@@ -0,0 +1,123 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ============================================
|
||||
THEME — Racing Cards Design System
|
||||
============================================ */
|
||||
@theme {
|
||||
/* Colors — Base */
|
||||
--color-bg: #0a0a12;
|
||||
--color-surface: #161621;
|
||||
--color-surface-alt: #1e1e3a;
|
||||
--color-border: #2a2a4a;
|
||||
|
||||
/* Colors — Primary (Red) */
|
||||
--color-primary: #e10600;
|
||||
--color-primary-hover: #c00500;
|
||||
--color-primary-active: #a00400;
|
||||
--color-primary-subtle: #2a0a0a;
|
||||
|
||||
/* Colors — Accent (Cyan) */
|
||||
--color-accent: #00e5c8;
|
||||
--color-accent-hover: #00c4ab;
|
||||
--color-accent-subtle: #0a1e1a;
|
||||
|
||||
/* Colors — Text */
|
||||
--color-text: #f0f0f5;
|
||||
--color-text-muted: #8888a0;
|
||||
--color-text-disabled: #505068;
|
||||
|
||||
/* Colors — States */
|
||||
--color-success: #22c55e;
|
||||
--color-error: #ef4444;
|
||||
|
||||
/* Typography */
|
||||
--font-heading: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-body: 'Inter', system-ui, sans-serif;
|
||||
|
||||
/* Border radius */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-xl: 24px;
|
||||
--radius-card: 14px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-card: 0 4px 24px oklch(0% 0 0 / 0.4);
|
||||
--shadow-glow-accent: 0 0 20px oklch(75% 0.18 180 / 0.3), 0 0 60px oklch(75% 0.18 180 / 0.1);
|
||||
--shadow-glow-primary: 0 0 20px oklch(55% 0.28 29 / 0.3), 0 0 60px oklch(55% 0.28 29 / 0.1);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
BASE STYLES
|
||||
============================================ */
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
font-family: var(--font-heading);
|
||||
color: var(--color-text);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
p, li {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ANIMATIONS
|
||||
============================================ */
|
||||
@layer components {
|
||||
/* Speed lines — Hero decoration */
|
||||
.speed-lines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.speed-lines::before,
|
||||
.speed-lines::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 1px;
|
||||
width: 200px;
|
||||
background: linear-gradient(90deg, transparent, oklch(75% 0.18 180 / 0.4), transparent);
|
||||
animation: speed-line 3s linear infinite;
|
||||
}
|
||||
|
||||
.speed-lines::before { top: 30%; }
|
||||
.speed-lines::after { top: 70%; width: 150px; animation-delay: 1.5s; }
|
||||
|
||||
@keyframes speed-line {
|
||||
from { translate: -200px 0; }
|
||||
to { translate: calc(100vw + 200px) 0; }
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.hide-scrollbar::-webkit-scrollbar { display: none; }
|
||||
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
REDUCED MOTION
|
||||
============================================ */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
3
tsconfig.json
Normal file
3
tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
Reference in New Issue
Block a user