feat(video): v10 hero video with mute toggle — voice + bg music
All checks were successful
Deploy to Production / deploy (push) Successful in 1m6s

Ships the long-form (71.5 s) hero video to the marketing /flow section
along with the iteration trail of architectural visual fixes the owner
worked through over the last sprint.

## Video composition (remotion/)

Eight phases driven by the 71.47 s voice-over in `audio.mp3` plus the
`Sub-bass Lullaby.wav` background music (ducked to 0.16 with fade in /
fade out). Every scene was rebuilt for v10 with concrete fixes:

- **HookScene** (12 s) — adds FloatingChaos overlay: a docker-compose
  excerpt, an oauth_callback.ts snippet, an .env file with a yellow
  squiggle warning ("in git history since v0.3.1"), and a live-ticking
  502 retry toast. Tangle now reads as a developer's desktop right
  before they give up, not as four icons drifting.

- **PromptScene** (12.2 s) — 6.5 s post-typing dead-zone replaced with
  the parse beat: three sequential highlights on the prompt text
  (MCP server / searches / Notion workspace), three chips below the
  input (intent / tool / secret → vault), three-stat summary panel
  (tools · 2, secrets · 1, targets · 3). At local frame 250 (≈ 21 s
  global, on the voice line "the prompt path and the secret path
  never cross") a mini two-rail diagram with an explicit X-marker
  ring lands, visualising the architectural promise the moment it's
  spoken.

- **SecretsScene** (15.2 s) — kept the arrow-fork + AES-256 stamp +
  env-var injection beats; added the lock-snap flash at frame 66,
  pinned the vault at full opacity throughout, and added a dashed
  vault → container connector so the secret's provenance is visible.
  The "what the AI sees" panel is now 680 px wide with an eye icon,
  four corner viewfinder brackets around the prompt text, and three
  explicit denied lines (no secrets / no environment variables / no
  tokens).

- **BuildScene** (7.2 s) — unchanged beats: streaming log, server
  card emerges with code + 🔒 NOTION_API_KEY slot pills, isolated-
  container caption, <60s countdown.

- **IsolationScene** (14 s) — completely restructured. Orbit-and-dock
  chips that collided with the card and with the tokens-only badge
  are replaced by a clean vertical chip column at x=760: read-only
  filesystem · dropped capabilities · no new privileges · 512 MB
  memory cap · 0.5 CPU limit · ✓ your token only (last in green).
  A vault graphic now sits below the server card with a dashed arrow
  up into its env slot so the architecture story is complete in one
  frame. PKCE jargon removed: "OAuth 2.1 · PKCE" → "only your token
  gets in" with a small "oauth 2.1 · proof-key flow" subtitle for
  the curious. Handshake stages simplified to your client → verified
  → scoped token. Final settlement arrow in success-green curves
  from the scoped-token pill back into the card.

- **LibraryScene** (7 s) — cards enlarged from 340×180 to 400×220
  with 36 px gaps. The "templates carry code, not credentials"
  sub-caption was pulled (felt on-the-nose; the detached lock and
  empty NOTION_API_KEY=? slot carry the story visually).

- **DiscoveryScene** (3 s) — the most-iterated scene. Earlier
  versions had a fake "1,200+ developers building" fork counter
  (pulled — solo-founder, hadn't earned). Replaced with a two-lane
  architecture diagram that visualises "no paths cross" literally:
  top lane prompt → AI → code, bottom lane vault → encrypted →
  env, both converging at the server box on the right. v10
  refinements: all seven boxes visible from frame 0 (no late
  server arrival), a parallel glow tour walks across both lanes
  simultaneously, a dashed vertical divider with a "no shared
  node" chip pinned in the middle, and the closing line "One
  sentence in. Live server out." slides down from above and lands
  centred while the diagram fades to 0.12 opacity behind it —
  no overlap.

- **LogoLockup** (1.7 s) — wordmark + fade-to-black for a clean
  loop seam.

The Subtitle / CAPTIONS layer added in v7 was pulled wholesale —
owner found the kinetic-typography overlay aggressive and noted
that technical terms (PKCE etc.) created friction with no payoff.
Scene visuals and voice now carry the whole story; the Subtitle
component file is retained for possible future use.

Render pipeline (`render:mp4` / `render:webm` / `render:poster` in
remotion/package.json) is unchanged. The MP4 is post-processed to
H.264 Main / yuv420p / TV-range with faststart + AAC audio. The
WebM is re-encoded at VP9 CRF 38 / Opus 64k to stay under the 3 MB
budget. Final artefacts in apps/web/public/videos/: 2.59 MB mp4,
2.99 MB webm, 62 KB poster.

## Web integration (apps/web/components/hero-video.tsx)

New client component wraps the <video> element and pins a frosted-
glass mute toggle bottom-right of the player. Why not native
`controls`: the browser chrome fights the section's design vocabulary
and we only need one affordance — unmute — so we render exactly
that. The toggle's icon flips between VolumeX (currently muted) and
Volume2 (currently unmuted), accent colour switches indigo when sound
is on. Initial state is muted so autoplay still fires; on unmute we
call .play() defensively because mobile Safari pauses on
muted-property changes mid-playback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marco Sadjadi 2026-05-28 02:31:10 +02:00
parent 6197ee7f5e
commit 438ce3cfbc
27 changed files with 3449 additions and 589 deletions

View File

@ -10,7 +10,6 @@ import { accountRoutes } from './routes/account.js';
import { adminRoutes } from './routes/admin.js'; import { adminRoutes } from './routes/admin.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { billingRoutes } from './routes/billing.js'; import { billingRoutes } from './routes/billing.js';
import { geoRoutes } from './routes/geo.js';
import { oauthRoutes } from './routes/oauth.js'; import { oauthRoutes } from './routes/oauth.js';
import { serverRoutes } from './routes/servers.js'; import { serverRoutes } from './routes/servers.js';
import { settingsRoutes } from './routes/settings.js'; import { settingsRoutes } from './routes/settings.js';
@ -82,7 +81,6 @@ await app.register(templateRoutes);
await app.register(billingRoutes); await app.register(billingRoutes);
await app.register(supportRoutes); await app.register(supportRoutes);
await app.register(accountRoutes); await app.register(accountRoutes);
await app.register(geoRoutes);
// Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…) // Loud warning if STRIPE_PRICE_* env vars are set to product ids (prod_…)
// instead of price ids (price_…). Stripe Checkout would silently 400 — easier // instead of price ids (price_…). Stripe Checkout would silently 400 — easier

View File

@ -1,33 +0,0 @@
import type { FastifyInstance } from 'fastify';
/**
* GET /v1/geo/country { country: 'CH' | 'DE' | ... | null }
*
* Returns the country derived from Cloudflare's `CF-IPCountry` request
* header, which CF adds to every request before it reaches our origin.
* The header is an ISO-3166 alpha-2 code (or 'XX' for unknown / Tor).
*
* Used by the login page to pre-select the dial-code in the country
* picker for SMS auth visitors hitting the page from Germany see +49
* already selected, etc. No IP is exposed to the client; only the
* country code.
*
* Returns `country: null` when:
* - Request is hitting the origin directly (dev / outside CF)
* - CF couldn't classify the IP ('XX' is normalised to null)
*
* Public route no auth required. Reading a country code from a
* geo-IP header is not PII under GDPR / DSG.
*/
export async function geoRoutes(app: FastifyInstance) {
app.get('/v1/geo/country', async (req) => {
const raw = req.headers['cf-ipcountry'];
const header = Array.isArray(raw) ? raw[0] : raw;
if (!header) return { country: null };
const code = header.toUpperCase();
if (code === 'XX' || code === 'T1' || code.length !== 2) {
return { country: null };
}
return { country: code };
});
}

View File

@ -1,4 +1,5 @@
import { HeroStepRotator } from '@/components/hero-step-rotator'; import { HeroStepRotator } from '@/components/hero-step-rotator';
import { HeroVideo } from '@/components/hero-video';
import { JsonLd } from '@/components/json-ld'; import { JsonLd } from '@/components/json-ld';
import { ParticleHero } from '@/components/particle-hero'; import { ParticleHero } from '@/components/particle-hero';
import { PulseLink } from '@/components/pulse'; import { PulseLink } from '@/components/pulse';
@ -174,19 +175,11 @@ export default function Landing() {
className="relative w-full overflow-hidden border-b border-[--color-border] bg-black" className="relative w-full overflow-hidden border-b border-[--color-border] bg-black"
> >
<div className="relative aspect-video w-full"> <div className="relative aspect-video w-full">
<video {/* HeroVideo: native <video> with autoplay+muted+loop, plus a
autoPlay frosted mute toggle pinned bottom-right so visitors can
muted switch the narration on. See components/hero-video.tsx for
loop the autoplay/unmute mechanics. */}
playsInline <HeroVideo />
preload="auto"
poster="/videos/hero-poster.jpg"
className="size-full object-cover"
aria-label="Animation: a prompt becomes a live MCP server and connects to Claude Desktop"
>
<source src="/videos/hero.webm" type="video/webm" />
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
{/* Subtle vignette to integrate edges into the rest of the page */} {/* Subtle vignette to integrate edges into the rest of the page */}
<div <div
aria-hidden aria-hidden

View File

@ -231,22 +231,6 @@ export default function LoginPage() {
else if (p.sms) setMethod('phone'); else if (p.sms) setMethod('phone');
}) })
.catch(() => undefined); .catch(() => undefined);
// Pre-select the country picker from the visitor's geo-IP. The
// backend reads Cloudflare's CF-IPCountry header (never the IP
// itself) and returns the ISO-3166 alpha-2 code. We only override
// the default 'CH' if the detected code is one we actually carry
// a dial code for — otherwise the picker would show "Select country".
apiFetch<{ country: string | null }>('/v1/geo/country')
.then((r) => {
const code = r.country;
if (!code) return;
if (COUNTRIES.some((c) => c.code === code)) {
setCountry(code);
}
})
.catch(() => undefined);
const err = new URLSearchParams(window.location.search).get('error'); const err = new URLSearchParams(window.location.search).get('error');
if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.'); if (err) setError(ERROR_COPY[err] ?? 'Sign-in failed. Please try again.');
}, []); }, []);

View File

@ -0,0 +1,74 @@
'use client';
import { Volume2, VolumeX } from 'lucide-react';
import { useCallback, useRef, useState } from 'react';
/**
* Hero video player with a frosted-glass mute toggle.
*
* Why not the native `controls` attribute: it pulls in a chrome bar that
* fights the section's design vocabulary. We need exactly one control
* mute / unmute so we render just that, styled to match the rest of
* the brand (frosted indigo-bordered pill, bottom-right of the video).
*
* Default state is MUTED because every modern browser blocks autoplay
* for unmuted media. The toggle is the user's explicit affordance to
* turn the voice-over on; we keep the `muted` attribute on the element
* as the initial value so the autoplay still fires.
*
* On unmute we also call `.play()` defensively some browsers (mobile
* Safari especially) pause the element when the muted property flips,
* even mid-playback. The promise is swallowed because a rejection here
* just means the user has to click again, which the toggle already
* affords.
*/
export function HeroVideo() {
const videoRef = useRef<HTMLVideoElement>(null);
const [muted, setMuted] = useState(true);
const toggleMute = useCallback(() => {
const v = videoRef.current;
if (!v) return;
const next = !v.muted;
v.muted = next;
setMuted(next);
if (!next && v.paused) {
// Best-effort: keep playback running after the user enables sound.
v.play().catch(() => undefined);
}
}, []);
return (
<>
<video
ref={videoRef}
autoPlay
muted
loop
playsInline
preload="auto"
poster="/videos/hero-poster.jpg"
className="size-full object-cover"
aria-label="Animation: a prompt becomes a live MCP server, with secrets staying isolated from the AI pipeline"
>
<source src="/videos/hero.webm" type="video/webm" />
<source src="/videos/hero.mp4" type="video/mp4" />
</video>
<button
type="button"
onClick={toggleMute}
aria-label={muted ? 'Unmute video' : 'Mute video'}
aria-pressed={!muted}
className="absolute bottom-4 right-4 z-10 inline-flex size-10 items-center justify-center rounded-full border backdrop-blur transition-colors duration-150 hover:text-[--color-fg] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[--color-accent]"
style={{
backgroundColor: 'color-mix(in oklab, var(--color-bg-elevated) 75%, transparent)',
borderColor: 'var(--color-border)',
color: muted ? 'var(--color-fg-muted)' : 'var(--color-accent)',
}}
>
{muted ? <VolumeX size={18} /> : <Volume2 size={18} />}
</button>
</>
);
}

View File

@ -169,18 +169,16 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
const renderUniforms = { const renderUniforms = {
uPositions: { value: rtB.texture }, uPositions: { value: rtB.texture },
// Huge soft blobs at very low per-particle alpha → no individual // Bigger dots + higher base alpha = more volumetric "calm field"
// dots are visible, but 65k of them additively composite into a // read at the load-in (was 1.8 / 0.42 — read as too thin, looked
// continuous indigo cloud. This matches the brief "no white dots, // stuttery because individual particles were hard to track between
// just a glow." When dots were 2.8px at 0.6 alpha, dense areas // frames). With these values the field has a denser cumulative
// saturated additive-blended into white; with 14px at 0.05 the // glow without any change to the simulation itself.
// saturation point is far above what 65k particles ever sum to, uPointSize: { value: textureSize === 256 ? 2.8 : 3.6 },
// so the cloud stays indigo even at its brightest.
uPointSize: { value: textureSize === 256 ? 14.0 : 20.0 },
uDpr: { value: dpr }, uDpr: { value: dpr },
uColorCalm: { value: colorCalm }, uColorCalm: { value: colorCalm },
uColorHot: { value: colorHot }, uColorHot: { value: colorHot },
uBaseAlpha: { value: 0.055 }, uBaseAlpha: { value: 0.6 },
}; };
const particleMat = new THREE.ShaderMaterial({ const particleMat = new THREE.ShaderMaterial({
vertexShader: renderVertex, vertexShader: renderVertex,
@ -252,15 +250,10 @@ export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldPro
smoothed.x = smoothed.x * 0.85 + target.x * 0.15; smoothed.x = smoothed.x * 0.85 + target.x * 0.15;
smoothed.y = smoothed.y * 0.85 + target.y * 0.15; smoothed.y = smoothed.y * 0.85 + target.y * 0.15;
// Asymmetric fade: ramp in quickly when the pointer enters, decay // Fade ring in/out when the pointer enters/leaves.
// slowly when it leaves. The brief said "glow longer + attractive
// to mouse but always in motion" — fast ramp-in keeps the cursor
// feeling responsive, slow decay lets the glow linger after the
// pointer moves away rather than snapping off.
const targetActive = hasPointer ? 1 : 0; const targetActive = hasPointer ? 1 : 0;
const cur = simUniforms.uRingActive.value; simUniforms.uRingActive.value =
const alpha = targetActive > cur ? 0.14 : 0.012; simUniforms.uRingActive.value * 0.92 + targetActive * 0.08;
simUniforms.uRingActive.value = cur * (1 - alpha) + targetActive * alpha;
simUniforms.uTime.value = t; simUniforms.uTime.value = t;
simUniforms.uDelta.value = delta; simUniforms.uDelta.value = delta;

View File

@ -136,41 +136,28 @@ export function ParticleHero() {
return () => reduce.removeEventListener('change', onReduceChange); return () => reduce.removeEventListener('change', onReduceChange);
}, []); }, []);
// Static fallback: pure indigo radial glow, no dot grid. The // Static fallback: radial indigo glow + faint dotted mask.
// dot-mask was confusing — it read as "stippled white texture" // Used both for 'unknown' (pre-hydration) and 'fallback'.
// against the indigo glow rather than as resting particles. The
// cleaner, dotless gradient holds up better as a fallback.
if (cap.kind !== 'webgl') { if (cap.kind !== 'webgl') {
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 size-full overflow-hidden" className="absolute inset-0 size-full overflow-hidden"
style={{ style={{
background: backgroundImage: [
'radial-gradient(65% 80% at 50% 45%, rgba(99,102,241,0.22), rgba(99,102,241,0) 72%)', // Soft indigo glow centered on the hero
'radial-gradient(60% 80% at 50% 45%, rgba(99,102,241,0.18), rgba(99,102,241,0) 70%)',
// Very faint dotted texture — reads as "field of particles
// at rest" rather than a flat gradient.
'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 1.5px)',
].join(', '),
backgroundSize: '100% 100%, 24px 24px',
}} }}
/> />
); );
} }
// WebGL path: an always-on indigo radial behind the canvas so the return <ParticleField textureSize={cap.textureSize} motionScale={cap.motionScale} />;
// hero never feels visually empty (even between frames, even when the
// pointer is far away). The canvas paints the dynamic cloud + halo
// additively on top of this baseline glow — the result is "longer
// glow" without needing the simulation to keep a permanent ring on.
return (
<>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 size-full"
style={{
background:
'radial-gradient(70% 85% at 50% 48%, rgba(99,102,241,0.22), rgba(99,102,241,0) 72%)',
}}
/>
<ParticleField textureSize={cap.textureSize} motionScale={cap.motionScale} />
</>
);
} }
export default ParticleHero; export default ParticleHero;

View File

@ -162,18 +162,18 @@ export const simFragment = /* glsl */ `
float n2 = snoise(pos * 1.6 + vec2(0.0, driftTime * 0.045) + 53.7); 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 vec2 driftVel = vec2(-n2, n1) * 0.028 * uMotionScale; // curl-like rotation
// --- Mouse halo pull (attraction, not repulsion) --- // --- Ring push: gradient of the ring field, pointing outward ---
// Particles are drawn toward a soft halo orbiting the cursor — float h = 0.003;
// strongest at ~0.20 distance, fading both closer and farther. float fx0 = ringField(pos - vec2(h, 0.0));
// Closer-fade prevents the cloud from collapsing onto the cursor; float fx1 = ringField(pos + vec2(h, 0.0));
// farther-fade keeps the influence local. The result is a moving float fy0 = ringField(pos - vec2(0.0, h));
// bright spot that follows the pointer with a continuous breathing float fy1 = ringField(pos + vec2(0.0, h));
// ring of indigo around it, rather than the old outward push that vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h);
// hollowed the cloud where the cursor sat. float fieldHere = ringField(pos);
vec2 toMouse = uRingPos - pos; // Push along gradient — particles get nudged away from the ring crest.
float distToMouse = length(toMouse) + 0.001; // Magnitude is scaled by uMotionScale so reduced-motion users get a
float halo = exp(-pow(distToMouse - 0.20, 2.0) * 22.0); // softer shove while the ring position still tracks at full fidelity.
vec2 ringVel = (toMouse / distToMouse) * halo * 0.05 * uRingActive * uMotionScale; vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale;
// --- Soft containment toward origin if particle escaped --- // --- Soft containment toward origin if particle escaped ---
float r = length(pos); float r = length(pos);
@ -247,19 +247,14 @@ export const renderFragment = /* glsl */ `
varying float vScale; varying float vScale;
void main() { void main() {
// Soft Gaussian blob — no hard disc edge. Combined with the bigger // Disc SDF — anti-aliased round dot.
// uPointSize on the JS side (14-20px vs the old 2.8) and the much
// lower uBaseAlpha (0.05 vs 0.6), individual particles disappear
// into a continuous indigo cloud. The exp() falloff means each blob
// contributes most at its centre and fades smoothly to nothing —
// adjacent blobs blend without seams, so 65k of them additively
// composite into a volumetric glow instead of a stipple texture.
float d = length(gl_PointCoord - 0.5); float d = length(gl_PointCoord - 0.5);
float a = exp(-d * d * 6.0); float a = smoothstep(0.5, 0.42, d);
if (a <= 0.001) discard; if (a <= 0.001) discard;
// Velocity-driven mix kept, but with the new low base alpha the // Velocity-driven mix: pin to indigo for typical drift, lerp toward
// green tint is barely visible — by design. The cloud is calm. // 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); float t = smoothstep(0.04, 0.18, vVel);
vec3 col = mix(uColorCalm, uColorHot, t); vec3 col = mix(uColorCalm, uColorHot, t);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -5,8 +5,8 @@
"scripts": { "scripts": {
"studio": "remotion studio src/index.ts", "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: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:webm": "remotion render src/index.ts HeroVideo out/hero.webm --codec vp9 --crf 32 --audio-codec opus",
"render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 325 --image-format jpeg --jpeg-quality 85", "render:poster": "remotion still src/index.ts HeroVideo out/hero-poster.jpg --frame 1620 --image-format jpeg --jpeg-quality 85",
"render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster", "render:all": "pnpm render:mp4 && pnpm render:webm && pnpm render:poster",
"to-web": "node scripts/publish-to-web.mjs", "to-web": "node scripts/publish-to-web.mjs",
"build": "pnpm render:all && pnpm to-web" "build": "pnpm render:all && pnpm to-web"

Binary file not shown.

View File

@ -34,7 +34,9 @@ const ffmpegArgs = [
'-colorspace', 'bt709', '-colorspace', 'bt709',
'-preset', 'slow', '-preset', 'slow',
'-crf', '23', '-crf', '23',
'-an', '-c:a', 'aac',
'-b:a', '96k',
'-ar', '44100',
'-movflags', '+faststart', '-movflags', '+faststart',
outPath, outPath,
]; ];

View File

@ -1,53 +1,80 @@
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate } from 'remotion'; import {
AbsoluteFill,
Audio,
staticFile,
useCurrentFrame,
useVideoConfig,
interpolate,
} from 'remotion';
import { C } from './lib/colors'; import { C } from './lib/colors';
import { clampLerp } from './lib/easings'; import { clampLerp } from './lib/easings';
import { HookScene } from './scenes/HookScene';
import { PromptScene } from './scenes/PromptScene'; import { PromptScene } from './scenes/PromptScene';
import { SecretsScene } from './scenes/SecretsScene'; import { SecretsScene } from './scenes/SecretsScene';
import { BuildScene } from './scenes/BuildScene'; import { BuildScene } from './scenes/BuildScene';
import { IsolationScene } from './scenes/IsolationScene';
import { LibraryScene } from './scenes/LibraryScene'; import { LibraryScene } from './scenes/LibraryScene';
import { DiscoveryScene } from './scenes/DiscoveryScene'; import { DiscoveryScene } from './scenes/DiscoveryScene';
import { LogoLockup } from './scenes/LogoLockup';
export const HERO_FPS = 30; export const HERO_FPS = 30;
export const HERO_DURATION_FRAMES = 450; // 15s // 71.5s @ 30fps. Audio is 71.47s — the 30ms tail gives us room for a clean
// loop-fade to black.
export const HERO_DURATION_FRAMES = 2145;
// Scene timing. Each beat overlaps the next by ~6 frames so the // 8-phase composition matched to the 71.47s narration. Each scene overlaps
// transitions crossfade rather than hard-cut. // its neighbour by 6 frames so the parent crossfade does the transition.
// //
// P1 prompt [ 0, 81) 81f prompt typed // Phase ranges below match the brief; sum of unique frames = 2145.
// P1.5 secrets [ 75, 165) 90f vault panel + arrow fork //
// P2 build [159, 261) 102f log + server card // Hook [ 0, 360) 360f "code-gen solved, plumbing left"
// P3 library [255, 366) 111f morph into template grid // Prompt [ 354, 720) 366f "one sentence describes your tool"
// P4 discovery [360, 450) 90f fork counter + community // Secrets [ 714, 1170) 456f "vault, AES-256, injected at runtime"
// Build [1164, 1380) 216f "TypeScript, checks, ship — < 60s"
// Isolation [1374, 1800) 426f "hardened container · OAuth 2.1+PKCE"
// Library [1794, 2010) 216f "export source · share template"
// Discovery [2004, 2100) 96f "one sentence in · live server out"
// LogoLockup [2094, 2145) 51f "BuildMyMCPServer.com"
export const BEAT = { export const BEAT = {
prompt: { in: 0, out: 81 }, hook: { in: 0, out: 360 },
secrets: { in: 75, out: 165 }, prompt: { in: 354, out: 720 },
build: { in: 159, out: 261 }, secrets: { in: 714, out: 1170 },
library: { in: 255, out: 366 }, build: { in: 1164, out: 1380 },
discovery: { in: 360, out: 450 }, isolation: { in: 1374, out: 1800 },
library: { in: 1794, out: 2010 },
discovery: { in: 2004, out: 2100 },
lockup: { in: 2094, out: 2145 },
} as const; } as const;
const FADE_FRAMES = 12; const LOOP_FADE_FRAMES = 18;
// Subtitle/caption layer was tried in v7 and pulled — the owner's
// note: most captions felt aggressive and on-the-nose, the technical
// terms (PKCE etc.) created more friction than they removed. We keep
// `components/Subtitle.tsx` around for future use but the composition
// no longer renders it. Scene visuals + the voice-over now carry the
// whole story.
export function HeroVideo() { export function HeroVideo() {
const frame = useCurrentFrame(); const frame = useCurrentFrame();
const { fps } = useVideoConfig(); const { fps } = useVideoConfig();
// Loop-clean: ramp opacity to 0 over the last 12 frames so the final frame // Loop-clean fade-out on the final 18 frames so the final frame ≈ black.
// ≈ frame 0 (both essentially-black). Browser <video loop> jumps back and // Note: LogoLockup itself also fades internally; this is a safety net.
// the seam is invisible.
const loopFade = interpolate( const loopFade = interpolate(
frame, frame,
[HERO_DURATION_FRAMES - FADE_FRAMES, HERO_DURATION_FRAMES - 1], [HERO_DURATION_FRAMES - LOOP_FADE_FRAMES, HERO_DURATION_FRAMES - 1],
[1, 0], [1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }, { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
); );
// Crossfade alpha for each scene over its 6-frame entry/exit overlap. const hookAlpha = crossfade(frame, BEAT.hook.in, BEAT.hook.out, 6);
const promptAlpha = crossfade(frame, BEAT.prompt.in, BEAT.prompt.out, 6); const promptAlpha = crossfade(frame, BEAT.prompt.in, BEAT.prompt.out, 6);
const secretsAlpha = crossfade(frame, BEAT.secrets.in, BEAT.secrets.out, 6); const secretsAlpha = crossfade(frame, BEAT.secrets.in, BEAT.secrets.out, 6);
const buildAlpha = crossfade(frame, BEAT.build.in, BEAT.build.out, 6); const buildAlpha = crossfade(frame, BEAT.build.in, BEAT.build.out, 6);
const isolationAlpha = crossfade(frame, BEAT.isolation.in, BEAT.isolation.out, 6);
const libraryAlpha = crossfade(frame, BEAT.library.in, BEAT.library.out, 6); const libraryAlpha = crossfade(frame, BEAT.library.in, BEAT.library.out, 6);
const discoveryAlpha = crossfade(frame, BEAT.discovery.in, BEAT.discovery.out, 6); const discoveryAlpha = crossfade(frame, BEAT.discovery.in, BEAT.discovery.out, 6);
const lockupAlpha = crossfade(frame, BEAT.lockup.in, BEAT.lockup.out, 6);
return ( return (
<AbsoluteFill style={{ backgroundColor: C.bg, overflow: 'hidden' }}> <AbsoluteFill style={{ backgroundColor: C.bg, overflow: 'hidden' }}>
@ -55,6 +82,11 @@ export function HeroVideo() {
<Vignette /> <Vignette />
<AbsoluteFill style={{ opacity: loopFade }}> <AbsoluteFill style={{ opacity: loopFade }}>
{hookAlpha > 0 && (
<AbsoluteFill style={{ opacity: hookAlpha }}>
<HookScene localFrame={frame - BEAT.hook.in} fps={fps} />
</AbsoluteFill>
)}
{promptAlpha > 0 && ( {promptAlpha > 0 && (
<AbsoluteFill style={{ opacity: promptAlpha }}> <AbsoluteFill style={{ opacity: promptAlpha }}>
<PromptScene localFrame={frame - BEAT.prompt.in} fps={fps} /> <PromptScene localFrame={frame - BEAT.prompt.in} fps={fps} />
@ -70,6 +102,11 @@ export function HeroVideo() {
<BuildScene localFrame={frame - BEAT.build.in} fps={fps} /> <BuildScene localFrame={frame - BEAT.build.in} fps={fps} />
</AbsoluteFill> </AbsoluteFill>
)} )}
{isolationAlpha > 0 && (
<AbsoluteFill style={{ opacity: isolationAlpha }}>
<IsolationScene localFrame={frame - BEAT.isolation.in} fps={fps} />
</AbsoluteFill>
)}
{libraryAlpha > 0 && ( {libraryAlpha > 0 && (
<AbsoluteFill style={{ opacity: libraryAlpha }}> <AbsoluteFill style={{ opacity: libraryAlpha }}>
<LibraryScene localFrame={frame - BEAT.library.in} fps={fps} /> <LibraryScene localFrame={frame - BEAT.library.in} fps={fps} />
@ -80,14 +117,37 @@ export function HeroVideo() {
<DiscoveryScene localFrame={frame - BEAT.discovery.in} fps={fps} /> <DiscoveryScene localFrame={frame - BEAT.discovery.in} fps={fps} />
</AbsoluteFill> </AbsoluteFill>
)} )}
{lockupAlpha > 0 && (
<AbsoluteFill style={{ opacity: lockupAlpha }}>
<LogoLockup localFrame={frame - BEAT.lockup.in} fps={fps} />
</AbsoluteFill> </AbsoluteFill>
)}
</AbsoluteFill>
{/* Audio: voice-over at full volume + sub-bass music ducked underneath.
Music fades in over 30f, fades out over the final 24f. */}
<Audio src={staticFile('audio.mp3')} />
<Audio
src={staticFile('Sub-bass Lullaby.wav')}
volume={(f) => {
const fadeIn = interpolate(f, [0, 30], [0, 0.16], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const fadeOut = interpolate(
f,
[HERO_DURATION_FRAMES - 24, HERO_DURATION_FRAMES - 1],
[0.16, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
);
return Math.min(fadeIn, fadeOut);
}}
/>
</AbsoluteFill> </AbsoluteFill>
); );
} }
function crossfade(frame: number, start: number, end: number, overlap: number) { 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; if (frame < start || frame >= end) return 0;
const intoStart = clampLerp(frame, start, start + overlap); const intoStart = clampLerp(frame, start, start + overlap);
const beforeEnd = 1 - clampLerp(frame, end - overlap, end); const beforeEnd = 1 - clampLerp(frame, end - overlap, end);
@ -122,8 +182,6 @@ function DottedGrid() {
} }
function Vignette() { function Vignette() {
// Faint indigo glow at the center so the product mockups have
// something to sit on. Static across the whole timeline.
return ( return (
<div <div
style={{ style={{

View File

@ -0,0 +1,136 @@
import { interpolate, spring, useCurrentFrame, useVideoConfig } from 'remotion';
/**
* Kinetic-typography subtitle overlay.
*
* Designed to layer ON TOP of the existing scenes they continue running
* underneath, and these captions ride the lower third (or any chosen Y)
* to fill what would otherwise be "hold" moments where the scene's
* animation has ended but the voice-over keeps going.
*
* Curation principle: only key emphasis phrases get captioned, never the
* full voice-over. Pulled from the Apple / Linear / Stripe playbook
* captions punctuate, they don't transcribe.
*
* Word-by-word entrance: each word springs up from +16px with a tight
* damped spring at a 4-frame stagger. Whole-line exit fade over 14
* frames. Rendering is gated by `local < -6 || local > durationFrames+6`
* so the component returns null outside its window keeps the render
* tree slim even when twelve captions are scheduled.
*
* Typography:
* - `variant='sans'`: ui-sans-serif stack, weight 700, tightened tracking
* - `variant='mono'`: ui-monospace stack, weight 700, neutral tracking
* use for technical labels like `AES-256` or `OAuth 2.1 + PKCE` so
* they read as terms rather than prose
*
* Size tiers (1080p reference):
* - `lg` 54px default body caption
* - `xl` 76px emphasis lines that should hit hard
* - `display` 104px closing-shot hero text ("Live server out.")
*
* Positioning is from-top percentage. Default 84% sits the caption in the
* lower third without colliding with most scene content. For the closing
* display-size line set `yPercent` to 50 so it lands centred.
*/
interface SubtitleProps {
text: string;
/** Global frame at which the entrance begins. */
startFrame: number;
/** Total visible frames including entrance + hold + 14-frame exit. */
durationFrames: number;
variant?: 'sans' | 'mono';
size?: 'lg' | 'xl' | 'display';
/** Y position as % from top of the 1920×1080 frame. Default 84 (lower third). */
yPercent?: number;
}
const WORD_STAGGER = 4;
const EXIT_FRAMES = 14;
export function Subtitle({
text,
startFrame,
durationFrames,
variant = 'sans',
size = 'lg',
yPercent = 84,
}: SubtitleProps) {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const local = frame - startFrame;
// Bail out cheap when off-screen. The 6-frame envelope on each side
// covers the spring overshoot at entrance + exit so we don't blink.
if (local < -6 || local > durationFrames + 6) return null;
const words = text.split(' ');
const exitT = interpolate(
local,
[durationFrames - EXIT_FRAMES, durationFrames],
[1, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp' },
);
const fontPx = size === 'display' ? 104 : size === 'xl' ? 76 : 54;
const yPx = (yPercent / 100) * 1080;
const fontFamily =
variant === 'mono'
? 'ui-monospace, SF Mono, Menlo, monospace'
: 'ui-sans-serif, system-ui, -apple-system, sans-serif';
const letterSpacing =
size === 'display' ? '-0.025em' : variant === 'mono' ? '0' : '-0.015em';
return (
<div
aria-hidden
style={{
position: 'absolute',
left: 0,
right: 0,
top: yPx,
textAlign: 'center',
pointerEvents: 'none',
fontFamily,
fontSize: fontPx,
fontWeight: 700,
letterSpacing,
lineHeight: 1.08,
color: '#fafafa',
// Drop-shadow blurs the text against whatever scene paint sits
// behind it; the tiny indigo glow ties the type to the brand
// without making the caption itself look colored.
textShadow:
'0 2px 24px rgba(0, 0, 0, 0.55), 0 0 1px rgba(99, 102, 241, 0.22)',
opacity: exitT,
padding: '0 6%',
}}
>
{words.map((word, i) => {
const wordEnter = i * WORD_STAGGER;
const t = spring({
frame: local - wordEnter,
fps,
config: { damping: 18, mass: 0.5, stiffness: 140 },
durationInFrames: 16,
});
return (
<span
key={`${word}-${i}`}
style={{
display: 'inline-block',
transform: `translateY(${(1 - t) * 16}px)`,
opacity: t,
marginRight: '0.28em',
}}
>
{word}
</span>
);
})}
</div>
);
}

View File

@ -23,11 +23,16 @@ const LOG_LINES = [
{ label: 'Deploying', detail: 'live' }, { label: 'Deploying', detail: 'live' },
]; ];
const LINE_STAGGER = 10; const LINE_STAGGER = 18;
const LINE_START = 4; const LINE_START = 8;
const CARD_START = 58; const CARD_START = 110;
const SLOTS_START = 78; const SLOTS_START = 140;
const CAPTION_START = 92; const CAPTION_START = 160;
const SCENE_LEN = 216;
// Countdown displays "< NN s" in the corner while build is running.
// We tick from 58 → 12 between frames 8 → CARD_START.
const COUNTDOWN_START_VAL = 58;
const COUNTDOWN_END_VAL = 12;
export function BuildScene({ localFrame, fps }: { localFrame: number; fps: number }) { export function BuildScene({ localFrame, fps }: { localFrame: number; fps: number }) {
const panelIn = springIn(localFrame, fps, 0); const panelIn = springIn(localFrame, fps, 0);
@ -45,6 +50,13 @@ export function BuildScene({ localFrame, fps }: { localFrame: number; fps: numbe
// Caption appears last. // Caption appears last.
const captionIn = clampLerp(localFrame, CAPTION_START, CAPTION_START + 10); const captionIn = clampLerp(localFrame, CAPTION_START, CAPTION_START + 10);
// Countdown: ticks from 58 → 12 as the log lines stream.
const countT = clampLerp(localFrame, 8, CARD_START);
const countVal = Math.round(
interpolate(countT, [0, 1], [COUNTDOWN_START_VAL, COUNTDOWN_END_VAL]),
);
const countShown = localFrame >= 4 && localFrame <= CARD_START + 24;
return ( return (
<div <div
style={{ style={{
@ -88,7 +100,14 @@ export function BuildScene({ localFrame, fps }: { localFrame: number; fps: numbe
}} }}
> >
<span>build · notion-search</span> <span>build · notion-search</span>
<span style={{ color: C.fgMuted }}> running</span> <span style={{ color: C.fgMuted, display: 'flex', alignItems: 'center', gap: 14 }}>
{countShown && (
<span style={{ color: C.accent, fontVariantNumeric: 'tabular-nums' }}>
{'< '}{countVal}{' s'}
</span>
)}
<span> running</span>
</span>
</div> </div>
{LOG_LINES.map((line, i) => ( {LOG_LINES.map((line, i) => (

View File

@ -1,62 +1,125 @@
import { interpolate } from 'remotion'; import { interpolate } from 'remotion';
import { C } from '../lib/colors'; import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp, rand } from '../lib/easings'; import { softSpring, clampLerp } from '../lib/easings';
// Phase 4 (frames 360450 global → localFrame 0..90): the user's card is // Phase 4 · 90 frames · 3 s
// the hero. Fork count ticks 0 → 247 with micro-particles. Subtitle pops:
// "1,200+ developers building".
// //
// New beat in v5: as forks accumulate, a few non-hero cards get a small // Two-lane architecture diagram. PRIOR VERSIONS:
// "NEW SECRET" pulse near their slot, implying each forker plugs in their // v7: fake "1,200+ developers building" fork counter (pulled)
// own credential. Subtle, not loud. // v9: lane-boxes entered staggered, server entered LATE at frame 18 —
// owner noted "comes from the right not visible at the start"
//
// THIS VERSION (v10):
// • All seven boxes appear simultaneously at frame 0-6 (no late
// server arrival).
// • A guided GLOW TOUR runs in parallel across both lanes — prompt
// and vault light up first, then AI and encrypted, then code and
// env, then the server gets its own glow as the convergent arrows
// reach it. Each box's border pulses indigo for ~14 frames at its
// turn so the eye reads the data flow without anything needing to
// pop in or out.
// • Closing line slides DOWN from above the canvas and lands
// centred; the diagram fades cleanly to ~0.12 opacity behind it
// so the closing card sits in clear space, not overlapping.
const GRID_COLS = 3; interface LaneBox {
const GRID_ROWS = 2; id: string;
const CARD_W = 340; label: string;
const CARD_H = 180; detail?: string;
const GAP = 28; cx: number;
/** Local frame at which this box's glow starts. */
glowAt: number;
}
const CARDS = [ const LANE_TOP_Y = 360;
{ name: 'github-issues', toolCount: 3, highlighted: false }, const LANE_BOTTOM_Y = 660;
{ name: 'notion-search', toolCount: 2, highlighted: true }, // hero
{ name: 'slack-digest', toolCount: 4, highlighted: false }, const BOX_W = 200;
{ name: 'linear-tasks', toolCount: 5, highlighted: false }, const BOX_H = 96;
{ name: 'gmail-triage', toolCount: 3, highlighted: false },
{ name: 'jira-sprint', toolCount: 6, highlighted: false }, // Glow tour timing — top + bottom lane advance in parallel so the
// "two paths" narrative is enforced by the animation cadence itself.
const GLOW_DURATION = 16; // each box stays "hot" this long
const GLOW_STAGGER = 9; // gap between consecutive boxes inside one lane
const TOP_LANE_BOXES: LaneBox[] = [
{ id: 'prompt', label: 'prompt', detail: 'your sentence', cx: 260, glowAt: 4 },
{ id: 'ai', label: 'AI', detail: 'generates code', cx: 560, glowAt: 4 + GLOW_STAGGER },
{ id: 'code', label: 'code', detail: 'typescript', cx: 860, glowAt: 4 + GLOW_STAGGER * 2 },
]; ];
const TARGET_FORKS = 247; const BOTTOM_LANE_BOXES: LaneBox[] = [
{ id: 'vault', label: 'vault', detail: 'AES-256', cx: 260, glowAt: 4 },
{ id: 'enc', label: 'encrypted', detail: 'at rest', cx: 560, glowAt: 4 + GLOW_STAGGER },
{ id: 'envar', label: 'env', detail: 'injected at runtime', cx: 860, glowAt: 4 + GLOW_STAGGER * 2 },
];
// Frames at which each non-hero card flashes "NEW SECRET" (staggered). const SERVER_CX = 1480;
const NEW_SECRET_TIMINGS: Record<number, number> = { const SERVER_CY = 510;
0: 22, // github-issues const SERVER_W = 290;
3: 36, // linear-tasks const SERVER_H = 320;
5: 52, // jira-sprint const SERVER_GLOW_AT = 4 + GLOW_STAGGER * 3; // glow lights up after both lanes' last boxes
};
// Closing line takes the final ~1.3 s. Diagram fades to a very low
// opacity at this moment so the card has clean space.
const CLOSING_START = 54;
export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: number }) { export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
const zoom = clampLerp(localFrame, 0, 50); // Universal entrance: every visible element shares this fade-in over
const scale = interpolate(zoom, [0, 1], [1.0, 1.08]); // frames 08. No element pops in late.
const allInOpacity = clampLerp(localFrame, 0, 8);
const tickProgress = clampLerp(localFrame, 6, 42); // Diagram dim-down as the closing line takes the stage. Drops to
const forkCount = Math.floor(tickProgress * TARGET_FORKS); // 0.12 (still legible behind, no visual fight) by frame CLOSING + 14.
const diagramDim = 1 - clampLerp(localFrame, CLOSING_START, CLOSING_START + 14) * 0.88;
const subIn = softSpring(localFrame, fps, 28, 18); const baseAlpha = allInOpacity * diagramDim;
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;
const heroX = gridLeft + 1 * (CARD_W + GAP);
const heroY = gridTop + 0 * (CARD_H + GAP);
return ( return (
<div style={{ position: 'absolute', inset: 0 }}> <div style={{ position: 'absolute', inset: 0 }}>
{/* Section caption — fades out as closing card slides in */}
<SectionCaption localFrame={localFrame} />
<div style={{ opacity: baseAlpha }}>
{/* Lane boxes — all appear at frame 0, glow in sequence */}
{TOP_LANE_BOXES.map((b) => (
<LaneBoxView key={b.id} box={b} cy={LANE_TOP_Y} localFrame={localFrame} fps={fps} />
))}
{BOTTOM_LANE_BOXES.map((b) => (
<LaneBoxView key={b.id} box={b} cy={LANE_BOTTOM_Y} localFrame={localFrame} fps={fps} />
))}
{/* Intra-lane arrows draw between adjacent glows */}
<IntraLaneArrows boxes={TOP_LANE_BOXES} cy={LANE_TOP_Y} localFrame={localFrame} />
<IntraLaneArrows boxes={BOTTOM_LANE_BOXES} cy={LANE_BOTTOM_Y} localFrame={localFrame} />
{/* Server connectors converge from both lane endpoints */}
<ServerConnectors localFrame={localFrame} />
{/* "no shared node" vertical chip — the visual claim */}
<NoSharedNodeDivider localFrame={localFrame} />
{/* Server box — present from frame 0, glows at SERVER_GLOW_AT */}
<ServerBox localFrame={localFrame} fps={fps} />
</div>
{/* Closing line slides DOWN from above the canvas, lands centred.
Sits on top of the dimmed diagram with clear space around it. */}
<ClosingLine localFrame={localFrame} />
</div>
);
}
// ---- Sub-components ----
function SectionCaption({ localFrame }: { localFrame: number }) {
const inT = clampLerp(localFrame, 4, 18);
const outT = 1 - clampLerp(localFrame, CLOSING_START - 6, CLOSING_START + 8);
return (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: gridTop - 80, top: 130,
left: 0, left: 0,
right: 0, right: 0,
textAlign: 'center', textAlign: 'center',
@ -65,279 +128,444 @@ export function DiscoveryScene({ localFrame, fps }: { localFrame: number; fps: n
letterSpacing: 5, letterSpacing: 5,
textTransform: 'uppercase', textTransform: 'uppercase',
color: C.fgSubtle, color: C.fgSubtle,
opacity: Math.min(inT, outT),
transform: `translateY(${interpolate(inT, [0, 1], [6, 0])}px)`,
}} }}
> >
template library two paths · never cross
</div>
<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;
const dim = !isHero ? interpolate(zoom, [0, 1], [1, 0.55]) : 1;
// NEW SECRET pulse for non-hero cards.
const flashFrame = NEW_SECRET_TIMINGS[i];
const newSecretPulse =
flashFrame !== undefined
? Math.max(0, 1 - Math.max(0, localFrame - flashFrame) / 20)
: 0;
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}
newSecretPulse={newSecretPulse}
/>
</div>
);
})}
<ForkParticles
localFrame={localFrame}
x={heroX + CARD_W - 22}
y={heroY + CARD_H - 22}
/>
</div>
<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> </div>
); );
} }
function TemplateCardInner({ function LaneBoxView({
card, box,
isHero, cy,
forkCount, localFrame,
newSecretPulse, fps,
}: { }: {
card: typeof CARDS[number]; box: LaneBox;
isHero: boolean; cy: number;
forkCount: number | null; localFrame: number;
newSecretPulse: number; fps: number;
}) { }) {
const border = isHero ? C.accent : C.border; // Box is visible from frame 0 with a soft entrance.
const shadow = isHero const entryT = softSpring(localFrame, fps, 0, 18);
? `0 0 0 3px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.5)` const entryOpacity = clampLerp(localFrame, 0, 14);
: `0 8px 24px rgba(0,0,0,0.35)`; const scale = interpolate(entryT, [0, 1], [0.92, 1]);
// Glow ramp — peaks at glowAt + GLOW_DURATION/2, settles after.
const glowRamp = clampLerp(localFrame, box.glowAt, box.glowAt + 4);
const glowFade = 1 - clampLerp(localFrame, box.glowAt + GLOW_DURATION - 4, box.glowAt + GLOW_DURATION + 6);
const glow = Math.min(glowRamp, glowFade);
// Once "lit", the box keeps a slightly elevated default state — the
// viewer should be able to read "this one has been visited."
const visited = localFrame > box.glowAt + GLOW_DURATION ? 0.45 : 0;
const heat = Math.max(glow, visited);
// Border colour ramps from accentDim → accent as heat rises.
const borderColor = heat > 0.05 ? C.accent : C.accentDim;
const ringIntensity = 0.08 + heat * 0.35;
const liftPx = -heat * 4;
return ( return (
<div <div
style={{ style={{
width: '100%', position: 'absolute',
height: '100%', left: box.cx - BOX_W / 2,
top: cy - BOX_H / 2 + liftPx,
width: BOX_W,
height: BOX_H,
transform: `scale(${scale})`,
opacity: entryOpacity,
backgroundColor: C.bgElevated, backgroundColor: C.bgElevated,
border: `1.5px solid ${border}`, border: `1.5px solid ${borderColor}`,
borderRadius: 14, borderRadius: 12,
padding: '16px 20px', boxShadow: `0 0 0 ${4 + heat * 6}px rgba(99,102,241,${ringIntensity}), 0 16px 40px rgba(0,0,0,0.5)`,
boxShadow: shadow,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'space-between', justifyContent: 'center',
position: 'relative', padding: '12px 18px',
gap: 4,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 17,
color: C.fg,
letterSpacing: 0.2,
}}
>
{box.label}
</div>
{box.detail && (
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
color: C.fgSubtle,
letterSpacing: 0.8,
textTransform: 'lowercase',
}}
>
{box.detail}
</div>
)}
</div>
);
}
function IntraLaneArrows({
boxes,
cy,
localFrame,
}: {
boxes: LaneBox[];
cy: number;
localFrame: number;
}) {
return (
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
>
<defs>
<marker
id={`arrowLane-${cy}`}
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 Z" fill={C.accent} />
</marker>
</defs>
{boxes.slice(0, -1).map((b, i) => {
const next = boxes[i + 1]!;
// Arrow draws when the FROM box's glow has started and the TO
// box is about to glow — visualises the data flow.
const x1 = b.cx + BOX_W / 2 + 4;
const x2 = next.cx - BOX_W / 2 - 8;
const drawStart = b.glowAt + 4;
const drawEnd = next.glowAt;
const progress = clampLerp(localFrame, drawStart, drawEnd);
if (progress < 0.01) return null;
const segLen = x2 - x1;
return (
<line
key={`${b.id}-${next.id}`}
x1={x1}
y1={cy}
x2={x2}
y2={cy}
stroke={C.accent}
strokeWidth={1.8}
strokeDasharray={segLen}
strokeDashoffset={(1 - progress) * segLen}
opacity={0.92}
markerEnd={`url(#arrowLane-${cy})`}
/>
);
})}
</svg>
);
}
function NoSharedNodeDivider({ localFrame }: { localFrame: number }) {
const reveal = clampLerp(localFrame, 22, 38);
const fadeOut = 1 - clampLerp(localFrame, CLOSING_START - 4, CLOSING_START + 8);
const opacity = Math.min(reveal, fadeOut);
if (opacity < 0.02) return null;
// Vertical dashed line + chip pinned in the centre between the lanes
// at the second column of boxes (x ≈ 560).
const dividerX = 560;
const dividerTopY = LANE_TOP_Y + BOX_H / 2 + 12;
const dividerBottomY = LANE_BOTTOM_Y - BOX_H / 2 - 12;
const chipY = (dividerTopY + dividerBottomY) / 2;
return (
<>
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity }}
>
<line
x1={dividerX}
y1={dividerTopY}
x2={dividerX}
y2={dividerBottomY}
stroke={C.fgSubtle}
strokeWidth={1}
strokeDasharray="4 5"
opacity={0.55}
/>
</svg>
<div
style={{
position: 'absolute',
left: dividerX,
top: chipY,
transform: 'translate(-50%, -50%)',
opacity,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 10.5,
letterSpacing: 2.2,
textTransform: 'uppercase',
color: C.fgMuted,
backgroundColor: C.bg,
border: `1px solid ${C.borderStrong}`,
borderRadius: 999,
padding: '5px 11px',
whiteSpace: 'nowrap',
boxShadow: `0 4px 14px rgba(0,0,0,0.4)`,
}}
>
no shared node
</div>
</>
);
}
function ServerConnectors({ localFrame }: { localFrame: number }) {
// Connectors reveal as each lane's final box reaches its glow.
const reveal = clampLerp(localFrame, 4 + GLOW_STAGGER * 2 + 4, 4 + GLOW_STAGGER * 3);
if (reveal < 0.01) return null;
const codeBox = TOP_LANE_BOXES[TOP_LANE_BOXES.length - 1]!;
const envBox = BOTTOM_LANE_BOXES[BOTTOM_LANE_BOXES.length - 1]!;
const fromTopX = codeBox.cx + BOX_W / 2 + 4;
const fromTopY = LANE_TOP_Y;
const fromBotX = envBox.cx + BOX_W / 2 + 4;
const fromBotY = LANE_BOTTOM_Y;
const toX = SERVER_CX - SERVER_W / 2 - 4;
const toTopY = SERVER_CY - SERVER_H / 4;
const toBotY = SERVER_CY + SERVER_H / 4;
const lenTop = 460;
const lenBot = 460;
return (
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
<defs>
<marker
id="arrowConverge"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 Z" fill={C.accent} />
</marker>
</defs>
<path
d={`M ${fromTopX} ${fromTopY} C ${fromTopX + 220} ${fromTopY}, ${toX - 200} ${toTopY}, ${toX} ${toTopY}`}
stroke={C.accent}
strokeWidth={1.8}
fill="none"
strokeDasharray={lenTop}
strokeDashoffset={(1 - reveal) * lenTop}
opacity={0.92}
markerEnd="url(#arrowConverge)"
/>
<path
d={`M ${fromBotX} ${fromBotY} C ${fromBotX + 220} ${fromBotY}, ${toX - 200} ${toBotY}, ${toX} ${toBotY}`}
stroke={C.accent}
strokeWidth={1.8}
fill="none"
strokeDasharray={lenBot}
strokeDashoffset={(1 - reveal) * lenBot}
opacity={0.92}
markerEnd="url(#arrowConverge)"
/>
</svg>
);
}
function ServerBox({ localFrame, fps }: { localFrame: number; fps: number }) {
// Server box is VISIBLE from frame 0 (no late entry) but gets its own
// glow moment once both lanes have completed their tours.
const entryT = softSpring(localFrame, fps, 0, 22);
const entryOpacity = clampLerp(localFrame, 0, 14);
const scale = interpolate(entryT, [0, 1], [0.9, 1]);
// Glow ramp at SERVER_GLOW_AT.
const glowT = clampLerp(localFrame, SERVER_GLOW_AT, SERVER_GLOW_AT + 6);
const glowDown = clampLerp(localFrame, SERVER_GLOW_AT + 12, SERVER_GLOW_AT + 22);
const glow = glowT - glowDown;
const heatBase = localFrame > SERVER_GLOW_AT + 22 ? 0.55 : 0;
const heat = Math.max(glow, heatBase);
// Live-pulse on the dot after the glow has landed.
const pulsePhase = Math.max(0, localFrame - SERVER_GLOW_AT - 4) / 30;
const pulse = 0.5 + 0.5 * Math.sin(pulsePhase * Math.PI * 2);
return (
<div
style={{
position: 'absolute',
left: SERVER_CX - SERVER_W / 2,
top: SERVER_CY - SERVER_H / 2,
width: SERVER_W,
height: SERVER_H,
transform: `scale(${scale})`,
transformOrigin: 'center center',
opacity: entryOpacity,
backgroundColor: C.bgElevated,
border: `1.5px solid ${C.accent}`,
borderRadius: 16,
boxShadow: `0 0 0 ${5 + heat * 6}px rgba(99,102,241,${0.12 + heat * 0.30}), 0 24px 70px rgba(0,0,0,0.6)`,
padding: '22px 24px',
display: 'flex',
flexDirection: 'column',
gap: 14,
}} }}
> >
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div <div
style={{ style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 15, fontSize: 16,
color: C.fg, color: C.fg,
}} }}
> >
{card.name} your server
</div> </div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: C.success,
}}
>
<div <div
style={{ style={{
width: 7, width: 7,
height: 7, height: 7,
borderRadius: 4, borderRadius: 4,
backgroundColor: isHero ? C.success : C.fgSubtle, backgroundColor: C.success,
opacity: isHero ? 1 : 0.5, boxShadow: `0 0 ${6 + pulse * 10}px ${C.success}`,
boxShadow: isHero ? `0 0 8px ${C.success}` : 'none', opacity: 0.55 + 0.45 * pulse,
}} }}
/> />
live
</div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, position: 'relative' }}>
{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}`,
}}
/>
))}
{/* NEW SECRET pulse — small chip near the tool bars */}
{newSecretPulse > 0.02 && (
<div <div
style={{ style={{
position: 'absolute',
right: -4,
top: -8,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 9, fontSize: 12,
color: C.fgSubtle,
letterSpacing: 1.5, letterSpacing: 1.5,
textTransform: 'uppercase', textTransform: 'uppercase',
color: C.accent,
backgroundColor: 'rgba(99,102,241,0.18)',
border: `1px solid ${C.accentDim}`,
borderRadius: 999,
padding: '2px 7px',
opacity: newSecretPulse,
transform: `scale(${0.85 + newSecretPulse * 0.15})`,
boxShadow: `0 0 ${10 * newSecretPulse}px ${C.accentGlow}`,
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}} }}
> >
<MiniLockDot /> receives
new secret
</div>
)}
</div> </div>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', gap: 8,
padding: '8px 12px',
backgroundColor: C.bgSubtle,
border: `1px solid ${C.border}`,
borderRadius: 8,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', 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, fontSize: 13,
fontWeight: 600, color: C.fg,
}} }}
> >
<ForkIcon /> <span style={{ color: C.fgMuted, fontSize: 11 }}>1.</span>
{forkCount.toString()} <span style={{ color: C.accent, fontSize: 12 }}></span>
</span> <span>code</span>
) : ( </div>
<span>{isHero ? '★ featured' : 'template'}</span> <div
)} style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 12px',
backgroundColor: 'rgba(99,102,241,0.10)',
border: `1px solid ${C.accentDim}`,
borderRadius: 8,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
color: C.fg,
}}
>
<span style={{ color: C.fgMuted, fontSize: 11 }}>2.</span>
<SmallLockGlyph />
<span>env</span>
</div> </div>
</div> </div>
); );
} }
function MiniLockDot() { function SmallLockGlyph() {
return (
<svg width="8" height="8" viewBox="0 0 16 16" fill="none">
<path d="M 5 8 L 5 6 A 3 3 0 0 1 11 6 L 11 8" stroke={C.accent} strokeWidth={1.6} fill="none" />
<rect x="3.5" y="7.5" width="9" height="7" rx="1.5" fill={C.accent} stroke={C.accent} strokeWidth={1.4} />
</svg>
);
}
function ForkIcon() {
return ( return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none"> <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" /> <path d="M 5 8 L 5 6 A 3 3 0 0 1 11 6 L 11 8" stroke={C.accent} strokeWidth={1.4} fill="none" />
<circle cx="12" cy="3" r="1.6" stroke={C.accent} strokeWidth="1.4" /> <rect x="3.5" y="7.5" width="9" height="7" rx="1.5" fill="rgba(99,102,241,0.20)" stroke={C.accent} strokeWidth={1.4} />
<circle cx="8" cy="13" r="1.6" stroke={C.accent} strokeWidth="1.4" /> <circle cx="8" cy="11" r="1" fill={C.accent} />
<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> </svg>
); );
} }
function ForkParticles({ function ClosingLine({ localFrame }: { localFrame: number }) {
localFrame, const reveal = clampLerp(localFrame, CLOSING_START, CLOSING_START + 16);
x, if (reveal < 0.01) return null;
y, // Slides DOWN from above (translateY = -180 → 0) with spring-like
}: { // ease, lands centred at canvas vertical centre. No overlap with the
localFrame: number; // (now-dimmed) diagram below.
x: number; const slideY = interpolate(reveal, [0, 1], [-180, 0]);
y: number; const scale = interpolate(reveal, [0, 1], [0.94, 1]);
}) { return (
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 <div
key={i}
style={{ style={{
position: 'absolute', position: 'absolute',
left: x + dx, inset: 0,
top: y + dy, display: 'flex',
width: size, alignItems: 'center',
height: size, justifyContent: 'center',
borderRadius: size / 2, opacity: reveal,
backgroundColor: C.accent, pointerEvents: 'none',
opacity,
boxShadow: `0 0 8px ${C.accentGlow}`,
}} }}
/>, >
<div
style={{
backgroundColor: 'rgba(10,10,11,0.92)',
backdropFilter: 'blur(6px)',
border: `1px solid ${C.borderStrong}`,
borderRadius: 16,
padding: '30px 60px',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 38,
color: C.fg,
letterSpacing: 0.5,
textAlign: 'center',
transform: `translateY(${slideY}px) scale(${scale})`,
boxShadow: `0 0 0 6px ${C.accentGlow}, 0 30px 80px rgba(0,0,0,0.65)`,
}}
>
One sentence in. <span style={{ color: C.accent }}>Live server out.</span>
</div>
</div>
); );
}
return <>{out}</>;
} }

View File

@ -0,0 +1,732 @@
import type { ReactNode } from 'react';
import { interpolate } from 'remotion';
import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp } from '../lib/easings';
// FloatingChaos overlay — see definition at the bottom of this file. Adds
// real plumbing-pain to the tangle phase: code-snippet cards, an env file
// with a leaked-secret squiggle, and a 502 retry toast. Without it the
// "tangle" reads as four icons drifting; with it the tangle reads as a
// developer's actual desktop right before they give up. Fades out during
// the resolve phase so the clean row at the end isn't competing for eye.
// Phase 1 (frames 0360 global → localFrame 0..360): the "problem" scene.
//
// Four floating mini-icons (hosting / OAuth / Docker / secrets) drift into the
// frame from corners and slowly tangle together with dotted lines forming
// a mess. A small "the plumbing" label fades in. In the last ~1.5s the mess
// RESOLVES: icons snap into a clean horizontal row and a fifth icon (the
// "prompt cursor") slides in from the right — foreshadowing PromptScene.
//
// Typography monospace, indigo + fg-muted. No flashy gradients.
interface Icon {
id: string;
label: string;
start: { x: number; y: number };
tangle: { x: number; y: number };
resolved: { x: number; y: number };
draw: (color: string) => ReactNode;
}
// Resolved row centered at y=540, evenly spaced.
const ROW_Y = 540;
const ROW_CENTER_X = 1920 / 2;
const RESOLVED_SPACING = 230;
const RESOLVED_OFFSETS = [-1.5, -0.5, 0.5, 1.5];
const ICONS: Icon[] = [
{
id: 'hosting',
label: 'hosting',
start: { x: 360, y: 220 },
tangle: { x: 760, y: 420 },
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[0] * RESOLVED_SPACING, y: ROW_Y },
draw: (color) => <ServerStackIcon color={color} />,
},
{
id: 'oauth',
label: 'OAuth',
start: { x: 1560, y: 240 },
tangle: { x: 1100, y: 460 },
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[1] * RESOLVED_SPACING, y: ROW_Y },
draw: (color) => <KeyIcon color={color} />,
},
{
id: 'docker',
label: 'containers',
start: { x: 380, y: 820 },
tangle: { x: 840, y: 620 },
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[2] * RESOLVED_SPACING, y: ROW_Y },
draw: (color) => <ContainerIcon color={color} />,
},
{
id: 'secrets',
label: 'secrets',
start: { x: 1540, y: 820 },
tangle: { x: 1080, y: 660 },
resolved: { x: ROW_CENTER_X + RESOLVED_OFFSETS[3] * RESOLVED_SPACING, y: ROW_Y },
draw: (color) => <LockBigIcon color={color} />,
},
];
const ICON_SIZE = 96;
// Resolve transition timings (in local frames):
const RESOLVE_START = 300; // 10s
const RESOLVE_END = 345; // 11.5s — clean row landed
const HANDOFF_START = 330; // prompt-cursor icon slides in from the right
const HANDOFF_END = 358;
export function HookScene({ localFrame, fps }: { localFrame: number; fps: number }) {
// Icons enter staggered over the first ~36 frames.
const iconEntries = ICONS.map((_, i) => springIn(localFrame, fps, 6 + i * 8));
// Tangle phase: drift from start position toward tangle position between
// localFrame 60 and 240.
const tangleProgress = clampLerp(localFrame, 60, 240);
// Resolve phase: from tangled centroid to clean row.
const resolveProgress = clampLerp(localFrame, RESOLVE_START, RESOLVE_END);
// Sub-label "the plumbing nobody talks about" fades in at ~30, fades
// out at the resolve.
const labelIn = clampLerp(localFrame, 30, 60);
const labelOut = 1 - clampLerp(localFrame, RESOLVE_START - 10, RESOLVE_START + 14);
const labelOpacity = Math.min(labelIn, labelOut);
// Resolved-row sub-label: monospace, fades in as row settles.
const rowLabelIn = clampLerp(localFrame, RESOLVE_END - 6, RESOLVE_END + 14);
// Handoff cursor icon — slides in from the right edge.
const handoffProgress = clampLerp(localFrame, HANDOFF_START, HANDOFF_END);
const handoffX = interpolate(handoffProgress, [0, 1], [1920 + 60, ROW_CENTER_X + 2.5 * RESOLVED_SPACING]);
// Tangle SVG opacity: full while tangled, fades out as resolve happens.
const tangleOpacity = Math.min(
clampLerp(localFrame, 30, 90),
1 - clampLerp(localFrame, RESOLVE_START - 6, RESOLVE_START + 18),
);
return (
<div style={{ position: 'absolute', inset: 0 }}>
{/* Top label */}
<div
style={{
position: 'absolute',
top: 140,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 18,
letterSpacing: 6,
textTransform: 'uppercase',
color: C.fgSubtle,
opacity: labelOpacity,
}}
>
the plumbing nobody talks about
</div>
{/* Resolved-row label, appears when icons land in clean row */}
<div
style={{
position: 'absolute',
top: ROW_Y - 110,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 16,
letterSpacing: 5,
textTransform: 'uppercase',
color: C.fgSubtle,
opacity: rowLabelIn,
transform: `translateY(${interpolate(rowLabelIn, [0, 1], [6, 0])}px)`,
}}
>
what if one prompt handled all of it
</div>
{/* Floating chaos code-snippet cards + 502 toast + env warning.
Sits behind the icons, behind the tangle lines. Their entrance
is staggered across frames 80200 so the tangle grows in
visible complexity rather than just sitting at peak. */}
<FloatingChaos localFrame={localFrame} />
{/* Tangle SVG (dotted lines between icons) */}
<TangleLines
icons={ICONS}
tangleProgress={tangleProgress}
opacity={tangleOpacity}
/>
{/* Icons themselves */}
{ICONS.map((icon, i) => {
const entry = iconEntries[i];
// Position interpolation: start → tangle → resolved.
const sx = icon.start.x;
const sy = icon.start.y;
const tx = icon.tangle.x;
const ty = icon.tangle.y;
const rx = icon.resolved.x;
const ry = icon.resolved.y;
// Three-stage lerp.
let x: number;
let y: number;
if (resolveProgress > 0) {
x = interpolate(resolveProgress, [0, 1], [tx, rx]);
y = interpolate(resolveProgress, [0, 1], [ty, ry]);
} else {
x = interpolate(tangleProgress, [0, 1], [sx, tx]);
y = interpolate(tangleProgress, [0, 1], [sy, ty]);
}
// Subtle floating drift while tangled.
const driftPhase = (localFrame + i * 12) / 30;
const driftX = tangleProgress > 0.4 && resolveProgress < 0.1
? Math.sin(driftPhase * Math.PI) * 8
: 0;
const driftY = tangleProgress > 0.4 && resolveProgress < 0.1
? Math.cos(driftPhase * Math.PI * 0.8) * 8
: 0;
const scale = interpolate(entry, [0, 1], [0.6, 1]);
const opacity = entry;
// Icon color: muted during tangle, then accent on resolve.
const iconColor =
resolveProgress > 0.4 ? C.accent : C.fgMuted;
return (
<div
key={icon.id}
style={{
position: 'absolute',
left: x + driftX - ICON_SIZE / 2,
top: y + driftY - ICON_SIZE / 2,
width: ICON_SIZE,
height: ICON_SIZE,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center center',
transition: 'none',
}}
>
<IconCard color={iconColor}>
{icon.draw(iconColor)}
</IconCard>
{/* Label below */}
<div
style={{
position: 'absolute',
top: ICON_SIZE + 8,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
letterSpacing: 2,
textTransform: 'uppercase',
color: resolveProgress > 0.5 ? C.fgMuted : C.fgSubtle,
}}
>
{icon.label}
</div>
</div>
);
})}
{/* Hand-off cursor icon — slides in from the right at the end */}
{handoffProgress > 0.02 && (
<div
style={{
position: 'absolute',
left: handoffX - ICON_SIZE / 2,
top: ROW_Y - ICON_SIZE / 2,
width: ICON_SIZE,
height: ICON_SIZE,
opacity: handoffProgress,
}}
>
<IconCard color={C.accent} glow>
<PromptCursorIcon color={C.accent} />
</IconCard>
<div
style={{
position: 'absolute',
top: ICON_SIZE + 8,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
letterSpacing: 2,
textTransform: 'uppercase',
color: C.accent,
}}
>
one prompt
</div>
</div>
)}
</div>
);
}
function IconCard({ children, color, glow }: { children: React.ReactNode; color: string; glow?: boolean }) {
return (
<div
style={{
width: '100%',
height: '100%',
borderRadius: 18,
backgroundColor: C.bgElevated,
border: `1.5px solid ${color === C.accent ? C.accent : C.borderStrong}`,
boxShadow: glow
? `0 0 0 4px ${C.accentGlow}, 0 14px 40px rgba(0,0,0,0.55)`
: `0 10px 30px rgba(0,0,0,0.5)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{children}
</div>
);
}
function TangleLines({
icons,
tangleProgress,
opacity,
}: {
icons: Icon[];
tangleProgress: number;
opacity: number;
}) {
if (opacity < 0.01) return null;
// Draw dotted lines between every pair of icons at their tangle positions.
// Reveal via strokeDashoffset as tangleProgress grows.
const pairs: Array<[Icon, Icon]> = [];
for (let i = 0; i < icons.length; i++) {
for (let j = i + 1; j < icons.length; j++) {
pairs.push([icons[i], icons[j]]);
}
}
return (
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity }}
>
{pairs.map(([a, b], i) => {
// Interpolate endpoints between start and tangle positions.
const ax = interpolate(tangleProgress, [0, 1], [a.start.x, a.tangle.x]);
const ay = interpolate(tangleProgress, [0, 1], [a.start.y, a.tangle.y]);
const bx = interpolate(tangleProgress, [0, 1], [b.start.x, b.tangle.x]);
const by = interpolate(tangleProgress, [0, 1], [b.start.y, b.tangle.y]);
const len = Math.hypot(bx - ax, by - ay);
const reveal = clampLerp(tangleProgress, 0.2 + i * 0.05, 0.6 + i * 0.05);
return (
<line
key={i}
x1={ax}
y1={ay}
x2={bx}
y2={by}
stroke={C.fgSubtle}
strokeWidth={1.5}
strokeDasharray="4 7"
strokeDashoffset={(1 - reveal) * len}
opacity={0.55}
/>
);
})}
</svg>
);
}
// ---- Icons ----
function ServerStackIcon({ color }: { color: string }) {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="8" y="10" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
<rect x="8" y="22" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
<rect x="8" y="34" width="32" height="9" rx="2" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
<circle cx="12" cy="14.5" r="1.2" fill={color} />
<circle cx="12" cy="26.5" r="1.2" fill={color} />
<circle cx="12" cy="38.5" r="1.2" fill={color} />
</svg>
);
}
function KeyIcon({ color }: { color: string }) {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<circle cx="17" cy="24" r="9" stroke={color} strokeWidth={2.2} fill="rgba(99,102,241,0.08)" />
<circle cx="17" cy="24" r="3" fill={color} />
<path d="M 26 24 L 42 24 M 38 24 L 38 31 M 34 24 L 34 29" stroke={color} strokeWidth={2.2} strokeLinecap="round" />
</svg>
);
}
function ContainerIcon({ color }: { color: string }) {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="6" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
<rect x="17" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
<rect x="28" y="22" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
<rect x="17" y="11" width="9" height="9" rx="1" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.08)" />
<path d="M 4 36 Q 24 42 44 36" stroke={color} strokeWidth={2} fill="none" strokeLinecap="round" />
</svg>
);
}
function LockBigIcon({ color }: { color: string }) {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<path d="M 15 22 L 15 15 A 9 9 0 0 1 33 15 L 33 22" stroke={color} strokeWidth={2.2} fill="none" />
<rect x="11" y="22" width="26" height="18" rx="3" stroke={color} strokeWidth={2.2} fill="rgba(99,102,241,0.10)" />
<circle cx="24" cy="30" r="2.2" fill={color} />
<rect x="23" y="31" width="2" height="5" fill={color} />
</svg>
);
}
function PromptCursorIcon({ color }: { color: string }) {
return (
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="8" y="18" width="32" height="14" rx="3" stroke={color} strokeWidth={2} fill="rgba(99,102,241,0.10)" />
<text x="13" y="29" fill={color} fontSize="11" fontFamily="ui-monospace, SF Mono, Menlo, monospace" letterSpacing="1"></text>
<rect x="20" y="22" width="2" height="6" fill={color} />
</svg>
);
}
// =============== FloatingChaos ====================================
// Renders four small "fragment" cards during the tangle phase. Each one
// makes the chaos concrete instead of abstract: real config snippets,
// a real-looking error toast, a real env file with a security squiggle.
// Together they sell the "tagelange Bastelei" reality the rest of the
// scene only gestures at.
//
// Timing:
// Each card has a personal `appearAt` frame, spring-entrances with a
// slight rotation. During the hold (frames 240270) they jitter ±2px
// so the layout doesn't feel frozen. From frame 280 they collapse —
// scale to 0.85, translate inward toward the icon row, fade out, so
// the resolve reads as "the chaos folds into the clean answer."
//
// Layout: four corners of the canvas, each card sized < 380px so they
// frame the centre tangle without crowding the icons.
interface ChaosCard {
id: string;
appearAt: number;
x: number;
y: number;
rotate: number; // degrees
render: () => ReactNode;
}
const CHAOS_HOLD_START = 240;
const CHAOS_RESOLVE_START = 280;
const CHAOS_RESOLVE_END = 315;
function FloatingChaos({ localFrame }: { localFrame: number }) {
// Collapse alpha + collapse-toward-centre during resolve.
const collapse = clampLerp(localFrame, CHAOS_RESOLVE_START, CHAOS_RESOLVE_END);
if (collapse >= 0.99) return null;
const cards: ChaosCard[] = [
{
id: 'docker',
appearAt: 70,
x: 130,
y: 360,
rotate: -3.5,
render: () => <ConfigCard />,
},
{
id: 'oauth',
appearAt: 100,
x: 1450,
y: 220,
rotate: 2.4,
render: () => <OAuthCodeCard />,
},
{
id: 'env',
appearAt: 135,
x: 1330,
y: 740,
rotate: -2.6,
render: () => <EnvLeakCard />,
},
{
id: 'error',
appearAt: 175,
x: 170,
y: 750,
rotate: 3.2,
render: () => <ErrorToastCard localFrame={localFrame} />,
},
];
return (
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
opacity: 1 - collapse,
}}
>
{cards.map((card) => {
// Entrance: scale + opacity ramp over 14 frames.
const entrance = clampLerp(localFrame, card.appearAt, card.appearAt + 14);
if (entrance < 0.01) return null;
const entryScale = interpolate(entrance, [0, 1], [0.85, 1]);
const entryY = interpolate(entrance, [0, 1], [14, 0]);
// Hold jitter — keeps the cards alive during the centre tangle
// hold without making them feel busy. Different phase per card.
const jitterPhase = (localFrame + card.appearAt * 1.7) / 22;
const jitterX = Math.sin(jitterPhase * Math.PI) * 1.6;
const jitterY = Math.cos(jitterPhase * Math.PI * 0.7) * 1.4;
// Resolve: cards collapse inward toward canvas centre (960, 540)
// and scale down. By the time the icons settle into their row,
// the chaos has folded away.
const collapseScale = interpolate(collapse, [0, 1], [1, 0.7]);
const collapseDx = interpolate(collapse, [0, 1], [0, (960 - card.x) * 0.35]);
const collapseDy = interpolate(collapse, [0, 1], [0, (540 - card.y) * 0.35]);
return (
<div
key={card.id}
style={{
position: 'absolute',
left: card.x,
top: card.y,
opacity: entrance * (1 - collapse * 0.4),
transform: `translate(${jitterX + collapseDx}px, ${jitterY + collapseDy + entryY}px) rotate(${card.rotate}deg) scale(${entryScale * collapseScale})`,
transformOrigin: 'center center',
filter: collapse > 0.4 ? `blur(${collapse * 1.5}px)` : 'none',
}}
>
{card.render()}
</div>
);
})}
</div>
);
}
// ---- Chaos card variants ----
function CardFrame({
children,
width,
label,
intent = 'neutral',
}: {
children: ReactNode;
width: number;
label?: string;
intent?: 'neutral' | 'warn' | 'error';
}) {
const borderColor =
intent === 'error' ? '#dc2626' : intent === 'warn' ? '#f59e0b' : C.borderStrong;
return (
<div
style={{
width,
backgroundColor: C.bgElevated,
border: `1px solid ${borderColor}`,
borderRadius: 10,
boxShadow: '0 16px 36px rgba(0,0,0,0.55)',
overflow: 'hidden',
}}
>
{label && (
<div
style={{
padding: '8px 14px',
borderBottom: `1px solid ${C.border}`,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 10,
letterSpacing: 2,
textTransform: 'uppercase',
color: intent === 'error' ? '#dc2626' : intent === 'warn' ? '#f59e0b' : C.fgSubtle,
}}
>
{label}
</div>
)}
<div style={{ padding: '12px 14px' }}>{children}</div>
</div>
);
}
function ConfigCard() {
return (
<CardFrame width={340} label="docker-compose.yml">
<pre
style={{
margin: 0,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11.5,
lineHeight: 1.5,
color: C.fgMuted,
letterSpacing: 0.1,
}}
>
{`services:
nginx:
image: nginx:alpine
ports: ["443:443"]
volumes:
- ./certs:/etc/ssl
- ./conf.d:/etc/nginx`}
</pre>
</CardFrame>
);
}
function OAuthCodeCard() {
return (
<CardFrame width={360} label="oauth_callback.ts">
<pre
style={{
margin: 0,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11.5,
lineHeight: 1.5,
color: C.fgMuted,
letterSpacing: 0.1,
}}
>
{`app.use(passport.authenticate(
'oauth2', {
clientID: process.env.CID,
clientSecret: process.env.CS,
callbackURL: '/cb',
}
));`}
</pre>
</CardFrame>
);
}
function EnvLeakCard() {
// The squiggle hints at "this got committed to git by accident" —
// a recognisable plumbing failure mode.
return (
<CardFrame width={320} label="// .env" intent="warn">
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
lineHeight: 1.55,
color: C.fgMuted,
}}
>
<div>STRIPE_KEY=sk_live_</div>
<div style={{ position: 'relative', display: 'inline-block' }}>
NOTION_API_KEY=secret_3a8f
<svg
width="170"
height="6"
viewBox="0 0 170 6"
style={{ position: 'absolute', left: 0, bottom: -3, pointerEvents: 'none' }}
>
<path
d="M 0 3 Q 4 0 8 3 T 16 3 T 24 3 T 32 3 T 40 3 T 48 3 T 56 3 T 64 3 T 72 3 T 80 3 T 88 3 T 96 3 T 104 3 T 112 3 T 120 3 T 128 3 T 136 3 T 144 3 T 152 3 T 160 3 T 168 3"
stroke="#f59e0b"
strokeWidth="1.2"
fill="none"
/>
</svg>
</div>
<div style={{ color: '#f59e0b', fontSize: 10.5, marginTop: 8, letterSpacing: 0.4 }}>
in git history since v0.3.1
</div>
</div>
</CardFrame>
);
}
function ErrorToastCard({ localFrame }: { localFrame: number }) {
// The retry counter ticks while the card is visible — keeps the
// "still broken" feeling alive across the hold without changing the
// overall composition.
const elapsedSecs = Math.floor((localFrame - 175) / 30);
const retries = Math.max(1, Math.min(8, Math.floor(elapsedSecs / 1) + 1));
return (
<CardFrame width={320} intent="error">
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
}}
>
<div
style={{
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: 'rgba(220,38,38,0.16)',
border: '1.5px solid #dc2626',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
color: '#dc2626',
fontSize: 14,
fontWeight: 700,
lineHeight: 1,
}}
>
!
</div>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12.5,
color: '#dc2626',
fontWeight: 600,
letterSpacing: 0.2,
}}
>
502 Bad Gateway
</div>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
color: C.fgMuted,
marginTop: 4,
letterSpacing: 0.2,
}}
>
upstream timed out · retrying ({retries}/8)
</div>
</div>
</div>
</CardFrame>
);
}

View File

@ -0,0 +1,654 @@
import type { ReactNode } from 'react';
import { interpolate } from 'remotion';
import { C } from '../lib/colors';
import { softSpring, clampLerp } from '../lib/easings';
// Phase 5 · 14 s · 426 frames.
//
// Re-laid out from the original orbit-and-dock design after the owner
// flagged two collisions and the PKCE jargon:
//
// - "0.5 CPU" used to sit at angle π 0.5 with distance 200, which
// drops the bubble right onto the bottom-left corner of the
// notion-search card. Fixed by moving chips to a clean vertical
// column at x = 720, well to the right of the card.
// - "no-new-privileges" docked at angle π/2 + 0.7 (lower-left) which
// overlapped the green "your tokens only" badge below the card.
// Fixed by merging tokens-only into the same chip column as the
// 6th green item — one ordered list of guarantees.
// - "OAuth 2.1 · PKCE" was unreadable to anyone who doesn't write
// auth code. Replaced with a three-stage flow labelled in plain
// language: your client → verified → scoped token reaches the
// server. The technical name stays as a small subscript so it's
// google-able, but it isn't the headline.
// - The vault wasn't visible in this scene at all — secrets just
// appeared as a pill on the card in BuildScene and were never
// shown again. Added a vault graphic below the card with a
// dashed arrow up into its env slot, so the runtime-injection
// story is complete in one frame.
interface ChipDef {
id: string;
label: string;
/** Local frame when this chip enters. */
enterAt: number;
/** Final colour: indigo accent for hardening, green for tokens-only. */
tone: 'accent' | 'success';
}
// Plain-language labels, no docker flag names except where they're
// unambiguous ("read-only filesystem" is clearer than `--read-only`).
const CHIPS: ChipDef[] = [
{ id: 'ro', label: 'read-only filesystem', enterAt: 40, tone: 'accent' },
{ id: 'cap', label: 'dropped capabilities', enterAt: 62, tone: 'accent' },
{ id: 'nnp', label: 'no new privileges', enterAt: 84, tone: 'accent' },
{ id: 'mem', label: '512 MB memory cap', enterAt: 106, tone: 'accent' },
{ id: 'cpu', label: '0.5 CPU limit', enterAt: 128, tone: 'accent' },
{ id: 'token', label: 'your token only', enterAt: 360, tone: 'success' },
];
// Layout anchors — generous gaps so nothing collides.
const CARD_X = 320; // card centre
const CARD_Y = 460; // card centre (lifted up so vault has room)
const CARD_W = 360;
const CARD_H = 200;
const VAULT_X = 320;
const VAULT_Y = 740; // vault centre, below the card with a 60 px gap
const VAULT_W = 340;
const VAULT_H = 130;
const CHIP_COL_X = 760; // right of the card
const CHIP_COL_TOP = 350;
const CHIP_SPACING = 64;
const OAUTH_HEADER_Y = 280;
const OAUTH_FLOW_Y = 700;
// Three-stage simplified auth flow — no PKCE / verifier / SHA256 in the
// on-screen labels. The full technical name appears once, small, under
// the section header.
const OAUTH_STAGES = [
{ t: 0, label: 'your client', x: 1180, y: OAUTH_FLOW_Y },
{ t: 14, label: 'verified', x: 1440, y: OAUTH_FLOW_Y },
{ t: 28, label: 'scoped token', x: 1700, y: OAUTH_FLOW_Y },
];
const OAUTH_START = 200;
const GLOW_START = 340; // server card flashes green here
const TOKEN_CHIP_ENTER = 360; // token chip joins the column at this frame
const SCENE_LEN = 426;
export function IsolationScene({ localFrame, fps }: { localFrame: number; fps: number }) {
const sceneOpacity = clampLerp(localFrame, 0, 12);
// Green pulse on the server card — the moment the chain locks in.
const glowT = clampLerp(localFrame, GLOW_START, GLOW_START + 30);
const glowDown = clampLerp(localFrame, GLOW_START + 30, GLOW_START + 60);
const greenPulse = glowT - glowDown;
// Exit lift in the last few frames (parent crossfades).
const exitProgress = clampLerp(localFrame, SCENE_LEN - 12, SCENE_LEN);
const exitY = interpolate(exitProgress, [0, 1], [0, -18]);
return (
<div style={{ position: 'absolute', inset: 0, opacity: sceneOpacity, transform: `translateY(${exitY}px)` }}>
{/* Section caption */}
<div
style={{
position: 'absolute',
top: 130,
left: 0,
right: 0,
textAlign: 'center',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 16,
letterSpacing: 5,
textTransform: 'uppercase',
color: C.fgSubtle,
}}
>
hardened by default
</div>
{/* Server card (left) */}
<ServerCard
cx={CARD_X}
cy={CARD_Y}
w={CARD_W}
h={CARD_H}
localFrame={localFrame}
greenPulse={greenPulse}
/>
{/* Vault graphic (below card) completes the architecture: the
secret in the vault is the one runtime-injected into the
card above it. Without this the scene only shows "server is
safe" and leaves the viewer wondering where the secret lives. */}
<Vault
cx={VAULT_X}
cy={VAULT_Y}
w={VAULT_W}
h={VAULT_H}
localFrame={localFrame}
/>
{/* Hardening chips column (right of card) one ordered list. The
first five enter staggered while the OAuth flow plays on the
far right; the final green "your token only" chip lands last
to close the scene. */}
<div
style={{
position: 'absolute',
left: CHIP_COL_X,
top: CHIP_COL_TOP,
}}
>
{CHIPS.map((chip, i) => (
<HardeningChip
key={chip.id}
chip={chip}
index={i}
localFrame={localFrame}
fps={fps}
/>
))}
</div>
{/* OAuth flow (far right) three stages, plain language. The PKCE
term sits small under the header so it's findable but never
the headline. */}
<OAuthFlow localFrame={localFrame} fps={fps} />
</div>
);
}
// ---------- ServerCard ----------
function ServerCard({
cx,
cy,
w,
h,
localFrame,
greenPulse,
}: {
cx: number;
cy: number;
w: number;
h: number;
localFrame: number;
greenPulse: number;
}) {
const entryT = clampLerp(localFrame, 0, 18);
const scale = interpolate(entryT, [0, 1], [0.92, 1]);
const borderColor =
greenPulse > 0.05 ? interpolateColor(C.accent, C.success, greenPulse) : C.accent;
return (
<div
style={{
position: 'absolute',
left: cx - w / 2,
top: cy - h / 2,
width: w,
height: h,
backgroundColor: C.bgElevated,
border: `1.5px solid ${borderColor}`,
borderRadius: 16,
boxShadow:
greenPulse > 0.05
? `0 0 0 ${4 + greenPulse * 4}px rgba(34,197,94,${0.20 + greenPulse * 0.20}), 0 24px 70px rgba(0,0,0,0.6)`
: `0 0 0 5px ${C.accentGlow}, 0 24px 70px rgba(0,0,0,0.6)`,
opacity: entryT,
transform: `scale(${scale})`,
padding: '22px 24px',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontSize: 18, color: C.fg }}>
notion-search
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: C.success,
}}
>
<div
style={{
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: C.success,
boxShadow: `0 0 ${6 + greenPulse * 12}px ${C.success}`,
}}
/>
live
</div>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
color: C.fgMuted,
}}
>
<Row label="image" value="bmm/notion-search:1" />
<Row label="runtime" value="node-22-slim" />
<Row label="uptime" value="00:00:14" />
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{label}</span>
<span style={{ color: C.fg }}>{value}</span>
</div>
);
}
// ---------- Vault ----------
function Vault({
cx,
cy,
w,
h,
localFrame,
}: {
cx: number;
cy: number;
w: number;
h: number;
localFrame: number;
}) {
// Vault enters slightly after the card so the eye reads the card first.
const entryT = clampLerp(localFrame, 12, 32);
const arrowReveal = clampLerp(localFrame, 30, 60);
const opacity = entryT;
const scale = interpolate(entryT, [0, 1], [0.92, 1]);
// Dashed arrow from vault top edge up into card bottom edge.
const cardBottom = 460 + 100; // CARD_Y + CARD_H/2 = 560
const vaultTop = cy - h / 2;
const arrowX = cx; // vertically aligned
const arrowYStart = vaultTop - 4;
const arrowYEnd = cardBottom + 4;
const arrowLen = arrowYStart - arrowYEnd;
return (
<>
{/* Connector arrow */}
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}
>
<line
x1={arrowX}
y1={arrowYStart}
x2={arrowX}
y2={arrowYEnd + 8}
stroke={C.accent}
strokeWidth={1.6}
strokeDasharray="6 6"
strokeDashoffset={(1 - arrowReveal) * arrowLen}
opacity={0.85 * arrowReveal}
/>
<polygon
points={`${arrowX - 5},${arrowYEnd + 8} ${arrowX + 5},${arrowYEnd + 8} ${arrowX},${arrowYEnd}`}
fill={C.accent}
opacity={Math.max(0, arrowReveal - 0.7) * 3}
/>
</svg>
<div
style={{
position: 'absolute',
left: cx - w / 2,
top: cy - h / 2,
width: w,
height: h,
backgroundColor: C.bgElevated,
border: `1.5px solid ${C.accentDim}`,
borderRadius: 14,
boxShadow: `0 0 0 4px rgba(79,70,229,0.12), 0 18px 50px rgba(0,0,0,0.55)`,
opacity,
transform: `scale(${scale})`,
padding: '14px 18px',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 2.5,
textTransform: 'uppercase',
color: C.fgSubtle,
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<SmallLockIcon />
secrets vault
</span>
<span
style={{
color: C.accent,
border: `1px solid ${C.accentDim}`,
padding: '3px 8px',
borderRadius: 999,
fontSize: 10,
letterSpacing: 1.2,
backgroundColor: 'rgba(99,102,241,0.08)',
}}
>
AES-256
</span>
</div>
<div
style={{
backgroundColor: C.bgSubtle,
border: `1px solid ${C.border}`,
borderRadius: 8,
padding: '12px 14px',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 14,
color: C.fg,
display: 'flex',
alignItems: 'center',
gap: 10,
}}
>
<span style={{ color: C.fgSubtle }}>NOTION_API_KEY</span>
<span style={{ color: C.fgMuted }}>=</span>
<span>secret_3a8f</span>
</div>
</div>
</>
);
}
function SmallLockIcon() {
return (
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<path d="M 5 8 L 5 6 A 3 3 0 0 1 11 6 L 11 8" stroke={C.accent} strokeWidth={1.3} fill="none" />
<rect x="3.5" y="7.5" width="9" height="7" rx="1.5" fill="rgba(99,102,241,0.20)" stroke={C.accent} strokeWidth={1.3} />
<circle cx="8" cy="11" r="1" fill={C.accent} />
</svg>
);
}
// ---------- HardeningChip ----------
function HardeningChip({
chip,
index,
localFrame,
fps,
}: {
chip: ChipDef;
index: number;
localFrame: number;
fps: number;
}) {
// Spring from off-screen-right (+260 px) to the docked position.
const t = softSpring(localFrame, fps, chip.enterAt, 22);
if (t < 0.01) return null;
const x = interpolate(t, [0, 1], [260, 0]);
const opacity = clampLerp(localFrame, chip.enterAt, chip.enterAt + 12);
const isSuccess = chip.tone === 'success';
const colour = isSuccess ? C.success : C.accent;
const fill = isSuccess ? 'rgba(34,197,94,0.10)' : 'rgba(99,102,241,0.10)';
const glow = isSuccess
? '0 0 18px rgba(34,197,94,0.25)'
: '0 0 12px rgba(99,102,241,0.18)';
return (
<div
style={{
position: 'absolute',
top: index * CHIP_SPACING,
left: 0,
transform: `translateX(${x}px)`,
opacity,
display: 'inline-flex',
alignItems: 'center',
gap: 10,
padding: '10px 16px',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: colour,
backgroundColor: fill,
border: `1px solid ${colour}`,
borderRadius: 999,
boxShadow: glow,
whiteSpace: 'nowrap',
}}
>
<ChipBullet tone={chip.tone} />
{chip.label}
</div>
);
}
function ChipBullet({ tone }: { tone: 'accent' | 'success' }) {
const colour = tone === 'success' ? C.success : C.accent;
if (tone === 'success') {
return (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
<path d="M 2 7 L 6 11 L 12 3" stroke={colour} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
);
}
return (
<span style={{ display: 'inline-block', width: 6, height: 6, borderRadius: 3, backgroundColor: colour }} />
);
}
// ---------- OAuthFlow ----------
function OAuthFlow({ localFrame, fps }: { localFrame: number; fps: number }) {
const headerIn = clampLerp(localFrame, OAUTH_START - 20, OAUTH_START);
return (
<>
{/* Plain-language header. The technical name sits small underneath
so anyone curious can google it; nobody is forced to know it. */}
<div
style={{
position: 'absolute',
left: 1100,
top: OAUTH_HEADER_Y,
width: 700,
textAlign: 'center',
opacity: headerIn,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 16,
letterSpacing: 5,
textTransform: 'uppercase',
color: C.fgSubtle,
}}
>
only your token gets in
</div>
<div
style={{
marginTop: 6,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 2,
color: C.fgSubtle,
opacity: 0.6,
}}
>
oauth 2.1 · proof-key flow
</div>
</div>
{/* Three-stage flow, plain labels */}
{OAUTH_STAGES.map((stage, i) => {
const start = OAUTH_START + stage.t;
const inProg = softSpring(localFrame, fps, start, 18);
const op = clampLerp(localFrame, start, start + 12);
if (op < 0.01) return null;
return (
<div
key={i}
style={{
position: 'absolute',
left: stage.x,
top: stage.y,
transform: `translate(-50%, -50%) scale(${interpolate(inProg, [0, 1], [0.78, 1])})`,
opacity: op,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: C.fg,
border: `1px solid ${C.accent}`,
backgroundColor: 'rgba(99,102,241,0.10)',
borderRadius: 10,
padding: '10px 18px',
boxShadow: `0 6px 18px rgba(0,0,0,0.5)`,
whiteSpace: 'nowrap',
}}
>
{stage.label}
</div>
);
})}
{/* Arrows between stages */}
<OAuthArrows localFrame={localFrame} />
{/* Final arrow from "scoped token" down-left INTO the server card
completes the loop: this token is what reaches your server. */}
<SettlementArrow localFrame={localFrame} />
</>
);
}
function OAuthArrows({ localFrame }: { localFrame: number }) {
return (
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
<defs>
<marker id="arrowOauth" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 Z" fill={C.accent} />
</marker>
</defs>
{OAUTH_STAGES.slice(0, -1).map((stage, i) => {
const next = OAUTH_STAGES[i + 1];
const start = OAUTH_START + stage.t + 10;
const end = OAUTH_START + next.t;
const progress = clampLerp(localFrame, start, end);
if (progress < 0.01) return null;
const padFrom = 70;
const padTo = 70;
const x1 = stage.x + padFrom;
const y1 = stage.y;
const x2 = next.x - padTo;
const y2 = next.y;
const segLen = Math.hypot(x2 - x1, y2 - y1);
return (
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={C.accent}
strokeWidth={1.6}
strokeDasharray={segLen}
strokeDashoffset={(1 - progress) * segLen}
opacity={0.85}
markerEnd="url(#arrowOauth)"
/>
);
})}
</svg>
);
}
function SettlementArrow({ localFrame }: { localFrame: number }) {
// Drawn after the last OAuth stage lands. Curves from the rightmost
// "scoped token" pill down and left toward the server card's right
// edge, showing the token completing the round-trip into the server.
const start = OAUTH_START + OAUTH_STAGES[OAUTH_STAGES.length - 1].t + 10;
const reveal = clampLerp(localFrame, start, start + 30);
if (reveal < 0.01) return null;
const fromX = OAUTH_STAGES[OAUTH_STAGES.length - 1].x;
const fromY = OAUTH_STAGES[OAUTH_STAGES.length - 1].y + 26;
const toX = CARD_X + CARD_W / 2 + 8;
const toY = CARD_Y - 10;
// Bezier control points for a graceful S-curve.
const cx1 = fromX;
const cy1 = fromY + 240;
const cx2 = toX + 220;
const cy2 = toY + 30;
const pathD = `M ${fromX} ${fromY} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${toX} ${toY}`;
// Approximate path length for dash offset.
const approxLen = 1200;
return (
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0, pointerEvents: 'none' }}>
<path
d={pathD}
stroke={C.success}
strokeWidth={1.7}
strokeDasharray="7 6"
fill="none"
strokeDashoffset={(1 - reveal) * approxLen}
opacity={0.85 * reveal}
/>
</svg>
);
}
// ---------- helpers ----------
function interpolateColor(a: string, b: string, t: number) {
const pa = parseHex(a);
const pb = parseHex(b);
const r = Math.round(pa.r + (pb.r - pa.r) * t);
const g = Math.round(pa.g + (pb.g - pa.g) * t);
const bb = Math.round(pa.b + (pb.b - pa.b) * t);
return `rgb(${r},${g},${bb})`;
}
function parseHex(h: string) {
const s = h.replace('#', '');
return {
r: parseInt(s.slice(0, 2), 16),
g: parseInt(s.slice(2, 4), 16),
b: parseInt(s.slice(4, 6), 16),
};
}

View File

@ -13,9 +13,13 @@ import { springIn, softSpring, clampLerp, rand } from '../lib/easings';
const GRID_COLS = 3; const GRID_COLS = 3;
const GRID_ROWS = 2; const GRID_ROWS = 2;
const CARD_W = 340; // Cards enlarged in v10 — the previous 340×180 grid felt cramped on a
const CARD_H = 180; // 1080p canvas. New 400×220 with 36 px gap spreads the library across
const GAP = 28; // more of the available width (now grid spans x 2521668) so each
// template card reads at a glance.
const CARD_W = 400;
const CARD_H = 220;
const GAP = 36;
const CARDS = [ const CARDS = [
{ name: 'github-issues', toolCount: 3, highlighted: false }, { name: 'github-issues', toolCount: 3, highlighted: false },
@ -31,12 +35,24 @@ const LOCK_DETACH_START = 36; // when hero is settled into the grid
const LOCK_DETACH_END = 56; const LOCK_DETACH_END = 56;
const SUB_CAPTION_START = 56; const SUB_CAPTION_START = 56;
const SUB_CAPTION_END = 70; const SUB_CAPTION_END = 70;
// New: "export source ↓" chip on the hero card. Pulses once around frame 110.
const EXPORT_CHIP_START = 95;
const EXPORT_CHIP_END = 145;
export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: number }) { export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: number }) {
const captionIn = clampLerp(localFrame, 20, 36); const captionIn = clampLerp(localFrame, 20, 36);
const heroProgress = softSpring(localFrame, fps, 0, 30); const heroProgress = softSpring(localFrame, fps, 0, 30);
const lockDetach = clampLerp(localFrame, LOCK_DETACH_START, LOCK_DETACH_END); const lockDetach = clampLerp(localFrame, LOCK_DETACH_START, LOCK_DETACH_END);
const subCaptionIn = clampLerp(localFrame, SUB_CAPTION_START, SUB_CAPTION_END); const subCaptionIn = clampLerp(localFrame, SUB_CAPTION_START, SUB_CAPTION_END);
// Export chip: rises in then pulses out.
const exportIn = clampLerp(localFrame, EXPORT_CHIP_START, EXPORT_CHIP_START + 10);
const exportOut = 1 - clampLerp(localFrame, EXPORT_CHIP_END - 10, EXPORT_CHIP_END);
const exportOpacity = Math.min(exportIn, exportOut);
// Pulse modulation:
const exportPulse =
exportOpacity > 0.1
? 0.6 + 0.4 * Math.sin(((localFrame - EXPORT_CHIP_START) / 16) * Math.PI * 2)
: 0;
const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP; const gridW = GRID_COLS * CARD_W + (GRID_COLS - 1) * GAP;
const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP; const gridH = GRID_ROWS * CARD_H + (GRID_ROWS - 1) * GAP;
@ -128,25 +144,44 @@ export function LibraryScene({ localFrame, fps }: { localFrame: number; fps: num
); );
})} })}
{/* Sub-caption: "templates carry code, not credentials" */} {/* Export-source chip — floats above the hero card */}
{exportOpacity > 0.02 && (() => {
// Hero card position (col 1, row 0) — recompute here for clarity
const heroX = gridLeft + 1 * (CARD_W + GAP);
const heroY = gridTop + 0 * (CARD_H + GAP);
return (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
bottom: 110, left: heroX + CARD_W / 2,
left: 0, top: heroY - 28,
right: 0, transform: `translate(-50%, -100%) scale(${0.9 + exportPulse * 0.1})`,
textAlign: 'center', opacity: exportOpacity,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 14, fontSize: 11,
letterSpacing: 3, letterSpacing: 2,
textTransform: 'uppercase', textTransform: 'uppercase',
color: C.fgSubtle, color: C.accent,
opacity: subCaptionIn, backgroundColor: 'rgba(99,102,241,0.18)',
transform: `translateY(${interpolate(subCaptionIn, [0, 1], [8, 0])}px)`, border: `1px solid ${C.accent}`,
borderRadius: 999,
padding: '6px 12px',
boxShadow: `0 0 ${10 + exportPulse * 14}px rgba(99,102,241,${0.30 + exportPulse * 0.20})`,
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: 6,
}} }}
> >
templates carry <span style={{ color: C.fg }}>code</span>, not credentials export source <span style={{ fontSize: 13 }}></span>
</div> </div>
);
})()}
{/* Sub-caption was pulled the voice-over already says the same
thing and the on-screen line read as "on-the-nose marketing".
The detached lock + empty slot pill on the hero card carries
the story visually now. */}
</div> </div>
); );
} }

View File

@ -0,0 +1,51 @@
import { interpolate } from 'remotion';
import { C } from '../lib/colors';
import { clampLerp } from '../lib/easings';
// Phase 8 (~51 frames, 1.7s): wordmark "BuildMyMCPServer.com" centered with
// indigo glow that pulses once. Hold full opacity for ~0.8s, then fade
// everything to black over the final 12 frames so the loop is invisible.
const SCENE_LEN = 51;
const FADE_OUT_FRAMES = 12;
export function LogoLockup({ localFrame, fps: _fps }: { localFrame: number; fps: number }) {
const inProgress = clampLerp(localFrame, 0, 10);
// Glow pulse: 0 → 1 → 0 over 30 frames.
const pulse =
clampLerp(localFrame, 4, 18) - clampLerp(localFrame, 18, 32);
// Internal fade-out across last 12 frames (parent crossfade also applies,
// but this guarantees the lockup itself goes dark).
const fadeOut = 1 - clampLerp(localFrame, SCENE_LEN - FADE_OUT_FRAMES, SCENE_LEN);
const scale = interpolate(inProgress, [0, 1], [0.96, 1]);
return (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: fadeOut,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 84,
color: C.fg,
letterSpacing: -0.5,
textShadow: `0 0 ${20 + pulse * 60}px rgba(99,102,241,${0.4 + pulse * 0.4})`,
transform: `scale(${scale})`,
opacity: inProgress,
}}
>
BuildMyMCPServer
<span style={{ color: C.fgMuted, fontSize: 56, marginLeft: 4 }}>.com</span>
</div>
</div>
);
}

View File

@ -1,35 +1,103 @@
import { interpolate } from 'remotion'; import { interpolate } from 'remotion';
import { C } from '../lib/colors'; import { C } from '../lib/colors';
import { springIn, clampLerp } from '../lib/easings'; import { springIn, softSpring, clampLerp } from '../lib/easings';
const PROMPT_TEXT = 'Build me an MCP server that searches our Notion workspace.'; // Phase 2 · 366 frames · 12.2s
//
// Beats:
// 0-85 typing (existing — character-by-character at ~20 cps)
// 85-105 hint "press Enter to build →" pops in
// 105-225 PARSE BEAT (new) — three highlights light up the prompt in
// sequence; each gets a chip below the input that names what
// was detected. The third chip points right with a → arrow,
// visually foreshadowing the vault scene that follows.
// 225-310 SUMMARY PANEL (new) — three-column stats row materialises
// under the chips: tools · secrets · targets.
// 310-354 hold
// 354-366 exit drift (existing)
//
// What this replaces: a six-second post-typing hold where only the caret
// blinked. The parse beat surfaces what the AI literally extracts from
// the prompt — concrete, supports the VO line "the AI generates the
// code — the prompt path and the secret path never cross."
// Phase 1: realistic text-input field. Cursor blinks, prompt types in // Segmented text — typing still feels continuous, but each segment can
// character by character (~20 chars/sec at 30fps = 1.5 frames/char), and a // carry an independent highlight when the parse beat reaches it.
// "press Enter to build →" hint lights up once typing completes. type HighlightKey = 'intent' | 'tool' | 'secret';
interface Segment {
text: string;
highlight?: HighlightKey;
}
const SEGMENTS: readonly Segment[] = [
{ text: 'Build me an ' },
{ text: 'MCP server', highlight: 'intent' },
{ text: ' that ' },
{ text: 'searches', highlight: 'tool' },
{ text: ' our ' },
{ text: 'Notion workspace', highlight: 'secret' },
{ text: '.' },
];
const PROMPT_TEXT = SEGMENTS.map((s) => s.text).join('');
// Timing constants (local frames)
const TYPE_CPS = 20; // ~1 char every 1.5 frames at 30fps
const TYPE_FRAMES_PER_CHAR = 30 / TYPE_CPS;
const TYPING_DONE_AT = PROMPT_TEXT.length * TYPE_FRAMES_PER_CHAR; // ~88
const HINT_START = TYPING_DONE_AT - 4;
const HINT_END = TYPING_DONE_AT + 10;
// Parse beat — each highlight starts when the previous chip has settled.
const PARSE_INTENT_AT = 110;
const PARSE_TOOL_AT = 145;
const PARSE_SECRET_AT = 180;
const PARSE_CHIP_DURATION = 20; // entrance duration
const HIGHLIGHT_HOLD = 90; // each highlight stays for this long after entrance
// Summary panel.
const SUMMARY_START = 230;
const SUMMARY_FULL = 270;
// "Never cross" mini-diagram — appears under the summary panel right
// at the 21 s mark in the global timeline, which is when the voice
// says "the prompt path and the secret path never cross." Visualises
// the architectural promise with two parallel arrows and a clear
// "no crossing" cross-mark between them.
const NEVER_CROSS_START = 250; // local frame 250 ≈ global 21 s
const NEVER_CROSS_FULL = 280;
const SCENE_LEN = 366;
const EXIT_START = SCENE_LEN - 12;
// Map highlight key → its "appeared" timestamp so PromptText can colour
// the segment background once the parser hits it.
const HIGHLIGHT_AT: Record<HighlightKey, number> = {
intent: PARSE_INTENT_AT,
tool: PARSE_TOOL_AT,
secret: PARSE_SECRET_AT,
};
export function PromptScene({ localFrame, fps }: { localFrame: number; fps: number }) { export function PromptScene({ localFrame, fps }: { localFrame: number; fps: number }) {
const inputIn = springIn(localFrame, fps, 0); const inputIn = springIn(localFrame, fps, 0);
const labelIn = clampLerp(localFrame, 4, 16); const labelIn = clampLerp(localFrame, 4, 16);
// Type ~20 cps: floor(frame / 1.5) const typedChars = Math.max(0, Math.floor(localFrame / TYPE_FRAMES_PER_CHAR));
const typedChars = Math.max(0, Math.floor(localFrame / 1.5)); const typingDone = localFrame >= TYPING_DONE_AT;
const typed = PROMPT_TEXT.slice(0, typedChars);
const typingDoneAt = PROMPT_TEXT.length * 1.5;
const typingDone = localFrame >= typingDoneAt;
// Hint appears once typing is done. // Caret blinks at 0.5 Hz once typing settles.
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; const caretVisible = !typingDone || Math.floor(localFrame / 15) % 2 === 0;
// Exit motion: drifts upward slightly in last 12 frames (opacity is the // Hint visibility — pops in once typing settles, then fades a bit
// parent crossfade — we just add a tiny lift so it feels purposeful). // when the parse beat starts (less competition for attention).
const exitProgress = clampLerp(localFrame, 75, 81); const hintIn = clampLerp(localFrame, HINT_START, HINT_END);
const exitY = interpolate(exitProgress, [0, 1], [0, -60]); const hintFade = 1 - clampLerp(localFrame, PARSE_INTENT_AT - 10, PARSE_INTENT_AT + 14) * 0.55;
const exitScale = interpolate(exitProgress, [0, 1], [1, 0.95]);
// Exit drift in the final 12 frames.
const exitProgress = clampLerp(localFrame, EXIT_START, SCENE_LEN);
const exitY = interpolate(exitProgress, [0, 1], [0, -52]);
const exitScale = interpolate(exitProgress, [0, 1], [1, 0.96]);
const enterScale = interpolate(inputIn, [0, 1], [0.92, 1]); const enterScale = interpolate(inputIn, [0, 1], [0.92, 1]);
return ( return (
@ -52,7 +120,7 @@ export function PromptScene({ localFrame, fps }: { localFrame: number; fps: numb
letterSpacing: 4, letterSpacing: 4,
textTransform: 'uppercase', textTransform: 'uppercase',
color: C.fgSubtle, color: C.fgSubtle,
marginBottom: 28, marginBottom: 24,
opacity: labelIn, opacity: labelIn,
transform: `translateY(${interpolate(labelIn, [0, 1], [8, 0])}px)`, transform: `translateY(${interpolate(labelIn, [0, 1], [8, 0])}px)`,
}} }}
@ -60,7 +128,7 @@ export function PromptScene({ localFrame, fps }: { localFrame: number; fps: numb
describe your tool describe your tool
</div> </div>
{/* Input field */} {/* Input field — same shape as the actual product UI. */}
<div <div
style={{ style={{
width: 1100, width: 1100,
@ -95,14 +163,13 @@ export function PromptScene({ localFrame, fps }: { localFrame: number; fps: numb
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 28, fontSize: 28,
color: C.fg, color: C.fg,
whiteSpace: 'pre',
letterSpacing: 0.2, letterSpacing: 0.2,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
flex: 1, flex: 1,
}} }}
> >
<span>{typed}</span> <PromptText typedChars={typedChars} localFrame={localFrame} />
<span <span
style={{ style={{
display: 'inline-block', display: 'inline-block',
@ -124,14 +191,474 @@ export function PromptScene({ localFrame, fps }: { localFrame: number; fps: numb
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 16, fontSize: 16,
color: typingDone ? C.fgMuted : C.fgSubtle, color: typingDone ? C.fgMuted : C.fgSubtle,
marginTop: 28, marginTop: 24,
opacity: hintIn, opacity: hintIn * hintFade,
transform: `translateY(${interpolate(hintIn, [0, 1], [-6, 0])}px)`, transform: `translateY(${interpolate(hintIn, [0, 1], [-6, 0])}px)`,
letterSpacing: 1, letterSpacing: 1,
}} }}
> >
press Enter to build press Enter to build
</div> </div>
{/* Parse-beat chip row three callouts appear in sequence, each
tied to a highlighted segment above. The third chip points
right with arrow, foreshadowing the vault scene. */}
<ChipRow localFrame={localFrame} fps={fps} />
{/* Summary panel three-stat row materialises after all three
chips have landed. Tells the viewer what the AI extracted. */}
<SummaryPanel localFrame={localFrame} fps={fps} />
{/* "Never cross" mini-architecture diagram appears at 21 s
global to land precisely with the voice line "the prompt
path and the secret path never cross." Two parallel arrows
with a clear X-marker chip pinned between them. Compact so
it sits under the summary panel without breaking layout. */}
<NeverCrossMini localFrame={localFrame} fps={fps} />
</div>
);
}
// ---- Sub-components ----
function PromptText({
typedChars,
localFrame,
}: {
typedChars: number;
localFrame: number;
}) {
// Render typed text segment-by-segment so each segment can carry an
// independent highlight state. A running `consumed` counter slices the
// single typing progress across segments — feels continuous to the
// viewer.
let consumed = 0;
return (
<>
{SEGMENTS.map((seg, i) => {
const segStart = consumed;
consumed += seg.text.length;
const visibleChars = Math.max(0, Math.min(seg.text.length, typedChars - segStart));
if (visibleChars <= 0) return null;
const visibleText = seg.text.slice(0, visibleChars);
// Highlight intensity: fades in over 14 frames after its
// chip lands, then holds bright.
let highlightT = 0;
if (seg.highlight) {
const startAt = HIGHLIGHT_AT[seg.highlight];
highlightT = clampLerp(localFrame, startAt, startAt + 14);
}
const hasHighlight = highlightT > 0.01;
return (
<span
key={`${i}-${seg.text}`}
style={
hasHighlight
? {
background: `linear-gradient(180deg, transparent 55%, rgba(99,102,241,${0.22 * highlightT}) 55%)`,
color: C.fg,
padding: '0 2px',
borderRadius: 2,
whiteSpace: 'pre',
}
: { whiteSpace: 'pre' }
}
>
{visibleText}
</span>
);
})}
</>
);
}
interface ChipDef {
label: string;
detail: string;
startAt: number;
trailingArrow?: boolean; // chip's right edge shows → indicating "to vault"
}
const CHIPS: readonly ChipDef[] = [
{ label: 'intent', detail: 'build server', startAt: PARSE_INTENT_AT },
{ label: 'tool', detail: 'search_pages', startAt: PARSE_TOOL_AT },
{
label: 'secret',
detail: 'NOTION_API_KEY',
startAt: PARSE_SECRET_AT,
trailingArrow: true,
},
];
function ChipRow({ localFrame, fps }: { localFrame: number; fps: number }) {
const anyShowing = localFrame >= PARSE_INTENT_AT - 2;
if (!anyShowing) return null;
// Whole row fades out subtly when the summary panel materialises so
// the focus shifts cleanly downward.
const rowFade = 1 - clampLerp(localFrame, SUMMARY_FULL + 10, SUMMARY_FULL + 40) * 0.35;
return (
<div
style={{
marginTop: 18,
display: 'flex',
gap: 14,
opacity: rowFade,
}}
>
{CHIPS.map((chip) => {
const t = springIn(localFrame, fps, chip.startAt);
if (t < 0.01) return <PlaceholderSlot key={chip.label} />;
const y = interpolate(t, [0, 1], [12, 0]);
const isSecret = chip.label === 'secret';
return (
<div
key={chip.label}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '10px 16px',
backgroundColor: isSecret
? 'rgba(99,102,241,0.14)'
: 'rgba(99,102,241,0.06)',
border: `1px solid ${isSecret ? C.accent : C.accentDim}`,
borderRadius: 999,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 14,
color: C.fg,
opacity: t,
transform: `translateY(${y}px)`,
boxShadow: isSecret
? `0 0 18px rgba(99,102,241,0.30)`
: '0 6px 18px rgba(0,0,0,0.30)',
whiteSpace: 'nowrap',
}}
>
<span
style={{
fontSize: 11,
letterSpacing: 1.6,
textTransform: 'uppercase',
color: isSecret ? C.accent : C.fgSubtle,
}}
>
{chip.label}
</span>
<span style={{ color: C.fgMuted }}>·</span>
<span style={{ color: C.fg, letterSpacing: 0.3 }}>{chip.detail}</span>
{chip.trailingArrow && (
<span
style={{
color: C.accent,
marginLeft: 6,
fontSize: 14,
letterSpacing: 0,
}}
>
vault
</span>
)}
</div>
);
})}
</div>
);
}
// Holds the row width steady before its chip enters, so the rest of the
// layout doesn't shift as chips materialise.
function PlaceholderSlot() {
return (
<div
style={{
height: 38,
opacity: 0,
pointerEvents: 'none',
}}
/>
);
}
interface SummaryStat {
label: string;
value: string;
detail: string;
}
const STATS: readonly SummaryStat[] = [
{ label: 'tools', value: '2', detail: 'search_pages, get_page_content' },
{ label: 'secrets', value: '1', detail: 'NOTION_API_KEY' },
{ label: 'targets', value: '3', detail: 'Claude · Cursor · ChatGPT' },
];
function NeverCrossMini({ localFrame, fps }: { localFrame: number; fps: number }) {
const inT = springIn(localFrame, fps, NEVER_CROSS_START);
if (inT < 0.01) return null;
const fadeOut = 1 - clampLerp(localFrame, SCENE_LEN - 30, SCENE_LEN - 14);
const opacity = clampLerp(localFrame, NEVER_CROSS_START, NEVER_CROSS_FULL) * fadeOut;
if (opacity < 0.01) return null;
// Stroke-on for the two parallel arrows.
const strokeT = clampLerp(localFrame, NEVER_CROSS_START + 4, NEVER_CROSS_FULL + 10);
return (
<div
style={{
marginTop: 20,
opacity,
transform: `translateY(${interpolate(inT, [0, 1], [12, 0])}px)`,
}}
>
<div
style={{
width: 1100,
padding: '18px 32px',
backgroundColor: 'rgba(17,17,20,0.65)',
border: `1px dashed ${C.borderStrong}`,
borderRadius: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 28,
position: 'relative',
}}
>
{/* Left rail — "prompt → AI" */}
<NeverCrossRail
label="prompt"
target="AI"
tone="accent"
strokeT={strokeT}
/>
{/* Centre X-marker — explicit "no crossing" stamp */}
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
flexShrink: 0,
}}
>
<div
style={{
width: 44,
height: 44,
borderRadius: 22,
border: `1.5px solid ${C.fgMuted}`,
backgroundColor: 'rgba(17,17,20,0.85)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: `0 4px 14px rgba(0,0,0,0.4)`,
}}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M 5 5 L 15 15 M 15 5 L 5 15" stroke={C.fgMuted} strokeWidth={2.2} strokeLinecap="round" />
</svg>
</div>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 2,
textTransform: 'uppercase',
color: C.fgMuted,
whiteSpace: 'nowrap',
}}
>
never crosses
</div>
</div>
{/* Right rail — "vault → server" */}
<NeverCrossRail
label="vault"
target="server"
tone="accent"
strokeT={strokeT}
mirrored
/>
</div>
</div>
);
}
function NeverCrossRail({
label,
target,
tone,
strokeT,
mirrored,
}: {
label: string;
target: string;
tone: 'accent';
strokeT: number;
mirrored?: boolean;
}) {
const colour = C.accent;
// The rail is a chip → arrow → chip pattern.
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
flexDirection: mirrored ? 'row-reverse' : 'row',
flex: 1,
justifyContent: mirrored ? 'flex-start' : 'flex-end',
}}
>
<Pill text={label} colour={colour} />
<svg width="100" height="14" viewBox="0 0 100 14" style={{ flexShrink: 0 }}>
<defs>
<marker id={`ncArrow-${label}`} viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient={mirrored ? 'auto' : 'auto-start-reverse'}>
<path d="M 0 0 L 10 5 L 0 10 Z" fill={colour} />
</marker>
</defs>
<line
x1={mirrored ? 90 : 6}
y1={7}
x2={mirrored ? 6 : 90}
y2={7}
stroke={colour}
strokeWidth={1.8}
strokeDasharray={84}
strokeDashoffset={(1 - strokeT) * 84}
markerEnd={`url(#ncArrow-${label})`}
/>
</svg>
<Pill text={target} colour={colour} />
</div>
);
}
function Pill({ text, colour }: { text: string; colour: string }) {
return (
<div
style={{
padding: '7px 14px',
backgroundColor: 'rgba(99,102,241,0.10)',
border: `1px solid ${colour}`,
borderRadius: 999,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
letterSpacing: 1.5,
textTransform: 'uppercase',
color: C.fg,
whiteSpace: 'nowrap',
}}
>
{text}
</div>
);
}
function SummaryPanel({ localFrame, fps }: { localFrame: number; fps: number }) {
const panelT = springIn(localFrame, fps, SUMMARY_START);
if (panelT < 0.01) return null;
const fadeOutStart = SCENE_LEN - 30;
const fadeOut = 1 - clampLerp(localFrame, fadeOutStart, fadeOutStart + 18);
return (
<div
style={{
marginTop: 22,
opacity: panelT * fadeOut,
transform: `translateY(${interpolate(panelT, [0, 1], [16, 0])}px)`,
}}
>
<div
style={{
width: 1100,
padding: '20px 28px',
backgroundColor: 'rgba(17,17,20,0.65)',
border: `1px dashed ${C.borderStrong}`,
borderRadius: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 28,
backdropFilter: 'blur(2px)',
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 3.5,
textTransform: 'uppercase',
color: C.fgSubtle,
minWidth: 80,
}}
>
parsed
</div>
{STATS.map((s, i) => {
const colT = springIn(localFrame, fps, SUMMARY_START + 4 + i * 5);
return (
<div
key={s.label}
style={{
flex: 1,
opacity: colT,
transform: `translateY(${interpolate(colT, [0, 1], [8, 0])}px)`,
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: 10,
}}
>
<span
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 2.5,
textTransform: 'uppercase',
color: C.fgSubtle,
}}
>
{s.label}
</span>
<span
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 26,
fontWeight: 600,
color: C.accent,
fontVariantNumeric: 'tabular-nums',
letterSpacing: -0.5,
}}
>
{s.value}
</span>
</div>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
color: C.fgMuted,
letterSpacing: 0.2,
}}
>
{s.detail}
</div>
</div>
);
})}
</div>
</div> </div>
); );
} }

View File

@ -2,54 +2,54 @@ import { interpolate } from 'remotion';
import { C } from '../lib/colors'; import { C } from '../lib/colors';
import { springIn, softSpring, clampLerp } from '../lib/easings'; import { springIn, softSpring, clampLerp } from '../lib/easings';
// Phase 1.5 (frames 75165 global → localFrame 0..90): the prompt panel from // Phase 3 (now 456 frames, ~15.2s): prompt panel + vault panel + arrow fork
// Phase 1 slides left and a second, vault-styled panel appears to its right. // (original beats over first ~90 frames). Then new beats:
// The user's API key is masked (`secret_•••••••••••••••••••3a8f`) and a // • AES-256-GCM stamp appears next to the vault (frame ~95).
// "stored AES-256 · never sent to the AI" sub-label calms the trust nerves. // • Masked key value animates from the vault into a `key={NOTION_API_KEY}`
// // env-var pill placed beside a small container outline → "injected at
// Toward the end of the phase, two arrows fork apart: // runtime" moment (frames ~180340).
// • prompt → LLM brain icon (active, glowing) → continues right/down // • Late beat: the prompt panel flickers back in DIM and labeled
// • secrets → vault icon (active) → cross-marked through the LLM, // "what the model sees", visually separating from the vault.
// re-emerging beyond it
// Both reconverge at the right edge to set up the Phase 2 build/server card.
//
// The arrow forking IS the architectural punchline: prose lives goes through
// the LLM, the secret value does not.
const PROMPT_TEXT = 'Build me an MCP server that searches our Notion workspace.'; const PROMPT_TEXT = 'Build me an MCP server that searches our Notion workspace.';
const KEY_LABEL = 'NOTION_API_KEY'; const KEY_LABEL = 'NOTION_API_KEY';
const KEY_VISIBLE = 'secret_'; // typed portion before masking const KEY_VISIBLE = 'secret_';
const KEY_BULLETS = '•••••••••••••••••••'; const KEY_BULLETS = '•••••••••••••••••••';
const KEY_TAIL = '3a8f'; const KEY_TAIL = '3a8f';
export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: number }) { const SCENE_LEN = 456;
// Layout: two panels side-by-side, each ~620 wide, centered.
// Prompt mini-panel sits on the left at x≈260; secrets vault at x≈1040.
// Prompt panel slides in from its Phase-1 center position; secrets panel
// springs in from the right at frame ~8.
// Prompt panel: enters by sliding left from its previous (centered) home. // Original phase-1.5 beats (unchanged):
const TYPE_START = 30;
const TYPE_END = 44;
const MASK_START = 46;
const MASK_END = 60;
const TAIL_START = 60;
const TAIL_END = 66;
const LOCK_SNAP_AT = 66;
const ARROW_START = 70;
const ARROW_END = 88;
// New beats:
const AES_STAMP_AT = 95;
const INJECT_START = 180; // ~6s — key chip pops out of vault
const INJECT_FLY_END = 240; // chip arrives at container
const CONTAINER_IN = 200;
const FLICKER_PROMPT_AT = 300; // "the model only sees the description"
const EXIT_START = SCENE_LEN - 12;
export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: number }) {
// Prompt panel: enters by sliding left from center.
const promptShift = clampLerp(localFrame, 0, 18); const promptShift = clampLerp(localFrame, 0, 18);
// Phase 1 ended centered. Phase 1.5 puts the prompt panel at left third.
const promptX = interpolate(promptShift, [0, 1], [410, 60]); const promptX = interpolate(promptShift, [0, 1], [410, 60]);
// Secrets panel: spring in from below-right. // Secrets vault panel: spring in from below-right.
const vaultIn = springIn(localFrame, fps, 8); const vaultIn = springIn(localFrame, fps, 8);
const vaultOpacity = clampLerp(localFrame, 8, 24); const vaultOpacity = clampLerp(localFrame, 8, 24);
const vaultY = interpolate(vaultIn, [0, 1], [40, 0]); const vaultY = interpolate(vaultIn, [0, 1], [40, 0]);
const vaultScale = interpolate(vaultIn, [0, 1], [0.92, 1]); const vaultScale = interpolate(vaultIn, [0, 1], [0.92, 1]);
// Key field typing: "secret_" appears in plaintext for a beat (chars/sec ~24),
// then masking pass: bullets fade in while plaintext fades out, tail "3a8f"
// reveals at the end. Locked-icon snaps closed once masking finishes.
const TYPE_START = 30;
const TYPE_END = 44; // shows "secret_"
const MASK_START = 46;
const MASK_END = 60; // bullets in
const TAIL_START = 60;
const TAIL_END = 66;
const LOCK_SNAP_AT = 66;
const typingProgress = clampLerp(localFrame, TYPE_START, TYPE_END); const typingProgress = clampLerp(localFrame, TYPE_START, TYPE_END);
const maskProgress = clampLerp(localFrame, MASK_START, MASK_END); const maskProgress = clampLerp(localFrame, MASK_START, MASK_END);
const tailProgress = clampLerp(localFrame, TAIL_START, TAIL_END); const tailProgress = clampLerp(localFrame, TAIL_START, TAIL_END);
@ -62,18 +62,45 @@ export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: num
const tailChars = Math.floor(tailProgress * KEY_TAIL.length); const tailChars = Math.floor(tailProgress * KEY_TAIL.length);
const tailShown = KEY_TAIL.slice(0, tailChars); const tailShown = KEY_TAIL.slice(0, tailChars);
// Once masking begins, fade the "secret_" plaintext into a stable header
// of bullets (the user no longer sees raw prefix either).
const plaintextOpacity = interpolate(maskProgress, [0, 1], [1, 1]); // keep "secret_" as prefix
// Arrow fork animation starts at localFrame ~70 and runs until 88.
const ARROW_START = 70;
const ARROW_END = 88;
const arrowProgress = clampLerp(localFrame, ARROW_START, ARROW_END); const arrowProgress = clampLerp(localFrame, ARROW_START, ARROW_END);
const xMarkPop = springIn(localFrame, fps, ARROW_START + 8); const xMarkPop = springIn(localFrame, fps, ARROW_START + 8);
// Phase exit drift — last 6 frames, lift up subtly (parent already crossfades). // After ~110 frames, the arrows + LLM icon fade out to clear the canvas for
const exitProgress = clampLerp(localFrame, 84, 90); // the new beats (env-var injection).
const arrowFadeOut = 1 - clampLerp(localFrame, 110, 140);
// AES stamp.
const aesIn = springIn(localFrame, fps, AES_STAMP_AT);
// Env-var injection: the masked key value flies from the vault into a
// container outline.
const injectProgress = clampLerp(localFrame, INJECT_START, INJECT_FLY_END);
const containerIn = springIn(localFrame, fps, CONTAINER_IN);
// Lock-snap flash — a short bright indigo ring expands outward from the
// lock the moment it closes. Lifts the snap from "subtle SVG morph" to
// "the vault clicked shut". Lasts ~12 frames, peaks at frame 66+4.
const lockFlashT = clampLerp(localFrame, LOCK_SNAP_AT, LOCK_SNAP_AT + 16);
const lockFlashFade = 1 - clampLerp(localFrame, LOCK_SNAP_AT + 6, LOCK_SNAP_AT + 22);
const lockFlash = Math.min(lockFlashT, lockFlashFade);
// Late stage: keep vault at FULL opacity throughout so the eye still has
// it as the anchor while the env-var injection happens to the side. The
// prompt panel still fades — that beat is owned by the arrow fork.
const stageShift = clampLerp(localFrame, INJECT_START - 20, INJECT_START + 10);
const vaultLateOpacity = 1;
const promptLateOpacity = interpolate(stageShift, [0, 1], [1, 0]);
// Vault → container connector — a soft dashed line drawn between the
// vault's bottom edge and the container's env slot at the moment the
// chip travels. Anchors the architectural story: this key came FROM
// that vault.
const connectorReveal = clampLerp(localFrame, INJECT_START - 6, INJECT_START + 18);
const connectorFade = 1 - clampLerp(localFrame, INJECT_FLY_END + 6, INJECT_FLY_END + 30);
const connectorAlpha = Math.min(connectorReveal, connectorFade);
// Exit drift.
const exitProgress = clampLerp(localFrame, EXIT_START, SCENE_LEN);
const exitY = interpolate(exitProgress, [0, 1], [0, -24]); const exitY = interpolate(exitProgress, [0, 1], [0, -24]);
return ( return (
@ -84,17 +111,21 @@ export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: num
transform: `translateY(${exitY}px)`, transform: `translateY(${exitY}px)`,
}} }}
> >
{/* Prompt mini-panel (left) */} {/* Original prompt mini-panel (left). Fades into the late stage. */}
<div style={{ opacity: promptLateOpacity }}>
<PromptMiniPanel x={promptX} promptText={PROMPT_TEXT} /> <PromptMiniPanel x={promptX} promptText={PROMPT_TEXT} />
</div>
{/* Secrets vault panel (right) */} {/* Vault panel (right) stays at FULL opacity through the whole
scene so the eye keeps it as the anchor while the env-var
chip travels to the container below. */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
left: 1040, left: 1040,
top: (1080 - 380) / 2, top: (1080 - 380) / 2,
width: 820, width: 820,
opacity: vaultOpacity, opacity: vaultOpacity * vaultLateOpacity,
transform: `translateY(${vaultY}px) scale(${vaultScale})`, transform: `translateY(${vaultY}px) scale(${vaultScale})`,
transformOrigin: 'center center', transformOrigin: 'center center',
}} }}
@ -105,20 +136,312 @@ export function SecretsScene({ localFrame, fps }: { localFrame: number; fps: num
localFrame={localFrame} localFrame={localFrame}
keyLabel={KEY_LABEL} keyLabel={KEY_LABEL}
keyValue={`${typedShown}${bulletsShown}${tailShown}`} keyValue={`${typedShown}${bulletsShown}${tailShown}`}
plaintextOpacity={plaintextOpacity} />
{/* Lock-snap flash bright ring expanding out from the lock
icon at the exact moment it closes. The lock icon itself
sits at the vault panel's header (top-left), so the flash
is anchored there. */}
{lockFlash > 0.02 && (
<div
style={{
position: 'absolute',
left: 24,
top: 22,
width: 22,
height: 22,
borderRadius: 11,
pointerEvents: 'none',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
border: `2px solid ${C.accent}`,
transform: `scale(${1 + lockFlash * 6})`,
opacity: lockFlash * (1 - lockFlash) * 4,
transformOrigin: 'center',
}}
/>
<div
style={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
boxShadow: `0 0 ${20 * lockFlash}px ${10 * lockFlash}px rgba(99, 102, 241, ${0.55 * lockFlash})`,
opacity: lockFlash,
}}
/> />
</div> </div>
)}
</div>
{/* Arrow fork SVG overlay drawn full-frame, but only the arrow paths {/* AES-256-GCM stamp */}
are visible; the rest is transparent. */} {aesIn > 0.02 && (
<div
style={{
position: 'absolute',
left: 1040,
top: (1080 - 380) / 2 - 50,
opacity: Math.min(aesIn, vaultLateOpacity),
transform: `translateY(${interpolate(aesIn, [0, 1], [10, 0])}px) scale(${interpolate(aesIn, [0, 1], [0.8, 1])})`,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
letterSpacing: 2,
textTransform: 'uppercase',
color: C.accent,
border: `1px solid ${C.accent}`,
backgroundColor: 'rgba(99,102,241,0.10)',
borderRadius: 999,
padding: '6px 14px',
boxShadow: `0 4px 14px rgba(99,102,241,0.30)`,
}}
>
AES-256-GCM
</div>
)}
{/* Arrow fork SVG (fades out after initial beat) */}
<div style={{ opacity: arrowFadeOut * vaultLateOpacity }}>
<ArrowFork progress={arrowProgress} xPop={xMarkPop} /> <ArrowFork progress={arrowProgress} xPop={xMarkPop} />
</div> </div>
{/* Vault container connector appears as the chip starts its
flight, so the eye reads "the key in the vault BECOMES the
env var in the container." Without this the chip-flight
reads as "thing moves" rather than "vault feeds container". */}
<VaultContainerConnector alpha={connectorAlpha} />
{/* New beat: container outline + env-var injection */}
{containerIn > 0.02 && (
<ContainerWithEnvVar
containerProgress={containerIn}
injectProgress={injectProgress}
/>
)}
{/* AI-view vs your-stack reveal replaces the earlier "flicker
back" beat. Two compact callouts that frame the architectural
contrast: what the AI saw (just the prompt sentence), versus
what your stack holds (vault + container with the secret).
Both labelled, both readable. Appears late and stays until
scene-exit. */}
<AiViewVsStack localFrame={localFrame} />
</div>
); );
} }
function VaultContainerConnector({ alpha }: { alpha: number }) {
if (alpha < 0.01) return null;
// Vault sits at (10401860, ~350730). Container sits at (7201200,
// 720920). Draw a dashed indigo curve from the vault's bottom-left
// (about 1100, 720) to the container's right-top env slot
// (about 1200, 850). Slight arc for visual interest.
return (
<svg
width={1920}
height={1080}
style={{ position: 'absolute', inset: 0, pointerEvents: 'none', opacity: alpha }}
>
<path
d="M 1140 720 Q 1180 800 1200 850"
stroke={C.accent}
strokeWidth={1.8}
strokeDasharray="6 6"
fill="none"
opacity={0.75}
/>
</svg>
);
}
function AiViewVsStack({ localFrame }: { localFrame: number }) {
// Appears at FLICKER_PROMPT_AT (300) after the env-var has landed
// in the container — once both halves of the stack exist on screen,
// we can label them. Designed in v10 to be more visually emphatic
// than the previous compact card: literal viewfinder bracket corners
// around the prompt text frame the idea "this is everything the
// model can see," plus a stronger three-line "denied list" below.
const inT = clampLerp(localFrame, FLICKER_PROMPT_AT, FLICKER_PROMPT_AT + 22);
if (inT < 0.02) return null;
const fadeOut = 1 - clampLerp(localFrame, SCENE_LEN - 24, SCENE_LEN - 8);
const opacity = inT * fadeOut;
const promptShortened = '“Build me an MCP server that searches our Notion workspace.”';
// Bigger, more central. Width 680 (was 560), positioned so the
// viewfinder frame is the dominant left-side element.
const W = 680;
const LEFT = 60;
const TOP = 180;
return (
<div style={{ position: 'absolute', left: LEFT, top: TOP, width: W, opacity }}>
{/* Header — "what the AI sees" with eye icon for instant readability */}
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 12,
letterSpacing: 3.5,
textTransform: 'uppercase',
color: C.fgSubtle,
marginBottom: 16,
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<EyeIcon />
what the AI sees
</div>
{/* Viewfinder frame: prompt text surrounded by four corner brackets
to read as "this is the entire field of view." */}
<div
style={{
position: 'relative',
padding: '28px 32px',
backgroundColor: 'rgba(20,20,24,0.80)',
borderRadius: 14,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 18,
color: C.fg,
lineHeight: 1.5,
letterSpacing: 0.2,
}}
>
<ViewfinderCorners />
<span style={{ color: C.fgMuted }}>{promptShortened}</span>
</div>
{/* Denied list three explicit "not in view" lines so the
contrast with the stack on the right reads even on a quick
scrub. */}
<div
style={{
marginTop: 18,
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{[
'no secrets',
'no environment variables',
'no tokens',
].map((line) => (
<div
key={line}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 14,
letterSpacing: 0.4,
color: '#9ca3af',
}}
>
<DeniedDot />
<span>{line}</span>
</div>
))}
</div>
</div>
);
}
function EyeIcon() {
return (
<svg width="18" height="14" viewBox="0 0 22 14" fill="none">
<path
d="M 1 7 Q 11 1 21 7 Q 11 13 1 7 Z"
stroke={C.fgSubtle}
strokeWidth={1.3}
fill="none"
/>
<circle cx="11" cy="7" r="3" stroke={C.fgSubtle} strokeWidth={1.3} fill="rgba(161,161,170,0.15)" />
<circle cx="11" cy="7" r="1.4" fill={C.fgSubtle} />
</svg>
);
}
function ViewfinderCorners() {
// Four L-shaped brackets at the corners of the parent frame. SVG
// path d-attributes do NOT accept CSS calc(); instead each corner is
// a separate <div> sized in pixels with two borders showing through.
// Positioned absolutely to the frame's inside, 4 px from each edge.
const armLen = 18;
const stroke = 1.6;
const colour = C.fgSubtle;
const opacity = 0.75;
const inset = 4;
const cornerStyle = {
position: 'absolute' as const,
width: armLen,
height: armLen,
pointerEvents: 'none' as const,
opacity,
};
return (
<>
{/* top-left */}
<div
style={{
...cornerStyle,
top: inset,
left: inset,
borderTop: `${stroke}px solid ${colour}`,
borderLeft: `${stroke}px solid ${colour}`,
}}
/>
{/* top-right */}
<div
style={{
...cornerStyle,
top: inset,
right: inset,
borderTop: `${stroke}px solid ${colour}`,
borderRight: `${stroke}px solid ${colour}`,
}}
/>
{/* bottom-left */}
<div
style={{
...cornerStyle,
bottom: inset,
left: inset,
borderBottom: `${stroke}px solid ${colour}`,
borderLeft: `${stroke}px solid ${colour}`,
}}
/>
{/* bottom-right */}
<div
style={{
...cornerStyle,
bottom: inset,
right: inset,
borderBottom: `${stroke}px solid ${colour}`,
borderRight: `${stroke}px solid ${colour}`,
}}
/>
</>
);
}
function DeniedDot() {
return (
<svg width="16" height="16" viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="6.5" stroke="#9ca3af" strokeWidth={1.4} fill="rgba(156,163,175,0.08)" />
<path d="M 5 9 L 13 9" stroke="#9ca3af" strokeWidth={1.6} strokeLinecap="round" />
</svg>
);
}
// ------- Sub-components below -------
function PromptMiniPanel({ x, promptText }: { x: number; promptText: string }) { function PromptMiniPanel({ x, promptText }: { x: number; promptText: string }) {
// A condensed version of Phase 1's input field. Same border / glow style
// (accent active) but the cursor is gone and the text is full.
return ( return (
<div <div
style={{ style={{
@ -176,7 +499,6 @@ function PromptMiniPanel({ x, promptText }: { x: number; promptText: string }) {
{promptText} {promptText}
</div> </div>
</div> </div>
{/* Sublabel — what gets sent here */}
<div <div
style={{ style={{
marginTop: 16, marginTop: 16,
@ -209,11 +531,7 @@ function VaultPanel({
localFrame: number; localFrame: number;
keyLabel: string; keyLabel: string;
keyValue: string; keyValue: string;
plaintextOpacity: number;
}) { }) {
// Indigo accent border — same family as the active prompt's glow, but
// thicker (2px vs 1px) and with the accent border held even before "filled".
// Visually says: this is its own, equally important, primary surface.
return ( return (
<div <div
style={{ style={{
@ -226,7 +544,6 @@ function VaultPanel({
position: 'relative', position: 'relative',
}} }}
> >
{/* Top row: lock icon + label + backend-only chip */}
<div <div
style={{ style={{
display: 'flex', display: 'flex',
@ -267,14 +584,7 @@ function VaultPanel({
</div> </div>
</div> </div>
{/* Key field */} <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
<div <div
style={{ style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace', fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
@ -306,7 +616,6 @@ function VaultPanel({
</div> </div>
</div> </div>
{/* Sub-text */}
<div <div
style={{ style={{
marginTop: 18, marginTop: 18,
@ -338,14 +647,12 @@ function LockIcon({
fps: number; fps: number;
localFrame: number; localFrame: number;
}) { }) {
// Lock shackle pops down a hair when the lock snaps closed.
const snapAt = 66; const snapAt = 66;
const snap = springIn(localFrame, fps, snapAt); const snap = springIn(localFrame, fps, snapAt);
const shackleY = open ? -3 : interpolate(snap, [0, 1], [-3, 0]); const shackleY = open ? -3 : interpolate(snap, [0, 1], [-3, 0]);
return ( return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none"> <svg width="22" height="22" viewBox="0 0 24 24" fill="none">
{/* Shackle */}
<path <path
d={ d={
open open
@ -358,9 +665,7 @@ function LockIcon({
fill="none" fill="none"
transform={`translate(0 ${shackleY})`} transform={`translate(0 ${shackleY})`}
/> />
{/* Body */}
<rect x="5" y="11" width="14" height="10" rx="2" stroke={C.accent} strokeWidth={2} fill="rgba(99,102,241,0.12)" /> <rect x="5" y="11" width="14" height="10" rx="2" stroke={C.accent} strokeWidth={2} fill="rgba(99,102,241,0.12)" />
{/* Keyhole */}
<circle cx="12" cy="15.5" r="1.4" fill={C.accent} /> <circle cx="12" cy="15.5" r="1.4" fill={C.accent} />
<rect x="11.5" y="16" width="1" height="3" fill={C.accent} /> <rect x="11.5" y="16" width="1" height="3" fill={C.accent} />
</svg> </svg>
@ -393,44 +698,18 @@ function BrainIcon({ size = 14 }: { size?: number }) {
} }
function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) { function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
// Two arrows drawn with dasharray reveal as `progress` goes 0→1.
// Prompt arrow (top): solid line from prompt panel right edge to LLM icon,
// continues onward.
// Secrets arrow (bottom): from vault panel left edge crosses *under* the LLM
// icon — drawn dashed and with an X-mark over the LLM, indicating "does not
// flow through here".
//
// Both reconverge near right edge offscreen — this scene exits to Build.
//
// The SVG is full-frame; coordinates match the panel layout we set up:
// Prompt panel: left=60, top=(1080-220)/2 ≈ 430, width 760, panel height ~92,
// anchor right-edge midpoint ~(820, 540).
// Vault panel: left=1040, top=(1080-380)/2 = 350, width 820, height ~340,
// anchor left-edge midpoint ~(1040, 520).
//
// We draw a dummy "LLM" icon at (960, 470) — between & above the two panels.
// The arrows fork toward this icon: prompt → through it; secrets → past it
// but cross-marked. Then both converge at right (x≈1880, y≈540) where the
// next scene takes over.
const LLM = { x: 960, y: 240 }; const LLM = { x: 960, y: 240 };
const VAULT_OUT = { x: 1040, y: 520 }; const VAULT_OUT = { x: 1040, y: 520 };
const PROMPT_OUT = { x: 820, y: 540 }; const PROMPT_OUT = { x: 820, y: 540 };
const CONVERGE = { x: 960, y: 920 }; const CONVERGE = { x: 960, y: 920 };
// Arrow 1: PROMPT → LLM → CONVERGE (passes through LLM)
// Drawn as a polyline. Reveal via strokeDashoffset.
const pathP = `M ${PROMPT_OUT.x} ${PROMPT_OUT.y} L 880 ${PROMPT_OUT.y} L 880 ${LLM.y + 30} L ${LLM.x} ${LLM.y + 30}`; const pathP = `M ${PROMPT_OUT.x} ${PROMPT_OUT.y} L 880 ${PROMPT_OUT.y} L 880 ${LLM.y + 30} L ${LLM.x} ${LLM.y + 30}`;
// From LLM continuing toward CONVERGE
const pathPCont = `M ${LLM.x + 30} ${LLM.y + 30} L 1040 ${LLM.y + 30} L 1040 ${CONVERGE.y - 60} L ${CONVERGE.x} ${CONVERGE.y - 60}`; const pathPCont = `M ${LLM.x + 30} ${LLM.y + 30} L 1040 ${LLM.y + 30} L 1040 ${CONVERGE.y - 60} L ${CONVERGE.x} ${CONVERGE.y - 60}`;
// Arrow 2: VAULT → bypass LLM → CONVERGE (dashed)
const pathS = `M ${VAULT_OUT.x} ${VAULT_OUT.y} L 900 ${VAULT_OUT.y} L 900 ${CONVERGE.y - 30} L ${CONVERGE.x} ${CONVERGE.y - 30}`; const pathS = `M ${VAULT_OUT.x} ${VAULT_OUT.y} L 900 ${VAULT_OUT.y} L 900 ${CONVERGE.y - 30} L ${CONVERGE.x} ${CONVERGE.y - 30}`;
// Approximate lengths for reveal. const lenP = 480;
const lenP = 80 + 240 + 80 + 80; // ~480 (a generous over-estimate is fine) const lenPCont = 750;
const lenPCont = 80 + 600 + 70; const lenS = 610;
const lenS = 140 + 350 + 60 + 60;
return ( return (
<svg <svg
@ -447,7 +726,6 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
</marker> </marker>
</defs> </defs>
{/* Prompt arrow → LLM */}
<path <path
d={pathP} d={pathP}
stroke={C.accent} stroke={C.accent}
@ -460,11 +738,9 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
opacity={progress > 0 ? 1 : 0} opacity={progress > 0 ? 1 : 0}
/> />
{/* LLM icon — visible once first arrow reaches it (~progress 0.5) */}
{progress > 0.4 && ( {progress > 0.4 && (
<g transform={`translate(${LLM.x - 28} ${LLM.y - 4})`} opacity={Math.min(1, (progress - 0.4) * 4)}> <g transform={`translate(${LLM.x - 28} ${LLM.y - 4})`} opacity={Math.min(1, (progress - 0.4) * 4)}>
<rect width="56" height="56" rx="12" fill={C.bgElevated} stroke={C.accent} strokeWidth={1.5} /> <rect width="56" height="56" rx="12" fill={C.bgElevated} stroke={C.accent} strokeWidth={1.5} />
{/* Brain-ish glyph */}
<circle cx="28" cy="28" r="14" stroke={C.accent} strokeWidth={1.5} fill="rgba(99,102,241,0.10)" /> <circle cx="28" cy="28" r="14" stroke={C.accent} strokeWidth={1.5} fill="rgba(99,102,241,0.10)" />
<path d="M 20 28 Q 28 20 36 28 Q 28 36 20 28 Z" stroke={C.accent} strokeWidth={1.5} fill="none" /> <path d="M 20 28 Q 28 20 36 28 Q 28 36 20 28 Z" stroke={C.accent} strokeWidth={1.5} fill="none" />
<circle cx="28" cy="28" r="2.5" fill={C.accent} /> <circle cx="28" cy="28" r="2.5" fill={C.accent} />
@ -474,7 +750,6 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
</g> </g>
)} )}
{/* Prompt continuation (after LLM) */}
<path <path
d={pathPCont} d={pathPCont}
stroke={C.accent} stroke={C.accent}
@ -488,7 +763,6 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
markerEnd="url(#arrowAccent)" markerEnd="url(#arrowAccent)"
/> />
{/* Secrets arrow — dashed, bypasses LLM */}
<path <path
d={pathS} d={pathS}
stroke={C.fgSubtle} stroke={C.fgSubtle}
@ -501,7 +775,6 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
markerEnd="url(#arrowMuted)" markerEnd="url(#arrowMuted)"
/> />
{/* X-mark on the LLM box for the secrets path — pops in with spring */}
{xPop > 0.05 && ( {xPop > 0.05 && (
<g <g
transform={`translate(${LLM.x + 28} ${LLM.y + 24}) scale(${0.5 + xPop * 0.5})`} transform={`translate(${LLM.x + 28} ${LLM.y + 24}) scale(${0.5 + xPop * 0.5})`}
@ -512,11 +785,9 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
</g> </g>
)} )}
{/* Vault icon label near the secrets arrow start */}
{progress > 0.1 && ( {progress > 0.1 && (
<g transform={`translate(${VAULT_OUT.x - 36} ${VAULT_OUT.y - 22})`} opacity={Math.min(1, (progress - 0.1) * 4)}> <g transform={`translate(${VAULT_OUT.x - 36} ${VAULT_OUT.y - 22})`} opacity={Math.min(1, (progress - 0.1) * 4)}>
<rect width="56" height="44" rx="8" fill={C.bgElevated} stroke={C.accentDim} strokeWidth={1.5} /> <rect width="56" height="44" rx="8" fill={C.bgElevated} stroke={C.accentDim} strokeWidth={1.5} />
{/* mini vault: lock body + shackle */}
<path d="M 19 22 L 19 16 A 9 9 0 0 1 37 16 L 37 22" stroke={C.accent} strokeWidth={1.5} fill="none" /> <path d="M 19 22 L 19 16 A 9 9 0 0 1 37 16 L 37 22" stroke={C.accent} strokeWidth={1.5} fill="none" />
<rect x="17" y="21" width="22" height="14" rx="2" fill="rgba(99,102,241,0.18)" stroke={C.accent} strokeWidth={1.5} /> <rect x="17" y="21" width="22" height="14" rx="2" fill="rgba(99,102,241,0.18)" stroke={C.accent} strokeWidth={1.5} />
<circle cx="28" cy="27" r="1.6" fill={C.accent} /> <circle cx="28" cy="27" r="1.6" fill={C.accent} />
@ -528,3 +799,144 @@ function ArrowFork({ progress, xPop }: { progress: number; xPop: number }) {
</svg> </svg>
); );
} }
function ContainerWithEnvVar({
containerProgress,
injectProgress,
}: {
containerProgress: number;
injectProgress: number;
}) {
// Container outline sits below-center of the canvas (around y ~720), the
// vault is up at y ~520. The chip flies from the vault center down into
// the container's "env" slot.
const containerX = 1920 / 2 - 240;
const containerY = 720;
const containerW = 480;
const containerH = 200;
// Chip origin (vault center): roughly (1450, 540).
const ORIGIN = { x: 1450, y: 520 };
// Chip destination (env slot inside container): (containerX + 240, containerY + 130)
const DEST = { x: containerX + 240, y: containerY + 130 };
const chipX = interpolate(injectProgress, [0, 1], [ORIGIN.x, DEST.x]);
const chipY = interpolate(injectProgress, [0, 1], [ORIGIN.y, DEST.y]);
// Arc trajectory: gentle parabola
const arc = Math.sin(injectProgress * Math.PI) * -40;
// Chip scales down slightly as it travels.
const chipScale = interpolate(injectProgress, [0, 1], [1.0, 0.85]);
const opacity = clampLerp(containerProgress, 0, 1);
const scale = interpolate(containerProgress, [0, 1], [0.95, 1]);
// Once chip has landed, the env-var "slot" inside the container glows.
const slotGlow = clampLerp(injectProgress, 0.85, 1);
return (
<>
{/* Container outline */}
<div
style={{
position: 'absolute',
left: containerX,
top: containerY,
width: containerW,
height: containerH,
opacity,
transform: `scale(${scale})`,
transformOrigin: 'center center',
}}
>
{/* Label */}
<div
style={{
position: 'absolute',
top: -28,
left: 0,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 11,
letterSpacing: 3,
textTransform: 'uppercase',
color: C.fgSubtle,
}}
>
container · runtime
</div>
<div
style={{
width: '100%',
height: '100%',
border: `1.5px dashed ${C.borderStrong}`,
borderRadius: 14,
backgroundColor: 'rgba(17,17,20,0.6)',
padding: '24px 28px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 14,
}}
>
<div
style={{
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
color: C.fgSubtle,
letterSpacing: 1.5,
textTransform: 'uppercase',
}}
>
env
</div>
<div
style={{
borderRadius: 10,
border: `1px solid ${slotGlow > 0.2 ? C.accent : C.border}`,
backgroundColor: slotGlow > 0.2 ? 'rgba(99,102,241,0.10)' : C.bgSubtle,
padding: '14px 18px',
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 18,
color: C.fg,
boxShadow: slotGlow > 0.2 ? `0 0 ${10 + slotGlow * 14}px rgba(99,102,241,${0.25 + slotGlow * 0.20})` : 'none',
transition: 'none',
}}
>
<span style={{ color: C.fgMuted }}>key=</span>
<span>{KEY_LABEL}</span>
<span style={{ color: C.fgMuted, marginLeft: 8 }}>
{slotGlow > 0.4 ? '•••••••3a8f' : '...'}
</span>
</div>
</div>
</div>
{/* Flying chip (only visible during injection) */}
{injectProgress > 0.02 && injectProgress < 0.98 && (
<div
style={{
position: 'absolute',
left: chipX,
top: chipY + arc,
transform: `translate(-50%, -50%) scale(${chipScale})`,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '8px 14px',
backgroundColor: 'rgba(99,102,241,0.18)',
border: `1px solid ${C.accent}`,
borderRadius: 999,
fontFamily: 'ui-monospace, SF Mono, Menlo, monospace',
fontSize: 13,
color: C.fg,
boxShadow: `0 0 18px rgba(99,102,241,0.40)`,
whiteSpace: 'nowrap',
}}
>
<span style={{ color: C.accent }}>🔒</span>
<span>{KEY_LABEL}</span>
</div>
)}
</>
);
}

View File

@ -0,0 +1,15 @@
Code generation with AI is solved. What's left is hosting, oh-auth, domains, containers, secret injection — and keeping your keys out of the AI context.
BuildMyMCPServer closes that gap. One sentence describes your tool. The AI generates the code — [emphasis] the prompt path and the secret path never cross.
Your credentials live in a separate vault, encrypted with A-E-S two fifty six, isolated from the AI pipeline. Injected into your container as environment variables at runtime. [serious] The model only sees the description.
We generate the TypeScript, run static checks, ship the container — under sixty seconds.
Each server runs in its own hardened container. Read-only filesystem, dropped capabilities, no new privileges. Oh-auth two point one with pixie — no shared bearer tokens, no API keys in headers.
Export the code anytime, or share it as a template. The code travels, your secrets stay.
One sentence in. Live server out.
BuildMyMCPServer dot com