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>
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import { useCurrentFrame, useVideoConfig, interpolate, spring } from 'remotion';
|
|
import { C } from '../lib/colors';
|
|
import { rand, clampLerp, easeInOut } from '../lib/easings';
|
|
import { BEAT } from '../HeroVideo';
|
|
|
|
// Beat 2 — the wow moment.
|
|
//
|
|
// The collapsed prompt point at (960, 540) detonates RADIALLY into ~60
|
|
// glowing particles that scatter spherically, then magnetically snap into
|
|
// target slots along a SERVER SCHEMATIC. Particles are supporting players:
|
|
// small enough that the schematic — the thing being built — reads as the
|
|
// primary subject. Schematic strokes on IN PARALLEL with the convergence
|
|
// so the eye always has something to anchor to.
|
|
|
|
const PARTICLE_COUNT = 60;
|
|
|
|
const SERVER_W = 720;
|
|
const SERVER_H = 420;
|
|
const CX = 960;
|
|
const CY = 540;
|
|
|
|
function targetSlot(i: number) {
|
|
const N = PARTICLE_COUNT;
|
|
if (i < N / 2) {
|
|
// perimeter walk
|
|
const t = i / (N / 2);
|
|
const perim = 2 * (SERVER_W + SERVER_H);
|
|
const d = t * perim;
|
|
const left = CX - SERVER_W / 2;
|
|
const top = CY - SERVER_H / 2;
|
|
let px = left;
|
|
let py = top;
|
|
if (d < SERVER_W) { px = left + d; py = top; }
|
|
else if (d < SERVER_W + SERVER_H) { px = left + SERVER_W; py = top + (d - SERVER_W); }
|
|
else if (d < 2 * SERVER_W + SERVER_H) { px = left + SERVER_W - (d - SERVER_W - SERVER_H); py = top + SERVER_H; }
|
|
else { px = left; py = top + SERVER_H - (d - 2 * SERVER_W - SERVER_H); }
|
|
return { x: px, y: py };
|
|
}
|
|
// Inside the box: three tool rows
|
|
const j = i - N / 2;
|
|
const perRow = Math.ceil(N / 2 / 3);
|
|
const row = Math.floor(j / perRow);
|
|
const col = (j % perRow) / Math.max(1, perRow - 1);
|
|
const rowY = CY - 90 + row * 90;
|
|
const rowX = CX - SERVER_W / 2 + 50 + col * (SERVER_W - 100);
|
|
return { x: rowX, y: rowY };
|
|
}
|
|
|
|
export function TransformScene() {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
const local = frame - BEAT.transform.in;
|
|
|
|
const sceneIn = clampLerp(frame, BEAT.transform.in, BEAT.transform.in + 6);
|
|
const sceneOut = 1 - clampLerp(frame, BEAT.transform.out - 8, BEAT.transform.out);
|
|
const sceneAlpha = Math.min(sceneIn, sceneOut);
|
|
|
|
// Schematic strokes on IN PARALLEL with the convergence — starts at
|
|
// local 8 instead of 30 so the box is visible before particles arrive.
|
|
const strokeT = easeInOut(clampLerp(local, 8, 55));
|
|
// Ports + rows show up as schematic completes
|
|
const innerT = clampLerp(local, 35, 65);
|
|
const portPulse = clampLerp(local, 55, 90);
|
|
|
|
// Scan-line — diagonal pass once the schematic is fully drawn
|
|
const scanT = clampLerp(local, 55, 90);
|
|
|
|
// Central core glow — visible throughout Beat 2 so the eye has an
|
|
// anchor even when particles are mid-flight. Pulses softly.
|
|
const coreAlpha = clampLerp(local, 0, 12) * (1 - clampLerp(local, 95, 110) * 0.4);
|
|
const corePulse = 1 + 0.25 * Math.sin(local * 0.18);
|
|
|
|
return (
|
|
<div style={{ position: 'absolute', inset: 0, opacity: sceneAlpha }}>
|
|
{/* Central core — a hint of radial glow at the explosion origin.
|
|
Toned down from earlier versions so the schematic, not the core,
|
|
carries the visual weight. */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
left: CX - 110,
|
|
top: CY - 110,
|
|
width: 220,
|
|
height: 220,
|
|
background: `radial-gradient(circle, ${C.accentGlow} 0%, transparent 60%)`,
|
|
opacity: coreAlpha * 0.45,
|
|
transform: `scale(${corePulse})`,
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
|
|
{/* Particles — 60 small glowing dots radiating from a single origin.
|
|
They support the schematic; they do not dominate it. */}
|
|
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
|
|
<defs>
|
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur stdDeviation="1.4" result="blur" />
|
|
<feMerge>
|
|
<feMergeNode in="blur" />
|
|
<feMergeNode in="SourceGraphic" />
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
{Array.from({ length: PARTICLE_COUNT }).map((_, i) => {
|
|
// SINGLE-POINT ORIGIN. All 60 particles start at canvas center —
|
|
// the exact point Beat 1's prompt just collapsed into. This is
|
|
// what makes the explosion read as radial/spherical instead of
|
|
// horizontal.
|
|
const originX = CX;
|
|
const originY = CY;
|
|
|
|
const slot = targetSlot(i);
|
|
// Velocity vectors — even spherical distribution. Golden-angle
|
|
// stratification of `i` plus a small jitter prevents the visible
|
|
// banding you'd get from a pure uniform-random angle on 60 dots.
|
|
const goldenAngle = (i * 2.39996323) % (Math.PI * 2);
|
|
const jitter = (rand(i * 1.71) - 0.5) * 0.35;
|
|
const angle = goldenAngle + jitter;
|
|
const speed = 220 + rand(i * 4.13) * 320;
|
|
const vx = Math.cos(angle) * speed;
|
|
const vy = Math.sin(angle) * speed;
|
|
|
|
const explode = clampLerp(local, 0, 22);
|
|
// Pull — slower, more deliberate. Inlined spring so we can set
|
|
// the exact damping/mass/stiffness/duration the scene needs
|
|
// without bloating the easings module.
|
|
const pull = spring({
|
|
frame: frame - (BEAT.transform.in + 22),
|
|
fps,
|
|
config: { damping: 25, mass: 1.3, stiffness: 55 },
|
|
durationInFrames: 60,
|
|
});
|
|
|
|
const driftX = originX + vx * explode * (1 - pull);
|
|
const driftY = originY + vy * explode * (1 - pull);
|
|
const x = driftX + (slot.x - driftX) * pull;
|
|
const y = driftY + (slot.y - driftY) * pull;
|
|
|
|
// Radius: 4→2 as particles lock in. Smaller than v2 so the
|
|
// schematic carries primary visual weight.
|
|
const r = interpolate(pull, [0, 1], [4, 2]);
|
|
const color = C.accent;
|
|
const alpha = clampLerp(local, 0, 4);
|
|
const fadeOut = 1 - clampLerp(local, 88, 108) * 0.4;
|
|
|
|
return (
|
|
<circle
|
|
key={i}
|
|
cx={x}
|
|
cy={y}
|
|
r={r}
|
|
fill={color}
|
|
opacity={alpha * fadeOut * 0.9}
|
|
filter="url(#glow)"
|
|
/>
|
|
);
|
|
})}
|
|
</svg>
|
|
|
|
{/* Server schematic */}
|
|
<svg
|
|
width={1920}
|
|
height={1080}
|
|
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
|
|
>
|
|
<defs>
|
|
<linearGradient id="scanline" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
<stop offset={`${Math.max(0, scanT * 100 - 6)}%`} stopColor={C.accent} stopOpacity="0" />
|
|
<stop offset={`${scanT * 100}%`} stopColor={C.accent} stopOpacity="0.95" />
|
|
<stop offset={`${Math.min(100, scanT * 100 + 6)}%`} stopColor={C.accent} stopOpacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
|
|
{/* Inner panel — fills earlier and darker so the schematic reads
|
|
as a solid object the particles are building, not a wireframe
|
|
sketch. Reaches 0.9 opacity by the time the strokes complete. */}
|
|
<rect
|
|
x={CX - SERVER_W / 2}
|
|
y={CY - SERVER_H / 2}
|
|
width={SERVER_W}
|
|
height={SERVER_H}
|
|
rx={8}
|
|
fill={C.bgElevated}
|
|
opacity={Math.min(0.9, strokeT * 1.5)}
|
|
/>
|
|
|
|
{/* Outer rectangle stroke — wider and with a heavier drop-shadow
|
|
so the chassis outline is the dominant element on screen. */}
|
|
<rect
|
|
x={CX - SERVER_W / 2}
|
|
y={CY - SERVER_H / 2}
|
|
width={SERVER_W}
|
|
height={SERVER_H}
|
|
rx={8}
|
|
fill="none"
|
|
stroke={C.accent}
|
|
strokeWidth={4}
|
|
strokeDasharray={2 * (SERVER_W + SERVER_H)}
|
|
strokeDashoffset={(1 - strokeT) * 2 * (SERVER_W + SERVER_H)}
|
|
opacity={0.98}
|
|
style={{ filter: `drop-shadow(0 0 16px ${C.accentGlow}) drop-shadow(0 0 4px ${C.accentGlow})` }}
|
|
/>
|
|
|
|
{/* Three internal tool rows */}
|
|
{[0, 1, 2].map((r) => {
|
|
const y = CY - 90 + r * 90;
|
|
return (
|
|
<g key={r} opacity={innerT}>
|
|
<line
|
|
x1={CX - SERVER_W / 2 + 40}
|
|
y1={y}
|
|
x2={CX + SERVER_W / 2 - 40}
|
|
y2={y}
|
|
stroke={C.borderStrong}
|
|
strokeWidth={1.5}
|
|
/>
|
|
<circle cx={CX - SERVER_W / 2 + 50} cy={y} r={5} fill={C.accent} opacity={0.95}
|
|
style={{ filter: `drop-shadow(0 0 4px ${C.accentGlow})` }}
|
|
/>
|
|
</g>
|
|
);
|
|
})}
|
|
|
|
{/* Port dots */}
|
|
{[-1, 1].map((side) =>
|
|
[-1, 0, 1].map((off) => (
|
|
<circle
|
|
key={`${side}-${off}`}
|
|
cx={CX + (SERVER_W / 2) * side}
|
|
cy={CY + off * 90}
|
|
r={6}
|
|
fill={C.accent}
|
|
opacity={portPulse * (0.7 + 0.3 * Math.sin(local * 0.3 + off))}
|
|
style={{ filter: `drop-shadow(0 0 8px ${C.accentGlow})` }}
|
|
/>
|
|
)),
|
|
)}
|
|
|
|
{/* Scan-line sweep */}
|
|
{scanT > 0 && scanT < 1 && (
|
|
<rect
|
|
x={CX - SERVER_W / 2}
|
|
y={CY - SERVER_H / 2}
|
|
width={SERVER_W}
|
|
height={SERVER_H}
|
|
rx={8}
|
|
fill="url(#scanline)"
|
|
opacity={0.65}
|
|
/>
|
|
)}
|
|
</svg>
|
|
|
|
{/* Corner labels — bigger and earlier */}
|
|
<CornerLabel x={CX - SERVER_W / 2} y={CY - SERVER_H / 2 - 36} text="mcp-notion" appearAt={local} delay={42} />
|
|
<CornerLabel x={CX + SERVER_W / 2} y={CY - SERVER_H / 2 - 36} text="OAuth 2.1" appearAt={local} delay={48} align="right" />
|
|
<CornerLabel x={CX - SERVER_W / 2} y={CY + SERVER_H / 2 + 16} text="search_pages" appearAt={local} delay={54} />
|
|
<CornerLabel x={CX + SERVER_W / 2} y={CY + SERVER_H / 2 + 16} text="get_page_content" appearAt={local} delay={60} align="right" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CornerLabel({
|
|
x, y, text, appearAt, delay, align = 'left',
|
|
}: {
|
|
x: number; y: number; text: string; appearAt: number; delay: number; align?: 'left' | 'right';
|
|
}) {
|
|
const t = clampLerp(appearAt, delay, delay + 8);
|
|
return (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
left: align === 'left' ? x : undefined,
|
|
right: align === 'right' ? 1920 - x : undefined,
|
|
top: y,
|
|
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
|
|
fontSize: 17,
|
|
letterSpacing: '0.08em',
|
|
color: C.fgMuted,
|
|
opacity: t,
|
|
transform: `translateY(${(1 - t) * 4}px)`,
|
|
}}
|
|
>
|
|
{text}
|
|
</div>
|
|
);
|
|
}
|