feat(web): mobile-fit hero tiles + voluminous calmer particle field + FAQ accordion
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s

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>
This commit is contained in:
Marco Sadjadi 2026-05-27 12:35:03 +02:00
parent 6f8b8da151
commit 035e55f00c
12 changed files with 1051 additions and 717 deletions

View File

@ -5,6 +5,7 @@ import { PulseLink } from '@/components/pulse';
import { ScrollCue } from '@/components/scroll-cue';
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.
@ -351,17 +352,33 @@ export default function Landing() {
</div>
</section>
{/* FAQ */}
{/* FAQ collapsible accordion using native <details>. Crawlers
and screen readers still see the full Q+A in the HTML; users
see one question at a time and expand on demand. No JS, no
state, semantically correct. `list-none` + the WebKit-marker
pseudo-class suppress the default disclosure triangle so we
can render our own chevron that rotates via `group-open`. */}
<section className="py-14 sm:py-20">
<JsonLd data={faqJsonLd()} />
<div className="mx-auto max-w-6xl px-6">
<div className="mx-auto max-w-3xl px-6">
<h2 className="text-[28px] font-semibold tracking-tight">FAQ</h2>
<div className="mt-8 grid gap-x-12 gap-y-6 md:grid-cols-2">
<div className="mt-8 border-t border-[--color-border]">
{FAQ.map((f) => (
<div key={f.q}>
<h3 className="text-[14px] font-semibold tracking-tight">{f.q}</h3>
<p className="mt-1.5 text-[13px] leading-relaxed text-[--color-fg-muted]">{f.a}</p>
</div>
<details
key={f.q}
className="group border-b border-[--color-border] [&_summary::-webkit-details-marker]:hidden"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 py-4 text-[14.5px] font-semibold tracking-tight text-[--color-fg] transition-colors hover:text-[--color-accent]">
<span>{f.q}</span>
<ChevronDown
size={16}
className="shrink-0 text-[--color-fg-subtle] transition-transform duration-200 group-open:rotate-180 group-open:text-[--color-accent]"
/>
</summary>
<p className="pb-5 pr-8 text-[13.5px] leading-relaxed text-[--color-fg-muted]">
{f.a}
</p>
</details>
))}
</div>
</div>

View File

@ -16,31 +16,39 @@ interface Step {
code: string;
}
// Snippets are deliberately short — they have to fit in a tile that
// shrinks to roughly 295 px wide on a 375 px mobile viewport, with
// monospace 12.5 px. Long lines (URLs especially) get truncated to a
// recognisable shape (`mcp/notion-x9`) so we never need horizontal
// scrolling inside the tile. `whitespace-pre-wrap` on the <pre> below
// lets any remaining over-width tokens (e.g. someone shrinks the
// viewport to 320 px) wrap instead of overflowing.
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.`,
code: `Build an MCP server that
searches our Notion workspace.
Tools: search_pages, get_page
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`,
code: `✓ Generating spec (2 tools)
Static checks passed
Building image 17.2s
Deploying ok
Live mcp/notion-x9`,
},
{
label: 'claude_desktop_config.json',
label: 'claude.config.json',
badge: '03 · Connect',
code: `{
"mcpServers": {
"notion": {
"url": "https://notion-x9.mcp.buildmymcpserver.com/mcp",
"url": ".../notion-x9/mcp",
"auth": "oauth2"
}
}
@ -161,7 +169,7 @@ export function HeroStepRotator() {
{current.badge}
</span>
</div>
<pre className="mono relative overflow-x-auto px-4 py-4 text-[12.5px] leading-relaxed text-[--color-fg]">
<pre className="mono relative whitespace-pre-wrap break-words px-4 py-4 text-[12.5px] leading-relaxed text-[--color-fg]">
<code>{current.code}</code>
</pre>
</motion.div>

View File

@ -169,11 +169,16 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
const renderUniforms = {
uPositions: { value: rtB.texture },
uPointSize: { value: textureSize === 256 ? 1.8 : 2.4 },
// 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.42 },
uBaseAlpha: { value: 0.6 },
};
const particleMat = new THREE.ShaderMaterial({
vertexShader: renderVertex,

View File

@ -121,11 +121,14 @@ export const simFragment = /* glsl */ `
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;
// Breathing distortion of the radius itself. Time scale tuned
// down (was 0.35 → 0.22) so the ring "breathes" rather than
// pulsates, which used to read as visual stutter on slow drift.
float noise = snoise(p * 4.0 + uTime * 0.22) * 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;
// Same calming pass on both phases.
float wave = sin(ang * 5.0 + uTime * 0.7) * 0.012
+ cos(ang * 3.0 - uTime * 0.42) * 0.010;
float rr = r + noise + wave;
// Three bands of different thickness at slightly offset radii.
@ -149,10 +152,15 @@ export const simFragment = /* glsl */ `
// --- Idle drift: rotational simplex-noise current ---
// Time is scaled by uMotionScale so reduced-motion users get a
// calmer field that evolves at half speed.
// Noise-evolution speed dropped from 0.08 → 0.045 (almost halved)
// and velocity magnitude from 0.045 → 0.028. The combination kills
// the perceived stutter — each particle now moves slowly enough
// that the eye tracks individual motion as smooth drift rather
// than as jerky per-frame teleportation.
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
float n1 = snoise(pos * 1.6 + vec2(driftTime * 0.045, 0.0));
float n2 = snoise(pos * 1.6 + vec2(0.0, driftTime * 0.045) + 53.7);
vec2 driftVel = vec2(-n2, n1) * 0.028 * uMotionScale; // curl-like rotation
// --- Ring push: gradient of the ring field, pointing outward ---
float h = 0.003;

View File

@ -6,7 +6,7 @@
"studio": "remotion studio src/index.ts",
"render:mp4": "remotion render src/index.ts HeroVideo out/hero-raw.mp4 --codec h264 --crf 28 --pixel-format yuv420p && node scripts/postprocess.mjs",
"render:webm": "remotion render src/index.ts HeroVideo out/hero.webm --codec vp9 --crf 32",
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 180 --image-format jpeg --jpeg-quality 85",
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 210 --image-format jpeg --jpeg-quality 85",
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",
"to-web": "node scripts/publish-to-web.mjs",
"build": "pnpm render:all && pnpm to-web"

View File

@ -1,76 +1,105 @@
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
import { C } from './lib/colors';
import { clampLerp } from './lib/easings';
import { PromptScene } from './scenes/PromptScene';
import { TransformScene } from './scenes/TransformScene';
import { ServerScene } from './scenes/ServerScene';
import { BuildScene } from './scenes/BuildScene';
import { LibraryScene } from './scenes/LibraryScene';
import { DiscoveryScene } from './scenes/DiscoveryScene';
export const HERO_FPS = 30;
export const HERO_DURATION_FRAMES = 240; // 8s
export const HERO_DURATION_FRAMES = 300; // 10s
// Scene timing — frame ranges, inclusive on start, exclusive on end.
// Beats overlap intentionally at the edges so transitions cross-fade
// rather than hard-cut. The last 12 frames fade the whole canvas to
// black so the loop seam disappears (frame 0 starts equally dark).
// Scene timing. Each beat overlaps the next by ~6 frames so the
// transitions crossfade rather than hard-cut.
//
// P1 prompt [ 0, 81)
// P2 build [ 75, 171)
// P3 library [165, 246)
// P4 discovery [240, 300)
export const BEAT = {
prompt: { in: 0, out: 70 },
transform: { in: 55, out: 165 },
server: { in: 150, out: 240 },
prompt: { in: 0, out: 81 },
build: { in: 75, out: 171 },
library: { in: 165, out: 246 },
discovery: { in: 240, out: 300 },
} as const;
const FADE_FRAMES = 12;
export function HeroVideo() {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Loop-clean: ramp opacity to 0 over the last 12 frames so frame 239 ≈
// Loop-clean: ramp opacity to 0 over the last 12 frames so frame 299 ≈
// frame 0 (both essentially-black). Browser <video loop> will jump back
// and the seam is invisible.
const loopFade = interpolate(frame, [HERO_DURATION_FRAMES - 12, HERO_DURATION_FRAMES - 1], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const loopFade = interpolate(
frame,
[HERO_DURATION_FRAMES - FADE_FRAMES, HERO_DURATION_FRAMES - 1],
[1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
);
// Crossfade alpha for each scene over its 6-frame entry/exit overlap.
const promptAlpha = crossfade(frame, BEAT.prompt.in, BEAT.prompt.out, 6);
const buildAlpha = crossfade(frame, BEAT.build.in, BEAT.build.out, 6);
const libraryAlpha = crossfade(frame, BEAT.library.in, BEAT.library.out, 6);
const discoveryAlpha = crossfade(frame, BEAT.discovery.in, BEAT.discovery.out, 6);
return (
<AbsoluteFill style={{ backgroundColor: C.bg, overflow: 'hidden' }}>
{/* Subtle dotted grid set design vocabulary, never moves. Drawn
once at the bottom and every scene paints on top. */}
<DottedGrid />
<Vignette />
<AbsoluteFill style={{ opacity: loopFade }}>
{frame < BEAT.prompt.out && <PromptScene />}
{frame >= BEAT.transform.in && frame < BEAT.transform.out && <TransformScene />}
{frame >= BEAT.server.in && <ServerScene fps={fps} />}
{promptAlpha > 0 && (
<AbsoluteFill style={{ opacity: promptAlpha }}>
<PromptScene localFrame={frame - BEAT.prompt.in} fps={fps} />
</AbsoluteFill>
)}
{buildAlpha > 0 && (
<AbsoluteFill style={{ opacity: buildAlpha }}>
<BuildScene localFrame={frame - BEAT.build.in} fps={fps} />
</AbsoluteFill>
)}
{libraryAlpha > 0 && (
<AbsoluteFill style={{ opacity: libraryAlpha }}>
<LibraryScene localFrame={frame - BEAT.library.in} fps={fps} />
</AbsoluteFill>
)}
{discoveryAlpha > 0 && (
<AbsoluteFill style={{ opacity: discoveryAlpha }}>
<DiscoveryScene localFrame={frame - BEAT.discovery.in} fps={fps} />
</AbsoluteFill>
)}
</AbsoluteFill>
{/* Cursor visible at start and end, the only motif that bridges the
loop. Hidden during Transform/Server because the camera focus is
elsewhere. */}
<CursorMotif frame={frame} />
</AbsoluteFill>
);
}
function crossfade(frame: number, start: number, end: number, overlap: number) {
// Returns 0..1: ramps up `overlap` frames after start, ramps down `overlap`
// frames before end. Outside [start, end) returns 0.
if (frame < start || frame >= end) return 0;
const intoStart = clampLerp(frame, start, start + overlap);
const beforeEnd = 1 - clampLerp(frame, end - overlap, end);
return Math.min(intoStart, beforeEnd);
}
function DottedGrid() {
// 32px dot grid, fading toward the edges. Drawn with a single radial
// mask + repeating dot pattern. Static; renders into PNG quickly.
const dotSize = 1.5;
const gap = 48;
const dots = [];
for (let y = 0; y < 1080 + gap; y += gap) {
for (let x = 0; x < 1920 + gap; x += gap) {
dots.push(
<circle key={`${x}-${y}`} cx={x} cy={y} r={dotSize} fill={C.borderStrong} opacity={0.5} />,
<circle key={`${x}-${y}`} cx={x} cy={y} r={dotSize} fill={C.borderStrong} opacity={0.45} />,
);
}
}
return (
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0 }}
// Radial gradient mask: dots strongest center, fading at edges
>
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
<defs>
<radialGradient id="vignette" cx="50%" cy="50%" r="60%">
<radialGradient id="vignette" cx="50%" cy="50%" r="65%">
<stop offset="0%" stopColor="white" stopOpacity="1" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</radialGradient>
@ -83,33 +112,17 @@ function DottedGrid() {
);
}
function CursorMotif({ frame }: { frame: number }) {
// Beat 1: cursor blinks at the right edge of the typed prompt. We
// place a generic blinking caret in the bottom-third area so the loop
// ends with an invitation to type again.
const showStart = frame < BEAT.transform.in;
const showEnd = frame >= HERO_DURATION_FRAMES - 24;
if (!showStart && !showEnd) return null;
// 0.5Hz blink
const blink = Math.floor(frame / 15) % 2 === 0;
// Start-position cursor lives at the end of the typed prompt; end-position
// cursor lives in the same place to set up the loop.
const x = 960 + 220; // anchored after the prompt baseline
const y = 540;
function Vignette() {
// Faint indigo glow at the center so the product mockups have
// something to sit on. Static across the whole timeline.
return (
<div
style={{
position: 'absolute',
left: x,
top: y - 24,
width: 4,
height: 48,
backgroundColor: C.accent,
opacity: blink ? 0.9 : 0,
borderRadius: 1,
boxShadow: `0 0 12px ${C.accentGlow}`,
transition: 'opacity 0.05s',
inset: 0,
background:
'radial-gradient(ellipse 60% 50% at 50% 50%, rgba(99,102,241,0.10) 0%, rgba(99,102,241,0) 70%)',
pointerEvents: 'none',
}}
/>
);

View File

@ -0,0 +1,265 @@
import { interpolate } from 'remotion';
import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp } from '../lib/easings';
// Phase 2 (frames 75171 global → localFrame 0..96): build log streams in
// line-by-line, then a server card emerges.
//
// Log lines stagger ~12 frames apart starting at localFrame 4.
// Server card emerges at localFrame ~64.
const LOG_LINES = [
{ label: 'Generating spec', detail: '2 tools detected' },
{ label: 'Static checks', detail: 'passed' },
{ label: 'Building image', detail: '17.2s' },
{ label: 'Deploying', detail: 'live' },
];
const LINE_STAGGER = 10;
const LINE_START = 4;
const CARD_START = 60;
export function BuildScene({ localFrame, fps }: { localFrame: number; fps: number }) {
const panelIn = springIn(localFrame, fps, 0);
const panelOpacity = clampLerp(localFrame, 0, 12);
// Card emerges late in phase.
const cardIn = softSpring(localFrame, fps, CARD_START, 24);
// Once the card is up, the log panel slides up to make room.
const panelShift = interpolate(cardIn, [0, 1], [0, -140]);
return (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
{/* Build log panel */}
<div
style={{
width: 720,
backgroundColor: C.bgElevated,
border: `1px solid ${C.border}`,
borderRadius: 14,
padding: '24px 28px',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
opacity: panelOpacity,
transform: `translateY(${interpolate(panelIn, [0, 1], [20, 0]) + panelShift}px) scale(${interpolate(panelIn, [0, 1], [0.96, 1])})`,
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
{/* Panel header — tiny status row */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingBottom: 12,
borderBottom: `1px solid ${C.border}`,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 14,
color: C.fgSubtle,
letterSpacing: 1,
textTransform: 'uppercase',
}}
>
<span>build · notion-search</span>
<span style={{ color: C.fgMuted }}> running</span>
</div>
{LOG_LINES.map((line, i) => (
<LogLine
key={i}
label={line.label}
detail={line.detail}
localFrame={localFrame}
startFrame={LINE_START + i * LINE_STAGGER}
fps={fps}
/>
))}
</div>
{/* Server card (emerges in second half) */}
{cardIn > 0.01 && (
<ServerCard
progress={cardIn}
localFrame={localFrame}
/>
)}
</div>
);
}
function LogLine({
label,
detail,
localFrame,
startFrame,
fps,
}: {
label: string;
detail: string;
localFrame: number;
startFrame: number;
fps: number;
}) {
const spring = springIn(localFrame, fps, startFrame);
const opacity = clampLerp(localFrame, startFrame, startFrame + 10);
const x = interpolate(spring, [0, 1], [-30, 0]);
// Check fills in slightly after the line slides in.
const checkFill = clampLerp(localFrame, startFrame + 6, startFrame + 14);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
opacity,
transform: `translateX(${x}px)`,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 18,
}}
>
{/* Checkmark circle */}
<div
style={{
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: `rgba(34, 197, 94, ${checkFill * 0.18})`,
border: `1.5px solid ${C.success}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<svg width="12" height="12" viewBox="0 0 12 12">
<path
d="M 2.5 6.5 L 5 9 L 9.5 3.5"
fill="none"
stroke={C.success}
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
strokeDasharray={12}
strokeDashoffset={(1 - checkFill) * 12}
/>
</svg>
</div>
<span style={{ color: C.fg, minWidth: 220 }}>{label}</span>
<span style={{ color: C.fgMuted, flex: 1 }}>{detail}</span>
</div>
);
}
function ServerCard({ progress, localFrame }: { progress: number; localFrame: number }) {
const scale = interpolate(progress, [0, 1], [0.85, 1]);
const y = interpolate(progress, [0, 1], [40, 180]);
// Pulse the live dot at ~1Hz.
const pulsePhase = (localFrame - 60) / 30;
const livePulse = 0.6 + 0.4 * Math.sin(pulsePhase * Math.PI * 2);
return (
<div
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: `translate(-50%, calc(-50% + ${y}px)) scale(${scale})`,
opacity: progress,
}}
>
<div
style={{
width: 480,
backgroundColor: C.bgElevated,
border: `1.5px solid ${C.accent}`,
borderRadius: 16,
padding: '24px 28px',
boxShadow: `0 0 0 5px ${C.accentGlow}, 0 24px 70px rgba(0,0,0,0.6)`,
}}
>
{/* Header row: title + live dot */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 18,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 20,
color: C.fg,
letterSpacing: 0.2,
}}
>
notion-search
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
color: C.success,
letterSpacing: 1.5,
textTransform: 'uppercase',
}}
>
<div
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: C.success,
boxShadow: `0 0 ${10 * livePulse}px ${C.success}`,
opacity: 0.5 + 0.5 * livePulse,
}}
/>
live
</div>
</div>
{/* Tool rows */}
<ToolRow name="search_pages" desc="full-text query" />
<div style={{ height: 8 }} />
<ToolRow name="get_page_content" desc="fetch by id" />
</div>
</div>
);
}
function ToolRow({ name, desc }: { name: string; desc: string }) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 14px',
backgroundColor: C.bgSubtle,
border: `1px solid ${C.border}`,
borderRadius: 10,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
}}
>
<div style={{ width: 6, height: 6, borderRadius: 3, backgroundColor: C.accent }} />
<span style={{ color: C.fg, fontSize: 16, minWidth: 200 }}>{name}</span>
<span style={{ color: C.fgSubtle, fontSize: 14, flex: 1 }}>{desc}</span>
</div>
);
}

View File

@ -0,0 +1,295 @@
import { interpolate } from 'remotion';
import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
// Phase 4 (frames 240300 global → localFrame 0..60): the user's card is
// the hero. A fork count ticks 0 → 247 with micro-particles. A subtitle
// pops in: "1,200+ developers building".
//
// We keep the same grid layout as Phase 3 (so the transition is a
// crossfade in place) but zoom slightly toward the hero card and emphasize
// it. The fork counter sits on the hero card.
const GRID_COLS = 3;
const GRID_ROWS = 2;
const CARD_W = 340;
const CARD_H = 180;
const GAP = 28;
const CARDS = [
{ name: 'github-issues', toolCount: 3, highlighted: false },
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero
{ name: 'slack-digest', toolCount: 4, highlighted: false },
{ name: 'linear-tasks', toolCount: 5, highlighted: false },
{ name: 'gmail-triage', toolCount: 3, highlighted: false }, // demote — only the user's is the star
{ name: 'jira-sprint', toolCount: 6, highlighted: false },
];
const TARGET_FORKS = 247;
export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
// Slow zoom toward the hero card.
const zoom = clampLerp(localFrame, 0, 50);
const scale = interpolate(zoom, [0, 1], [1.0, 1.08]);
// Fork count ticks 0 → 247 over ~36 frames starting at localFrame 6.
const tickProgress = clampLerp(localFrame, 6, 42);
const forkCount = Math.floor(tickProgress * TARGET_FORKS);
// Subtitle "1,200+ developers building" pops at localFrame ~28.
const subIn = softSpring(localFrame, fps, 28, 18);
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
const gridLeft = (1920 - gridW) / 2;
const gridTop = (1080 - gridH) / 2 + 30;
// Hero card position (index 1: col=1, row=0)
const heroX = gridLeft + 1 * (CARD_W + GAP);
const heroY = gridTop + 0 * (CARD_H + GAP);
return (
<div style={{ position: 'absolute', inset: 0 }}>
{/* Section caption (matches Phase 3 caption position) */}
<div
style={{
position: 'absolute',
top: gridTop - 80,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 16,
letterSpacing: 5,
textTransform: 'uppercase',
color: C.fgSubtle,
}}
>
template library
</div>
{/* Zoom group — scales everything around the hero card center */}
<div
style={{
position: 'absolute',
inset: 0,
transform: `scale(${scale})`,
transformOrigin: `${heroX + CARD_W / 2}px ${heroY + CARD_H / 2}px`,
}}
>
{CARDS.map((card, i) => {
const col = i % GRID_COLS;
const row = Math.floor(i / GRID_COLS);
const x = gridLeft + col * (CARD_W + GAP);
const y = gridTop + row * (CARD_H + GAP);
const isHero = i === 1;
// Non-hero cards desaturate slightly as the camera focuses.
const dim = !isHero ? interpolate(zoom, [0, 1], [1, 0.55]) : 1;
return (
<div
key={i}
style={{
position: 'absolute',
left: x,
top: y,
width: CARD_W,
height: CARD_H,
opacity: dim,
}}
>
<TemplateCardInner card={card} isHero={isHero} forkCount={isHero ? forkCount : null} />
</div>
);
})}
{/* Fork-tick micro-particles around the hero card */}
<ForkParticles
localFrame={localFrame}
x={heroX + CARD_W - 22}
y={heroY + CARD_H - 22}
/>
</div>
{/* Subtitle "1,200+ developers building" — bottom of frame */}
<div
style={{
position: 'absolute',
bottom: 90,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 22,
color: C.fgMuted,
letterSpacing: 1.5,
opacity: subIn,
transform: `translateY(${interpolate(subIn, [0, 1], [10, 0])}px)`,
}}
>
<span style={{ color: C.fg, fontWeight: 600 }}>1,200+</span> developers building
</div>
</div>
);
}
function TemplateCardInner({
card,
isHero,
forkCount,
}: {
card: typeof CARDS[number];
isHero: boolean;
forkCount: number | null;
}) {
const border = isHero ? C.accent : C.border;
const shadow = isHero
? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)`
: `0 8px 24px rgba(0,0,0,0.35)`;
return (
<div
style={{
width: '100%',
height: '100%',
backgroundColor: C.bgElevated,
border: `1.5px solid ${border}`,
borderRadius: 14,
padding: '16px 20px',
boxShadow: shadow,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
position: 'relative',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 15,
color: C.fg,
}}
>
{card.name}
</div>
<div
style={{
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: isHero ? C.success : C.fgSubtle,
opacity: isHero ? 1 : 0.5,
boxShadow: isHero ? `0 0 8px ${C.success}` : 'none',
}}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{Array.from({ length: 3 }).map((_, j) => (
<div
key={j}
style={{
height: 7,
borderRadius: 3.5,
backgroundColor: C.bgSubtle,
width: `${[88, 64, 76][j]}%`,
border: `1px solid ${C.border}`,
}}
/>
))}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
color: C.fgSubtle,
letterSpacing: 0.5,
}}
>
<span>{card.toolCount} tools</span>
{isHero && forkCount !== null ? (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
color: C.accent,
fontSize: 13,
fontWeight: 600,
}}
>
<ForkIcon />
{forkCount.toString()}
</span>
) : (
<span>{isHero ? '★ featured' : 'template'}</span>
)}
</div>
</div>
);
}
function ForkIcon() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none">
<circle cx="4" cy="3" r="1.6" stroke={C.accent} strokeWidth="1.4" />
<circle cx="12" cy="3" r="1.6" stroke={C.accent} strokeWidth="1.4" />
<circle cx="8" cy="13" r="1.6" stroke={C.accent} strokeWidth="1.4" />
<path d="M 4 4.6 L 4 7 Q 4 8 5 8 L 11 8 Q 12 8 12 7 L 12 4.6" stroke={C.accent} strokeWidth="1.4" fill="none" />
<path d="M 8 8 L 8 11.4" stroke={C.accent} strokeWidth="1.4" />
</svg>
);
}
function ForkParticles({
localFrame,
x,
y,
}: {
localFrame: number;
x: number;
y: number;
}) {
// Emit a particle every ~3 frames during the tick window (frames 6-42).
// Each particle lives ~14 frames, drifting up and fading out.
const PARTICLES = 12;
const EMIT_START = 6;
const EMIT_INTERVAL = 3;
const PARTICLE_LIFE = 16;
const out = [];
for (let i = 0; i < PARTICLES; i++) {
const birth = EMIT_START + i * EMIT_INTERVAL;
const age = localFrame - birth;
if (age < 0 || age > PARTICLE_LIFE) continue;
const t = age / PARTICLE_LIFE;
const opacity = (1 - t) * 0.9;
const r = rand(i * 31);
const dx = (r - 0.5) * 50;
const dy = -36 * t - 6;
const size = 4 + r * 3;
out.push(
<div
key={i}
style={{
position: 'absolute',
left: x + dx,
top: y + dy,
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: C.accent,
opacity,
boxShadow: `0 0 8px ${C.accentGlow}`,
}}
/>,
);
}
return <>{out}</>;
}

View File

@ -0,0 +1,250 @@
import { interpolate } from 'remotion';
import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
// Phase 3 (frames 165246 global → localFrame 0..81): the server card pulls
// back / scales down and multiplies into a 3×2 grid of template cards.
//
// We "zoom out" by starting with a single big card centered (matches the
// position where Phase 2 left it) and animating it to grid-slot (1,0)
// while five sibling cards fade in around it.
const GRID_COLS = 3;
const GRID_ROWS = 2;
const CARD_W = 340;
const CARD_H = 180;
const GAP = 28;
// Card metadata. The "hero" card (index 1, top-center) corresponds to the
// server the user just built. It stays highlighted with the indigo border.
// Index 0 and 4 are also highlighted to suggest "popular templates".
const CARDS = [
{ name: 'github-issues', toolCount: 3, highlighted: false },
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero
{ name: 'slack-digest', toolCount: 4, highlighted: false },
{ name: 'linear-tasks', toolCount: 5, highlighted: false },
{ name: 'gmail-triage', toolCount: 3, highlighted: true },
{ name: 'jira-sprint', toolCount: 6, highlighted: false },
];
export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
// The caption above the grid.
const captionIn = clampLerp(localFrame, 20, 36);
// Hero card animation — starts at its previous-scene location (centered,
// big) and morphs to grid slot 1.
const heroProgress = softSpring(localFrame, fps, 0, 30);
// The grid is centered. Compute slot positions.
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
const gridLeft = (1920 - gridW) / 2;
const gridTop = (1080 - gridH) / 2 + 30; // nudge down a bit for the caption
return (
<div style={{ position: 'absolute', inset: 0 }}>
{/* Caption */}
<div
style={{
position: 'absolute',
top: gridTop - 80,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 16,
letterSpacing: 5,
textTransform: 'uppercase',
color: C.fgSubtle,
opacity: captionIn,
transform: `translateY(${interpolate(captionIn, [0, 1], [10, 0])}px)`,
}}
>
template library
</div>
{CARDS.map((card, i) => {
const col = i % GRID_COLS;
const row = Math.floor(i / GRID_COLS);
const targetX = gridLeft + col * (CARD_W + GAP);
const targetY = gridTop + row * (CARD_H + GAP);
const isHero = i === 1;
if (isHero) {
// Hero card morphs from "big centered" to its grid slot.
const startW = 480;
const startH = 220;
const startX = (1920 - startW) / 2;
const startY = (1080 - startH) / 2 + 90; // matches BuildScene y=180 offset
const w = interpolate(heroProgress, [0, 1], [startW, CARD_W]);
const h = interpolate(heroProgress, [0, 1], [startH, CARD_H]);
const x = interpolate(heroProgress, [0, 1], [startX, targetX]);
const y = interpolate(heroProgress, [0, 1], [startY, targetY]);
return (
<TemplateCard
key={i}
card={card}
x={x}
y={y}
w={w}
h={h}
opacity={1}
/>
);
}
// Sibling cards fade in staggered.
const delay = 16 + i * 3;
const sib = springIn(localFrame, fps, delay);
const opacity = clampLerp(localFrame, delay, delay + 14);
// Slight scale-in from 0.92.
const scale = interpolate(sib, [0, 1], [0.92, 1]);
// Use deterministic randomness for tiny drift-in offsets.
const drift = (rand(i + 7) - 0.5) * 14;
return (
<div
key={i}
style={{
position: 'absolute',
left: targetX,
top: targetY + drift * (1 - sib),
width: CARD_W,
height: CARD_H,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center center',
}}
>
<TemplateCardInner card={card} w={CARD_W} h={CARD_H} />
</div>
);
})}
</div>
);
}
function TemplateCard({
card,
x,
y,
w,
h,
opacity,
}: {
card: typeof CARDS[number];
x: number;
y: number;
w: number;
h: number;
opacity: number;
}) {
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width: w,
height: h,
opacity,
}}
>
<TemplateCardInner card={card} w={w} h={h} />
</div>
);
}
function TemplateCardInner({
card,
w,
h,
}: {
card: typeof CARDS[number];
w: number;
h: number;
}) {
const border = card.highlighted ? C.accent : C.border;
const shadow = card.highlighted
? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)`
: `0 8px 24px rgba(0,0,0,0.35)`;
// Pad inside scales mildly with card size (since hero is bigger).
const padX = Math.max(18, w * 0.06);
const padY = Math.max(16, h * 0.08);
return (
<div
style={{
width: '100%',
height: '100%',
backgroundColor: C.bgElevated,
border: `1.5px solid ${border}`,
borderRadius: 14,
padding: `${padY}px ${padX}px`,
boxShadow: shadow,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
{/* Top: title + indicator dot */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: w > 380 ? 18 : 15,
color: C.fg,
letterSpacing: 0.1,
}}
>
{card.name}
</div>
<div
style={{
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: card.highlighted ? C.success : C.fgSubtle,
opacity: card.highlighted ? 1 : 0.5,
boxShadow: card.highlighted ? `0 0 8px ${C.success}` : 'none',
}}
/>
</div>
{/* Middle: faux tool bars */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{Array.from({ length: Math.min(3, card.toolCount) }).map((_, j) => (
<div
key={j}
style={{
height: 7,
borderRadius: 3.5,
backgroundColor: C.bgSubtle,
width: `${[88, 64, 76][j]}%`,
border: `1px solid ${C.border}`,
}}
/>
))}
</div>
{/* Bottom: tool count + status */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: w > 380 ? 13 : 11,
color: C.fgSubtle,
letterSpacing: 0.5,
}}
>
<span>{card.toolCount} tools</span>
<span style={{ color: card.highlighted ? C.accent : C.fgSubtle }}>
{card.highlighted ? '★ featured' : 'template'}
</span>
</div>
</div>
);
}

View File

@ -1,33 +1,36 @@
import { useCurrentFrame, useVideoConfig, interpolate } from 'remotion';
import { interpolate } from 'remotion';
import { C } from '../lib/colors';
import { springIn, clampLerp } from '../lib/easings';
// "Search our Notion workspace" — short, recognisable, fits one line at the
// monospace size we're using. Each word springs in from below with a brief
// indigo under-glow that fades as it settles.
const PROMPT_WORDS = ['Search', 'our', 'Notion', 'workspace'];
const WORD_STAGGER = 9; // frames between each word's entrance
const PROMPT_TEXT = 'Build me an MCP server that searches our Notion workspace.';
export function PromptScene() {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// Phase 1: realistic text-input field. Cursor blinks, prompt types in
// character by character (~20 chars/sec at 30fps = 1.5 frames/char), and a
// "press Enter to build →" hint lights up once typing completes.
// Whole scene fades out after frame 55 so it dissolves into Transform.
// The collapse: between frame 50-70 all four words scale down toward
// their shared geometric center (960, 540) and fade. Visually the
// prompt "drops" into a single bright point that Beat 2 will explode
// from — Beat 2's particle origin matches this same point.
const sceneOut = interpolate(frame, [60, 70], [1, 0], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const collapseT = interpolate(frame, [50, 68], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
// Ease the collapse so it accelerates inward at the very end.
const collapseEase = collapseT * collapseT;
const collapseScale = interpolate(collapseEase, [0, 1], [1, 0.2]);
export function PromptScene({ localFrame, fps }: { localFrame: number; fps: number }) {
const inputIn = springIn(localFrame, fps, 0);
const labelIn = clampLerp(localFrame, 4, 16);
// Type ~20 cps: floor(frame / 1.5)
const typedChars = Math.max(0, Math.floor(localFrame / 1.5));
const typed = PROMPT_TEXT.slice(0, typedChars);
const typingDoneAt = PROMPT_TEXT.length * 1.5;
const typingDone = localFrame >= typingDoneAt;
// Hint appears once typing is done.
const hintIn = clampLerp(localFrame, typingDoneAt - 4, typingDoneAt + 10);
// Caret blinks at 0.5Hz while idle; stays solid while typing.
const caretVisible = !typingDone || Math.floor(localFrame / 15) % 2 === 0;
// Exit motion: drifts upward slightly in last 12 frames (opacity is the
// parent crossfade — we just add a tiny lift so it feels purposeful).
const exitProgress = clampLerp(localFrame, 75, 81);
const exitY = interpolate(exitProgress, [0, 1], [0, -60]);
const exitScale = interpolate(exitProgress, [0, 1], [1, 0.95]);
const enterScale = interpolate(inputIn, [0, 1], [0.92, 1]);
return (
<div
@ -35,88 +38,99 @@ export function PromptScene() {
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
opacity: sceneOut,
transform: `translateY(${exitY}px) scale(${exitScale})`,
}}
>
{/* prompt.txt label — sits above the prompt as a file tag */}
{/* Label above input */}
<div
style={{
position: 'absolute',
left: '50%',
top: 460,
transform: 'translateX(-50%)',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 16,
letterSpacing: '0.18em',
fontSize: 18,
letterSpacing: 4,
textTransform: 'uppercase',
color: C.fgSubtle,
opacity: clampLerp(frame, 0, 8),
marginBottom: 28,
opacity: labelIn,
transform: `translateY(${interpolate(labelIn, [0, 1], [8, 0])}px)`,
}}
>
prompt.txt
describe your tool
</div>
{/* Input field */}
<div
style={{
width: 1100,
height: 92,
borderRadius: 16,
backgroundColor: C.bgElevated,
border: `1px solid ${typingDone ? C.accent : C.borderStrong}`,
boxShadow: typingDone
? `0 0 0 4px ${C.accentGlow}, 0 20px 60px rgba(0,0,0,0.55)`
: `0 20px 60px rgba(0,0,0,0.55)`,
display: 'flex',
alignItems: 'center',
paddingLeft: 32,
paddingRight: 32,
opacity: inputIn,
transform: `scale(${enterScale})`,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 28,
color: C.accent,
marginRight: 20,
lineHeight: 1,
}}
>
</div>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 28,
color: C.fg,
whiteSpace: 'pre',
letterSpacing: 0.2,
display: 'flex',
alignItems: 'center',
flex: 1,
}}
>
<span>{typed}</span>
<span
style={{
display: 'inline-block',
width: 3,
height: 32,
backgroundColor: C.accent,
marginLeft: 4,
opacity: caretVisible ? 1 : 0,
boxShadow: `0 0 10px ${C.accentGlow}`,
borderRadius: 1,
}}
/>
</div>
</div>
{/* Hint below input */}
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 56,
fontWeight: 600,
color: C.fg,
letterSpacing: '-0.01em',
display: 'flex',
gap: '18px',
// Collapse the entire word row toward its center point (which is
// canvas center 960,540 because the parent flex is fullscreen-
// centered). transform-origin: center makes all four words pull
// into a single bright point right before Beat 2's explosion.
transform: `scale(${collapseScale})`,
transformOrigin: 'center center',
filter: collapseT > 0
? `drop-shadow(0 0 ${12 + collapseEase * 28}px ${C.accentGlow})`
: undefined,
fontSize: 16,
color: typingDone ? C.fgMuted : C.fgSubtle,
marginTop: 28,
opacity: hintIn,
transform: `translateY(${interpolate(hintIn, [0, 1], [-6, 0])}px)`,
letterSpacing: 1,
}}
>
{PROMPT_WORDS.map((word, i) => {
const delay = 6 + i * WORD_STAGGER;
const t = springIn(frame, fps, delay);
// Translate from +28px below, settling to 0. Slight rotate adds
// mechanical-feel, undone as it settles.
const y = interpolate(t, [0, 1], [28, 0]);
const rotate = interpolate(t, [0, 1], [4, 0]);
// Brief under-glow: rises with entrance, fades 12 frames later.
const glowEnter = clampLerp(frame, delay, delay + 6);
const glowFade = 1 - clampLerp(frame, delay + 8, delay + 22);
const glowOpacity = Math.min(glowEnter, glowFade);
return (
<span
key={word}
style={{
display: 'inline-block',
transform: `translateY(${y}px) rotate(${rotate}deg)`,
opacity: t,
position: 'relative',
}}
>
{word}
<span
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: -6,
height: 3,
background: `linear-gradient(90deg, transparent, ${C.accent}, transparent)`,
opacity: glowOpacity,
borderRadius: 2,
filter: `blur(3px)`,
}}
/>
</span>
);
})}
press Enter to build
</div>
</div>
);

View File

@ -1,255 +0,0 @@
import { useCurrentFrame, interpolate } from 'remotion';
import { C } from '../lib/colors';
import { clampLerp, easeInOut, softSpring } from '../lib/easings';
import { BEAT } from '../HeroVideo';
// Beat 3 — Living server connected to a client.
//
// The server schematic from Beat 2 stays anchored. A "Claude" client
// panel slides in from the right. A Bézier wire animates between them.
// Three data-pulse dots travel back and forth along the wire. A "200"
// status tag flashes briefly. Live-dot in the server's top-right corner
// pulses through the whole scene.
const CX = 760;
const CY = 540;
const SERVER_W = 600;
const SERVER_H = 360;
// Claude client panel — anchored right of the server
const CLIENT_W = 360;
const CLIENT_H = 240;
const CLIENT_CX = 1480;
const CLIENT_CY = 540;
export function ServerScene({ fps }: { fps: number }) {
const frame = useCurrentFrame();
const local = frame - BEAT.server.in; // 0..90
// Scene fade-in (over previous scene)
const sceneIn = clampLerp(frame, BEAT.server.in, BEAT.server.in + 6);
// Client panel slides in from x=+1920 to its anchor
const clientSlide = softSpring(frame, fps, BEAT.server.in + 4, 30);
const clientX = interpolate(clientSlide, [0, 1], [1920 + CLIENT_W, CLIENT_CX]);
// Wire stroke-on (after client lands)
const wireT = easeInOut(clampLerp(local, 22, 50));
// Data-pulse positions along the wire path — three packets, staggered
const pulse1 = ((local - 40) * 0.025) % 1;
const pulse2 = ((local - 50) * 0.025) % 1;
const pulse3 = ((local - 60) * 0.025) % 1;
// "200 OK" tag — pops once at local frame 52, fades over 18 frames
const okT = clampLerp(local, 52, 60);
const okFade = 1 - clampLerp(local, 70, 84);
const okAlpha = Math.min(okT, okFade);
// Live-dot pulse — pulses at 1Hz
const liveDot = 0.6 + 0.4 * Math.sin(local * 0.21);
// Wire path — Bézier from server right-edge to client left-edge
const wireStart = { x: CX + SERVER_W / 2, y: CY };
const wireEnd = { x: CLIENT_CX - CLIENT_W / 2 + clientX - CLIENT_CX, y: CLIENT_CY };
const ctrl1 = { x: wireStart.x + 80, y: wireStart.y };
const ctrl2 = { x: wireEnd.x - 80, y: wireEnd.y };
const wirePath = `M ${wireStart.x} ${wireStart.y} C ${ctrl1.x} ${ctrl1.y}, ${ctrl2.x} ${ctrl2.y}, ${wireEnd.x} ${wireEnd.y}`;
// Compute pulse position on Bézier — sample the cubic
const pulsePos = (t: number) => {
const tt = Math.max(0, Math.min(1, t));
const u = 1 - tt;
const x =
u * u * u * wireStart.x +
3 * u * u * tt * ctrl1.x +
3 * u * tt * tt * ctrl2.x +
tt * tt * tt * wireEnd.x;
const y =
u * u * u * wireStart.y +
3 * u * u * tt * ctrl1.y +
3 * u * tt * tt * ctrl2.y +
tt * tt * tt * wireEnd.y;
return { x, y };
};
return (
<div style={{ position: 'absolute', inset: 0, opacity: sceneIn }}>
{/* Server schematic — same as Transform end state, holds its position */}
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
<rect
x={CX - SERVER_W / 2}
y={CY - SERVER_H / 2}
width={SERVER_W}
height={SERVER_H}
rx={6}
fill={C.bgElevated}
stroke={C.accent}
strokeWidth={2}
opacity={0.95}
/>
{/* Inner panel — slightly subtler */}
<rect
x={CX - SERVER_W / 2 + 6}
y={CY - SERVER_H / 2 + 6}
width={SERVER_W - 12}
height={SERVER_H - 12}
rx={4}
fill={C.bgSubtle}
opacity={0.6}
/>
{/* Tool rows inside server */}
{[0, 1, 2].map((r) => {
const y = CY - 90 + r * 90;
return (
<g key={r}>
<line
x1={CX - SERVER_W / 2 + 24}
y1={y}
x2={CX + SERVER_W / 2 - 24}
y2={y}
stroke={C.borderStrong}
strokeWidth={1}
/>
<circle cx={CX - SERVER_W / 2 + 30} cy={y} r={3} fill={C.accent} opacity={0.9} />
</g>
);
})}
{/* Live dot */}
<circle
cx={CX + SERVER_W / 2 - 14}
cy={CY - SERVER_H / 2 + 14}
r={5}
fill={C.success}
opacity={liveDot}
style={{ filter: `drop-shadow(0 0 6px ${C.success}88)` }}
/>
{/* Wire — drawn with dash-offset progression */}
<path
d={wirePath}
fill="none"
stroke={C.accent}
strokeWidth={2}
strokeOpacity={0.7}
strokeDasharray={400}
strokeDashoffset={(1 - wireT) * 400}
/>
{/* Data pulses traveling on wire — only after wire is fully drawn */}
{wireT > 0.95 &&
[pulse1, pulse2, pulse3].map((p, i) => {
if (p < 0 || p > 1) return null;
const pos = pulsePos(p);
return (
<circle
key={i}
cx={pos.x}
cy={pos.y}
r={4}
fill={C.accent}
opacity={0.95}
style={{ filter: `drop-shadow(0 0 8px ${C.accent})` }}
/>
);
})}
</svg>
{/* Client panel — Claude Desktop style */}
<div
style={{
position: 'absolute',
left: clientX - CLIENT_W / 2,
top: CLIENT_CY - CLIENT_H / 2,
width: CLIENT_W,
height: CLIENT_H,
borderRadius: 8,
backgroundColor: C.bgElevated,
border: `1px solid ${C.borderStrong}`,
padding: '18px 20px',
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
letterSpacing: '0.16em',
textTransform: 'uppercase',
color: C.fgSubtle,
marginBottom: 14,
}}
>
claude desktop
</div>
<div style={{ height: 1, background: C.border, marginBottom: 16 }} />
<div
style={{
fontSize: 14,
color: C.fgMuted,
lineHeight: 1.5,
}}
>
search "Q3 strategy"
</div>
<div
style={{
marginTop: 12,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
color: C.fg,
}}
>
3 pages found
</div>
<div
style={{
position: 'absolute',
bottom: 14,
left: 20,
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
color: C.success,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: C.success,
boxShadow: `0 0 6px ${C.success}`,
}}
/>
connected
</div>
</div>
{/* 200 OK tag — flashes above the wire midway through */}
{okAlpha > 0 && (
<div
style={{
position: 'absolute',
left: (wireStart.x + wireEnd.x) / 2 - 30,
top: wireStart.y - 40 - okT * 8,
padding: '3px 10px',
borderRadius: 999,
backgroundColor: 'rgba(34, 197, 94, 0.18)',
border: `1px solid ${C.success}`,
color: C.success,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
opacity: okAlpha,
}}
>
200 OK
</div>
)}
</div>
);
}

View File

@ -1,286 +0,0 @@
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>
);
}