diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index 1e26bf3..c2d2ef8 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -1,6 +1,9 @@ +import { HeroStepRotator } from '@/components/hero-step-rotator'; import { JsonLd } from '@/components/json-ld'; +import { ParticleHero } from '@/components/particle-hero'; import { StaticCodeBlock } from '@/components/static-code-block'; import { FAQ, faqJsonLd } from '@/lib/seo'; +import { ChevronDown } from 'lucide-react'; import Link from 'next/link'; const PROMPT_EXAMPLE = `Create an MCP server that searches our Notion workspace. @@ -83,14 +86,25 @@ const TIERS = [ export default function Landing() { return ( <> - {/* Hero */} -
-
+ {/* Hero — left: copy + CTAs, right: cycling step-rotator tile. + The old layout stacked three static code blocks vertically; the + new layout shows one centered tile that cycles through the same + three artifacts (prompt → build.log → claude config) with a + mouse-reactive 3D tilt and a step indicator. Shorter overall + so the video section below is teased above the fold. */} +
+ {/* WebGL particle field — capability-detected client component. + Sits behind the hero content at z-0 with pointer-events:none + so the CTAs above remain fully interactive. The canvas listens + for pointermove on window itself, so the ring still tracks + the cursor through the content above. */} + +
v0.1 — updated 2026-05-20 -

+

Describe your tool.
We host the server. @@ -115,7 +129,7 @@ export default function Landing() { Read the docs

-
+
OAuth 2.1 + PKCE @@ -132,14 +146,53 @@ export default function Landing() {
-
-
- - - -
+
+ {/* Scroll cue — hints the video section sits directly below. */} + + see it run + + +
+ + {/* Flow video — full-width edge-to-edge under the hero. The clip + shows the real flow (prompt → server schematic → live connection + to Claude Desktop) in three smooth phases. autoplay-muted-loop + + playsInline satisfies every mobile browser autoplay policy; the + `poster` carries first paint while the video decodes. */} +
+
+ + {/* Subtle vignette to integrate edges into the rest of the page */} +
+
{/* How it works */} @@ -152,33 +205,11 @@ export default function Landing() {

- {/* Hero video — visualises the three beats: prompt → transform → - live server connected to client. autoplay-muted-loop satisfies - every mobile browser autoplay policy. WebM is offered first - (smaller file on Chrome/Firefox), MP4 is the Safari fallback. - motion-reduce:hidden swaps in a static poster for users with - prefers-reduced-motion — Tailwind ships that variant by default. */} -
- - {/* prefers-reduced-motion fallback */} - Prompt becoming a live MCP server -
- + {/* The same video used to live here; it now has its own + full-width section directly under the hero so it's teased + above the fold and gets edge-to-edge real estate. This + section keeps the three explanatory cards as supporting + copy under the video. */}
{[ { diff --git a/apps/web/components/hero-animation.tsx b/apps/web/components/hero-animation.tsx new file mode 100644 index 0000000..0e9f0bd --- /dev/null +++ b/apps/web/components/hero-animation.tsx @@ -0,0 +1,591 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +/** + * HeroAnimation — full-bleed Canvas 2D piece for the marketing hero. + * + * Four layers (back to front): + * 1. Drifting indigo particle dots (texture) + * 2. Code-block panel with typewriter cycling 3 snippets (centerpiece) + * 3. Mouse-tracking glow ring (interactive, additive) + * 4. Data packets shooting from the panel toward the canvas edges + * + * Hex colors are hardcoded here on purpose — Tailwind v4 beta oklch tokens + * don't round-trip cleanly when read from JS, and we want pixel-stable + * brand colors inside the canvas regardless of the design-token layer. + */ + +const COLORS = { + bg: '#0a0a0b', + bgElevated: '#111114', + border: '#1f1f22', + borderStrong: '#2a2a2e', + fg: '#fafafa', + fgMuted: '#a1a1aa', + fgSubtle: '#71717a', + accent: '#6366f1', + success: '#22c55e', +} as const; + +const SNIPPETS: ReadonlyArray> = [ + [ + '// MCP server — auto-generated', + 'import { Server } from "@bmm/mcp";', + '', + 'const server = new Server({', + ' name: "data-bridge",', + ' tools: [getRecord, search, write],', + '});', + '', + 'await server.listen();', + ], + [ + '> Generating spec... OK', + '> Static checks OK', + '> Building image OK 17.2s', + '> Deploying container OK', + '> Live at https://...mcp.bmm.io', + ], + [ + '// claude_desktop_config.json', + '{', + ' "mcpServers": {', + ' "data-bridge": {', + ' "url": "https://data.mcp.bmm.io",', + ' "auth": "oauth2"', + ' }', + ' }', + '}', + ], +]; + +const KEYWORDS = new Set([ + 'import', + 'from', + 'const', + 'await', + 'new', + 'return', + 'function', + 'let', + 'var', + 'if', + 'else', + 'export', + 'default', +]); + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + r: number; + seed: number; +} + +interface Packet { + x: number; + y: number; + vx: number; + vy: number; + life: number; // 0..1, decreases + maxLife: number; +} + +interface HeroAnimationProps { + className?: string; +} + +export function HeroAnimation({ className }: HeroAnimationProps) { + const wrapRef = useRef(null); + const canvasRef = useRef(null); + + useEffect(() => { + const wrap = wrapRef.current; + const canvas = canvasRef.current; + if (!wrap || !canvas) return; + + const ctx = canvas.getContext('2d', { alpha: true }); + if (!ctx) return; + + const reducedMotion = + typeof window !== 'undefined' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + // ----- sizing (DPR-aware) ----- + let cssW = 0; + let cssH = 0; + const setSize = () => { + const rect = wrap.getBoundingClientRect(); + cssW = Math.max(1, Math.floor(rect.width)); + cssH = Math.max(1, Math.floor(rect.height)); + const dpr = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = cssW * dpr; + canvas.height = cssH * dpr; + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + seedParticles(); + }; + + // ----- particles ----- + const particles: Particle[] = []; + const PARTICLE_COUNT = 60; + const seedParticles = () => { + particles.length = 0; + for (let i = 0; i < PARTICLE_COUNT; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = (0.15 + Math.random() * 0.25) * (reducedMotion ? 0.4 : 1); + particles.push({ + x: Math.random() * cssW, + y: Math.random() * cssH, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + r: 0.7 + Math.random() * 1.3, + seed: Math.random() * 1000, + }); + } + }; + + // ----- pointer state ----- + // Track in CSS pixels (post setTransform), -1,-1 means "not over". + const pointer = { + raw: { x: -1, y: -1 }, + smooth: { x: cssW / 2, y: cssH / 2 }, + inside: false, + ringAlpha: 0, // eased + }; + + const onPointerMove = (e: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + pointer.raw.x = e.clientX - rect.left; + pointer.raw.y = e.clientY - rect.top; + pointer.inside = true; + }; + const onPointerLeave = () => { + pointer.inside = false; + }; + canvas.addEventListener('pointermove', onPointerMove, { passive: true }); + canvas.addEventListener('pointerleave', onPointerLeave, { passive: true }); + canvas.addEventListener('pointercancel', onPointerLeave, { passive: true }); + + // ----- packets ----- + const packets: Packet[] = []; + let lastPacketSpawn = 0; + + // ----- typewriter state ----- + // Snippet cycle: + // typing -> holding -> erasing -> pausing -> next snippet + // Timings calibrated so each snippet runs ~4-5s. + const FORWARD_MS = reducedMotion ? 45 : 25; + const ERASE_MS = reducedMotion ? 28 : 15; + const HOLD_MS = 2500; + const PAUSE_MS = 500; + + let snippetIndex = 0; + type Phase = 'typing' | 'holding' | 'erasing' | 'pausing'; + let phase: Phase = 'typing'; + let phaseStart = 0; + // Characters revealed across the FULL snippet text including newlines. + const fullText = (i: number): string => + (SNIPPETS[i] ?? []).join('\n'); + + // ----- animation loop ----- + let rafId = 0; + let startTime = performance.now(); + let lastFrame = startTime; + let visible = !document.hidden; + + const onVisibility = () => { + visible = !document.hidden; + if (visible) { + // Reset frame delta so paused time doesn't lurch animation. + lastFrame = performance.now(); + rafId = requestAnimationFrame(frame); + } + }; + document.addEventListener('visibilitychange', onVisibility); + + // ----- drawing helpers ----- + const drawParticles = (elapsed: number, dt: number) => { + ctx.save(); + ctx.fillStyle = COLORS.accent; + ctx.globalAlpha = 0.2; + const driftScale = reducedMotion ? 0.4 : 1; + for (const p of particles) { + // Cheap pseudo-simplex: layered sines indexed by seed + time. + const t = elapsed * 0.0004; + const nx = Math.sin(t + p.seed) * Math.cos(t * 0.7 + p.seed * 1.3); + const ny = Math.cos(t * 0.9 + p.seed * 0.6) * Math.sin(t * 0.5 + p.seed); + p.x += (p.vx + nx * 0.3) * dt * 0.06 * driftScale; + p.y += (p.vy + ny * 0.3) * dt * 0.06 * driftScale; + + // Wrap around edges so density stays uniform. + if (p.x < -4) p.x = cssW + 4; + else if (p.x > cssW + 4) p.x = -4; + if (p.y < -4) p.y = cssH + 4; + else if (p.y > cssH + 4) p.y = -4; + + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + }; + + // Returns { x, y, w, h } of the code panel in CSS pixels. + const panelRect = () => { + const w = cssW * 0.8; + // Sized to comfortably fit the longest snippet (~9 lines @ ~18px line-height). + const h = Math.min(cssH * 0.55, 240); + return { + x: (cssW - w) / 2, + y: (cssH - h) / 2, + w, + h, + }; + }; + + const drawPanel = (rect: { x: number; y: number; w: number; h: number }) => { + ctx.save(); + ctx.fillStyle = COLORS.bg; + ctx.strokeStyle = COLORS.border; + ctx.lineWidth = 1; + const r = 8; + const { x, y, w, h } = rect; + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + ctx.restore(); + }; + + // Tokenize a line for syntax color. Cheap heuristic, not a real parser. + type Token = { text: string; color: string }; + const tokenizeLine = (line: string, elapsed: number): Token[] => { + // Comment lines. + if (line.trim().startsWith('//')) { + return [{ text: line, color: COLORS.fgSubtle }]; + } + // Log lines: ">" prefix style, flash "OK" green briefly when it appears. + if (line.startsWith('>')) { + const tokens: Token[] = []; + // Find OK occurrences and color them. + const parts = line.split(/(\bOK\b)/g); + for (const part of parts) { + if (part === 'OK') { + // Subtle flash effect: brighter for first ~500ms after fully typed. + const flash = 0.65 + 0.35 * (0.5 + 0.5 * Math.sin(elapsed * 0.005)); + tokens.push({ + text: part, + color: withAlpha(COLORS.success, flash), + }); + } else { + tokens.push({ text: part, color: COLORS.fgMuted }); + } + } + return tokens; + } + // Generic JS/JSON: split on word boundaries, color keywords + strings. + const tokens: Token[] = []; + // Match: string literals, words, or anything else. + const re = /("[^"]*"|\b[A-Za-z_$][\w$]*\b|[^"\w]+)/g; + let match: RegExpExecArray | null; + while ((match = re.exec(line)) !== null) { + const t = match[0]; + if (t.startsWith('"')) { + tokens.push({ text: t, color: COLORS.fgMuted }); + } else if (KEYWORDS.has(t)) { + tokens.push({ text: t, color: COLORS.accent }); + } else { + tokens.push({ text: t, color: COLORS.fg }); + } + } + return tokens; + }; + + const drawCode = ( + rect: { x: number; y: number; w: number; h: number }, + visibleText: string, + elapsed: number, + ) => { + ctx.save(); + // Clip to panel so text never bleeds over the rounded border. + ctx.beginPath(); + ctx.rect(rect.x, rect.y, rect.w, rect.h); + ctx.clip(); + + const fontSize = 12.5; + const lineHeight = 18; + const padX = 16; + const padY = 14; + ctx.font = `${fontSize}px var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace`; + ctx.textBaseline = 'top'; + + const lines = visibleText.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ''; + const y = rect.y + padY + i * lineHeight; + if (y > rect.y + rect.h - padY) break; + let x = rect.x + padX; + const tokens = tokenizeLine(line, elapsed); + for (const tok of tokens) { + ctx.fillStyle = tok.color; + ctx.fillText(tok.text, x, y); + x += ctx.measureText(tok.text).width; + } + } + + // Blinking caret at the end of the visible text. + const lastLine = lines[lines.length - 1] ?? ''; + const caretX = + rect.x + padX + ctx.measureText(lastLine).width; + const caretY = rect.y + padY + (lines.length - 1) * lineHeight; + const blink = (elapsed % 1000) < 500; + if (blink && caretY + lineHeight <= rect.y + rect.h - padY + lineHeight) { + ctx.fillStyle = COLORS.accent; + ctx.fillRect(caretX + 1, caretY + 2, 6, fontSize); + } + ctx.restore(); + }; + + const drawRing = (elapsed: number) => { + if (pointer.ringAlpha < 0.005) return; + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + const cx = pointer.smooth.x; + const cy = pointer.smooth.y; + const baseRadii: ReadonlyArray<{ r: number; w: number; a: number }> = [ + { r: 60, w: 2.4, a: 0.4 }, + { r: 90, w: 1.6, a: 0.28 }, + { r: 130, w: 1.0, a: 0.18 }, + ]; + for (const band of baseRadii) { + ctx.beginPath(); + const segments = 96; + for (let i = 0; i <= segments; i++) { + const theta = (i / segments) * Math.PI * 2; + // Breathing distortion around the circumference. + const wobble = Math.sin(theta * 3 + elapsed * 0.002) * 4; + const r = band.r + wobble; + const x = cx + Math.cos(theta) * r; + const y = cy + Math.sin(theta) * r; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.strokeStyle = withAlpha( + COLORS.accent, + band.a * pointer.ringAlpha, + ); + ctx.lineWidth = band.w; + ctx.stroke(); + } + ctx.restore(); + }; + + const spawnPacket = (rect: { x: number; y: number; w: number; h: number }) => { + // Pick an edge of the panel. + const edge = Math.floor(Math.random() * 4); + let x = 0; + let y = 0; + let vx = 0; + let vy = 0; + const speed = 0.06 + Math.random() * 0.04; // CSS px / ms + if (edge === 0) { + // top + x = rect.x + Math.random() * rect.w; + y = rect.y; + vx = (Math.random() - 0.5) * 0.02; + vy = -speed; + } else if (edge === 1) { + // right + x = rect.x + rect.w; + y = rect.y + Math.random() * rect.h; + vx = speed; + vy = (Math.random() - 0.5) * 0.02; + } else if (edge === 2) { + // bottom + x = rect.x + Math.random() * rect.w; + y = rect.y + rect.h; + vx = (Math.random() - 0.5) * 0.02; + vy = speed; + } else { + // left + x = rect.x; + y = rect.y + Math.random() * rect.h; + vx = -speed; + vy = (Math.random() - 0.5) * 0.02; + } + // Distance to nearest canvas edge along direction → travel time. + const distX = vx > 0 ? cssW - x : vx < 0 ? x : Infinity; + const distY = vy > 0 ? cssH - y : vy < 0 ? y : Infinity; + const tx = vx !== 0 ? distX / Math.abs(vx) : Infinity; + const ty = vy !== 0 ? distY / Math.abs(vy) : Infinity; + const maxLife = Math.min(tx, ty); + packets.push({ x, y, vx, vy, life: 1, maxLife }); + }; + + const drawPackets = (dt: number) => { + ctx.save(); + for (let i = packets.length - 1; i >= 0; i--) { + const p = packets[i]; + if (!p) continue; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.life -= dt / p.maxLife; + if ( + p.life <= 0 || + p.x < -4 || + p.x > cssW + 4 || + p.y < -4 || + p.y > cssH + 4 + ) { + packets.splice(i, 1); + continue; + } + const alpha = Math.max(0, p.life) * 0.85; + ctx.fillStyle = withAlpha(COLORS.accent, alpha); + ctx.beginPath(); + ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + }; + + // ----- main frame ----- + const frame = (now: number) => { + if (!visible) return; // visibilitychange will restart us + const dt = Math.min(48, now - lastFrame); // cap to avoid lurches + lastFrame = now; + const elapsed = now - startTime; + + // Pointer smoothing (EMA). When outside, ease ring alpha to 0 (~400ms). + if (pointer.inside && pointer.raw.x >= 0) { + pointer.smooth.x = 0.82 * pointer.smooth.x + 0.18 * pointer.raw.x; + pointer.smooth.y = 0.82 * pointer.smooth.y + 0.18 * pointer.raw.y; + // Ease alpha to 1 over ~250ms. + pointer.ringAlpha = Math.min(1, pointer.ringAlpha + dt / 250); + } else { + pointer.ringAlpha = Math.max(0, pointer.ringAlpha - dt / 400); + } + + // Clear (transparent — wrapper supplies bg color). + ctx.clearRect(0, 0, cssW, cssH); + + // Layer 1: particles + drawParticles(elapsed, dt); + + // Layer 2: panel + typewriter + const rect = panelRect(); + drawPanel(rect); + + // Compute visible characters for the current snippet. + const text = fullText(snippetIndex); + const totalChars = text.length; + let visibleChars = 0; + const sincePhase = now - phaseStart; + if (phase === 'typing') { + visibleChars = Math.min(totalChars, Math.floor(sincePhase / FORWARD_MS)); + if (visibleChars >= totalChars) { + phase = 'holding'; + phaseStart = now; + } + } else if (phase === 'holding') { + visibleChars = totalChars; + if (sincePhase >= HOLD_MS) { + phase = 'erasing'; + phaseStart = now; + } + } else if (phase === 'erasing') { + visibleChars = Math.max( + 0, + totalChars - Math.floor(sincePhase / ERASE_MS), + ); + if (visibleChars <= 0) { + phase = 'pausing'; + phaseStart = now; + } + } else { + visibleChars = 0; + if (sincePhase >= PAUSE_MS) { + snippetIndex = (snippetIndex + 1) % SNIPPETS.length; + phase = 'typing'; + phaseStart = now; + } + } + drawCode(rect, text.slice(0, visibleChars), elapsed); + + // Layer 4 (drawn before ring so the glow sits on top): packets. + // Skipped under reduced-motion. + if (!reducedMotion) { + if (now - lastPacketSpawn > 600 && packets.length < 6) { + spawnPacket(rect); + lastPacketSpawn = now; + } + drawPackets(dt); + } + + // Layer 3: glow ring (additive, on top) + drawRing(elapsed); + + rafId = requestAnimationFrame(frame); + }; + + // ----- resize observer ----- + const ro = new ResizeObserver(() => { + setSize(); + }); + ro.observe(wrap); + setSize(); + + // Kick off. + startTime = performance.now(); + lastFrame = startTime; + phaseStart = startTime; + rafId = requestAnimationFrame(frame); + + return () => { + cancelAnimationFrame(rafId); + ro.disconnect(); + document.removeEventListener('visibilitychange', onVisibility); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerleave', onPointerLeave); + canvas.removeEventListener('pointercancel', onPointerLeave); + }; + }, []); + + return ( +
+
+ ); +} + +/** + * Append an alpha channel to a 6-digit hex color. + * `withAlpha('#6366f1', 0.4)` -> 'rgba(99, 102, 241, 0.4)' + */ +function withAlpha(hex: string, alpha: number): string { + const h = hex.replace('#', ''); + const r = parseInt(h.slice(0, 2), 16); + const g = parseInt(h.slice(2, 4), 16); + const b = parseInt(h.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} diff --git a/apps/web/components/hero-step-rotator.tsx b/apps/web/components/hero-step-rotator.tsx new file mode 100644 index 0000000..fb8a40d --- /dev/null +++ b/apps/web/components/hero-step-rotator.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { + AnimatePresence, + motion, + useMotionValue, + useReducedMotion, + useSpring, + useTransform, +} from 'framer-motion'; +import { useEffect, useRef, useState } from 'react'; + +interface Step { + label: string; // file-name badge top-left + badge: string; // "01 · Describe" badge top-right + code: string; +} + +const STEPS: Step[] = [ + { + label: 'prompt.txt', + badge: '01 · Describe', + code: `Create an MCP server that searches our Notion workspace. +Tools: search_pages, get_page_content. +Auth: NOTION_API_KEY.`, + }, + { + label: 'build.log', + badge: '02 · Generate', + code: `> Generating spec... OK (2 tools) +> Static checks OK +> Building image bmm-mcp-notion OK 17.2s +> Deploying container OK +> Live at https://notion-x9.mcp.buildmymcpserver.com +> First request: 401 → token → 200 OK`, + }, + { + label: 'claude_desktop_config.json', + badge: '03 · Connect', + code: `{ + "mcpServers": { + "notion": { + "url": "https://notion-x9.mcp.buildmymcpserver.com/mcp", + "auth": "oauth2" + } + } +}`, + }, +]; + +const AUTO_MS = 3500; + +/** + * Hero step rotator — single centered tile cycling through three states. + * + * Replaces the old static stack of three code blocks. Auto-advances every + * 3.5s, pauses on hover, jumps instantly when the user clicks a dot. + * + * Mouse interaction: the tile reacts with a subtle 3D tilt driven by spring- + * smoothed motion values, plus a radial glow that translates toward the + * cursor. Both are disabled when `prefers-reduced-motion: reduce` is set — + * the rotation is content-essential and still advances, but the transition + * collapses to an instant cross-fade and the tilt/glow are stripped out. + */ +export function HeroStepRotator() { + const [step, setStep] = useState(0); + const [paused, setPaused] = useState(false); + const containerRef = useRef(null); + const reduced = useReducedMotion(); + + // Mouse motion values for tilt + glow translation. `mx` and `my` are + // raw cursor offsets (-1..1); the `*Spring` versions add the smoothing + // so the tilt doesn't jitter on every mousemove event. + const mx = useMotionValue(0); + const my = useMotionValue(0); + const mxSpring = useSpring(mx, { damping: 22, stiffness: 200 }); + const mySpring = useSpring(my, { damping: 22, stiffness: 200 }); + const rotateX = useTransform(mySpring, [-1, 1], reduced ? [0, 0] : [6, -6]); + const rotateY = useTransform(mxSpring, [-1, 1], reduced ? [0, 0] : [-8, 8]); + // Glow translates rather than re-rendering background-position — keeps + // it on the GPU compositor instead of pegging the main thread. + const glowDx = useTransform(mxSpring, [-1, 1], reduced ? [0, 0] : [-140, 140]); + const glowDy = useTransform(mySpring, [-1, 1], reduced ? [0, 0] : [-110, 110]); + + useEffect(() => { + if (paused) return; + const t = setTimeout(() => setStep((s) => (s + 1) % STEPS.length), AUTO_MS); + return () => clearTimeout(t); + }, [step, paused]); + + function onMove(e: React.MouseEvent) { + const r = containerRef.current?.getBoundingClientRect(); + if (!r) return; + const x = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width) * 2 - 1)); + const y = Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height) * 2 - 1)); + mx.set(x); + my.set(y); + } + function onEnter() { + setPaused(true); + } + function onLeave() { + setPaused(false); + mx.set(0); + my.set(0); + } + + const current = STEPS[step]!; + + return ( +
+
+ + + {/* Cursor-following glow — sits behind the content, additive. */} + +
+ + {current.label} + + + {current.badge} + +
+
+              {current.code}
+            
+
+
+
+ + {/* Step indicator — accent dot is wider + glows so the active step + reads at a glance. Buttons stay clickable so users can jump. */} +
+ {STEPS.map((s, i) => ( +
+
+ ); +} diff --git a/apps/web/components/particle-hero/ParticleField.tsx b/apps/web/components/particle-hero/ParticleField.tsx new file mode 100644 index 0000000..8217850 --- /dev/null +++ b/apps/web/components/particle-hero/ParticleField.tsx @@ -0,0 +1,322 @@ +'use client'; + +/** + * ParticleField — GPGPU particle simulation backing the marketing hero. + * + * Lean Three.js, no @react-three/fiber. Renders a 256×256 (or 128×128 + * on lower-end devices) float texture of positions, ping-ponged each + * frame through a sim shader, then drawn as gl_Points with an + * anti-aliased disc SDF and additive blending. + * + * Callers MUST gate this behind the capability checks in `index.tsx` — + * this component assumes WebGL2 + float-render-target support exists + * and will throw if they don't. + */ + +import { useEffect, useRef } from 'react'; +import * as THREE from 'three'; + +import { + initFragment, + renderFragment, + renderVertex, + simFragment, + simVertex, +} from './shaders'; + +export interface ParticleFieldProps { + /** Sqrt of particle count. 256 → 65,536 particles, 128 → 16,384. */ + textureSize: 128 | 256; + /** + * Global multiplier on drift + ring-push velocity. 1.0 default; 0.5 + * for reduced-motion users. Pointer position is NOT scaled — the ring + * still tracks the cursor at full fidelity, only the ambient motion + * and the gradient-push are damped. + */ + motionScale?: number; +} + +export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldProps) { + const containerRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + // ----- Renderer --------------------------------------------------- + // alpha:true so the hero gradient/border behind the canvas shows + // through where particles are sparse. premultipliedAlpha pairs with + // the premultiplied output of the render fragment shader. + const canvas = document.createElement('canvas'); + canvas.style.display = 'block'; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + container.appendChild(canvas); + + const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: false, + alpha: true, + premultipliedAlpha: true, + powerPreference: 'high-performance', + }); + renderer.setClearColor(0x000000, 0); + + // Clamp DPR — going above 2 on a 65k-particle field burns laptop GPUs + // with negligible visual gain. + const dpr = Math.min(window.devicePixelRatio || 1, 2); + renderer.setPixelRatio(dpr); + + const initialRect = container.getBoundingClientRect(); + renderer.setSize(Math.max(1, initialRect.width), Math.max(1, initialRect.height), false); + + // ----- Float-texture support check -------------------------------- + // EXT_color_buffer_float is required to render INTO a float target + // on WebGL2. Without it, ping-pong won't work — bail out and let the + // wrapper fall back to the static gradient. + const gl = renderer.getContext() as WebGL2RenderingContext; + const floatExt = gl.getExtension('EXT_color_buffer_float'); + if (!floatExt) { + // Tear down what we built and signal failure via the canvas + // remaining empty. The wrapper checks this synchronously before + // we even mount, so this is a belt-and-braces guard. + canvas.remove(); + renderer.dispose(); + return; + } + + // ----- Scenes & camera -------------------------------------------- + // Two scenes: one for the simulation pass (fullscreen quad), one + // for the actual particle render. Both use the same OrthographicCamera + // because everything is already in clip space. + const simScene = new THREE.Scene(); + const renderScene = new THREE.Scene(); + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + // ----- Ping-pong render targets ------------------------------------ + const rtParams: THREE.RenderTargetOptions = { + minFilter: THREE.NearestFilter, + magFilter: THREE.NearestFilter, + format: THREE.RGBAFormat, + type: THREE.FloatType, + depthBuffer: false, + stencilBuffer: false, + generateMipmaps: false, + }; + + let rtA = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams); + let rtB = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams); + + // ----- Init pass: seed both targets with the starting field ------- + const initMaterial = new THREE.ShaderMaterial({ + vertexShader: simVertex, + fragmentShader: initFragment, + }); + const fsQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), initMaterial); + simScene.add(fsQuad); + + renderer.setRenderTarget(rtA); + renderer.render(simScene, camera); + renderer.setRenderTarget(rtB); + renderer.render(simScene, camera); + renderer.setRenderTarget(null); + + // Swap in the actual sim material on the same quad. + const simUniforms = { + uPrev: { value: rtA.texture }, + uTime: { value: 0 }, + uDelta: { value: 1 / 60 }, + uRingPos: { value: new THREE.Vector2(0, 0) }, + uRingRadius: { value: 0.22 }, + uRingWidth: { value: 0.05 }, + uRingActive: { value: 0 }, + uMotionScale: { value: motionScale }, + }; + const simMaterial = new THREE.ShaderMaterial({ + vertexShader: simVertex, + fragmentShader: simFragment, + uniforms: simUniforms, + }); + fsQuad.material = simMaterial; + initMaterial.dispose(); + + // ----- Particle geometry: one vertex per texel -------------------- + const count = textureSize * textureSize; + const indexUvs = new Float32Array(count * 2); + const positionsAttr = new Float32Array(count * 3); // unused but required + for (let y = 0; y < textureSize; y++) { + for (let x = 0; x < textureSize; x++) { + const i = y * textureSize + x; + // Sample texels at their centers, not corners. + indexUvs[i * 2 + 0] = (x + 0.5) / textureSize; + indexUvs[i * 2 + 1] = (y + 0.5) / textureSize; + } + } + const particleGeo = new THREE.BufferGeometry(); + particleGeo.setAttribute('position', new THREE.BufferAttribute(positionsAttr, 3)); + particleGeo.setAttribute('aIndexUv', new THREE.BufferAttribute(indexUvs, 2)); + // Tell Three.js never to frustum-cull this — positions live in + // the texture, not the bounding box of the buffer geometry. + particleGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 10); + + // Brand colors — read from --color-accent (#6366f1) and + // --color-success (#22c55e). Kept as constants here rather than + // reading from CSS variables: those resolve to oklch in modern + // Tailwind builds, which needs parsing. Hardcode the hex values + // the design system already commits to. + const colorCalm = new THREE.Color('#6366f1'); + const colorHot = new THREE.Color('#22c55e'); + + const renderUniforms = { + uPositions: { value: rtB.texture }, + uPointSize: { value: textureSize === 256 ? 1.8 : 2.4 }, + uDpr: { value: dpr }, + uColorCalm: { value: colorCalm }, + uColorHot: { value: colorHot }, + uBaseAlpha: { value: 0.42 }, + }; + const particleMat = new THREE.ShaderMaterial({ + vertexShader: renderVertex, + fragmentShader: renderFragment, + uniforms: renderUniforms, + transparent: true, + depthTest: false, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const particles = new THREE.Points(particleGeo, particleMat); + renderScene.add(particles); + + // ----- Pointer tracking ------------------------------------------ + // Raw target (last pointer event), smoothed via EMA into the + // uniform each frame so the ring tracks fluidly even if events + // are sparse (touch / pen / throttled mouse). + const target = new THREE.Vector2(0, 0); + const smoothed = new THREE.Vector2(0, 0); + let hasPointer = false; + + const updatePointerFromClient = (clientX: number, clientY: number) => { + const rect = container.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * 2 - 1; + // Flip Y so up is positive — matches clip space. + const y = -(((clientY - rect.top) / rect.height) * 2 - 1); + target.set(x, y); + hasPointer = true; + }; + + const onPointerMove = (e: PointerEvent) => { + updatePointerFromClient(e.clientX, e.clientY); + }; + const onPointerLeave = () => { + hasPointer = false; + }; + + // Listen on window so the ring tracks even when the cursor is over + // the codeblocks/CTAs that sit above the canvas. The container is + // pointer-events:none-friendly because we read clientX/clientY. + window.addEventListener('pointermove', onPointerMove, { passive: true }); + container.addEventListener('pointerleave', onPointerLeave, { passive: true }); + + // ----- Resize handling ------------------------------------------- + const onResize = () => { + const rect = container.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return; + renderer.setSize(rect.width, rect.height, false); + }; + const ro = new ResizeObserver(onResize); + ro.observe(container); + + // ----- Animation loop -------------------------------------------- + const clock = new THREE.Clock(); + let raf = 0; + let running = true; + + // Defer the first frame to idle to keep LCP clean — the hero text + // is the LCP element and must paint before we start eating GPU. + const startLoop = () => { + const tick = () => { + if (!running) return; + raf = requestAnimationFrame(tick); + + const delta = Math.min(clock.getDelta(), 1 / 30); // tab-switch guard + const t = clock.elapsedTime; + + // Smooth the pointer position (EMA, alpha=0.15). + smoothed.x = smoothed.x * 0.85 + target.x * 0.15; + smoothed.y = smoothed.y * 0.85 + target.y * 0.15; + + // Fade ring in/out when the pointer enters/leaves. + const targetActive = hasPointer ? 1 : 0; + simUniforms.uRingActive.value = + simUniforms.uRingActive.value * 0.92 + targetActive * 0.08; + + simUniforms.uTime.value = t; + simUniforms.uDelta.value = delta; + simUniforms.uRingPos.value.copy(smoothed); + + // Sim pass: read rtA, write rtB. + simUniforms.uPrev.value = rtA.texture; + renderer.setRenderTarget(rtB); + renderer.render(simScene, camera); + renderer.setRenderTarget(null); + + // Render pass: draw particles sampling rtB. + renderUniforms.uPositions.value = rtB.texture; + renderer.render(renderScene, camera); + + // Swap. + const tmp = rtA; + rtA = rtB; + rtB = tmp; + }; + tick(); + }; + + let idleHandle: number | null = null; + const w = window as Window & { + requestIdleCallback?: (cb: IdleRequestCallback) => number; + cancelIdleCallback?: (h: number) => void; + }; + if (typeof w.requestIdleCallback === 'function') { + idleHandle = w.requestIdleCallback(() => startLoop()); + } else { + idleHandle = window.setTimeout(startLoop, 80); + } + + // ----- Cleanup ---------------------------------------------------- + // Three.js leaks GPU memory aggressively if we skip any of this. + return () => { + running = false; + if (raf) cancelAnimationFrame(raf); + if (idleHandle !== null) { + if (typeof w.cancelIdleCallback === 'function') { + w.cancelIdleCallback(idleHandle); + } else { + window.clearTimeout(idleHandle); + } + } + window.removeEventListener('pointermove', onPointerMove); + container.removeEventListener('pointerleave', onPointerLeave); + ro.disconnect(); + + particleGeo.dispose(); + particleMat.dispose(); + simMaterial.dispose(); + (fsQuad.geometry as THREE.BufferGeometry).dispose(); + rtA.dispose(); + rtB.dispose(); + renderer.dispose(); + if (canvas.parentNode) canvas.parentNode.removeChild(canvas); + }; + }, [textureSize, motionScale]); + + // The container is the surface that receives pointer events. + // Visually transparent — the canvas it owns paints the field. + return ( +