diff --git a/packages/website/package.json b/packages/website/package.json new file mode 100644 index 000000000..3ed56f66f --- /dev/null +++ b/packages/website/package.json @@ -0,0 +1,33 @@ +{ + "name": "@paseo/website", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "deploy": "npm run build && wrangler deploy" + }, + "dependencies": { + "@cloudflare/vite-plugin": "^1.20.3", + "@cloudflare/workers-types": "^4.20260114.0", + "@tanstack/react-router": "^1.120.3", + "@tanstack/react-start": "^1.120.3", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "wrangler": "^4.59.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.8", + "@types/node": "^22.15.29", + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.5", + "@vitejs/plugin-react": "^4.5.1", + "tailwindcss": "^4.1.8", + "typescript": "^5.9.3", + "vite": "^7.0.0", + "vite-tsconfig-paths": "^5.1.4" + } +} diff --git a/packages/website/public/favicon.ico b/packages/website/public/favicon.ico new file mode 100644 index 000000000..170789c6b Binary files /dev/null and b/packages/website/public/favicon.ico differ diff --git a/packages/website/public/favicon.svg b/packages/website/public/favicon.svg new file mode 100644 index 000000000..5d1dedaef --- /dev/null +++ b/packages/website/public/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/website/public/hero-bg.jpg b/packages/website/public/hero-bg.jpg new file mode 100644 index 000000000..d366ec223 Binary files /dev/null and b/packages/website/public/hero-bg.jpg differ diff --git a/packages/website/public/logo.svg b/packages/website/public/logo.svg new file mode 100644 index 000000000..b8ca9396c --- /dev/null +++ b/packages/website/public/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/website/src/components/butterfly.tsx b/packages/website/src/components/butterfly.tsx new file mode 100644 index 000000000..afd31f8fa --- /dev/null +++ b/packages/website/src/components/butterfly.tsx @@ -0,0 +1,353 @@ +import { createContext, useContext, useEffect, useRef, useState } from 'react' + +// --- Vector math utilities --- + +interface Vec2 { + x: number + y: number +} + +function vec2(x: number, y: number): Vec2 { + return { x, y } +} + +function add(a: Vec2, b: Vec2): Vec2 { + return { x: a.x + b.x, y: a.y + b.y } +} + +function sub(a: Vec2, b: Vec2): Vec2 { + return { x: a.x - b.x, y: a.y - b.y } +} + +function scale(v: Vec2, s: number): Vec2 { + return { x: v.x * s, y: v.y * s } +} + +function length(v: Vec2): number { + return Math.sqrt(v.x * v.x + v.y * v.y) +} + +function normalize(v: Vec2): Vec2 { + const len = length(v) + if (len === 0) return { x: 0, y: 0 } + return { x: v.x / len, y: v.y / len } +} + +function lerpAngle(a: number, b: number, t: number): number { + // Handle angle wrapping for smooth interpolation + let diff = b - a + while (diff > 180) diff -= 360 + while (diff < -180) diff += 360 + return a + diff * t +} + +// --- Cursor context --- + +const CursorContext = createContext(null) + +export function CursorFieldProvider({ children }: { children: React.ReactNode }) { + const [cursor, setCursor] = useState(null) + + useEffect(() => { + function handleMouseMove(e: MouseEvent) { + setCursor({ x: e.clientX, y: e.clientY }) + } + + function handleMouseLeave() { + setCursor(null) + } + + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseleave', handleMouseLeave) + + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseleave', handleMouseLeave) + } + }, []) + + return {children} +} + +// --- Physics simulation state --- + +interface ButterflyState { + position: Vec2 // Current offset from origin + velocity: Vec2 // Current velocity + heading: number // Current facing angle in degrees (0 = down, positive = clockwise) + targetHeading: number // Target heading to smoothly rotate towards +} + +// Physics constants +const WANDER_STRENGTH = 0.15 // How strongly butterflies wander +const WANDER_RADIUS = 40 // Max wander distance from origin +const RETURN_STRENGTH = 0.02 // How strongly butterflies return to origin +const REPULSION_RADIUS = 150 // Cursor repulsion detection radius +const REPULSION_STRENGTH = 2.5 // How strongly cursor repels +const MAX_SPEED = 1.5 // Maximum velocity +const FRICTION = 0.98 // Velocity damping per frame +const HEADING_SMOOTHING = 0.08 // How quickly heading catches up to velocity direction + +// --- Butterfly visual component --- + +interface ButterflyVisualProps { + size?: number + color?: string + delay?: number + duration?: number + direction?: 'left' | 'right' + heading?: number +} + +function ButterflyVisual({ + size = 40, + color = '#e8a87c', + delay = 0, + duration = 0.5, + direction = 'right', + heading = 0, +}: ButterflyVisualProps) { + const wingStyle = { + animationDelay: `${delay}s`, + animationDuration: `${duration}s`, + } + + const scaleX = direction === 'left' ? -1 : 1 + + // Base butterfly has rotate(30deg), so it's already looking 30° down from horizontal. + // To keep it in the bottom half (never looking up), rotateZ range is -30° to +90° + // -30° = horizontal, 0° = 30° down (base), +90° = 120° down (nearly straight down) + const clampedHeading = Math.max(-30, Math.min(90, heading)) + + return ( +
+ + + + + + + + + {/* Far side wings (darker, behind, slightly rotated) */} + + + + + + {/* Near side wings (main color, front) */} + + + + + + + + +
+ ) +} + +function darken(hex: string, amount: number): string { + const num = parseInt(hex.replace('#', ''), 16) + const r = Math.max(0, (num >> 16) - amount) + const g = Math.max(0, ((num >> 8) & 0x00ff) - amount) + const b = Math.max(0, (num & 0x0000ff) - amount) + return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}` +} + +// --- Main FloatingButterfly component with physics --- + +interface FloatingButterflyProps { + style?: React.CSSProperties + size?: number + color?: string + delay?: number + duration?: number + direction?: 'left' | 'right' +} + +export function FloatingButterfly({ style, size, color, delay, duration, direction }: FloatingButterflyProps) { + // Initialize state lazily to avoid hydration mismatch + const initialStateRef = useRef<{ x: number; y: number; heading: number } | null>(null) + if (initialStateRef.current === null && typeof window !== 'undefined') { + // Random starting position within wander radius + const angle = Math.random() * Math.PI * 2 + const dist = Math.random() * WANDER_RADIUS * 0.5 + initialStateRef.current = { + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + heading: Math.random() * 40 - 10, + } + } + + const [mounted, setMounted] = useState(false) + const [renderState, setRenderState] = useState({ x: 0, y: 0, heading: 0 }) + + const stateRef = useRef(null) + const elementRef = useRef(null) + const rafRef = useRef(null) + const wanderAngleRef = useRef(Math.random() * Math.PI * 2) + const lastTimeRef = useRef(null) + + const cursor = useContext(CursorContext) + const cursorRef = useRef(null) + + useEffect(() => { + // Initialize physics state on mount with pre-computed random values + const init = initialStateRef.current || { x: 0, y: 0, heading: 0 } + stateRef.current = { + position: vec2(init.x, init.y), + velocity: vec2(0, 0), + heading: init.heading, + targetHeading: init.heading, + } + // Set initial render state to match physics state + setRenderState({ x: init.x, y: init.y, heading: init.heading }) + setMounted(true) + }, []) + + // Keep cursor ref in sync + useEffect(() => { + cursorRef.current = cursor + }, [cursor]) + + useEffect(() => { + if (!mounted) return + + function simulate(timestamp: number) { + const state = stateRef.current + if (!state) return + + // Skip first frame to let things settle, then start tracking time + if (lastTimeRef.current === null) { + lastTimeRef.current = timestamp + rafRef.current = requestAnimationFrame(simulate) + return + } + + // Calculate delta time (cap at 50ms to avoid huge jumps) + const dt = Math.min((timestamp - lastTimeRef.current) / 16.67, 3) + lastTimeRef.current = timestamp + + let acceleration = vec2(0, 0) + + // 1. Wander behavior - gentle random movement + wanderAngleRef.current += (Math.random() - 0.5) * 0.3 * dt + const wanderForce = vec2( + Math.cos(wanderAngleRef.current) * WANDER_STRENGTH, + Math.sin(wanderAngleRef.current) * WANDER_STRENGTH + ) + acceleration = add(acceleration, wanderForce) + + // 2. Return to origin - soft spring force + const distFromOrigin = length(state.position) + if (distFromOrigin > WANDER_RADIUS * 0.5) { + const returnForce = scale(normalize(scale(state.position, -1)), RETURN_STRENGTH * (distFromOrigin / WANDER_RADIUS)) + acceleration = add(acceleration, returnForce) + } + + // 3. Cursor repulsion + const currentCursor = cursorRef.current + if (currentCursor && elementRef.current) { + const rect = elementRef.current.getBoundingClientRect() + const worldPos = vec2(rect.left + rect.width / 2 + state.position.x, rect.top + rect.height / 2 + state.position.y) + + const toCursor = sub(worldPos, currentCursor) + const distToCursor = length(toCursor) + + if (distToCursor < REPULSION_RADIUS && distToCursor > 0) { + // Inverse square falloff for more natural feel + const strength = REPULSION_STRENGTH * Math.pow(1 - distToCursor / REPULSION_RADIUS, 2) + const repulsionForce = scale(normalize(toCursor), strength) + acceleration = add(acceleration, repulsionForce) + } + } + + // 4. Update velocity with acceleration + state.velocity = add(state.velocity, scale(acceleration, dt)) + + // 5. Apply friction + state.velocity = scale(state.velocity, Math.pow(FRICTION, dt)) + + // 6. Clamp velocity to max speed + const speed = length(state.velocity) + if (speed > MAX_SPEED) { + state.velocity = scale(normalize(state.velocity), MAX_SPEED) + } + + // 7. Update position + state.position = add(state.position, scale(state.velocity, dt)) + + // 8. Soft clamp position to wander radius + const posLen = length(state.position) + if (posLen > WANDER_RADIUS) { + state.position = scale(normalize(state.position), WANDER_RADIUS) + // Dampen velocity when hitting boundary + state.velocity = scale(state.velocity, 0.8) + } + + // 9. Calculate target heading from velocity direction + if (speed > 0.1) { + // Convert velocity to angle: 0 = moving down screen (+Y), positive = clockwise + const rawHeading = Math.atan2(state.velocity.x, state.velocity.y) * (180 / Math.PI) + // Clamp to valid range: -30° to +90° (relative to base 30° rotation) + state.targetHeading = Math.max(-30, Math.min(90, rawHeading)) + } + + // 10. Smoothly interpolate heading towards target (already clamped) + state.heading = lerpAngle(state.heading, state.targetHeading, HEADING_SMOOTHING * dt) + + // Update render state + setRenderState({ + x: state.position.x, + y: state.position.y, + heading: state.heading, + }) + + rafRef.current = requestAnimationFrame(simulate) + } + + rafRef.current = requestAnimationFrame(simulate) + + return () => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current) + } + } + }, [mounted]) + + if (!mounted) return null + + return ( +
+ +
+ ) +} diff --git a/packages/website/src/routeTree.gen.ts b/packages/website/src/routeTree.gen.ts new file mode 100644 index 000000000..f1c69c7ab --- /dev/null +++ b/packages/website/src/routeTree.gen.ts @@ -0,0 +1,129 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as DocsRouteImport } from './routes/docs' +import { Route as IndexRouteImport } from './routes/index' +import { Route as DocsIndexRouteImport } from './routes/docs/index' +import { Route as DocsBestPracticesRouteImport } from './routes/docs/best-practices' + +const DocsRoute = DocsRouteImport.update({ + id: '/docs', + path: '/docs', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const DocsIndexRoute = DocsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => DocsRoute, +} as any) +const DocsBestPracticesRoute = DocsBestPracticesRouteImport.update({ + id: '/best-practices', + path: '/best-practices', + getParentRoute: () => DocsRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/docs': typeof DocsRouteWithChildren + '/docs/best-practices': typeof DocsBestPracticesRoute + '/docs/': typeof DocsIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/docs/best-practices': typeof DocsBestPracticesRoute + '/docs': typeof DocsIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/docs': typeof DocsRouteWithChildren + '/docs/best-practices': typeof DocsBestPracticesRoute + '/docs/': typeof DocsIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/docs' | '/docs/best-practices' | '/docs/' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/docs/best-practices' | '/docs' + id: '__root__' | '/' | '/docs' | '/docs/best-practices' | '/docs/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + DocsRoute: typeof DocsRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/docs': { + id: '/docs' + path: '/docs' + fullPath: '/docs' + preLoaderRoute: typeof DocsRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/docs/': { + id: '/docs/' + path: '/' + fullPath: '/docs/' + preLoaderRoute: typeof DocsIndexRouteImport + parentRoute: typeof DocsRoute + } + '/docs/best-practices': { + id: '/docs/best-practices' + path: '/best-practices' + fullPath: '/docs/best-practices' + preLoaderRoute: typeof DocsBestPracticesRouteImport + parentRoute: typeof DocsRoute + } + } +} + +interface DocsRouteChildren { + DocsBestPracticesRoute: typeof DocsBestPracticesRoute + DocsIndexRoute: typeof DocsIndexRoute +} + +const DocsRouteChildren: DocsRouteChildren = { + DocsBestPracticesRoute: DocsBestPracticesRoute, + DocsIndexRoute: DocsIndexRoute, +} + +const DocsRouteWithChildren = DocsRoute._addFileChildren(DocsRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + DocsRoute: DocsRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +import type { getRouter } from './router.tsx' +import type { createStart } from '@tanstack/react-start' +declare module '@tanstack/react-start' { + interface Register { + ssr: true + router: Awaited> + } +} diff --git a/packages/website/src/router.tsx b/packages/website/src/router.tsx new file mode 100644 index 000000000..12ebb735e --- /dev/null +++ b/packages/website/src/router.tsx @@ -0,0 +1,15 @@ +import { createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +export function getRouter() { + return createRouter({ + routeTree, + scrollRestoration: true, + }) +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType + } +} diff --git a/packages/website/src/routes/__root.tsx b/packages/website/src/routes/__root.tsx new file mode 100644 index 000000000..84158a814 --- /dev/null +++ b/packages/website/src/routes/__root.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react' +import { + Outlet, + createRootRoute, + HeadContent, + Scripts, +} from '@tanstack/react-router' + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { name: 'theme-color', content: '#0a0a0a' }, + ], + links: [ + { rel: 'icon', href: '/favicon.ico', sizes: '48x48' }, + { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }, + { rel: 'apple-touch-icon', href: '/favicon.svg' }, + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' }, + { + rel: 'stylesheet', + href: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;500;600&display=swap', + }, + ], + }), + component: RootComponent, +}) + +function RootComponent() { + return ( + + + + ) +} + +function RootDocument({ children }: Readonly<{ children: ReactNode }>) { + return ( + + + + + + {children} + + + + ) +} diff --git a/packages/website/src/styles.css b/packages/website/src/styles.css new file mode 100644 index 000000000..7bf137a67 --- /dev/null +++ b/packages/website/src/styles.css @@ -0,0 +1,111 @@ +@import 'tailwindcss'; + +@theme { + --font-serif: 'Playfair Display', serif; + + --color-background: #0a0a0a; + --color-foreground: #fafafa; + --color-muted: #262626; + --color-muted-foreground: #a1a1aa; + --color-card: #171717; + --color-border: #27272a; + --color-primary: #3b82f6; + --color-primary-foreground: #ffffff; + --color-secondary: #27272a; + --color-secondary-foreground: #fafafa; + + --animate-flutter-left: flutter-left 0.6s ease-in-out infinite; + --animate-flutter-right: flutter-right 0.6s ease-in-out infinite; + --animate-float: float 8s ease-in-out infinite; + --animate-flutter-body: flutter-body 0.5s ease-in-out infinite; +} + +@keyframes flutter-left { + 0%, + 100% { + transform: rotateY(0deg) rotateZ(0deg); + } + 50% { + transform: rotateY(50deg) rotateZ(-5deg); + } +} + +@keyframes flutter-right { + 0%, + 100% { + transform: rotateY(0deg) rotateZ(0deg); + } + 50% { + transform: rotateY(-50deg) rotateZ(5deg); + } +} + +@keyframes flutter-body { + 0%, + 100% { + transform: translateY(0) rotate(0deg); + } + 50% { + transform: translateY(-2px) rotate(-3deg); + } +} + +@keyframes float-1 { + 0% { + transform: translate(0, 0) rotate(0deg); + } + 20% { + transform: translate(12px, -18px) rotate(5deg); + } + 40% { + transform: translate(-8px, -30px) rotate(-3deg); + } + 60% { + transform: translate(-15px, -12px) rotate(2deg); + } + 80% { + transform: translate(5px, 8px) rotate(-4deg); + } + 100% { + transform: translate(0, 0) rotate(0deg); + } +} + +@keyframes float-2 { + 0% { + transform: translate(0, 0) rotate(0deg); + } + 15% { + transform: translate(-10px, -12px) rotate(-4deg); + } + 35% { + transform: translate(8px, -25px) rotate(3deg); + } + 55% { + transform: translate(15px, -8px) rotate(-2deg); + } + 75% { + transform: translate(-5px, 10px) rotate(5deg); + } + 100% { + transform: translate(0, 0) rotate(0deg); + } +} + +@keyframes float-3 { + 0% { + transform: translate(0, 0) rotate(0deg); + } + 25% { + transform: translate(18px, -10px) rotate(3deg); + } + 50% { + transform: translate(5px, -35px) rotate(-5deg); + } + 75% { + transform: translate(-12px, -15px) rotate(4deg); + } + 100% { + transform: translate(0, 0) rotate(0deg); + } +} diff --git a/packages/website/tsconfig.json b/packages/website/tsconfig.json new file mode 100644 index 000000000..532ad144d --- /dev/null +++ b/packages/website/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ES2022", + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "noEmit": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { + "~/*": ["./src/*"] + } + }, + "include": ["src/**/*", "vite.config.ts"] +} diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts new file mode 100644 index 000000000..176c7c543 --- /dev/null +++ b/packages/website/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import tsConfigPaths from 'vite-tsconfig-paths' +import { tanstackStart } from '@tanstack/react-start/plugin/vite' +import { cloudflare } from '@cloudflare/vite-plugin' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + server: { + port: 8082, + }, + plugins: [ + cloudflare({ viteEnvironment: { name: 'ssr' } }), + tsConfigPaths(), + tanstackStart(), + react(), + tailwindcss(), + ], +}) diff --git a/packages/website/wrangler.toml b/packages/website/wrangler.toml new file mode 100644 index 000000000..ee8aa4236 --- /dev/null +++ b/packages/website/wrangler.toml @@ -0,0 +1,13 @@ +name = "paseo-website" +account_id = "10ed39a1dbf316e30abd0c409bed40d6" +compatibility_date = "2024-12-01" +compatibility_flags = ["nodejs_compat"] +main = "@tanstack/react-start/server-entry" + +routes = [ + { pattern = "paseo.sh", custom_domain = true }, + { pattern = "www.paseo.sh", custom_domain = true } +] + +[observability] +enabled = true