feat(web): hero redesign — cycling step rotator + full-width video section
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
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>
This commit is contained in:
295
apps/web/components/particle-hero/shaders.ts
Normal file
295
apps/web/components/particle-hero/shaders.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* GLSL shaders for the particle-field hero.
|
||||
*
|
||||
* Shaders are exported as tagged-template strings with a leading
|
||||
* `/* glsl *\/` comment marker so future syntax highlighters or
|
||||
* static analysers can pick them up without us adding a webpack loader.
|
||||
*
|
||||
* Conventions:
|
||||
* - All positions live in clip-space-like coordinates: x, y ∈ [-1, +1].
|
||||
* - Position texture is RGBA32F:
|
||||
* r = x
|
||||
* g = y
|
||||
* b = scale (per-particle render size jitter)
|
||||
* a = velocity magnitude (used for color tint)
|
||||
* - Simulation runs in a fullscreen quad pass — each fragment = one particle.
|
||||
*/
|
||||
|
||||
const simplexNoise = /* glsl */ `
|
||||
// 2D simplex noise by Ian McEwan / Ashima Arts — public domain.
|
||||
// Used both for idle drift in the sim and for organic distortion of
|
||||
// the cursor-tracking ring.
|
||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(
|
||||
0.211324865405187,
|
||||
0.366025403784439,
|
||||
-0.577350269189626,
|
||||
0.024390243902439
|
||||
);
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
i = mod289(i);
|
||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
|
||||
+ i.x + vec3(0.0, i1.x, 1.0));
|
||||
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
|
||||
m = m * m;
|
||||
m = m * m;
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Sim vertex shader — trivial fullscreen pass.
|
||||
* Writes through clip-space UVs so the fragment shader receives one
|
||||
* fragment per particle in the position texture.
|
||||
*/
|
||||
export const simVertex = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Sim fragment shader — the actual integrator.
|
||||
*
|
||||
* Inputs:
|
||||
* uPrev — previous-frame position texture (ping-pong source)
|
||||
* uTime — elapsed seconds
|
||||
* uDelta — clamped frame delta (seconds), guards against tab-switch spikes
|
||||
* uRingPos — mouse position in clip space, smoothed
|
||||
* uRingRadius— current ring radius (clip-space units)
|
||||
* uRingWidth — base ring thickness (clip-space units)
|
||||
* uRingActive— 0..1 fade so the ring softly vanishes when the mouse leaves
|
||||
* uMotionScale— global multiplier on drift + ring-push velocity. 1.0 is
|
||||
* default; the prefers-reduced-motion path passes 0.5 so
|
||||
* the field reads as calm without removing interaction.
|
||||
* Pointer *position* is not scaled — the ring still
|
||||
* tracks the cursor at full fidelity.
|
||||
*
|
||||
* Per-particle dynamics:
|
||||
* 1. Idle drift: rotational simplex-noise velocity field — feels like
|
||||
* slow oceanic currents rather than random brownian jitter.
|
||||
* 2. Ring push: three overlapping smoothstep bands at slightly offset
|
||||
* radii, with the radius input distorted by simplex noise and a
|
||||
* polar sin/cos wave. The gradient of the resulting field is
|
||||
* applied as an outward push, so particles get gently shoved as
|
||||
* the ring sweeps over them.
|
||||
* 3. Containment: a very soft spring pulls particles back toward the
|
||||
* origin if they drift past the field edge — prevents particles
|
||||
* from escaping to infinity on long sessions.
|
||||
* 4. Damping: every frame velocity decays so the field returns to a
|
||||
* calm steady state when the mouse is idle.
|
||||
*/
|
||||
export const simFragment = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uPrev;
|
||||
uniform float uTime;
|
||||
uniform float uDelta;
|
||||
uniform vec2 uRingPos;
|
||||
uniform float uRingRadius;
|
||||
uniform float uRingWidth;
|
||||
uniform float uRingActive;
|
||||
uniform float uMotionScale;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
${simplexNoise}
|
||||
|
||||
// Organic ring field — value peaks ON the ring, falls off either side.
|
||||
// Three overlapping smoothstep bands with simplex-noise + polar-wave
|
||||
// distortion to keep the boundary breathing instead of geometric.
|
||||
float ringField(vec2 p) {
|
||||
vec2 d = p - uRingPos;
|
||||
float r = length(d);
|
||||
float ang = atan(d.y, d.x);
|
||||
|
||||
// Breathing distortion of the radius itself.
|
||||
float noise = snoise(p * 4.0 + uTime * 0.35) * 0.05;
|
||||
// Polar wave — a slow rippling around the circumference.
|
||||
float wave = sin(ang * 5.0 + uTime * 1.2) * 0.012
|
||||
+ cos(ang * 3.0 - uTime * 0.7) * 0.010;
|
||||
float rr = r + noise + wave;
|
||||
|
||||
// Three bands of different thickness at slightly offset radii.
|
||||
float w = uRingWidth;
|
||||
float b1 = smoothstep(uRingRadius - w * 0.30, uRingRadius, rr)
|
||||
* (1.0 - smoothstep(uRingRadius, uRingRadius + w * 0.30, rr));
|
||||
float b2 = smoothstep(uRingRadius - w * 0.80, uRingRadius - w * 0.15, rr)
|
||||
* (1.0 - smoothstep(uRingRadius - w * 0.15, uRingRadius + w * 0.65, rr));
|
||||
float b3 = smoothstep(uRingRadius - w * 1.40, uRingRadius - w * 0.50, rr)
|
||||
* (1.0 - smoothstep(uRingRadius - w * 0.50, uRingRadius + w * 1.20, rr));
|
||||
|
||||
return (b1 * 1.0 + b2 * 0.55 + b3 * 0.30) * uRingActive;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 prev = texture2D(uPrev, vUv);
|
||||
vec2 pos = prev.xy;
|
||||
float scale = prev.z;
|
||||
float velPrev = prev.w;
|
||||
|
||||
// --- Idle drift: rotational simplex-noise current ---
|
||||
// Time is scaled by uMotionScale so reduced-motion users get a
|
||||
// calmer field that evolves at half speed.
|
||||
float driftTime = uTime * uMotionScale;
|
||||
float n1 = snoise(pos * 1.6 + vec2(driftTime * 0.08, 0.0));
|
||||
float n2 = snoise(pos * 1.6 + vec2(0.0, driftTime * 0.08) + 53.7);
|
||||
vec2 driftVel = vec2(-n2, n1) * 0.045 * uMotionScale; // curl-like rotation
|
||||
|
||||
// --- Ring push: gradient of the ring field, pointing outward ---
|
||||
float h = 0.003;
|
||||
float fx0 = ringField(pos - vec2(h, 0.0));
|
||||
float fx1 = ringField(pos + vec2(h, 0.0));
|
||||
float fy0 = ringField(pos - vec2(0.0, h));
|
||||
float fy1 = ringField(pos + vec2(0.0, h));
|
||||
vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h);
|
||||
float fieldHere = ringField(pos);
|
||||
// Push along gradient — particles get nudged away from the ring crest.
|
||||
// Magnitude is scaled by uMotionScale so reduced-motion users get a
|
||||
// softer shove while the ring position still tracks at full fidelity.
|
||||
vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale;
|
||||
|
||||
// --- Soft containment toward origin if particle escaped ---
|
||||
float r = length(pos);
|
||||
vec2 containVel = vec2(0.0);
|
||||
if (r > 1.05) {
|
||||
containVel = -normalize(pos) * (r - 1.05) * 0.6;
|
||||
}
|
||||
|
||||
// --- Integrate ---
|
||||
vec2 vel = driftVel + ringVel + containVel;
|
||||
vec2 next = pos + vel * uDelta * 60.0; // normalise to 60fps reference
|
||||
|
||||
// Velocity magnitude for color tint — EMA so flash decays gracefully.
|
||||
float velMag = length(vel);
|
||||
float velOut = mix(velPrev, velMag, 0.20);
|
||||
|
||||
gl_FragColor = vec4(next, scale, velOut);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Render vertex shader — one vertex per particle, sampled from the
|
||||
* position texture. The vertex's `position` attribute is unused;
|
||||
* instead `aIndexUv` carries the (u, v) coordinate of this particle
|
||||
* inside the position texture, and we read the actual position from
|
||||
* `uPositions`.
|
||||
*
|
||||
* `gl_PointSize` is scaled by per-particle `scale` (z channel) and the
|
||||
* device pixel ratio so the disc stays the same physical size on
|
||||
* retina displays.
|
||||
*/
|
||||
export const renderVertex = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uPositions;
|
||||
uniform float uPointSize;
|
||||
uniform float uDpr;
|
||||
|
||||
attribute vec2 aIndexUv;
|
||||
|
||||
varying float vVel;
|
||||
varying float vScale;
|
||||
|
||||
void main() {
|
||||
vec4 p = texture2D(uPositions, aIndexUv);
|
||||
vScale = p.z;
|
||||
vVel = p.w;
|
||||
|
||||
// Position is already clip-space xy in [-1, +1]; pin z = 0.
|
||||
gl_Position = vec4(p.xy, 0.0, 1.0);
|
||||
gl_PointSize = uPointSize * p.z * uDpr;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Render fragment shader — anti-aliased disc + velocity-based tint.
|
||||
*
|
||||
* Color: most of the field stays calm indigo at low opacity; particles
|
||||
* that just got shoved by the ring (high velocity) flash toward a
|
||||
* success-green tint. Output is premultiplied so additive blending
|
||||
* gives the bloom-like glow without needing a post-process pass.
|
||||
*/
|
||||
export const renderFragment = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform vec3 uColorCalm; // indigo
|
||||
uniform vec3 uColorHot; // success-green
|
||||
uniform float uBaseAlpha;
|
||||
|
||||
varying float vVel;
|
||||
varying float vScale;
|
||||
|
||||
void main() {
|
||||
// Disc SDF — anti-aliased round dot.
|
||||
float d = length(gl_PointCoord - 0.5);
|
||||
float a = smoothstep(0.5, 0.42, d);
|
||||
if (a <= 0.001) discard;
|
||||
|
||||
// Velocity-driven mix: pin to indigo for typical drift, lerp toward
|
||||
// green only on real shoves. The 0.04..0.18 band is roughly where
|
||||
// ring pushes live; idle drift stays below 0.03.
|
||||
float t = smoothstep(0.04, 0.18, vVel);
|
||||
vec3 col = mix(uColorCalm, uColorHot, t);
|
||||
|
||||
float alpha = uBaseAlpha * a * (0.6 + 0.4 * vScale);
|
||||
// Premultiplied alpha — pairs with THREE.AdditiveBlending.
|
||||
gl_FragColor = vec4(col * alpha, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Init fragment — runs once into both ping-pong targets to seed the
|
||||
* starting field. Uses a tempered random distribution: uniform in the
|
||||
* disc, with a small radial bias toward the edges so the field doesn't
|
||||
* look like a bullseye on first frame.
|
||||
*/
|
||||
export const initFragment = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
${simplexNoise}
|
||||
|
||||
// Tiny hash for per-particle deterministic randoms.
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float r1 = hash(vUv);
|
||||
float r2 = hash(vUv + 17.3);
|
||||
float r3 = hash(vUv + 91.7);
|
||||
|
||||
// Polar-uniform disc with a soft outward bias.
|
||||
float angle = r1 * 6.28318;
|
||||
float radius = sqrt(r2) * 1.0;
|
||||
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
|
||||
|
||||
// Slight horizontal stretch so the field reads as a wide hero band,
|
||||
// not a perfect circle.
|
||||
pos.x *= 1.25;
|
||||
|
||||
float scale = 0.55 + r3 * 0.85;
|
||||
|
||||
gl_FragColor = vec4(pos, scale, 0.0);
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user