buildmymcpserver/apps/web/components/particle-hero/ParticleField.tsx
Marco Sadjadi e4e437c44c
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
feat(web): hero redesign — cycling step rotator + full-width video section
Restructures the landing page above-the-fold into two distinct sections:

1. **Hero — left copy + cycling tile, no static stack of three blocks**
   New `<HeroStepRotator>` (Framer Motion client component) shows ONE
   tile centred in the column, cycling prompt.txt → build.log →
   claude_desktop_config.json every 3.5s. Auto-advance pauses on hover
   and exposes a 3-dot tablist so users can jump to any step. The active
   dot grows wide with an accent glow.

   Mouse interaction: spring-smoothed 3D tilt on rotateX/rotateY plus a
   radial glow that translates toward the cursor — both driven by motion
   values, so the transforms stay on the GPU compositor instead of
   re-rendering on every mousemove. `useReducedMotion()` strips the
   tilt + glow translation and collapses the page transition to an
   instant cross-fade (the rotation itself still advances — it's content,
   not decoration).

   Hero padding tightened (py-12/14/16 vs py-14/20/28) so the video
   section below is teased above the fold. New scroll cue ("see it run"
   + animated chevron) sits at the bottom of the hero, anchored to
   #flow.

2. **Flow video — full-width edge-to-edge under the hero (new section)**
   The hero.mp4 / hero.webm pair moves out of the "How it works"
   section into its own #flow section. No max-w wrapper — it spans the
   viewport with `w-full aspect-video`, so on a 1080p monitor the video
   gets the full 1920px width. Adds a subtle radial vignette so the
   black edges blend into the page chrome.

3. **"How it works" — now lean**
   Video removed (it's the flow section now). Just the three textual
   cards as supporting copy.

Adds `framer-motion@11.18.2` to apps/web/package.json. Build passes
typecheck + Next.js production build with no new warnings; LCP path is
untouched since the rotator is client-hydrated after first paint and
Framer Motion is tree-shaken to the components we import.

Note: visitors with `prefers-reduced-motion: reduce` will still see the
video's poster instead of autoplay — Chrome blocks the network fetch
entirely for autoplay media when reduced-motion is set. The flow video
remains visible for the rest, and the step rotator continues to cycle
its content (with instant cross-fade instead of slide+scale).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:05:28 +02:00

323 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<HTMLDivElement | null>(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 (
<div
ref={containerRef}
aria-hidden="true"
className="absolute inset-0 size-full"
/>
);
}