diff --git a/.roo/rules-code/rules.md b/.roo/rules-code/rules.md index afc35743b..833a58e6f 100644 --- a/.roo/rules-code/rules.md +++ b/.roo/rules-code/rules.md @@ -3,69 +3,87 @@ ## Role You are **Ken Thompson**. You write minimal, correct code from precise objectives. -You never explain methods. -You never output anything except test-driven results. +You never explain *how* you solved something. +But you DO report **what changed**, **what passed**, and **what the system state is** — clearly and concisely. -You: -- Follow strict TDD (RED → GREEN → Refactor). -- Write the smallest code that works. -- Use short, readable names (no abbreviations). -- Keep every file single-purpose. -- Remove all debug traces. +You speak briefly, directly, and only in facts. + +## Team Micro-Dialogue (Optional) +Before producing your result, you may output a **tiny expert exchange**: +- Booch: architecture insight (max 1 line) +- Carmack: stability / correctness insight (max 1 line) +- Thompson: implementation stance (max 1 line) + +Maximum 3 lines. +No fluff. No reasoning. +Only insight. + +Example style: +- Booch: “Boundary consistent.” +- Carmack: “Behavior stable.” +- Thompson: “Applied minimal change.” ## Mission -Given an objective, you deliver **one cohesive implementation package**: +You deliver **one cohesive implementation package**: - one behavior -- one change set -- one reasoning flow -- test-driven and minimal +- one code change +- one test cycle (RED → GREEN → Refactor) +- nothing beyond the objective -You implement only what the objective requires — nothing else. +You implement only what is required. ## Output Rules You output **one** compact `attempt_completion` with: -- `actions` — ≤ 140 chars (RED → GREEN → Refactor summary) -- `tests` — ≤ 120 chars (relevant pass/fail summary) -- `files` — list of affected files (each ≤ 60 chars) -- `context` — ≤ 120 chars (area touched) -- `notes` — max 2 bullets, each ≤ 100 chars +- `actions` — ≤ 140 chars (what changed: RED→GREEN→REF) +- `tests` — ≤ 120 chars (summary of pass/fail) +- `files` — affected files (each ≤ 60 chars) +- `context` — ≤ 120 chars (where the change applies) +- `notes` — max 2 bullets (≤ 100 chars) with factual, non-method details -You must not: -- output logs -- output long text -- output commentary -- describe technique or reasoning -- generate architecture -- produce multi-purpose files +You ARE allowed to say: +- “added test for …” +- “implemented missing behavior …” +- “refactored selector logic …” +- “aligned domain model …” +- “removed unused paths …” -Only minimal, factual results. +You are NOT allowed to: +- explain how +- write narrative +- produce code explanations +- justify design +- include logs or verbose text ## Information Sweep You check only: - the objective -- related tests -- relevant files -- previous expert output +- tests that define the behavior +- files touched by that behavior +- results from previous experts Stop once you know: -1. what behavior to test -2. what behavior to implement -3. which files it touches +1. what behavior to encode in RED +2. what minimal change makes GREEN +3. which files to touch ## File Discipline - One function/class per file. -- Files must remain focused and compact. -- Split immediately if a file grows beyond a single purpose. -- Keep code small, clear, direct. +- Keep files compact. +- Split if a file grows beyond one purpose. +- Maintain minimal, direct code. ## Constraints -- No comments, scaffolding, or TODOs. -- No speculative design. -- No unnecessary abstractions. -- Never silence lint/type errors — fix at the source. -- Zero excess. Everything minimal. +- No comments, TODOs, scaffolding. +- No speculative abstractions. +- Fix lint/type errors at source. +- Zero excess. ## Completion -You emit one compact `attempt_completion` with RED/GREEN/refactor results. +You emit one compact `attempt_completion` containing: +- what changed +- what passed +- what files moved +- what context applied + Nothing else. \ No newline at end of file diff --git a/apps/website/.env.example b/apps/website/.env.example index 4b00037fc..c16bb5733 100644 --- a/apps/website/.env.example +++ b/apps/website/.env.example @@ -20,6 +20,12 @@ NEXT_PUBLIC_GRIDPILOT_MODE=pre-launch # Site URL (for metadata and OG tags) NEXT_PUBLIC_SITE_URL=https://gridpilot.com +# Discord Community +# Discord invite URL for the community CTA +# Get this from: Discord Server Settings -> Invites -> Create Invite +# Example: https://discord.gg/your-invite-code +NEXT_PUBLIC_DISCORD_URL=https://discord.gg/your-invite-code + # Example for post-launch mode: # GRIDPILOT_MODE=post-launch # NEXT_PUBLIC_GRIDPILOT_MODE=post-launch \ No newline at end of file diff --git a/apps/website/app/api/signup/route.ts b/apps/website/app/api/signup/route.ts deleted file mode 100644 index 7fd800eba..000000000 --- a/apps/website/app/api/signup/route.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { validateEmail, isDisposableEmail } from '@/lib/email-validation'; -import { checkRateLimit, getClientIp } from '@/lib/rate-limit'; - -const SIGNUP_LIST_KEY = 'signups:emails'; -const isDev = !process.env.KV_REST_API_URL; - -// In-memory fallback for development -const devSignups = new Map(); - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const { email } = body; - - if (!email || typeof email !== 'string') { - return NextResponse.json( - { error: "That email doesn't look right." }, - { status: 400 } - ); - } - - const validation = validateEmail(email); - if (!validation.success) { - return NextResponse.json( - { error: "That email doesn't look right." }, - { status: 400 } - ); - } - - const sanitizedEmail = validation.email!; - - if (isDisposableEmail(sanitizedEmail)) { - return NextResponse.json( - { error: "That email doesn't look right." }, - { status: 400 } - ); - } - - const clientIp = getClientIp(request); - const rateLimitResult = await checkRateLimit(clientIp); - - if (!rateLimitResult.allowed) { - const retrySeconds = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000); - return NextResponse.json( - { - error: 'Too fast. Try again in a minute.', - resetAt: rateLimitResult.resetAt, - retryAfter: retrySeconds, - }, - { - status: 429, - headers: { - 'Retry-After': retrySeconds.toString(), - }, - } - ); - } - - if (isDev) { - console.warn('[DEV MODE] Using in-memory signup storage - data will not persist'); - - if (devSignups.has(sanitizedEmail)) { - return NextResponse.json( - { error: "Already got you. I'll keep you posted." }, - { status: 409 } - ); - } - - const signupData = { - email: sanitizedEmail, - timestamp: new Date().toISOString(), - ip: clientIp, - }; - - devSignups.set(sanitizedEmail, signupData); - - return NextResponse.json( - { - success: true, - message: 'Thanks. That means a lot.', - }, - { - status: 200, - headers: { - 'X-RateLimit-Remaining': rateLimitResult.remaining.toString(), - 'X-RateLimit-Reset': rateLimitResult.resetAt.toString(), - }, - } - ); - } - - // Production: Use Vercel KV - const { kv } = await import('@vercel/kv'); - - const existingSignup = await kv.hget(SIGNUP_LIST_KEY, sanitizedEmail); - - if (existingSignup) { - return NextResponse.json( - { error: "Already got you. I'll keep you posted." }, - { status: 409 } - ); - } - - const signupData = { - email: sanitizedEmail, - timestamp: new Date().toISOString(), - ip: clientIp, - }; - - await kv.hset(SIGNUP_LIST_KEY, { - [sanitizedEmail]: JSON.stringify(signupData), - }); - - return NextResponse.json( - { - success: true, - message: 'Thanks. That means a lot.', - }, - { - status: 200, - headers: { - 'X-RateLimit-Remaining': rateLimitResult.remaining.toString(), - 'X-RateLimit-Reset': rateLimitResult.resetAt.toString(), - }, - } - ); - } catch (error) { - console.error('Signup error:', error); - - return NextResponse.json( - { error: 'Something broke. Try again?' }, - { status: 500 } - ); - } -} - -/** - * GET /api/signup - * Return 405 Method Not Allowed - */ -export async function GET() { - return NextResponse.json( - { error: 'Method not allowed' }, - { status: 405 } - ); -} \ No newline at end of file diff --git a/apps/website/app/globals.css b/apps/website/app/globals.css index 84dd72882..2de174740 100644 --- a/apps/website/app/globals.css +++ b/apps/website/app/globals.css @@ -13,10 +13,61 @@ --color-performance-green: #6FE37A; --color-warning-amber: #FFC556; --color-neon-aqua: #43C9E6; + --sat: env(safe-area-inset-top); + --sar: env(safe-area-inset-right); + --sab: env(safe-area-inset-bottom); + --sal: env(safe-area-inset-left); + } + + * { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + } + + html { + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; } body { @apply bg-deep-graphite text-white antialiased; + overscroll-behavior: none; + } + + button, a { + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + } + + .scroll-container { + -webkit-overflow-scrolling: touch; + scroll-behavior: smooth; + } + + /* Mobile typography optimization - lighter and more spacious */ + @media (max-width: 640px) { + h1 { + font-size: clamp(1.5rem, 6vw, 2rem); + font-weight: 600; + line-height: 1.2; + } + + h2 { + font-size: clamp(1.125rem, 4.5vw, 1.5rem); + font-weight: 600; + line-height: 1.3; + } + + h3 { + font-size: 1rem; + font-weight: 500; + } + + p { + font-size: 0.8125rem; /* 13px */ + line-height: 1.6; + } } } @@ -24,4 +75,159 @@ .animate-spring { transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); } + + /* Racing stripe patterns */ + .racing-stripes { + background: linear-gradient( + 45deg, + transparent 25%, + rgba(25, 140, 255, 0.03) 25%, + rgba(25, 140, 255, 0.03) 50%, + transparent 50%, + transparent 75%, + rgba(25, 140, 255, 0.03) 75% + ); + background-size: 60px 60px; + } + + /* Checkered flag pattern */ + .checkered-pattern { + background-image: + linear-gradient(45deg, rgba(255,255,255,0.02) 25%, transparent 25%), + linear-gradient(-45deg, rgba(255,255,255,0.02) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.02) 75%), + linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.02) 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0px; + } + + /* Speed lines animation */ + @keyframes speed-lines { + 0% { + transform: translateX(0) scaleX(0); + opacity: 0; + } + 50% { + opacity: 0.3; + } + 100% { + transform: translateX(100px) scaleX(1); + opacity: 0; + } + } + + .animate-speed-lines { + animation: speed-lines 1.5s ease-out infinite; + } + + /* Racing accent line */ + .racing-accent { + position: relative; + } + + .racing-accent::before { + content: ''; + position: absolute; + left: -16px; + top: 0; + bottom: 0; + width: 3px; + background: linear-gradient(to bottom, #FF0000, #198CFF); + border-radius: 2px; + } + + /* Carbon fiber texture */ + .carbon-fiber { + background-image: + linear-gradient(27deg, rgba(255,255,255,0.02) 5%, transparent 5%), + linear-gradient(207deg, rgba(255,255,255,0.02) 5%, transparent 5%), + linear-gradient(27deg, rgba(0,0,0,0.05) 5%, transparent 5%), + linear-gradient(207deg, rgba(0,0,0,0.05) 5%, transparent 5%); + background-size: 10px 10px; + } + + /* Racing red-white-blue animated gradient */ + @keyframes racing-gradient { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } + } + + .animate-racing-gradient { + background: linear-gradient( + 90deg, + #DC0000 0%, + #FFFFFF 25%, + #0066FF 50%, + #DC0000 75%, + #FFFFFF 100% + ); + background-size: 300% 100%; + animation: racing-gradient 12s linear infinite; + -webkit-background-clip: text; + background-clip: text; + } + + /* Static red-white-blue gradient (no animation) */ + .static-racing-gradient { + background: linear-gradient( + 90deg, + #DC0000 0%, + #FFFFFF 50%, + #2563eb 100% + ); + -webkit-background-clip: text; + background-clip: text; + } + + @media (prefers-reduced-motion: reduce) { + .animate-racing-gradient { + animation: none; + } + } + + /* Entrance animations */ + @keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + transform: translateY(0); + } + } + + .animate-fade-in-up { + animation: fade-in-up 0.6s ease-out forwards; + } + + .animate-fade-in { + animation: fade-in 0.4s ease-out forwards; + } + + @media (prefers-reduced-motion: reduce) { + .animate-fade-in-up, + .animate-fade-in { + animation: none; + opacity: 1; + transform: none; + } + } } \ No newline at end of file diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index b44f4c58a..068cabcf3 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -4,6 +4,18 @@ import './globals.css'; export const metadata: Metadata = { title: 'GridPilot - iRacing League Racing Platform', description: 'The dedicated home for serious iRacing leagues. Automatic results, standings, team racing, and professional race control.', + viewport: { + width: 'device-width', + initialScale: 1, + maximumScale: 1, + userScalable: false, + viewportFit: 'cover', + }, + themeColor: '#0a0a0a', + appleWebApp: { + capable: true, + statusBarStyle: 'black-translucent', + }, openGraph: { title: 'GridPilot - iRacing League Racing Platform', description: 'Structure over chaos. The professional platform for iRacing league racing.', @@ -22,8 +34,11 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - + + + + +
diff --git a/apps/website/app/page.tsx b/apps/website/app/page.tsx index 7fa33d3cb..41ebaf967 100644 --- a/apps/website/app/page.tsx +++ b/apps/website/app/page.tsx @@ -2,13 +2,14 @@ import { ModeGuard } from '@/components/shared/ModeGuard'; import Hero from '@/components/landing/Hero'; import AlternatingSection from '@/components/landing/AlternatingSection'; import FeatureGrid from '@/components/landing/FeatureGrid'; -import EmailCapture from '@/components/landing/EmailCapture'; +import DiscordCTA from '@/components/landing/DiscordCTA'; import FAQ from '@/components/landing/FAQ'; import Footer from '@/components/landing/Footer'; import CareerProgressionMockup from '@/components/mockups/CareerProgressionMockup'; import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup'; import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup'; import SimPlatformMockup from '@/components/mockups/SimPlatformMockup'; +import MockupStack from '@/components/ui/MockupStack'; export default function HomePage() { return ( @@ -19,25 +20,47 @@ export default function HomePage() { {/* Section 1: A Persistent Identity */}

Your races, your seasons, your progress — finally in one place.

-
    -
  • - - Lifetime stats and season history across all your leagues -
  • -
  • - - Track your performance, consistency, and team contributions -
  • -
  • - - Your own rating that reflects real league competition -
  • -
+
+
+
+
+
+ + + +
+ Lifetime stats and season history across all your leagues +
+
+
+
+
+
+ + + +
+ Track your performance, consistency, and team contributions +
+
+
+
+
+
+ + + +
+ Your own rating that reflects real league competition +
+
+

iRacing gives you physics. GridPilot gives you a career.

@@ -55,29 +78,50 @@ export default function HomePage() { backgroundImage="/images/ff1600.jpeg" description={ <> -

+

Every race you run stays with you.

-
    -
  • - - Your stats, your team, your story — all connected -
  • -
  • - - One race result updates your profile, team points, rating, and season history -
  • -
  • - - No more fragmented data across spreadsheets and forums -
  • -
-

+

+
+
+
+
+ + + +
+ Your stats, your team, your story — all connected +
+
+
+
+
+
+ + + +
+ One race result updates your profile, team points, rating, and season history +
+
+
+
+
+
+ + + +
+ No more fragmented data across spreadsheets and forums +
+
+
+

Your racing career, finally in one place.

} - mockup={} + mockup={} layout="text-right" /> @@ -86,28 +130,50 @@ export default function HomePage() { heading="Automatic Session Creation" description={ <> -

+

Setting up league races used to mean clicking through iRacing's wizard 20 times.

-
    -
  • - - Our companion app syncs with your league schedule -
  • -
  • - - When it's race time, it creates the iRacing session automatically -
  • -
  • - - No clicking through wizards. No manual setup -
  • -
  • - - Runs on your machine, totally transparent, completely safe -
  • -
-

+

+
+
+
+
+ 1 +
+ Our companion app syncs with your league schedule +
+
+
+
+
+
+ 2 +
+ When it's race time, it creates the iRacing session automatically +
+
+
+
+
+
+ 3 +
+ No clicking through wizards. No manual setup +
+
+
+
+
+
+ + + +
+ Runs on your machine, totally transparent, completely safe +
+
+
+

Automation instead of repetition.

@@ -122,16 +188,16 @@ export default function HomePage() { backgroundImage="/images/lmp3.jpeg" description={ <> -

+

Right now, we're focused on making iRacing league racing better.

-

+

But sims come and go. Your leagues, your teams, your rating — those stay.

-

+

GridPilot is built to outlast any single platform.

-

+

When the next sim arrives, your competitive identity moves with you.

@@ -140,7 +206,7 @@ export default function HomePage() { layout="text-right" /> - +