diff --git a/Dockerfile b/Dockerfile index 867bfd4..9fd9344 100644 --- a/Dockerfile +++ b/Dockerfile @@ -111,9 +111,11 @@ RUN chown node:node .next COPY --from=builder --chown=node:node /app/.next/standalone ./ COPY --from=builder --chown=node:node /app/.next/static ./.next/static -# If you want to persist the fetch cache generated during the build so that -# cached responses are available immediately on startup, uncomment this line: -# COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache +# Persist the build-time cache so cached fetch responses are available +# immediately on startup. Note this does NOT pre-warm the image optimizer: +# /_next/image results are produced on demand, so the first request for each +# (image, width) still pays the sharp encode on every task. +COPY --from=builder --chown=node:node /app/.next/cache ./.next/cache # Switch to non-root user for security best practices USER node diff --git a/app/(marketing)/how-to-mcp/HowToMcp.tsx b/app/(marketing)/how-to-mcp/HowToMcp.tsx new file mode 100644 index 0000000..60b247e --- /dev/null +++ b/app/(marketing)/how-to-mcp/HowToMcp.tsx @@ -0,0 +1,368 @@ +"use client"; + +import { useState } from "react"; +import { motion, useReducedMotion } from "framer-motion"; +import { AgentTerminal } from "@/components/landing/AgentTerminal"; +import { PillTabGroup } from "@/components/landing/PillTabGroup"; +import { CopyChip, CommandBlock } from "@/components/landing/CopyBlock"; +import Image from "next/image"; +import { DOT_PAPER_BG, GRAIN_240 } from "@/lib/landing/textures"; +import { + AUTH_NOTES, + CLIENT_GUIDES, + CLIENT_IDS, + INTRO, + MACHINE_MD, + PROMPTS, + SERVER_URL, + type AuthNotePart, + type ClientBlock, + type ClientId, + type RichSegment, +} from "./content"; + +const HAIRLINE = "rgba(58,74,38,0.14)"; + +function Rise({ + children, + delay = 0, + className, +}: { + children: React.ReactNode; + delay?: number; + className?: string; +}) { + const reduced = useReducedMotion(); + return ( + + {children} + + ); +} + +function SectionTitle({ title }: { title: string }) { + return ( +
+

+ {title} +

+
+ ); +} + +function Step({ n, children }: { n: number; children: React.ReactNode }) { + return ( +
  • + + {String(n).padStart(2, "0")} + + {children} +
  • + ); +} + +type Mode = "human" | "machine"; + +function RichText({ segments }: { segments: RichSegment[] }) { + return ( + <> + {segments.map((segment, i) => + typeof segment === "string" ? ( + {segment} + ) : ( + + {segment.code} + + ), + )} + + ); +} + +function ClientSetupBlocks({ blocks }: { blocks: ClientBlock[] }) { + return ( +
    + {blocks.map((block, i) => { + switch (block.type) { + case "p": + return ( +

    + {block.text} +

    + ); + case "p-rich": + return ( +

    + +

    + ); + case "cmd": + return ( + + {block.template.replaceAll("${SERVER_URL}", SERVER_URL)} + + ); + case "steps": + return ( +
    + {block.intro && ( +

    {block.intro}

    + )} +
      + {block.items.map((item, n) => ( + + {item} + + ))} +
    +
    + ); + } + })} +
    + ); +} + +function AuthNoteBody({ parts }: { parts: AuthNotePart[] }) { + return ( + <> + {parts.map((part, i) => + typeof part === "string" ? ( + {part} + ) : ( + + {part.text} + + ), + )} + + ); +} + +function ClientInstructions({ client }: { client: ClientId }) { + return ; +} + +function ClientTabs() { + const [client, setClient] = useState("claude"); + return ( +
    + + ({ + value: id, + label: CLIENT_GUIDES[id].label, + }))} + /> + + + +
    + ); +} + +function HumanMode() { + return ( +
    +
    + +

    + Connect an AI agent to MHacks +

    +

    + {INTRO} +

    +
    + + + + + + +

    + Server URL: use it exactly as written, in any client below +

    + {SERVER_URL} +
    +
    + + + +
    + +
    + {PROMPTS.map((p) => ( + +
    +

    + “{p.quote}” +

    +

    + {p.detail} +

    +
    +
    + ))} +
    +
    + +
    + +
      + {AUTH_NOTES.map((note) => ( + +
    • + + ✳ + +

      + + {note.lead} + {" "} + +

      +
    • +
      + ))} +
    +
    +
    + ); +} + +function MachineMode() { + return ( +
    +

    + Copy this markdown and paste it into your agent (Claude Code, Codex, or + any MCP-capable client). It has everything your agent needs to connect + and apply; from there, just follow your agent’s instructions. +

    +
    + + how-to-mcp.md + + +
    +
    +        {MACHINE_MD}
    +      
    +
    + ); +} + +/** + * Paxel-style document page: the garden photo runs full-bleed behind a + * centered paper sheet. `human` renders the typeset guide with copyable + * commands; `machine` renders the same content as one plain markdown file. + */ +export function HowToMcp() { + const [mode, setMode] = useState("human"); + + return ( +
    + {/* Backdrop: aerial terraces behind the same frosted veil as the hero + (blur values match HeroReveal's, painted on an oversized plate so + the blur never samples past the edges). Fixed so the paper scrolls + over it — z-0 (not negative) so it paints above the shell's cream + parchment background. */} +
    +
    + +
    +
    +
    +
    + +
    + {/* The paper */} +
    + {/* Same dotted tooth as the FAQ sheet */} +
    + +
    + {/* Mode toggle — sits at the top of the paper and scrolls away + with it */} +
    + +
    + + {mode === "human" ? : } +
    +
    +
    +
    + ); +} diff --git a/app/(marketing)/how-to-mcp/content.ts b/app/(marketing)/how-to-mcp/content.ts new file mode 100644 index 0000000..98394ab --- /dev/null +++ b/app/(marketing)/how-to-mcp/content.ts @@ -0,0 +1,266 @@ +/** + * Single source for the How to MCP page copy. Human-mode JSX in HowToMcp.tsx + * renders from these exports; MACHINE_MD is generated from the same data. + */ + +export const SERVER_URL = "https://www.mhacks.org/mcp"; + +export const INTRO = + "MHacks has an MCP server that lets you apply through Claude, Codex, or any other MCP-capable agent instead of filling out the web form by hand. Your agent can read the application schema, save a draft, ask you questions, upload your resume, and submit, all tied to your real, logged-in MHacks account."; + +export const PROMPTS = [ + { + quote: "Who am I connected as?", + detail: + "Confirms the MHacks account your agent is authenticated as, straight from your login, before you do anything else.", + }, + { + quote: "Check my MHacks application status", + detail: + "See whether you've already applied, and if so, its current status.", + }, + { + quote: "Help me fill out my MHacks application", + detail: + "Your agent can walk you through each field, save your progress as a draft, and come back to it later.", + }, + { + quote: "Submit my MHacks application", + detail: "Once everything's filled in, your agent submits it for you.", + }, +] as const; + +export type AuthNotePart = string | { text: string; href: string }; + +export interface AuthNote { + lead: string; + parts: AuthNotePart[]; +} + +export const AUTH_NOTES: AuthNote[] = [ + { + lead: "Your identity comes from your login, not from anything you tell the agent.", + parts: [ + "Whatever email you authenticate with is the account the application is tied to. An agent can't submit on someone else's behalf.", + ], + }, + { + lead: "Submission is final.", + parts: [ + "There's currently no MCP tool to edit or withdraw a submitted application, so review it with your agent before confirming.", + ], + }, + { + lead: "You'll be asked to explicitly agree", + parts: [ + "to the MLH Code of Conduct, Privacy Policy, and communications terms before submission. Your agent should read these to you and ask for a clear yes/no, not assume.", + ], + }, + { + lead: "Resume upload usually won't happen through the agent.", + parts: [ + "Uploading requires the agent to make its own HTTP request with the file's raw bytes. Attaching a PDF to the chat only lets the agent read it. Coding-agent clients with their own network access (Claude Code, Codex, Cursor) can do this; standard Claude.ai / Claude Desktop chat can't, so expect your agent to tell you to upload your resume yourself at ", + { text: "mhacks.org/apply", href: "https://www.mhacks.org/apply" }, + ", then it'll confirm it landed before continuing.", + ], + }, + { + lead: "You can revoke access at any time.", + parts: [ + "See and revoke any agent's access at ", + { + text: "mhacks.org/account/connections", + href: "https://www.mhacks.org/account/connections", + }, + ".", + ], + }, +]; + +export type ClientId = "claude" | "claude-code" | "codex" | "other"; + +export type RichSegment = string | { code: string }; + +export type ClientBlock = + | { type: "p"; text: string } + | { type: "p-rich"; segments: RichSegment[] } + | { type: "steps"; intro?: string; items: string[] } + | { type: "cmd"; template: string }; + +export interface ClientGuide { + label: string; + mdHeading: string; + blocks: ClientBlock[]; +} + +export const CLIENT_GUIDES: Record = { + claude: { + label: "Claude.ai", + mdHeading: "Claude.ai / Claude Desktop", + blocks: [ + { + type: "p", + text: "Works in Claude.ai on the web and in Claude Desktop.", + }, + { + type: "steps", + items: [ + "Go to Settings → Connectors → Add custom connector.", + "Paste the server URL above.", + "Claude will open a login page — sign in with your email (MHacks uses a one-time code sent to your inbox, no password).", + "Approve the connection when prompted. You'll see what Claude is requesting access to before you approve.", + ], + }, + ], + }, + "claude-code": { + label: "Claude Code", + mdHeading: "Claude Code", + blocks: [ + { + type: "cmd", + template: "claude mcp add --transport http mhacks ${SERVER_URL}", + }, + { + type: "p-rich", + segments: [ + "Then inside a session, run ", + { code: "/mcp" }, + ", select ", + { code: "mhacks" }, + ", and authenticate — same email login + approval as Claude.ai.", + ], + }, + ], + }, + codex: { + label: "Codex CLI", + mdHeading: "Codex CLI", + blocks: [ + { + type: "p", + text: "Add the server to ~/.codex/config.toml:", + }, + { + type: "cmd", + template: '[mcp_servers.mhacks]\nurl = "${SERVER_URL}"', + }, + { type: "p", text: "Then log in and approve access with:" }, + { type: "cmd", template: "codex mcp login mhacks" }, + { + type: "p", + text: "Codex will open a browser window for the same email one-time-code flow.", + }, + ], + }, + other: { + label: "Other", + mdHeading: "Other clients", + blocks: [ + { + type: "p", + text: "Any client that supports the MCP Streamable HTTP transport and OAuth 2.1 can connect using the same server URL. You'll go through the same email-login-and-approve flow regardless of client.", + }, + ], + }, +}; + +export const CLIENT_IDS = Object.keys(CLIENT_GUIDES) as ClientId[]; + +function expandTemplate(template: string): string { + return template.replaceAll("${SERVER_URL}", SERVER_URL); +} + +function richSegmentsPlain(segments: RichSegment[]): string { + return segments + .map((segment) => (typeof segment === "string" ? segment : segment.code)) + .join(""); +} + +function clientBlockMarkdown(block: ClientBlock): string { + switch (block.type) { + case "p": + return block.text; + case "p-rich": + return richSegmentsPlain(block.segments); + case "cmd": + return `\n ${expandTemplate(block.template)}\n`; + case "steps": + return block.items.map((item, i) => `${i + 1}. ${item}`).join("\n"); + } +} + +function clientGuideMarkdown(guide: ClientGuide): string { + const body = guide.blocks + .map((block) => { + if (block.type === "steps") { + return clientBlockMarkdown(block); + } + if (block.type === "cmd") { + return expandTemplate(block.template); + } + return clientBlockMarkdown(block); + }) + .join("\n\n"); + + if (guide.blocks.some((block) => block.type === "steps")) { + return body; + } + return body; +} + +function clientSetupMarkdown(): string { + return CLIENT_IDS.map((id) => { + const guide = CLIENT_GUIDES[id]; + const body = clientGuideMarkdown(guide); + if (guide.blocks.some((block) => block.type === "steps")) { + return `### ${guide.mdHeading}\n\n${body}`; + } + if (guide.blocks.some((block) => block.type === "cmd")) { + const parts: string[] = [`### ${guide.mdHeading}`, ""]; + for (const block of guide.blocks) { + if (block.type === "p" || block.type === "p-rich") { + parts.push(clientBlockMarkdown(block), ""); + } else if (block.type === "cmd") { + parts.push(` ${expandTemplate(block.template)}`, ""); + } + } + return parts.join("\n").trimEnd(); + } + return `### ${guide.mdHeading}\n\n${body}`; + }).join("\n\n"); +} + +function authNotePlainText(note: AuthNote): string { + return note.parts + .map((part) => (typeof part === "string" ? part : part.href)) + .join(""); +} + +export const MACHINE_MD = `# Connect an AI agent to MHacks + +> This file contains setup instructions only. The only URL the agent should +> connect to is ${SERVER_URL}. Never submit the application without the +> user's explicit confirmation. + +${INTRO} + +## Server URL + + ${SERVER_URL} + +Use it exactly as written, in any client below. +Transport: MCP Streamable HTTP. Auth: OAuth 2.1 (email one-time-code login). + +## Point your client at the server + +${clientSetupMarkdown()} + +## Just talk to your agent + +${PROMPTS.map((p) => `- "${p.quote}" — ${p.detail}`).join("\n")} + +## How auth works + +${AUTH_NOTES.map((note) => `- ${note.lead} ${authNotePlainText(note)}`).join("\n")} +`; diff --git a/app/(marketing)/how-to-mcp/page.tsx b/app/(marketing)/how-to-mcp/page.tsx new file mode 100644 index 0000000..5f792f7 --- /dev/null +++ b/app/(marketing)/how-to-mcp/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { HowToMcp } from "./HowToMcp"; +import { Footer } from "@/components/landing/sections/Footer"; + +export const metadata: Metadata = { + title: "How to MCP · MHacks 2026", + description: + "Connect Claude, Codex, or any MCP-capable agent to the MHacks MCP server and apply straight from your terminal.", +}; + +export default function HowToMcpPage() { + return ( +
    + +
    +
    + ); +} diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..7f3e7a1 --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,9 @@ +import { MarketingShell } from "@/components/landing/marketing-shell"; + +export default function MarketingLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx new file mode 100644 index 0000000..feb5c4a --- /dev/null +++ b/app/(marketing)/page.tsx @@ -0,0 +1,23 @@ +import { About } from "@/components/landing/sections/About"; +import { Agent } from "@/components/landing/sections/Agent"; +import { Faq } from "@/components/landing/sections/Faq"; +import { Footer } from "@/components/landing/sections/Footer"; +import { Hero } from "@/components/landing/sections/Hero"; +import { Schedule } from "@/components/landing/sections/Schedule"; +import { Sponsors } from "@/components/landing/sections/Sponsors"; +import { StackedPages } from "@/components/landing/StackedPages"; + +export default function Home() { + return ( +
    + + + + + + +
    + +
    + ); +} diff --git a/app/globals.css b/app/globals.css index 64ba02b..1cea0b4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -11,18 +11,67 @@ --font-mono: var(--font-geist-mono); --font-heading: var(--font-instrument-serif); --font-red-hat: var(--font-red-hat-display); + --font-serif: var(--font-instrument-serif); + --font-display: var(--font-red-hat-display); + --font-body: var(--font-red-hat-display); - /* Brand palette */ + /* Landing moss scale */ + --color-moss-900: #1d2412; + --color-moss-800: #2c3a1c; + --color-moss-700: #3a4a26; + --color-moss-500: #5d6b3a; + --color-moss-300: #bdc59a; + --color-parchment: #f5f1de; + --color-cream-soft: #f5f1de; + --color-bloom: #e07a9a; + --color-sun: #e8d35a; + --color-leaf: #c9e07a; + --color-brand-blue: #3a5ad4; + --color-surface: #ffffff; + --color-bg: #f5f1de; + --color-brand: #3a4a26; + --color-brand-on: #efe9d4; + --color-border-strong: rgba(29, 36, 18, 0.28); + + --radius-xs: 4px; + --radius-pill: 999px; + + --shadow-e-1: 0 1px 2px rgba(20, 30, 10, 0.06); + --shadow-e-2: 0 4px 10px rgba(20, 30, 10, 0.08); + --shadow-e-3: 0 8px 24px rgba(20, 30, 10, 0.1); + --shadow-e-4: 0 16px 40px rgba(20, 30, 10, 0.14); + --shadow-e-glass: 0 8px 28px rgba(20, 30, 10, 0.18); + + --text-display-1: clamp(80px, 15vw, 220px); + --text-display-1--line-height: 0.92; + --text-display-1--letter-spacing: -0.02em; + --text-display-2: clamp(56px, 8vw, 112px); + --text-display-2--line-height: 0.98; + --text-display-2--letter-spacing: -0.02em; + --text-display-3: clamp(40px, 5vw, 72px); + --text-display-3--line-height: 1.02; + --text-display-3--letter-spacing: -0.015em; + --text-display-4: clamp(28px, 3vw, 40px); + --text-display-4--line-height: 1.1; + --text-display-4--letter-spacing: -0.01em; + + --animate-drift: drift 6s ease-in-out infinite; + --animate-pulse-soft: pulse_soft 3.4s ease-in-out infinite; + + --ease-soft: cubic-bezier(0.2, 0.7, 0.2, 1); + --ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1); + --color-night: #0b0d08; + --color-fog: #f0efe6; + --color-paper: #f4f2e8; + --color-haze: #e8e5d6; + + /* Brand palette (legacy + landing) */ --color-moss: #3a4a26; --color-olive: #445721; --color-fern: #5d6b3a; --color-sage: #bec59b; --color-cream: #efe9d4; - --color-ink: #1f2a16; - --color-night: #0b0d08; - --color-fog: #f0efe6; - --color-paper: #f4f2e8; - --color-haze: #e8e5d6; + --color-ink: #1d2412; --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); @@ -224,3 +273,266 @@ @apply border border-white/30 bg-paper/88 shadow-[0_24px_64px] shadow-black/40 inset-shadow-[0_1px_0] inset-shadow-white/90 backdrop-blur-xl; } } + +/* ---------- Landing / marketing site (Digital Garden) ---------- */ +.marketing-site { + --primary-blue: #3a5ad4; + --landing-secondary: #7a93c9; + --secondary-alt: #a8c08a; + --accent-green: #c9e07a; + --accent-yellow: #e8d35a; + --accent-pink: #e07a9a; + --moss-900: #1d2412; + --moss-800: #2c3a1c; + --moss-700: #3a4a26; + --moss-500: #5d6b3a; + --moss-300: #bdc59a; + --parchment: #f5f1de; + --landing-ink: var(--moss-900); + --landing-bg: var(--parchment); + --landing-surface: #ffffff; + --landing-muted: #5d6b3a; + --landing-brand: var(--moss-700); + --landing-brand-on: var(--cream); + --border: rgba(29, 36, 18, 0.14); + --landing-border: rgba(29, 36, 18, 0.14); + --landing-border-strong: rgba(29, 36, 18, 0.28); + --pill-glass: rgba(245, 241, 222, 0.78); + --glass-border: rgba(255, 255, 255, 0.4); + --ease-soft: cubic-bezier(0.2, 0.7, 0.2, 1); + --ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1); + + background: var(--landing-bg); + color: var(--landing-ink); + font-family: var(--font-red-hat-display), system-ui, sans-serif; + /* clip — not hidden — so sticky sheets stick to the viewport, not this box */ + overflow-x: clip; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.marketing-site a:not([data-slot="button"]) { + color: inherit; + text-decoration: none; +} + +.marketing-site button { + font-family: inherit; +} + +.marketing-site .font-serif-it { + font-family: var(--font-instrument-serif), serif; + font-style: italic; + letter-spacing: -0.01em; +} + +.marketing-site .font-mono { + font-family: var(--font-red-hat-mono), ui-monospace, monospace; +} + +.marketing-site .reveal-line { + overflow: hidden; + display: block; + padding: 0 0.14em 0.08em; + margin: 0 -0.14em -0.08em; +} + +.marketing-site .type-caret { + animation: caret-blink 0.85s steps(1) infinite; +} + +@keyframes caret-blink { + 50% { + opacity: 0; + } +} + +@keyframes drift { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-8px); + } +} + +@keyframes pulse_soft { + 0%, + 100% { + opacity: 0.55; + } + 50% { + opacity: 0.85; + } +} + +html.lenis, +html.lenis body { + height: auto; +} + +.lenis.lenis-smooth { + scroll-behavior: auto !important; +} + +.lenis.lenis-smooth [data-lenis-prevent] { + overscroll-behavior: contain; +} + +.lenis.lenis-stopped { + overflow: hidden; +} + +button, +[role="button"], +[data-cursor="hover"] { + cursor: pointer; +} + +html.has-custom-cursor [data-cursor-zone], +html.has-custom-cursor [data-cursor-zone] * { + cursor: none !important; +} + +html.has-custom-cursor [data-cursor-zone] a, +html.has-custom-cursor [data-cursor-zone] a *, +html.has-custom-cursor [data-cursor-zone] button, +html.has-custom-cursor [data-cursor-zone] button *, +html.has-custom-cursor [data-cursor-zone] [data-cursor="hover"], +html.has-custom-cursor [data-cursor-zone] [data-cursor="hover"] *, +html.has-custom-cursor [data-cursor-zone] [role="button"] { + cursor: pointer !important; +} + +html.has-custom-cursor [data-cursor-zone] input, +html.has-custom-cursor [data-cursor-zone] textarea { + cursor: text !important; +} + +html.has-custom-cursor [data-cursor-zone] select { + cursor: default !important; +} + +@media (hover: none), (pointer: coarse) { + html.has-custom-cursor [data-cursor-zone], + html.has-custom-cursor [data-cursor-zone] * { + cursor: auto !important; + } +} + +.liquid-glass { + position: relative; + isolation: isolate; + border: none; + background: linear-gradient( + 120deg, + rgba(255, 255, 255, 0.14), + rgba(255, 255, 255, 0.04) 40%, + rgba(255, 255, 255, 0.1) + ); + -webkit-backdrop-filter: blur(6px) saturate(160%) brightness(1.06); + backdrop-filter: blur(6px) saturate(160%) brightness(1.06); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.65), + inset 0 -1px 1px rgba(255, 255, 255, 0.22), + inset 2px 4px 9px -4px rgba(255, 255, 255, 0.7), + inset -3px -5px 10px -5px rgba(29, 36, 18, 0.28); + transition: + box-shadow 300ms var(--ease-soft), + background 300ms var(--ease-soft); +} + +.liquid-glass.lg-on-light { + background: linear-gradient( + 120deg, + rgba(29, 36, 18, 0.08), + rgba(29, 36, 18, 0.02) 40%, + rgba(29, 36, 18, 0.06) + ); + box-shadow: + inset 0 1px 1px rgba(255, 255, 255, 0.8), + inset 0 -1px 1px rgba(29, 36, 18, 0.16), + inset 2px 4px 9px -4px rgba(255, 255, 255, 0.9), + inset -3px -5px 10px -5px rgba(29, 36, 18, 0.22), + 0 0 0 1px rgba(29, 36, 18, 0.14); +} + +html.lg-refract .liquid-glass { + -webkit-backdrop-filter: url(#liquid-glass-distortion) blur(1.5px) + saturate(165%) brightness(1.06); + backdrop-filter: url(#liquid-glass-distortion) blur(1.5px) saturate(165%) + brightness(1.06); +} + +.liquid-glass::before { + content: ""; + position: absolute; + inset: 1px; + border-radius: inherit; + pointer-events: none; + background: linear-gradient( + 173deg, + rgba(255, 255, 255, 0.5) 0%, + rgba(255, 255, 255, 0.12) 22%, + transparent 42%, + transparent 78%, + rgba(255, 255, 255, 0.14) 100% + ); + opacity: 0.55; + mix-blend-mode: screen; +} + +.liquid-glass::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + pointer-events: none; + background: + radial-gradient( + 55% 130% at -4% 50%, + rgba(255, 255, 255, 0.38) 0%, + transparent 55% + ), + radial-gradient( + 55% 130% at 104% 50%, + rgba(255, 255, 255, 0.28) 0%, + transparent 55% + ); + opacity: 0.8; +} + +.liquid-glass.lg-static { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +.liquid-glass.lg-static::before { + mix-blend-mode: normal; + opacity: 0.35; +} + +.liquid-glass:hover { + background: linear-gradient( + 120deg, + rgba(255, 255, 255, 0.2), + rgba(255, 255, 255, 0.08) 40%, + rgba(255, 255, 255, 0.16) + ); + box-shadow: + inset 0 1px 1.5px rgba(255, 255, 255, 0.8), + inset 0 -1px 1px rgba(255, 255, 255, 0.28), + inset 2px 4px 10px -4px rgba(255, 255, 255, 0.8), + inset -3px -5px 10px -5px rgba(29, 36, 18, 0.24); +} + +.grain::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: 1000; + opacity: 0.04; + background-image: url("data:image/svg+xml;utf8,"); +} diff --git a/app/how-to-mcp/page.tsx b/app/how-to-mcp/page.tsx deleted file mode 100644 index 3ec82b8..0000000 --- a/app/how-to-mcp/page.tsx +++ /dev/null @@ -1,638 +0,0 @@ -"use client"; - -// Public docs page: how to connect an AI agent (Claude, Codex, or any -// custom MCP client) to the MHacks MCP server. Content is adapted from -// mcp-docs/INSTRUCTIONS.md (kept as the repo-internal reference) with an -// added Codex section and a developer-facing auth explanation. Linked -// from the navbar as "MCP". -// -// Visual language mirrors the landing page: moss-on-paper, Red Hat -// throughout (headings included), Geist Mono for everything the machine -// says, and dotted leaders from the Timeline section. A photo fades in -// down the side gutters (see GutterPhoto), leaving the center column -// clear for content. The one dark element is the hero terminal — a -// staged agent session, since the page's subject is literally an agent -// talking to this server. - -import { useEffect, useState } from "react"; -import { motion, useReducedMotion } from "framer-motion"; -import NavBar from "@/components/navbar"; -import SiteFooter from "@/components/site-footer"; - -const MOSS = "#3A4A26"; -const MOSS_SOFT = "rgba(58,74,38,0.8)"; -const MOSS_FAINT = "rgba(58,74,38,0.45)"; -const HAIRLINE = "rgba(58,74,38,0.14)"; -const LEADER = "rgba(58,74,38,0.25)"; -const CREAM = "#efe9d4"; -const PINE = "#1c2513"; // terminal panel — the page's single dark surface -const PARCHMENT = "#ebe4ce"; // terminal foreground, same as hero title -const SAGE = "#bec59b"; - -const SERVER_URL = "https://www.mhacks.org/mcp"; -const EASE = [0.25, 0.1, 0.25, 1] as const; - -/* ── shared building blocks ─────────────────────────────────────────── */ - -function Reveal({ - children, - delay = 0, - className, - onLoad = false, -}: { - children: React.ReactNode; - delay?: number; - className?: string; - /** Hero pieces animate on mount — the server URL must never wait on a - scroll trigger. Everything below the fold reveals as it scrolls in. */ - onLoad?: boolean; -}) { - const reduced = useReducedMotion(); - return ( - - {children} - - ); -} - -function SectionHeading({ title }: { title: string }) { - return ( -
    -

    - {title} -

    -
    - ); -} - -function CopyButton({ - text, - emphasized = false, -}: { - text: string; - emphasized?: boolean; -}) { - const [copied, setCopied] = useState(false); - useEffect(() => { - if (!copied) return; - const id = setTimeout(() => setCopied(false), 1800); - return () => clearTimeout(id); - }, [copied]); - const style = copied - ? emphasized - ? { backgroundColor: CREAM, borderColor: CREAM, color: MOSS } - : { backgroundColor: MOSS, borderColor: MOSS, color: CREAM } - : emphasized - ? { borderColor: "rgba(239,233,212,0.5)", color: CREAM } - : { borderColor: LEADER, color: MOSS_SOFT }; - return ( - - ); -} - -function CodeBlock({ - children, - emphasized = false, -}: { - children: string; - /** The one call site the page most wants a visitor's eye to land on — - a solid moss fill instead of the usual cream, so it reads as the - main focal point rather than blending into the section. */ - emphasized?: boolean; -}) { - return ( -
    - - {children} - - -
    - ); -} - -/* ── hero terminal — the page's signature ───────────────────────────── */ - -const TRANSCRIPT: { - kind: "user" | "tool" | "agent" | "prompt"; - text: string; -}[] = [ - { kind: "user", text: "help me apply to MHacks" }, - { kind: "tool", text: "mhacks · get_application_schema" }, - { kind: "tool", text: "mhacks · save_application_draft" }, - { - kind: "agent", - text: "Draft saved. I still need your school and t-shirt size — then we can review the MLH terms together before submitting.", - }, - { kind: "prompt", text: "" }, -]; - -function TranscriptLine({ line }: { line: (typeof TRANSCRIPT)[number] }) { - if (line.kind === "user" || line.kind === "prompt") { - return ( -

    - - - {line.text} - {line.kind === "prompt" && ( - - )} - -

    - ); - } - if (line.kind === "tool") { - return ( -

    - - - {line.text} … ok - -

    - ); - } - return ( -

    - {line.text} -

    - ); -} - -function HeroTerminal() { - const reduced = useReducedMotion(); - return ( -
    -
    - {[0, 1, 2].map((i) => ( - - ))} - - agent — {SERVER_URL.replace("https://", "")} - -
    -
    - {TRANSCRIPT.map((line, i) => ( - - - - ))} -
    -
    - ); -} - -/* ── connect: one client at a time ──────────────────────────────────── */ - -const CLIENTS = [ - { id: "claude", label: "Claude.ai" }, - { id: "claude-code", label: "Claude Code" }, - { id: "codex", label: "Codex CLI" }, - { id: "other", label: "Other" }, -] as const; - -type ClientId = (typeof CLIENTS)[number]["id"]; - -function Step({ n, children }: { n: number; children: React.ReactNode }) { - return ( -
  • - - {String(n).padStart(2, "0")} - - {children} -
  • - ); -} - -function ClientPanel({ client }: { client: ClientId }) { - if (client === "claude") { - return ( -
    -

    - Works in Claude.ai on the web and in Claude Desktop. -

    -
      - Go to Settings → Connectors → Add custom connector. - Paste the server URL above. - - Claude will open a login page — sign in with your email (MHacks uses - a one-time code sent to your inbox, no password). - - - Approve the connection when prompted. You'll see what Claude is - requesting access to before you approve. - -
    -
    - ); - } - if (client === "claude-code") { - return ( -
    - {`claude mcp add --transport http mhacks ${SERVER_URL}`} -

    - Then inside a session, run{" "} - /mcp, select{" "} - mhacks, and - authenticate — same email login + approval as Claude.ai. -

    -
    - ); - } - if (client === "codex") { - return ( -
    - {/* - Best-effort: based on Codex CLI's config.toml MCP server - format as of this assistant's knowledge cutoff (Jan 2026). - Codex's remote-MCP / OAuth support may have changed since — - verify this against current Codex docs before treating it as - authoritative, and update if the config shape or command has - moved. - */} -

    - Add the server to{" "} - ~/.codex/config.toml: -

    - {`[mcp_servers.mhacks]\nurl = "${SERVER_URL}"`} -

    Then log in and approve access with:

    - {`codex mcp login mhacks`} -

    - Codex will open a browser window for the same email one-time-code - flow. -

    -
    - ); - } - return ( -

    - Any client that supports the MCP Streamable HTTP transport and OAuth 2.1 - can connect using the same server URL. You'll go through the same - email-login-and-approve flow regardless of client. -

    - ); -} - -function ConnectSection() { - const [client, setClient] = useState("claude"); - const reduced = useReducedMotion(); - return ( -
    - -
    - {CLIENTS.map((c) => { - const active = c.id === client; - return ( - - ); - })} -
    - - - -
    - ); -} - -/* ── what you can do: the prompts are the content ───────────────────── */ - -const PROMPTS = [ - { - quote: "Who am I connected as?", - detail: - "Confirms the MHacks account your agent is authenticated as, straight from your login, before you do anything else.", - }, - { - quote: "Check my MHacks application status", - detail: - "See whether you've already applied, and if so, its current status.", - }, - { - quote: "Help me fill out my MHacks application", - detail: - "Your agent can walk you through each field, save your progress as a draft, and come back to it later.", - }, - { - quote: "Submit my MHacks application", - detail: "Once everything's filled in, your agent submits it for you.", - }, -]; - -function PromptsSection() { - return ( -
    - -
    - {PROMPTS.map((p) => ( - -
    -

    - “{p.quote}” -

    -

    - {p.detail} -

    -
    -
    - ))} -
    -
    - ); -} - -/* ── how auth works ─────────────────────────────────────────────────── */ - -const AUTH_FACTS: { lead: string; body: React.ReactNode }[] = [ - { - lead: "Your identity comes from your login, not from anything you tell the agent.", - body: "Whatever email you authenticate with is the account the application is tied to — an agent can't submit on someone else's behalf.", - }, - { - lead: "Submission is final.", - body: "There's currently no MCP tool to edit or withdraw a submitted application, so review it with your agent before confirming.", - }, - { - lead: "You'll be asked to explicitly agree", - body: "to the MLH Code of Conduct, Privacy Policy, and communications terms before submission — your agent should read these to you and ask for a clear yes/no, not assume.", - }, - { - lead: "Resume upload usually won't happen through the agent.", - body: ( - <> - Uploading requires the agent to make its own HTTP request with the - file's raw bytes — attaching a PDF to the chat only lets the agent - read it. Coding-agent clients with their own network access (Claude - Code, Codex, Cursor) can do this; standard Claude.ai / Claude Desktop - chat can't, so expect your agent to tell you to upload your resume - yourself at{" "} - - mhacks.org/apply - - , then it'll confirm it landed before continuing. - - ), - }, - { - lead: "You can revoke access at any time.", - body: ( - <> - See and revoke any agent's access at{" "} - - mhacks.org/account/connections - - . - - ), - }, -]; - -function AuthSection() { - return ( -
    - -
      - {AUTH_FACTS.map((f, i) => ( - -
    • - - ✳ - -

      - - {f.lead} - {" "} - {f.body} -

      -
    • -
      - ))} -
    -
    - ); -} - -/* ── gutter photo ────────────────────────────────────────────────────── */ - -// The clear band must never run narrower than the content column, or text -// spills past it into the photo. The content column is `max-w-3xl` (768px) -// with `sm:px-6` (24px/side = 48px total) padding — the gutter photo only -// ever renders at sm and up, so those are the only numbers that matter -// here. MIN_GAP is extra breathing room on top of that: pure cream between -// the text and the photo, never the two touching edge-to-edge. Below the -// viewport where that gap can be honored, the photo clamps to 0 and just -// isn't shown — "disappear" is the fallback, not the padding. Fixed -// position (not scroll-linked) since a static photo doesn't need the -// flowers' scroll-driven redraw. -const MIN_GAP = 32; -const GUTTER_WIDTH = `max(0px, calc((100vw - min(768px, calc(100vw - 48px))) / 2 - ${MIN_GAP}px))`; - -function GutterPhoto() { - return ( -
    -
    -
    -
    - ); -} - -/* ── page ───────────────────────────────────────────────────────────── */ - -export default function HowToMcpPage() { - return ( -
    - - - -
    - {/* ── Hero ── */} -
    - {/* Soft bloom behind the title — a quiet nod to the landing hero. */} -
    - -

    - Connect an AI agent to MHacks -

    -

    - MHacks has an MCP server that lets you apply through Claude, - Codex, or any other MCP-capable agent instead of filling out the - web form by hand. Your agent can read the application schema, save - a draft, ask you questions, upload your resume, and submit — all - tied to your real, logged-in MHacks account. -

    -
    - - - - - - -

    - Server URL — use it exactly as written, in any client below -

    - {SERVER_URL} -
    -
    - - - - -
    - -
    - ); -} diff --git a/app/layout.tsx b/app/layout.tsx index 3f626a5..b0ef23f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,9 +1,11 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono, + Instrument_Sans, Instrument_Serif, Red_Hat_Display, + Red_Hat_Mono, } from "next/font/google"; import "./globals.css"; import { TooltipProvider } from "@/components/ui/tooltip"; @@ -28,6 +30,12 @@ const instrumentSerif = Instrument_Serif({ style: ["normal", "italic"], }); +const instrumentSans = Instrument_Sans({ + variable: "--font-instrument-sans", + subsets: ["latin"], + weight: ["400", "500", "600"], +}); + const redHatDisplay = Red_Hat_Display({ variable: "--font-red-hat-display", subsets: ["latin"], @@ -35,10 +43,28 @@ const redHatDisplay = Red_Hat_Display({ style: ["normal", "italic"], }); +const redHatMono = Red_Hat_Mono({ + variable: "--font-red-hat-mono", + subsets: ["latin"], + weight: ["400", "500", "600"], +}); + export const metadata: Metadata = { - title: "MHacks 2026", + metadataBase: new URL("https://mhacks.org"), + title: "MHacks 2026 · Digital Garden", description: - "Michigan's premier student hackathon — 24 hours, limitless ideas.", + "MHacks is the University of Michigan's flagship hackathon. 24 hours of building at the intersection of nature and technology. Ann Arbor, Fall 2026.", + openGraph: { + title: "MHacks 2026 · Digital Garden", + description: + "The University of Michigan's flagship hackathon. Build something that grows.", + type: "website", + }, +}; + +export const viewport: Viewport = { + themeColor: "#3A4A26", + colorScheme: "light", }; export default function RootLayout({ @@ -50,7 +76,7 @@ export default function RootLayout({ diff --git a/app/not-found.tsx b/app/not-found.tsx index 21c051d..4164f7c 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,125 +1,133 @@ import Image from "next/image"; -import Link from "next/link"; -import { ArrowLeft, ArrowUpRight, Mail } from "lucide-react"; -const navLinks = [ - { href: "/#about", label: "About" }, - { href: "/#timeline", label: "Dates" }, - { href: "/#sponsors", label: "Sponsors" }, - { href: "/#faqs", label: "FAQ" }, -]; +import { MlhBadge } from "@/components/landing/MlhBadge"; +import { CtaButton } from "@/components/landing/cta-button"; +import { asset } from "@/lib/landing/asset"; +import { GRAIN_140 } from "@/lib/landing/textures"; -const detailItems = ["October 3 - 4, 2026", "Ann Arbor, MI", "800+ Hackers"]; +const DOT_GRID = [ + "radial-gradient(circle, rgba(255,255,255,0.3) 0.5px, transparent 0.5px)", + "radial-gradient(circle, rgba(255,255,255,0.16) 0.5px, transparent 0.5px)", +].join(", "); export default function NotFound() { return ( -
    - - -
    +
    +
    +
    +
    +
    + +
    -
    - - MHacks + +
    + +
    +
    + +
    - - +
    - - Apply Now - -
    +
    +
    -
    -
    -

    - 404 / Page not found -

    -

    - Lost in Ann Arbor -

    -

    - This path did not bloom, but MHacks is still right where you left - it. -

    + -
    - - - Back home - - - Apply - - - + -
    + Lost in Ann Arbor + + +

    + This path did not bloom, but MHacks is still right where you left + it. +

    -
    - {detailItems.map((item, index) => ( - - {item} - {index < detailItems.length - 1 ? ( - - ) : null} - - ))} +
    + + Back home + + + Apply + +
    +
    -
    -
    +
    +
    ); } diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index c6f797e..0000000 --- a/app/page.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import HeroSection from "@/components/hero-section"; -import NavBar from "@/components/navbar"; -import AsciiBackground from "@/components/ascii-background"; -import AboutSection from "@/components/about-section"; -import VideoSpotlight from "@/components/video-spotlight"; -import StatsBand from "@/components/stats-band"; -import KeyDates from "@/components/key-dates"; -import SponsorsSection from "@/components/sponsors-section"; -import FaqSection from "@/components/faq-section"; -import SiteFooter from "@/components/site-footer"; -import GradientBlobs from "@/components/gradient-blobs"; -const ribbonItems = [ - "$40k+ In Prizes", - "800+ Hackers", - "24 Hours", - "October 3 - 4, 2026", - "Ann Arbor, MI", -]; - -export default function Home() { - return ( -
    - - {/* ── Navbar ── */} - - - {/* ── Hero ── */} - - - {/* ── Scrolling ribbon ── */} -
    -
    - {[...ribbonItems, ...ribbonItems].map((item, i) => ( -
    -

    - {item} -

    - - ◆ - -
    - ))} -
    -
    - - {/* ── About + gradient blobs ── */} - {/* overflow-hidden keeps the blobs contained so they don't overlap StatsBand */} -
    - - -
    - - - {/* ── MHacks 2025 Recap Video ── */} - - - {/* ── Timeline ── */} - - - {/* ── Sponsors ── */} - - - {/* ── FAQs ── */} - - - {/* ── CTA ── */} - {/* */} - - {/* ── Footer ── */} - -
    - ); -} diff --git a/components/about-flowers.tsx b/components/about-flowers.tsx deleted file mode 100644 index b085387..0000000 --- a/components/about-flowers.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import Image from "next/image"; - -export default function AboutFlowers() { - return ( - <> -
    - -
    -
    - -
    - - ); -} diff --git a/components/about-section.tsx b/components/about-section.tsx deleted file mode 100644 index 584142e..0000000 --- a/components/about-section.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import AboutFlowers from "@/components/about-flowers"; - -export default function AboutSection() { - return ( -
    - -
    -
    -

    - About MHacks -

    -

    - Calling All - Hackers -

    -
    -
    -

    - MHacks is the University of Michigan's flagship hackathon, - bringing together the brightest student minds from across the - country. -
    -
    - Over 24 hours, you'll collaborate, create, and compete for over - $40,000 in prizes. -

    -

    - Whether you're a seasoned hacker or attending your first - hackathon, MHacks is the place to turn your wildest ideas into - reality. -
    -
    - Join us for a weekend of innovation, mentorship, and community. -

    -
    -
    -
    - ); -} diff --git a/components/ascii-background.tsx b/components/ascii-background.tsx deleted file mode 100644 index 0d0f16f..0000000 --- a/components/ascii-background.tsx +++ /dev/null @@ -1,138 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; - -// deterministic 2D hash → [0,1), used to scatter + vary the flowers -function hash(x: number, y: number) { - let h = Math.imul(x, 374761393) + Math.imul(y, 668265263); - h = Math.imul(h ^ (h >>> 13), 1274126177); - return ((h ^ (h >>> 16)) >>> 0) / 4294967295; -} - -// a small ascii flower: a center dot ringed by inner petals and outer tips, -// all drawn as little squares to keep the dotted-glyph aesthetic -function drawFlower( - ctx: CanvasRenderingContext2D, - cx: number, - cy: number, - r: number, - rot: number, - alpha: number, - color: string, -) { - const dot = Math.max(2, Math.round(r / 5)); - const tip = Math.max(1, dot - 1); - ctx.fillStyle = `rgba(${color}, ${alpha})`; - // center - ctx.fillRect(cx - dot / 2, cy - dot / 2, dot, dot); - const petals = 6; - for (let p = 0; p < petals; p++) { - const a = rot + (p / petals) * Math.PI * 2; - const ix = cx + Math.cos(a) * r * 0.55; - const iy = cy + Math.sin(a) * r * 0.55; - ctx.fillRect(ix - dot / 2, iy - dot / 2, dot, dot); - const ox = cx + Math.cos(a) * r; - const oy = cy + Math.sin(a) * r; - ctx.fillRect(ox - tip / 2, oy - tip / 2, tip, tip); - } -} - -/** Ascii flowers scattered down the left and right gutters, leaving the center - * column clear for text and cards. The field parallaxes with the page and the - * blooms gently rotate/swell as you scroll, so it reacts to scroll site-wide. - * Sits at -z-10 so it paints over the paper background but behind content. */ -export default function AsciiBackground({ - gap = 74, - color = "58, 74, 38", // moss rgb channels — alpha is computed per flower -}: { - gap?: number; - color?: string; -}) { - const ref = useRef(null); - - useEffect(() => { - const canvas = ref.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - let raf = 0; - let scrollY = window.scrollY; - let dpr = 1; - - const draw = () => { - const w = canvas.width / dpr; - const h = canvas.height / dpr; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, w, h); - - // clear central band for content; flowers live in the side gutters - const clear = Math.min(w - 64, 940); - const gutter = Math.max(0, (w - clear) / 2); - if (gutter < 8) return; - - const cols = Math.ceil(w / gap) + 2; - const startRow = Math.floor(scrollY / gap) - 1; - const endRow = Math.ceil((scrollY + h) / gap) + 1; - - for (let gy = startRow; gy <= endRow; gy++) { - for (let gx = 0; gx < cols; gx++) { - if (hash(gx, gy) < 0.42) continue; - const seed = hash(gx, gy); - const jx = (hash(gx + 31, gy + 7) - 0.5) * gap * 0.6; - const jy = (hash(gx + 5, gy + 53) - 0.5) * gap * 0.6; - const x = gx * gap + jx; - const worldY = gy * gap + jy; - const y = worldY - scrollY; - if (y < -gap || y > h + gap) continue; - - // only keep flowers inside the side gutters, fading toward center - const distFromEdge = Math.min(x, w - x); - if (distFromEdge > gutter) continue; - const edge = Math.max(0, Math.min(1, 1 - distFromEdge / gutter)); - - // scroll-driven bloom: blossoms swell + brighten in a travelling wave - const wave = - Math.sin(worldY / 220 - gx * 0.4 - scrollY / 360) * 0.5 + 0.5; - const r = gap * (0.24 + seed * 0.16) * (0.8 + wave * 0.4); - const alpha = (0.22 + edge * 0.48) * (0.65 + wave * 0.35); - const rot = seed * Math.PI * 2 + scrollY / 1200; - - drawFlower(ctx, x, y, r, rot, alpha, color); - } - } - }; - - const resize = () => { - const w = window.innerWidth; - const h = window.innerHeight; - dpr = Math.min(window.devicePixelRatio || 1, 2); - canvas.width = Math.round(w * dpr); - canvas.height = Math.round(h * dpr); - draw(); - }; - - const onScroll = () => { - scrollY = window.scrollY; - cancelAnimationFrame(raf); - raf = requestAnimationFrame(draw); - }; - - resize(); - window.addEventListener("scroll", onScroll, { passive: true }); - window.addEventListener("resize", resize); - return () => { - window.removeEventListener("scroll", onScroll); - window.removeEventListener("resize", resize); - cancelAnimationFrame(raf); - }; - }, [gap, color]); - - return ( - - ); -} diff --git a/components/cta-section.tsx b/components/cta-section.tsx deleted file mode 100644 index 3444b97..0000000 --- a/components/cta-section.tsx +++ /dev/null @@ -1,68 +0,0 @@ -"use client"; - -import { motion } from "framer-motion"; -import { useApplicationsOpen } from "./use-applications-open"; - -const EASE = [0.25, 0.1, 0.25, 1] as const; - -export default function CtaSection() { - const applicationsOpen = useApplicationsOpen(); - - return ( -
    - -

    - Come{" "} - - build - {" "} - something that{" "} - - grows - - . -

    - -
    -
    - ); -} diff --git a/components/faq-accordion.tsx b/components/faq-accordion.tsx deleted file mode 100644 index cf966e1..0000000 --- a/components/faq-accordion.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { useState } from "react"; - -const faqs = [ - { - q: "What is MHacks?", - a: "MHacks is the University of Michigan's premier student-run hackathon, held annually in Ann Arbor. It brings together hundreds of students from across the country for a weekend of building, learning, and competing.", - }, - { - q: "Who can attend?", - a: "MHacks is open to all currently enrolled undergraduate and graduate students. Students of all skill levels and disciplines are welcome — no prior hackathon experience required.", - }, - { - q: "Where is MHacks?", - a: "MHacks is held on the University of Michigan's campus in Ann Arbor, Michigan. Venue details will be shared with registered participants closer to the event date.", - }, - { - q: "Are there travel reimbursements?", - a: "We offer travel reimbursements on a limited basis for participants traveling from outside the Ann Arbor area. Apply during registration and we'll follow up with details.", - }, - { - q: "How much does MHacks cost?", - a: "MHacks is completely free to attend. We cover meals, snacks, and event resources throughout the entire 24-hour event, thanks to our generous sponsors.", - }, - { - q: "How do teams work?", - a: "Teams can have 1–4 members. You can form a team before the event or find teammates at our team formation session at the start of the hackathon.", - }, - { - q: "What if this is my first hackathon?", - a: "First-timers are absolutely welcome. We'll have intro workshops, beginner-friendly resources, and dedicated mentors to help you build something great regardless of your experience level.", - }, -]; - -export default function FaqAccordion() { - const [open, setOpen] = useState(null); - - return ( -
    - {faqs.map((faq, i) => ( -
    - -
    -

    {faq.a}

    -
    -
    - ))} -
    - ); -} diff --git a/components/faq-section.tsx b/components/faq-section.tsx deleted file mode 100644 index 281d6c3..0000000 --- a/components/faq-section.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Image from "next/image"; -import { AnimatePresence, motion } from "framer-motion"; - -const EASE = [0.25, 0.1, 0.25, 1] as const; - -const FAQS = [ - { - q: "What is MHacks?", - a: "MHacks is the University of Michigan's premier student-run hackathon, held annually in Ann Arbor. It brings together hundreds of students from across the country for a weekend of building, learning, and competing.", - }, - { - q: "Who can attend?", - a: "MHacks is open to all currently enrolled undergraduate and graduate students. Students of all skill levels and disciplines are welcome — no prior hackathon experience required.", - }, - { - q: "Where is MHacks?", - a: "MHacks is held on the University of Michigan's campus in Ann Arbor, Michigan. Venue details will be shared with registered participants closer to the event date.", - }, - { - q: "Are there travel reimbursements?", - a: "We offer travel reimbursements on a limited basis for participants traveling from outside the Ann Arbor area. To qualify, you must submit your application before the early deadline (Aug. 7). Apply during registration and we'll follow up with details.", - }, - { - q: "How much does MHacks cost?", - a: "MHacks is completely free to attend. We cover meals, snacks, and event resources throughout the entire 24-hour event, thanks to our generous sponsors.", - }, - { - q: "How do teams work?", - a: "Teams can have 1–4 members. You can form a team before the event or find teammates at our team formation session at the start of the hackathon.", - }, - { - q: "What if this is my first hackathon?", - a: "First-timers are absolutely welcome. We'll have intro workshops, beginner-friendly resources, and dedicated mentors to help you build something great regardless of your experience level.", - }, -]; - -function FaqItem({ q, a, index }: { q: string; a: string; index: number }) { - const [open, setOpen] = useState(false); - - return ( - - - - - {open && ( - -

    - {a} -

    -
    - )} -
    -
    - ); -} - -export default function FaqSection() { - return ( -
    -
    -
    - -

    - FAQs -

    - -
    - -
    - {FAQS.map((faq, i) => ( - - ))} -
    -
    -
    - ); -} diff --git a/components/gradient-blobs.tsx b/components/gradient-blobs.tsx deleted file mode 100644 index d94d1d5..0000000 --- a/components/gradient-blobs.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// Each blob is a circle whose center sits on the screen edge, so only the -// inner half is visible — a half-circle wrapping around the flowers, which are -// also vertically centered (top: 50%) on each side. -// Full circle diameter (half of it shows). It scales with the viewport -// (proportional to the flowers) but never shrinks below MIN_DIAM, so it always -// stays large enough to surround the flowers. Below md (768px) the blobs are -// removed entirely via `hidden md:block`, so they disappear with the flowers -// instead of continuing to shrink past them. -const FLOWER_VW = 50; // proportional to the flowers (~3.2x their 30vw) -const MIN_DIAM = 100; // px — floor that still surrounds the flowers at md -const MAX_DIAM = 900; // px — cap on large screens -const DIAM = `clamp(${MIN_DIAM}px, ${FLOWER_VW}vw, ${MAX_DIAM}px)`; -const HALF_OFFSET = `calc(${DIAM} / -2)`; // pulls the circle center onto the edge -const PINK = "rgba(255,170,216,0.9)"; // stronger pink -const BLUE = "rgba(190,220,255,0.9)"; // stronger blue -const CORE = "60%"; // color stays solid out to here -const EDGE = "76%"; // gone by here — the CORE→EDGE band is the visible perimeter -const SOFTEN = 8; // px blur; small so the rim stays defined - -export default function GradientBlobs() { - return ( - <> - {/* Pink half-circle — left side, centered on the flowers */} -
    - {/* Blue half-circle — right side, centered on the flowers */} -
    - - ); -} diff --git a/components/hero-section.tsx b/components/hero-section.tsx deleted file mode 100644 index 2b76488..0000000 --- a/components/hero-section.tsx +++ /dev/null @@ -1,229 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { useApplicationsOpen } from "./use-applications-open"; -import MlhTrustBadge from "./mlh-trust-badge"; -import posthog from "posthog-js"; - -const BOX_W = 176; -const BOX_H = 224; -const LABEL_H = 22; -const LERP = 0.1; - -export default function HeroSection() { - const applicationsOpen = useApplicationsOpen(); - const boxRef = useRef(null); - const bgRef = useRef(null); - const labelRef = useRef(null); - const target = useRef({ x: 0, y: 0 }); - const current = useRef({ x: 0, y: 0 }); - const heroSize = useRef({ w: 0, h: 0 }); - const rafId = useRef(null); - const visible = useRef(false); - const overButton = useRef(false); - - useEffect(() => { - const tick = () => { - current.current.x += (target.current.x - current.current.x) * LERP; - current.current.y += (target.current.y - current.current.y) * LERP; - - const imageLeft = current.current.x - BOX_W / 2; - const imageTop = current.current.y - BOX_H / 2; - - if (boxRef.current) { - boxRef.current.style.left = `${imageLeft}px`; - boxRef.current.style.top = `${imageTop - LABEL_H}px`; - } - if (bgRef.current) { - bgRef.current.style.left = `${-imageLeft}px`; - bgRef.current.style.top = `${-imageTop}px`; - bgRef.current.style.width = `${heroSize.current.w}px`; - bgRef.current.style.height = `${heroSize.current.h}px`; - } - if (labelRef.current && heroSize.current.h > 0) { - // Real latitude range centered on Ann Arbor (42.2808°N) ± 1 degree - const ty = Math.max( - 0, - Math.min(1, target.current.y / heroSize.current.h), - ); - const lat = (43.2808 - ty * 2).toFixed(4); - labelRef.current.textContent = `${lat}°N`; - } - - rafId.current = requestAnimationFrame(tick); - }; - - rafId.current = requestAnimationFrame(tick); - return () => { - if (rafId.current !== null) cancelAnimationFrame(rafId.current); - }; - }, []); - - const handleMouseMove = useCallback((e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - heroSize.current = { w: rect.width, h: rect.height }; - - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - if (!visible.current) { - current.current = { x, y }; - visible.current = true; - if (boxRef.current) { - const el = boxRef.current; - el.classList.remove("lens-pop"); - void el.offsetWidth; // force reflow so animation restarts - el.classList.add("lens-pop"); - el.addEventListener( - "animationend", - () => { - el.classList.remove("lens-pop"); - el.style.opacity = "1"; - }, - { once: true }, - ); - } - } - - target.current = { x, y }; - }, []); - - const handleMouseLeave = useCallback(() => { - visible.current = false; - if (boxRef.current) boxRef.current.style.opacity = "0"; - }, []); - - const handleButtonEnter = useCallback(() => { - overButton.current = true; - if (boxRef.current) boxRef.current.style.opacity = "0"; - }, []); - - const handleButtonLeave = useCallback(() => { - overButton.current = false; - if (visible.current && boxRef.current) boxRef.current.style.opacity = "1"; - }, []); - - return ( -
    -
    - {/* Mobile: original portrait bg */} - MHacks 2026 - {/* Desktop: cropped landscape bg aligned with the lens */} - MHacks 2026 - - {/* Cursor-following lens */} -
    - {/* Ann Arbor latitude — updates live with lens position */} -

    - 42.28°N -

    - - {/* Lens: overflow:hidden clips the clear bg */} -
    -
    -
    -
    -
    - -
    - - - -
    - {/* Top bar: logo left, apply right */} -
    - - MHacks - - { - if (!applicationsOpen) e.preventDefault(); - else posthog.capture("apply_now_clicked", { location: "hero" }); - }} - className="relative group hidden lg:block" - onMouseEnter={handleButtonEnter} - onMouseLeave={handleButtonLeave} - > - - Apply Now - - -
    - - {/* Bottom: left-aligned title then dates */} -
    -

    - MHACKS 2026 -

    -

    - October 3 - 4, 2026 -  ·  -
    - Ann Arbor, Michigan -

    -
    -
    -
    -
    - ); -} diff --git a/components/key-dates.tsx b/components/key-dates.tsx deleted file mode 100644 index 5ec40b1..0000000 --- a/components/key-dates.tsx +++ /dev/null @@ -1,168 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { motion } from "framer-motion"; - -const EASE = [0.25, 0.1, 0.25, 1] as const; - -const KEY_DATES = [ - { - iso: "2026-06-22", - time: "12:00:00", - date: "Jun. 22", - label: "Applications Open", - }, - { iso: "2026-08-07", date: "Aug. 07", label: "Early Application Deadline" }, - { - iso: "2026-08-14", - date: "Aug. 14", - label: "Early Decisions Released", - }, - { - iso: "2026-09-12", - date: "Sep. 12", - label: "Regular Applications Deadline", - }, - { iso: "2026-09-19", date: "Sep. 19", label: "Regular Decisions Released" }, -]; - -function easternDateKey(ms: number) { - return new Intl.DateTimeFormat("en-CA", { - timeZone: "America/Detroit", - year: "numeric", - month: "2-digit", - day: "2-digit", - }).format(new Date(ms)); -} - -function timelineStatus(iso: string, now: number | null) { - if (now === null) return "Upcoming"; - - const today = easternDateKey(now); - const yesterday = easternDateKey(now - 86_400_000); - const tomorrow = easternDateKey(now + 86_400_000); - - if (iso === today) return "Today"; - if (iso === tomorrow) return "Tomorrow"; - if (iso === yesterday) return "Yesterday"; - return iso < today ? "Passed" : "Upcoming"; -} - -function useNow() { - const [now, setNow] = useState(null); - - useEffect(() => { - const updateNow = () => setNow(Date.now()); - const initialId = window.setTimeout(updateNow, 0); - const intervalId = window.setInterval(updateNow, 1000); - - return () => { - window.clearTimeout(initialId); - window.clearInterval(intervalId); - }; - }, []); - - return now; -} - -export default function KeyDates() { - const now = useNow(); - - return ( -
    - {/* */} -
    -
    -

    - Applications -

    -

    - Timeline -

    -
    - -
    - {KEY_DATES.map((item, i) => { - const status = timelineStatus(item.iso, now); - const isActive = status === "Today"; - const isPast = status === "Yesterday" || status === "Passed"; - return ( - - (e.currentTarget.style.backgroundColor = - "rgba(58,74,38,0.06)") - } - onMouseLeave={(e) => - (e.currentTarget.style.backgroundColor = "#f4f2e8") - } - > - - {status} - -

    - {item.label} -

    - - - {item.date} - -
    - ); - })} -
    -
    -
    - ); -} diff --git a/components/landing/AgentTerminal.tsx b/components/landing/AgentTerminal.tsx new file mode 100644 index 0000000..fdf7dc5 --- /dev/null +++ b/components/landing/AgentTerminal.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { motion, useReducedMotion } from "framer-motion"; +import { SERVER_URL } from "@/app/(marketing)/how-to-mcp/content"; + +type Line = + | { kind: "user"; text: string } + | { kind: "tool"; text: string } + | { kind: "agent"; text: string } + | { kind: "prompt" }; + +const LINES: Line[] = [ + { kind: "user", text: "help me apply to MHacks" }, + { kind: "tool", text: "mhacks · get_application_schema" }, + { kind: "tool", text: "mhacks · save_application_draft" }, + { + kind: "agent", + text: "Draft saved. I still need your school and t-shirt size. Then we can review the MLH terms together before submitting.", + }, + { kind: "prompt" }, +]; + +function TerminalLine({ line }: { line: Line }) { + if (line.kind === "user" || line.kind === "prompt") { + return ( +

    + + + {line.kind === "user" ? line.text : null} + {line.kind === "prompt" && ( + + )} + +

    + ); + } + if (line.kind === "tool") { + return ( +

    + + + {line.text} … ok + +

    + ); + } + return

    {line.text}

    ; +} + +/** Faux agent session showing the MCP apply flow — the dark terminal card + used on the home Agent section and the How to MCP page hero. */ +export function AgentTerminal({ className }: { className?: string }) { + const reduced = useReducedMotion(); + return ( +
    +
    + {[0, 1, 2].map((i) => ( + + ))} + + agent · {SERVER_URL.replace("https://", "")} + +
    +
    + {LINES.map((line, i) => ( + + + + ))} +
    +
    + ); +} diff --git a/components/landing/AsciiBloom.tsx b/components/landing/AsciiBloom.tsx new file mode 100644 index 0000000..c069c93 --- /dev/null +++ b/components/landing/AsciiBloom.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { cn, prefersReducedMotion } from "@/lib/utils"; +import { useInView } from "@/lib/landing/useInView"; + +/** + * The ASCII flower beside the "click around the canvas" hint. It blooms + * through a few frames once it scrolls into view — a small tell that this + * corner of the section is playful — and replays with a color lift on hover. + * Every frame is the same 5x3 character grid, so swapping never shifts layout. + */ +const FRAMES = [ + " . \n-( )-\n ' ", + ". | .\n-(o)-\n' | '", + "\\ | /\n-(*)-\n/ | \\", +]; + +const FRAME_MS = 220; + +export function AsciiBloom({ className }: { className?: string }) { + const { ref, inView } = useInView(); + const [frame, setFrame] = useState(0); + // Bumping the cycle restarts the bloom (hover replays it). + const [cycle, setCycle] = useState(0); + const [hovered, setHovered] = useState(false); + + useEffect(() => { + if (!inView || prefersReducedMotion()) return; + const reset = window.setTimeout(() => setFrame(0), 0); + const id = setInterval(() => { + setFrame((f) => { + if (f >= FRAMES.length - 1) { + clearInterval(id); + return f; + } + return f + 1; + }); + }, FRAME_MS); + return () => { + clearTimeout(reset); + clearInterval(id); + }; + }, [inView, cycle]); + + const displayFrame = + inView && prefersReducedMotion() ? FRAMES.length - 1 : frame; + + return ( +
     {
    +        setHovered(true);
    +        if (!prefersReducedMotion()) setCycle((c) => c + 1);
    +      }}
    +      onMouseLeave={() => setHovered(false)}
    +      className={cn(
    +        "select-none transition-colors duration-300",
    +        hovered ? "text-moss-700" : "text-moss-500",
    +        className,
    +      )}
    +    >
    +      {FRAMES[displayFrame]}
    +    
    + ); +} diff --git a/components/landing/AsciiCanvas.tsx b/components/landing/AsciiCanvas.tsx new file mode 100644 index 0000000..54476b0 --- /dev/null +++ b/components/landing/AsciiCanvas.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { prefersReducedMotion } from "@/lib/utils"; + +interface Props { + className?: string; + color?: string; + chars?: string[]; + step?: number; + interactive?: boolean; + fontSize?: number; + opacity?: number; + blendMode?: React.CSSProperties["mixBlendMode"]; + /** Mark for StackedPages so the loop stops once the host sheet is buried. */ + stackPause?: boolean; +} + +/** + * Ambient character field. Ports the interactive dot/char overlay from the + * original index.html prototype into a React canvas that reacts to the cursor. + */ +export function AsciiCanvas({ + className, + color = "245,241,222", // parchment + chars = [".", ":", "·", "+", "x", "o", "*", "M", "H"], + step = 14, + interactive = true, + fontSize = 11, + opacity = 0.55, + blendMode = "screen", + stackPause, +}: Props) { + const canvasRef = useRef(null); + + useEffect(() => { + const c = canvasRef.current; + if (!c) return; + const ctx = c.getContext("2d"); + if (!ctx) return; + const parent = c.parentElement; + if (!parent) return; + + const reduced = prefersReducedMotion(); + let W = 0; + let H = 0; + let raf = 0; + let running = false; + let inView = true; + const mouse = { x: -1e4, y: -1e4 }; + // The field is a pure function of the pointer position — no time term — so + // a frame with an unmoved cursor would repaint identical pixels. Track what + // the canvas currently shows and sleep as soon as it matches. + let drawnX = NaN; + let drawnY = NaN; + + interface Dot { + x: number; + y: number; + ch: string; + base: number; + } + let dots: Dot[] = []; + + const isActive = () => + inView && !document.hidden && c.offsetParent !== null; + + const build = () => { + dots = []; + for (let y = step; y < H; y += step) { + for (let x = step; x < W; x += step) { + if (Math.random() < 0.18) continue; + dots.push({ + x: x + (Math.random() * 4 - 2), + y: y + (Math.random() * 4 - 2), + ch: chars[Math.floor(Math.random() * chars.length)], + base: 0.15 + Math.random() * 0.25, + }); + } + } + }; + + const resize = () => { + const dpr = Math.min(2, window.devicePixelRatio || 1); + W = parent.clientWidth; + H = parent.clientHeight; + // Polaroids are `hidden lg:block`, so a resize across that breakpoint + // measures a collapsed parent. Bail rather than zero out the canvas. + if (W === 0 || H === 0) return; + c.width = Math.floor(W * dpr); + c.height = Math.floor(H * dpr); + c.style.width = `${W}px`; + c.style.height = `${H}px`; + // Resizing the backing store resets every context property, so the text + // state has to be re-applied here rather than per frame. + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.font = `${fontSize}px var(--font-red-hat-mono), ui-monospace, monospace`; + ctx.textBaseline = "middle"; + ctx.textAlign = "center"; + build(); + drawnX = NaN; // new dot field — force a repaint + if (reduced) draw(); + else start(); + }; + + const REACH = 180; + const REACH2 = REACH * REACH; + + const draw = () => { + ctx.clearRect(0, 0, W, H); + // One colour parse per frame instead of one per glyph: alpha rides on + // globalAlpha, which composites identically to baking it into rgba(). + ctx.fillStyle = `rgb(${color})`; + for (const d of dots) { + let a = d.base; + let ox = 0; + let oy = 0; + if (interactive && !reduced) { + const dx = d.x - mouse.x; + const dy = d.y - mouse.y; + const d2 = dx * dx + dy * dy; + // Beyond REACH the falloff clamps to zero, so skip the sqrt entirely. + if (d2 < REACH2) { + const near = 1 - Math.sqrt(d2) / REACH; + a = Math.min(1, d.base + near * 0.9); + ox = dx * near * 0.06; + oy = dy * near * 0.06; + } + } + ctx.globalAlpha = a; + ctx.fillText(d.ch, d.x + ox, d.y + oy); + } + ctx.globalAlpha = 1; + }; + + const tick = () => { + if (!isActive()) { + running = false; + return; + } + // Nothing moved since the last paint — the next frame would be a pixel + // copy of what's already on screen. Sleep; onMove/onLeave will wake us. + if (mouse.x === drawnX && mouse.y === drawnY) { + running = false; + return; + } + drawnX = mouse.x; + drawnY = mouse.y; + draw(); + raf = requestAnimationFrame(tick); + }; + + const start = () => { + if (running || reduced) return; + running = true; + raf = requestAnimationFrame(tick); + }; + + const onMove = (e: MouseEvent) => { + const r = c.getBoundingClientRect(); + mouse.x = e.clientX - r.left; + mouse.y = e.clientY - r.top; + start(); + }; + const onLeave = () => { + mouse.x = -1e4; + mouse.y = -1e4; + start(); // repaint the resting field, then sleep again + }; + + const observer = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting; + if (inView) { + if (c.width === 0 || c.height === 0) resize(); + start(); + } else { + cancelAnimationFrame(raf); + running = false; + } + }); + + const onVisibility = () => { + if (!document.hidden) start(); + }; + + resize(); + start(); + observer.observe(c); + window.addEventListener("resize", resize); + document.addEventListener("visibilitychange", onVisibility); + if (interactive) { + parent.addEventListener("mousemove", onMove); + parent.addEventListener("mouseleave", onLeave); + } + + return () => { + cancelAnimationFrame(raf); + observer.disconnect(); + window.removeEventListener("resize", resize); + document.removeEventListener("visibilitychange", onVisibility); + if (interactive) { + parent.removeEventListener("mousemove", onMove); + parent.removeEventListener("mouseleave", onLeave); + } + }; + }, [chars, color, step, interactive, fontSize]); + + return ( + + ); +} diff --git a/components/landing/AsciiGlow.tsx b/components/landing/AsciiGlow.tsx new file mode 100644 index 0000000..cec26a1 --- /dev/null +++ b/components/landing/AsciiGlow.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { subscribeScroll } from "@/lib/landing/scroll"; +import { prefersReducedMotion } from "@/lib/utils"; + +/** + * A starfield of ASCII glyphs over the hero — a galaxy night sky. Lots of + * small faint "stars" with rarer, larger ones; each twinkles on its own slow + * sine wave. Canvas-rendered at ~8fps (no shadowBlur halos — those were the + * main perf cost). Loop pauses while scrolling, offscreen, or tab hidden. + */ + +const SMALL_GLYPHS = "·.,:˙'`°"; +const STAR_GLYPHS = "+*×✽✦✧"; +const ACCENT_RGB = "rgb(232, 211, 90)"; +const DUST_RGB = "rgb(239, 233, 212)"; +const CELL = 22; // px between glyph centers +const DENSITY = 0.34; // fraction of cells that hold a glyph +const FRAME_MS = 1000 / 8; +/** Backing store vs CSS size — slightly softens glyphs, fewer pixels per frame. */ +const RES_SCALE = 0.85; +/** Quiet period after the last scroll event before the twinkle resumes. */ +const SCROLL_RESUME_MS = 150; + +interface Glyph { + x: number; + y: number; + char: string; + size: number; // font px + phase: number; + speed: number; // radians per second + peak: number; // max alpha + accent: boolean; + star: boolean; // bigger feature star vs. background dust +} + +function buildField( + w: number, + h: number, + cell: number, + density: number, +): Glyph[] { + const glyphs: Glyph[] = []; + const cols = Math.ceil(w / cell); + const rows = Math.ceil(h / cell); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + if (Math.random() > density) continue; + // Mostly small dust, ~1 in 4 are proper stars. + const star = Math.random() < 0.28; + const pool = star ? STAR_GLYPHS : SMALL_GLYPHS; + glyphs.push({ + x: c * cell + cell / 2 + (Math.random() - 0.5) * cell * 0.8, + y: r * cell + cell / 2 + (Math.random() - 0.5) * cell * 0.8, + char: pool[Math.floor(Math.random() * pool.length)], + // Whole pixels, so the sort below actually collapses into ~20 runs. + size: star + ? 18 + Math.round(Math.random() * 14) + : 12 + Math.round(Math.random() * 6), + phase: Math.random() * Math.PI * 2, + speed: 0.3 + Math.random() * 1.1, + peak: star ? 0.45 + Math.random() * 0.5 : 0.2 + Math.random() * 0.35, + accent: Math.random() < 0.14, + star, + }); + } + } + return glyphs.sort((a, b) => a.size - b.size); +} + +export function AsciiGlow({ + className, + cell = CELL, + density = DENSITY, +}: { + className?: string; + /** px between glyph centers — smaller = denser field */ + cell?: number; + /** fraction of cells holding a glyph */ + density?: number; +}) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const reduced = prefersReducedMotion(); + let glyphs: Glyph[] = []; + let rafId = 0; + let timeoutId = 0; + let scrollIdleId = 0; + let running = false; + let inView = true; + let scrolling = false; + let lastFrame = 0; + let dpr = 1; + let logicalW = 0; + let logicalH = 0; + let clockOffset = 0; + let pauseStart = 0; + + const canRun = () => inView && !scrolling && !document.hidden; + + const draw = (timeSec: number) => { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, logicalW, logicalH); + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + let currentSize = 0; + let currentFill = ""; + for (const g of glyphs) { + const wave = 0.5 + 0.5 * Math.sin(timeSec * g.speed + g.phase); + const alpha = g.peak * wave * wave * wave; + if (alpha < 0.01) { + if (Math.random() < 0.02) { + const pool = g.star ? STAR_GLYPHS : SMALL_GLYPHS; + g.char = pool[Math.floor(Math.random() * pool.length)]; + } + continue; + } + if (g.size !== currentSize) { + currentSize = g.size; + ctx.font = `${g.size}px var(--font-red-hat-mono), ui-monospace, monospace`; + } + const fill = g.accent ? ACCENT_RGB : DUST_RGB; + if (fill !== currentFill) { + currentFill = fill; + ctx.fillStyle = fill; + } + ctx.globalAlpha = alpha; + ctx.fillText(g.char, g.x, g.y); + } + ctx.globalAlpha = 1; + }; + + const loop = (now: number) => { + if (!canRun()) { + running = false; + return; + } + if (now - lastFrame >= FRAME_MS) { + lastFrame = now; + draw((now - clockOffset) / 1000); + } + const wait = Math.max(4, FRAME_MS - (performance.now() - lastFrame)); + timeoutId = window.setTimeout(() => { + rafId = requestAnimationFrame(loop); + }, wait); + }; + + const start = () => { + if (running || reduced || !canRun()) return; + running = true; + lastFrame = 0; + rafId = requestAnimationFrame(loop); + }; + + const stop = () => { + cancelAnimationFrame(rafId); + clearTimeout(timeoutId); + running = false; + }; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + dpr = Math.min(window.devicePixelRatio || 1, 2); + logicalW = rect.width * RES_SCALE; + logicalH = rect.height * RES_SCALE; + canvas.width = Math.round(logicalW * dpr); + canvas.height = Math.round(logicalH * dpr); + glyphs = buildField(logicalW, logicalH, cell, density); + if (reduced) draw(1.2); + }; + + const observer = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting; + if (inView) { + if (canvas.width === 0 || canvas.height === 0) resize(); + start(); + } else stop(); + }); + + const onVisibility = () => { + if (!document.hidden) start(); + }; + + const onScroll = () => { + if (!scrolling) { + scrolling = true; + pauseStart = performance.now(); + stop(); + } + clearTimeout(scrollIdleId); + scrollIdleId = window.setTimeout(() => { + scrolling = false; + clockOffset += performance.now() - pauseStart; + start(); + }, SCROLL_RESUME_MS); + }; + + resize(); + start(); + observer.observe(canvas); + window.addEventListener("resize", resize); + window.addEventListener("scroll", onScroll, { passive: true }); + const unsubLenis = subscribeScroll(onScroll); + document.addEventListener("visibilitychange", onVisibility); + + return () => { + stop(); + clearTimeout(scrollIdleId); + observer.disconnect(); + window.removeEventListener("resize", resize); + window.removeEventListener("scroll", onScroll); + unsubLenis(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [cell, density]); + + return ( + + ); +} diff --git a/components/landing/CopyBlock.tsx b/components/landing/CopyBlock.tsx new file mode 100644 index 0000000..fb28d74 --- /dev/null +++ b/components/landing/CopyBlock.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { cn } from "@/lib/utils"; + +/** + * Copy-to-clipboard chip + command block, shared by the How to MCP page and + * the home Agent section. `emphasized` inverts the block into the brand + * moss fill for the one command that matters most (the server URL). + */ +export function CopyChip({ + text, + label = "copy", + emphasized = false, +}: { + text: string; + label?: string; + emphasized?: boolean; +}) { + const [copied, setCopied] = useState(false); + + useEffect(() => { + if (!copied) return; + const t = setTimeout(() => setCopied(false), 1800); + return () => clearTimeout(t); + }, [copied]); + + return ( + + ); +} + +export function CommandBlock({ + children, + emphasized = false, +}: { + children: string; + emphasized?: boolean; +}) { + return ( +
    + + {children} + + +
    + ); +} diff --git a/components/landing/Cursor.tsx b/components/landing/Cursor.tsx new file mode 100644 index 0000000..a33cb85 --- /dev/null +++ b/components/landing/Cursor.tsx @@ -0,0 +1,418 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { subscribeScroll } from "@/lib/landing/scroll"; +import { isTouchDevice, prefersReducedMotion } from "@/lib/utils"; + +/** Last known pointer position — updated before React mounts the cursor. */ +const lastPointer = { x: -1, y: -1 }; + +if (typeof window !== "undefined") { + window.addEventListener( + "pointermove", + (e) => { + lastPointer.x = e.clientX; + lastPointer.y = e.clientY; + }, + { capture: true, passive: true }, + ); +} + +export function Cursor() { + const dotRef = useRef(null); + const ringRef = useRef(null); + const ditherRef = useRef(null); + const crosshairRef = useRef(null); + const labelRef = useRef(null); + const stateRef = useRef({ + visible: false, + hovering: false, + boxLabel: null as string | null, + }); + + useEffect(() => { + if (isTouchDevice()) return; + document.documentElement.classList.add("has-custom-cursor"); + return () => document.documentElement.classList.remove("has-custom-cursor"); + }, []); + + useEffect(() => { + if (isTouchDevice()) return; + if (prefersReducedMotion()) return; + + const dot = dotRef.current; + const ring = ringRef.current; + const dither = ditherRef.current; + const crosshair = crosshairRef.current; + const label = labelRef.current; + if (!dot || !ring || !dither || !crosshair || !label) return; + + let heroBoxReady = false; + let morphRaf = 0; + + const ringSize = () => ({ + w: ring.offsetWidth || 34, + h: ring.offsetHeight || 34, + }); + + const applyRingTransform = () => { + const { w, h } = ringSize(); + ring.style.transform = `translate3d(${ringPos.x - w / 2}px, ${ringPos.y - h / 2}px, 0)`; + }; + + const applyDotTransform = () => { + dot.style.transform = `translate3d(${dotPos.x - 2}px, ${dotPos.y - 2}px, 0)`; + }; + + const pinRingDuringMorph = () => { + cancelAnimationFrame(morphRaf); + const start = performance.now(); + const step = () => { + applyRingTransform(); + if (performance.now() - start < 280) { + morphRaf = requestAnimationFrame(step); + } + }; + morphRaf = requestAnimationFrame(step); + }; + + const applyPresentation = () => { + const { visible, hovering, boxLabel } = stateRef.current; + const inBox = boxLabel !== null; + const showRing = visible && !hovering; + const showDot = visible && !inBox && !hovering; + const wasInBox = ring.dataset.inBox === "1"; + + ring.style.opacity = showRing ? "1" : "0"; + dot.style.opacity = showDot ? "1" : "0"; + + ring.style.width = inBox ? "300px" : "34px"; + ring.style.height = inBox ? "400px" : "34px"; + ring.style.borderRadius = inBox ? "2px" : "999px"; + ring.dataset.inBox = inBox ? "1" : "0"; + ring.style.border = `1px solid ${ + inBox ? "rgba(245,241,222,0.95)" : "rgba(58,74,38,0.55)" + }`; + ring.style.background = inBox + ? "rgba(245,241,222,0.03)" + : "rgba(239,233,212,0.06)"; + ring.style.mixBlendMode = inBox ? "normal" : "multiply"; + + const backdrop = inBox + ? "contrast(1.2) saturate(0.8) brightness(1.05)" + : "none"; + dither.style.opacity = inBox ? "0.9" : "0"; + dither.style.backdropFilter = backdrop; + dither.style.setProperty("-webkit-backdrop-filter", backdrop); + crosshair.style.opacity = inBox ? "0.9" : "0"; + label.textContent = boxLabel ?? "You"; + label.style.opacity = inBox ? "1" : "0"; + + applyRingTransform(); + if (inBox !== wasInBox) pinRingDuringMorph(); + }; + + const mouse = { + x: lastPointer.x >= 0 ? lastPointer.x : 0, + y: lastPointer.y >= 0 ? lastPointer.y : 0, + }; + const dotPos = { x: mouse.x, y: mouse.y }; + const ringPos = { x: mouse.x, y: mouse.y }; + let hasPointer = lastPointer.x >= 0; + + const snapTrailers = () => { + dotPos.x = mouse.x; + dotPos.y = mouse.y; + ringPos.x = mouse.x; + ringPos.y = mouse.y; + applyRingTransform(); + applyDotTransform(); + }; + + const syncFromElement = (el: Element | null) => { + if (!el) { + stateRef.current.visible = false; + stateRef.current.hovering = false; + stateRef.current.boxLabel = null; + return; + } + const boxHost = el.closest("[data-cursor-box]"); + const boxReady = + heroBoxReady || + (boxHost?.hasAttribute("data-cursor-box-ready") ?? false); + + // Hold the hero cursor until the title finishes typing. + if (boxHost && !boxReady) { + stateRef.current.visible = false; + stateRef.current.hovering = false; + stateRef.current.boxLabel = null; + return; + } + + stateRef.current.visible = + el.closest("[data-cursor-zone]") !== null && el !== null; + const interactive = + el.closest( + "a, button, [data-cursor='hover'], [role='button'], input, textarea, select", + ) !== null; + stateRef.current.hovering = interactive; + stateRef.current.boxLabel = + !interactive && boxHost && boxReady + ? boxHost.dataset.cursorBox || null + : null; + }; + + const onMove = (e: MouseEvent) => { + hasPointer = true; + mouse.x = e.clientX; + mouse.y = e.clientY; + const el = e.target instanceof Element ? e.target : null; + const prevVisible = stateRef.current.visible; + const prevBox = stateRef.current.boxLabel; + syncFromElement(el); + const waitingForHero = + el?.closest("[data-cursor-box]") && + !heroBoxReady && + !el.closest("[data-cursor-box]")?.hasAttribute("data-cursor-box-ready"); + if (waitingForHero) { + // Track position invisibly so the box can morph in at the cursor. + snapTrailers(); + return; + } + const revealing = + (stateRef.current.visible && !prevVisible) || + (stateRef.current.boxLabel && !prevBox); + if (revealing) snapTrailers(); + applyPresentation(); + start(); + }; + + const onLeave = () => { + stateRef.current.visible = false; + stateRef.current.boxLabel = null; + applyPresentation(); + }; + + let raf = 0; + let running = false; + + const tick = () => { + dotPos.x += (mouse.x - dotPos.x) * 0.55; + dotPos.y += (mouse.y - dotPos.y) * 0.55; + ringPos.x += (mouse.x - ringPos.x) * 0.18; + ringPos.y += (mouse.y - ringPos.y) * 0.18; + applyRingTransform(); + applyDotTransform(); + + if ( + Math.abs(mouse.x - ringPos.x) < 0.1 && + Math.abs(mouse.y - ringPos.y) < 0.1 + ) { + running = false; + return; + } + raf = requestAnimationFrame(tick); + }; + const start = () => { + if (running) return; + running = true; + raf = requestAnimationFrame(tick); + }; + + // mouseover doesn't fire when the pointer is already over the hero on load, + // so derive zone/box state from the element under the cursor on mount too. + const syncFromPoint = (x: number, y: number) => { + mouse.x = x; + mouse.y = y; + syncFromElement(document.elementFromPoint(x, y)); + snapTrailers(); + applyPresentation(); + start(); + }; + + const resyncAtPointer = () => { + if (!hasPointer && lastPointer.x < 0) return; + const x = hasPointer ? mouse.x : lastPointer.x; + const y = hasPointer ? mouse.y : lastPointer.y; + syncFromElement(document.elementFromPoint(x, y)); + applyPresentation(); + }; + + // Lenis fires many scroll events per frame; batch zone checks to one + // elementFromPoint read per frame so the cursor still hides when content + // scrolls away under a stationary pointer. + let scrollRafId = 0; + let scrollIdleId = 0; + const SCROLL_RESUME_MS = 150; + + const scheduleScrollResync = () => { + if (scrollRafId) return; + scrollRafId = requestAnimationFrame(() => { + scrollRafId = 0; + resyncAtPointer(); + }); + }; + + const onScrollIdle = () => { + resyncAtPointer(); + }; + + const onScroll = () => { + scheduleScrollResync(); + clearTimeout(scrollIdleId); + scrollIdleId = window.setTimeout(onScrollIdle, SCROLL_RESUME_MS); + }; + + const onHeroCursorReady = () => { + heroBoxReady = true; + if (!hasPointer && lastPointer.x < 0) return; + const x = hasPointer ? mouse.x : lastPointer.x; + const y = hasPointer ? mouse.y : lastPointer.y; + mouse.x = x; + mouse.y = y; + syncFromElement(document.elementFromPoint(x, y)); + snapTrailers(); + applyPresentation(); + }; + + if (lastPointer.x >= 0) { + syncFromPoint(lastPointer.x, lastPointer.y); + } else { + ring.style.transform = "translate3d(-9999px, -9999px, 0)"; + dot.style.transform = "translate3d(-9999px, -9999px, 0)"; + applyPresentation(); + } + window.addEventListener("mousemove", onMove); + window.addEventListener("mhacks:hero-cursor-ready", onHeroCursorReady); + window.addEventListener("scroll", onScroll, { passive: true }); + const unsubScroll = subscribeScroll(onScroll); + document.addEventListener("mouseleave", onLeave); + + return () => { + cancelAnimationFrame(raf); + cancelAnimationFrame(morphRaf); + cancelAnimationFrame(scrollRafId); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mhacks:hero-cursor-ready", onHeroCursorReady); + clearTimeout(scrollIdleId); + window.removeEventListener("scroll", onScroll); + unsubScroll(); + document.removeEventListener("mouseleave", onLeave); + }; + }, []); + + return ( + <> +
    +
    + +
    + + +
    + +
    + You +
    +
    +
    + + ); +} diff --git a/components/landing/DeadlineCountdown.tsx b/components/landing/DeadlineCountdown.tsx new file mode 100644 index 0000000..d51a196 --- /dev/null +++ b/components/landing/DeadlineCountdown.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { getNextDeadline, getTimeParts } from "@/lib/landing/deadlines"; + +/** + * Live countdown pill to the next application deadline (see lib/deadlines.ts + * for the schedule and backend handoff notes). Ticks every second. Renders an + * invisible placeholder until mounted so the server and client markup match, + * and renders nothing once every deadline has passed. + */ +export function DeadlineCountdown({ className }: { className?: string }) { + const [now, setNow] = useState(null); + // Typewriter entrance runs once per deadline phase; the timer digits then + // tick in place without re-animating the label. + const [typed, setTyped] = useState(0); + const deadlineId = useRef(null); + + useEffect(() => { + const id = setInterval(() => setNow(new Date()), 1000); + const initial = window.setTimeout(() => setNow(new Date()), 0); + return () => { + clearInterval(id); + clearTimeout(initial); + }; + }, []); + + const next = now ? getNextDeadline(now) : null; + const phaseId = next?.id; + + useEffect(() => { + if (!phaseId) return; + if (deadlineId.current === phaseId) return; + deadlineId.current = phaseId; + setTyped(0); + + const typer = window.setInterval(() => { + setTyped((count) => { + if (count >= 80) { + clearInterval(typer); + return count; + } + return count + 1; + }); + }, 24); + + return () => clearInterval(typer); + }, [phaseId]); + + // Pre-mount (SSR + first client render): reserve the pill's height so the + // hero lockup doesn't shift when the timer appears. + if (!now) return
    ; + if (!next) return null; + + const t = getTimeParts(new Date(next.date), now); + const pad = (n: number) => String(n).padStart(2, "0"); + const label = `${next.countdownLabel} in ${t.days}d ${pad(t.hours)}h ${pad(t.minutes)}m ${pad(t.seconds)}s`; + + return ( +
    + + {label.slice(0, typed)} + +
    + ); +} diff --git a/components/landing/DotGridReactive.tsx b/components/landing/DotGridReactive.tsx new file mode 100644 index 0000000..a9f3696 --- /dev/null +++ b/components/landing/DotGridReactive.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { prefersReducedMotion } from "@/lib/utils"; + +/** + * Mouse-reactive dot-grid background: a faint lattice of dots that swell and + * brighten around the cursor as it moves over the host section. The cursor + * position and influence ease frame-to-frame, so the swell trails the pointer + * like a soft spotlight. + * + * Perf: canvas-drawn; the rAF loop runs only while the pointer is over the + * host (plus a short ease-out settle), pauses offscreen via + * IntersectionObserver, and reduced-motion gets a single static grid. + */ + +const SPACING = 30; // px between dot centers +const BASE_R = 1.1; // resting dot radius +const MAX_R = 3.6; // radius at the cursor's center +const REACH = 180; // influence radius around the cursor +const BASE_A = 0.14; // resting alpha +const MAX_A = 0.75; // alpha at the cursor's center + +export function DotGridReactive({ + className, + stackPause, +}: { + className?: string; + stackPause?: boolean; +}) { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const reduced = prefersReducedMotion(); + const host: HTMLElement = + canvas.closest("section") ?? canvas.parentElement ?? document.body; + + let dpr = 1; + let w = 0; + let h = 0; + // Target and eased cursor position (canvas space) + influence strength. + let tx = -9999; + let ty = -9999; + let cx = -9999; + let cy = -9999; + let strength = 0; + let targetStrength = 0; + let running = false; + let inView = true; + let rafId = 0; + + const draw = () => { + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + const reach2 = REACH * REACH; + for (let y = SPACING / 2; y < h; y += SPACING) { + for (let x = SPACING / 2; x < w; x += SPACING) { + const dx = x - cx; + const dy = y - cy; + const d2 = dx * dx + dy * dy; + let e = 0; + if (d2 < reach2 && strength > 0.001) { + const t = 1 - Math.sqrt(d2) / REACH; + e = t * t * (3 - 2 * t) * strength; // smoothstep falloff + } + const r = BASE_R + (MAX_R - BASE_R) * e; + ctx.fillStyle = `rgba(239, 233, 212, ${BASE_A + (MAX_A - BASE_A) * e})`; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + } + } + }; + + const isActive = () => + inView && !document.hidden && canvas.offsetParent !== null; + + const loop = () => { + if (!isActive()) { + running = false; + return; + } + cx += (tx - cx) * 0.18; + cy += (ty - cy) * 0.18; + strength += (targetStrength - strength) * 0.12; + draw(); + // Sleep once the pointer has left and the swell has fully decayed. + if ( + targetStrength === 0 && + strength < 0.005 && + Math.abs(tx - cx) < 0.5 && + Math.abs(ty - cy) < 0.5 + ) { + strength = 0; + draw(); + running = false; + return; + } + rafId = requestAnimationFrame(loop); + }; + + const start = () => { + if (running || reduced) return; + running = true; + rafId = requestAnimationFrame(loop); + }; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + dpr = Math.min(window.devicePixelRatio || 1, 2); + w = rect.width; + h = rect.height; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + draw(); // repaint the resting grid (also the reduced-motion frame) + }; + + const onMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + tx = e.clientX - rect.left; + ty = e.clientY - rect.top; + // Snap the eased position on the first entry so the swell doesn't + // fly in from the previous exit point. + if (targetStrength === 0) { + cx = tx; + cy = ty; + } + targetStrength = 1; + start(); + }; + const onLeave = () => { + targetStrength = 0; + start(); // run the decay, then sleep + }; + + const observer = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting; + if (inView) start(); + else { + cancelAnimationFrame(rafId); + running = false; + } + }); + + const onVisibility = () => { + if (!document.hidden && isActive()) start(); + }; + + resize(); + observer.observe(canvas); + window.addEventListener("resize", resize); + document.addEventListener("visibilitychange", onVisibility); + if (!reduced) { + host.addEventListener("mousemove", onMove); + host.addEventListener("mouseleave", onLeave); + } + + return () => { + cancelAnimationFrame(rafId); + observer.disconnect(); + window.removeEventListener("resize", resize); + document.removeEventListener("visibilitychange", onVisibility); + host.removeEventListener("mousemove", onMove); + host.removeEventListener("mouseleave", onLeave); + }; + }, []); + + return ( + + ); +} diff --git a/components/landing/FaqItem.tsx b/components/landing/FaqItem.tsx new file mode 100644 index 0000000..f82dd2a --- /dev/null +++ b/components/landing/FaqItem.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { cn } from "@/lib/utils"; + +interface Props { + value: string; + q: string; + a: string; +} + +export function FaqItem({ value, q, a }: Props) { + return ( + + + {q} + + + + + + + {a} + + + ); +} diff --git a/components/landing/FlowerStamps.tsx b/components/landing/FlowerStamps.tsx new file mode 100644 index 0000000..c11974f --- /dev/null +++ b/components/landing/FlowerStamps.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { motion } from "framer-motion"; + +/** + * Click-to-stamp Michigan wildflowers. Mount inside any `position: relative` + * section: clicks on the host section plant an ASCII flower at the cursor. + * Stamps persist (capped) so the visitor can garden the page. + * + * The ASCII art is a placeholder — when the real flower SVGs arrive, swap the + *
     for an / per variant and keep the same spawn logic.
    + */
    +
    +const FLOWERS = [
    +  String.raw`\ | /
    +-(*)-
    +/ | \
    +  |`,
    +  String.raw` .-.
    +( ~ )
    + '-'
    +  \|`,
    +  String.raw`\\_//
    + \_/
    +  |
    + \|/`,
    +  String.raw` * *
    +* + *
    + * *
    +  |`,
    +  String.raw`  @
    + \|/
    +--+--
    +  |`,
    +];
    +
    +/* Species read differently against parchment vs. moss backgrounds. */
    +const LIGHT_BG_COLORS = ["#3A4A26", "#E07A9A", "#3A5AD4", "#5D6B3A", "#C08457"];
    +const DARK_BG_COLORS = ["#EFE9D4", "#E8D35A", "#E07A9A", "#C9E07A", "#7A93C9"];
    +
    +const MAX_STAMPS = 60;
    +
    +interface Stamp {
    +  id: number;
    +  x: number;
    +  y: number;
    +  art: string;
    +  color: string;
    +  rot: number;
    +  scale: number;
    +}
    +
    +let nextId = 1;
    +
    +export function FlowerStamps({ tone = "light" }: { tone?: "light" | "dark" }) {
    +  const overlayRef = useRef(null);
    +  const [stamps, setStamps] = useState([]);
    +
    +  useEffect(() => {
    +    const overlay = overlayRef.current;
    +    const host = overlay?.parentElement;
    +    if (!host) return;
    +
    +    // Pointer-based tap detection instead of `click`: mobile browsers don't
    +    // always synthesize clicks for taps on non-interactive areas, and this
    +    // treats mouse and touch identically while ignoring scroll gestures.
    +    let down: { x: number; y: number; t: number; id: number } | null = null;
    +
    +    const onDown = (e: PointerEvent) => {
    +      if (!e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) return;
    +      down = {
    +        x: e.clientX,
    +        y: e.clientY,
    +        t: performance.now(),
    +        id: e.pointerId,
    +      };
    +    };
    +
    +    const onUp = (e: PointerEvent) => {
    +      if (!down || e.pointerId !== down.id) return;
    +      const moved = Math.hypot(e.clientX - down.x, e.clientY - down.y);
    +      const held = performance.now() - down.t;
    +      down = null;
    +      // A scroll/drag or long-press isn't a stamp request.
    +      if (moved > 12 || held > 600) return;
    +      // Don't stamp over real interactions (nav, accordion toggles, links…).
    +      const target = e.target as HTMLElement | null;
    +      if (
    +        target?.closest("a, button, [role='button'], input, textarea, select")
    +      )
    +        return;
    +
    +      const r = host.getBoundingClientRect();
    +      const colors = tone === "dark" ? DARK_BG_COLORS : LIGHT_BG_COLORS;
    +      setStamps((s) => [
    +        ...s.slice(-(MAX_STAMPS - 1)),
    +        {
    +          id: nextId++,
    +          x: e.clientX - r.left,
    +          y: e.clientY - r.top,
    +          art: FLOWERS[Math.floor(Math.random() * FLOWERS.length)],
    +          color: colors[Math.floor(Math.random() * colors.length)],
    +          rot: (Math.random() - 0.5) * 30,
    +          scale: 0.8 + Math.random() * 0.5,
    +        },
    +      ]);
    +    };
    +
    +    host.addEventListener("pointerdown", onDown);
    +    host.addEventListener("pointerup", onUp);
    +    return () => {
    +      host.removeEventListener("pointerdown", onDown);
    +      host.removeEventListener("pointerup", onUp);
    +    };
    +  }, [tone]);
    +
    +  return (
    +    
    + {stamps.map((st) => ( + + {st.art} + + ))} +
    + ); +} diff --git a/components/landing/HeroReveal.tsx b/components/landing/HeroReveal.tsx new file mode 100644 index 0000000..a85f6fc --- /dev/null +++ b/components/landing/HeroReveal.tsx @@ -0,0 +1,376 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion, type MotionValue } from "framer-motion"; +import Image from "next/image"; +import { cn, isTouchDevice, prefersReducedMotion } from "@/lib/utils"; + +interface HeroImageProps { + src: string; + alt?: string; + className?: string; + priority?: boolean; + hidden?: boolean; + quality?: number; + sizes?: string; + fetchPriority?: "high" | "low" | "auto"; + loading?: "eager" | "lazy"; + unoptimized?: boolean; + onLoad?: () => void; +} + +/** + * Point next/image at the largest source and let the optimizer derive the + * responsive set — this replaces a hand-rolled srcSet whose width descriptors + * described the files' heights (these photos are portrait), so the 3840w + * candidate was really 2876px wide. + */ +function HeroImage({ + src, + alt = "", + className, + priority, + hidden, + quality = 40, + sizes = "100vw", + fetchPriority, + loading, + unoptimized, + onLoad, +}: HeroImageProps) { + return ( + {alt} + ); +} + +interface Props { + src?: string; + radius?: number; + /** True while StackedPages has buried the hero — defers sharp load and pauses reveal. */ + paused?: boolean; + tiltX?: MotionValue; + tiltY?: MotionValue; + shiftX?: MotionValue; + shiftY?: MotionValue; + tiltScale?: number; +} + +const DOT_GRID = [ + "radial-gradient(circle, rgba(255,255,255,0.3) 0.5px, transparent 0.5px)", + "radial-gradient(circle, rgba(255,255,255,0.16) 0.5px, transparent 0.5px)", +].join(", "); + +const BLUR_SRC: Record = { + "/hero/hero-clean-3840.png": "/hero/hero-clean-blur.webp", + "/hero/hero-flower.jpg": "/hero/hero-flower-blur.webp", + "/hero/hero-cloud.jpg": "/hero/hero-cloud-blur.webp", +}; + +function blurPlateFor(src: string) { + return BLUR_SRC[src] ?? src; +} + +/** + * Dark blurred meadow with a soft "flashlight" circle that reveals the sharp + * photo under the cursor. + * + * Perf: blur is a static pre-rendered plate (no CSS filter:blur). 3D tilt + * applies to the sharp reveal window so pointer movement never repaints the + * blurred layer. The reveal moves via transforms only. + */ +export function HeroReveal({ + src = "/hero/hero-clean-3840.png", + radius = 280, + paused = false, + tiltX, + tiltY, + shiftX, + shiftY, + tiltScale = 1.06, +}: Props) { + const stageRef = useRef(null); + const windowRef = useRef(null); + const innerRef = useRef(null); + const [hasActivated, setHasActivated] = useState(false); + const [blurLoadedSrc, setBlurLoadedSrc] = useState(null); + const blurSrc = blurPlateFor(src); + const sharpReady = blurLoadedSrc === src; + + if (!paused && !hasActivated) { + setHasActivated(true); + } + + useEffect(() => { + if (!hasActivated) return; + const id = requestAnimationFrame(() => { + const img = stageRef.current?.querySelector( + "[data-hero-blur] img", + ); + if (img?.complete) setBlurLoadedSrc(src); + }); + return () => cancelAnimationFrame(id); + }, [src, hasActivated]); + + const innerR = radius * 0.68; + const outerR = radius * 1.55; + const winSize = Math.ceil(outerR * 2); + const half = winSize / 2; + + useEffect(() => { + const stage = stageRef.current; + const win = windowRef.current; + const inner = innerRef.current; + if (!stage || !win || !inner || paused) return; + + if (prefersReducedMotion() || isTouchDevice()) { + win.style.display = "none"; + return; + } + + let rafId = 0; + let running = false; + let inView = true; + let hasPointer = false; + let targetX = 0; + let targetY = 0; + let currentX = 0; + let currentY = 0; + let targetO = 0; + let currentO = 0; + let pendingX = 0; + let pendingY = 0; + let hasPending = false; + + const sizeInner = () => { + inner.style.width = `${stage.offsetWidth}px`; + inner.style.height = `${stage.offsetHeight}px`; + }; + + const apply = () => { + win.style.transform = `translate3d(${currentX - half}px, ${currentY - half}px, 0)`; + inner.style.transform = `translate3d(${half - currentX}px, ${half - currentY}px, 0)`; + win.style.opacity = currentO.toFixed(3); + }; + + const consumePointer = () => { + if (!hasPending) return; + hasPending = false; + const r = stage.getBoundingClientRect(); + const sx = stage.offsetWidth / r.width; + const sy = stage.offsetHeight / r.height; + targetX = (pendingX - r.left) * sx; + targetY = (pendingY - r.top) * sy; + if (!hasPointer) { + hasPointer = true; + currentX = targetX; + currentY = targetY; + } + }; + + const loop = () => { + consumePointer(); + currentX += (targetX - currentX) * 0.2; + currentY += (targetY - currentY) * 0.2; + currentO += (targetO - currentO) * 0.16; + + const settled = + Math.abs(targetX - currentX) < 0.1 && + Math.abs(targetY - currentY) < 0.1 && + Math.abs(targetO - currentO) < 0.005; + + if (settled) { + currentX = targetX; + currentY = targetY; + currentO = targetO; + apply(); + running = false; + return; + } + + apply(); + rafId = requestAnimationFrame(loop); + }; + + const start = () => { + if (running || !inView) return; + running = true; + rafId = requestAnimationFrame(loop); + }; + + const onMove = (e: MouseEvent) => { + pendingX = e.clientX; + pendingY = e.clientY; + hasPending = true; + targetO = 1; + start(); + }; + const onLeave = () => { + targetO = 0; + start(); + }; + const onEnter = () => { + targetO = 1; + start(); + }; + + const resizeObserver = new ResizeObserver(sizeInner); + const intersectionObserver = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting; + if (!inView) { + cancelAnimationFrame(rafId); + running = false; + } else { + start(); + } + }); + + sizeInner(); + apply(); + resizeObserver.observe(stage); + intersectionObserver.observe(stage); + stage.addEventListener("mousemove", onMove); + stage.addEventListener("mouseleave", onLeave); + stage.addEventListener("mouseenter", onEnter); + + return () => { + cancelAnimationFrame(rafId); + resizeObserver.disconnect(); + intersectionObserver.disconnect(); + stage.removeEventListener("mousemove", onMove); + stage.removeEventListener("mouseleave", onLeave); + stage.removeEventListener("mouseenter", onEnter); + }; + }, [half, paused]); + + const maskGradient = `radial-gradient(circle at center, #000 0px, #000 ${innerR}px, transparent ${outerR}px)`; + const onBlurLoaded = () => setBlurLoadedSrc(src); + + return ( +
    + {/* Pre-blurred plate — stays fixed while StackedPages pins the hero. */} +
    + {hasActivated ? ( +
    +
    + + + + +
    + +
    + +
    + +
    +
    + ) : null} +
    + + {/* Sharp reveal — 3D tilt + cursor flashlight */} +
    + +
    +
    + {hasActivated && !paused && sharpReady ? ( + + + + + + ) : null} +
    +
    +
    +
    +
    + ); +} diff --git a/components/landing/ImageCarousel.tsx b/components/landing/ImageCarousel.tsx new file mode 100644 index 0000000..22196bf --- /dev/null +++ b/components/landing/ImageCarousel.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { motion } from "framer-motion"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; + +/* Raw public paths, not asset(): next/image builds its own /_next/image URLs + and Next's basePath already rewrites those, unlike bare /CSS URLs. */ +const IMAGES = [ + "/about/about-01.jpg", + "/about/about-02.jpg", + "/about/about-03.jpg", + "/about/about-04.jpg", + "/about/about-05.jpg", + "/about/about-06.jpg", + "/about/about-07.jpg", + "/about/about-08.jpg", +]; + +interface Props { + images?: string[]; + className?: string; + /** StackedPages burial — stops the marquee loop. */ + paused?: boolean; +} + +/** + * Continuous right-to-left photo marquee. The strip is rendered twice and + * translated by half its width on a linear loop, so it scrolls seamlessly. + * + * The slot is a fixed 300x200 (360x240 at md), so `sizes` pins the candidate + * width instead of letting the browser assume full-viewport — the sources are + * 1600px wide originals and would otherwise be served far larger than the + * frame they land in. Quality is kept low — motion hides compression artifacts. + * Only viewport width + two buffer slots are mounted so the loop does not fetch + * every photo up front. + */ +const GAP_PX = 20; // gap-5 +const MOBILE_IMAGE_WIDTH = 300; +const DESKTOP_IMAGE_WIDTH = 360; +const DESKTOP_BREAKPOINT = 768; +const BUFFER_IMAGES = 2; + +function slotWidthForViewport(viewportWidth: number) { + const imageWidth = + viewportWidth >= DESKTOP_BREAKPOINT + ? DESKTOP_IMAGE_WIDTH + : MOBILE_IMAGE_WIDTH; + return imageWidth + GAP_PX; +} + +function visibleImageCount(containerWidth: number) { + const slots = Math.ceil( + containerWidth / slotWidthForViewport(containerWidth), + ); + return Math.min(Math.max(slots + BUFFER_IMAGES, 1), IMAGES.length); +} + +export function ImageCarousel({ + images = IMAGES, + className, + paused = false, +}: Props) { + const containerRef = useRef(null); + // Keep the initial count SSR-stable; ResizeObserver below syncs to the real + // container width after hydration. + const [visibleCount, setVisibleCount] = useState(() => + visibleImageCount(MOBILE_IMAGE_WIDTH), + ); + + useEffect(() => { + const node = containerRef.current; + if (!node) return; + + const update = () => { + setVisibleCount(visibleImageCount(node.clientWidth)); + }; + + update(); + const observer = new ResizeObserver(update); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + const visibleImages = images.slice(0, visibleCount); + + return ( +
    + + {[0, 1].map((copy) => ( +
    + {visibleImages.map((src, i) => ( + {`MHacks + ))} +
    + ))} +
    +
    + ); +} diff --git a/components/landing/LiquidGlassFilter.tsx b/components/landing/LiquidGlassFilter.tsx new file mode 100644 index 0000000..4bda224 --- /dev/null +++ b/components/landing/LiquidGlassFilter.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Shared SVG displacement filter that powers the `.liquid-glass` class. + * The filter refracts whatever sits behind the element (feTurbulence noise + * driving a displacement map), which is what sells the "liquid" look. + * + * SVG filters inside `backdrop-filter` only render in Chromium, and Safari + * even parses the value while drawing nothing — so instead of @supports we + * gate the refraction on an actual Chromium check and tag with + * `.lg-refract`. Everyone else gets the frosted-glass fallback. + */ +export function LiquidGlassFilter() { + useEffect(() => { + const isChromium = + typeof (window as { chrome?: unknown }).chrome !== "undefined" && + CSS.supports("backdrop-filter", "url(#liquid-glass-distortion)"); + if (isChromium) { + document.documentElement.classList.add("lg-refract"); + return () => document.documentElement.classList.remove("lg-refract"); + } + }, []); + + return ( + + + + + + + + + + ); +} diff --git a/components/landing/Logo.tsx b/components/landing/Logo.tsx new file mode 100644 index 0000000..2bb8f2e --- /dev/null +++ b/components/landing/Logo.tsx @@ -0,0 +1,70 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { + handleMarketingNavClick, + isMarketingHome, + resolveMarketingHref, +} from "@/lib/landing/nav"; +import { asset } from "@/lib/landing/asset"; + +interface Props { + size?: number; + className?: string; + imageClassName?: string; + href?: string; + priority?: boolean; +} + +export function Logo({ + size = 44, + className, + imageClassName, + href = "#top", + priority = false, +}: Props) { + const onHome = isMarketingHome(usePathname()); + const img = ( + MHacks + ); + + if (!href) { + return ( + + {img} + + ); + } + + return ( + handleMarketingNavClick(href, onHome, e)} + className={cn( + "inline-flex shrink-0 items-center justify-center", + className, + )} + style={{ width: size, height: size }} + > + {img} + + ); +} diff --git a/components/landing/MlhBadge.tsx b/components/landing/MlhBadge.tsx new file mode 100644 index 0000000..719b46e --- /dev/null +++ b/components/landing/MlhBadge.tsx @@ -0,0 +1,28 @@ +import Image from "next/image"; + +/** + * MLH trust badge — rests at the top of the hero (absolute, not fixed), a + * little to the right of the MHacks logo. It scrolls away with the page: + * once the next sheet slides over the hero, the badge is gone. + */ +export function MlhBadge() { + return ( + + Major League Hacking 2026 Hackathon Season + + ); +} diff --git a/components/landing/PillNav.tsx b/components/landing/PillNav.tsx new file mode 100644 index 0000000..f8870b6 --- /dev/null +++ b/components/landing/PillNav.tsx @@ -0,0 +1,73 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { motion } from "framer-motion"; +import { + handleMarketingNavClick, + isMarketingHome, + MARKETING_NAV_ITEMS, + resolveMarketingHref, + type MarketingNavItem, +} from "@/lib/landing/nav"; +import { cn } from "@/lib/utils"; + +interface Props { + items?: MarketingNavItem[]; + className?: string; + variant?: "light" | "dark"; + /** Inline overrides (e.g. adaptive nav tone); wins over variant styling. */ + style?: React.CSSProperties; +} + +export function PillNav({ + items = MARKETING_NAV_ITEMS, + className, + variant = "light", + style, +}: Props) { + const onHome = isMarketingHome(usePathname()); + return ( + + {items.map((it) => ( + handleMarketingNavClick(it.href, onHome, e)} + className={cn( + "font-display px-4 py-1.5 text-[15px] font-medium leading-none rounded-pill transition-all duration-300", + it.cta + ? variant === "light" + ? "bg-moss-800 text-cream shadow-e-2 hover:bg-moss-900" + : "bg-cream text-moss-900 hover:bg-white" + : variant === "light" + ? "hover:bg-moss-700/10 hover:text-moss-800" + : "hover:bg-cream/15", + )} + > + {it.label} + + ))} + + ); +} diff --git a/components/landing/PillTabGroup.tsx b/components/landing/PillTabGroup.tsx new file mode 100644 index 0000000..41e748d --- /dev/null +++ b/components/landing/PillTabGroup.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; + +interface TabOption { + value: T; + label: string; +} + +interface Props { + value: T; + onValueChange: (value: T) => void; + options: TabOption[]; + ariaLabel: string; + variant?: "spread" | "segmented"; + className?: string; +} + +const triggerBase = + "font-mono uppercase tracking-[0.15em] transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-moss-700"; + +/** Accessible pill tabs styled for the marketing site. */ +export function PillTabGroup({ + value, + onValueChange, + options, + ariaLabel, + variant = "spread", + className, +}: Props) { + return ( + onValueChange(next as T)} + className={className} + > + + {options.map((option) => ( + + {option.label} + + ))} + + + ); +} diff --git a/components/landing/PolaroidDecor.tsx b/components/landing/PolaroidDecor.tsx new file mode 100644 index 0000000..ae26c13 --- /dev/null +++ b/components/landing/PolaroidDecor.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { motion } from "framer-motion"; +import Image from "next/image"; +import { AsciiCanvas } from "@/components/landing/AsciiCanvas"; + +interface Props { + src: string; + caption: string; + side: "left" | "right"; + initialRotate: number; + restRotate: number; + hoverRotate: number; + delay?: number; + shadow?: string; +} + +/** Decorative polaroid with digitized ASCII overlay — used in About. */ +export function PolaroidDecor({ + src, + caption, + side, + initialRotate, + restRotate, + hoverRotate, + delay = 0.25, + shadow = "0 26px 60px rgba(29,36,18,0.3)", +}: Props) { + const position = + side === "right" + ? "absolute right-[-70px] top-[10%] hidden lg:block" + : "absolute left-[-70px] top-[12%] hidden lg:block"; + + return ( + + +
    + {/* The frame is 376px wide (400 minus padding) and the photo is + blurred to 3px on top of that, so a 400px candidate is already + more detail than survives the treatment. */} + + +
    +
    + {caption} +
    +
    +
    + ); +} diff --git a/components/landing/SiteHeader.tsx b/components/landing/SiteHeader.tsx new file mode 100644 index 0000000..143c7f8 --- /dev/null +++ b/components/landing/SiteHeader.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { motion } from "framer-motion"; +import { usePathname } from "next/navigation"; +import { Logo } from "@/components/landing/Logo"; +import { PillNav } from "@/components/landing/PillNav"; +import { CtaButton } from "@/components/landing/cta-button"; +import { + handleMarketingNavClick, + isMarketingHome, + resolveMarketingHref, +} from "@/lib/landing/nav"; +import { useScrollDirection } from "@/lib/landing/useScrollDirection"; +import { useNavTheme } from "@/lib/landing/useNavTheme"; +import { cn } from "@/lib/utils"; + +export function SiteHeader() { + const visible = useScrollDirection({ threshold: 6, minScroll: 80 }); + // On the hero: transparent header, glass pills, cream text. Past it: one + // frosted bar across the whole nav, pills removed, dark text + dark logo. + const frosted = useNavTheme() !== "hero"; + // On subpages (/how-to-mcp) the CTAs route back to the home page's + // sections; raw hrefs need the deploy base path prefixed by hand. + const onHome = isMarketingHome(usePathname()); + + return ( + +
    + {/* Logo - left; always white, regardless of zone */} +
    + +
    + + {/* Nav - centered; pill container dissolves on the frosted bar. + Hidden below lg — under ~1024px the centered pill collides with + the logo/badge and action buttons, and the footer nav covers + in-page links on small screens. */} +
    + +
    + + {/* Sponsor us + Apply - right; canonical CTA pills in both nav states. + Mobile keeps Apply in the bar (persistent CTA); Sponsor us joins + at md+ and also lives in the hero stack on small screens. */} +
    +
    + handleMarketingNavClick("#sponsors", onHome, e)} + > + Sponsor us + +
    + + Apply + +
    +
    +
    + ); +} diff --git a/components/landing/SmoothScroll.tsx b/components/landing/SmoothScroll.tsx new file mode 100644 index 0000000..7c085bf --- /dev/null +++ b/components/landing/SmoothScroll.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { useEffect } from "react"; +import Lenis from "lenis"; +import { prefersReducedMotion } from "@/lib/utils"; +import { registerLenis } from "@/lib/landing/scroll"; + +export function SmoothScroll({ children }: { children: React.ReactNode }) { + useEffect(() => { + if (typeof window !== "undefined") history.scrollRestoration = "manual"; + if (prefersReducedMotion()) return; + const lenis = new Lenis({ + duration: 1.15, + easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), + smoothWheel: true, + }); + registerLenis(lenis); + + let raf = 0; + const loop = (time: number) => { + lenis.raf(time); + raf = requestAnimationFrame(loop); + }; + raf = requestAnimationFrame(loop); + + return () => { + cancelAnimationFrame(raf); + registerLenis(null); + lenis.destroy(); + }; + }, []); + + return <>{children}; +} diff --git a/components/landing/SpeciesLabel.tsx b/components/landing/SpeciesLabel.tsx new file mode 100644 index 0000000..f92f59a --- /dev/null +++ b/components/landing/SpeciesLabel.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { motion } from "framer-motion"; + +/** + * Sticker-style species tag for the botanical garlands. The site's flowers + * are a Michigan-flora highlight, so each garland gets an identifying label + * tucked into a dip in its vine (positioned by the parent): common name, + * scientific name, and what kind of plant it is plus whether it's actually + * native to Michigan. + */ +type Tone = "native" | "introduced" | "invasive"; + +const TONE_COLOR: Record = { + native: "text-moss-500", + introduced: "text-[#8A6D2B]", + invasive: "text-[#A2542C]", +}; + +export function SpeciesLabel({ + name, + species, + status, + tone = "native", + rotate = -3, + className = "", +}: { + name: string; + /** Scientific (Latin) name, shown in italics under the common name. */ + species: string; + /** Plant type + provenance, e.g. "native wildflower". */ + status: string; + tone?: Tone; + rotate?: number; + className?: string; +}) { + return ( + + + {name} + + + {species} + + + {status} + + + ); +} diff --git a/components/landing/SplitReveal.tsx b/components/landing/SplitReveal.tsx new file mode 100644 index 0000000..67af7fe --- /dev/null +++ b/components/landing/SplitReveal.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { motion, useReducedMotion, Variants } from "framer-motion"; +import { ElementType } from "react"; +import { cn } from "@/lib/utils"; + +interface Props { + children: string; + as?: ElementType; + className?: string; + delay?: number; + stagger?: number; + by?: "word" | "line"; + once?: boolean; + /** Animate on mount instead of waiting for scroll into view. Use for above-the-fold hero copy. */ + immediate?: boolean; + /** Style the first N words with `leadClassName` — e.g. a sans lead-in + before the serif accent words in section headings. */ + leadWords?: number; + leadClassName?: string; +} + +/** + * Line/word reveal with clip-path masks. Each token slides up from below + * a hidden overflow line. Pair with hero display copy for editorial motion. + */ +export function SplitReveal({ + children, + as: Tag = "span", + className, + delay = 0, + stagger = 0.055, + by = "word", + once = true, + immediate = false, + leadWords = 0, + leadClassName, +}: Props) { + const reduced = useReducedMotion(); + const tokens = by === "word" ? children.split(/(\s+)/) : children.split("\n"); + + const container: Variants = { + initial: {}, + animate: { + transition: { + staggerChildren: reduced ? 0 : stagger, + delayChildren: reduced ? 0 : delay, + }, + }, + }; + + const item: Variants = { + initial: { y: reduced ? "0%" : "115%" }, + animate: { + y: "0%", + transition: { duration: reduced ? 0 : 0.9, ease: [0.2, 0.8, 0.2, 1] }, + }, + }; + + const tokenMeta = tokens.map((token, i) => { + if (/^\s+$/.test(token)) { + return { token, i, isLead: false, isSpace: true as const }; + } + const wordIndex = tokens.slice(0, i).filter((t) => !/^\s+$/.test(t)).length; + return { + token, + i, + isLead: by === "word" && wordIndex < leadWords, + isSpace: false as const, + }; + }); + + return ( + + + {tokenMeta.map(({ token, i, isLead, isSpace }) => { + if (isSpace) return {token}; + return ( + + + {token} + + + ); + })} + + + ); +} diff --git a/components/landing/StackedPages.tsx b/components/landing/StackedPages.tsx new file mode 100644 index 0000000..5018f64 --- /dev/null +++ b/components/landing/StackedPages.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useEffect } from "react"; +import { scrollToHash, subscribeScroll } from "@/lib/landing/scroll"; +import { normalizeHash } from "@/lib/landing/nav"; + +/** + * Turns the top-level sections into a stacked-pages scroll: each sheet + * scrolls in normally, freezes once its last screenful is in view, and the + * next sheet slides up over it — a page laid on top rather than a linear + * scroll. + * + * Every stacking sheet must be at least one viewport tall (min-h-screen) so + * that once it has slid into place it covers the previous sheet completely. + * + * How: every pinning sheet becomes `position: sticky` with + * `top: viewport − height`. Sheets taller than the viewport scroll through + * their content first and pin at their last screenful; shorter sheets pin as + * soon as they're fully visible. The sections' existing ascending z-indexes + * and opaque rounded-top backgrounds do the actual covering, so this adds no + * layout change — only the pinning. + * + * Perf: pinned sheets stay in the viewport geometrically forever, so without + * intervention the compositor keeps every sheet (plus its blurs, canvases, + * and glass layers) resident on the GPU for the whole scroll — deep in the + * page that exhausts texture memory and Chromium starts dropping surfaces + * (flashing glass/nav/sections). So once a sheet is fully buried it's + * flipped to `visibility: hidden` (evicted from compositing), and anything + * inside it marked [data-stack-pause] is display-toggled when out-of-flow + * (absolute/fixed/canvas) so loops stop without shrinking in-flow layout Visibility and canvas + * pause/resume follow document scroll position (each sheet restores only once + * you've scrolled back up into its zone), and updates are rAF-throttled and + * driven by both native scroll and Lenis. + */ +export function StackedPages() { + useEffect(() => { + const main = document.querySelector("main"); + if (!main) return; + + const sheets = Array.from(main.children).filter( + (el): el is HTMLElement => el instanceof HTMLElement, + ); + if (sheets.length < 3) return; + // The footer (last sheet) is a normal scroll reveal, not a page laid on + // top — so it never pins, and neither does the sheet before it (that one + // must scroll off naturally with the footer trailing it in flow). Faq is + // still buried once the footer takes over so its animations stop. + const pinned = sheets.slice(0, -2); + // Footer never buries; Faq does once the footer scrolls in — otherwise + // its violets and scroll hooks keep running while you're at the bottom. + const buriable = sheets.slice(0, -1); + + // Pin each sheet at its last screenful — top equals vh − height so sticky + // engages with no jump. Rounded tops sit above the viewport naturally when + // h > vh; the next sheet's negative margin covers the handoff. + const layout = () => { + const vh = window.innerHeight; + for (const el of pinned) { + el.style.position = "sticky"; + el.style.top = `${Math.round(vh - el.offsetHeight)}px`; + } + }; + + // Pause markers per sheet, resolved once up front. + const pauseMarks = buriable.map((el) => + Array.from(el.querySelectorAll("[data-stack-pause]")), + ); + const pausedFlags = buriable.map(() => false); + + // Flow offset for a sheet — sticky rects lie; offsetTop chain doesn't. + const flowTop = (el: HTMLElement) => { + let top = 0; + for ( + let n: HTMLElement | null = el; + n; + n = n.offsetParent as HTMLElement | null + ) { + top += n.offsetTop; + } + return top; + }; + + // Negative top margin on the successor (= how far it overlaps the sheet + // behind). Wait that long past the flow handoff before hiding the old + // sheet — otherwise visibility:hidden fires while the new sheet is still + // rising and the underlay flashes through. + const overlapOf = (el: HTMLElement) => { + const mt = parseFloat(getComputedStyle(el).marginTop); + return mt < 0 ? -mt : 0; + }; + + const buryAt = (i: number) => { + const next = sheets[i + 1]; + return flowTop(next) + overlapOf(next); + }; + // Scroll-back restores slightly earlier than bury so sheets don't flicker + // visible/invisible when hovering near a handoff line. + const RESTORE_LAG = 40; + + let ticking = false; + + const canDisplayToggle = (n: HTMLElement) => { + const pos = getComputedStyle(n).position; + return n.tagName === "CANVAS" || pos === "absolute" || pos === "fixed"; + }; + + const pauseSheet = (i: number) => { + pausedFlags[i] = true; + buriable[i].style.visibility = "hidden"; + pauseMarks[i].forEach((n) => { + // display:none only on out-of-flow nodes — toggling in-flow garlands + // and carousels shrinks buried sticky sheets and jumps scroll position. + if (canDisplayToggle(n)) n.style.display = "none"; + }); + }; + + const restoreSheet = (i: number) => { + pausedFlags[i] = false; + buriable[i].style.visibility = ""; + pauseMarks[i].forEach((n) => { + if (canDisplayToggle(n)) n.style.display = ""; + }); + }; + + const update = () => { + ticking = false; + const y = window.scrollY; + for (let i = 0; i < buriable.length; i++) { + const threshold = buryAt(i); + if (!pausedFlags[i] && y >= threshold) { + pauseSheet(i); + } else if (pausedFlags[i] && y < threshold - RESTORE_LAG) { + restoreSheet(i); + } + } + }; + + const onScroll = () => { + if (!ticking) { + ticking = true; + requestAnimationFrame(update); + } + }; + + // Sheet heights move (accordion opens, images load) — re-pin when they do. + const ro = new ResizeObserver(layout); + pinned.forEach((el) => ro.observe(el)); + layout(); + onScroll(); + // Arriving with a #hash (e.g. /#about): the native anchor jump lands + // wrong because pinned sheets report sticky rects, so re-target once the + // sheets are pinned and re-sync nav state when the scroll settles. + if (window.location.hash) { + const hash = normalizeHash(window.location.hash); + if (hash !== window.location.hash) history.replaceState(null, "", hash); + requestAnimationFrame(() => scrollToHash(hash)); + } + window.addEventListener("resize", layout); + window.addEventListener("scroll", onScroll, { passive: true }); + const unsubLenis = subscribeScroll(onScroll); + + return () => { + ro.disconnect(); + window.removeEventListener("resize", layout); + window.removeEventListener("scroll", onScroll); + unsubLenis(); + for (const el of pinned) { + el.style.position = ""; + el.style.top = ""; + } + for (const el of buriable) { + el.style.visibility = ""; + } + pauseMarks.flat().forEach((n) => (n.style.display = "")); + }; + }, []); + + return null; +} diff --git a/components/landing/StackedSheet.tsx b/components/landing/StackedSheet.tsx new file mode 100644 index 0000000..a6a3c2f --- /dev/null +++ b/components/landing/StackedSheet.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { forwardRef, type ComponentPropsWithoutRef } from "react"; +import { cn } from "@/lib/utils"; + +export const stackedSheetClassName = + "relative -mt-14 md:-mt-20 overflow-hidden rounded-t-[40px] md:rounded-t-[48px]"; + +/** Shared shell for stacked landing sections (rounded top, negative overlap). */ +export const StackedSheet = forwardRef< + HTMLElement, + ComponentPropsWithoutRef<"section"> & { id: string } +>(function StackedSheet({ id, className, children, ...props }, ref) { + return ( +
    + {children} +
    + ); +}); diff --git a/components/landing/Typewriter.tsx b/components/landing/Typewriter.tsx new file mode 100644 index 0000000..5f9cb9d --- /dev/null +++ b/components/landing/Typewriter.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { prefersReducedMotion } from "@/lib/utils"; + +interface Props { + text: string; + /** ms before typing starts */ + delay?: number; + /** ms per character */ + speed?: number; + className?: string; + showCaret?: boolean; + /** After the first reveal, show text updates immediately (for live labels). */ + freezeAfterComplete?: boolean; + /** Fires once when the full string is visible. */ + onComplete?: () => void; +} + +/** + * Types text out character by character while holding the final layout: the + * not-yet-typed remainder is rendered invisibly, so the block never moves or + * re-centers as it types. A caret blinks at the type position until done. + */ +export function Typewriter({ + text, + delay = 400, + speed = 85, + className, + showCaret = true, + freezeAfterComplete = false, + onComplete, +}: Props) { + const reducedMotion = prefersReducedMotion(); + const [n, setN] = useState(0); + const completed = useRef(false); + const onCompleteRef = useRef(onComplete); + + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); + + const visible = reducedMotion ? text.length : n; + const done = visible >= text.length; + + useEffect(() => { + if (reducedMotion) { + completed.current = true; + onCompleteRef.current?.(); + return; + } + + let tickId = 0; + let cancelled = false; + + if (freezeAfterComplete && completed.current) { + tickId = window.setTimeout(() => { + if (!cancelled) setN(text.length); + }, 0); + return () => { + cancelled = true; + clearTimeout(tickId); + }; + } + + completed.current = false; + let i = 0; + + const tick = () => { + if (cancelled) return; + i++; + setN(i); + if (i < text.length) { + tickId = window.setTimeout(tick, speed); + } else { + completed.current = true; + onCompleteRef.current?.(); + } + }; + + tickId = window.setTimeout(() => { + if (cancelled) return; + setN(0); + tickId = window.setTimeout(tick, delay); + }, 0); + + return () => { + cancelled = true; + clearTimeout(tickId); + }; + }, [text, delay, speed, reducedMotion, freezeAfterComplete]); + + return ( + + {text.slice(0, visible)} + + {!done && showCaret && ( + + )} + {text.slice(visible)} + + + ); +} diff --git a/components/landing/cta-button.tsx b/components/landing/cta-button.tsx new file mode 100644 index 0000000..f4cb1c9 --- /dev/null +++ b/components/landing/cta-button.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { motion, type HTMLMotionProps } from "framer-motion"; +import { forwardRef } from "react"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { VariantProps } from "class-variance-authority"; + +type CtaVariant = "cta" | "primary" | "parchment" | "cream" | "glass"; +type CtaSize = "sm" | "md" | "lg"; + +const variantMap: Record< + CtaVariant, + VariantProps["variant"] +> = { + cta: "cta", + primary: "cta", + parchment: "parchment", + cream: "cream", + glass: "glass", +}; + +const sizeMap: Record["size"]> = { + sm: "landing-sm", + md: "md", + lg: "landing-lg", +}; + +interface CtaButtonProps extends Omit, "ref"> { + variant?: CtaVariant; + size?: CtaSize; + href?: string; +} + +export const CtaButton = forwardRef( + function CtaButton( + { + variant = "primary", + size = "md", + className, + children, + href = "#", + ...rest + }, + ref, + ) { + return ( + + {children} + + ); + }, +); diff --git a/components/landing/marketing-shell.tsx b/components/landing/marketing-shell.tsx new file mode 100644 index 0000000..5995c95 --- /dev/null +++ b/components/landing/marketing-shell.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { Cursor } from "@/components/landing/Cursor"; +import { LiquidGlassFilter } from "@/components/landing/LiquidGlassFilter"; +import { SiteHeader } from "@/components/landing/SiteHeader"; +import { SmoothScroll } from "@/components/landing/SmoothScroll"; + +export function MarketingShell({ children }: { children: React.ReactNode }) { + return ( +
    + + + + {children} + + +
    + ); +} diff --git a/components/landing/sections/About.tsx b/components/landing/sections/About.tsx new file mode 100644 index 0000000..4fd551e --- /dev/null +++ b/components/landing/sections/About.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { motion, useScroll, useTransform } from "framer-motion"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import { PolaroidDecor } from "@/components/landing/PolaroidDecor"; +import { SplitReveal } from "@/components/landing/SplitReveal"; +import { ImageCarousel } from "@/components/landing/ImageCarousel"; +import { FlowerStamps } from "@/components/landing/FlowerStamps"; +import { AsciiBloom } from "@/components/landing/AsciiBloom"; +import { useStackPaused } from "@/lib/landing/useStackPaused"; + +/* Keeps the blossom a single Image in the DOM rather than nesting it in a + motion wrapper, so the existing layout classes still apply directly. */ +const MotionImage = motion.create(Image); + +export function About() { + const ref = useRef(null); + const paused = useStackPaused(ref); + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start end", "start start"], + }); + // Starts exactly equal to the -mt-28 pull-up (112px), so at page load the + // sheet's top sits flush with the hero's bottom edge — the hero owns the + // full first viewport and About only appears once you scroll. The -mt pulls + // the section 112px into the viewport before any scrolling, so progress at + // load is 112/viewportHeight, not 0 — anchor the ramp there. + const [loadProgress, setLoadProgress] = useState(0); + useEffect(() => { + const update = () => setLoadProgress(112 / window.innerHeight); + update(); + window.addEventListener("resize", update); + return () => window.removeEventListener("resize", update); + }, []); + const tabY = useTransform(scrollYProgress, [loadProgress, 1], [112, 0]); + + return ( +
    + + + + + + + + {/* Centered heading + copy filling the viewport */} +
    + {/* Peach blossom resting above the heading */} + +

    + + + {"About MHacks"} + + + {".☘︎ ݁˖"} + + +

    + + + MHacks is the University of Michigan’s flagship hackathon. 24 + hours of creative engineering, design, building, and prototyping + that blur the line between code and the real world. Join 1,000+ + student builders this fall in Ann Arbor and build something that can + have lasting impact. + + + {/* Hint for the click-to-stamp flowers */} + + + + Click around the canvas for some fun. + + + + + + +
    + + {/* Photo carousel — full bleed, drifting right to left */} + + + +
    +
    + ); +} diff --git a/components/landing/sections/Agent.tsx b/components/landing/sections/Agent.tsx new file mode 100644 index 0000000..086d04c --- /dev/null +++ b/components/landing/sections/Agent.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useRef } from "react"; +import { motion, useReducedMotion } from "framer-motion"; +import { SplitReveal } from "@/components/landing/SplitReveal"; +import { FlowerStamps } from "@/components/landing/FlowerStamps"; +import { SpeciesLabel } from "@/components/landing/SpeciesLabel"; +import { AgentTerminal } from "@/components/landing/AgentTerminal"; +import { CommandBlock } from "@/components/landing/CopyBlock"; +import { CtaButton } from "@/components/landing/cta-button"; +import { SERVER_URL } from "@/app/(marketing)/how-to-mcp/content"; +import { StackedSheet } from "@/components/landing/StackedSheet"; +import { useGarlandEntrance } from "@/lib/landing/useGarlandEntrance"; +import { useStackPaused } from "@/lib/landing/useStackPaused"; +import { asset } from "@/lib/landing/asset"; +import Image from "next/image"; + +/** + * "Agent" — teaser sheet for the MCP guide: MHacks has an MCP server, so + * hackers can apply through Claude/Codex instead of the web form. Sits + * between the Timeline and FAQ sheets; the CTA leads to /how-to-mcp. + */ +export function Agent() { + const ref = useRef(null); + const reduced = useReducedMotion(); + const paused = useStackPaused(ref); + const garlandX = useGarlandEntrance(ref, "right"); + + return ( + + + + {/* Black-eyed Susan garland along the top — full-bleed, in flow */} + + {/* Idle waver anchored toward the left, where the stem roots offscreen */} + + + + + + +
    +
    +
    +

    + + Apply with your agent + +

    +

    + MHacks has an MCP server. Connect Claude, Codex, or any + MCP-capable agent and apply without ever opening the form. Your + agent reads the application schema, drafts your answers with you, + and submits, all tied to your real MHacks login. +

    +
    + +
    +

    + Server URL +

    +
    + {SERVER_URL} +
    +
    + +
    + + How to connect → + +
    +
    + + +
    +
    + ); +} diff --git a/components/landing/sections/Faq.tsx b/components/landing/sections/Faq.tsx new file mode 100644 index 0000000..b7a5ed1 --- /dev/null +++ b/components/landing/sections/Faq.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { useRef, useState } from "react"; +import Image from "next/image"; +import { Accordion } from "@/components/ui/accordion"; +import { + motion, + useReducedMotion, + useScroll, + useTransform, +} from "framer-motion"; +import { SplitReveal } from "@/components/landing/SplitReveal"; +import { FaqItem } from "@/components/landing/FaqItem"; +import { FlowerStamps } from "@/components/landing/FlowerStamps"; +import { SpeciesLabel } from "@/components/landing/SpeciesLabel"; +import { StackedSheet } from "@/components/landing/StackedSheet"; +import { formatDeadlineDate } from "@/lib/landing/deadlines"; +import { useStackPaused } from "@/lib/landing/useStackPaused"; + +const MotionImage = motion.create(Image); + +const earlyAppsDue = formatDeadlineDate("early-apps-due"); +const regularAppsDue = formatDeadlineDate("regular-apps-due"); + +const FAQS = [ + { + q: "Who can apply?", + a: "Any current undergraduate or graduate student is welcome to apply. You don't need to be a CS major. First-time hackers, designers, hardware tinkerers, and builders are all welcome. If you are looking to build something meaningful, you are encouraged to apply.", + }, + { + q: "Is there a cost to attend?", + a: "No, MHacks is free for accepted hackers, including meals and swag. Travel reimbursement is available on a limited basis. We do not provide living accommodations, but the hacking location will be open 24 hours.", + }, + { + q: "Do I need a team?", + a: "Nope, you can apply solo or form a team of up to four. We will host a team matching session on the first day of the hackathon.", + }, + { + q: "What should I build?", + a: "Anything pertaining to our upcoming tracks. Past projects have spanned AI agents, hardware, wearables, climate tools, games, creative installations, and more. Sponsor tracks offer focused prize categories.", + }, + { + q: "When do applications close?", + a: `Early applications are due ${earlyAppsDue}, and regular applications are due ${regularAppsDue}. Decisions will be out approximately one week after each deadline.`, + }, + { + q: "Where does the event happen?", + a: "North Campus of the University of Michigan in Ann Arbor. Detailed venue and logistics will become public as we get closer to the event date.", + }, +]; + +export function Faq() { + const ref = useRef(null); + const reduced = useReducedMotion(); + const paused = useStackPaused(ref); + const [openItem, setOpenItem] = useState(""); + + // Violets zoom into existence as the sheet scrolls into place — staggered + // so they bloom one after another. Scroll-linked scale, transform-only. + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start end", "start start"], + }); + const bloom1 = useTransform(scrollYProgress, [0.35, 0.8], [0, 1]); + const bloom2 = useTransform(scrollYProgress, [0.45, 0.9], [0, 1]); + const bloom3 = useTransform(scrollYProgress, [0.55, 1], [0, 1]); + + const violets = [ + { + src: "/faq/flower-1.webp", + width: 152, + intrinsicWidth: 500, + intrinsicHeight: 608, + scale: bloom1, + sway: 5, + drift: -3.5, + }, + { + src: "/faq/flower-2.webp", + width: 120, + intrinsicWidth: 500, + intrinsicHeight: 505, + scale: bloom2, + sway: 6.2, + drift: 4, + }, + { + src: "/faq/flower-3.webp", + width: 138, + intrinsicWidth: 500, + intrinsicHeight: 432, + scale: bloom3, + sway: 5.6, + drift: -4.5, + }, + ]; + + return ( + + + +
    + + {/* Violet trio anchored to the section's bottom edge, beneath the + questions — when an answer expands and the sheet grows, they ride + down with the bottom. Each blooms in on scroll, then idles with its + own slow sway. */} +
    + {/* Species tag planted at the base of the trio */} + + {violets.map((v) => ( + + + + ))} +
    + + ); +} diff --git a/components/landing/sections/Footer.tsx b/components/landing/sections/Footer.tsx new file mode 100644 index 0000000..72e365c --- /dev/null +++ b/components/landing/sections/Footer.tsx @@ -0,0 +1,164 @@ +"use client"; + +import Link from "next/link"; +import Image from "next/image"; +import { usePathname } from "next/navigation"; +import { motion, useReducedMotion } from "framer-motion"; +import { Logo } from "@/components/landing/Logo"; +import { SpeciesLabel } from "@/components/landing/SpeciesLabel"; +import { stackedSheetClassName } from "@/components/landing/StackedSheet"; +import { + FOOTER_NAV_ITEMS, + handleMarketingNavClick, + isMarketingHome, + resolveMarketingHref, +} from "@/lib/landing/nav"; +import { GRAIN_240 } from "@/lib/landing/textures"; +import { cn } from "@/lib/utils"; + +/** + * Footer as the last sheet in the stack: rounded top corners over the + * newsletter section. The backdrop is a heavily blurred pastel photo under + * dense grain — soft pastels on sandy paper, with the pale text punching + * through. (The giant MHACKS 2026 wordmark is removed for now.) + */ +export function Footer() { + const reduced = useReducedMotion(); + // The footer also renders on /how-to-mcp — from there, hash links route + // back to the home page's section instead of a dead in-page anchor. + const onHome = isMarketingHome(usePathname()); + return ( +
    + {/* Sandy-pastel backdrop: pre-blurred pastel photo, a soft tint for + text contrast, then two passes of dense grain for the paper tooth. */} +
    + {/* Blur + grade are baked into the image — no live CSS filter, so the + compositor never has to rebuild a giant blurred surface mid-scroll. */} + + {/* Tonal sweep: lighter band up top falling into deep olive shadow + below-left, so the wash reads moody rather than uniform */} +
    + {/* Single grain pass — two tiled feTurbulence layers with blend modes + repainted the full footer on every scroll frame. */} +
    +
    + + {/* Michigan lily garland — migrated from the retired Timeline section. + Slides in from the left, then sways idly across the footer's top. */} + + + + + + + + {/* "Brought to you by the MHacks Team" banner in the open field between + the garland and the footer nav — centered under the second lily from + the right (the garland spans 88% of the row, so this tracks it + proportionally at any width) */} +
    + Brought to you by the MHacks Team +
    + +
    + {/* Logo / pages / rights */} +
    + + + + +
    + © MHACKS 2026 · All rights reserved +
    +
    +
    +
    + ); +} diff --git a/components/landing/sections/Hero.tsx b/components/landing/sections/Hero.tsx new file mode 100644 index 0000000..045eed5 --- /dev/null +++ b/components/landing/sections/Hero.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { motion, useMotionValue, useSpring, useTransform } from "framer-motion"; +import { HeroReveal } from "@/components/landing/HeroReveal"; +import { MlhBadge } from "@/components/landing/MlhBadge"; +import { Typewriter } from "@/components/landing/Typewriter"; +import { DeadlineCountdown } from "@/components/landing/DeadlineCountdown"; +import { AsciiGlow } from "@/components/landing/AsciiGlow"; +import { CtaButton } from "@/components/landing/cta-button"; +import { useMobileLayout } from "@/lib/landing/useMobileLayout"; +import { GRAIN_140 } from "@/lib/landing/textures"; +import { scrollToHash } from "@/lib/landing/scroll"; +import { useStackPaused } from "@/lib/landing/useStackPaused"; +import { prefersReducedMotion } from "@/lib/utils"; + +/* Hero backdrop variants, switched by the icon buttons above the countdown. + `src: null` keeps HeroReveal's default meadow. The blur + dot-grid + ASCII + treatment is applied by HeroReveal/AsciiGlow in CSS, so it covers every + variant automatically. Paths are raw: next/image builds its own + /_next/image URLs, which Next's basePath already rewrites. */ +const HERO_BGS = [ + { + id: "leaf", + icon: "/hero/icon-leaf.png", + label: "Meadow backdrop", + src: null, + }, + { + id: "flower", + icon: "/hero/icon-flower.png", + label: "Peony garden backdrop", + src: "/hero/hero-flower.jpg", + }, + { + id: "cloud", + icon: "/hero/icon-cloud.png", + label: "Sky backdrop", + src: "/hero/hero-cloud.jpg", + }, +] as const; + +type HeroBgId = (typeof HERO_BGS)[number]["id"]; + +export function Hero() { + const ref = useRef(null); + const paused = useStackPaused(ref); + const reducedRef = useRef(false); + const rectRef = useRef(null); + const mobile = useMobileLayout(); + const [bgId, setBgId] = useState("leaf"); + const [cursorBoxReady, setCursorBoxReady] = useState(false); + + useEffect(() => { + reducedRef.current = prefersReducedMotion(); + }, []); + + // The section itself is never transformed (the tilt lives on an inner + // layer), so its box only moves on scroll or resize. Caching it keeps the + // pointer handler from forcing a synchronous layout on every mousemove — + // scroll fires at most once per frame, so this reads at most once per frame. + useEffect(() => { + const invalidate = () => { + rectRef.current = null; + }; + window.addEventListener("scroll", invalidate, { passive: true }); + window.addEventListener("resize", invalidate); + return () => { + window.removeEventListener("scroll", invalidate); + window.removeEventListener("resize", invalidate); + }; + }, []); + + // Cursor-driven 3D tilt: the meadow leans toward the pointer while the + // ASCII starfield drifts the opposite way, so the layers read as depth. + const pointerX = useMotionValue(0.5); + const pointerY = useMotionValue(0.5); + const px = useSpring(pointerX, { stiffness: 55, damping: 18, mass: 0.6 }); + const py = useSpring(pointerY, { stiffness: 55, damping: 18, mass: 0.6 }); + + const tiltX = useTransform(py, [0, 1], [2.4, -2.4]); + const tiltY = useTransform(px, [0, 1], [-3.2, 3.2]); + const shiftX = useTransform(px, [0, 1], [-16, 16]); + const shiftY = useTransform(py, [0, 1], [-11, 11]); + const starX = useTransform(px, [0, 1], [9, -9]); + const starY = useTransform(py, [0, 1], [7, -7]); + + const onTiltMove = (e: React.MouseEvent) => { + if (reducedRef.current) return; + let r = rectRef.current; + if (!r) { + r = ref.current?.getBoundingClientRect() ?? null; + rectRef.current = r; + } + if (!r) return; + pointerX.set((e.clientX - r.left) / r.width); + pointerY.set((e.clientY - r.top) / r.height); + }; + const onTiltLeave = () => { + pointerX.set(0.5); + pointerY.set(0.5); + }; + + const activeBg = HERO_BGS.find((b) => b.id === bgId) ?? HERO_BGS[0]; + const bgProps = activeBg.src ? { src: activeBg.src } : {}; + + return ( +
    +
    + {/* StackedPages pins the hero while the next sheet rises — no scroll + parallax here or the old page drifts instead of staying put. */} +
    + +
    + + {/* Breathing ASCII starfield — mounts only while the hero sheet is visible. */} + + {!paused && ( + + )} + + + {/* MLH trust badge, resting on the hero's top edge — scrolls away with + the hero (covered by the next sheet), unlike the fixed header */} + + + {/* Vignette for text legibility */} +
    + + {/* Top gradient band behind the headline/CTA/badge — fades out by 40% + so the lower meadow stays untouched */} +
    + + {/* Film grain — static, unblended, outside the transformed layers */} +
    +
    + + {/* Meta row + giant title, edge-aligned as one lockup */} +
    + {/* Width is set by the title, so the meta row spans exactly its edges. */} +
    + {/* Live application deadline countdown — absolutely positioned so it + doesn't push the title off vertical center */} + + + {/* Backdrop switcher — one icon per hero variant */} +
    + {HERO_BGS.map((b) => ( + + ))} +
    + +
    +
    + + + { + ref.current?.setAttribute("data-cursor-box-ready", ""); + setCursorBoxReady(true); + window.dispatchEvent( + new CustomEvent("mhacks:hero-cursor-ready"), + ); + }} + /> + + + + + + Build something that grows. + + + + + October 3–4, 2026 · Ann Arbor, Michigan + + + + + {/* Mobile CTAs — the header's Apply/Sponsor us pills live here on + small screens, stacked under the date line */} + + + + Apply + + { + e.preventDefault(); + scrollToHash("#sponsors"); + }} + > + Sponsor us + + + +
    +
    + + {/* Scroll cue — chevron flashing between full and zero opacity. */} +
    + + + + + +
    +
    + ); +} diff --git a/components/landing/sections/Schedule.tsx b/components/landing/sections/Schedule.tsx new file mode 100644 index 0000000..0b262e9 --- /dev/null +++ b/components/landing/sections/Schedule.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useRef } from "react"; +import { motion, useReducedMotion } from "framer-motion"; +import { SplitReveal } from "@/components/landing/SplitReveal"; +import { DotGridReactive } from "@/components/landing/DotGridReactive"; +import { FlowerStamps } from "@/components/landing/FlowerStamps"; +import { SpeciesLabel } from "@/components/landing/SpeciesLabel"; +import { DEADLINES } from "@/lib/landing/deadlines"; +import { StackedSheet } from "@/components/landing/StackedSheet"; +import { useGarlandEntrance } from "@/lib/landing/useGarlandEntrance"; +import { useStackPaused } from "@/lib/landing/useStackPaused"; +import Image from "next/image"; + +/** + * "Timeline" — the application-season schedule, typeset like a festival + * programme: hairline-ruled rows with a big date, a time column, and the + * milestone. Rows come from lib/deadlines.ts (single source of truth shared + * with the hero countdown); only hard deadlines show a time. + */ +const fmt = (iso: string, options: Intl.DateTimeFormatOptions) => + new Date(iso).toLocaleString("en-US", { + ...options, + timeZone: "America/Detroit", + }); + +const ROWS = DEADLINES.map((d) => ({ + id: d.id, + dow: fmt(d.date, { weekday: "short" }), + month: fmt(d.date, { month: "long" }), + day: fmt(d.date, { day: "numeric" }), + time: d.id.endsWith("-due") + ? `${fmt(d.date, { hour: "numeric", minute: "2-digit", hour12: true })} ET` + : "", + label: d.label, +})); + +export function Schedule() { + const ref = useRef(null); + const reduced = useReducedMotion(); + const paused = useStackPaused(ref); + const garlandX = useGarlandEntrance(ref, "left"); + + return ( + + {/* Vignette: darker toward the edges so the schedule rows glow */} +
    + + {/* Dot lattice that swells and brightens around the cursor */} + + + + + {/* White lily-of-the-valley garland along the top — full-bleed, in flow */} + + {/* Idle waver anchored toward the right, where the stem roots offscreen */} + + + + {/* Species tag in the blank pocket above the vine's dip — the one + non-native species in the set, so it's flagged as invasive */} + + + +
    +

    + + {"Timeline"} + +

    + + {"⋆˚꩜。.ᐟ"} +
    + {"˖᯽ ݁˖· ─ °❀⋆"} +
    +
    + +
      + {ROWS.map((row, i) => ( + + {/* Weekday tag beside the full date, both on one baseline */} +
      + + {row.dow} + + + {row.month} {row.day} + +
      + + {/* Milestone pinned to the row's right edge, time tucked under */} +
      +
      + {row.label} +
      + {row.time && ( +
      + {row.time} +
      + )} +
      +
      + ))} +
    + + ); +} diff --git a/components/landing/sections/Sponsors.tsx b/components/landing/sections/Sponsors.tsx new file mode 100644 index 0000000..b9b976a --- /dev/null +++ b/components/landing/sections/Sponsors.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useRef } from "react"; +import { motion, useReducedMotion } from "framer-motion"; +import { SplitReveal } from "@/components/landing/SplitReveal"; +import { CtaButton } from "@/components/landing/cta-button"; +import { FlowerStamps } from "@/components/landing/FlowerStamps"; +import { SpeciesLabel } from "@/components/landing/SpeciesLabel"; +import { StackedSheet } from "@/components/landing/StackedSheet"; +import { useGarlandEntrance } from "@/lib/landing/useGarlandEntrance"; +import { useStackPaused } from "@/lib/landing/useStackPaused"; +import Image from "next/image"; + +export function Sponsors() { + const ref = useRef(null); + const reduced = useReducedMotion(); + const paused = useStackPaused(ref); + const branchX = useGarlandEntrance(ref, "left"); + + return ( + + {/* Blueprint grid (Studio Apply-style) */} +
    + + + + {/* Blossom branch filling the gap above the heading — full-bleed, in + flow so it can never collide with the heading below it */} + + {/* Gentle idle sway on top of the scroll-linked drift, anchored + toward the branch's right side like it's rooted offscreen */} + + + + {/* Species tag in the blank pocket above the branch's dip */} + + + +
    +

    + + + {"Our Sponsors"} + + + {"ᯓ★ˎˊ˗"} + + +

    + + Our 2026 sponsor lineup is taking shape, check back soon. + +
    + + +
    +
    + Sponsor MHacks. + Sponsor MHacks 2026. +
    +
    + Reach 1,000+ technical students from across North America. +
    +
    + + Contact us + + Interested in sponsoring? Contact us + + +
    + + ); +} diff --git a/components/mlh-trust-badge.tsx b/components/mlh-trust-badge.tsx deleted file mode 100644 index 8b78c23..0000000 --- a/components/mlh-trust-badge.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import Image from "next/image"; - -export default function MlhTrustBadge() { - return ( - - Major League Hacking 2026 Hackathon Season - - ); -} diff --git a/components/navbar.tsx b/components/navbar.tsx deleted file mode 100644 index b7afe81..0000000 --- a/components/navbar.tsx +++ /dev/null @@ -1,199 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { Menu, X } from "lucide-react"; -import dynamic from "next/dynamic"; -import Image from "next/image"; -import Link from "next/link"; -import { useApplicationsOpen } from "./use-applications-open"; -import posthog from "posthog-js"; - -const LIQUID_GLASS_PROPS = { - displacementScale: 34, - blurAmount: 0.16, - saturation: 120, - aberrationIntensity: 1.2, - elasticity: 0.12, - cornerRadius: 999, - mode: "standard" as const, -}; - -const LiquidGlass = dynamic(() => import("liquid-glass-react"), { - ssr: false, -}); - -const links = [ - { href: "/#about", label: "About" }, - // { href: "/#tracks", label: "Tracks" }, - { href: "/#timeline", label: "Dates" }, - { href: "/#sponsors", label: "Sponsors" }, - { href: "/#faqs", label: "FAQ" }, - { href: "/how-to-mcp", label: "Agent" }, -]; - -export default function NavBar({ - forceShowLogo = false, -}: { - /** Pages without their own top-left hero logo (unlike the landing page's - hero) have nothing for the IntersectionObserver below to watch, so the - navbar logo would never appear. Set this to show it immediately. */ - forceShowLogo?: boolean; -} = {}) { - const [open, setOpen] = useState(false); - const [showLogo, setShowLogo] = useState(forceShowLogo); - const applicationsOpen = useApplicationsOpen(); - - useEffect(() => { - if (forceShowLogo) return; - - const heroLogo = document.getElementById("hero-logo"); - if (!heroLogo) return; - - const observer = new IntersectionObserver( - ([entry]) => setShowLogo(!entry.isIntersecting), - { threshold: 0 }, - ); - - observer.observe(heroLogo); - return () => observer.disconnect(); - }, [forceShowLogo]); - - useEffect(() => { - let rafId = 0; - const startedAt = performance.now(); - const duration = 550; - - const tick = (now: number) => { - window.dispatchEvent(new Event("resize")); - if (now - startedAt < duration) { - rafId = window.requestAnimationFrame(tick); - } - }; - - rafId = window.requestAnimationFrame(tick); - return () => window.cancelAnimationFrame(rafId); - }, [showLogo]); - - return ( - <> - {/* ── Mobile (< lg) ── */} - - - {/* ── Desktop (lg+) ── */} - - - - - ); -} diff --git a/components/photo-carousel.tsx b/components/photo-carousel.tsx deleted file mode 100644 index e02eceb..0000000 --- a/components/photo-carousel.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Image from "next/image"; - -const IMAGES = ["/c_pic1.jpg", "/c_pic2.jpg", "/c_pic3.jpg"]; - -// Visual offset for each stack position (0 = top, increasing = deeper in stack). -// Cards rotate counter-clockwise (negative deg) so the top-left corner rises -// above the card in front, creating a visible gap at the top-left edge. -const STACK = [ - { x: 0, y: 0, rotate: 0, z: 30 }, - { x: 0, y: 6, rotate: -7, z: 20 }, - { x: 0, y: 12, rotate: -14, z: 10 }, -]; - -// Front card = darkest (#3A4A26), each deeper card progressively lighter -const CARD_COLORS = ["#3A4A26", "#7A9A4E", "#C4D98E"]; - -export default function PhotoCarousel() { - const [order, setOrder] = useState([0, 1, 2]); - const [exiting, setExiting] = useState(false); - - useEffect(() => { - const id = setInterval(() => { - setExiting(true); - setTimeout(() => { - setOrder((prev) => { - const [top, ...rest] = prev; - return [...rest, top]; - }); - setExiting(false); - }, 480); - }, 3600); - return () => clearInterval(id); - }, []); - - return ( -
    - {order.map((imgIdx, pos) => { - const isTop = pos === 0; - const s = STACK[pos] ?? STACK[STACK.length - 1]; - const cardColor = - CARD_COLORS[pos] ?? CARD_COLORS[CARD_COLORS.length - 1]; - - return ( -
    - {/* Card bg — peeks out 8px around the image on all sides */} -
    -
    - {`MHacks -
    -
    -
    - ); - })} -
    - ); -} diff --git a/components/photo-grid.tsx b/components/photo-grid.tsx deleted file mode 100644 index 96bf765..0000000 --- a/components/photo-grid.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import Image from "next/image"; - -const DESKTOP_GRID = [ - [ - { flex: 2, src: "/c_pic1.jpg" }, - { flex: 4, src: "/c_pic2.jpg" }, - { flex: 3, src: "/c_pic3.jpg" }, - ], - [ - { flex: 3, src: "/c_pic4.jpg" }, - { flex: 2, src: "/c_pic5.jpg" }, - { flex: 3, src: "/c_pic6.jpg" }, - { flex: 2, src: "/c_pic7.jpg" }, - ], - [ - { flex: 4, src: "/c_pic8.jpg" }, - { flex: 2, src: "/c_pic9.jpg" }, - { flex: 3, src: "/c_pic10.jpg" }, - ], -]; - -const MOBILE_GRID = [ - [ - { flex: 1, src: "/c_pic1.jpg" }, - { flex: 1, src: "/c_pic2.jpg" }, - ], - [ - { flex: 1, src: "/c_pic3.jpg" }, - { flex: 1, src: "/c_pic4.jpg" }, - ], -]; - -export default function PhotoGrid() { - return ( - <> - {/* Mobile: 2×2 */} -
    - {MOBILE_GRID.map((row, rowIdx) => ( -
    - {row.map(({ flex, src }, colIdx) => ( -
    - MHacks photo -
    - ))} -
    - ))} -
    - - {/* Desktop: 3 rows, 3–4 cols */} -
    - {DESKTOP_GRID.map((row, rowIdx) => ( -
    - {row.map(({ flex, src }, colIdx) => ( -
    - MHacks photo -
    - ))} -
    - ))} -
    - - ); -} diff --git a/components/scroll-expand-video.tsx b/components/scroll-expand-video.tsx deleted file mode 100644 index 4a1586f..0000000 --- a/components/scroll-expand-video.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client"; - -import { useRef, useEffect, useState } from "react"; -import VideoPlayer from "./video-player"; - -const WRAPPER_VH = 160; -const EARLY = 0.7; -const VH_START = 65; -const VH_PEAK = 50; -const VH_END = 28; - -const ABS_BEFORE = VH_START - EARLY * (WRAPPER_VH - 100); // 23 -const ABS_AFTER = VH_END + (1 - EARLY) * (WRAPPER_VH - 100); // 46 - -export default function ScrollExpandVideo() { - const wrapperRef = useRef(null); - const [progress, setProgress] = useState(-2); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = () => setIsMobile(window.innerWidth < 768); - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); - - useEffect(() => { - if (isMobile) return; - const update = () => { - if (!wrapperRef.current) return; - const rect = wrapperRef.current.getBoundingClientRect(); - const scrollable = wrapperRef.current.offsetHeight - window.innerHeight; - setProgress(-rect.top / scrollable + EARLY); - }; - window.addEventListener("scroll", update, { passive: true }); - update(); - return () => window.removeEventListener("scroll", update); - }, [isMobile]); - - if (isMobile) { - return ( -
    - -
    - ); - } - - const isActive = progress >= 0 && progress <= 1; - - const P0 = 0.35; - const P1 = 0.65; - const ep = isActive - ? progress < P0 - ? progress / P0 - : progress > P1 - ? (1 - progress) / (1 - P1) - : 1 - : 0; - - const topVh = isActive - ? progress < P0 - ? VH_START + (VH_PEAK - VH_START) * (progress / P0) - : progress > P1 - ? VH_PEAK + (VH_END - VH_PEAK) * ((progress - P1) / (1 - P1)) - : VH_PEAK - : 0; - - const widthVw = 50 + ep * 25; - const overlayOpacity = ep * 0.7; - const absoluteTop = progress > 1 ? `${ABS_AFTER}vh` : `${ABS_BEFORE}vh`; - - return ( -
    - {overlayOpacity > 0 && ( -
    - )} -
    - -
    -
    - ); -} diff --git a/components/site-footer.tsx b/components/site-footer.tsx deleted file mode 100644 index bca432d..0000000 --- a/components/site-footer.tsx +++ /dev/null @@ -1,179 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Image from "next/image"; -import { useApplicationsOpen } from "./use-applications-open"; - -const COORDS = "42.2911672°N 83.7182928°W · Ann Arbor, MI"; - -const COLUMNS: { - title: string; - links: { label: string; href: string; muted?: boolean; external?: boolean }[]; -}[] = [ - { - title: "Get involved", - links: [ - { label: "Apply", href: "/apply" }, - { label: "Contact", href: "mailto:hackathon@mhacks.org" }, - { label: "Become a sponsor", href: "mailto:sponsorship@mhacks.org" }, - ], - }, - { - title: "Policies", - links: [ - { - label: "MLH Code of Conduct", - href: "https://github.com/MLH/mlh-policies/blob/main/code-of-conduct.md", - external: true, - }, - ], - }, -]; - -function useNow() { - const [now, setNow] = useState(null); - - useEffect(() => { - const updateNow = () => setNow(Date.now()); - const initialId = window.setTimeout(updateNow, 0); - const intervalId = window.setInterval(updateNow, 1000); - - return () => { - window.clearTimeout(initialId); - window.clearInterval(intervalId); - }; - }, []); - - return now; -} - -function AnnArborClock() { - const now = useNow(); - const time = now - ? new Date(now).toLocaleTimeString("en-US", { - timeZone: "America/Detroit", - hour12: false, - }) - : "--:--:--"; - return Ann Arbor — {time}; -} - -export default function SiteFooter() { - const applicationsOpen = useApplicationsOpen(); - - return ( - - ); -} diff --git a/components/smooth-scroll-provider.tsx b/components/smooth-scroll-provider.tsx deleted file mode 100644 index d3293b1..0000000 --- a/components/smooth-scroll-provider.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import Lenis from "lenis"; -import { useEffect } from "react"; - -export default function SmoothScrollProvider({ - children, -}: { - children: React.ReactNode; -}) { - useEffect(() => { - const lenis = new Lenis({ - duration: 1.2, - easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), - // Use native touch on mobile — avoids fighting the browser - touchMultiplier: 0, - }); - - let raf: number; - function loop(time: number) { - lenis.raf(time); - raf = requestAnimationFrame(loop); - } - raf = requestAnimationFrame(loop); - - return () => { - cancelAnimationFrame(raf); - lenis.destroy(); - }; - }, []); - - return <>{children}; -} diff --git a/components/sponsors-section.tsx b/components/sponsors-section.tsx deleted file mode 100644 index 1e3458b..0000000 --- a/components/sponsors-section.tsx +++ /dev/null @@ -1,128 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import Image from "next/image"; -import { motion, useScroll, useTransform } from "framer-motion"; - -const EASE = [0.25, 0.1, 0.25, 1] as const; - -/** Corner bracket fiducials */ -function Fiducials() { - const corner = "absolute w-4 h-4 border-[rgba(58,74,38,0.3)]"; - return ( - <> - - - - - - ); -} - -export default function SponsorsSection() { - const ref = useRef(null); - const { scrollYProgress } = useScroll({ - target: ref, - offset: ["start end", "end start"], - }); - const parallaxY = useTransform(scrollYProgress, [0, 1], ["-6%", "6%"]); - - return ( -
    -
    - -
    - - - - - - sponsors/2026.log - - - status: pending - - -
    -
    -

    - Our Sponsors -

    -

    - Coming Soon - - ▮ - -

    -
    - - Become a sponsor ↗ - -
    -
    - - -
    - -
    -
    - ); -} diff --git a/components/stats-band.tsx b/components/stats-band.tsx deleted file mode 100644 index 13ef9c2..0000000 --- a/components/stats-band.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import Image from "next/image"; - -const PHOTOS = [ - { src: "/c_pic1.jpg" }, - { src: "/c_pic2.jpg" }, - { src: "/c_pic3.jpg" }, - { src: "/c_pic4.jpg" }, - { src: "/c_pic5.jpg" }, - { src: "/c_pic6.jpg" }, - { src: "/c_pic7.jpg" }, - { src: "/c_pic8.jpg" }, - { src: "/c_pic9.jpg" }, - { src: "/c_pic10.jpg" }, -]; - -function PhotoCarousel() { - return ( -
    -
    - {[...PHOTOS, ...PHOTOS].map((p, i) => ( -
    - -
    - ))} -
    -
    - ); -} - -export default function StatsBand() { - return ( -
    - {/* ASCII background */} - - {/* Paper overlay */} -
    - {/* Edge fades */} -
    -
    - -
    -

    - Remember{" "} - -  MHacks 2025?  - -

    -
    - -
    - -
    -
    - ); -} diff --git a/components/tracks-section.tsx b/components/tracks-section.tsx deleted file mode 100644 index ff00ab3..0000000 --- a/components/tracks-section.tsx +++ /dev/null @@ -1,216 +0,0 @@ -"use client"; - -import { useRef, useState } from "react"; -import { motion, useMotionValueEvent, useScroll } from "framer-motion"; -import Image from "next/image"; - -const EASE = [0.25, 0.1, 0.25, 1] as const; - -const tracks = [ - { - name: "Artificial Intelligence", - description: "Build systems that sense, reason, and act in the world", - flower: "/dark_blue_flower.png", - glow: "rgba(60, 110, 255, 0.72)", - }, - { - name: "Sustainability", - description: "Engineer tech-driven solutions for a resilient planet", - flower: "/light_blue_flower.png", - glow: "rgba(55, 195, 105, 0.68)", - }, - { - name: "Healthcare", - description: "Reimagine how people access and receive care", - flower: "/pink_flower.png", - glow: "rgba(230, 55, 130, 0.65)", - }, - { - name: "Fintech", - description: "Reshape money, markets, and economic access for all", - flower: "/yellow_flower.png", - glow: "rgba(245, 200, 20, 0.72)", - }, -]; - -const N = tracks.length; - -function TrackCard({ track }: { track: (typeof tracks)[number] }) { - return ( -
    -
    - - - {track.name} - -
    - ); -} - -function TrackRow({ - track, - on, -}: { - track: (typeof tracks)[number]; - on: boolean; -}) { - return ( -
    -
    -
    - -
    -
    -

    - {track.name} -

    -

    - {track.description} -

    -
    -
    - ); -} - -export default function TracksSection() { - const ref = useRef(null); - const [active, setActive] = useState(0); - - const { scrollYProgress } = useScroll({ - target: ref, - offset: ["start start", "end end"], - }); - - useMotionValueEvent(scrollYProgress, "change", (v) => { - setActive(Math.min(N - 1, Math.max(0, Math.floor(v * N)))); - }); - - return ( -
    - {/* Desktop — sticky scroll */} -
    -
    -
    -

    - Choose Your Focus -

    -

    - Official Tracks -

    -
    - {tracks.map((track, i) => ( - - ))} -
    -
    - - {/* Stacked cards — active slides out upward */} -
    - {tracks.map((track, i) => ( - - - - ))} -
    -
    -
    - - {/* Mobile — simple stacked layout */} -
    -

    - Choose Your Focus -

    -

    - Official Tracks -

    -
    - {tracks.map((track) => ( -
    - -
    - -
    -
    - ))} -
    -
    -
    - ); -} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..8615eb8 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,87 @@ +"use client"; + +import * as React from "react"; +import { Accordion as AccordionPrimitive } from "radix-ui"; + +import { cn } from "@/lib/utils"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +function Accordion({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
    + {children} +
    +
    + ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx index a9ab272..1e7c136 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -19,6 +19,19 @@ const buttonVariants = cva( destructive: "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", link: "text-primary underline-offset-4 hover:underline", + cta: "rounded-pill bg-moss-700 text-cream hover:bg-moss-800 focus-visible:ring-moss-700/30", + glass: + "liquid-glass rounded-pill font-semibold text-cream [text-shadow:0_1px_8px_rgba(20,30,10,0.45)] hover:opacity-95", + parchment: + "rounded-pill bg-parchment text-ink hover:bg-cream focus-visible:ring-moss-700/30", + cream: + "rounded-pill bg-cream text-moss-700 hover:bg-white focus-visible:ring-moss-700/30", + accent: + "rounded-pill bg-sun text-moss-900 hover:brightness-95 focus-visible:ring-sun/40", + "landing-outline": + "rounded-pill border border-border-strong text-moss-700 hover:bg-moss-700/10 bg-transparent", + "landing-ghost": + "rounded-pill text-moss-700 hover:bg-moss-700/10 bg-transparent", }, size: { default: @@ -32,6 +45,9 @@ const buttonVariants = cva( "icon-sm": "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", "icon-lg": "size-9", + md: "h-auto gap-2 rounded-pill px-6 py-3 text-[15px]", + "landing-sm": "h-auto gap-2 rounded-pill px-3.5 py-2 text-[13px]", + "landing-lg": "h-auto gap-2 rounded-pill px-7 py-4 text-[16px]", }, }, defaultVariants: { diff --git a/components/use-applications-open.ts b/components/use-applications-open.ts deleted file mode 100644 index d2652fe..0000000 --- a/components/use-applications-open.ts +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; - -const APPLICATIONS_OPEN_AT = new Date("2026-06-22T15:00:00-04:00").getTime(); - -export function useApplicationsOpen() { - const [open, setOpen] = useState(false); - - useEffect(() => { - const updateOpen = () => setOpen(Date.now() >= APPLICATIONS_OPEN_AT); - - updateOpen(); - const intervalId = window.setInterval(updateOpen, 30_000); - - return () => window.clearInterval(intervalId); - }, []); - - return open; -} diff --git a/components/video-player.tsx b/components/video-player.tsx deleted file mode 100644 index 98a9258..0000000 --- a/components/video-player.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -export default function VideoPlayer() { - return ( -
    -
    - ); -} diff --git a/components/video-spotlight.tsx b/components/video-spotlight.tsx deleted file mode 100644 index 2996bf4..0000000 --- a/components/video-spotlight.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import { useRef } from "react"; -import { motion, useScroll, useTransform } from "framer-motion"; - -export default function VideoSpotlight() { - const ref = useRef(null); - - const { scrollYProgress } = useScroll({ - target: ref, - offset: ["start end", "end start"], - }); - - const scale = useTransform(scrollYProgress, [0, 0.5, 1], [0.86, 1.04, 0.86]); - const overlay = useTransform(scrollYProgress, [0.12, 0.5, 0.88], [0, 0.9, 0]); - const shadow = useTransform( - scrollYProgress, - [0, 0.5, 1], - [ - "0 20px 50px -30px rgba(31,42,22,0.4)", - "0 50px 120px -40px rgba(0,0,0,0.75)", - "0 20px 50px -30px rgba(31,42,22,0.4)", - ], - ); - - return ( - <> - - -
    - - - - -
    - - ); -} diff --git a/lib/landing/asset.ts b/lib/landing/asset.ts new file mode 100644 index 0000000..773a13e --- /dev/null +++ b/lib/landing/asset.ts @@ -0,0 +1,11 @@ +/** + * Prefixes a public-asset path with the deploy base path (e.g. GitHub Pages + * serves the site under /mhacks-2026-site). Next's basePath only rewrites + * routes and optimized images, not raw /CSS URLs, so every reference to + * a file in /public should go through this helper. + */ +const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH || ""; + +export function asset(path: string): string { + return `${BASE_PATH}${path}`; +} diff --git a/lib/landing/deadlines.ts b/lib/landing/deadlines.ts new file mode 100644 index 0000000..86482ff --- /dev/null +++ b/lib/landing/deadlines.ts @@ -0,0 +1,92 @@ +/** + * Application timeline — single source of truth for the hero countdown. + * + * ENGINEERING HANDOFF NOTES + * - To drive this from a backend, replace the DEADLINES constant with fetched + * data (same shape) — everything downstream (`getNextDeadline`, + * `DeadlineCountdown`) only depends on this array. + * - Dates are ISO 8601 with an explicit Eastern offset (-04:00 = EDT), i.e. + * deadlines land at end-of-day Ann Arbor time. Adjust if the real deadline + * time differs. + * - `countdownLabel` is the phrasing shown in the hero pill while this + * deadline is the next one upcoming. + */ + +export interface Deadline { + id: string; + /** Short human label, e.g. for a future timeline UI. */ + label: string; + /** Phrase used by the countdown pill: " in 12d 04h…" */ + countdownLabel: string; + /** ISO 8601 with timezone offset. */ + date: string; +} + +export const DEADLINES: Deadline[] = [ + { + id: "apps-open", + label: "Applications open", + countdownLabel: "Applications open", + date: "2026-06-22T00:00:00-04:00", + }, + { + id: "early-apps-due", + label: "Early applications due", + countdownLabel: "Early applications close", + date: "2026-08-07T23:59:59-04:00", + }, + { + id: "early-decisions", + label: "Early decisions released", + countdownLabel: "Early decisions out", + date: "2026-08-14T23:59:59-04:00", + }, + { + id: "regular-apps-due", + label: "Regular applications due", + countdownLabel: "Applications close", + date: "2026-09-12T23:59:59-04:00", + }, + { + id: "regular-decisions", + label: "Regular decisions released", + countdownLabel: "Decisions out", + date: "2026-09-19T23:59:59-04:00", + }, +]; + +/** Format a deadline id as a human-readable calendar date. */ +export function formatDeadlineDate(id: string): string { + const deadline = DEADLINES.find((d) => d.id === id); + if (!deadline) return ""; + return new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }).format(new Date(deadline.date)); +} + +/** Next deadline still in the future, or null once the season is over. */ +export function getNextDeadline(now: Date = new Date()): Deadline | null { + return ( + DEADLINES.find((d) => new Date(d.date).getTime() > now.getTime()) ?? null + ); +} + +export interface TimeParts { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +/** Non-negative breakdown of the time from `now` until `target`. */ +export function getTimeParts(target: Date, now: Date): TimeParts { + const total = Math.max(0, target.getTime() - now.getTime()); + return { + days: Math.floor(total / 86_400_000), + hours: Math.floor(total / 3_600_000) % 24, + minutes: Math.floor(total / 60_000) % 60, + seconds: Math.floor(total / 1_000) % 60, + }; +} diff --git a/lib/landing/nav.ts b/lib/landing/nav.ts new file mode 100644 index 0000000..8379506 --- /dev/null +++ b/lib/landing/nav.ts @@ -0,0 +1,57 @@ +import type { MouseEvent } from "react"; + +import { asset } from "@/lib/landing/asset"; +import { scrollToHash } from "@/lib/landing/scroll"; + +export interface MarketingNavItem { + label: string; + href: string; + cta?: boolean; +} + +export const MARKETING_NAV_ITEMS: MarketingNavItem[] = [ + { label: "About", href: "#about" }, + { label: "Sponsors", href: "#sponsors" }, + { label: "Timeline", href: "#timeline" }, + { label: "Agent", href: "/how-to-mcp" }, + { label: "FAQ", href: "#faq" }, +]; + +export const FOOTER_NAV_ITEMS: MarketingNavItem[] = [ + ...MARKETING_NAV_ITEMS, + { label: "Contact", href: "mailto:hackathon@mhacks.org" }, + { + label: "MLH Policies", + href: "https://github.com/MLH/mlh-policies/blob/main/code-of-conduct.md", + }, +]; + +/** Strip duplicated fragments (e.g. `#about#about` → `#about`). */ +export function normalizeHash(hash: string): string { + const id = hash.replace(/^#+/, "").split("#")[0]; + return id ? `#${id}` : ""; +} + +export function isMarketingHome(pathname: string | null): boolean { + return pathname === "/"; +} + +/** Hash links as root-absolute paths (e.g. `/#about`). Bare `#about` appends + * to the current fragment (`/#sponsors` → `/#sponsors#about`). Off-home + * links also need the deploy base path (GitHub Pages). */ +export function resolveMarketingHref(href: string, onHome: boolean): string { + if (href.startsWith("#")) return onHome ? `/${href}` : asset(`/${href}`); + return href; +} + +export function handleMarketingNavClick( + href: string, + onHome: boolean, + e: MouseEvent, +) { + if (href.startsWith("#") && onHome) { + e.preventDefault(); + history.replaceState(null, "", href); + scrollToHash(href); + } +} diff --git a/lib/landing/scroll.ts b/lib/landing/scroll.ts new file mode 100644 index 0000000..3b11b43 --- /dev/null +++ b/lib/landing/scroll.ts @@ -0,0 +1,54 @@ +import type Lenis from "lenis"; + +let lenis: Lenis | null = null; +const scrollListeners = new Set<() => void>(); + +function emitScroll() { + scrollListeners.forEach((listener) => listener()); +} + +/** Subscribe to scroll position changes (Lenis + native fallback). */ +export function subscribeScroll(listener: () => void) { + scrollListeners.add(listener); + return () => scrollListeners.delete(listener); +} + +/** Called by SmoothScroll so anchor navigation can drive the Lenis instance. */ +export function registerLenis(instance: Lenis | null) { + lenis?.off("scroll", emitScroll); + lenis = instance; + lenis?.on("scroll", emitScroll); +} + +/** Animated scroll to an in-page anchor (e.g. "#about"). */ +export function scrollToHash(hash: string) { + const el = document.querySelector(hash); + if (!el) return; + + // Sections are sticky-pinned sheets, so their bounding rects (which Lenis + // uses for element targets) report the pinned position, not where they + // live in the document — walk the offsetParent chain for the flow position. + let top = 0; + for ( + let n: HTMLElement | null = el; + n; + n = n.offsetParent as HTMLElement | null + ) { + top += n.offsetTop; + } + + if (lenis) { + const distance = Math.abs(window.scrollY - top); + const vh = window.innerHeight || 1; + // Scale with travel distance so deep links (e.g. logo → #top) don't run a + // fixed 1.6s crawl; cap low so short hops still feel snappy. + const duration = Math.min(1.05, Math.max(0.45, (distance / vh) * 0.32)); + lenis.scrollTo(top, { + duration, + easing: (t) => 1 - Math.pow(1 - t, 4), + }); + return; + } + // No Lenis (reduced motion): jump without animation. + window.scrollTo({ top }); +} diff --git a/lib/landing/textures.ts b/lib/landing/textures.ts new file mode 100644 index 0000000..f7ad681 --- /dev/null +++ b/lib/landing/textures.ts @@ -0,0 +1,12 @@ +/** Film-grain SVG tiles — shared across hero, footer, and how-to-mcp. */ +export const GRAIN_140 = + "url(\"data:image/svg+xml;utf8,\")"; + +export const GRAIN_240 = + "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")"; + +export const DOT_PAPER_BG = { + backgroundImage: + "radial-gradient(rgba(58,74,38,0.12) 1px, transparent 1.4px)", + backgroundSize: "26px 26px", +} as const; diff --git a/lib/landing/useGarlandEntrance.ts b/lib/landing/useGarlandEntrance.ts new file mode 100644 index 0000000..2dcabff --- /dev/null +++ b/lib/landing/useGarlandEntrance.ts @@ -0,0 +1,17 @@ +"use client"; + +import { type RefObject } from "react"; +import { useScroll, useTransform, type MotionValue } from "framer-motion"; + +/** Scroll-linked garland entrance shared by Sponsors, Schedule, and Agent. */ +export function useGarlandEntrance( + ref: RefObject, + direction: "left" | "right", +): MotionValue { + const { scrollYProgress } = useScroll({ + target: ref, + offset: ["start end", "start start"], + }); + const from = direction === "left" ? "62vw" : "-62vw"; + return useTransform(scrollYProgress, [0.08, 0.92], [from, "0vw"]); +} diff --git a/lib/landing/useInView.ts b/lib/landing/useInView.ts new file mode 100644 index 0000000..b21b5f0 --- /dev/null +++ b/lib/landing/useInView.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +const DEFAULTS: IntersectionObserverInit = { + threshold: 0.2, + rootMargin: "0px 0px -10% 0px", +}; + +export function useInView( + options?: IntersectionObserverInit, +) { + const ref = useRef(null); + const [inView, setInView] = useState( + () => typeof IntersectionObserver === "undefined", + ); + + const threshold = options?.threshold ?? DEFAULTS.threshold; + const rootMargin = options?.rootMargin ?? DEFAULTS.rootMargin; + const root = options?.root; + + useEffect(() => { + const node = ref.current; + if (!node) return; + if (typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + observer.disconnect(); + } + }, + { threshold, rootMargin, root }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, [threshold, rootMargin, root]); + + return { ref, inView }; +} diff --git a/lib/landing/useMobileLayout.ts b/lib/landing/useMobileLayout.ts new file mode 100644 index 0000000..bf1501b --- /dev/null +++ b/lib/landing/useMobileLayout.ts @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** Match hero mobile density without mounting a second AsciiGlow canvas. */ +export function useMobileLayout(): boolean { + const [mobile, setMobile] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(max-width: 767px)"); + const sync = () => setMobile(mq.matches); + sync(); + mq.addEventListener("change", sync); + return () => mq.removeEventListener("change", sync); + }, []); + + return mobile; +} diff --git a/lib/landing/useNavTheme.ts b/lib/landing/useNavTheme.ts new file mode 100644 index 0000000..c52df02 --- /dev/null +++ b/lib/landing/useNavTheme.ts @@ -0,0 +1,44 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect, useState } from "react"; +import { subscribeScroll } from "@/lib/landing/scroll"; + +/** + * "hero" while the scroll position is above the hero's midpoint, "page" once + * it passes halfway through the hero. Deterministic single boundary — the + * frosted nav bar is on for everything below that midpoint, and the + * transparent hero variant is guaranteed by the time you're back at the top. + */ +export function useNavTheme(fraction = 0.5): "hero" | "page" { + const pathname = usePathname(); + const [zone, setZone] = useState<"hero" | "page">("hero"); + + useEffect(() => { + const hero = document.getElementById("top"); + + const update = () => { + if (!hero) { + setZone("page"); + return; + } + // The hero is sticky-pinned under the page stack, so its bounding rect + // never moves once pinned — judge by scroll position against its flow + // height instead. + setZone(window.scrollY < hero.offsetHeight * fraction ? "hero" : "page"); + }; + + const id = requestAnimationFrame(update); + window.addEventListener("scroll", update, { passive: true }); + window.addEventListener("resize", update); + const unsubLenis = subscribeScroll(update); + return () => { + cancelAnimationFrame(id); + window.removeEventListener("scroll", update); + window.removeEventListener("resize", update); + unsubLenis(); + }; + }, [fraction, pathname]); + + return zone; +} diff --git a/lib/landing/useScrollDirection.ts b/lib/landing/useScrollDirection.ts new file mode 100644 index 0000000..c4daf1e --- /dev/null +++ b/lib/landing/useScrollDirection.ts @@ -0,0 +1,68 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { subscribeScroll } from "@/lib/landing/scroll"; + +interface Options { + /** Minimum scroll delta before toggling direction. */ + threshold?: number; + /** Scroll Y after which hide-on-scroll-down activates. */ + minScroll?: number; +} + +/** + * Tracks scroll direction for Mercury-style sticky nav: hide when scrolling + * down, reveal when scrolling up. + */ +export function useScrollDirection({ + threshold = 8, + minScroll = 64, +}: Options = {}) { + const pathname = usePathname(); + const [visible, setVisible] = useState(true); + const lastY = useRef(0); + + useEffect(() => { + const syncFromPosition = () => { + const y = window.scrollY; + lastY.current = y; + setVisible(y < minScroll); + }; + + const onScroll = () => { + const y = window.scrollY; + const delta = y - lastY.current; + + if (y < minScroll) { + lastY.current = y; + setVisible(true); + return; + } + + // Native scroll and Lenis both call this handler; the second pass has + // delta 0 and was hiding the bar right after a scroll-up reveal. + if (delta === 0) return; + + lastY.current = y; + + if (Math.abs(delta) < threshold) { + setVisible(delta < 0); + return; + } + + setVisible(delta < 0); + }; + + window.addEventListener("scroll", onScroll, { passive: true }); + const unsubLenis = subscribeScroll(onScroll); + const id = requestAnimationFrame(syncFromPosition); + return () => { + cancelAnimationFrame(id); + window.removeEventListener("scroll", onScroll); + unsubLenis(); + }; + }, [threshold, minScroll, pathname]); + + return visible; +} diff --git a/lib/landing/useStackPaused.ts b/lib/landing/useStackPaused.ts new file mode 100644 index 0000000..662aff1 --- /dev/null +++ b/lib/landing/useStackPaused.ts @@ -0,0 +1,27 @@ +"use client"; + +import { type RefObject, useEffect, useState } from "react"; + +/** + * True while StackedPages has buried this sheet (`visibility: hidden`). + * Use to stop infinite Framer Motion loops — display:none and visibility + * alone do not pause JS-driven animations on sticky sheets that still sit + * in the viewport geometrically. + */ +export function useStackPaused(ref: RefObject) { + const [paused, setPaused] = useState(false); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + const sync = () => setPaused(el.style.visibility === "hidden"); + + sync(); + const observer = new MutationObserver(sync); + observer.observe(el, { attributes: true, attributeFilter: ["style"] }); + return () => observer.disconnect(); + }, [ref]); + + return paused; +} diff --git a/lib/utils.ts b/lib/utils.ts index a5ef193..94101de 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,13 @@ import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function prefersReducedMotion(): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +export function isTouchDevice(): boolean { + if (typeof window === "undefined") return false; + return window.matchMedia("(hover: none), (pointer: coarse)").matches; +} diff --git a/next.config.ts b/next.config.ts index 8477446..5d2dedd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,14 @@ const nextConfig: NextConfig = { allowedDevOrigins: ["172.16.0.49", "127.0.0.1"], output: "standalone", images: { + // Prefer AVIF, fall back to WebP — smaller payloads at the same visual quality. + formats: ["image/avif", "image/webp"], + // Permit lower quality values when passed explicitly on . + qualities: [20, 30, 40, 50, 60, 75], + // Cap generated widths — nothing on the site needs 2k/4k full-bleed variants. + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2560], + imageSizes: [32, 48, 64, 96, 128, 256, 384, 512], + minimumCacheTTL: 86_400, remotePatterns: [ { protocol: "https", diff --git a/package.json b/package.json index 3b9182e..2658dbe 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "date-fns": "^4.4.0", "drizzle-orm": "^0.45.2", "framer-motion": "^12.42.2", + "gsap": "^3.15.0", "input-otp": "^1.4.2", "jotai": "^2.20.2", "lenis": "^1.3.25", @@ -62,6 +63,7 @@ "react-resizable-panels": "^4.12.2", "recharts": "^3.10.0", "shadcn": "^4.13.1", + "sharp": "^0.35.3", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ab154b..c7c1d17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: framer-motion: specifier: ^12.42.2 version: 12.42.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + gsap: + specifier: ^3.15.0 + version: 3.15.0 input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -125,6 +128,9 @@ importers: shadcn: specifier: ^4.13.1 version: 4.13.1(supports-color@7.2.0)(typescript@5.9.3) + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@24.13.3) sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -1000,70 +1006,145 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.34.5': resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.2.4': resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.2.4': resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.2.4': resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1071,6 +1152,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1078,6 +1166,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1085,6 +1180,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1092,6 +1194,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1099,6 +1208,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1106,6 +1222,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1113,6 +1236,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -1120,29 +1250,63 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.34.5': resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.34.5': resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -3505,6 +3669,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + gsap@3.15.0: + resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4670,6 +4837,15 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5853,103 +6029,206 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@img/colour@1.1.0': - optional: true + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + '@img/sharp-darwin-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + '@img/sharp-linux-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + '@img/sharp-linux-arm@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + '@img/sharp-linux-s390x@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + '@img/sharp-linux-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-arm64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + '@img/sharp-linuxmusl-x64@0.34.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + '@img/sharp-wasm32@0.34.5': dependencies: '@emnapi/runtime': 1.11.2 optional: true + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.3': + optional: true + '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.3': + optional: true + '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.3': + optional: true + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -8443,6 +8722,8 @@ snapshots: graceful-fs@4.2.11: {} + gsap@3.15.0: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -9653,6 +9934,39 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + sharp@0.35.3(@types/node@24.13.3): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 24.13.3 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/public/MHacks 2025 Recap Final Draft.mp4 b/public/MHacks 2025 Recap Final Draft.mp4 deleted file mode 100644 index 7e4b78d..0000000 Binary files a/public/MHacks 2025 Recap Final Draft.mp4 and /dev/null differ diff --git a/public/about/about-01.jpg b/public/about/about-01.jpg new file mode 100644 index 0000000..ce2ada7 Binary files /dev/null and b/public/about/about-01.jpg differ diff --git a/public/about/about-02.jpg b/public/about/about-02.jpg new file mode 100644 index 0000000..510b01c Binary files /dev/null and b/public/about/about-02.jpg differ diff --git a/public/about/about-03.jpg b/public/about/about-03.jpg new file mode 100644 index 0000000..127c68f Binary files /dev/null and b/public/about/about-03.jpg differ diff --git a/public/about/about-04.jpg b/public/about/about-04.jpg new file mode 100644 index 0000000..6ce3b7b Binary files /dev/null and b/public/about/about-04.jpg differ diff --git a/public/about/about-05.jpg b/public/about/about-05.jpg new file mode 100644 index 0000000..3ba6da3 Binary files /dev/null and b/public/about/about-05.jpg differ diff --git a/public/about/about-06.jpg b/public/about/about-06.jpg new file mode 100644 index 0000000..2f97fb3 Binary files /dev/null and b/public/about/about-06.jpg differ diff --git a/public/about/about-07.jpg b/public/about/about-07.jpg new file mode 100644 index 0000000..43cd09c Binary files /dev/null and b/public/about/about-07.jpg differ diff --git a/public/about/about-08.jpg b/public/about/about-08.jpg new file mode 100644 index 0000000..912fc3b Binary files /dev/null and b/public/about/about-08.jpg differ diff --git a/public/about/about-09.jpg b/public/about/about-09.jpg new file mode 100644 index 0000000..34b280b Binary files /dev/null and b/public/about/about-09.jpg differ diff --git a/public/about/about-10.jpg b/public/about/about-10.jpg new file mode 100644 index 0000000..5a8dec2 Binary files /dev/null and b/public/about/about-10.jpg differ diff --git a/public/about/blossom.png b/public/about/blossom.png new file mode 100644 index 0000000..4f82d3c Binary files /dev/null and b/public/about/blossom.png differ diff --git a/public/agent/garland-susan.webp b/public/agent/garland-susan.webp new file mode 100644 index 0000000..451f3ac Binary files /dev/null and b/public/agent/garland-susan.webp differ diff --git a/public/ascii_flower_1.png b/public/ascii_flower_1.png deleted file mode 100644 index 1cb668c..0000000 Binary files a/public/ascii_flower_1.png and /dev/null differ diff --git a/public/ascii_flower_2.png b/public/ascii_flower_2.png deleted file mode 100644 index f8f9fbb..0000000 Binary files a/public/ascii_flower_2.png and /dev/null differ diff --git a/public/c_pic1.jpg b/public/c_pic1.jpg deleted file mode 100644 index 617c01e..0000000 Binary files a/public/c_pic1.jpg and /dev/null differ diff --git a/public/c_pic10.jpg b/public/c_pic10.jpg deleted file mode 100644 index 5dff21d..0000000 Binary files a/public/c_pic10.jpg and /dev/null differ diff --git a/public/c_pic2.jpg b/public/c_pic2.jpg deleted file mode 100644 index 6af860b..0000000 Binary files a/public/c_pic2.jpg and /dev/null differ diff --git a/public/c_pic3.jpg b/public/c_pic3.jpg deleted file mode 100644 index f5096fa..0000000 Binary files a/public/c_pic3.jpg and /dev/null differ diff --git a/public/c_pic4.jpg b/public/c_pic4.jpg deleted file mode 100644 index 431fb4a..0000000 Binary files a/public/c_pic4.jpg and /dev/null differ diff --git a/public/c_pic5.jpg b/public/c_pic5.jpg deleted file mode 100644 index e61b863..0000000 Binary files a/public/c_pic5.jpg and /dev/null differ diff --git a/public/c_pic6.jpg b/public/c_pic6.jpg deleted file mode 100644 index 50cd430..0000000 Binary files a/public/c_pic6.jpg and /dev/null differ diff --git a/public/c_pic7.jpg b/public/c_pic7.jpg deleted file mode 100644 index b2367aa..0000000 Binary files a/public/c_pic7.jpg and /dev/null differ diff --git a/public/c_pic8.jpg b/public/c_pic8.jpg deleted file mode 100644 index fb1f0a6..0000000 Binary files a/public/c_pic8.jpg and /dev/null differ diff --git a/public/c_pic9.jpg b/public/c_pic9.jpg deleted file mode 100644 index 0b01276..0000000 Binary files a/public/c_pic9.jpg and /dev/null differ diff --git a/public/droopy_flowers.png b/public/droopy_flowers.png deleted file mode 100644 index 496010c..0000000 Binary files a/public/droopy_flowers.png and /dev/null differ diff --git a/public/faq/flower-1.webp b/public/faq/flower-1.webp new file mode 100644 index 0000000..7853234 Binary files /dev/null and b/public/faq/flower-1.webp differ diff --git a/public/faq/flower-2.webp b/public/faq/flower-2.webp new file mode 100644 index 0000000..f7520a9 Binary files /dev/null and b/public/faq/flower-2.webp differ diff --git a/public/faq/flower-3.webp b/public/faq/flower-3.webp new file mode 100644 index 0000000..a36ba39 Binary files /dev/null and b/public/faq/flower-3.webp differ diff --git a/public/faq/polaroid-1.jpg b/public/faq/polaroid-1.jpg new file mode 100644 index 0000000..7a6fe6b Binary files /dev/null and b/public/faq/polaroid-1.jpg differ diff --git a/public/faq/polaroid-2.jpg b/public/faq/polaroid-2.jpg new file mode 100644 index 0000000..b276006 Binary files /dev/null and b/public/faq/polaroid-2.jpg differ diff --git a/public/footer/brought-to-you.png b/public/footer/brought-to-you.png new file mode 100644 index 0000000..88806f4 Binary files /dev/null and b/public/footer/brought-to-you.png differ diff --git a/public/footer/footer-pastel-soft.jpg b/public/footer/footer-pastel-soft.jpg new file mode 100644 index 0000000..ad9f426 Binary files /dev/null and b/public/footer/footer-pastel-soft.jpg differ diff --git a/public/footer/footer-pastel.jpg b/public/footer/footer-pastel.jpg new file mode 100644 index 0000000..04f32b8 Binary files /dev/null and b/public/footer/footer-pastel.jpg differ diff --git a/public/footer_logo.svg b/public/footer_logo.svg deleted file mode 100644 index a9803ad..0000000 --- a/public/footer_logo.svg +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/hero/hero-clean-2560.png b/public/hero/hero-clean-2560.png new file mode 100644 index 0000000..aa330ae Binary files /dev/null and b/public/hero/hero-clean-2560.png differ diff --git a/public/hero/hero-clean-3840.png b/public/hero/hero-clean-3840.png new file mode 100644 index 0000000..d46678d Binary files /dev/null and b/public/hero/hero-clean-3840.png differ diff --git a/public/hero/hero-clean-blur.webp b/public/hero/hero-clean-blur.webp new file mode 100644 index 0000000..c90ba54 Binary files /dev/null and b/public/hero/hero-clean-blur.webp differ diff --git a/public/hero/hero-clean.png b/public/hero/hero-clean.png new file mode 100644 index 0000000..e785a41 Binary files /dev/null and b/public/hero/hero-clean.png differ diff --git a/public/hero/hero-cloud-blur.webp b/public/hero/hero-cloud-blur.webp new file mode 100644 index 0000000..36b9e77 Binary files /dev/null and b/public/hero/hero-cloud-blur.webp differ diff --git a/public/hero/hero-cloud.jpg b/public/hero/hero-cloud.jpg new file mode 100644 index 0000000..06ff287 Binary files /dev/null and b/public/hero/hero-cloud.jpg differ diff --git a/public/hero/hero-flower-blur.webp b/public/hero/hero-flower-blur.webp new file mode 100644 index 0000000..1b32b3c Binary files /dev/null and b/public/hero/hero-flower-blur.webp differ diff --git a/public/hero/hero-flower.jpg b/public/hero/hero-flower.jpg new file mode 100644 index 0000000..e7e53b1 Binary files /dev/null and b/public/hero/hero-flower.jpg differ diff --git a/public/hero/icon-cloud.png b/public/hero/icon-cloud.png new file mode 100644 index 0000000..df39b59 Binary files /dev/null and b/public/hero/icon-cloud.png differ diff --git a/public/hero/icon-flower.png b/public/hero/icon-flower.png new file mode 100644 index 0000000..6a56558 Binary files /dev/null and b/public/hero/icon-flower.png differ diff --git a/public/hero/icon-leaf.png b/public/hero/icon-leaf.png new file mode 100644 index 0000000..8475c5a Binary files /dev/null and b/public/hero/icon-leaf.png differ diff --git a/public/hero_bg_clear.jpg b/public/hero_bg_clear.jpg deleted file mode 100644 index 6ed781e..0000000 Binary files a/public/hero_bg_clear.jpg and /dev/null differ diff --git a/public/hero_bg_w_overlay_mobile.png b/public/hero_bg_w_overlay_mobile.png deleted file mode 100644 index bfbbd53..0000000 Binary files a/public/hero_bg_w_overlay_mobile.png and /dev/null differ diff --git a/public/idk_what_this_is.jpg b/public/idk_what_this_is.jpg deleted file mode 100644 index 5450ced..0000000 Binary files a/public/idk_what_this_is.jpg and /dev/null differ diff --git a/public/logos/mhacks-logo.png b/public/logos/mhacks-logo.png new file mode 100644 index 0000000..fd6050f Binary files /dev/null and b/public/logos/mhacks-logo.png differ diff --git a/public/logos/mlh-trust-badge-2027-black.svg b/public/logos/mlh-trust-badge-2027-black.svg new file mode 100644 index 0000000..e165484 --- /dev/null +++ b/public/logos/mlh-trust-badge-2027-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/pixel_flowers_blue.svg b/public/pixel_flowers_blue.svg deleted file mode 100644 index e811266..0000000 --- a/public/pixel_flowers_blue.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/public/pixel_flowers_green.svg b/public/pixel_flowers_green.svg deleted file mode 100644 index 1271598..0000000 --- a/public/pixel_flowers_green.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/public/social/garland-white.webp b/public/social/garland-white.webp new file mode 100644 index 0000000..b93cafb Binary files /dev/null and b/public/social/garland-white.webp differ diff --git a/public/sponsors-ascii.png b/public/sponsors-ascii.png deleted file mode 100644 index 7053e36..0000000 Binary files a/public/sponsors-ascii.png and /dev/null differ diff --git a/public/sponsors/branch.webp b/public/sponsors/branch.webp new file mode 100644 index 0000000..9ea12e0 Binary files /dev/null and b/public/sponsors/branch.webp differ diff --git a/public/sponsors_bot.png b/public/sponsors_bot.png deleted file mode 100644 index 1df47c1..0000000 Binary files a/public/sponsors_bot.png and /dev/null differ diff --git a/public/timeline/garland-orange.webp b/public/timeline/garland-orange.webp new file mode 100644 index 0000000..8183d19 Binary files /dev/null and b/public/timeline/garland-orange.webp differ diff --git a/public/timeline_pointer_ann_arbor.svg b/public/timeline_pointer_ann_arbor.svg deleted file mode 100644 index b8fcd60..0000000 --- a/public/timeline_pointer_ann_arbor.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/timeline_pointer_oct_3_4.svg b/public/timeline_pointer_oct_3_4.svg deleted file mode 100644 index 762efaf..0000000 --- a/public/timeline_pointer_oct_3_4.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - -