buildmymcpserver/apps/web/components/particle-hero/ParticleField.tsx
Marco Sadjadi 035e55f00c
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
feat(web): mobile-fit hero tiles + voluminous calmer particle field + FAQ accordion
Three coordinated polish items requested:

1. **Hero step-rotator tiles fit mobile without horizontal scroll.**
   The previous snippets contained a 50+ char `Live at https://notion-x9.mcp.buildmymcpserver.com` URL that overflowed the ~295 px text area on a 375 px viewport. Rewrote all three snippets to be naturally short — same product story, no full URLs. The <pre> drops `overflow-x-auto` and gains `whitespace-pre-wrap break-words` so any token that does exceed the column wraps gracefully instead of forcing a scrollbar.

2. **ParticleHero — more volumetric, slower, steadier at load-in.**
   The "stuttery / too fast" feedback came from two issues compounding: tiny dots (1.8 px on 256-tier, with 0.42 base alpha) gave the eye too few pixels to track between frames, so individual particles read as snapping rather than drifting; and the simplex-noise drift evolved at 0.08 time-scale with 0.045 velocity, fast enough that frame-to-frame deltas exceeded a tracked particle's diameter.

   Render uniforms tuned:
   - `uPointSize` 1.8 → 2.8 (256-tier), 2.4 → 3.6 (128-tier)
   - `uBaseAlpha` 0.42 → 0.60

   Simulation shader tuned:
   - Drift noise time scale 0.08 → 0.045 (the most impactful single change — particles now move at half the previous speed)
   - Drift velocity magnitude 0.045 → 0.028
   - Ring breathing noise time scale 0.35 → 0.22
   - Ring polar-wave time scales 1.2 / 0.7 → 0.7 / 0.42

   Net effect: same number of particles (65k) but each individually larger, brighter, and moving more slowly. The cumulative additive bloom is denser without the jitter that read as visual stutter.

3. **FAQ collapsed into a native `<details>` accordion.**
   Crawlers and screen readers still see every Q+A in the SSR'd HTML — `<details><summary>...</summary><p>answer</p></details>` is the standard semantic pattern for disclosure widgets. Users see one question at a time and expand on demand, which keeps the page from feeling like an endless wall of marketing text below the fold.

   Container narrowed `max-w-6xl` → `max-w-3xl` for accordion typography (long-form prose reads better single-column). The default WebKit disclosure-triangle marker is suppressed with `list-none` + `[&_summary::-webkit-details-marker]:hidden`, and a `lucide-react` `ChevronDown` icon rotates 180° via `group-open:rotate-180` to indicate state.

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

328 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 },
// Bigger dots + higher base alpha = more volumetric "calm field"
// read at the load-in (was 1.8 / 0.42 — read as too thin, looked
// stuttery because individual particles were hard to track between
// frames). With these values the field has a denser cumulative
// glow without any change to the simulation itself.
uPointSize: { value: textureSize === 256 ? 2.8 : 3.6 },
uDpr: { value: dpr },
uColorCalm: { value: colorCalm },
uColorHot: { value: colorHot },
uBaseAlpha: { value: 0.6 },
};
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"
/>
);
}